a73x

414d282a

test: e2e — auto-start arcs (proxy + pty), muxd stop teardown, pins bumped

a73x   2026-08-10 19:05

Commit message
test: e2e — auto-start arcs (proxy + pty), muxd stop teardown, pins bumped

Two scenarios, one per auto-start call site, each ending in `muxd stop`:
the verb under test is also the teardown, and the trap only backstops it.

The proxy arc attaches over `--via "muxd proxy --sock SOCK14"` with
nothing serving that path, so the attach itself has to produce the
daemon. It pins the cold shape (`muxd proxy: starting…`, a pid on the
up-line, that pid alive), then a warm attach that must print NO spawn
progress and must reach the SAME daemon — asserted by the pid as well as
by the marker, because "a second daemon would have started a fresh
shell" is a fact a reader should not have to derive. Teardown is stop's
own pins: `muxd: stopped`, the socket gone, and the daemon OBSERVED dead
by polling `kill -0` on the tracked pid rather than believing the
command's claim. Then the idempotence control — stop with nothing there
is exit 0 and says `nothing listening on <sock>`.

The pty leg runs local `mux --sock SOCK15` under the M12 fixture with
PATH prefixed by the build dir, so findInPath must resolve exactly the
muxd under test. `mux: starting…` proves it did; the up-line's pid gives
this daemon a handle too, which the plan had assumed the suite could not
have, and stop's death is observed here the same way.

Three shape adjustments, all of them following the suite rather than the
plan's sketch:

  the pty script opens on `expect \x1b[?1049h` before the first `send`,
  as every M12 leg does. The client takes the tty raw with TCSAFLUSH
  (client.zig:520), which DISCARDS input queued before it got there —
  and on this leg that window is a whole cold spawn wide, so a leading
  `send` would be typing into /dev/null.

  the marker is printed as `printf 'pty-%s\n' auto`, and the detach
  waits on `settle 500`, not on the marker. tp2a's two rules: a marker
  must not appear in the line that produces it, and the last thing
  waited for must not be the output, because a shell writes its result
  and its next prompt as two separate writes and a capture that stops
  between them diverges from a grid that is correct.

  the fixture's own output goes to `> LOG 2>&1` behind `set +e`/`RC=$?`,
  the tp2a/tp2b shape, so a fixture failure is reported with its exit
  code and its tail rather than aborting the run bare. The plan's
  `2> LOG` would have left the fixture's stdout unredirected.

Two smaller ones: `|| true` on both `--via` invocations, for the reason
the M6 --via scenario carries it (set -e would abort on the client's
status before the labelled diagnostics print), and the warm-silence grep
is written in the suite's negative-control shape at :460 — `grep -q … &&
{ fail; }` with no trailing `|| true`, which is correct under set -e
because a failing command that is not the last of an AND-list does not
trip it.

Pin arithmetic. Scenarios 13 → 15: one `ok` per scenario, two added.
Convergence points 25 → 28, not the 27 the plan predicted:
assert_converged increments once per CALL, and the proxy arc makes two
of them — the cold attach's capture and the warm attach's, which are
different captures of different grids and neither is redundant — plus
one for the pty leg. 25 + 2 + 1 = 28, and 28 is what the run reports.

Verified: `make e2e` → `e2e OK (15 scenarios, 28 convergence points)`;
`SOAK_N=10 make soak` → 10/10 green. After both, `ps -eo comm` shows no
muxd, mux, or ptyclient process, and no muxd-e2e-*/mux-e2e-* files are
left in /tmp.

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

test/e2e.sh
Old New
@@ -75,6 +75,16 @@ TP1SH="${TMPDIR:-/tmp}/mux-e2e-tp1-$$.sh"
75 # tp1 runs its fixture in the BACKGROUND — the tear has to happen while the 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. 76 # script is mid-flight — so its pid is tracked and killed like a daemon's.
77 TP1PID="" 77 TP1PID=""
78 # M13 auto-start + stop. Two paths: the proxy arc's daemon is spawned BY
79 # the proxy, the pty leg's by local mux under the M12 fixture. Both are
80 # torn down by `muxd stop` — the verb under test is also the cleanup, and
81 # the trap only backstops it. Neither daemon is a child of this shell, so
82 # each pid is read off the up-line its own spawner printed, which is the
83 # only handle that can never name a bystander.
84 SOCK14="${TMPDIR:-/tmp}/muxd-e2e-astart-$$.sock"
85 SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock"
86 APID=""
87 PAPID=""
78 88
79 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 89 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
80 # such line on exit; every field is a key=value pair, so a rename or reorder 90 # such line on exit; every field is a key=value pair, so a rename or reorder
@@ -254,6 +264,16 @@ cleanup() {
254 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true 264 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true
255 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true 265 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true
256 [ -n "$GPID" ] && kill "$GPID" 2>/dev/null || true 266 [ -n "$GPID" ] && kill "$GPID" 2>/dev/null || true
267 # The M13 auto-started daemons, by the pid their spawner's up-line
268 # reported. Cleared by the scenarios themselves once `muxd stop` has
269 # been OBSERVED to end them, so a pid here means a failing run.
270 [ -n "$APID" ] && kill "$APID" 2>/dev/null || true
271 [ -n "$PAPID" ] && kill "$PAPID" 2>/dev/null || true
272 # ...and the same two by socket, because a spawn that failed between
273 # the fork and the up-line leaves a daemon this trap holds no pid for.
274 # `muxd stop` on a path nobody serves is a no-op that exits 0.
275 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK14" >/dev/null 2>&1 || true
276 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK15" >/dev/null 2>&1 || true
257 rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \ 277 rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \
258 "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \ 278 "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \
259 "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ 279 "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \
@@ -287,6 +307,11 @@ cleanup() {
287 "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \ 307 "$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" \ 308 "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \
289 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt" 309 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt"
310 # M13 auto-start + stop.
311 rm -f "$SOCK14" "$SOCK15" \
312 "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" \
313 "$OUT.stop" "$OUT.stop2" \
314 "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log"
290 # The convergence files a FAILING assert_converged leaves behind 315 # The convergence files a FAILING assert_converged leaves behind
291 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 316 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
292 # chased here: on a failing run they are the evidence. 317 # chased here: on a failing run they are the evidence.
@@ -1991,17 +2016,145 @@ D13PID=""
1991 rm -f "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH" 2016 rm -f "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH"
1992 ok "reconnect while scrolled: view restored, prediction resumed" 2017 ok "reconnect while scrolled: view restored, prediction resumed"
1993 2018
2019 # --- M13: attach auto-start (proxy) + muxd stop ------------------------
2020 #
2021 # Nothing is serving SOCK14: the attach itself must produce the daemon.
2022 # SHELL pinned because the auto-started daemon gets no --shell flag and
2023 # resolves $SHELL — the suite must not inherit the developer's.
2024 #
2025 # `|| true` for the same reason the M6 --via scenario has it: without it
2026 # `set -e` aborts the run on the client's exit status before the labelled
2027 # diagnostics below get to print the stderr that says why. `timeout` is
2028 # what keeps that from being a licence to hang.
2029 { printf 'printf "auto-%%s\\n" start\n'; sleep 2; printf '\034'; } | \
2030 SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14" \
2031 > "$OUT.as" 2> "$OUT.as.err" || true
2032 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-start" || {
2033 echo "e2e FAIL: auto-started daemon lost the marker"
2034 cat "$OUT.as.err"; exit 1; }
2035 grep -q '^muxd proxy: starting' "$OUT.as.err" || {
2036 echo "e2e FAIL: cold attach printed no starting line"; cat "$OUT.as.err"; exit 1; }
2037 # The pid is the whole point of the up-line: it is the only handle the
2038 # suite has on a daemon that is nobody's child, so it is read before
2039 # anything else here can fail and leave one unreachable.
2040 APID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.as.err" | head -1)
2041 [ -n "$APID" ] || { echo "e2e FAIL: proxy up-line carries no pid"; cat "$OUT.as.err"; exit 1; }
2042 kill -0 "$APID" || { echo "e2e FAIL: auto-started daemon not alive"; exit 1; }
2043 assert_converged "$OUT.as" "$SOCK14" "auto-start via proxy"
2044
2045 # Silence is the fast path: a warm attach must print NO spawn progress.
2046 { printf 'printf "auto-%%s\\n" again\n'; sleep 2; printf '\034'; } | \
2047 SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14" \
2048 > "$OUT.as2" 2> "$OUT.as2.err" || true
2049 grep -q 'starting' "$OUT.as2.err" && {
2050 echo "e2e FAIL: warm attach printed spawn progress"
2051 cat "$OUT.as2.err"; exit 1; }
2052 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-again" || {
2053 echo "e2e FAIL: warm attach did not reach the same daemon"; exit 1; }
2054 # The same daemon, asserted by pid rather than by the marker alone: a
2055 # second daemon on the path would have started a fresh shell, but a
2056 # reader should not have to reason that out from a missing string.
2057 kill -0 "$APID" || { echo "e2e FAIL: warm attach replaced the daemon"; exit 1; }
2058 assert_converged "$OUT.as2" "$SOCK14" "auto-start warm attach"
2059
2060 # The verb under test is the teardown: stopped line, exit 0, socket gone,
2061 # and the daemon OBSERVED dead by pid — never the command's claim alone.
2062 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop"
2063 grep -q '^muxd: stopped' "$OUT.stop" || {
2064 echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; }
2065 [ ! -S "$SOCK14" ] || { echo "e2e FAIL: stop left the socket behind"; exit 1; }
2066 # `muxd: stopped` is printed on the first probe that gets a refusal, which
2067 # is the socket being unlinked — the process itself can still be a
2068 # fraction behind it. Hence the poll: 2s, the same budget the stop verb
2069 # gives itself, and only then a failure.
2070 _i=0
2071 while kill -0 "$APID" 2>/dev/null; do
2072 _i=$((_i + 1)); [ "$_i" -lt 40 ] || {
2073 echo "e2e FAIL: stop reported stopped but pid $APID still runs"; exit 1; }
2074 sleep 0.05
2075 done
2076 APID=""
2077
2078 # Idempotence control: stop with nothing there is exit 0 and says so.
2079 set +e
2080 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop2"
2081 RC_STOP=$?
2082 set -e
2083 [ "$RC_STOP" = "0" ] || {
2084 echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; }
2085 grep -q "nothing listening on $SOCK14" "$OUT.stop2" || {
2086 echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; }
2087 rm -f "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" "$OUT.stop" "$OUT.stop2"
2088 ok "attach auto-start via proxy; muxd stop tears it down"
2089
2090 # --- M13: local mux auto-start, under the pty fixture ------------------
2091 #
2092 # A local attach is tty-gated territory, so it runs on the M12 fixture.
2093 # PATH is prefixed with the build dir: findInPath must resolve exactly
2094 # the muxd under test, and the suite proves the resolution by the daemon
2095 # existing afterwards.
2096 #
2097 # The script keeps the two M12 rules that every pty leg is written to:
2098 # it opens on the alt-screen enter rather than on a `send`. The client
2099 # takes the tty raw with TCSAFLUSH (client.zig), which DISCARDS input
2100 # queued before it got there — and here that window is a whole cold
2101 # spawn wide, so typing into it is typing into /dev/null;
2102 # the marker text never appears in the line that produces it, and the
2103 # detach waits on `settle` rather than on the marker. A shell writes its
2104 # output and its next prompt as two separate writes, so a script that
2105 # detaches on the output stops one frame short of the grid the daemon
2106 # holds, and the convergence check below would blame the product for it.
2107 set +e
2108 SHELL=/bin/sh PATH="$(dirname "$MUXD"):$PATH" timeout 40 \
2109 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pa" --err "$OUT.pa.err" \
2110 -- "$MUX" --sock "$SOCK15" > "$OUT.pa.log" 2>&1 <<'EOF'
2111 expect \x1b[?1049h 15000
2112 send printf 'pty-%s\\n' auto\n
2113 expect pty-auto 15000
2114 settle 500 15000
2115 send \x1c
2116 waitexit 10000
2117 EOF
2118 RC=$?
2119 set -e
2120 [ "$RC" -eq 0 ] || {
2121 echo "e2e FAIL: local auto-start ptyclient exited $RC:"
2122 cat "$OUT.pa.log"; cat -v "$OUT.pa.err" 2>/dev/null; exit 1; }
2123 "$MUXD" dump --sock "$SOCK15" | grep -q "pty-auto" || {
2124 echo "e2e FAIL: local auto-start lost the marker"
2125 cat "$OUT.pa.log" "$OUT.pa.err" 2>/dev/null; exit 1; }
2126 # The fixture hands the client a FILE for stderr, so the progress lines
2127 # are non-tty ones and land here rather than on the pty — which is also
2128 # why the capture the convergence check reads is undisturbed by them.
2129 grep -q '^mux: starting' "$OUT.pa.err" || {
2130 echo "e2e FAIL: local auto-start printed no mux-prefixed starting line"
2131 cat "$OUT.pa.err"; exit 1; }
2132 PAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.pa.err" | head -1)
2133 [ -n "$PAPID" ] || { echo "e2e FAIL: mux up-line carries no pid"; cat "$OUT.pa.err"; exit 1; }
2134 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start"
2135 "$MUXD" stop --sock "$SOCK15" 2>/dev/null
2136 [ ! -S "$SOCK15" ] || { echo "e2e FAIL: stop left the pty leg's socket"; exit 1; }
2137 _i=0
2138 while kill -0 "$PAPID" 2>/dev/null; do
2139 _i=$((_i + 1)); [ "$_i" -lt 40 ] || {
2140 echo "e2e FAIL: stop reported stopped but pid $PAPID still runs"; exit 1; }
2141 sleep 0.05
2142 done
2143 PAPID=""
2144 rm -f "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log"
2145 ok "local mux auto-start under the pty fixture"
2146
1994 # The pins. Literals, not variables set from counting something else — 2147 # The pins. Literals, not variables set from counting something else —
1995 # "assert the literal, never the constant the code under test reads" 2148 # "assert the literal, never the constant the code under test reads"
1996 # (decisions.md, M10). 13 scenario checkpoints; 25 convergence points. 2149 # (decisions.md, M10). 15 scenario checkpoints; 28 convergence points.
1997 # Anyone adding a scenario updates these by hand, on purpose. 2150 # Anyone adding a scenario updates these by hand, on purpose.
1998 [ "$OK_COUNT" = "13" ] || { 2151 [ "$OK_COUNT" = "15" ] || {
1999 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 13 —" 2152 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 15 —"
2000 echo " a scenario was added (update the pin) or silently lost" 2153 echo " a scenario was added (update the pin) or silently lost"
2001 exit 1 2154 exit 1
2002 } 2155 }
2003 [ "$CONV_COUNT" = "25" ] || { 2156 [ "$CONV_COUNT" = "28" ] || {
2004 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 25" 2157 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 28"
2005 exit 1 2158 exit 1
2006 } 2159 }
2007 echo "e2e OK (13 scenarios, 25 convergence points)" 2160 echo "e2e OK (15 scenarios, 28 convergence points)"