repos / zmx

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

commit
b471f1a
parent
31205d2
author
Eric Bower
date
2025-11-27 11:33:54 -0500 EST
feat: render terminal snapshot to client on re-attach

This change uses libghostty-vt to restore the previous state of the
terminal when a client re-attaches to a session.

How it works:

- user creates session `zmx attach term`
- user interacts with terminal stdin
- stdin gets sent to pty via daemon
- daemon sends pty output to client *and* ghostty-vt
- ghostty-vt holds terminal state and scrollback
- user disconnects
- user re-attaches to session
- ghostty-vt sends terminal snapshot to client stdout

In this way, ghostty-vt doesn't sit in the middle of an active terminal
session, it simply receives all the same data the client receives so it
can re-hydrate clients that connect to the session.
3 files changed,  +67, -34
M build.zig.zon
+2, -2
 1@@ -33,8 +33,8 @@
 2     // internet connectivity.
 3     .dependencies = .{
 4         .ghostty = .{
 5-            .url = "git+https://github.com/ghostty-org/ghostty.git?ref=HEAD#42a38ff672fe0cbbb8588380058c91ac16ed9069",
 6-            .hash = "ghostty-1.2.1-5UdBC-7EVAMgwHLhtsdeH4rgcZ7WlagXZ1QXN9bRKJ5s",
 7+            .url = "git+https://github.com/ghostty-org/ghostty.git?ref=HEAD#4ff0e0c9d251ddd0687ead578a90d58d63f9840b",
 8+            .hash = "ghostty-1.3.0-dev-5UdBC1jbPQQiKIuCh-CDmtqsuAVn7HUDNjkzKjt9H_aX",
 9         },
10     },
11     .paths = .{
M src/ipc.zig
+1, -0
1@@ -9,6 +9,7 @@ pub const Tag = enum(u8) {
2     DetachAll = 4,
3     Kill = 5,
4     Info = 6,
5+    Init = 7,
6 };
7 
8 pub const Header = packed struct {
M src/main.zig
+64, -32
  1@@ -1,6 +1,7 @@
  2 const std = @import("std");
  3 const posix = std.posix;
  4 const builtin = @import("builtin");
  5+const ghostty_vt = @import("ghostty-vt");
  6 const ipc = @import("ipc.zig");
  7 const log = @import("log.zig");
  8 
  9@@ -158,6 +159,10 @@ pub fn main() !void {
 10         }
 11 
 12         const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 13+        var command: ?[][]const u8 = null;
 14+        if (command_args.items.len > 0) {
 15+            command = command_args.items;
 16+        }
 17         var daemon = Daemon{
 18             .running = true,
 19             .cfg = &cfg,
 20@@ -166,7 +171,7 @@ pub fn main() !void {
 21             .session_name = session_name,
 22             .socket_path = undefined,
 23             .pid = undefined,
 24-            .command = if (command_args.items.len > 0) command_args.items else null,
 25+            .command = command,
 26         };
 27         daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 28         std.log.info("socket path={s}", .{daemon.socket_path});
 29@@ -420,10 +425,9 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 30 
 31     setupSigwinchHandler();
 32 
 33-    // Send initial terminal size
 34-    if (getTerminalSize()) |size| {
 35-        ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&size)) catch {};
 36-    }
 37+    // Send init message with terminal size
 38+    const size = getTerminalSize(posix.STDOUT_FILENO);
 39+    ipc.send(client_sock_fd, .Init, std.mem.asBytes(&size)) catch {};
 40 
 41     var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 2);
 42     defer poll_fds.deinit(alloc);
 43@@ -443,12 +447,11 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 44     while (true) {
 45         // Check for pending SIGWINCH
 46         if (sigwinch_received.swap(false, .acq_rel)) {
 47-            if (getTerminalSize()) |size| {
 48-                ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&size)) catch |err| switch (err) {
 49-                    error.BrokenPipe, error.ConnectionResetByPeer => return,
 50-                    else => return err,
 51-                };
 52-            }
 53+            const next_size = getTerminalSize(posix.STDOUT_FILENO);
 54+            ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&next_size)) catch |err| switch (err) {
 55+                error.BrokenPipe, error.ConnectionResetByPeer => return,
 56+                else => return err,
 57+            };
 58         }
 59 
 60         poll_fds.clearRetainingCapacity();
 61@@ -569,6 +572,15 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
 62     var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
 63     defer poll_fds.deinit(daemon.alloc);
 64 
 65+    const init_size = getTerminalSize(pty_fd);
 66+    var term = try ghostty_vt.Terminal.init(daemon.alloc, .{
 67+        .cols = init_size.cols,
 68+        .rows = init_size.rows,
 69+    });
 70+    defer term.deinit(daemon.alloc);
 71+    var vt_stream = term.vtStream();
 72+    defer vt_stream.deinit();
 73+
 74     while (!should_exit and daemon.running) {
 75         poll_fds.clearRetainingCapacity();
 76 
 77@@ -615,17 +627,6 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
 78             client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 4096);
 79             try daemon.clients.append(daemon.alloc, client);
 80             std.log.info("client connected fd={d} total={d}", .{ client_fd, daemon.clients.items.len });
 81-
 82-            // TODO: Replace this SIGWINCH hack with proper terminal state restoration
 83-            // using ghostty-vt to maintain a virtual terminal buffer and replay it
 84-            // to new clients on re-attach.
 85-            // For now, trigger a SIGWINCH and in-band resize to force applications to redraw.
 86-            var ws: c.struct_winsize = undefined;
 87-            if (c.ioctl(pty_fd, c.TIOCGWINSZ, &ws) == 0) {
 88-                _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
 89-            }
 90-            // Send in-band resize notification
 91-            _ = posix.write(pty_fd, "\x1b[?2048h") catch {};
 92         }
 93 
 94         if (poll_fds.items[1].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
 95@@ -642,6 +643,9 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
 96                     std.log.info("shell exited pty_fd={d}", .{pty_fd});
 97                     should_exit = true;
 98                 } else {
 99+                    // Feed PTY output to terminal emulator for state tracking
100+                    try vt_stream.nextSlice(buf[0..n]);
101+
102                     // Broadcast data to all clients
103                     for (daemon.clients.items) |client| {
104                         ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
105@@ -692,6 +696,37 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
106                                 _ = try posix.write(pty_fd, msg.payload);
107                             }
108                         },
109+                        .Init => {
110+                            if (msg.payload.len == @sizeOf(ipc.Resize)) {
111+                                const resize = std.mem.bytesToValue(ipc.Resize, msg.payload);
112+                                var ws: c.struct_winsize = .{
113+                                    .ws_row = resize.rows,
114+                                    .ws_col = resize.cols,
115+                                    .ws_xpixel = 0,
116+                                    .ws_ypixel = 0,
117+                                };
118+                                _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
119+                                try term.resize(daemon.alloc, resize.cols, resize.rows);
120+                                std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
121+
122+                                // Send terminal state after resize
123+                                var builder: std.Io.Writer.Allocating = .init(daemon.alloc);
124+                                defer builder.deinit();
125+                                var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(&term, .vt);
126+                                term_formatter.content = .{ .selection = null };
127+                                term_formatter.extra = .all;
128+                                term_formatter.format(&builder.writer) catch |err| {
129+                                    std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
130+                                };
131+                                const term_output = builder.writer.buffered();
132+                                if (term_output.len > 0) {
133+                                    ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, term_output) catch |err| {
134+                                        std.log.warn("failed to buffer terminal state for client err={s}", .{@errorName(err)});
135+                                    };
136+                                    client.has_pending_output = true;
137+                                }
138+                            }
139+                        },
140                         .Resize => {
141                             if (msg.payload.len == @sizeOf(ipc.Resize)) {
142                                 const resize = std.mem.bytesToValue(ipc.Resize, msg.payload);
143@@ -702,6 +737,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
144                                     .ws_ypixel = 0,
145                                 };
146                                 _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
147+                                try term.resize(daemon.alloc, resize.cols, resize.rows);
148                                 std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
149                             }
150                         },
151@@ -768,14 +804,10 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
152 }
153 
154 fn spawnPty(daemon: *Daemon) !c_int {
155-    // Get terminal size
156-    var orig_ws: c.struct_winsize = undefined;
157-    const result = c.ioctl(posix.STDOUT_FILENO, c.TIOCGWINSZ, &orig_ws);
158-    const rows: u16 = if (result == 0) orig_ws.ws_row else 24;
159-    const cols: u16 = if (result == 0) orig_ws.ws_col else 80;
160+    const size = getTerminalSize(posix.STDOUT_FILENO);
161     var ws: c.struct_winsize = .{
162-        .ws_row = rows,
163-        .ws_col = cols,
164+        .ws_row = size.rows,
165+        .ws_col = size.cols,
166         .ws_xpixel = 0,
167         .ws_ypixel = 0,
168     };
169@@ -875,10 +907,10 @@ fn setupSigwinchHandler() void {
170     posix.sigaction(posix.SIG.WINCH, &act, null);
171 }
172 
173-fn getTerminalSize() ?ipc.Resize {
174+fn getTerminalSize(fd: i32) ipc.Resize {
175     var ws: c.struct_winsize = undefined;
176-    if (c.ioctl(posix.STDOUT_FILENO, c.TIOCGWINSZ, &ws) == 0) {
177+    if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0) {
178         return .{ .rows = ws.ws_row, .cols = ws.ws_col };
179     }
180-    return null;
181+    return .{ .rows = 24, .cols = 80 };
182 }