a73x

583b36c0

test: tp1 — reconnect while scrolled restores the view and prediction

a73x   2026-08-10 12:29

Commit message
test: tp1 — reconnect while scrolled restores the view and prediction

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

test/e2e.sh
Old New
@@ -68,6 +68,13 @@ GPID=""
68 # wrapper, so neither can share the long-lived /bin/sh daemon. 68 # wrapper, so neither can share the long-lived /bin/sh daemon.
69 SOCK12="${TMPDIR:-/tmp}/muxd-e2e-tp2-$$.sock" 69 SOCK12="${TMPDIR:-/tmp}/muxd-e2e-tp2-$$.sock"
70 D12PID="" 70 D12PID=""
71 SOCK13="${TMPDIR:-/tmp}/muxd-e2e-tp1-$$.sock"
72 D13PID=""
73 # tp1's session: a wrapper that fills the scrollback and then becomes cat.
74 TP1SH="${TMPDIR:-/tmp}/mux-e2e-tp1-$$.sh"
75 # tp1 runs its fixture in the BACKGROUND — the tear has to happen while the
76 # script is mid-flight — so its pid is tracked and killed like a daemon's.
77 TP1PID=""
71 78
72 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 79 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
73 # such line on exit; every field is a key=value pair, so a rename or reorder 80 # such line on exit; every field is a key=value pair, so a rename or reorder
@@ -237,6 +244,12 @@ cleanup() {
237 [ -n "$D9PID" ] && kill "$D9PID" 2>/dev/null || true 244 [ -n "$D9PID" ] && kill "$D9PID" 2>/dev/null || true
238 [ -n "$D10PID" ] && kill "$D10PID" 2>/dev/null || true 245 [ -n "$D10PID" ] && kill "$D10PID" 2>/dev/null || true
239 [ -n "$D12PID" ] && kill "$D12PID" 2>/dev/null || true 246 [ -n "$D12PID" ] && kill "$D12PID" 2>/dev/null || true
247 [ -n "$D13PID" ] && kill "$D13PID" 2>/dev/null || true
248 # The fixture, by the pid the suite holds. Killing it closes the pty
249 # master, and that SIGHUPs the client on the slave — the same guarantee
250 # a real terminal gives, which is why the fixture never reaps its own
251 # child and this trap does not chase one.
252 [ -n "$TP1PID" ] && kill "$TP1PID" 2>/dev/null || true
240 # Detached daemons: killed by the pid their own up-line reported. 253 # Detached daemons: killed by the pid their own up-line reported.
241 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true 254 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true
242 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true 255 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true
@@ -266,6 +279,14 @@ cleanup() {
266 rm -f "$SOCK12" "$OUT.tp2.d" \ 279 rm -f "$SOCK12" "$OUT.tp2.d" \
267 "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ 280 "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
268 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" 281 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log"
282 # tp1, including the doctored copy and the convergence residue its
283 # cannot-fail control leaves behind BY DESIGN: that check passes when
284 # converged_quiet fails, and a failing converged_quiet keeps its files.
285 rm -f "$SOCK13" "$TP1SH" "$OUT.tp1.d" \
286 "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" \
287 "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \
288 "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \
289 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt"
269 # The convergence files a FAILING assert_converged leaves behind 290 # The convergence files a FAILING assert_converged leaves behind
270 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 291 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
271 # chased here: on a failing run they are the evidence. 292 # chased here: on a failing run they are the evidence.
@@ -1770,23 +1791,180 @@ assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30
1770 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ 1791 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
1771 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" 1792 "$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: 1793 # 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 1794 # tp1 runs below and would otherwise share the box with a daemon nobody is
1774 # next scenario appended below would inherit a stray daemon silently. 1795 # watching, holding a session whose shell is still alive.
1775 kill "$D12PID" 2>/dev/null || true 1796 kill "$D12PID" 2>/dev/null || true
1776 D12PID="" 1797 D12PID=""
1777 ok "a pty client resizes: snapshot prefix applied, winch follows the tty" 1798 ok "a pty client resizes: snapshot prefix applied, winch follows the tty"
1778 1799
1800 # ---- M12 tp1: reconnect while scrolled (row 18 + the 412f38f pin) ------
1801 # The session fills its scrollback BY ITSELF and then execs cat: no
1802 # pre-tear typing means no pre-tear predictions, so the predict counters at
1803 # exit belong entirely to the one post-resync keystroke — which is what
1804 # lets them isolate the 412f38f fix. cat leaves the pty canonical and
1805 # echoing, the `.always` tier, so a real keystroke predicts.
1806 cat > "$TP1SH" <<'EOF'
1807 #!/bin/sh
1808 seq 1 100
1809 exec /bin/cat
1810 EOF
1811 chmod +x "$TP1SH"
1812 "$MUXD" run --sock "$SOCK13" --shell "$TP1SH" > "$OUT.tp1.d" 2>&1 &
1813 D13PID=$!
1814 i=0
1815 while [ ! -S "$SOCK13" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
1816 [ -S "$SOCK13" ] || { echo "e2e FAIL: tp1 daemon never bound"; cat "$OUT.tp1.d"; exit 1; }
1817 # Attach only after seq finished: the client must be served a snapshot of
1818 # the TAIL, so the scrollback page it fetches later is content it has never
1819 # been sent. Observed at 80x24: the attach snapshot carries rows 78..100 and
1820 # the session goes quiet there (cat prints no prompt), history_rows is 77,
1821 # and page 1 is rows 54..77. Every needle below is chosen against those
1822 # numbers, which is why this wait is part of the scenario and not a nicety.
1823 i=0
1824 until "$MUXD" dump --sock "$SOCK13" | grep -q "100"; do
1825 i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: tp1 session never finished seq"; exit 1; }
1826 sleep 0.1
1827 done
1828
1829 # MUX_PREDICT_STATS is exported into the FIXTURE's environment, not spelled
1830 # as a `VAR=x cmd` prefix on the client: the fixture execve()s its child
1831 # with an absolute path and no PATH search (src/pty.zig), so `env` would
1832 # have to be an absolute path too, and the child inherits this environment
1833 # anyway. Getting that wrong costs a client that exits 127 before the first
1834 # verb, which the fixture reports as "closed the pty before ... matched".
1835 #
1836 # EXACTLY ONE keystroke, and that is the load-bearing choice in this script.
1837 # offerKeystroke refuses any stdin chunk that is not one byte — an escape
1838 # sequence, a multi-byte character, or a paste — and counts it suppressed.
1839 # Consecutive `send`s have no barrier between them, so the client's next
1840 # read can pick up two of them at once and predict neither: measured at
1841 # idle, a three-keystroke version of this leg scored made=3 on one run and
1842 # made=2/suppressed=1 on the next. Under load all three could coalesce,
1843 # which would read as made=0 — the mutation's own signature. One send
1844 # cannot coalesce with anything, so the counters below are exact.
1845 set +e
1846 MUX_PREDICT_STATS=1 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \
1847 --out "$OUT.tp1" --err "$OUT.tp1.err" -- \
1848 "$MUX" --via "$MUXD proxy --sock $SOCK13" > "$OUT.tp1.log" 2>&1 <<'EOF' &
1849 expect 100 10000
1850 send \x1b[5;2~
1851 expect 60 10000
1852 expect reconnecting 20000
1853 expect 100 20000
1854 send t
1855 expect \x1b[2K\x1b[0mt 20000
1856 settle 500 15000
1857 send \x1c
1858 waitexit 15000
1859 EOF
1860 TP1PID=$!
1861 set -e
1862
1863 # The tear goes between verb 3 (the scroll view is on screen) and verb 4;
1864 # the fixture's "done 3" line is the barrier. Every needle above is unique
1865 # to its phase, which is what makes each expect sound under the no-counting
1866 # rule:
1867 # the first "100" is the attach snapshot's last row;
1868 # "60" is in the history page (54..77) and in NOTHING before it — the
1869 # snapshot's rows are 78..100 and the client's own paints address rows
1870 # and column 1 only, so no cursor escape can spell it;
1871 # "reconnecting" is the banner, painted once, on the tear;
1872 # the SECOND "100" can only be the post-resync repaint: the scroll view
1873 # holds 54..77, and the banner carries no digits but its own position;
1874 # the last needle is STRUCTURAL, and it has to be. In canonical mode the
1875 # only thing a single keystroke produces is the line discipline's echo of
1876 # the very glyph the prediction just painted, so no content needle can
1877 # tell the daemon's answer from the client's own guess. `\x1b[2K` can:
1878 # it is emitted by exactly one paint path in the client, the one that
1879 # paints a DELTA (client.zig, paintDeltaClipped), so its arrival means a
1880 # frame came back. The prediction paints the same glyph underlined and
1881 # without an erase ("\x1b[24;1H\x1b[4mt\x1b[0m"), and the resync repaint
1882 # erases the whole screen with \x1b[2J instead — neither can forge this.
1883 # The two post-tear deadlines are 20000 because a reconnect is allowed to
1884 # retry: the daemon may not have reaped the dead client's slot yet, and the
1885 # client tolerates that for reconnect_grace_ms (5s) with a backoff that
1886 # reaches 2s. That is a budget for a documented retry loop, not a deadline
1887 # tuned until a flake stopped.
1888 wait_for "$OUT.tp1.log" "done 3" 20 || {
1889 echo "e2e FAIL: tp1 never reached the scroll view:"; cat "$OUT.tp1.log"; exit 1; }
1890 TP1PROXY=$(proxy_pid "$SOCK13")
1891 [ -n "$TP1PROXY" ] || { echo "e2e FAIL: tp1: no proxy to tear"; exit 1; }
1892 kill -9 "$TP1PROXY"
1893 kill -0 "$D13PID" || { echo "e2e FAIL: tp1 tear killed the daemon, not the proxy"; exit 1; }
1894
1895 set +e
1896 wait "$TP1PID"
1897 RC=$?
1898 TP1PID=""
1899 set -e
1900 [ "$RC" -eq 0 ] || {
1901 echo "e2e FAIL: tp1 ptyclient exited $RC:"; cat "$OUT.tp1.log"
1902 cat -v "$OUT.tp1.err" 2>/dev/null; exit 1; }
1903
1904 # Verb 5 is row 18's catch: a resync that fails to leave the scroll view
1905 # suppresses every live paint, so the second "100" never arrives.
1906 #
1907 # The counters are the 412f38f pin, and they are EXACT rather than floors.
1908 # One keystroke in the whole scenario, typed after the resync onto a blank
1909 # cell at a live cursor, in a session whose termios never moves: nothing
1910 # here is timing-dependent, so every number is a fact.
1911 # made=1, suppressed=0 is the fix working. With it reverted the overlay
1912 # still believes it is scrolled and predictAt refuses on that bit alone —
1913 # made=0, suppressed=1, with nothing else in the run to hide it.
1914 # displayed=1 says the glyph reached the SCREEN, which `made` does not:
1915 # the overlay queues while unconfident and paints nothing, so a client
1916 # that lost the `.always` tier would still count the prediction.
1917 # confirmed=1 and contradicted=0 say the daemon's own echo agreed with
1918 # the guess, which is what makes the prediction a speedup and not a lie.
1919 want_stat "$OUT.tp1.err" made 1 "pty scroll reconnect"
1920 want_stat "$OUT.tp1.err" displayed 1 "pty scroll reconnect"
1921 want_stat "$OUT.tp1.err" confirmed 1 "pty scroll reconnect"
1922 want_stat "$OUT.tp1.err" contradicted 0 "pty scroll reconnect"
1923 want_stat "$OUT.tp1.err" suppressed 0 "pty scroll reconnect"
1924 assert_converged "$OUT.tp1" "$SOCK13" "pty scroll reconnect"
1925
1926 # The doctored control, extended to a pty capture: this capture's byte shape
1927 # (alt screen, banner paints, a history page) exists nowhere else in the
1928 # corpus, so it earns its own cannot-fail check.
1929 #
1930 # It CANNOT be the plain append the non-pty control uses. render replays
1931 # only up to the LAST alt-screen exit, and a tty client's capture ends with
1932 # one — so appended bytes land after the grid under test and change nothing.
1933 # Measured: the doctored capture rendered byte-identical to the original,
1934 # i.e. a control that could never fire. Dropping that trailing 8-byte exit
1935 # puts the append back INSIDE the alt screen (a stream with no alt-exit is
1936 # replayed whole), which corrupts exactly one row of the real grid — row 10,
1937 # "87" in this session. The tail is asserted rather than assumed, because if
1938 # the client's teardown ever stops ending there, the truncation would silently
1939 # go back to being a no-op appended after the grid.
1940 [ "$(tail -c 8 "$OUT.tp1" | od -An -tx1 | tr -d ' \n')" = "1b5b3f313034396c" ] || {
1941 echo "e2e FAIL: tp1 capture does not end with the alt-screen exit;"
1942 echo " the doctored control below would not be doctoring the grid"
1943 tail -c 16 "$OUT.tp1" | cat -v; exit 1; }
1944 head -c "$(( $(wc -c < "$OUT.tp1") - 8 ))" "$OUT.tp1" > "$OUT.tp1.doc"
1945 printf '\033[10;1Hpty-doctor-glyphs' >> "$OUT.tp1.doc"
1946 if converged_quiet "$OUT.tp1.doc" "$SOCK13"; then
1947 echo "e2e FAIL: convergence control did not fire on a doctored pty capture"; exit 1
1948 fi
1949 rm -f "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \
1950 "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \
1951 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt"
1952 kill "$D13PID" 2>/dev/null || true
1953 D13PID=""
1954 rm -f "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH"
1955 ok "reconnect while scrolled: view restored, prediction resumed"
1956
1779 # The pins. Literals, not variables set from counting something else — 1957 # The pins. Literals, not variables set from counting something else —
1780 # "assert the literal, never the constant the code under test reads" 1958 # "assert the literal, never the constant the code under test reads"
1781 # (decisions.md, M10). 12 scenario checkpoints; 24 convergence points. 1959 # (decisions.md, M10). 13 scenario checkpoints; 25 convergence points.
1782 # Anyone adding a scenario updates these by hand, on purpose. 1960 # Anyone adding a scenario updates these by hand, on purpose.
1783 [ "$OK_COUNT" = "12" ] || { 1961 [ "$OK_COUNT" = "13" ] || {
1784 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 12 —" 1962 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 13 —"
1785 echo " a scenario was added (update the pin) or silently lost" 1963 echo " a scenario was added (update the pin) or silently lost"
1786 exit 1 1964 exit 1
1787 } 1965 }
1788 [ "$CONV_COUNT" = "24" ] || { 1966 [ "$CONV_COUNT" = "25" ] || {
1789 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 24" 1967 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 25"
1790 exit 1 1968 exit 1
1791 } 1969 }
1792 echo "e2e OK (12 scenarios, 24 convergence points)" 1970 echo "e2e OK (13 scenarios, 25 convergence points)"