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
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{ |