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 { | |
| 48 | 48 | cols: u16, | |
| 49 | 49 | xpixel: u16 = 0, | |
| 50 | 50 | 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 | + | } | |
| 51 | 55 | }; | |
| 52 | 56 | ||
| 53 | 57 | 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 | |
| 245 | 245 | var marker_carry: [32]u8 = undefined; | |
| 246 | 246 | var marker_carry_len: usize = 0; | |
| 247 | 247 | ||
| 248 | + | var had_terminal_client = daemon.hasTerminalClient(); | |
| 249 | + | ||
| 248 | 250 | 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 | + | ||
| 249 | 260 | poll_fds.clearRetainingCapacity(); | |
| 250 | 261 | ||
| 251 | 262 | try poll_fds.append(gpa, .{ |
| ... | ... | @@ -347,10 +358,9 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 347 | 358 | // When no real terminal client has attached yet, respond to | |
| 348 | 359 | // terminal queries (e.g. DA1/DA2) on behalf of the terminal. | |
| 349 | 360 | // 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 | |
| 354 | 364 | daemon.pty_write_buf.items.len < Daemon.PTY_WRITE_BUF_MAX) | |
| 355 | 365 | { | |
| 356 | 366 | 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 | |
| 459 | 469 | .Input => try daemon.handleInput(gpa, client, msg.payload), | |
| 460 | 470 | .Send => daemon.handleSend(gpa, msg.payload), | |
| 461 | 471 | .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), | |
| 463 | 473 | .Switch => try daemon.handleSwitch(gpa, msg.payload), | |
| 464 | 474 | .Resize => try daemon.handleResize(gpa, client, pty_fd, &term, msg.payload), | |
| 465 | 475 | .Detach => { |
| ... | ... | @@ -534,6 +544,7 @@ pub const Client = struct { | |
| 534 | 544 | alloc: std.mem.Allocator, | |
| 535 | 545 | socket_fd: i32, | |
| 536 | 546 | has_pending_output: bool = false, | |
| 547 | + | is_terminal: bool = false, // sent .Init (a `zmx attach`), not a run/send/tail client | |
| 537 | 548 | read_buf: ipc.SocketBuffer, | |
| 538 | 549 | write_buf: std.ArrayList(u8), | |
| 539 | 550 | env_str: ?[]u8 = null, |
| ... | ... | @@ -587,7 +598,6 @@ pub const Daemon = struct { | |
| 587 | 598 | cwd_path_buf: [std.fs.max_path_bytes]u8 = undefined, | |
| 588 | 599 | has_pty_output: bool = false, | |
| 589 | 600 | has_had_client: bool = false, | |
| 590 | - | has_terminal_client: bool = false, // true only after a real attach (.Init received) | |
| 591 | 601 | created_at: u64, // unix timestamp (ns) | |
| 592 | 602 | is_task_mode: bool = false, // flag for when session is run as a task | |
| 593 | 603 | task_id: [4]u8 = undefined, |
| ... | ... | @@ -630,6 +640,41 @@ pub const Daemon = struct { | |
| 630 | 640 | self.clients.clearRetainingCapacity(); | |
| 631 | 641 | } | |
| 632 | 642 | ||
| 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 | + | ||
| 633 | 678 | pub fn closeClient(self: *Daemon, gpa: std.mem.Allocator, client: *Client, i: usize, shutdown_on_last: bool) bool { | |
| 634 | 679 | const fd = client.socket_fd; | |
| 635 | 680 | // leader is disconnected, remove ref and let another client claim leader on input |
| ... | ... | @@ -921,6 +966,7 @@ pub const Daemon = struct { | |
| 921 | 966 | pub fn handleInit( | |
| 922 | 967 | self: *Daemon, | |
| 923 | 968 | gpa: std.mem.Allocator, | |
| 969 | + | io: std.Io, | |
| 924 | 970 | client: *Client, | |
| 925 | 971 | pty_fd: i32, | |
| 926 | 972 | term: *ghostty_vt.Terminal, |
| ... | ... | @@ -928,9 +974,24 @@ pub const Daemon = struct { | |
| 928 | 974 | ) !void { | |
| 929 | 975 | if (payload.len != @sizeOf(ipc.Resize)) return; | |
| 930 | 976 | ||
| 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 | + | ||
| 934 | 995 | // Only serialize on re-attach (has_had_client), not first attach, to avoid | |
| 935 | 996 | // interfering with shell initialization (DA1 queries, etc.) | |
| 936 | 997 | if (self.has_pty_output and self.has_had_client) { |
| ... | ... | @@ -956,37 +1017,20 @@ pub const Daemon = struct { | |
| 956 | 1017 | } | |
| 957 | 1018 | } | |
| 958 | 1019 | ||
| 959 | - | // no leader is set so set one | |
| 960 | - | if (self.leader_client_fd == null) { | |
| 961 | - | try self.setLeader(gpa, client); | |
| 962 | - | } | |
| 963 | - | ||
| 964 | 1020 | // 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 | + | } | |
| 973 | 1030 | _ = 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); | |
| 986 | 1031 | ||
| 987 | 1032 | // Mark that we've had a client init, so subsequent clients get terminal state | |
| 988 | 1033 | self.has_had_client = true; | |
| 989 | - | self.has_terminal_client = true; | |
| 990 | 1034 | ||
| 991 | 1035 | std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols }); | |
| 992 | 1036 | } |
| ... | ... | @@ -1008,22 +1052,9 @@ pub const Daemon = struct { | |
| 1008 | 1052 | if (self.leader_client_fd != client.socket_fd) return; | |
| 1009 | 1053 | ||
| 1010 | 1054 | 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(); | |
| 1017 | 1056 | _ = 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); | |
| 1027 | 1058 | std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols }); | |
| 1028 | 1059 | } | |
| 1029 | 1060 |
| ... | ... | @@ -1034,11 +1065,11 @@ pub const Daemon = struct { | |
| 1034 | 1065 | ||
| 1035 | 1066 | pub fn handleDetachAll(self: *Daemon, gpa: std.mem.Allocator) void { | |
| 1036 | 1067 | 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); | |
| 1040 | 1072 | } | |
| 1041 | - | self.clients.clearRetainingCapacity(); | |
| 1042 | 1073 | } | |
| 1043 | 1074 | ||
| 1044 | 1075 | pub fn handleKill(self: *Daemon, gpa: std.mem.Allocator, io: std.Io) void { |
| ... | ... | @@ -1341,18 +1372,63 @@ pub const Daemon = struct { | |
| 1341 | 1372 | } | |
| 1342 | 1373 | }; | |
| 1343 | 1374 | ||
| 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 .{ | |
| 1347 | 1377 | .cfg = undefined, | |
| 1348 | 1378 | .clients = .empty, | |
| 1349 | - | .leader_client_fd = 42, | |
| 1379 | + | .leader_client_fd = null, | |
| 1350 | 1380 | .session_name = "test", | |
| 1351 | 1381 | .socket_path = "", | |
| 1352 | 1382 | .running = true, | |
| 1353 | 1383 | .pid = 0, | |
| 1354 | 1384 | .created_at = 0, | |
| 1355 | 1385 | }; | |
| 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; | |
| 1356 | 1432 | defer daemon.pty_write_buf.deinit(alloc); | |
| 1357 | 1433 | ||
| 1358 | 1434 | 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 | + | } |