repos / zmx

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

commit
f6b9539
parent
fa0955b
author
Eric Bower
date
2025-10-15 16:14:51 -0400 EDT
fix: reattach rendering
2 files changed,  +77, -28
M src/attach.zig
+22, -3
 1@@ -203,6 +203,7 @@ fn readCallback(
 2         const data = read_buffer.slice[0..len];
 3 
 4         // Check if this is a binary frame (starts with FrameHeader)
 5+        var remaining_data = data;
 6         if (data.len >= @sizeOf(protocol.FrameHeader)) {
 7             const potential_header = data[0..@sizeOf(protocol.FrameHeader)];
 8             const header: *const protocol.FrameHeader = @ptrCast(@alignCast(potential_header));
 9@@ -214,7 +215,14 @@ fn readCallback(
10                     // We have the complete frame
11                     const payload = data[@sizeOf(protocol.FrameHeader)..expected_total];
12                     writeToStdout(ctx, payload);
13-                    return .rearm;
14+
15+                    // Check if there's more data after this frame (e.g., JSON response)
16+                    if (data.len > expected_total) {
17+                        remaining_data = data[expected_total..];
18+                        // Continue processing remaining data as JSON below
19+                    } else {
20+                        return .rearm;
21+                    }
22                 } else {
23                     // Partial frame, buffer it
24                     ctx.frame_buffer.appendSlice(ctx.allocator, data) catch {};
25@@ -243,11 +251,11 @@ fn readCallback(
26         }
27 
28         // Otherwise parse as JSON control message
29-        const newline_idx = std.mem.indexOf(u8, data, "\n") orelse {
30+        const newline_idx = std.mem.indexOf(u8, remaining_data, "\n") orelse {
31             return .rearm;
32         };
33 
34-        const msg_line = data[0..newline_idx];
35+        const msg_line = remaining_data[0..newline_idx];
36 
37         const msg_type_parsed = protocol.parseMessageType(ctx.allocator, msg_line) catch |err| {
38             std.debug.print("JSON parse error: {s}\r\n", .{@errorName(err)});
39@@ -262,13 +270,17 @@ fn readCallback(
40 
41         switch (msg_type) {
42             .attach_session_response => {
43+                std.debug.print("Received attach_session_response\r\n", .{});
44                 const parsed = protocol.parseMessage(protocol.AttachSessionResponse, ctx.allocator, msg_line) catch |err| {
45                     std.debug.print("Failed to parse attach response: {s}\r\n", .{@errorName(err)});
46+                    std.debug.print("Message line: {s}\r\n", .{msg_line});
47                     return .rearm;
48                 };
49                 defer parsed.deinit();
50 
51+                std.debug.print("Parsed response: status={s}, client_fd={?d}\r\n", .{ parsed.value.payload.status, parsed.value.payload.client_fd });
52                 if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
53+                    std.debug.print("Status is OK, processing...\r\n", .{});
54                     const client_fd = parsed.value.payload.client_fd orelse {
55                         std.debug.print("Missing client_fd in response\r\n", .{});
56                         return .rearm;
57@@ -347,6 +359,12 @@ fn readCallback(
58 }
59 
60 fn startStdinReading(ctx: *Context) void {
61+    // Don't start if already reading
62+    if (ctx.stdin_completion != null) {
63+        std.debug.print("Stdin reading already started, skipping\r\n", .{});
64+        return;
65+    }
66+
67     const stdin_ctx = ctx.allocator.create(StdinContext) catch @panic("failed to create stdin context");
68     stdin_ctx.* = .{
69         .ctx = ctx,
70@@ -359,6 +377,7 @@ fn startStdinReading(ctx: *Context) void {
71     ctx.stdin_completion = stdin_completion;
72     ctx.stdin_ctx = stdin_ctx;
73 
74+    std.debug.print("Starting stdin reading\r\n", .{});
75     ctx.stdin_stream.read(ctx.loop, stdin_completion, .{ .slice = &stdin_ctx.buffer }, StdinContext, stdin_ctx, stdinReadCallback);
76 }
77 
M src/daemon.zig
+55, -25
  1@@ -245,6 +245,7 @@ const Session = struct {
  2     vt_stream: ghostty.Stream(*VTHandler),
  3     vt_handler: VTHandler,
  4     attached_clients: std.AutoHashMap(std.posix.fd_t, void),
  5+    pty_reading: bool = false, // Track if PTY reads are active
  6 
  7     fn deinit(self: *Session) void {
  8         self.allocator.free(self.name);
  9@@ -762,23 +763,6 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
 10         break :blk new_session;
 11     };
 12 
 13-    // Update libghostty-vt terminal size and PTY window size
 14-    // Only resize if we have valid dimensions
 15-    if (rows > 0 and cols > 0) {
 16-        try session.vt.resize(session.allocator, cols, rows);
 17-
 18-        var ws = c.struct_winsize{
 19-            .ws_row = rows,
 20-            .ws_col = cols,
 21-            .ws_xpixel = 0,
 22-            .ws_ypixel = 0,
 23-        };
 24-        const result = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
 25-        if (result < 0) {
 26-            return error.IoctlFailed;
 27-        }
 28-    }
 29-
 30     // Mark client as attached
 31     client.attached_session = session.name;
 32 
 33@@ -791,21 +775,63 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
 34 
 35     // Start reading from PTY if not already started (first client)
 36     const is_first_client = session.attached_clients.count() == 1;
 37+    std.debug.print("is_reattach={}, is_first_client={}, attached_clients.count={}\n", .{ is_reattach, is_first_client, session.attached_clients.count() });
 38 
 39-    // For reattaching clients, send snapshot BEFORE starting PTY reads to prevent interleaving
 40+    // For reattaching clients, resize VT BEFORE snapshot so snapshot matches client size
 41+    // But defer TIOCSWINSZ until after snapshot to prevent SIGWINCH during send
 42     if (is_reattach) {
 43+        if (rows > 0 and cols > 0) {
 44+            // Only resize VT if geometry changed
 45+            if (session.vt.cols != cols or session.vt.rows != rows) {
 46+                try session.vt.resize(session.allocator, cols, rows);
 47+                std.debug.print("Resized VT to {d}x{d} before snapshot\n", .{ cols, rows });
 48+            }
 49+        }
 50+
 51+        // Render snapshot at correct client size
 52         const buffer_slice = try terminal_snapshot.render(&session.vt, client.allocator);
 53         defer client.allocator.free(buffer_slice);
 54 
 55         try protocol.writeBinaryFrame(client.fd, .pty_binary, buffer_slice);
 56         std.debug.print("Sent scrollback buffer to client fd={d} ({d} bytes)\n", .{ client.fd, buffer_slice.len });
 57 
 58-        // Unmute client now that snapshot is sent
 59+        // Unmute client before TIOCSWINSZ so client can receive the redraw
 60         client.muted = false;
 61+
 62+        // Now send TIOCSWINSZ to trigger app (vim) redraw - client will receive it
 63+        if (rows > 0 and cols > 0) {
 64+            var ws = c.struct_winsize{
 65+                .ws_row = rows,
 66+                .ws_col = cols,
 67+                .ws_xpixel = 0,
 68+                .ws_ypixel = 0,
 69+            };
 70+            const result = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
 71+            if (result < 0) {
 72+                return error.IoctlFailed;
 73+            }
 74+        }
 75+    } else if (!is_reattach and rows > 0 and cols > 0) {
 76+        // New session: just resize normally
 77+        try session.vt.resize(session.allocator, cols, rows);
 78+
 79+        var ws = c.struct_winsize{
 80+            .ws_row = rows,
 81+            .ws_col = cols,
 82+            .ws_xpixel = 0,
 83+            .ws_ypixel = 0,
 84+        };
 85+        const result = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
 86+        if (result < 0) {
 87+            return error.IoctlFailed;
 88+        }
 89     }
 90 
 91-    if (is_first_client) {
 92-        // Start PTY reads AFTER snapshot is sent
 93+    // Only start PTY reading if not already started
 94+    if (!session.pty_reading) {
 95+        session.pty_reading = true;
 96+        std.debug.print("Starting PTY reads for session {s}\n", .{session.name});
 97+        // Start PTY reads AFTER snapshot is sent (readFromPty sends attach response)
 98         try readFromPty(ctx, client, session);
 99 
100         // For first attach to new session, clear the client's terminal
101@@ -813,11 +839,15 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
102             try protocol.writeBinaryFrame(client.fd, .pty_binary, "\x1b[2J\x1b[H");
103         }
104     } else {
105-        // Send attach success response for additional clients
106-        try protocol.writeJson(ctx.allocator, client.fd, .attach_session_response, protocol.AttachSessionResponse{
107+        // PTY already reading - just send attach response
108+        std.debug.print("PTY already reading for session {s}, sending attach response to client fd={d}\n", .{ session.name, client.fd });
109+        const response = protocol.AttachSessionResponse{
110             .status = "ok",
111             .client_fd = client.fd,
112-        });
113+        };
114+        std.debug.print("Response payload: status={s}, client_fd={?d}\n", .{ response.status, response.client_fd });
115+        try protocol.writeJson(ctx.allocator, client.fd, .attach_session_response, response);
116+        std.debug.print("Attach response sent successfully\n", .{});
117     }
118 }
119 
120@@ -832,7 +862,7 @@ fn handlePtyInput(client: *Client, text: []const u8) !void {
121         return error.SessionNotFound;
122     };
123 
124-    std.debug.print("Writing {d} bytes to PTY fd={d}\n", .{ text.len, session.pty_master_fd });
125+    std.debug.print("Client fd={d}: Writing {d} bytes to PTY fd={d} (first byte: {d})\n", .{ client.fd, text.len, session.pty_master_fd, if (text.len > 0) text[0] else 0 });
126 
127     // Write input to PTY master fd
128     const written = posix.write(session.pty_master_fd, text) catch |err| {