repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
fa0955b
parent
17988b5
author
Eric Bower
date
2025-10-15 15:32:28 -0400 EDT
fix: restore errors
2 files changed,  +116, -16
M src/daemon.zig
+28, -11
 1@@ -265,6 +265,9 @@ const Client = struct {
 2     attached_session: ?[]const u8,
 3     server_ctx: *ServerContext,
 4     message_buffer: std.ArrayList(u8),
 5+    /// Gate for preventing live PTY output during snapshot send
 6+    /// When true, this client will not receive live PTY frames
 7+    muted: bool = false,
 8 };
 9 
10 // Main daemon server state that manages the event loop, Unix socket server,
11@@ -778,10 +781,31 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
12 
13     // Mark client as attached
14     client.attached_session = session.name;
15+
16+    // Mute client before adding to attached_clients to prevent PTY interleaving during snapshot
17+    if (is_reattach) {
18+        client.muted = true;
19+    }
20+
21     try session.attached_clients.put(client.fd, {});
22 
23     // Start reading from PTY if not already started (first client)
24-    if (session.attached_clients.count() == 1) {
25+    const is_first_client = session.attached_clients.count() == 1;
26+
27+    // For reattaching clients, send snapshot BEFORE starting PTY reads to prevent interleaving
28+    if (is_reattach) {
29+        const buffer_slice = try terminal_snapshot.render(&session.vt, client.allocator);
30+        defer client.allocator.free(buffer_slice);
31+
32+        try protocol.writeBinaryFrame(client.fd, .pty_binary, buffer_slice);
33+        std.debug.print("Sent scrollback buffer to client fd={d} ({d} bytes)\n", .{ client.fd, buffer_slice.len });
34+
35+        // Unmute client now that snapshot is sent
36+        client.muted = false;
37+    }
38+
39+    if (is_first_client) {
40+        // Start PTY reads AFTER snapshot is sent
41         try readFromPty(ctx, client, session);
42 
43         // For first attach to new session, clear the client's terminal
44@@ -795,15 +819,6 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
45             .client_fd = client.fd,
46         });
47     }
48-
49-    // If reattaching, send the scrollback buffer as binary frame
50-    if (is_reattach) {
51-        const buffer_slice = try terminal_snapshot.render(&session.vt, client.allocator);
52-        defer client.allocator.free(buffer_slice);
53-
54-        try protocol.writeBinaryFrame(client.fd, .pty_binary, buffer_slice);
55-        std.debug.print("Sent scrollback buffer to client fd={d} ({d} bytes)\n", .{ client.fd, buffer_slice.len });
56-    }
57 }
58 
59 fn handlePtyInput(client: *Client, text: []const u8) !void {
60@@ -989,10 +1004,12 @@ fn readPtyCallback(
61             frame_buf.appendSlice(session.allocator, header_bytes) catch return .disarm;
62             frame_buf.appendSlice(session.allocator, data) catch return .disarm;
63 
64-            // Send to all attached clients using async write
65+            // Send to all attached clients using async write (skip muted clients)
66             var it = session.attached_clients.keyIterator();
67             while (it.next()) |client_fd| {
68                 const attached_client = ctx.clients.get(client_fd.*) orelse continue;
69+                // Skip muted clients (during snapshot send)
70+                if (attached_client.muted) continue;
71                 const owned_frame = session.allocator.dupe(u8, frame_buf.items) catch continue;
72 
73                 const write_ctx = session.allocator.create(PtyWriteContext) catch {
M src/terminal_snapshot.zig
+88, -5
  1@@ -57,14 +57,25 @@ pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
  2     var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
  3     errdefer output.deinit(allocator);
  4 
  5-    // Prepare terminal: hide cursor, reset scroll region, reset SGR, clear screen, home cursor
  6+    // Check if we're on alternate screen (vim, less, etc.)
  7+    const is_alt_screen = vt.active_screen == .alternate;
  8+
  9+    // Prepare terminal: hide cursor, reset scroll region, reset SGR
 10     try output.appendSlice(allocator, "\x1b[?25l"); // Hide cursor
 11     try output.appendSlice(allocator, "\x1b[r"); // Reset scroll region
 12     try output.appendSlice(allocator, "\x1b[0m"); // Reset SGR (colors/styles)
 13-    try output.appendSlice(allocator, "\x1b[2J"); // Clear entire screen
 14-    try output.appendSlice(allocator, "\x1b[H"); // Home cursor (1,1)
 15 
 16-    // Get the terminal's page list
 17+    // If alternate screen, switch to it before rendering
 18+    if (is_alt_screen) {
 19+        try output.appendSlice(allocator, "\x1b[?1049h"); // Enter alt screen (save cursor, switch, clear)
 20+        try output.appendSlice(allocator, "\x1b[2J"); // Clear alt screen explicitly
 21+        try output.appendSlice(allocator, "\x1b[H"); // Home cursor
 22+    } else {
 23+        try output.appendSlice(allocator, "\x1b[2J"); // Clear entire screen
 24+        try output.appendSlice(allocator, "\x1b[H"); // Home cursor (1,1)
 25+    }
 26+
 27+    // Get the terminal's page list (for active screen)
 28     const pages = &vt.screen.pages;
 29 
 30     // Create row iterator for active viewport
 31@@ -164,7 +175,56 @@ pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
 32         }
 33     }
 34 
 35-    try std.fmt.format(output.writer(allocator), "\x1b[{d};{d}H", .{ cursor_row, cursor_col });
 36+    // Restore scroll margins from terminal state (critical for vim scrolling)
 37+    const scroll = vt.scrolling_region;
 38+    const is_full_tb = (scroll.top == 0 and scroll.bottom == vt.rows - 1);
 39+
 40+    if (!is_full_tb) {
 41+        // Restore top/bottom margins
 42+        const top = scroll.top + 1; // Convert to 1-based
 43+        const bottom = scroll.bottom + 1; // Convert to 1-based
 44+        try std.fmt.format(output.writer(allocator), "\x1b[{d};{d}r", .{ top, bottom });
 45+    }
 46+
 47+    // Restore terminal modes (critical for vim and other apps)
 48+    // These modes affect cursor positioning, scrolling, and input behavior
 49+
 50+    // Origin mode (?6 / DECOM): cursor positioning relative to margins
 51+    const origin = vt.modes.get(.origin);
 52+    if (origin) {
 53+        try output.appendSlice(allocator, "\x1b[?6h");
 54+    }
 55+
 56+    // Wraparound mode (?7 / DECAWM): automatic line wrapping
 57+    const wrap = vt.modes.get(.wraparound);
 58+    if (!wrap) { // Default is true, so only emit if disabled
 59+        try output.appendSlice(allocator, "\x1b[?7l");
 60+    }
 61+
 62+    // Reverse wraparound (?45): bidirectional wrapping
 63+    const reverse_wrap = vt.modes.get(.reverse_wrap);
 64+    if (reverse_wrap) {
 65+        try output.appendSlice(allocator, "\x1b[?45h");
 66+    }
 67+
 68+    // Bracketed paste (?2004): paste detection
 69+    const bracketed = vt.modes.get(.bracketed_paste);
 70+    if (bracketed) {
 71+        try output.appendSlice(allocator, "\x1b[?2004h");
 72+    }
 73+
 74+    // TODO: Restore left/right margins if enabled (need to check modes for left_right_margins)
 75+
 76+    // Compute cursor position (may be relative to scroll margins if origin mode is on)
 77+    var final_cursor_row = cursor_row;
 78+    const final_cursor_col = cursor_col;
 79+
 80+    // If origin mode is on, cursor is relative to the top margin
 81+    if (origin and !is_full_tb) {
 82+        final_cursor_row = cursor_row - scroll.top;
 83+    }
 84+
 85+    try std.fmt.format(output.writer(allocator), "\x1b[{d};{d}H", .{ final_cursor_row, final_cursor_col });
 86 
 87     // Show cursor
 88     try output.appendSlice(allocator, "\x1b[?25h");
 89@@ -336,3 +396,26 @@ test "render: cursor position restoration" {
 90     try testing.expect(std.mem.indexOf(u8, result, "\x1b[?25h") != null); // Show cursor at end
 91     try testing.expect(std.mem.indexOf(u8, result, "Test") != null);
 92 }
 93+
 94+test "render: alternate screen detection" {
 95+    const testing = std.testing;
 96+    const allocator = testing.allocator;
 97+
 98+    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
 99+    defer vt.deinit(allocator);
100+
101+    // Switch to alternate screen
102+    _ = vt.switchScreen(.alternate);
103+
104+    // Write content to alt screen
105+    try vt.print('V');
106+    try vt.print('I');
107+    try vt.print('M');
108+
109+    const result = try render(&vt, allocator);
110+    defer allocator.free(result);
111+
112+    // Should contain alt screen switch sequence
113+    try testing.expect(std.mem.indexOf(u8, result, "\x1b[?1049h") != null);
114+    try testing.expect(std.mem.indexOf(u8, result, "VIM") != null);
115+}