a73x

18c4c7f5

fix: tp2b syncs on unique markers and silence — sentinel counts have no order under split deltas

a73x   2026-08-10 11:30

Commit message
fix: tp2b syncs on unique markers and silence — sentinel counts have no order under split deltas

The loaded gate found both directions of the same flaw. A prompt-row
sentinel that arrives too late gets consumed by the output needle that
precedes it in the script, and the next sentinel expect hangs; one that
arrives too early lets the client detach mid-repaint, leaving a capture
in the old geometry — a diff byte-identical to row 7's, so a healthy
build fails wearing the bug's own signature.

Sentinel occurrences are repaints of one row, and once a scrolling repaint
splits across deltas the rows have no reliable order. So tp2b now uses
unique output markers for progress and a new fixture verb, settle, for
quiesce: it waits for the master to go silent, which names no row and so
cannot be raced by one. tp2a keeps its PS1 doubling — safe only because
nothing scrolls there — and gains a settle before its detach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

test/e2e.sh
Old New
@@ -1654,11 +1654,18 @@ send PS1=tp2rdy@\n
1654 # Twice, and the fixture's cursor is what makes that meaningful: the first 1654 # Twice, and the fixture's cursor is what makes that meaningful: the first
1655 # hit is the ECHO of the assignment line, the second is the prompt the 1655 # hit is the ECHO of the assignment line, the second is the prompt the
1656 # shell painted from it. Only the second proves PS1 took effect. 1656 # shell painted from it. Only the second proves PS1 took effect.
1657 #
1658 # Counting sentinel occurrences is safe HERE and nowhere else in tp2: this
1659 # grid is nearly empty, so nothing scrolls, no repaint splits across
1660 # deltas, and the two hits keep the order they were painted in. tp2b runs
1661 # on a full screen and cannot assume any of that — see the hazard note
1662 # there before copying this shape.
1657 expect tp2rdy@ 10000 1663 expect tp2rdy@ 10000
1658 expect tp2rdy@ 10000 1664 expect tp2rdy@ 10000
1659 send printf 'tp2-%s\\n' claim\n 1665 send printf 'tp2-%s\\n' claim\n
1660 expect tp2-claim 10000 1666 expect tp2-claim 10000
1661 expect tp2rdy@ 10000 1667 expect tp2rdy@ 10000
1668 settle 500 15000
1662 send \x1c 1669 send \x1c
1663 waitexit 10000 1670 waitexit 10000
1664 EOF 1671 EOF
@@ -1681,48 +1688,42 @@ assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30
1681 # trailing prompt is consumed BEFORE the resize. That leaves the expect 1688 # trailing prompt is consumed BEFORE the resize. That leaves the expect
1682 # after `resize` satisfiable only by the answering snapshot's repaint. 1689 # after `resize` satisfiable only by the answering snapshot's repaint.
1683 # 1690 #
1684 # THE HAZARD, which cost a 1-in-21 hang before it was understood: on a 1691 # THE HAZARD, and it is worth stating precisely because three shapes of
1685 # FULL screen the output scrolls, and the daemon can flush the new prompt 1692 # this scenario died to it: on a FULL screen the output scrolls, and the
1686 # row in one delta and the scrolled rows in a LATER one. A sentinel can 1693 # daemon can flush the new prompt row in one delta and the scrolled rows
1687 # therefore reach the byte stream ~600 bytes BEFORE an output needle that 1694 # in a LATER one. Prompt-row sentinels therefore have NO reliable order
1688 # precedes it in this script. Any `expect NEEDLE` followed by `expect 1695 # relative to the output rows around them, and counting their occurrences
1689 # SENTINEL` can then deadlock: the needle's match consumes the cursor past 1696 # fails in both directions. Too late: the sentinel's bytes arrive before
1690 # the sentinel, and the sentinel expect waits on a shell that has gone 1697 # the output needle that precedes it in the script, the needle's match
1691 # idle — a hang, on a client whose grid was right all along. So no expect 1698 # consumes the cursor past them, and the next sentinel expect waits on a
1692 # here may assume that one ROW reached the stream before another. 1699 # shell that has gone idle — a hang. Too early: a post-resize sentinel
1700 # matches bytes from BEFORE the resize and the client detaches mid-repaint,
1701 # leaving a capture in the old geometry. Measured under load, that second
1702 # one is the dangerous one: it produces a diff byte-identical to row 7's
1703 # — missing rows and a still-wrapped wide row — so a healthy build fails
1704 # wearing the bug's own signature.
1693 # 1705 #
1694 # What this scenario must NOT do to fix that is replace `expect 00007` 1706 # The immune forms, and the only ones used below. UNIQUE OUTPUT MARKERS
1695 # with a second sentinel. Measured against the row-7 mutation: 00007 is 1707 # (`fill-done`, `00007`) for progress: each is printed once, by a command
1696 # 10/10 red, doubled sentinels 1/10. The reason is that a sentinel is 1708 # whose typed line cannot contain it, so its arrival is a fact about the
1697 # satisfied by any repaint of the prompt row, including the ones the 1709 # session rather than about paint order. And SILENCE for quiesce: `settle`
1698 # scroll itself emits, so the resize fires before the wide row's deltas 1710 # waits for the master to stop changing, which names no row at all and so
1699 # have landed and the later deltas — which bypass the replica — heal the 1711 # cannot be raced by one. Occurrence-counting of a REPAINTED row — which
1700 # damage before the repaint can expose it. `expect 00007` is what holds 1712 # is what a prompt sentinel is — is not a substitute for either.
1701 # the resize until the wide row is actually ON the screen it will be
1702 # repainted from.
1703 # 1713 #
1704 # The post-resize needle is 91 zeros, and it is a WIDTH WITNESS rather 1714 # `expect 00007` earns its place as more than a marker: its arrival proves
1705 # than a sync point: at 90 columns the wide row is painted wrapped, so the 1715 # the daemon processed the wide row while the grid was still 90 columns,
1706 # longest contiguous run the client can emit is 90, and only a paint at 1716 # which is what puts the damage on the screen the resize will repaint from.
1707 # the reflowed width can produce 91. That makes it immune to the hazard
1708 # above (it cannot have arrived before the resize, whatever order the rows
1709 # came in) and a discriminator in its own right — under the mutation it
1710 # never arrives and the leg fails on the fixture's timeout in ~10s rather
1711 # than on the convergence diff. The trailing sentinel is safe where it now
1712 # sits: a repaint emits its rows top to bottom, so the prompt row follows
1713 # the wide row within the very paint the witness just matched.
1714 set +e 1717 set +e
1715 timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ 1718 timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \
1716 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' 1719 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF'
1717 expect \x1b[?1049h 10000 1720 expect \x1b[?1049h 10000
1718 send seq 1 60; printf 'fill-%s\\n' done\n 1721 send seq 1 60; printf 'fill-%s\\n' done\n
1719 expect fill-done 10000 1722 expect fill-done 10000
1720 expect tp2rdy@ 10000
1721 send printf '%095d\\n' 7\n 1723 send printf '%095d\\n' 7\n
1722 expect 00007 10000 1724 expect 00007 10000
1723 resize 100 30 1725 resize 100 30
1724 expect 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 10000 1726 settle 500 15000
1725 expect tp2rdy@ 10000
1726 send \x1c 1727 send \x1c
1727 waitexit 10000 1728 waitexit 10000
1728 EOF 1729 EOF
@@ -1731,16 +1732,14 @@ set -e
1731 [ "$RC" -eq 0 ] || { 1732 [ "$RC" -eq 0 ] || {
1732 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } 1733 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; }
1733 # Graded against the mutation (row 7: the prefix resize commented out), 1734 # Graded against the mutation (row 7: the prefix resize commented out),
1734 # 10 runs out of 10 red. What the mutated build does now is fail ABOVE, 1735 # and this is where it dies — `settle` waits for the repaint rather than
1735 # on the width witness: the 91-zero needle never arrives, so the fixture 1736 # racing it, so the capture faithfully records what the stale geometry
1736 # times out at that verb and the RC check fires. Before the witness 1737 # painted, and both halves of it land in one diff: the 30-row grid comes
1737 # existed the same build reached this line and failed here instead, with 1738 # back short its top rows, because a 28-row replica has only 28 rows to
1738 # both halves of the stale geometry in one diff — the 30-row grid coming 1739 # repaint from, and the 95-wide row comes back still WRAPPED at 90 into
1739 # back short its top rows because a 28-row replica has only 28 to repaint 1740 # "...0" + "00007" while the daemon had rejoined it into one row at 100.
1740 # from, and the 95-wide row coming back still WRAPPED at 90 into "...0" + 1741 # Quiescing before the check does not heal that — the damage is in the
1741 # "00007" while the daemon had rejoined it at 100. Both checks are kept: 1742 # repaint itself, not in bytes that were still in flight.
1742 # the witness names the width directly and fails in ~10s, convergence
1743 # still covers everything the witness does not look at.
1744 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 1743 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30
1745 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ 1744 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
1746 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" 1745 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d"
test/ptyclient.zig
Old New
@@ -72,6 +72,7 @@ const Verb = union(enum) {
72 send: []u8, 72 send: []u8,
73 expect: struct { needle: []u8, deadline_ms: u64 }, 73 expect: struct { needle: []u8, deadline_ms: u64 },
74 resize: struct { cols: u16, rows: u16 }, 74 resize: struct { cols: u16, rows: u16 },
75 settle: struct { quiet_ms: u64, deadline_ms: u64 },
75 waitexit: u64, 76 waitexit: u64,
76 77
77 /// Exhaustive on purpose: a future arm that owns memory will not 78 /// Exhaustive on purpose: a future arm that owns memory will not
@@ -81,7 +82,7 @@ const Verb = union(enum) {
81 switch (self) { 82 switch (self) {
82 .send => |s| alloc.free(s), 83 .send => |s| alloc.free(s),
83 .expect => |e| alloc.free(e.needle), 84 .expect => |e| alloc.free(e.needle),
84 .resize, .waitexit => {}, 85 .resize, .settle, .waitexit => {},
85 } 86 }
86 } 87 }
87 }; 88 };
@@ -114,6 +115,12 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
114 const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; 115 const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
115 if (it.next() != null) return error.BadVerb; 116 if (it.next() != null) return error.BadVerb;
116 return .{ .resize = .{ .cols = cols, .rows = rows } }; 117 return .{ .resize = .{ .cols = cols, .rows = rows } };
118 } else if (std.mem.eql(u8, verb, "settle")) {
119 var it = std.mem.tokenizeScalar(u8, rest, ' ');
120 const quiet = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
121 const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
122 if (it.next() != null) return error.BadVerb;
123 return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } };
117 } else if (std.mem.eql(u8, verb, "waitexit")) { 124 } else if (std.mem.eql(u8, verb, "waitexit")) {
118 const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; 125 const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb;
119 return .{ .waitexit = ms }; 126 return .{ .waitexit = ms };
@@ -293,6 +300,49 @@ pub fn main() !void {
293 pty.resize(r.cols, r.rows) catch |e| 300 pty.resize(r.cols, r.rows) catch |e|
294 fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) }); 301 fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) });
295 }, 302 },
303 .settle => |s| {
304 // Quiesce by CONDITION rather than by needle: succeed once
305 // the master has gone quiet_ms without a byte. A needle can
306 // only ever say "this row arrived", and once a scrolling
307 // repaint splits across deltas the rows have no reliable
308 // order — so every needle-shaped wait is a race with some
309 // row it did not name, in both directions (a needle that
310 // matches too early detaches mid-repaint; one whose bytes
311 // were consumed by an earlier match waits forever). Silence
312 // names no row at all, which is why it closes all of those
313 // at once.
314 //
315 // Sound only where it is used: after the session has gone
316 // idle. The bytes still in flight are then deltas the daemon
317 // has already generated, so a quiet window means they landed
318 // — it is not a claim that nothing WILL ever arrive.
319 const start = std.time.milliTimestamp();
320 var last_seen = start;
321 while (true) {
322 const before = exp.buf.items.len;
323 if (!try drain(alloc, &pty, out, &exp)) {
324 // Nothing has asked the client to leave yet, so a
325 // closed pty here is a death, not a quiet screen.
326 std.debug.print("ptyclient: verb {d}: client closed the pty while settling\n", .{verb_no});
327 dumpTail(exp.buf.items);
328 std.process.exit(EXIT_CHILD_DIED);
329 }
330 const now = std.time.milliTimestamp();
331 if (exp.buf.items.len != before) last_seen = now;
332 if (now - last_seen >= s.quiet_ms) break;
333 if (now - start > s.deadline_ms) {
334 std.debug.print("ptyclient: verb {d}: settle: never saw {d}ms of silence within {d}ms\n", .{ verb_no, s.quiet_ms, s.deadline_ms });
335 dumpTail(exp.buf.items);
336 std.process.exit(EXIT_TIMEOUT);
337 }
338 var fds = [_]std.posix.pollfd{
339 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
340 };
341 // Well under the smallest useful quiet window, so the
342 // silence is measured rather than rounded up to a poll.
343 _ = try std.posix.poll(&fds, 20);
344 }
345 },
296 .waitexit => |deadline_ms| { 346 .waitexit => |deadline_ms| {
297 const start = std.time.milliTimestamp(); 347 const start = std.time.milliTimestamp();
298 while (true) { 348 while (true) {
@@ -410,6 +460,18 @@ test "parseLine: verbs, spaces in payloads, comments" {
410 const w = (try parseLine(alloc, "waitexit 10000")).?; 460 const w = (try parseLine(alloc, "waitexit 10000")).?;
411 try std.testing.expectEqual(@as(u64, 10000), w.waitexit); 461 try std.testing.expectEqual(@as(u64, 10000), w.waitexit);
412 462
463 const st = (try parseLine(alloc, "settle 500 15000")).?;
464 try std.testing.expectEqual(@as(u64, 500), st.settle.quiet_ms);
465 try std.testing.expectEqual(@as(u64, 15000), st.settle.deadline_ms);
466 // Both bounds are required and there are exactly two: a settle that
467 // silently defaulted its deadline would hang a suite instead of
468 // failing it, and a third token means the operator meant something
469 // this verb does not do.
470 try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500"));
471 try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500 15000 extra"));
472 try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle soon 15000"));
473 try std.testing.expectError(error.BadVerb, parseLine(alloc, "settle 500 later"));
474
413 try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x")); 475 try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x"));
414 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline")); 476 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline"));
415 // Doubled space: the needle would be empty, and an empty needle is a 477 // Doubled space: the needle would be empty, and an empty needle is a