Commit 7cc6d16

Ian Tay  ·  2026-07-23 19:07:30 -0400 EDT
parent fdc2449
Re-attach fixes: answer queries after detach, redraw at same size, focus events

Fixes for programs that keep interacting with the terminal across a
detach/re-attach.

1. Answer terminal queries again after a detach.
   With no client attached the daemon answers DA1/DA2 itself so a program
   probing the terminal isn't left waiting (this is what avoids fish's 10s
   startup delay). That was gated on `has_terminal_client`, set on the
   first .Init and never cleared, so after the first detach nothing
   answered anymore and a program started or probing while detached hung.
   The check is now hasTerminalClient(), computed from the client list.
   handleDetachAll goes through closeClient so the leader is cleared there
   as well.

2. Same-size re-attach makes the program redraw.
   On re-attach the program should redraw, since what is on screen is now
   the daemon's replay rather than what it last drew. A size change does
   that via SIGWINCH, but the kernel sends none when the size is unchanged.
   In that case the daemon now sets rows-1, waits 5ms, and restores. Rows
   rather than cols so nothing re-wraps; 5ms because programs were measured
   reacting in well under 1ms.

3. Snapshot at the client's size.
   handleInit serialized the terminal and resized afterwards, so a client of
   a different width received a snapshot its own terminal wrapped a second
   time. The daemon terminal is now resized to the leader's size first,
   then serialized, then the PTY is resized. The cursor stays consistent
   because it is serialized from the same already-resized terminal, and the
   shell's SIGWINCH redraw still arrives after the snapshot. The
   prompt-redraw guard is shared with handleResize (resizeTerm).

4. Focus in/out on attach/detach.
   If the program asked for focus reports (DECSET 1004), send CSI O when
   the last attached client leaves and CSI I when one returns, as a
   terminal does when its window loses/gains focus.

Tests: a unit test for hasTerminalClient; test/reattach.bats uses a small
socket client in place of a tty-backed attach to check DA1 is answered
while detached and after a detach, left to the client while attached, and
that a same-size re-attach yields SIGWINCH.
3 files changed,  +209, -58
+4, -0
......@@ -48,6 +48,10 @@ pub const Resize = packed struct {
4848 cols: u16,
4949 xpixel: u16 = 0,
5050 ypixel: u16 = 0,
51+
52+ pub fn winsize(self: Resize) cross.c.struct_winsize {
53+ return .{ .ws_row = self.rows, .ws_col = self.cols, .ws_xpixel = self.xpixel, .ws_ypixel = self.ypixel };
54+ }
5155 };
5256
5357 pub fn getTerminalSize(fd: i32) Resize {
+134, -58
......@@ -245,7 +245,18 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
245245 var marker_carry: [32]u8 = undefined;
246246 var marker_carry_len: usize = 0;
247247
248+ var had_terminal_client = daemon.hasTerminalClient();
249+
248250 daemon_loop: while (daemon.running) {
251+ // If the program asked for focus reports (DECSET 1004), send focus-out
252+ // when the last attached client leaves and focus-in when one returns,
253+ // as a terminal would when its window loses/gains focus.
254+ const has_terminal_client = daemon.hasTerminalClient();
255+ if (has_terminal_client != had_terminal_client and term.modes.get(.focus_event)) {
256+ daemon.queuePtyInput(gpa, if (has_terminal_client) "\x1b[I" else "\x1b[O");
257+ }
258+ had_terminal_client = has_terminal_client;
259+
249260 poll_fds.clearRetainingCapacity();
250261
251262 try poll_fds.append(gpa, .{
......@@ -347,10 +358,9 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
347358 // When no real terminal client has attached yet, respond to
348359 // terminal queries (e.g. DA1/DA2) on behalf of the terminal.
349360 // This prevents fish from waiting 10s for unanswered queries.
350- // `has_terminal_client` is only set when a client sends .Init
351- // (a real zmx attach), not when a `zmx run` tail-only client
352- // connects.
353- if (!daemon.has_terminal_client and
361+ // Only clients that sent .Init (a real zmx attach) count,
362+ // not a `zmx run` tail-only client.
363+ if (!daemon.hasTerminalClient() and
354364 daemon.pty_write_buf.items.len < Daemon.PTY_WRITE_BUF_MAX)
355365 {
356366 util.respondToDeviceAttributes(gpa, &daemon.pty_write_buf, buf[0..n]);
......@@ -459,7 +469,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
459469 .Input => try daemon.handleInput(gpa, client, msg.payload),
460470 .Send => daemon.handleSend(gpa, msg.payload),
461471 .Output => try daemon.handleOutput(gpa, msg.payload, &term, &vt_stream),
462- .Init => try daemon.handleInit(gpa, client, pty_fd, &term, msg.payload),
472+ .Init => try daemon.handleInit(gpa, io, client, pty_fd, &term, msg.payload),
463473 .Switch => try daemon.handleSwitch(gpa, msg.payload),
464474 .Resize => try daemon.handleResize(gpa, client, pty_fd, &term, msg.payload),
465475 .Detach => {
......@@ -534,6 +544,7 @@ pub const Client = struct {
534544 alloc: std.mem.Allocator,
535545 socket_fd: i32,
536546 has_pending_output: bool = false,
547+ is_terminal: bool = false, // sent .Init (a `zmx attach`), not a run/send/tail client
537548 read_buf: ipc.SocketBuffer,
538549 write_buf: std.ArrayList(u8),
539550 env_str: ?[]u8 = null,
......@@ -587,7 +598,6 @@ pub const Daemon = struct {
587598 cwd_path_buf: [std.fs.max_path_bytes]u8 = undefined,
588599 has_pty_output: bool = false,
589600 has_had_client: bool = false,
590- has_terminal_client: bool = false, // true only after a real attach (.Init received)
591601 created_at: u64, // unix timestamp (ns)
592602 is_task_mode: bool = false, // flag for when session is run as a task
593603 task_id: [4]u8 = undefined,
......@@ -630,6 +640,41 @@ pub const Daemon = struct {
630640 self.clients.clearRetainingCapacity();
631641 }
632642
643+ /// Resize the daemon's terminal with prompt_redraw disabled. On resize the
644+ /// terminal would clear prompt lines expecting the shell to redraw them,
645+ /// but the shell's redraw goes to the PTY (forwarded to clients), not to
646+ /// this terminal, so the clearing only corrupts our snapshot state.
647+ fn resizeTerm(gpa: std.mem.Allocator, term: *ghostty_vt.Terminal, cols: u16, rows: u16) !void {
648+ const saved = term.flags.shell_redraws_prompt;
649+ term.flags.shell_redraws_prompt = .false;
650+ defer term.flags.shell_redraws_prompt = saved;
651+ try term.resize(gpa, .{ .cols = cols, .rows = rows });
652+ }
653+
654+ /// If the PTY is already at `ws`, briefly shrink it by one row so the
655+ /// program receives a SIGWINCH and redraws (setting an identical size
656+ /// raises no signal). Rows rather than cols so nothing re-wraps. The pause
657+ /// lets the program read the intermediate size before the caller restores
658+ /// it; programs react in well under 1ms, 5ms is margin.
659+ fn forceSigwinchIfUnchanged(io: std.Io, pty_fd: i32, ws: cross.c.struct_winsize) void {
660+ var cur: cross.c.struct_winsize = undefined;
661+ if (cross.c.ioctl(pty_fd, cross.c.TIOCGWINSZ, &cur) != 0) return;
662+ if (cur.ws_row != ws.ws_row or cur.ws_col != ws.ws_col or
663+ cur.ws_xpixel != ws.ws_xpixel or cur.ws_ypixel != ws.ws_ypixel) return;
664+ if (cur.ws_row < 2) return;
665+ cur.ws_row -= 1;
666+ _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &cur);
667+ std.Io.sleep(io, std.Io.Duration.fromMilliseconds(5), .real) catch {};
668+ }
669+
670+ /// True while a client that sent .Init (a real `zmx attach`) is connected.
671+ fn hasTerminalClient(self: *const Daemon) bool {
672+ for (self.clients.items) |c| {
673+ if (c.is_terminal) return true;
674+ }
675+ return false;
676+ }
677+
633678 pub fn closeClient(self: *Daemon, gpa: std.mem.Allocator, client: *Client, i: usize, shutdown_on_last: bool) bool {
634679 const fd = client.socket_fd;
635680 // leader is disconnected, remove ref and let another client claim leader on input
......@@ -921,6 +966,7 @@ pub const Daemon = struct {
921966 pub fn handleInit(
922967 self: *Daemon,
923968 gpa: std.mem.Allocator,
969+ io: std.Io,
924970 client: *Client,
925971 pty_fd: i32,
926972 term: *ghostty_vt.Terminal,
......@@ -928,9 +974,24 @@ pub const Daemon = struct {
928974 ) !void {
929975 if (payload.len != @sizeOf(ipc.Resize)) return;
930976
931- // Serialize terminal state BEFORE resize to capture correct cursor position.
932- // Resizing triggers reflow which can move the cursor, and the shell's
933- // SIGWINCH-triggered redraw will run after our snapshot is sent.
977+ client.is_terminal = true;
978+
979+ if (self.leader_client_fd == null) {
980+ try self.setLeader(gpa, client);
981+ }
982+ const is_leader = self.leader_client_fd == client.socket_fd;
983+ const resize = std.mem.bytesToValue(ipc.Resize, payload);
984+
985+ // Resize our terminal (not yet the PTY) to the leader's size before
986+ // serializing, so the snapshot is laid out for the width the client
987+ // will render it at instead of being wrapped a second time on arrival.
988+ // Cursor position stays consistent because it is serialized from the
989+ // same, already-resized terminal; the PTY is resized below, so the
990+ // shell's own SIGWINCH redraw still arrives after the snapshot.
991+ if (is_leader) {
992+ try resizeTerm(gpa, term, resize.cols, resize.rows);
993+ }
994+
934995 // Only serialize on re-attach (has_had_client), not first attach, to avoid
935996 // interfering with shell initialization (DA1 queries, etc.)
936997 if (self.has_pty_output and self.has_had_client) {
......@@ -956,37 +1017,20 @@ pub const Daemon = struct {
9561017 }
9571018 }
9581019
959- // no leader is set so set one
960- if (self.leader_client_fd == null) {
961- try self.setLeader(gpa, client);
962- }
963-
9641020 // only resize if leader
965- if (self.leader_client_fd == client.socket_fd) {
966- const resize = std.mem.bytesToValue(ipc.Resize, payload);
967- var ws: cross.c.struct_winsize = .{
968- .ws_row = resize.rows,
969- .ws_col = resize.cols,
970- .ws_xpixel = resize.xpixel,
971- .ws_ypixel = resize.ypixel,
972- };
1021+ if (is_leader) {
1022+ var ws = resize.winsize();
1023+ // On re-attach the program should redraw, since what's on screen
1024+ // is now our replay rather than what it last drew. A size change
1025+ // makes it do that via SIGWINCH, but the kernel sends none when the
1026+ // size is unchanged, so force one in that case.
1027+ if (self.has_pty_output and self.has_had_client) {
1028+ forceSigwinchIfUnchanged(io, pty_fd, ws);
1029+ }
9731030 _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
974- // Disable prompt_redraw before resize. The daemon's internal terminal
975- // would otherwise clear prompt lines expecting the shell to redraw them,
976- // but the shell's redraw goes to the PTY (forwarded to clients), not to
977- // this daemon terminal. The clearing corrupts the daemon's snapshot state.
978- const saved_prompt_redraw = term.flags.shell_redraws_prompt;
979- term.flags.shell_redraws_prompt = .false;
980- defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
981- const opts = ghostty_vt.Terminal.Resize{
982- .cols = resize.cols,
983- .rows = resize.rows,
984- };
985- try term.resize(gpa, opts);
9861031
9871032 // Mark that we've had a client init, so subsequent clients get terminal state
9881033 self.has_had_client = true;
989- self.has_terminal_client = true;
9901034
9911035 std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
9921036 }
......@@ -1008,22 +1052,9 @@ pub const Daemon = struct {
10081052 if (self.leader_client_fd != client.socket_fd) return;
10091053
10101054 const resize = std.mem.bytesToValue(ipc.Resize, payload);
1011- var ws: cross.c.struct_winsize = .{
1012- .ws_row = resize.rows,
1013- .ws_col = resize.cols,
1014- .ws_xpixel = resize.xpixel,
1015- .ws_ypixel = resize.ypixel,
1016- };
1055+ var ws = resize.winsize();
10171056 _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
1018- // Disable prompt_redraw before resize (same rationale as handleInit).
1019- const saved_prompt_redraw = term.flags.shell_redraws_prompt;
1020- term.flags.shell_redraws_prompt = .false;
1021- defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
1022- const opts = ghostty_vt.Terminal.Resize{
1023- .cols = resize.cols,
1024- .rows = resize.rows,
1025- };
1026- try term.resize(gpa, opts);
1057+ try resizeTerm(gpa, term, resize.cols, resize.rows);
10271058 std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
10281059 }
10291060
......@@ -1034,11 +1065,11 @@ pub const Daemon = struct {
10341065
10351066 pub fn handleDetachAll(self: *Daemon, gpa: std.mem.Allocator) void {
10361067 std.log.info("detach all clients={d}", .{self.clients.items.len});
1037- for (self.clients.items) |client_to_close| {
1038- client_to_close.deinit(gpa);
1039- gpa.destroy(client_to_close);
1068+ // Go through closeClient so the leader is cleared like any other detach.
1069+ while (self.clients.items.len > 0) {
1070+ const last = self.clients.items.len - 1;
1071+ _ = self.closeClient(gpa, self.clients.items[last], last, false);
10401072 }
1041- self.clients.clearRetainingCapacity();
10421073 }
10431074
10441075 pub fn handleKill(self: *Daemon, gpa: std.mem.Allocator, io: std.Io) void {
......@@ -1341,18 +1372,63 @@ pub const Daemon = struct {
13411372 }
13421373 };
13431374
1344-test "send queues PTY input without changing leader" {
1345- const alloc = std.testing.allocator;
1346- var daemon = Daemon{
1375+fn testDaemon() Daemon {
1376+ return .{
13471377 .cfg = undefined,
13481378 .clients = .empty,
1349- .leader_client_fd = 42,
1379+ .leader_client_fd = null,
13501380 .session_name = "test",
13511381 .socket_path = "",
13521382 .running = true,
13531383 .pid = 0,
13541384 .created_at = 0,
13551385 };
1386+}
1387+
1388+fn testClient(alloc: std.mem.Allocator, fd: i32, is_terminal: bool) !*Client {
1389+ const c = try alloc.create(Client);
1390+ c.* = .{ .alloc = alloc, .socket_fd = fd, .read_buf = try ipc.SocketBuffer.init(alloc), .write_buf = .empty };
1391+ c.is_terminal = is_terminal;
1392+ return c;
1393+}
1394+
1395+test "hasTerminalClient follows attach, detach and detach-all" {
1396+ const alloc = std.testing.allocator;
1397+ var daemon = testDaemon();
1398+ defer daemon.clients.deinit(alloc);
1399+ defer daemon.pty_write_buf.deinit(alloc);
1400+
1401+ // Real fds (pipes) so Client.deinit's close() is legal.
1402+ const a = try lib_posix.pipe2(.{});
1403+ const b = try lib_posix.pipe2(.{});
1404+
1405+ try std.testing.expect(!daemon.hasTerminalClient());
1406+
1407+ // A run/send/tail client doesn't count.
1408+ const tail_client = try testClient(alloc, a[0], false);
1409+ try daemon.clients.append(alloc, tail_client);
1410+ try std.testing.expect(!daemon.hasTerminalClient());
1411+
1412+ // An attach client does, until it disconnects.
1413+ const term_client = try testClient(alloc, a[1], true);
1414+ try daemon.clients.append(alloc, term_client);
1415+ try std.testing.expect(daemon.hasTerminalClient());
1416+ _ = daemon.closeClient(alloc, tail_client, 0, false);
1417+ try std.testing.expect(daemon.hasTerminalClient());
1418+ _ = daemon.closeClient(alloc, term_client, 0, false);
1419+ try std.testing.expect(!daemon.hasTerminalClient());
1420+
1421+ try daemon.clients.append(alloc, try testClient(alloc, b[0], true));
1422+ try daemon.clients.append(alloc, try testClient(alloc, b[1], true));
1423+ daemon.handleDetachAll(alloc);
1424+ try std.testing.expect(!daemon.hasTerminalClient());
1425+ try std.testing.expectEqual(@as(?i32, null), daemon.leader_client_fd);
1426+}
1427+
1428+test "send queues PTY input without changing leader" {
1429+ const alloc = std.testing.allocator;
1430+ var daemon = testDaemon();
1431+ daemon.leader_client_fd = 42;
13561432 defer daemon.pty_write_buf.deinit(alloc);
13571433
13581434 daemon.handleSend(alloc, "hello");
+71, -0
......@@ -0,0 +1,71 @@
1+#!/usr/bin/env bats
2+# Re-attach behavior. `zmx attach` needs a tty, so a small python client that
3+# connects, sends .Init and holds the socket stands in for one here.
4+
5+load test_helper
6+
7+# fake_attach <session> <rows> <cols> [hold_seconds]
8+fake_attach() {
9+ local session="$1" rows="$2" cols="$3" hold="${4:-0.2}"
10+ python3 - "$ZMX_DIR" "$session" "$rows" "$cols" "$hold" 3>&- <<'PY'
11+import os, socket, struct, sys, time
12+sock_dir, name, rows, cols, hold = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
13+s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
14+s.connect(os.path.join(sock_dir, name))
15+payload = struct.pack("<HHHH", rows, cols, 0, 0) # ipc.Resize
16+s.sendall(struct.pack("<BIxxx", 7, len(payload)) + payload) # ipc.Header, tag .Init = 7
17+time.sleep(hold)
18+s.close()
19+PY
20+}
21+
22+# Shell snippet: send DA1 and report whether a reply arrived within $1
23+# seconds. The result word is assembled by printf so that it only appears in
24+# history as program output, never as part of the echoed command line.
25+da1_probe() {
26+ echo "printf '\033[c'; if IFS= read -r -s -t $1 -d c r; then printf 'DA1_%s\n' ANSWERED; else printf 'DA1_%s\n' TIMEOUT; fi"
27+}
28+
29+count() { "$ZMX" history "$1" | grep -o "$2" | wc -l | tr -d ' '; }
30+
31+@test "daemon answers DA1 while detached, also after a client has detached" {
32+ "$ZMX" run test-da -d bash -c "$(da1_probe 5)"
33+ wait_for_output test-da "DA1_ANSWERED"
34+
35+ fake_attach test-da 24 80 # attach + detach
36+ "$ZMX" run test-da bash -c "$(da1_probe 5)"
37+ for _ in $(seq 60); do [ "$(count test-da DA1_ANSWERED)" -eq 2 ] && break; sleep 0.1; done
38+ [ "$(count test-da DA1_ANSWERED)" -eq 2 ]
39+ [ "$(count test-da DA1_TIMEOUT)" -eq 0 ]
40+}
41+
42+@test "daemon leaves DA1 to the client while one is attached" {
43+ # The session's own command probes after a delay; a client is attached by
44+ # then, so the daemon must stay quiet and (with no real terminal behind the
45+ # fake client) the probe times out.
46+ "$ZMX" run test-da2 -d bash -c "sleep 1; $(da1_probe 1); sleep 30"
47+ wait_for_session test-da2
48+ fake_attach test-da2 24 80 5 &
49+ wait_for_output test-da2 "DA1_TIMEOUT" 5
50+ kill %1 2>/dev/null || true
51+ wait
52+}
53+
54+@test "re-attach at the same size still delivers SIGWINCH" {
55+ # The trap prints a word assembled at runtime so it can't be confused with
56+ # the echoed command line in history.
57+ "$ZMX" run test-winch -d bash -c 'trap "printf \"GOT_%s\n\" WINCH" WINCH; echo READY; while :; do sleep 0.1; done'
58+ wait_for_output test-winch "READY"
59+
60+ fake_attach test-winch 24 80 # first attach: sets the size, no bounce
61+ sleep 0.5
62+ before=$(count test-winch GOT_WINCH)
63+
64+ fake_attach test-winch 24 80 # same size again
65+ for _ in $(seq 30); do
66+ after=$(count test-winch GOT_WINCH)
67+ [ "$after" -gt "$before" ] && break
68+ sleep 0.1
69+ done
70+ [ "$after" -gt "$before" ]
71+}