a73x

2de33178

feat: carry the session's window title to the host terminal

a73x   2026-08-15 18:28

Commit message
feat: carry the session's window title to the host terminal

Sampled state on the mechanism Task 7 built, which is why this is one
getter, one sampler and one client arm. Your title bar has been wrong
under mux since M2 because nothing carried it.

An empty title is never sent, on either path. `Engine.getTitle()` returns
null for a session that never set one, and forwarding that as `ESC]0;BEL`
would CLEAR the user's own title bar on every attach to a fresh shell.
Silence is not "set it to empty". The consequence accepted: a session that
sets a title and then genuinely clears it leaves the last one standing.
Both the sampler and sendResync's defer apply that rule and the length cap
together, because a resync asserting something the sampler would never
have sent is a difference nobody would think to look for.

The title is refused if it carries a byte that would end the escape early
— the remainder would paint on the user's screen as text.

Restore-on-detach rather than tmux's leave-it-set, because the question was
answered: the operator ran the push/set/pop probe in a bare Alacritty
window and the title returned (217183c). `22;0t` leads the alt-screen
entry, `23;0t` sits second to last in the teardown, both under the same
gate so the pair is exactly one deep. Second to last and not last because
the pty capture's doctored control depends on `?1049l` being the final
bytes a tty client writes — measured, by breaking it.

Three byte-identical frame arms was one too many: they collapse into
writeSideChannel, which is where the "outside the sync bracket" reasoning
now lives. The builders stay separate; their validation genuinely differs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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)"