583b36c0
test: tp1 — reconnect while scrolled restores the view and prediction
a73x 2026-08-10 12:29
Commit message
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)" |