a73x

da3ffc01

fix: tp2b syncs on a width witness — a scrolling repaint reorders rows across deltas

a73x   2026-08-10 11:16

Commit message
fix: tp2b syncs on a width witness — a scrolling repaint reorders rows across deltas

The 1-in-21 hang was real: on a full screen the daemon can flush the new
prompt row in one delta and the scrolled rows in a later one, so the
sentinel reaches the stream before the output needle that precedes it in
the script, and the needle's match consumes the cursor past it.

Doubled sentinels are not the cure here. Measured against row 7's
mutation: expect 00007 is 10/10 red, doubled sentinels 1/10 — a sentinel
is satisfied by any repaint of the prompt row, so the resize fires before
the wide row has landed and the later deltas, which bypass the replica,
heal the damage first.

The post-resize expect is now a width witness (91 zeros): unreachable at
90 columns, so it cannot have arrived before the resize whatever order
the rows came in, and it names the stale width directly. 10/10 red under
the mutation, 25/25 green idle.

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

test/e2e.sh
Old New
@@ -1613,15 +1613,16 @@ ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, std
1613 # resize-on-prefix guard never fires. Verified by probe, not by reading. 1613 # resize-on-prefix guard never fires. Verified by probe, not by reading.
1614 # 1614 #
1615 # tp2b is the leg that catches row 7 (the prefix ignored), and it only 1615 # tp2b is the leg that catches row 7 (the prefix ignored), and it only
1616 # catches it because of WHERE the damage can show. A delta paints its 1616 # catches it because of WHERE the damage can show. DELTA STREAMING
1617 # rows straight to the tty, absolutely addressed and clipped to the tty, 1617 # bypasses the replica: those rows go straight to the tty, absolutely
1618 # so a stale replica is invisible on that path and a wrong row is healed 1618 # addressed and clipped to the tty, so a stale replica cannot spoil them
1619 # by the next delta that re-addresses it. The one moment the client paints 1619 # and a wrong row is healed by the next delta that re-addresses it. The
1620 # FROM the replica is the full repaint that answers a snapshot, clipped to 1620 # damage therefore has to survive to a FULL REPAINT, which is the paint
1621 # min(replica, tty) — so the damage has to be still ON SCREEN then, and in 1621 # that reads the replica back out and clips at min(replica, tty) — still
1622 # a region the stale geometry cannot reach. Hence: fill the screen, put a 1622 # on screen by then, and in a region the stale geometry cannot reach.
1623 # 95-wide row on it, then winch UP to 100x30. With the prefix ignored the 1623 # Hence: fill the screen, put a 95-wide row on it, then winch UP to
1624 # replica stays 90x28 and that repaint reproduces the OLD geometry. 1624 # 100x30. With the prefix ignored the replica stays 90x28 and the repaint
1625 # answering the resize reproduces the OLD geometry.
1625 "$MUXD" run --sock "$SOCK12" --shell /bin/sh > "$OUT.tp2.d" 2>&1 & 1626 "$MUXD" run --sock "$SOCK12" --shell /bin/sh > "$OUT.tp2.d" 2>&1 &
1626 D12PID=$! 1627 D12PID=$!
1627 i=0 1628 i=0
@@ -1679,6 +1680,37 @@ assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30
1679 # repainted, including by the echo of a command typed at it, so each 1680 # repainted, including by the echo of a command typed at it, so each
1680 # trailing prompt is consumed BEFORE the resize. That leaves the expect 1681 # trailing prompt is consumed BEFORE the resize. That leaves the expect
1681 # after `resize` satisfiable only by the answering snapshot's repaint. 1682 # after `resize` satisfiable only by the answering snapshot's repaint.
1683 #
1684 # THE HAZARD, which cost a 1-in-21 hang before it was understood: on a
1685 # FULL screen the output scrolls, and the daemon can flush the new prompt
1686 # row in one delta and the scrolled rows in a LATER one. A sentinel can
1687 # therefore reach the byte stream ~600 bytes BEFORE an output needle that
1688 # precedes it in this script. Any `expect NEEDLE` followed by `expect
1689 # SENTINEL` can then deadlock: the needle's match consumes the cursor past
1690 # the sentinel, and the sentinel expect waits on a shell that has gone
1691 # idle — a hang, on a client whose grid was right all along. So no expect
1692 # here may assume that one ROW reached the stream before another.
1693 #
1694 # What this scenario must NOT do to fix that is replace `expect 00007`
1695 # with a second sentinel. Measured against the row-7 mutation: 00007 is
1696 # 10/10 red, doubled sentinels 1/10. The reason is that a sentinel is
1697 # satisfied by any repaint of the prompt row, including the ones the
1698 # scroll itself emits, so the resize fires before the wide row's deltas
1699 # have landed and the later deltas — which bypass the replica — heal the
1700 # damage before the repaint can expose it. `expect 00007` is what holds
1701 # the resize until the wide row is actually ON the screen it will be
1702 # repainted from.
1703 #
1704 # The post-resize needle is 91 zeros, and it is a WIDTH WITNESS rather
1705 # than a sync point: at 90 columns the wide row is painted wrapped, so the
1706 # longest contiguous run the client can emit is 90, and only a paint at
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.
1682 set +e 1714 set +e
1683 timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \ 1715 timeout 40 "$PTYCLIENT" --cols 90 --rows 28 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \
1684 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF' 1716 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF'
@@ -1688,8 +1720,8 @@ expect fill-done 10000
1688 expect tp2rdy@ 10000 1720 expect tp2rdy@ 10000
1689 send printf '%095d\\n' 7\n 1721 send printf '%095d\\n' 7\n
1690 expect 00007 10000 1722 expect 00007 10000
1691 expect tp2rdy@ 10000
1692 resize 100 30 1723 resize 100 30
1724 expect 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 10000
1693 expect tp2rdy@ 10000 1725 expect tp2rdy@ 10000
1694 send \x1c 1726 send \x1c
1695 waitexit 10000 1727 waitexit 10000
@@ -1699,14 +1731,16 @@ set -e
1699 [ "$RC" -eq 0 ] || { 1731 [ "$RC" -eq 0 ] || {
1700 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; } 1732 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; }
1701 # Graded against the mutation (row 7: the prefix resize commented out), 1733 # Graded against the mutation (row 7: the prefix resize commented out),
1702 # and this is what the mutated build actually did — two divergences in one 1734 # 10 runs out of 10 red. What the mutated build does now is fail ABOVE,
1703 # diff, neither of them an expect timeout, since every needle still 1735 # on the width witness: the 91-zero needle never arrives, so the fixture
1704 # arrives on a stale replica: 1736 # times out at that verb and the RC check fires. Before the witness
1705 # the 30-row grid came back with its top rows missing, because a 28-row 1737 # existed the same build reached this line and failed here instead, with
1706 # replica has 28 rows to repaint from; 1738 # both halves of the stale geometry in one diff — the 30-row grid coming
1707 # the 95-wide row came back still WRAPPED at 90 into "...0" + "00007", 1739 # back short its top rows because a 28-row replica has only 28 to repaint
1708 # because the replica that reflowed it is still 90 wide, while the 1740 # from, and the 95-wide row coming back still WRAPPED at 90 into "...0" +
1709 # daemon had already rejoined it into one 95-char row at 100. 1741 # "00007" while the daemon had rejoined it at 100. Both checks are kept:
1742 # the witness names the width directly and fails in ~10s, convergence
1743 # still covers everything the witness does not look at.
1710 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 1744 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30
1711 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ 1745 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
1712 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" 1746 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d"