a73x

a9a5a8f3

fix: tp2b proves arrival then silence — witness and settle each cover the other's blind side

a73x   2026-08-10 11:49

Commit message
fix: tp2b proves arrival then silence — witness and settle each cover the other's blind side

Post-resize the leg asserts two independent things. The 91-zero witness
proves the reflowed paint ARRIVED: 91 contiguous zeros cannot exist at
width 90, so it cannot fire early whatever order the rows came in, and it
measured 150/150 under 16-way load. settle then proves nothing is still
IN FLIGHT, which the witness alone permits — a detach mid-repaint.

Neither subsumes the other. settle alone has a hole: its quiet window is
measured from its own start and never requires a byte, so a build whose
repaint has not begun inside it passes having observed nothing, leaving a
pre-resize capture that diffs exactly like row 7 — and it would run
outside its own precondition, since the answering snapshot does not exist
yet at that point.

Also closes the tp2 daemon at scenario end like every other per-scenario
daemon rather than relying on being last in the file, and corrects three
comments: a stale grid width, a set -e rationale that named a hazard the
guard's position cannot produce, and a regrade note that asserted a
mechanism where it should carry a measured count.

Graded in this shape against row 7's mutation: 10 targeted runs, 10 red,
dying at the witness; plus a full-suite resurrection run.

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

test/e2e.sh
Old New
@@ -149,14 +149,15 @@ proxy_pid() {
149 # holds or the diff compares two honest grids of different shapes. 149 # holds or the diff compares two honest grids of different shapes.
150 # It is an INPUT, not something this check validates, and the error is 150 # It is an INPUT, not something this check validates, and the error is
151 # one-sided: too small re-wraps or clips a row and diverges loudly (the 151 # one-sided: too small re-wraps or clips a row and diverges loudly (the
152 # 80x24 default against tp2b's 90-wide grid does exactly that), while too 152 # 80x24 default against tp2b's 100-wide final grid does exactly that),
153 # large only adds trailing blanks that the normalization strips, and 153 # while too large only adds trailing blanks that the normalization strips,
154 # passes. Pass the size the scenario actually ran at. 154 # and passes. Pass the size the scenario actually ran at.
155 converged_quiet() { 155 converged_quiet() {
156 _co="$1"; _cs="$2"; _sz="" 156 _co="$1"; _cs="$2"; _sz=""
157 # Not `[ $# -ge 4 ] && _sz=...`: as the last command of a function under 157 # An `if` rather than `[ ... ] && _sz=...` so it stays correct if it
158 # `set -e`, a false guard would return nonzero — a 2-argument call would 158 # ever ends up the last command in this function: there, a false guard
159 # report divergence it never looked for. 159 # would be the function's exit status under `set -e` and a 2-argument
160 # call would report a divergence it never looked for.
160 if [ $# -ge 4 ]; then _sz="--cols $3 --rows $4"; fi 161 if [ $# -ge 4 ]; then _sz="--cols $3 --rows $4"; fi
161 # shellcheck disable=SC2086 — $_sz is two flags or nothing, never data 162 # shellcheck disable=SC2086 — $_sz is two flags or nothing, never data
162 "$RENDER" $_sz < "$_co" > "$_co.render" || return 1 163 "$RENDER" $_sz < "$_co" > "$_co.render" || return 1
@@ -1706,14 +1707,33 @@ assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30
1706 # The immune forms, and the only ones used below. UNIQUE OUTPUT MARKERS 1707 # The immune forms, and the only ones used below. UNIQUE OUTPUT MARKERS
1707 # (`fill-done`, `00007`) for progress: each is printed once, by a command 1708 # (`fill-done`, `00007`) for progress: each is printed once, by a command
1708 # whose typed line cannot contain it, so its arrival is a fact about the 1709 # whose typed line cannot contain it, so its arrival is a fact about the
1709 # session rather than about paint order. And SILENCE for quiesce: `settle` 1710 # session rather than about paint order. Occurrence-counting of a
1710 # waits for the master to stop changing, which names no row at all and so 1711 # REPAINTED row — which is what a prompt sentinel is — is not a
1711 # cannot be raced by one. Occurrence-counting of a REPAINTED row — which 1712 # substitute, and is what the three earlier shapes of this leg died to.
1712 # is what a prompt sentinel is — is not a substitute for either.
1713 # 1713 #
1714 # `expect 00007` earns its place as more than a marker: its arrival proves 1714 # `expect 00007` earns its place as more than a marker: its arrival proves
1715 # the daemon processed the wide row while the grid was still 90 columns, 1715 # the daemon processed the wide row while the grid was still 90 columns,
1716 # which is what puts the damage on the screen the resize will repaint from. 1716 # which is what puts the damage on the screen the resize will repaint from.
1717 #
1718 # After the resize the leg asserts TWO different things, and neither one
1719 # implies the other:
1720 # the 91-zero WITNESS proves the reflowed paint ARRIVED. 91 contiguous
1721 # zeros cannot exist at width 90 — the row is painted wrapped there — so
1722 # this needle is structurally incapable of firing early, whatever order
1723 # the rows came in. It is also not the load risk an extra deadline looks
1724 # like: this needle went 150/150 under 16-way load, at the same load that
1725 # broke the two prompt-sentinel shapes 9/150 and 15/150.
1726 # `settle` then proves nothing further is IN FLIGHT. The witness alone
1727 # would permit a detach mid-repaint, with the rest of the screen still
1728 # on its way.
1729 # Dropping the witness and keeping only settle looks equivalent and is
1730 # not: settle succeeds on 500ms of silence measured from its own start and
1731 # never requires that a single byte arrived, so a healthy build whose
1732 # repaint has not BEGUN inside that window satisfies it having observed
1733 # nothing at all — leaving a pre-resize capture that diffs exactly like
1734 # row 7. It would also be used outside its own precondition, which is that
1735 # the session is already idle; straight after a resize the answering
1736 # snapshot has not been generated yet.
1717 set +e 1737 set +e
1718 timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ 1738 timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \
1719 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' 1739 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF'
@@ -1723,6 +1743,7 @@ expect fill-done 10000
1723 send printf '%095d\\n' 7\n 1743 send printf '%095d\\n' 7\n
1724 expect 00007 10000 1744 expect 00007 10000
1725 resize 100 30 1745 resize 100 30
1746 expect 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 10000
1726 settle 500 15000 1747 settle 500 15000
1727 send \x1c 1748 send \x1c
1728 waitexit 10000 1749 waitexit 10000
@@ -1731,18 +1752,28 @@ RC=$?
1731 set -e 1752 set -e
1732 [ "$RC" -eq 0 ] || { 1753 [ "$RC" -eq 0 ] || {
1733 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } 1754 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; }
1734 # Graded against the mutation (row 7: the prefix resize commented out), 1755 # Graded against the mutation (row 7: the prefix resize commented out) in
1735 # and this is where it dies — `settle` waits for the repaint rather than 1756 # this exact shape: 10 targeted runs, 10 red, plus a full-suite run. What
1736 # racing it, so the capture faithfully records what the stale geometry 1757 # dies is the WITNESS, above — a replica still 90 wide cannot paint 91
1737 # painted, and both halves of it land in one diff: the 30-row grid comes 1758 # contiguous zeros, so the leg fails on the fixture timeout at that verb
1738 # back short its top rows, because a 28-row replica has only 28 rows to 1759 # ("expect ... did not arrive within 10000ms", exit 3) and the RC check
1739 # repaint from, and the 95-wide row comes back still WRAPPED at 90 into 1760 # reports it in ~10s.
1740 # "...0" + "00007" while the daemon had rejoined it into one row at 100. 1761 #
1741 # Quiescing before the check does not heal that — the damage is in the 1762 # This assertion is the backstop rather than the tripwire, and it is kept
1742 # repaint itself, not in bytes that were still in flight. 1763 # because it sees what the witness does not: before the witness existed
1764 # the same build reached here and diverged with both halves of the stale
1765 # geometry in one diff — a 30-row grid coming back short its top rows,
1766 # because a 28-row replica has only 28 rows to repaint from, and the
1767 # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon
1768 # had rejoined it into one row at 100.
1743 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 1769 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30
1744 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ 1770 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
1745 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" 1771 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d"
1772 # Closed here like every other per-scenario daemon, not left to the trap:
1773 # this one currently survives only because nothing runs after it, and the
1774 # next scenario appended below would inherit a stray daemon silently.
1775 kill "$D12PID" 2>/dev/null || true
1776 D12PID=""
1746 ok "a pty client resizes: snapshot prefix applied, winch follows the tty" 1777 ok "a pty client resizes: snapshot prefix applied, winch follows the tty"
1747 1778
1748 # The pins. Literals, not variables set from counting something else — 1779 # The pins. Literals, not variables set from counting something else —
test/ptyclient.zig
Old New
@@ -312,10 +312,15 @@ pub fn main() !void {
312 // names no row at all, which is why it closes all of those 312 // names no row at all, which is why it closes all of those
313 // at once. 313 // at once.
314 // 314 //
315 // Sound only where it is used: after the session has gone 315 // What this does NOT say is that anything arrived. The quiet
316 // idle. The bytes still in flight are then deltas the daemon 316 // window is measured from this verb's own start and never
317 // has already generated, so a quiet window means they landed 317 // requires a byte, so a screen that has not begun changing
318 // — it is not a claim that nothing WILL ever arrive. 318 // yet satisfies it having observed nothing — which is a pass
319 // built on an empty observation. Pair it with a needle that
320 // proves the paint you care about ARRIVED, and use it only
321 // once the session is otherwise idle: the bytes still in
322 // flight are then deltas the daemon has already generated,
323 // so silence means they landed.
319 const start = std.time.milliTimestamp(); 324 const start = std.time.milliTimestamp();
320 var last_seen = start; 325 var last_seen = start;
321 while (true) { 326 while (true) {
@@ -357,11 +362,14 @@ pub fn main() !void {
357 // exit. Bounded, because anything OTHER than the client 362 // exit. Bounded, because anything OTHER than the client
358 // still holding the slave keeps the fd open forever, 363 // still holding the slave keeps the fd open forever,
359 // and a hung suite is worse than a short capture. Two 364 // and a hung suite is worse than a short capture. Two
360 // bounds: a quiet poll ends the drain normally, and the 365 // ways out: a quiet poll ends the drain normally, and
361 // verb's own deadline ends it when the holder is never 366 // the verb's deadline ends it when the holder is never
362 // quiet — a process that ignores the HUP and keeps 367 // quiet — a process that ignores the HUP and keeps
363 // writing satisfies every poll and would otherwise spin 368 // writing satisfies every poll and would otherwise spin
364 // here forever, filling --out as it went. 369 // here forever, filling --out as it went. That deadline
370 // is the OUTER one, measured from the same `start`, so
371 // the drain does not get a fresh budget: waitexit's
372 // bound covers the wait and this drain together.
365 while (try drain(alloc, &pty, out, &exp)) { 373 while (try drain(alloc, &pty, out, &exp)) {
366 if (std.time.milliTimestamp() - start > deadline_ms) break; 374 if (std.time.milliTimestamp() - start > deadline_ms) break;
367 var fds = [_]std.posix.pollfd{ 375 var fds = [_]std.posix.pollfd{