a73x

85da1e61

test: tp2 — a pty client attaches at 100x30 and resizes to 90x28

a73x   2026-08-10 09:02

Commit message
test: tp2 — a pty client attaches at 100x30 and resizes to 90x28

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

test/e2e.sh
Old New
@@ -63,6 +63,11 @@ QPORT4=$(( 36000 + ($$ % 4000) ))
63 SPID="" 63 SPID=""
64 TPID="" 64 TPID=""
65 GPID="" 65 GPID=""
66 # M12 pty scenarios. Each needs a daemon whose grid size it owns: tp2
67 # resizes the grid twice and tp1's session is a scrollback-generating
68 # wrapper, so neither can share the long-lived /bin/sh daemon.
69 SOCK12="${TMPDIR:-/tmp}/muxd-e2e-tp2-$$.sock"
70 D12PID=""
66 71
67 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 72 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
68 # such line on exit; every field is a key=value pair, so a rename or reorder 73 # such line on exit; every field is a key=value pair, so a rename or reorder
@@ -134,14 +139,27 @@ proxy_pid() {
134 } 139 }
135 140
136 # --- M11: render-vs-dump convergence ----------------------------------- 141 # --- M11: render-vs-dump convergence -----------------------------------
137 # converged_quiet CLIENT_OUT SOCK — render the captured client stream and 142 # converged_quiet CLIENT_OUT SOCK [COLS ROWS] — render the captured client
138 # diff it against the daemon's grid, plain and styled. Nonzero on 143 # stream and diff it against the daemon's grid, plain and styled. Nonzero on
139 # divergence, leaving CLIENT_OUT.{render,dump,diff,rvt,dvt} behind for 144 # divergence, leaving CLIENT_OUT.{render,dump,diff,rvt,dvt} behind for
140 # inspection. CLIENT_OUT must be PURE client stdout: a capture taken with 145 # inspection. CLIENT_OUT must be PURE client stdout: a capture taken with
141 # 2>&1 has exit messages and predict stats mixed into the escape stream. 146 # 2>&1 has exit messages and predict stats mixed into the escape stream.
147 # The optional size is for pty scenarios whose grids are not the non-tty
148 # 80x24 default; render must replay into the same dimensions the daemon
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
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
153 # large only adds trailing blanks that the normalization strips, and
154 # passes. Pass the size the scenario actually ran at.
142 converged_quiet() { 155 converged_quiet() {
143 _co="$1"; _cs="$2" 156 _co="$1"; _cs="$2"; _sz=""
144 "$RENDER" < "$_co" > "$_co.render" || return 1 157 # Not `[ $# -ge 4 ] && _sz=...`: as the last command of a function under
158 # `set -e`, a false guard would return nonzero — a 2-argument call would
159 # report divergence it never looked for.
160 if [ $# -ge 4 ]; then _sz="--cols $3 --rows $4"; fi
161 # shellcheck disable=SC2086 — $_sz is two flags or nothing, never data
162 "$RENDER" $_sz < "$_co" > "$_co.render" || return 1
145 "$MUXD" dump --sock "$_cs" > "$_co.dump" || return 1 163 "$MUXD" dump --sock "$_cs" > "$_co.dump" || return 1
146 # Trailing whitespace is a formatting difference between two correct 164 # Trailing whitespace is a formatting difference between two correct
147 # grids (padded vs unpadded row ends), not a divergence. 165 # grids (padded vs unpadded row ends), not a divergence.
@@ -153,7 +171,8 @@ converged_quiet() {
153 # or a leftover prediction underline — plain text dumps the same glyph 171 # or a leftover prediction underline — plain text dumps the same glyph
154 # either way, which is exactly why plain alone cannot carry M9's 172 # either way, which is exactly why plain alone cannot carry M9's
155 # overlay-never-becomes-state invariant. 173 # overlay-never-becomes-state invariant.
156 "$RENDER" --vt < "$_co" > "$_co.rvt" || return 1 174 # shellcheck disable=SC2086 — same as above: flags or nothing
175 "$RENDER" --vt $_sz < "$_co" > "$_co.rvt" || return 1
157 "$MUXD" dump --vt --sock "$_cs" > "$_co.dvt" || return 1 176 "$MUXD" dump --vt --sock "$_cs" > "$_co.dvt" || return 1
158 cmp -s "$_co.dvt" "$_co.rvt" || return 1 177 cmp -s "$_co.dvt" "$_co.rvt" || return 1
159 rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \ 178 rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \
@@ -161,13 +180,17 @@ converged_quiet() {
161 return 0 180 return 0
162 } 181 }
163 182
164 # assert_converged CLIENT_OUT SOCK NAME — a scenario's LAST act, after 183 # assert_converged CLIENT_OUT SOCK NAME [COLS ROWS] — a scenario's LAST act,
165 # quiesce and after the client detached: mid-scenario the dump is still 184 # after quiesce and after the client detached: mid-scenario the dump is still
166 # moving, and another attach would claim the grid. 185 # moving, and another attach would claim the grid.
167 CONV_COUNT=0 186 CONV_COUNT=0
168 assert_converged() { 187 assert_converged() {
169 CONV_COUNT=$((CONV_COUNT + 1)) 188 CONV_COUNT=$((CONV_COUNT + 1))
170 converged_quiet "$1" "$2" || { 189 if [ $# -ge 5 ]; then
190 converged_quiet "$1" "$2" "$4" "$5"
191 else
192 converged_quiet "$1" "$2"
193 fi || {
171 echo "e2e FAIL: $3: client render diverges from daemon grid (-daemon +client):" 194 echo "e2e FAIL: $3: client render diverges from daemon grid (-daemon +client):"
172 # An empty diff file is the styled-only case, not a passing one — 195 # An empty diff file is the styled-only case, not a passing one —
173 # the glyphs agree and the pens do not, which is precisely what the 196 # the glyphs agree and the pens do not, which is precisely what the
@@ -212,6 +235,7 @@ cleanup() {
212 [ -n "$D7PID" ] && kill "$D7PID" 2>/dev/null || true 235 [ -n "$D7PID" ] && kill "$D7PID" 2>/dev/null || true
213 [ -n "$D9PID" ] && kill "$D9PID" 2>/dev/null || true 236 [ -n "$D9PID" ] && kill "$D9PID" 2>/dev/null || true
214 [ -n "$D10PID" ] && kill "$D10PID" 2>/dev/null || true 237 [ -n "$D10PID" ] && kill "$D10PID" 2>/dev/null || true
238 [ -n "$D12PID" ] && kill "$D12PID" 2>/dev/null || true
215 # Detached daemons: killed by the pid their own up-line reported. 239 # Detached daemons: killed by the pid their own up-line reported.
216 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true 240 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true
217 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true 241 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true
@@ -238,6 +262,9 @@ cleanup() {
238 # would expand to a bare ".2" — an rm target in the CWD, not a temp file. 262 # would expand to a bare ".2" — an rm target in the CWD, not a temp file.
239 rm -f "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err" \ 263 rm -f "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err" \
240 "$OUT.pc.log" "$OUT.pc.log.2" "$OUT.pc.log.3" 264 "$OUT.pc.log" "$OUT.pc.log.2" "$OUT.pc.log.3"
265 rm -f "$SOCK12" "$OUT.tp2.d" \
266 "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
267 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log"
241 # The convergence files a FAILING assert_converged leaves behind 268 # The convergence files a FAILING assert_converged leaves behind
242 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 269 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
243 # chased here: on a failing run they are the evidence. 270 # chased here: on a failing run they are the evidence.
@@ -1520,7 +1547,7 @@ D5PID=""
1520 # what it did see. A check that cannot fail proves nothing. 1547 # what it did see. A check that cannot fail proves nothing.
1521 PCLOG="$OUT.pc.log" 1548 PCLOG="$OUT.pc.log"
1522 set +e 1549 set +e
1523 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc" --err "$OUT.pc.err" -- /bin/cat > "$PCLOG" 2>&1 <<'EOF' 1550 timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc" --err "$OUT.pc.err" -- /bin/cat > "$PCLOG" 2>&1 <<'EOF'
1524 send hello\n 1551 send hello\n
1525 expect hello 10000 1552 expect hello 10000
1526 send \x04 1553 send \x04
@@ -1535,7 +1562,7 @@ grep -q "hello" "$OUT.pc" || {
1535 # The must-fail leg. 500ms: nothing is being waited FOR — the needle never 1562 # The must-fail leg. 500ms: nothing is being waited FOR — the needle never
1536 # arrives by construction — so the deadline only bounds the control's cost. 1563 # arrives by construction — so the deadline only bounds the control's cost.
1537 set +e 1564 set +e
1538 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc2" --err "$OUT.pc2.err" -- /bin/cat > "$PCLOG.2" 2>&1 <<'EOF' 1565 timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc2" --err "$OUT.pc2.err" -- /bin/cat > "$PCLOG.2" 2>&1 <<'EOF'
1539 expect never-going-to-match 500 1566 expect never-going-to-match 500
1540 EOF 1567 EOF
1541 RC=$? 1568 RC=$?
@@ -1553,7 +1580,7 @@ grep -q "did not arrive" "$PCLOG.2" || {
1553 # a child that writes to BOTH streams can tell a working split from a child 1580 # a child that writes to BOTH streams can tell a working split from a child
1554 # that simply never wrote to stderr. 1581 # that simply never wrote to stderr.
1555 set +e 1582 set +e
1556 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc3" --err "$OUT.pc3.err" -- \ 1583 timeout 30 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc3" --err "$OUT.pc3.err" -- \
1557 /bin/sh -c 'printf pc-stdout; printf pc-stderr 1>&2' > "$PCLOG.3" 2>&1 <<'EOF' 1584 /bin/sh -c 'printf pc-stdout; printf pc-stderr 1>&2' > "$PCLOG.3" 2>&1 <<'EOF'
1558 expect pc-stdout 10000 1585 expect pc-stdout 10000
1559 waitexit 10000 1586 waitexit 10000
@@ -1573,17 +1600,107 @@ rm -f "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err
1573 "$PCLOG" "$PCLOG.2" "$PCLOG.3" 1600 "$PCLOG" "$PCLOG.2" "$PCLOG.3"
1574 ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, stderr stays off the capture" 1601 ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, stderr stays off the capture"
1575 1602
1603 # ---- M12 tp2: resize under a real tty (campaign row 7's branch) --------
1604 # Two clients, two convergence points, because convergence is a scenario's
1605 # LAST act after detach: tp2a exercises the snapshot resize prefix (a
1606 # 100x30 tty attaching to an 80x24 grid resizes the replica DOWN, then the
1607 # claim's answering snapshot resizes it back UP); tp2b exercises the winch
1608 # path mid-session and is the leg that catches row 7 — its 95-wide row is
1609 # two rows at 90 cols and one row at 100, so a replica whose width is
1610 # stale settles into a different SHAPE than the grid it is replicating.
1611 "$MUXD" run --sock "$SOCK12" --shell /bin/sh > "$OUT.tp2.d" 2>&1 &
1612 D12PID=$!
1613 i=0
1614 while [ ! -S "$SOCK12" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
1615 [ -S "$SOCK12" ] || { echo "e2e FAIL: tp2 daemon never bound"; cat "$OUT.tp2.d"; exit 1; }
1616
1617 # Two rules govern every script below, and breaking either one produces a
1618 # capture that is honestly SHORT rather than wrong — a divergence whose
1619 # diff blames the product for a scenario's mistake:
1620 #
1621 # A marker must not appear in the command line that produces it. The
1622 # shell echoes what is typed, so `echo tp2-claim` paints the needle
1623 # before it has run anything; the expect then matches the echo and the
1624 # script detaches while the OUTPUT is still in flight. `printf 'tp2-%s'
1625 # claim` keeps the literal off the typed line, so a hit is proof the
1626 # shell ran it — the same argument-splitting trick the M5 scenario uses.
1627 #
1628 # The LAST thing expected must be the prompt, not the output. A shell
1629 # writes its result and its next prompt as two separate writes, so a
1630 # script that detaches on the result can stop one frame short of the
1631 # grid the daemon holds. PS1 is set to a marker here purely so that
1632 # prompt is assertable: it is the quiesce signal that would otherwise
1633 # have to be a sleep.
1634 set +e
1635 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.tp2a" --err "$OUT.tp2a.err" -- \
1636 "$MUX" --sock "$SOCK12" > "$OUT.tp2a.log" 2>&1 <<'EOF'
1637 expect \x1b[?1049h 10000
1638 send PS1=tp2rdy@\n
1639 # Twice, and the fixture's cursor is what makes that meaningful: the first
1640 # hit is the ECHO of the assignment line, the second is the prompt the
1641 # shell painted from it. Only the second proves PS1 took effect.
1642 expect tp2rdy@ 10000
1643 expect tp2rdy@ 10000
1644 send printf 'tp2-%s\\n' claim\n
1645 expect tp2-claim 10000
1646 expect tp2rdy@ 10000
1647 send \x1c
1648 waitexit 10000
1649 EOF
1650 RC=$?
1651 set -e
1652 [ "$RC" -eq 0 ] || {
1653 echo "e2e FAIL: tp2a ptyclient exited $RC:"; cat "$OUT.tp2a.log"; exit 1; }
1654 assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30
1655
1656 set +e
1657 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \
1658 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF'
1659 expect \x1b[?1049h 10000
1660 send printf 'tp2-%s\\n' live\n
1661 expect tp2-live 10000
1662 resize 90 28
1663 expect tp2-live 10000
1664 send printf '%095d\\n' 7\n
1665 expect 00007 10000
1666 expect tp2rdy@ 10000
1667 send \x1c
1668 waitexit 10000
1669 EOF
1670 RC=$?
1671 set -e
1672 [ "$RC" -eq 0 ] || {
1673 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; }
1674 # The second `expect tp2-live` is the resize's answering snapshot, and it
1675 # is the leg's real assertion: `tp2-live` reaches the stream exactly once
1676 # per full paint (the typed line says `tp2-%s`), the cursor consumed that
1677 # one, and nothing in the session wrote anything since — so only a REPAINT
1678 # can satisfy it, and the resize is the only thing that could have asked
1679 # for one.
1680 #
1681 # `00007` is a synchronization point and NOT a discriminator, which is
1682 # worth saying plainly: the 95-wide row wraps into 90 zeros and "00007" at
1683 # 90 cols, but an unwrapped 95-char row ends in those same five bytes, so
1684 # the needle arrives either way. What the stale width would change is the
1685 # SHAPE of the grid — one row or two — and only the convergence check
1686 # below can see that. PS1 is still tp2rdy@ from tp2a: same daemon, same
1687 # shell, and the assignment outlived the client that typed it.
1688 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 90 28
1689 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
1690 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d"
1691 ok "a pty client resizes: snapshot prefix applied, winch follows the tty"
1692
1576 # The pins. Literals, not variables set from counting something else — 1693 # The pins. Literals, not variables set from counting something else —
1577 # "assert the literal, never the constant the code under test reads" 1694 # "assert the literal, never the constant the code under test reads"
1578 # (decisions.md, M10). 11 scenario checkpoints; 22 convergence points. 1695 # (decisions.md, M10). 12 scenario checkpoints; 24 convergence points.
1579 # Anyone adding a scenario updates these by hand, on purpose. 1696 # Anyone adding a scenario updates these by hand, on purpose.
1580 [ "$OK_COUNT" = "11" ] || { 1697 [ "$OK_COUNT" = "12" ] || {
1581 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 11 —" 1698 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 12 —"
1582 echo " a scenario was added (update the pin) or silently lost" 1699 echo " a scenario was added (update the pin) or silently lost"
1583 exit 1 1700 exit 1
1584 } 1701 }
1585 [ "$CONV_COUNT" = "22" ] || { 1702 [ "$CONV_COUNT" = "24" ] || {
1586 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 22" 1703 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 24"
1587 exit 1 1704 exit 1
1588 } 1705 }
1589 echo "e2e OK (11 scenarios, 22 convergence points)" 1706 echo "e2e OK (12 scenarios, 24 convergence points)"
test/ptyclient.zig
Old New
@@ -306,8 +306,14 @@ pub fn main() !void {
306 // reporting success. Read to EIO before believing the 306 // reporting success. Read to EIO before believing the
307 // exit. Bounded, because anything OTHER than the client 307 // exit. Bounded, because anything OTHER than the client
308 // still holding the slave keeps the fd open forever, 308 // still holding the slave keeps the fd open forever,
309 // and a hung suite is worse than a short capture. 309 // and a hung suite is worse than a short capture. Two
310 // bounds: a quiet poll ends the drain normally, and the
311 // verb's own deadline ends it when the holder is never
312 // quiet — a process that ignores the HUP and keeps
313 // writing satisfies every poll and would otherwise spin
314 // here forever, filling --out as it went.
310 while (try drain(alloc, &pty, out, &exp)) { 315 while (try drain(alloc, &pty, out, &exp)) {
316 if (std.time.milliTimestamp() - start > deadline_ms) break;
311 var fds = [_]std.posix.pollfd{ 317 var fds = [_]std.posix.pollfd{
312 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, 318 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
313 }; 319 };