2de33178
feat: carry the session's window title to the host terminal
a73x 2026-08-15 18:28
Commit message
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1177,8 +1177,16 @@ fn session( | |||
| 1177 | // A `--no-altscreen` or an inline mode would break (2) while | 1177 | // A `--no-altscreen` or an inline mode would break (2) while |
| 1178 | // leaving (1) true and this comment still reading as satisfied | 1178 | // leaving (1) true and this comment still reading as satisfied |
| 1179 | // — such a mode needs its own teardown gate, not this one. | 1179 | // — such a mode needs its own teardown gate, not this one. |
| 1180 | // | ||
| 1181 | // The title push (`22;0t`) rides the same gate for the same | ||
| 1182 | // pairing argument, and needs it more: an unmatched POP does not | ||
| 1183 | // restore a title, it pops whatever the terminal had underneath | ||
| 1184 | // — somebody else's. Pushed here and popped in | ||
| 1185 | // `terminal_teardown`, both under `alt_screen`, is what makes | ||
| 1186 | // the pair exactly one deep. `0` is "icon name and window | ||
| 1187 | // title", matching the OSC 0 `appendTermTitle` writes. | ||
| 1180 | if (is_tty and !alt_screen) { | 1188 | if (is_tty and !alt_screen) { |
| 1181 | try proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l"); | 1189 | try proto.writeAllFd(stdout_fd, terminal_setup); |
| 1182 | alt_screen = true; | 1190 | alt_screen = true; |
| 1183 | } | 1191 | } |
| 1184 | switch (frame.type) { | 1192 | switch (frame.type) { |
| @@ -1278,16 +1286,12 @@ fn session( | |||
| 1278 | if (scroll_pages == 0 or frame.payload.len < 6) continue; | 1286 | if (scroll_pages == 0 or frame.payload.len < 6) continue; |
| 1279 | try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd); | 1287 | try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd); |
| 1280 | }, | 1288 | }, |
| 1281 | .term_event => { | 1289 | .term_event => try writeSideChannel( |
| 1282 | var esc: std.ArrayList(u8) = .empty; | 1290 | alloc, |
| 1283 | defer esc.deinit(alloc); | 1291 | stdout_fd, |
| 1284 | try appendTermEvent(&esc, alloc, frame.payload); | 1292 | frame.payload, |
| 1285 | // Outside the paint's synchronized-update bracket: this | 1293 | appendTermEvent, |
| 1286 | // is a message to the terminal, not part of the picture, | 1294 | ), |
| 1287 | // and a sync bracket around it would hold it until the | ||
| 1288 | // next frame. | ||
| 1289 | if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items); | ||
| 1290 | }, | ||
| 1291 | .term_modes => { | 1295 | .term_modes => { |
| 1292 | // Repeats are expected, not a bug to filter. The daemon | 1296 | // Repeats are expected, not a bug to filter. The daemon |
| 1293 | // sends this from `sendResync`, whose only two callers | 1297 | // sends this from `sendResync`, whose only two callers |
| @@ -1312,10 +1316,17 @@ fn session( | |||
| 1312 | // a real term_modes(false), and relaying it strands the | 1316 | // a real term_modes(false), and relaying it strands the |
| 1313 | // terminal exactly that way. The daemon told the truth | 1317 | // terminal exactly that way. The daemon told the truth |
| 1314 | // and we passed it on — there is no better move here. | 1318 | // and we passed it on — there is no better move here. |
| 1315 | var esc: std.ArrayList(u8) = .empty; | 1319 | try writeSideChannel(alloc, stdout_fd, frame.payload, appendTermModes); |
| 1316 | defer esc.deinit(alloc); | 1320 | }, |
| 1317 | try appendTermModes(&esc, alloc, frame.payload); | 1321 | .term_title => { |
| 1318 | if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items); | 1322 | // Repeats are expected here for the same reason as |
| 1323 | // term_modes above — every attach resends the title — | ||
| 1324 | // and are harmless for a stronger reason: setting a | ||
| 1325 | // window title to the value it already holds is a | ||
| 1326 | // no-op with no counter or stack behind it. Note the | ||
| 1327 | // daemon never sends an empty one, so a repeat can | ||
| 1328 | // never clear a title the user is looking at. | ||
| 1329 | try writeSideChannel(alloc, stdout_fd, frame.payload, appendTermTitle); | ||
| 1319 | }, | 1330 | }, |
| 1320 | .exit_status => { | 1331 | .exit_status => { |
| 1321 | // Before any session state, exit_status is almost always | 1332 | // Before any session state, exit_status is almost always |
| @@ -1446,6 +1457,16 @@ fn isBase64Alphabet(s: []const u8) bool { | |||
| 1446 | return true; | 1457 | return true; |
| 1447 | } | 1458 | } |
| 1448 | 1459 | ||
| 1460 | /// Everything the client does TO the host terminal on the way in, in the | ||
| 1461 | /// order it does it: push the title, enter the alternate screen, hide the | ||
| 1462 | /// cursor, disable autowrap. Named rather than inline because it is one | ||
| 1463 | /// half of a pair — `terminal_teardown` undoes each of these, and the pair | ||
| 1464 | /// is pinned together in one test so neither half can drift alone. | ||
| 1465 | /// | ||
| 1466 | /// Written exactly once per process, under the same `alt_screen` gate that | ||
| 1467 | /// admits the teardown; the argument for that gate is at the call site. | ||
| 1468 | const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l"; | ||
| 1469 | |||
| 1449 | /// Everything the client must undo on its way out, in one literal. mux | 1470 | /// Everything the client must undo on its way out, in one literal. mux |
| 1450 | /// turns these on; leaving any of them set hands the user a terminal that | 1471 | /// turns these on; leaving any of them set hands the user a terminal that |
| 1451 | /// behaves oddly long after mux exited, with nothing on screen to explain | 1472 | /// behaves oddly long after mux exited, with nothing on screen to explain |
| @@ -1462,7 +1483,27 @@ fn isBase64Alphabet(s: []const u8) bool { | |||
| 1462 | /// child of the shell that launched it, host 2004 is already off, and off | 1483 | /// child of the shell that launched it, host 2004 is already off, and off |
| 1463 | /// is exactly what we put back. A host that armed 2004 and then ran a child | 1484 | /// is exactly what we put back. A host that armed 2004 and then ran a child |
| 1464 | /// without disarming would be restored wrongly — no shell in use here does. | 1485 | /// without disarming would be restored wrongly — no shell in use here does. |
| 1465 | const terminal_teardown = "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[?1049l"; | 1486 | /// |
| 1487 | /// The title pop (`23;0t`) is the one entry here that restores the user's | ||
| 1488 | /// OWN value rather than a power-on default — the terminal kept it on its | ||
| 1489 | /// stack, because mux cannot read a title back to restore it by hand. | ||
| 1490 | /// | ||
| 1491 | /// It sits SECOND TO LAST, and that placement is load-bearing even though | ||
| 1492 | /// the title stack and the alternate screen have nothing to do with each | ||
| 1493 | /// other. `?1049l` must remain the final bytes a tty client writes: the | ||
| 1494 | /// e2e doctored control for the pty capture (test/e2e.sh, tp1) appends | ||
| 1495 | /// bytes after the capture's trailing alt-screen exit, and `render` replays | ||
| 1496 | /// only up to the LAST one — so a teardown that stops ending there turns | ||
| 1497 | /// that control into a no-op that can never fail. Measured, not guessed: | ||
| 1498 | /// appending the pop after `?1049l` is what made that check fire. | ||
| 1499 | /// | ||
| 1500 | /// It is here because the question was ANSWERED, not assumed: the operator | ||
| 1501 | /// ran the push/set/pop probe in a bare Alacritty window on 2026-08-15 and | ||
| 1502 | /// the title returned (commit 217183c, and the design note it edits). A | ||
| 1503 | /// terminal without the stack ignores both halves, which costs a title bar | ||
| 1504 | /// left showing what the session set — the tmux behaviour, and the | ||
| 1505 | /// fallback this would otherwise have shipped as. | ||
| 1506 | const terminal_teardown = "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; | ||
| 1466 | 1507 | ||
| 1467 | /// Render a term_modes frame as the DECSET/DECRST writes it implies. | 1508 | /// Render a term_modes frame as the DECSET/DECRST writes it implies. |
| 1468 | /// Nothing at all for a payload we cannot parse: a payload we cannot parse | 1509 | /// Nothing at all for a payload we cannot parse: a payload we cannot parse |
| @@ -1532,6 +1573,69 @@ fn appendTermEvent( | |||
| 1532 | } | 1573 | } |
| 1533 | } | 1574 | } |
| 1534 | 1575 | ||
| 1576 | /// Render a term_title frame as the OSC 0 write it implies. | ||
| 1577 | /// | ||
| 1578 | /// Refuses any byte below 0x20 or the DEL at 0x7f. Such a byte terminates | ||
| 1579 | /// the OSC early — BEL is the terminator itself, ESC begins the other one — | ||
| 1580 | /// and everything after it lands on the user's screen as text they then | ||
| 1581 | /// have to clear. Same reasoning as the base64 alphabet check on the | ||
| 1582 | /// clipboard path, and the same all-or-nothing shape: nothing is appended | ||
| 1583 | /// until every check has passed. | ||
| 1584 | /// | ||
| 1585 | /// Refuses an empty title too, which is not a parse question but the client | ||
| 1586 | /// half of the daemon's policy (`sampleTermTitle`): `ESC]0;BEL` CLEARS the | ||
| 1587 | /// host terminal's title, and mux will not do that to a title it never set. | ||
| 1588 | /// Checked here as well as there because the peer is not necessarily this | ||
| 1589 | /// version of muxd. | ||
| 1590 | /// | ||
| 1591 | /// OSC 0 rather than OSC 2, so the icon name moves with the title: that is | ||
| 1592 | /// what the session's own applications write (both forms reach the engine | ||
| 1593 | /// as one window-title operation), and mirroring it is the point. | ||
| 1594 | /// | ||
| 1595 | /// Restoring the user's original title on exit is NOT this function's job | ||
| 1596 | /// and is not left undone: the terminal's own title stack carries it, via | ||
| 1597 | /// the `22;0t` that leads the alt-screen entry and the `23;0t` that closes | ||
| 1598 | /// `terminal_teardown`. See that constant for the observation that settled | ||
| 1599 | /// it. Nothing here needs to remember the old title, which is just as well | ||
| 1600 | /// — mux cannot read one back, and the engine cannot help either: ghostty's | ||
| 1601 | /// terminal handler ignores title_push/title_pop outright, so the SESSION's | ||
| 1602 | /// title stack does not exist to be mirrored. | ||
| 1603 | fn appendTermTitle( | ||
| 1604 | out: *std.ArrayList(u8), | ||
| 1605 | alloc: std.mem.Allocator, | ||
| 1606 | payload: []const u8, | ||
| 1607 | ) !void { | ||
| 1608 | if (payload.len == 0 or payload.len > proto.term_title_max) return; | ||
| 1609 | for (payload) |b| if (b < 0x20 or b == 0x7f) return; | ||
| 1610 | try out.appendSlice(alloc, "\x1b]0;"); | ||
| 1611 | try out.appendSlice(alloc, payload); | ||
| 1612 | try out.append(alloc, 0x07); | ||
| 1613 | } | ||
| 1614 | |||
| 1615 | /// Write one side channel's rendering of a frame to the host terminal. | ||
| 1616 | /// | ||
| 1617 | /// Outside the paint's synchronized-update bracket: these are messages TO | ||
| 1618 | /// the terminal, not part of the picture, and a sync bracket around one | ||
| 1619 | /// would hold it until the next frame. Nothing is written when the builder | ||
| 1620 | /// produced nothing — every builder here is all-or-nothing, so an empty | ||
| 1621 | /// buffer is a refusal, and half an escape sequence on a real tty paints | ||
| 1622 | /// garbage the user has to clear. | ||
| 1623 | /// | ||
| 1624 | /// `build` is comptime `anytype` rather than a declared function type: the | ||
| 1625 | /// three builders have inferred error sets, which do not coerce to a single | ||
| 1626 | /// `fn (...) anyerror!void` parameter. | ||
| 1627 | fn writeSideChannel( | ||
| 1628 | alloc: std.mem.Allocator, | ||
| 1629 | stdout_fd: std.posix.fd_t, | ||
| 1630 | payload: []const u8, | ||
| 1631 | comptime build: anytype, | ||
| 1632 | ) !void { | ||
| 1633 | var esc: std.ArrayList(u8) = .empty; | ||
| 1634 | defer esc.deinit(alloc); | ||
| 1635 | try build(&esc, alloc, payload); | ||
| 1636 | if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items); | ||
| 1637 | } | ||
| 1638 | |||
| 1535 | // ---- prediction -------------------------------------------------------- | 1639 | // ---- prediction -------------------------------------------------------- |
| 1536 | // | 1640 | // |
| 1537 | // The overlay is a display decision and nothing else. It never writes to | 1641 | // The overlay is a display decision and nothing else. It never writes to |
| @@ -2856,14 +2960,82 @@ test "client: a malformed term_modes payload writes nothing" { | |||
| 2856 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | 2960 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); |
| 2857 | } | 2961 | } |
| 2858 | 2962 | ||
| 2859 | test "client: the exit teardown unsets every mode mux turned on" { | 2963 | test "client: a title becomes an OSC 0 write, and empty or control bytes are refused" { |
| 2964 | const alloc = std.testing.allocator; | ||
| 2965 | var out: std.ArrayList(u8) = .empty; | ||
| 2966 | defer out.deinit(alloc); | ||
| 2967 | |||
| 2968 | try appendTermTitle(&out, alloc, "vim"); | ||
| 2969 | try std.testing.expectEqualStrings("\x1b]0;vim\x07", out.items); | ||
| 2970 | |||
| 2971 | // Seeded, not merely emptied: `len == 0` on a buffer that started empty | ||
| 2972 | // also passes for a builder that appends nothing ever. | ||
| 2973 | out.clearRetainingCapacity(); | ||
| 2974 | try out.append(alloc, refusal_sentinel); | ||
| 2975 | // A BEL inside the title would terminate the OSC early and paint the | ||
| 2976 | // rest — here a shell command — on the user's screen as text. | ||
| 2977 | try appendTermTitle(&out, alloc, "vim\x07rm -rf"); | ||
| 2978 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 2979 | |||
| 2980 | // ESC is the other terminator half (ST), and DEL is the control byte | ||
| 2981 | // that is not below 0x20 — both are refused by the same check. | ||
| 2982 | out.clearRetainingCapacity(); | ||
| 2983 | try out.append(alloc, refusal_sentinel); | ||
| 2984 | try appendTermTitle(&out, alloc, "vim\x1b]52;c;AAAA\x07"); | ||
| 2985 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 2986 | |||
| 2987 | out.clearRetainingCapacity(); | ||
| 2988 | try out.append(alloc, refusal_sentinel); | ||
| 2989 | try appendTermTitle(&out, alloc, "vim\x7f"); | ||
| 2990 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 2991 | |||
| 2992 | // Empty is refused rather than written: `ESC]0;BEL` would CLEAR the | ||
| 2993 | // host terminal's title, and no daemon of any version has a reason to | ||
| 2994 | // ask for that. See sampleTermTitle for the daemon half of this policy. | ||
| 2995 | out.clearRetainingCapacity(); | ||
| 2996 | try out.append(alloc, refusal_sentinel); | ||
| 2997 | try appendTermTitle(&out, alloc, ""); | ||
| 2998 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 2999 | } | ||
| 3000 | |||
| 3001 | test "client: the title cap is a cap, not an off-by-one" { | ||
| 3002 | const alloc = std.testing.allocator; | ||
| 3003 | var out: std.ArrayList(u8) = .empty; | ||
| 3004 | defer out.deinit(alloc); | ||
| 3005 | |||
| 3006 | const at_cap = try alloc.alloc(u8, proto.term_title_max); | ||
| 3007 | defer alloc.free(at_cap); | ||
| 3008 | @memset(at_cap, 'x'); | ||
| 3009 | try appendTermTitle(&out, alloc, at_cap); | ||
| 3010 | // "\x1b]0;" is four bytes and the BEL is one. | ||
| 3011 | try std.testing.expectEqual(proto.term_title_max + 5, out.items.len); | ||
| 3012 | |||
| 3013 | const over = try alloc.alloc(u8, proto.term_title_max + 1); | ||
| 3014 | defer alloc.free(over); | ||
| 3015 | @memset(over, 'x'); | ||
| 3016 | out.clearRetainingCapacity(); | ||
| 3017 | try out.append(alloc, refusal_sentinel); | ||
| 3018 | try appendTermTitle(&out, alloc, over); | ||
| 3019 | try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items); | ||
| 3020 | } | ||
| 3021 | |||
| 3022 | test "client: the exit teardown unsets every mode mux turned on, and pops the title" { | ||
| 2860 | // A multiplexer that leaves your terminal in a mode it enabled is worse | 3023 | // A multiplexer that leaves your terminal in a mode it enabled is worse |
| 2861 | // than one that pastes badly, so the teardown string is pinned as a | 3024 | // than one that pastes badly, so the teardown string is pinned as a |
| 2862 | // literal rather than assembled from the constants it writes. | 3025 | // literal rather than assembled from the constants it writes. |
| 2863 | try std.testing.expectEqualStrings( | 3026 | try std.testing.expectEqualStrings( |
| 2864 | "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[?1049l", | 3027 | "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l", |
| 2865 | terminal_teardown, | 3028 | terminal_teardown, |
| 2866 | ); | 3029 | ); |
| 3030 | // The pop is worthless — worse, it pops a stranger's title — without | ||
| 3031 | // the push that pairs with it, and the two live far apart: the push is | ||
| 3032 | // a literal inside the frame loop's alt-screen entry. Pinned here | ||
| 3033 | // together so deleting either one fails, rather than quietly leaving | ||
| 3034 | // the terminal one push deep forever or one pop too many. | ||
| 3035 | try std.testing.expectEqualStrings( | ||
| 3036 | "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l", | ||
| 3037 | terminal_setup, | ||
| 3038 | ); | ||
| 2867 | } | 3039 | } |
| 2868 | 3040 | ||
| 2869 | test "client: the clipboard cap is a cap, not an off-by-one" { | 3041 | test "client: the clipboard cap is a cap, not an off-by-one" { |
src/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -357,6 +357,19 @@ pub const Engine = struct { | |||
| 357 | return self.term.modes.get(.bracketed_paste); | 357 | return self.term.modes.get(.bracketed_paste); |
| 358 | } | 358 | } |
| 359 | 359 | ||
| 360 | /// The window title the session set (OSC 0/2), or empty if it never | ||
| 361 | /// did. Sampled state, like `bracketedPaste`: the client mirrors it onto | ||
| 362 | /// the host terminal, which is the only thing with a title bar. Nothing | ||
| 363 | /// carried it before, which is why your terminal's title has been wrong | ||
| 364 | /// under mux since M2. | ||
| 365 | /// | ||
| 366 | /// Empty and "never set" are the same answer here, and callers treat | ||
| 367 | /// them the same: see `sampleTermTitle` in server.zig for why mux | ||
| 368 | /// declines to forward either. | ||
| 369 | pub fn title(self: *const Engine) []const u8 { | ||
| 370 | return self.term.getTitle() orelse ""; | ||
| 371 | } | ||
| 372 | |||
| 360 | /// Number of history (scrolled-off) rows above the viewport on the | 373 | /// Number of history (scrolled-off) rows above the viewport on the |
| 361 | /// active screen. Alt screens have no scrollback: returns 0. | 374 | /// active screen. Alt screens have no scrollback: returns 0. |
| 362 | pub fn historyRows(self: *const Engine) u32 { | 375 | pub fn historyRows(self: *const Engine) u32 { |
| @@ -701,6 +714,20 @@ test "Engine: bracketed paste is readable as sampled state" { | |||
| 701 | try std.testing.expect(!e.bracketedPaste()); | 714 | try std.testing.expect(!e.bracketedPaste()); |
| 702 | } | 715 | } |
| 703 | 716 | ||
| 717 | test "Engine: the title is readable as sampled state" { | ||
| 718 | const alloc = std.testing.allocator; | ||
| 719 | var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 720 | defer eng.deinit(); | ||
| 721 | |||
| 722 | try std.testing.expectEqualStrings("", eng.title()); | ||
| 723 | eng.feed("\x1b]0;hello\x07"); | ||
| 724 | try std.testing.expectEqualStrings("hello", eng.title()); | ||
| 725 | // OSC 2 is the same window-title operation as OSC 0 to this engine, and | ||
| 726 | // the second title replaces the first rather than stacking. | ||
| 727 | eng.feed("\x1b]2;there\x07"); | ||
| 728 | try std.testing.expectEqualStrings("there", eng.title()); | ||
| 729 | } | ||
| 730 | |||
| 704 | test "Engine: resize" { | 731 | test "Engine: resize" { |
| 705 | const alloc = std.testing.allocator; | 732 | const alloc = std.testing.allocator; |
| 706 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 733 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |
src/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -36,7 +36,7 @@ pub const MsgType = enum(u8) { | |||
| 36 | await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason | 36 | await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason |
| 37 | status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply) | 37 | status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply) |
| 38 | term_modes = 0x8d, // payload: u32 LE bitset; bit0 bracketed paste | 38 | term_modes = 0x8d, // payload: u32 LE bitset; bit0 bracketed paste |
| 39 | term_title = 0x8e, // payload: UTF-8 title bytes, possibly empty | 39 | term_title = 0x8e, // payload: UTF-8 title bytes, never empty (see term_title_max) |
| 40 | term_event = 0x8f, // payload: 1 byte kind ++ kind-specific bytes (see TermEvent) | 40 | term_event = 0x8f, // payload: 1 byte kind ++ kind-specific bytes (see TermEvent) |
| 41 | dump_reply = 0xff, // payload: requested dump bytes | 41 | dump_reply = 0xff, // payload: requested dump bytes |
| 42 | _, | 42 | _, |
| @@ -527,6 +527,18 @@ pub const TermEvent = union(Kind) { | |||
| 527 | /// change that default with it. | 527 | /// change that default with it. |
| 528 | pub const clipboard_base64_max: usize = 64 * 1024; | 528 | pub const clipboard_base64_max: usize = 64 * 1024; |
| 529 | 529 | ||
| 530 | /// The longest `term_title` payload either end will send or act on. A title | ||
| 531 | /// is a window decoration: anything longer is a bug, or an attempt to push | ||
| 532 | /// bytes down a channel nobody inspects on the way past. | ||
| 533 | /// | ||
| 534 | /// 1024 is not an independent choice — it is the length ghostty's own | ||
| 535 | /// handler already truncates a title to before it reaches `Terminal.title` | ||
| 536 | /// (stream_terminal.zig, `max_title_len`), so a title read off the engine | ||
| 537 | /// can never exceed it today. Both ends check it anyway: the peer is not | ||
| 538 | /// necessarily this version of muxd, and the engine's truncation is not | ||
| 539 | /// part of any contract mux is entitled to lean on. | ||
| 540 | pub const term_title_max: usize = 1024; | ||
| 541 | |||
| 530 | /// Appends into a caller-owned `ArrayList` rather than this file's usual | 542 | /// Appends into a caller-owned `ArrayList` rather than this file's usual |
| 531 | /// caller-owned fixed buffer (see `encodeAttachNamed` and kin): a clipboard | 543 | /// caller-owned fixed buffer (see `encodeAttachNamed` and kin): a clipboard |
| 532 | /// payload can run to `clipboard_base64_max` (64 KiB), and that is too much | 544 | /// payload can run to `clipboard_base64_max` (64 KiB), and that is too much |
| @@ -1494,6 +1506,21 @@ test "term_modes: reserved bits go out zero and come back ignored" { | |||
| 1494 | try std.testing.expectEqualSlices(u8, &.{ 0x01, 0x00, 0x00, 0xF0 }, &encodeTermModes(m)); | 1506 | try std.testing.expectEqualSlices(u8, &.{ 0x01, 0x00, 0x00, 0xF0 }, &encodeTermModes(m)); |
| 1495 | } | 1507 | } |
| 1496 | 1508 | ||
| 1509 | test "term_title round-trips and matches golden bytes" { | ||
| 1510 | const alloc = std.testing.allocator; | ||
| 1511 | var buf: std.ArrayList(u8) = .empty; | ||
| 1512 | defer buf.deinit(alloc); | ||
| 1513 | try appendFrame(&buf, alloc, .term_title, "vim"); | ||
| 1514 | // 0x8e type, LE len=3, then the title's bytes verbatim: the payload has | ||
| 1515 | // no structure of its own to encode or decode. | ||
| 1516 | try std.testing.expectEqualSlices( | ||
| 1517 | u8, | ||
| 1518 | &.{ 0x8e, 0x03, 0x00, 0x00, 0x00, 'v', 'i', 'm' }, | ||
| 1519 | buf.items, | ||
| 1520 | ); | ||
| 1521 | try std.testing.expectEqualStrings("vim", buf.items[5..]); | ||
| 1522 | } | ||
| 1523 | |||
| 1497 | test "term_modes: a payload not exactly four bytes is refused" { | 1524 | test "term_modes: a payload not exactly four bytes is refused" { |
| 1498 | try std.testing.expectError(error.BadPayload, decodeTermModes(&.{})); | 1525 | try std.testing.expectError(error.BadPayload, decodeTermModes(&.{})); |
| 1499 | try std.testing.expectError(error.BadPayload, decodeTermModes(&[_]u8{ 0x01, 0x00 })); | 1526 | try std.testing.expectError(error.BadPayload, decodeTermModes(&[_]u8{ 0x01, 0x00 })); |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -286,6 +286,12 @@ const Session = struct { | |||
| 286 | /// The terminal modes as last put on the wire, or null before the first | 286 | /// The terminal modes as last put on the wire, or null before the first |
| 287 | /// sample. Same discipline as mode_sent: what clients have been TOLD. | 287 | /// sample. Same discipline as mode_sent: what clients have been TOLD. |
| 288 | term_modes_sent: ?proto.TermModes = null, | 288 | term_modes_sent: ?proto.TermModes = null, |
| 289 | /// The window title as last put on the wire, or null before the first | ||
| 290 | /// one. Same "what clients have been TOLD" discipline as the two fields | ||
| 291 | /// above, but OWNED: the engine keeps one title buffer and rewrites it | ||
| 292 | /// in place on the next OSC 0, so a borrowed slice would compare the | ||
| 293 | /// new title against itself. Freed in both teardown paths. | ||
| 294 | title_sent: ?[]const u8 = null, | ||
| 289 | /// The session's command state machine (OSC 133). Seq-stamped copies of | 295 | /// The session's command state machine (OSC 133). Seq-stamped copies of |
| 290 | /// its transitions are what cmd_state/await_reply/status_reply carry. | 296 | /// its transitions are what cmd_state/await_reply/status_reply carry. |
| 291 | cmd: cmdmod.Tracker = .{}, | 297 | cmd: cmdmod.Tracker = .{}, |
| @@ -579,6 +585,7 @@ pub const Server = struct { | |||
| 579 | if (slot.* == null) continue; | 585 | if (slot.* == null) continue; |
| 580 | const s = &slot.*.?; | 586 | const s = &slot.*.?; |
| 581 | s.tracker.deinit(self.alloc); | 587 | s.tracker.deinit(self.alloc); |
| 588 | if (s.title_sent) |t| self.alloc.free(t); | ||
| 582 | s.pty.deinit(); | 589 | s.pty.deinit(); |
| 583 | s.eng.deinit(); | 590 | s.eng.deinit(); |
| 584 | } | 591 | } |
| @@ -708,11 +715,13 @@ pub const Server = struct { | |||
| 708 | const c = self.clients[i] orelse continue; | 715 | const c = self.clients[i] orelse continue; |
| 709 | if (c.session == si) self.dropClient(i); | 716 | if (c.session == si) self.dropClient(i); |
| 710 | } | 717 | } |
| 711 | // Teardown in deinit's order: tracker, pty, eng. Nulling the | 718 | // Teardown in deinit's order: tracker, title, pty, eng. |
| 712 | // slot is what frees the name for re-creation — under a new | 719 | // Nulling the slot is what frees the name for re-creation — |
| 713 | // epoch, so a client quoting this instance's seqs resyncs by | 720 | // under a new epoch, so a client quoting this instance's |
| 714 | // snapshot rather than being delta-served history it never saw. | 721 | // seqs resyncs by snapshot rather than being delta-served |
| 722 | // history it never saw. | ||
| 715 | s.tracker.deinit(self.alloc); | 723 | s.tracker.deinit(self.alloc); |
| 724 | if (s.title_sent) |t| self.alloc.free(t); | ||
| 716 | s.pty.deinit(); | 725 | s.pty.deinit(); |
| 717 | s.eng.deinit(); | 726 | s.eng.deinit(); |
| 718 | slot.* = null; | 727 | slot.* = null; |
| @@ -822,6 +831,7 @@ pub const Server = struct { | |||
| 822 | self.drainMarkEvents(si); | 831 | self.drainMarkEvents(si); |
| 823 | self.drainSideEvents(si); | 832 | self.drainSideEvents(si); |
| 824 | self.sampleTermModes(si); | 833 | self.sampleTermModes(si); |
| 834 | self.sampleTermTitle(si); | ||
| 825 | } | 835 | } |
| 826 | } | 836 | } |
| 827 | 837 | ||
| @@ -2177,6 +2187,38 @@ pub const Server = struct { | |||
| 2177 | } | 2187 | } |
| 2178 | } | 2188 | } |
| 2179 | 2189 | ||
| 2190 | /// Sample the session's window title and tell its clients when it | ||
| 2191 | /// changed. Same sampled-state discipline as `sampleTermModes`, and the | ||
| 2192 | /// same early return for the same reason: a title changes when you cd or | ||
| 2193 | /// start an editor, not per chunk. | ||
| 2194 | /// | ||
| 2195 | /// An empty title is NOT sent, and that is the whole of mux's policy on | ||
| 2196 | /// clearing. `ESC]0;BEL` on the client would wipe whatever the user's | ||
| 2197 | /// own terminal had in its title bar, and a session that never set a | ||
| 2198 | /// title has said nothing that entitles mux to do that — silence is not | ||
| 2199 | /// "set it to empty". The consequence accepted: a session that sets a | ||
| 2200 | /// title and then genuinely clears it leaves the last one standing. | ||
| 2201 | /// `sendResync` applies the same two rules; they must agree, or an | ||
| 2202 | /// attach would assert something the sampler would never have sent. | ||
| 2203 | fn sampleTermTitle(self: *Server, si: usize) void { | ||
| 2204 | const s = self.ses(si); | ||
| 2205 | const now = s.eng.title(); | ||
| 2206 | if (now.len == 0 or now.len > proto.term_title_max) return; | ||
| 2207 | if (s.title_sent) |sent| { | ||
| 2208 | if (std.mem.eql(u8, sent, now)) return; | ||
| 2209 | } | ||
| 2210 | // Duped before anything is sent, and the old one freed only once the | ||
| 2211 | // new one exists: an OOM here leaves the session claiming to have | ||
| 2212 | // sent what it did send, so the next sample retries rather than | ||
| 2213 | // recording a title no client ever saw. | ||
| 2214 | const owned = self.alloc.dupe(u8, now) catch return; | ||
| 2215 | if (s.title_sent) |old| self.alloc.free(old); | ||
| 2216 | s.title_sent = owned; | ||
| 2217 | for (0..max_clients) |i| { | ||
| 2218 | if (self.inSession(i, si)) _ = self.queueFrame(i, .term_title, owned); | ||
| 2219 | } | ||
| 2220 | } | ||
| 2221 | |||
| 2180 | /// The current command state as a wire struct. `mechanism` is the | 2222 | /// The current command state as a wire struct. `mechanism` is the |
| 2181 | /// caller's claim about how the verdict was reached: marks pushes say | 2223 | /// caller's claim about how the verdict was reached: marks pushes say |
| 2182 | /// .marks; await resolutions say what actually resolved them. | 2224 | /// .marks; await resolutions say what actually resolved them. |
| @@ -2342,15 +2384,26 @@ pub const Server = struct { | |||
| 2342 | /// seq 900 from a daemon that has since been restarted would be told | 2384 | /// seq 900 from a daemon that has since been restarted would be told |
| 2343 | /// "you are current" against a session it has never seen a byte of. | 2385 | /// "you are current" against a session it has never seen a byte of. |
| 2344 | fn sendResync(self: *Server, si: usize, i: usize, have_seq: u64, have_epoch: u64, size_changed: bool) void { | 2386 | fn sendResync(self: *Server, si: usize, i: usize, have_seq: u64, have_epoch: u64, size_changed: bool) void { |
| 2345 | // Unconditional, after whichever content this call ends up sending. | 2387 | // After whichever content this call ends up sending. Modes and the |
| 2346 | // Modes are state, not history: a client returning to a session must | 2388 | // title are state, not history: a client returning to a session must |
| 2347 | // be told what is true now, and one that got a full snapshot needs it | 2389 | // be told what is true now, and one that got a full snapshot needs it |
| 2348 | // exactly as much as one that got a delta. Deferred rather than | 2390 | // exactly as much as one that got a delta. Deferred rather than |
| 2349 | // written out at each of the three returns below so that a fourth | 2391 | // written out at each of the three returns below so that a fourth |
| 2350 | // cannot be added without it. | 2392 | // cannot be added without them. |
| 2351 | defer _ = self.queueFrame(i, .term_modes, &proto.encodeTermModes( | 2393 | defer { |
| 2352 | .{ .bracketed_paste = self.ses(si).eng.bracketedPaste() }, | 2394 | _ = self.queueFrame(i, .term_modes, &proto.encodeTermModes( |
| 2353 | )); | 2395 | .{ .bracketed_paste = self.ses(si).eng.bracketedPaste() }, |
| 2396 | )); | ||
| 2397 | // Not unconditional, unlike the modes: empty and over-cap are | ||
| 2398 | // both refused here exactly as `sampleTermTitle` refuses them, | ||
| 2399 | // and for its reasons. Borrowing the engine's buffer is safe | ||
| 2400 | // because `queueFrame` copies into the client's pending bytes | ||
| 2401 | // before returning. | ||
| 2402 | const t = self.ses(si).eng.title(); | ||
| 2403 | if (t.len > 0 and t.len <= proto.term_title_max) { | ||
| 2404 | _ = self.queueFrame(i, .term_title, t); | ||
| 2405 | } | ||
| 2406 | } | ||
| 2354 | if (size_changed) { | 2407 | if (size_changed) { |
| 2355 | self.resyncSnapshot(si); | 2408 | self.resyncSnapshot(si); |
| 2356 | return; | 2409 | return; |
| @@ -7762,6 +7815,236 @@ test "Server: a joiner that resizes the grid is still told the session's modes" | |||
| 7762 | try std.testing.expect(b_modes orelse return error.NoTermModesOnResizingJoin); | 7815 | try std.testing.expect(b_modes orelse return error.NoTermModesOnResizingJoin); |
| 7763 | } | 7816 | } |
| 7764 | 7817 | ||
| 7818 | test "Server: a window title reaches clients on change, and only on change" { | ||
| 7819 | const alloc = std.testing.allocator; | ||
| 7820 | |||
| 7821 | var tmp = try TmpDir.make(); | ||
| 7822 | defer tmp.cleanup(); | ||
| 7823 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/title.sock", .{tmp.path()}); | ||
| 7824 | defer alloc.free(sock_path); | ||
| 7825 | |||
| 7826 | try tmp.dir.writeFile(.{ | ||
| 7827 | .sub_path = "title.sh", | ||
| 7828 | // The leading `read` is what makes this test about the SAMPLER. A | ||
| 7829 | // shell that printed its title immediately would race the attach: | ||
| 7830 | // the title would already exist when sendResync ran, and the frame | ||
| 7831 | // this test waits for could be the resync's. Gating the printf on | ||
| 7832 | // input the daemon can only forward to an attached client puts the | ||
| 7833 | // client there first, so nothing but the sampler can send it. | ||
| 7834 | .data = | ||
| 7835 | \\#!/bin/sh | ||
| 7836 | \\read -r go | ||
| 7837 | \\printf '\033]0;first\007' | ||
| 7838 | \\read -r go2 | ||
| 7839 | \\printf 'output that sets no title' | ||
| 7840 | \\read -r go3 | ||
| 7841 | \\printf '\033]0;second\007' | ||
| 7842 | \\exec sleep 30 | ||
| 7843 | \\ | ||
| 7844 | , | ||
| 7845 | .flags = .{ .mode = 0o755 }, | ||
| 7846 | }); | ||
| 7847 | const script = try std.fmt.allocPrintSentinel(alloc, "{s}/title.sh", .{tmp.path()}, 0); | ||
| 7848 | defer alloc.free(script); | ||
| 7849 | |||
| 7850 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script }); | ||
| 7851 | defer srv.deinit(); | ||
| 7852 | |||
| 7853 | const c = try std.net.connectUnixSocket(sock_path); | ||
| 7854 | defer c.close(); | ||
| 7855 | try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 7856 | |||
| 7857 | // Queued behind the attach on the same socket, so the daemon has taken | ||
| 7858 | // the attach before it can forward this — see the script's leading read. | ||
| 7859 | try proto.writeFrame(c.handle, .input, "go\n"); | ||
| 7860 | const first = (try awaitFrame(alloc, &srv, c.handle, .term_title, 400)) orelse | ||
| 7861 | return error.NoTitleFrame; | ||
| 7862 | defer first.deinit(alloc); | ||
| 7863 | try std.testing.expectEqualStrings("first", first.payload); | ||
| 7864 | |||
| 7865 | // More pty output with the title unchanged. The delta proves a chunk was | ||
| 7866 | // digested — without it the "no second frame" half would pass vacuously | ||
| 7867 | // on a session that simply never spoke again. | ||
| 7868 | try proto.writeFrame(c.handle, .input, "go\n"); | ||
| 7869 | var saw_content = false; | ||
| 7870 | var resent: usize = 0; | ||
| 7871 | var after: usize = 0; | ||
| 7872 | var i: usize = 0; | ||
| 7873 | while (i < 400 and after < 60) : (i += 1) { | ||
| 7874 | if (saw_content) after += 1; | ||
| 7875 | _ = try srv.pumpOnce(5); | ||
| 7876 | var pfd = [_]std.posix.pollfd{ | ||
| 7877 | .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 7878 | }; | ||
| 7879 | if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue; | ||
| 7880 | const f = (try proto.readFrame(alloc, c.handle)) orelse break; | ||
| 7881 | defer f.deinit(alloc); | ||
| 7882 | switch (f.type) { | ||
| 7883 | .delta, .snapshot => saw_content = true, | ||
| 7884 | .term_title => resent += 1, | ||
| 7885 | else => {}, | ||
| 7886 | } | ||
| 7887 | } | ||
| 7888 | try std.testing.expect(saw_content); | ||
| 7889 | try std.testing.expectEqual(@as(usize, 0), resent); | ||
| 7890 | |||
| 7891 | // A DIFFERENT title does travel: the early return is a change filter, | ||
| 7892 | // not a one-title-per-session latch. | ||
| 7893 | try proto.writeFrame(c.handle, .input, "go\n"); | ||
| 7894 | const second = (try awaitFrame(alloc, &srv, c.handle, .term_title, 400)) orelse | ||
| 7895 | return error.NoSecondTitleFrame; | ||
| 7896 | defer second.deinit(alloc); | ||
| 7897 | try std.testing.expectEqualStrings("second", second.payload); | ||
| 7898 | } | ||
| 7899 | |||
| 7900 | test "Server: a session that never set a title has none sent for it" { | ||
| 7901 | const alloc = std.testing.allocator; | ||
| 7902 | |||
| 7903 | var tmp = try TmpDir.make(); | ||
| 7904 | defer tmp.cleanup(); | ||
| 7905 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/notitle.sock", .{tmp.path()}); | ||
| 7906 | defer alloc.free(sock_path); | ||
| 7907 | |||
| 7908 | try tmp.dir.writeFile(.{ | ||
| 7909 | .sub_path = "notitle.sh", | ||
| 7910 | .data = | ||
| 7911 | \\#!/bin/sh | ||
| 7912 | \\printf 'plain output, no OSC 0 anywhere' | ||
| 7913 | \\exec sleep 30 | ||
| 7914 | \\ | ||
| 7915 | , | ||
| 7916 | .flags = .{ .mode = 0o755 }, | ||
| 7917 | }); | ||
| 7918 | const script = try std.fmt.allocPrintSentinel(alloc, "{s}/notitle.sh", .{tmp.path()}, 0); | ||
| 7919 | defer alloc.free(script); | ||
| 7920 | |||
| 7921 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script }); | ||
| 7922 | defer srv.deinit(); | ||
| 7923 | |||
| 7924 | // Two attaches, because the two paths that could send an empty title are | ||
| 7925 | // different code: the sampler (driven by the pty output below) and the | ||
| 7926 | // resync (driven by the second attach). An empty title on either would | ||
| 7927 | // make the client write ESC]0;BEL and wipe the title bar of a terminal | ||
| 7928 | // whose session has said nothing whatsoever about titles. | ||
| 7929 | const a = try std.net.connectUnixSocket(sock_path); | ||
| 7930 | defer a.close(); | ||
| 7931 | try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 7932 | |||
| 7933 | var saw_content = false; | ||
| 7934 | var titles: usize = 0; | ||
| 7935 | var after: usize = 0; | ||
| 7936 | var joined = false; | ||
| 7937 | var b: ?std.net.Stream = null; | ||
| 7938 | defer if (b) |s| s.close(); | ||
| 7939 | var i: usize = 0; | ||
| 7940 | while (i < 500 and after < 80) : (i += 1) { | ||
| 7941 | if (saw_content) { | ||
| 7942 | // The joiner goes in only once the shell's output has landed, so | ||
| 7943 | // its resync reads a session that has genuinely run. | ||
| 7944 | if (!joined) { | ||
| 7945 | b = try std.net.connectUnixSocket(sock_path); | ||
| 7946 | try proto.writeFrame(b.?.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 7947 | joined = true; | ||
| 7948 | } | ||
| 7949 | after += 1; | ||
| 7950 | } | ||
| 7951 | _ = try srv.pumpOnce(5); | ||
| 7952 | var pfds = [_]std.posix.pollfd{ | ||
| 7953 | .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 7954 | .{ .fd = if (b) |s| s.handle else a.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 7955 | }; | ||
| 7956 | if ((std.posix.poll(&pfds, 1) catch 0) == 0) continue; | ||
| 7957 | for (pfds, 0..) |pfd, n| { | ||
| 7958 | if (n == 1 and b == null) continue; | ||
| 7959 | if (pfd.revents & std.posix.POLL.IN == 0) continue; | ||
| 7960 | const f = (try proto.readFrame(alloc, pfd.fd)) orelse continue; | ||
| 7961 | defer f.deinit(alloc); | ||
| 7962 | switch (f.type) { | ||
| 7963 | .delta, .snapshot => saw_content = true, | ||
| 7964 | .term_title => titles += 1, | ||
| 7965 | else => {}, | ||
| 7966 | } | ||
| 7967 | } | ||
| 7968 | } | ||
| 7969 | try std.testing.expect(saw_content); | ||
| 7970 | try std.testing.expect(joined); | ||
| 7971 | try std.testing.expectEqual(@as(usize, 0), titles); | ||
| 7972 | } | ||
| 7973 | |||
| 7974 | test "Server: a joiner that resizes the grid is still told the session's title" { | ||
| 7975 | const alloc = std.testing.allocator; | ||
| 7976 | |||
| 7977 | var tmp = try TmpDir.make(); | ||
| 7978 | defer tmp.cleanup(); | ||
| 7979 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/titleresize.sock", .{tmp.path()}); | ||
| 7980 | defer alloc.free(sock_path); | ||
| 7981 | |||
| 7982 | try tmp.dir.writeFile(.{ | ||
| 7983 | .sub_path = "titleresize.sh", | ||
| 7984 | .data = | ||
| 7985 | \\#!/bin/sh | ||
| 7986 | \\printf '\033]0;vim\007' | ||
| 7987 | \\exec sleep 30 | ||
| 7988 | \\ | ||
| 7989 | , | ||
| 7990 | .flags = .{ .mode = 0o755 }, | ||
| 7991 | }); | ||
| 7992 | const script = try std.fmt.allocPrintSentinel(alloc, "{s}/titleresize.sh", .{tmp.path()}, 0); | ||
| 7993 | defer alloc.free(script); | ||
| 7994 | |||
| 7995 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script }); | ||
| 7996 | defer srv.deinit(); | ||
| 7997 | |||
| 7998 | // The first client latches title_sent, after which sampleTermTitle can | ||
| 7999 | // never fire again for this title and everything below is the resync's | ||
| 8000 | // doing alone. | ||
| 8001 | const a = try std.net.connectUnixSocket(sock_path); | ||
| 8002 | defer a.close(); | ||
| 8003 | try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 8004 | const latched = (try awaitFrame(alloc, &srv, a.handle, .term_title, 400)) orelse | ||
| 8005 | return error.NoTitleFrame; | ||
| 8006 | defer latched.deinit(alloc); | ||
| 8007 | try std.testing.expectEqualStrings("vim", latched.payload); | ||
| 8008 | |||
| 8009 | // The third arm of sendResync: a joiner at a DIFFERENT size returns early | ||
| 8010 | // through resyncSnapshot, before the delta/snapshot split. Left bare it | ||
| 8011 | // is a real regression and a quiet one — the title bar would silently | ||
| 8012 | // stop matching the session after any window resize. | ||
| 8013 | const b = try std.net.connectUnixSocket(sock_path); | ||
| 8014 | defer b.close(); | ||
| 8015 | try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(100, 30, 0, 0)); | ||
| 8016 | |||
| 8017 | // A's re-snapshot at B's size is the witness that this arm ran at all: | ||
| 8018 | // resyncSnapshot broadcasts, and the other two arms send to the joiner | ||
| 8019 | // alone. | ||
| 8020 | var a_resnapshotted = false; | ||
| 8021 | var b_title: ?[]const u8 = null; | ||
| 8022 | defer if (b_title) |t| alloc.free(t); | ||
| 8023 | var i: usize = 0; | ||
| 8024 | while (i < 400 and !(a_resnapshotted and b_title != null)) : (i += 1) { | ||
| 8025 | _ = try srv.pumpOnce(5); | ||
| 8026 | var pfds = [_]std.posix.pollfd{ | ||
| 8027 | .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 8028 | .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 8029 | }; | ||
| 8030 | if ((std.posix.poll(&pfds, 1) catch 0) == 0) continue; | ||
| 8031 | for (pfds) |pfd| { | ||
| 8032 | if (pfd.revents & std.posix.POLL.IN == 0) continue; | ||
| 8033 | const f = (try proto.readFrame(alloc, pfd.fd)) orelse continue; | ||
| 8034 | defer f.deinit(alloc); | ||
| 8035 | if (pfd.fd == a.handle and f.type == .snapshot) { | ||
| 8036 | const p = try proto.readSnapshotPrefix(f.payload); | ||
| 8037 | if (p.cols == 100 and p.rows == 30) a_resnapshotted = true; | ||
| 8038 | } | ||
| 8039 | if (pfd.fd == b.handle and f.type == .term_title and b_title == null) { | ||
| 8040 | b_title = try alloc.dupe(u8, f.payload); | ||
| 8041 | } | ||
| 8042 | } | ||
| 8043 | } | ||
| 8044 | try std.testing.expect(a_resnapshotted); | ||
| 8045 | try std.testing.expectEqualStrings("vim", b_title orelse return error.NoTitleOnResizingJoin); | ||
| 8046 | } | ||
| 8047 | |||
| 7765 | /// Pump until a resync's term_modes lands on `fd`, reporting the content | 8048 | /// Pump until a resync's term_modes lands on `fd`, reporting the content |
| 7766 | /// frame that came with it. Separate from awaitFrame because the question is | 8049 | /// frame that came with it. Separate from awaitFrame because the question is |
| 7767 | /// about a PAIR — which branch ran, and what it said about the modes — and | 8050 | /// about a PAIR — which branch ran, and what it said about the modes — and |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -2748,6 +2748,69 @@ grep -qaF "$CLIPESC" "$OUT.clip" || { | |||
| 2748 | rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" | 2748 | rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" |
| 2749 | ok "the session's OSC 52 reaches the host terminal" | 2749 | ok "the session's OSC 52 reaches the host terminal" |
| 2750 | 2750 | ||
| 2751 | # --- side channel: the session's window title reaches the host tty ------- | ||
| 2752 | # | ||
| 2753 | # The session sets its title with OSC **2** and the assertion below looks | ||
| 2754 | # for OSC **0**. That asymmetry is the instrument: a client that forwarded | ||
| 2755 | # session bytes would put back exactly what went in, so a capture holding | ||
| 2756 | # `ESC]0;` for a session that only ever wrote `ESC]2;` can only be the | ||
| 2757 | # client re-rendering sampled state (client.zig, appendTermTitle). Both | ||
| 2758 | # forms reach the engine as one window-title operation, which is why the | ||
| 2759 | # session is free to pick the one mux does not emit. | ||
| 2760 | # | ||
| 2761 | # Emitted by a FILE, never typed, for the M12 reason spelled out on the | ||
| 2762 | # clipboard leg above: the shell echoes what is typed, so a needle that | ||
| 2763 | # could arrive as an echo would pass on a client that forwards nothing. | ||
| 2764 | cat > "$OUT.title.sh" <<'TITLESH' | ||
| 2765 | printf '\033]2;mux-e2e-title\007' | ||
| 2766 | printf 'TITLEDONE\n' | ||
| 2767 | TITLESH | ||
| 2768 | set +e | ||
| 2769 | timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.title" --err "$OUT.title.err" \ | ||
| 2770 | -- "$MUX" --sock "$SOCK" > "$OUT.title.log" 2>&1 <<EOF | ||
| 2771 | expect \x1b[?1049h 15000 | ||
| 2772 | settle 400 15000 | ||
| 2773 | send sh $OUT.title.sh\n | ||
| 2774 | expect TITLEDONE 15000 | ||
| 2775 | settle 400 15000 | ||
| 2776 | send \x1c | ||
| 2777 | waitexit 10000 | ||
| 2778 | EOF | ||
| 2779 | RC=$? | ||
| 2780 | set -e | ||
| 2781 | [ "$RC" -eq 0 ] || { | ||
| 2782 | echo "e2e FAIL: title scenario did not run: ptyclient exited $RC" | ||
| 2783 | cat "$OUT.title.log"; cat -v "$OUT.title.err" 2>/dev/null; exit 1; } | ||
| 2784 | # Positive control: the marker travels the ordinary grid path, so without it | ||
| 2785 | # "no title" and "no session" would be the same failure line. | ||
| 2786 | grep -qa 'TITLEDONE' "$OUT.title" || { | ||
| 2787 | echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; } | ||
| 2788 | # Terminator included, for the same reason as the clipboard needle: the | ||
| 2789 | # builder writes the escape whole or writes nothing, and a needle stopping | ||
| 2790 | # at the payload could not tell one from half of one. | ||
| 2791 | TITLEESC=$(printf '\033]0;mux-e2e-title\007') | ||
| 2792 | grep -qaF "$TITLEESC" "$OUT.title" || { | ||
| 2793 | echo "e2e FAIL: the window title never reached the host tty"; exit 1; } | ||
| 2794 | # The restore pair, asserted by ORDER and not merely by presence. Presence | ||
| 2795 | # alone would pass on a client that popped before it pushed, or pushed twice | ||
| 2796 | # and popped once — and an unmatched pop does not restore a title, it pops | ||
| 2797 | # whatever the terminal had underneath, which is somebody else's. The | ||
| 2798 | # offsets say: pushed before mux set anything, popped after. | ||
| 2799 | title_off() { | ||
| 2800 | grep -aboF "$2" "$1" | head -1 | cut -d: -f1 | ||
| 2801 | } | ||
| 2802 | PUSH_AT=$(title_off "$OUT.title" "$(printf '\033[22;0t')") | ||
| 2803 | POP_AT=$(title_off "$OUT.title" "$(printf '\033[23;0t')") | ||
| 2804 | SET_AT=$(title_off "$OUT.title" "$TITLEESC") | ||
| 2805 | [ -n "$PUSH_AT" ] || { echo "e2e FAIL: no title push on the host tty"; exit 1; } | ||
| 2806 | [ -n "$POP_AT" ] || { echo "e2e FAIL: no title pop on the host tty (exit left it set)"; exit 1; } | ||
| 2807 | [ "$PUSH_AT" -lt "$SET_AT" ] || { | ||
| 2808 | echo "e2e FAIL: mux set the title at $SET_AT before pushing at $PUSH_AT"; exit 1; } | ||
| 2809 | [ "$SET_AT" -lt "$POP_AT" ] || { | ||
| 2810 | echo "e2e FAIL: the title pop at $POP_AT came before the set at $SET_AT"; exit 1; } | ||
| 2811 | rm_swept "$OUT.title" "$OUT.title.err" "$OUT.title.log" "$OUT.title.sh" | ||
| 2812 | ok "the window title reaches the host as OSC 0, pushed before and popped after" | ||
| 2813 | |||
| 2751 | # --- side channel: a paste into a real editor keeps its indentation ------ | 2814 | # --- side channel: a paste into a real editor keeps its indentation ------ |
| 2752 | # | 2815 | # |
| 2753 | # The byte-level pin (?2004h in a host capture) proves the FRAME arrived; | 2816 | # The byte-level pin (?2004h in a host capture) proves the FRAME arrived; |
| @@ -3860,7 +3923,7 @@ DPID="" | |||
| 3860 | 3923 | ||
| 3861 | # The pins. Literals, not variables set from counting something else — | 3924 | # The pins. Literals, not variables set from counting something else — |
| 3862 | # "assert the literal, never the constant the code under test reads" | 3925 | # "assert the literal, never the constant the code under test reads" |
| 3863 | # (decisions.md, M10). 28 scenario checkpoints; 35 convergence points. | 3926 | # (decisions.md, M10). 29 scenario checkpoints; 35 convergence points. |
| 3864 | # Anyone adding a scenario updates these by hand, on purpose. | 3927 | # Anyone adding a scenario updates these by hand, on purpose. |
| 3865 | # | 3928 | # |
| 3866 | # M18 added three checkpoints and no convergence points: its wall block | 3929 | # M18 added three checkpoints and no convergence points: its wall block |
| @@ -3870,9 +3933,11 @@ DPID="" | |||
| 3870 | # meaning anything. The side-channel leg added the 27th and no convergence | 3933 | # meaning anything. The side-channel leg added the 27th and no convergence |
| 3871 | # point: its capture is asserted on for a byte the grid does not carry. The | 3934 | # point: its capture is asserted on for a byte the grid does not carry. The |
| 3872 | # paste leg added the 28th and no convergence point either: what it asserts | 3935 | # paste leg added the 28th and no convergence point either: what it asserts |
| 3873 | # on is a file an editor wrote, not a grid at all. | 3936 | # on is a file an editor wrote, not a grid at all. The window-title leg |
| 3874 | [ "$OK_COUNT" = "28" ] || { | 3937 | # added the 29th, and no convergence point for the side-channel reason |
| 3875 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 28 —" | 3938 | # again: it asserts on a capture, for bytes no grid carries. |
| 3939 | [ "$OK_COUNT" = "29" ] || { | ||
| 3940 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 29 —" | ||
| 3876 | echo " a scenario was added (update the pin) or silently lost" | 3941 | echo " a scenario was added (update the pin) or silently lost" |
| 3877 | exit 1 | 3942 | exit 1 |
| 3878 | } | 3943 | } |
| @@ -3880,4 +3945,4 @@ DPID="" | |||
| 3880 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" | 3945 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" |
| 3881 | exit 1 | 3946 | exit 1 |
| 3882 | } | 3947 | } |
| 3883 | echo "e2e OK (28 scenarios, 35 convergence points)" | 3948 | echo "e2e OK (29 scenarios, 35 convergence points)" |