a73x

ae9d7790

test: leak sweep survives failure; ports are observations

a73x   2026-08-14 12:46

Commit message
test: leak sweep survives failure; ports are observations

e2e's leak sweep ran as the suite's last lines, so set -e meant any
earlier failure skipped it — a leak arriving with a failure was never
reported — and captures rm'd mid-suite were gone before it looked. The
sweep now runs in the EXIT trap, in a window between all the kills and
all the rms (with a bounded reap so killed daemons finish writing their
verdicts), and the eighteen mid-suite rms go through rm_swept, which
banks a capture's verdict at the moment of deletion — the recorded
$OUT.q debt, paid. The trap moves the status one way only: a leak
promotes green to red, never masks the suite's own code (_rc is
captured on the trap's first line, before anything can overwrite it).
The nothing-to-read canary still fails any would-be-green run and
degrades to a note on an already-failing early exit, where a fabricated
second defect would bury the real one. Report lines are cut back to the
verdict (a client capture's "line" is an escape-stream replay that
repaints the reader's terminal) and greps are -a (those captures make
grep answer "Binary file matches", which lacks the marker).

agent.sh's leakcheck read one muxd.log truncated per spawn — only the
most recent detached daemon ever had a verdict. Every daemon now gets
its own log via a per-scenario XDG_STATE_HOME (start exposes no --log;
the parent derives the path from the environment), leakcheck reads its
daemon's own file and a missing log is a failure, and an end-of-run
sweep behind the count pin covers the daemons leakcheck never saw —
session-ended, destroyed, or abandoned by a failing scenario — with a
vacuous-green guard on zero logs.

The $$-derived ports (a band inside tuned ephemeral ranges; two suites
congruent mod 900 collide) are now first candidates only: bind-with-
retry steps on the daemon's own "already listening on udp" refusal —
any other failure reports immediately rather than marching a real
defect through twelve ports — and the port written back is the one the
up-line named, so every dial targets what was bound. Same treatment in
valgrind-quic.sh, whose muxd log is now readable for the purpose.

deadcode.sh: literal matching (-F/-qxF) — a filename is never a regex.
The -qv multi-line semantics were audited and are correct as written
(the defining file is always in the list, so "some line is not $f" is
exactly "referenced outside"); a metacharacter filename previously
produced a silent false negative, shown by fixture.

All trap paths, the per-daemon sweep, the port retry and the write-back
were each proven by deliberate fault before being left green; both
suites pass whole on the final tree (e2e 23 scenarios / 35 convergence
points, agent 9/9).

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

test/agent.sh
Old New
@@ -60,13 +60,13 @@ SOCK_SETTLE="$TMP/settle.sock"
60 # very thing it is pinning, so it cannot share the reduced-idle daemon). 60 # very thing it is pinning, so it cannot share the reduced-idle daemon).
61 SOCK_TEAR="$TMP/tear.sock" 61 SOCK_TEAR="$TMP/tear.sock"
62 SOCK_QUIET="$TMP/quiet.sock" 62 SOCK_QUIET="$TMP/quiet.sock"
63 # Ports in a band of their own so a concurrent test/e2e.sh (11000..46000, in 63 # The four UDP ports are picked further down, after the trap: picking one
64 # 5000-wide slots) cannot collide, and per-run so two agent suites can overlap. 64 # binds a socket, and everything that can fail from here on must be able to
65 # All four are ports we BIND, which is what makes the ephemeral range safe here. 65 # take the tmpdir with it.
66 PORT_TEAR=$((51000 + ($$ % 900))) 66 PORT_TEAR=""
67 PORT_RELAY=$((52000 + ($$ % 900))) 67 PORT_RELAY=""
68 PORT_QUIET=$((53000 + ($$ % 900))) 68 PORT_QUIET=""
69 PORT_SINK=$((54000 + ($$ % 900))) 69 PORT_SINK=""
70 70
71 KEY="$TMP/key" 71 KEY="$TMP/key"
72 RELAY="$TMP/relay.py" 72 RELAY="$TMP/relay.py"
@@ -113,6 +113,59 @@ cleanup() {
113 } 113 }
114 trap 'cleanup' EXIT INT TERM 114 trap 'cleanup' EXIT INT TERM
115 115
116 # --- ports ------------------------------------------------------------------
117 # A band of its own so a concurrent test/e2e.sh (11000..50000, in 5000-wide
118 # slots) cannot collide, and derived from $$ so two agent suites usually start
119 # from different numbers. All four are ports we BIND — but "we bind it" makes
120 # a derived number a GUESS, not a fact, and this band sits INSIDE the
121 # ephemeral range on a stock box (32768-60999) and on a tuned one
122 # (ip_local_port_range is routinely lowered into five figures). Two ways it
123 # goes wrong, and they are the same bind failure: an unrelated outgoing
124 # connection is already holding the number, or two suites whose pids are
125 # congruent mod 900 derived the same four.
126 #
127 # So the number is turned into an observation before it is used: free_port
128 # BINDS each candidate and hands back the first one the kernel actually gave
129 # it, stepping by PORT_STEP. That leaves only the window between the probe's
130 # close and the daemon's own bind, which start_quic below covers by retrying
131 # on a fresh candidate — see there for why the daemon's up-line, not this
132 # probe, is what finally decides which port a scenario dials.
133 PORT_STEP=13
134 PORT_TRIES=12
135 free_port() {
136 python3 - "$1" "$PORT_TRIES" "$PORT_STEP" <<'PY'
137 import socket, sys
138 port, tries, step = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3])
139 for _ in range(tries):
140 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
141 try:
142 s.bind(("127.0.0.1", port))
143 except OSError:
144 port += step
145 continue
146 finally:
147 s.close()
148 print(port)
149 raise SystemExit(0)
150 raise SystemExit(1)
151 PY
152 }
153
154 # pick_port VAR BASE — free_port, with the refusal spelled out. A box that
155 # cannot hand out one of twelve candidates is not a box this suite can run
156 # on, and saying so beats every downstream scenario failing at its dial.
157 pick_port() {
158 _pp=$(free_port "$2") || {
159 echo "agent FAIL: no bindable UDP port in $PORT_TRIES candidates from $2"
160 exit 1
161 }
162 eval "$1=\$_pp"
163 }
164 pick_port PORT_TEAR $((51000 + ($$ % 900)))
165 pick_port PORT_RELAY $((52000 + ($$ % 900)))
166 pick_port PORT_QUIET $((53000 + ($$ % 900)))
167 pick_port PORT_SINK $((54000 + ($$ % 900)))
168
116 PASSES=0 169 PASSES=0
117 FAILS=0 170 FAILS=0
118 SKIPS=0 171 SKIPS=0
@@ -199,11 +252,17 @@ wait_for() {
199 return 1 252 return 1
200 } 253 }
201 254
255 # Where a given daemon's OWN stdout+stderr went — its allocator verdict
256 # among them (hygiene kit, 6a). One per daemon, by tag; see start_daemon.
257 daemon_log() { echo "$TMP/state/$1/mux/muxd.log"; }
258
202 # After a clean `muxd stop`, the daemon's whole lifecycle has run and its 259 # After a clean `muxd stop`, the daemon's whole lifecycle has run and its
203 # log carries the allocator's verdict (hygiene kit, 6a). The log is 260 # log carries the allocator's verdict (hygiene kit, 6a). The check runs only
204 # truncated at every spawn, so the check must run NOW, before the next 261 # after the process is actually gone, or the grep races the exit path it is
205 # scenario's daemon comes up — and only after the process is actually 262 # asserting about.
206 # gone, or the grep races the exit path it is asserting about. 263 #
264 # leakcheck PID TAG — TAG names the daemon's own log, so this asserts about
265 # THAT daemon rather than about whichever one spawned most recently.
207 leakcheck() { 266 leakcheck() {
208 _i=0 267 _i=0
209 while kill -0 "$1" 2>/dev/null && [ "$_i" -lt 100 ]; do 268 while kill -0 "$1" 2>/dev/null && [ "$_i" -lt 100 ]; do
@@ -215,8 +274,11 @@ leakcheck() {
215 # would read a stale log and call it healthy — swallowing both the 274 # would read a stale log and call it healthy — swallowing both the
216 # unreadable verdict AND the `muxd stop` that failed to kill anything. 275 # unreadable verdict AND the `muxd stop` that failed to kill anything.
217 kill -0 "$1" 2>/dev/null && { why "daemon still alive ${_i}x50ms after stop — leak verdict unreadable"; return 1; } 276 kill -0 "$1" 2>/dev/null && { why "daemon still alive ${_i}x50ms after stop — leak verdict unreadable"; return 1; }
218 _dl="$XDG_STATE_HOME/mux/muxd.log" 277 _dl=$(daemon_log "$2")
219 [ -f "$_dl" ] || return 0 278 # A missing log is a broken convention, not a clean daemon: `muxd start`
279 # creates this file before it forks, so every daemon that ever existed has
280 # one. Passing on its absence is how this gate would go quietly vacuous.
281 [ -f "$_dl" ] || { why "no daemon log at $_dl — the leak verdict was never captured"; return 1; }
220 grep -q "LEAK:" "$_dl" || return 0 282 grep -q "LEAK:" "$_dl" || return 0
221 why "daemon leaked: $(grep 'LEAK:' "$_dl" | head -1)" 283 why "daemon leaked: $(grep 'LEAK:' "$_dl" | head -1)"
222 } 284 }
@@ -224,10 +286,22 @@ leakcheck() {
224 # Start a detached daemon and hand back the pid IT reported. Never a pid this 286 # Start a detached daemon and hand back the pid IT reported. Never a pid this
225 # script guessed from a process name: the suite kills what it started, and a 287 # script guessed from a process name: the suite kills what it started, and a
226 # name match can only ever name a bystander. 288 # name match can only ever name a bystander.
289 #
290 # $_log catches what `muxd start` ITSELF prints (the up-line, or the refusal).
291 # The daemon's own output goes somewhere else entirely: start detaches the
292 # child onto $XDG_STATE_HOME/mux/muxd.log — a path computed from the
293 # environment `muxd start` is run with — and TRUNCATES it at every spawn. One
294 # state home for the whole suite would therefore leave exactly one daemon's
295 # allocator verdict readable, the last one's, with every earlier verdict
296 # overwritten by the next scenario's spawn. So each daemon gets a state home
297 # of its own, tagged by its log's name, and every daemon's verdict survives
298 # to leakcheck and to the sweep at the bottom of this file.
227 start_daemon() { 299 start_daemon() {
228 _log="$1" 300 _log="$1"
229 shift 301 shift
230 "$MUXD" start "$@" >"$_log" 2>&1 302 _tag=$(basename "$_log" .log)
303 mkdir -p "$TMP/state/$_tag/mux" || return 1
304 XDG_STATE_HOME="$TMP/state/$_tag" "$MUXD" start "$@" >"$_log" 2>&1
231 sed -n 's/^up .*pid=\([0-9]*\).*/\1/p' "$_log" | head -1 305 sed -n 's/^up .*pid=\([0-9]*\).*/\1/p' "$_log" | head -1
232 } 306 }
233 307
@@ -270,8 +344,62 @@ start_ready() {
270 return 0 344 return 0
271 } 345 }
272 346
347 # start_quic PORTVAR PIDVAR LOG SOCK ARGS... — start_ready for a daemon that
348 # also holds a UDP port, brought up on the first candidate port it can
349 # actually BIND. `--sock SOCK` and `--quic 127.0.0.1:PORT` are supplied here;
350 # ARGS carries the rest.
351 #
352 # The retry is the load-bearing half. free_port proved the number bindable a
353 # moment ago, and between that proof and this bind a concurrent suite or an
354 # outgoing connection can take it — muxd holds no SO_REUSEADDR and refuses to
355 # share a port rather than split its datagrams, so it prints `a daemon is
356 # already listening on udp ...` into its own log and exits, and `muxd start`
357 # reports no up-line. That refusal is the ONLY failure another port can fix,
358 # and it is told apart from the rest by the daemon's own words: anything else
359 # stops here and is reported, because a muxd that cannot start is a defect and
360 # marching it through twelve ports would only bury it under a port sweep.
361 #
362 # PORTVAR is written back with the port the daemon reported an up-line on, so
363 # everything downstream — muxa's --quic, the relay's target — dials what was
364 # BOUND rather than what this suite first derived.
365 start_quic() {
366 _qpv="$1"
367 _qpidv="$2"
368 _qlog="$3"
369 _qsock="$4"
370 shift 4
371 eval "_qp=\$$_qpv"
372 _qn=0
373 while : ; do
374 _qpid=$(start_daemon "$_qlog" --sock "$_qsock" --quic "127.0.0.1:$_qp" "$@")
375 if [ -n "$_qpid" ]; then
376 # Both written the INSTANT they are known, before the readiness
377 # wait and whatever it decides — start_ready's rule, for its
378 # reason: a daemon that came up and then never answered is still a
379 # daemon this run started, and cleanup has to be able to reach it.
380 eval "$_qpidv=\$_qpid"
381 eval "$_qpv=\$_qp"
382 wait_ready "$_qsock" ||
383 { why "the QUIC daemon (pid $_qpid) never answered on $_qsock"; return 1; }
384 return 0
385 fi
386 grep -q "listening on udp" "$(daemon_log "$(basename "$_qlog" .log)")" 2>/dev/null || {
387 why "the QUIC daemon printed no up-line [$(tr -d '\n' < "$_qlog")]"
388 return 1
389 }
390 _qn=$((_qn + 1))
391 [ "$_qn" -lt "$PORT_TRIES" ] ||
392 { why "$_qn candidate udp ports, up to $_qp, were all taken"; return 1; }
393 _qp=$(free_port $((_qp + PORT_STEP))) ||
394 { why "no bindable UDP port left above $_qp"; return 1; }
395 done
396 }
397
273 now_ms() { python3 -c 'import time; print(int(time.time() * 1000))'; } 398 now_ms() { python3 -c 'import time; print(int(time.time() * 1000))'; }
274 399
400 # The ports as picked. The two daemon ports can still move from here — a
401 # daemon that loses a race for one is restarted on the next candidate (see
402 # start_quic), and the scenarios dial whatever it bound.
275 echo "agent: ports ${PORT_TEAR}/${PORT_RELAY}/${PORT_QUIET}/${PORT_SINK}, tmp $TMP" 403 echo "agent: ports ${PORT_TEAR}/${PORT_RELAY}/${PORT_QUIET}/${PORT_SINK}, tmp $TMP"
276 404
277 # --- 1: marks. A shell with OSC 133 injected knows its own exit codes ------- 405 # --- 1: marks. A shell with OSC 133 injected knows its own exit codes -------
@@ -309,7 +437,7 @@ scen_marks() {
309 want "$TMP/m3" output "\"$_mark\"" || return 1 437 want "$TMP/m3" output "\"$_mark\"" || return 1
310 438
311 "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1 439 "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1
312 leakcheck "$D_MARKS" || return 1 440 leakcheck "$D_MARKS" marks || return 1
313 D_MARKS="" 441 D_MARKS=""
314 return 0 442 return 0
315 } 443 }
@@ -462,7 +590,7 @@ scen_settle() {
462 want "$TMP/s1" exit_code null || return 1 590 want "$TMP/s1" exit_code null || return 1
463 591
464 "$MUXD" stop --sock "$SOCK_SETTLE" >/dev/null 2>&1 592 "$MUXD" stop --sock "$SOCK_SETTLE" >/dev/null 2>&1
465 leakcheck "$D_SETTLE" || return 1 593 leakcheck "$D_SETTLE" settle || return 1
466 D_SETTLE="" 594 D_SETTLE=""
467 return 0 595 return 0
468 } 596 }
@@ -546,13 +674,8 @@ if [ -z "$QUIC_FAIL" ]; then
546 # and this daemon's 4s governs both — muxa has no idle flag of its own, 674 # and this daemon's 4s governs both — muxa has no idle flag of its own,
547 # and waiting out its 15s default twice would be most of this suite's 675 # and waiting out its 15s default twice would be most of this suite's
548 # runtime. The quiet-await scenario below deliberately does not use it. 676 # runtime. The quiet-await scenario below deliberately does not use it.
549 D_TEAR=$(start_daemon "$TMP/tear.log" --sock "$SOCK_TEAR" --shell /bin/bash \ 677 start_quic PORT_TEAR D_TEAR "$TMP/tear.log" "$SOCK_TEAR" --shell /bin/bash \
550 --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --quic-idle-ms 4000) 678 --key "$KEY" --quic-idle-ms 4000 || QUIC_FAIL="$WHY"
551 if [ -z "$D_TEAR" ]; then
552 QUIC_FAIL="the QUIC daemon printed no up-line [$(tr -d '\n' < "$TMP/tear.log")]"
553 elif ! wait_ready "$SOCK_TEAR"; then
554 QUIC_FAIL="the QUIC daemon (pid $D_TEAR) never answered on $SOCK_TEAR"
555 fi
556 fi 679 fi
557 if [ -z "$QUIC_FAIL" ]; then 680 if [ -z "$QUIC_FAIL" ]; then
558 python3 "$RELAY" "$PORT_RELAY" "$PORT_TEAR" "$CTL_FLOW" "$CTL_ALL" >"$RELAY_LOG" 2>&1 & 681 python3 "$RELAY" "$PORT_RELAY" "$PORT_TEAR" "$CTL_FLOW" "$CTL_ALL" >"$RELAY_LOG" 2>&1 &
@@ -668,8 +791,8 @@ run_scenario "quic: a tear with no path back fails with the whole story" scen_te
668 # happened, and the honest answer (still running) would have been one field. 791 # happened, and the honest answer (still running) would have been one field.
669 scen_keepalive() { 792 scen_keepalive() {
670 quic_gate || return $? 793 quic_gate || return $?
671 start_ready D_QUIET "$TMP/quiet.log" "$SOCK_QUIET" --sock "$SOCK_QUIET" --shell /bin/bash \ 794 start_quic PORT_QUIET D_QUIET "$TMP/quiet.log" "$SOCK_QUIET" --shell /bin/bash \
672 --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" || return 1 795 --key "$KEY" || return 1
673 796
674 timeout 40 "$MUXA" await --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" --timeout 20000 >"$TMP/q3" 2>&1 797 timeout 40 "$MUXA" await --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" --timeout 20000 >"$TMP/q3" 2>&1
675 _rc=$? 798 _rc=$?
@@ -682,7 +805,7 @@ scen_keepalive() {
682 why "duration_ms=$_dur — the wait did not survive the 15s idle timeout" || return 1 805 why "duration_ms=$_dur — the wait did not survive the 15s idle timeout" || return 1
683 806
684 "$MUXD" stop --sock "$SOCK_QUIET" >/dev/null 2>&1 807 "$MUXD" stop --sock "$SOCK_QUIET" >/dev/null 2>&1
685 leakcheck "$D_QUIET" || return 1 808 leakcheck "$D_QUIET" quiet || return 1
686 D_QUIET="" 809 D_QUIET=""
687 return 0 810 return 0
688 } 811 }
@@ -766,6 +889,50 @@ if [ "$TOTAL" -ne 9 ]; then
766 FAILS=$((FAILS + 1)) 889 FAILS=$((FAILS + 1))
767 fi 890 fi
768 891
892 # ---- per-daemon leak sweep (hygiene kit, 6a) ------------------------------
893 # Every daemon this run started wrote its verdict into a log of its own (see
894 # start_daemon), so this reads ALL of them rather than the most recent one's.
895 # leakcheck already gated the three scenarios that stop their own daemon; what
896 # lands here is everything else — the TUI daemon its own session ended, the
897 # QUIC daemon scenario 8 destroyed, and any daemon a FAILING scenario left
898 # behind, which is exactly the case that used to go unswept.
899 #
900 # After the count pin on purpose: a leak is not a scenario, and folding it
901 # into PASSES+FAILS+SKIPS would make that pin's number stop meaning "nine
902 # scenarios ran".
903 for _s in "$SOCK_MARKS" "$SOCK_TUI" "$SOCK_SETTLE" "$SOCK_TEAR" "$SOCK_QUIET"; do
904 [ -S "$_s" ] && "$MUXD" stop --sock "$_s" >/dev/null 2>&1
905 done
906 # The verdict is written on the way out, so a daemon still running has not
907 # written one yet. Bounded, and bounded rather than `wait`ed on because these
908 # are not this shell's children and because a sweep must never be the thing
909 # that hangs: a daemon that outlives the wait is read anyway and reports
910 # whatever it had written, which is the honest answer for a daemon that would
911 # not die.
912 _i=0
913 while [ "$_i" -lt 60 ]; do
914 _live=""
915 for _p in "$D_MARKS" "$D_TUI" "$D_SETTLE" "$D_TEAR" "$D_QUIET"; do
916 [ -n "$_p" ] || continue
917 if kill -0 "$_p" 2>/dev/null; then _live=1; fi
918 done
919 [ -n "$_live" ] || break
920 sleep 0.05
921 _i=$((_i + 1))
922 done
923 _logs=0
924 for _dl in "$TMP"/state/*/mux/muxd.log; do
925 [ -f "$_dl" ] || continue
926 _logs=$((_logs + 1))
927 if grep -q "LEAK:" "$_dl"; then
928 fail "leak sweep: the $(basename "$(dirname "$(dirname "$_dl")")") daemon leaked: $(grep 'LEAK:' "$_dl" | head -1)"
929 fi
930 done
931 # Vacuous-green guard: at least one daemon is started before any scenario can
932 # report anything, so a sweep with nothing to read means the log convention
933 # broke and this gate looked at nothing — a failure, never a silent pass.
934 [ "$_logs" -gt 0 ] || fail "leak sweep found no daemon logs to read under $TMP/state"
935
769 echo "agent: $PASSES passed, $FAILS failed, $SKIPS skipped" 936 echo "agent: $PASSES passed, $FAILS failed, $SKIPS skipped"
770 [ "$FAILS" -eq 0 ] || exit 1 937 [ "$FAILS" -eq 0 ] || exit 1
771 exit 0 938 exit 0
test/e2e.sh
Old New
@@ -428,7 +428,143 @@ ok() {
428 echo "e2e OK: $1" 428 echo "e2e OK: $1"
429 } 429 }
430 430
431 # ---- the leak sweep's two halves (hygiene kit, 6a) ---------------------
432 # Every capture this suite writes — daemon stderr AND client output — is a
433 # lifecycle log: a binary that leaked printed a `LEAK:` marker into one. The
434 # verdict is read in the EXIT trap, which is the only place that sees a
435 # FAILING run too; under `set -e` the bottom of this file is reached by
436 # passing runs alone, and a leak introduced alongside a defect is exactly
437 # the pair a suite should report together.
438 #
439 # That leaves the captures the scenarios delete as they go. A file removed
440 # at line 900 is not there for a trap at line 3000 to read, so removal is
441 # where its verdict has to be observed: rm_swept banks the marker first and
442 # deletes second, and the bank is what the trap reads for everything that no
443 # longer exists. Mid-suite removal of a capture goes through here — a plain
444 # `rm -f` of one is a verdict thrown away.
445 LEAKBANK="$OUT.leakbank"
446
447 # leak_line FILE — the verdict FILE holds, named and made readable. Never the
448 # matched line verbatim: half these captures are escape streams, whose one
449 # "line" is the entire session replay, and printing that repaints the reader's
450 # terminal instead of reporting to it. A log line short enough to BE a log
451 # line is kept whole (it names the binary, which is worth having); anything
452 # longer is cut back to the verdict. `grep -a` because the same captures make
453 # grep answer "Binary file ... matches", which does not even contain the
454 # marker a reader — or the sweep's own grep over the bank — is looking for.
455 leak_line() {
456 _ll=$(grep -a "LEAK:" "$1" 2>/dev/null | head -1 | tr -d '\000-\011\013-\037')
457 if [ "${#_ll}" -gt 120 ]; then
458 _ll=$(printf '%s\n' "$_ll" | sed 's/.*\(LEAK:\)/\1/' | cut -c1-120)
459 fi
460 case "$_ll" in
461 *LEAK:*) ;;
462 *) _ll="LEAK: marker present, unreadable as text" ;;
463 esac
464 echo "$1: $_ll"
465 }
466
467 rm_swept() {
468 for _rs in "$@"; do
469 # Regular files only: this list carries sockets and generated
470 # scripts too, and a socket path is not something to open.
471 [ -f "$_rs" ] || continue
472 if grep -q "LEAK:" "$_rs" 2>/dev/null; then
473 leak_line "$_rs" >> "$LEAKBANK"
474 fi
475 done
476 rm -f "$@"
477 }
478
479 # leak_sweep RC — the whole-suite verdict. RC is the suite's own exit status
480 # and decides one thing: what an empty sweep means.
481 #
482 # On a green run, nothing to read is a failure — the vacuous-green guard this
483 # gate has always had. $OUT.d1.d is created when daemon one is spawned and
484 # lives until this trap, and the bank exists once a verdict has been banked
485 # out of a deleted capture, so neither being there means the capture
486 # convention broke and this gate is reading nothing. On a run that is ALREADY
487 # failing, the same emptiness means something else entirely: an early exit,
488 # before the first daemon ever existed. That is not a second defect, and
489 # reporting it as one would stack a fabricated failure on top of the real
490 # one, so it says what it saw and returns clean.
491 #
492 # It never speaks for the suite either way: the caller promotes this verdict
493 # only over a green run (see cleanup).
494 leak_sweep() {
495 _lsrc="$1"
496 _lsbad=0
497 if [ ! -e "$OUT.d1.d" ] && [ ! -e "$LEAKBANK" ]; then
498 if [ "$_lsrc" -eq 0 ]; then
499 echo "e2e FAIL: leak sweep found no captures to read"
500 return 1
501 fi
502 echo "e2e note: leak sweep had nothing to read — the run exited $_lsrc" \
503 "before the first daemon"
504 return 0
505 fi
506 # The captures still on disk, one report line each. `"$OUT"` is named
507 # alongside the glob because the first client capture is written to the
508 # bare path, and `"$OUT".*` does not match it.
509 _lssaid=""
510 for _lsf in "$OUT" "$OUT".*; do
511 if [ ! -f "$_lsf" ]; then continue; fi
512 # The bank is not a capture; it is printed whole below, and running it
513 # through leak_line would report only its first entry.
514 if [ "$_lsf" = "$LEAKBANK" ]; then continue; fi
515 if grep -q "LEAK:" "$_lsf" 2>/dev/null; then
516 if [ -z "$_lssaid" ]; then
517 echo "e2e FAIL: a binary reported leaked allocations:"
518 _lssaid=1
519 fi
520 leak_line "$_lsf"
521 _lsbad=1
522 fi
523 done
524 # ...and the verdicts banked out of captures the suite deleted as it went.
525 if [ -s "$LEAKBANK" ]; then
526 echo "e2e FAIL: a binary reported leaked allocations into a capture" \
527 "the suite has since deleted:"
528 cat "$LEAKBANK"
529 _lsbad=1
530 fi
531 # The detached (`muxd start`) daemons log via XDG_STATE_HOME; the file is
532 # truncated at every spawn, so this asserts the LAST such daemon only —
533 # stated, not hidden.
534 if [ -f "$XDG_STATE_HOME/mux/muxd.log" ] &&
535 grep -q "LEAK:" "$XDG_STATE_HOME/mux/muxd.log"; then
536 echo "e2e FAIL: a detached daemon reported leaked allocations:"
537 grep -H "LEAK:" "$XDG_STATE_HOME/mux/muxd.log" || true
538 _lsbad=1
539 fi
540 [ "$_lsbad" -eq 0 ]
541 }
542
543 # A killed daemon writes its leak verdict on the way OUT, so a sweep that
544 # reads before the process is gone reads a file the verdict has not reached.
545 # Bounded, and shared across the pids rather than per-pid: a trap must never
546 # be the thing that hangs, and a daemon that outlives the wait is swept for
547 # whatever it did write.
548 reap_briefly() {
549 _rbi=0
550 while [ "$_rbi" -lt 40 ]; do
551 _rblive=""
552 for _rbp in "$@"; do
553 [ -n "$_rbp" ] || continue
554 if kill -0 "$_rbp" 2>/dev/null; then _rblive=1; fi
555 done
556 [ -n "$_rblive" ] || return 0
557 sleep 0.05
558 _rbi=$((_rbi + 1))
559 done
560 return 0
561 }
562
431 cleanup() { 563 cleanup() {
564 # The suite's own status, captured before anything in here can overwrite
565 # it. Every verdict below is composed onto THIS number, never in place of
566 # it: a run that failed at scenario 6 exits with scenario 6's failure.
567 _rc=$?
432 # `${DPID:-}` rather than `$DPID`: DPID is the one variable this trap 568 # `${DPID:-}` rather than `$DPID`: DPID is the one variable this trap
433 # reads that is assigned AFTER the trap is installed, and the scenarios 569 # reads that is assigned AFTER the trap is installed, and the scenarios
434 # above the first daemon (--version) can fail before it ever is. Under 570 # above the first daemon (--version) can fail before it ever is. Under
@@ -484,6 +620,38 @@ cleanup() {
484 [ -n "$H5PID" ] && kill "$H5PID" 2>/dev/null || true 620 [ -n "$H5PID" ] && kill "$H5PID" 2>/dev/null || true
485 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK16" >/dev/null 2>&1 || true 621 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK16" >/dev/null 2>&1 || true
486 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK17" >/dev/null 2>&1 || true 622 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK17" >/dev/null 2>&1 || true
623 # M-web: hubs and daemons by tracked pid, then stop-by-socket for any
624 # daemon that died between fork and up-line. Hoisted up here with the
625 # other kills so that every kill precedes the sweep below — a daemon
626 # killed AFTER it would be swept before it had written its verdict, and
627 # the sweep's answer would be about a lifecycle that had not ended.
628 [ -n "$W1PID" ] && kill "$W1PID" 2>/dev/null || true
629 [ -n "$W2PID" ] && kill "$W2PID" 2>/dev/null || true
630 [ -n "$WCLIPID" ] && kill "$WCLIPID" 2>/dev/null || true
631 [ -n "$D14PID" ] && kill "$D14PID" 2>/dev/null || true
632 [ -n "$D15PID" ] && kill "$D15PID" 2>/dev/null || true
633 [ -n "$D16PID" ] && kill "$D16PID" 2>/dev/null || true
634 [ -n "$D17PID" ] && kill "$D17PID" 2>/dev/null || true
635 # The stops still precede the socket rm below, like SOCK14-17 above:
636 # unlinking a socket first would leave a live daemon nothing could reach
637 # by path.
638 [ -S "$SOCK18" ] && "$MUXD" stop --sock "$SOCK18" 2>/dev/null || true
639 [ -S "$SOCK19" ] && "$MUXD" stop --sock "$SOCK19" 2>/dev/null || true
640 [ -S "$SOCK20" ] && "$MUXD" stop --sock "$SOCK20" 2>/dev/null || true
641
642 # ---- the leak sweep (hygiene kit, 6a) ----
643 # Here rather than at the bottom of the file, which `set -e` reaches only
644 # on a passing run: a leak that arrives alongside a defect is reported
645 # with it. Between the kills above and the rms below, which is the one
646 # window where every daemon has finished its exit path and every capture
647 # still exists.
648 reap_briefly "${DPID:-}" "$D2PID" "$D3PID" "$D4PID" "$D5PID" "$D6PID" \
649 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \
650 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \
651 "$D14PID" "$D15PID" "$D16PID" "$D17PID"
652 _leak=0
653 leak_sweep "$_rc" || _leak=1
654
487 rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \ 655 rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \
488 "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \ 656 "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \
489 "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ 657 "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \
@@ -531,19 +699,10 @@ cleanup() {
531 "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ 699 "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \
532 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" 700 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err"
533 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" 701 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2"
534 # M-web: hubs and daemons by tracked pid, then stop-by-socket for any 702 # M-web captures. Their hubs and daemons were killed with everyone
535 # daemon that died between fork and up-line, then the captures. The 703 # else's, up with the kills — every kill in this trap now precedes every
536 # stops must precede the socket rm, like SOCK14-17 above. 704 # rm, so the leak sweep sitting between the two reads captures that are
537 [ -n "$W1PID" ] && kill "$W1PID" 2>/dev/null || true 705 # still on disk and daemons that have already written their verdict.
538 [ -n "$W2PID" ] && kill "$W2PID" 2>/dev/null || true
539 [ -n "$WCLIPID" ] && kill "$WCLIPID" 2>/dev/null || true
540 [ -n "$D14PID" ] && kill "$D14PID" 2>/dev/null || true
541 [ -n "$D15PID" ] && kill "$D15PID" 2>/dev/null || true
542 [ -n "$D16PID" ] && kill "$D16PID" 2>/dev/null || true
543 [ -n "$D17PID" ] && kill "$D17PID" 2>/dev/null || true
544 [ -S "$SOCK18" ] && "$MUXD" stop --sock "$SOCK18" 2>/dev/null || true
545 [ -S "$SOCK19" ] && "$MUXD" stop --sock "$SOCK19" 2>/dev/null || true
546 [ -S "$SOCK20" ] && "$MUXD" stop --sock "$SOCK20" 2>/dev/null || true
547 rm -f "$SOCK18" "$SOCK19" "$SOCK20" \ 706 rm -f "$SOCK18" "$SOCK19" "$SOCK20" \
548 "$OUT.weba" "$OUT.weba.err" "$OUT.weba.d" \ 707 "$OUT.weba" "$OUT.weba.err" "$OUT.weba.d" \
549 "$OUT.web1x1" "$OUT.web1x1.err" "$OUT.web1x1.log" \ 708 "$OUT.web1x1" "$OUT.web1x1.err" "$OUT.web1x1.log" \
@@ -560,6 +719,12 @@ cleanup() {
560 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 719 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
561 # chased here: on a failing run they are the evidence. 720 # chased here: on a failing run they are the evidence.
562 rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "${NOKEY_CFG:-}" 721 rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "${NOKEY_CFG:-}"
722 rm -f "$LEAKBANK"
723 # This trap can change the suite's answer in exactly one direction: a run
724 # that was green and swept up a leak. Every other path returns normally
725 # and leaves the status alone — a suite that failed at scenario 6 must
726 # exit with scenario 6's failure, not with the trap's opinion of it.
727 if [ "$_rc" -eq 0 ] && [ "$_leak" -eq 1 ]; then exit 1; fi
563 } 728 }
564 trap cleanup EXIT INT TERM 729 trap cleanup EXIT INT TERM
565 730
@@ -624,7 +789,7 @@ printf '\033[12;1Hconvergence-control-glyphs' >> "$OUT.doctored"
624 if converged_quiet "$OUT.doctored" "$SOCK"; then 789 if converged_quiet "$OUT.doctored" "$SOCK"; then
625 echo "e2e FAIL: convergence control did not fire on a doctored stream"; exit 1 790 echo "e2e FAIL: convergence control did not fire on a doctored stream"; exit 1
626 fi 791 fi
627 rm -f "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \ 792 rm_swept "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \
628 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \ 793 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \
629 "$OUT.doctored.rvt" "$OUT.doctored.dvt" 794 "$OUT.doctored.rvt" "$OUT.doctored.dvt"
630 ok "convergence control fires on a doctored stream" 795 ok "convergence control fires on a doctored stream"
@@ -649,7 +814,7 @@ grep -q "styled-bold" "$OUT.st" || {
649 # The plain leg would pass on a bled attribute — same glyphs, wrong colours. 814 # The plain leg would pass on a bled attribute — same glyphs, wrong colours.
650 # The --vt leg inside assert_converged is the one that speaks here. 815 # The --vt leg inside assert_converged is the one that speaks here.
651 assert_converged "$OUT.st" "$SOCK" "styled content" 816 assert_converged "$OUT.st" "$SOCK" "styled content"
652 rm -f "$OUT.st" 817 rm_swept "$OUT.st"
653 ok "styled content survives the paint path" 818 ok "styled content survives the paint path"
654 819
655 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly. 820 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly.
@@ -667,7 +832,7 @@ grep -q "60" "$OUT.re" || {
667 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1; 832 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1;
668 } 833 }
669 assert_converged "$OUT.re" "$SOCK" "reattach after kill" 834 assert_converged "$OUT.re" "$SOCK" "reattach after kill"
670 rm -f "$OUT.kill" "$OUT.re" 835 rm_swept "$OUT.kill" "$OUT.re"
671 836
672 # --- M5: two clients on one session. Output typed in A reaches both; then A 837 # --- M5: two clients on one session. Output typed in A reaches both; then A
673 # detaches and B must still have a live input path (its own marker echoes back 838 # detaches and B must still have a live input path (its own marker echoes back
@@ -692,7 +857,7 @@ kill -0 "$DPID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1
692 # on a grid the daemon has since moved past. B saw both, and its convergence 857 # on a grid the daemon has since moved past. B saw both, and its convergence
693 # covers the same grid. 858 # covers the same grid.
694 assert_converged "$OUT.b" "$SOCK" "two clients" 859 assert_converged "$OUT.b" "$SOCK" "two clients"
695 rm -f "$OUT.a" "$OUT.b" 860 rm_swept "$OUT.a" "$OUT.b"
696 861
697 # --- M6: the same protocol over an arbitrary byte pipe. `muxd proxy` is a 862 # --- M6: the same protocol over an arbitrary byte pipe. `muxd proxy` is a
698 # frame-agnostic stdio<->socket pump; if the session works through it, the 863 # frame-agnostic stdio<->socket pump; if the session works through it, the
@@ -712,7 +877,7 @@ grep -q "m6-via-pipe" "$OUT.via" || {
712 } 877 }
713 kill -0 "$DPID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; } 878 kill -0 "$DPID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; }
714 assert_converged "$OUT.via" "$SOCK" "via transport" 879 assert_converged "$OUT.via" "$SOCK" "via transport"
715 rm -f "$OUT.via" 880 rm_swept "$OUT.via"
716 881
717 # --- M10: a --via command that dies before the first frame stops claiming 882 # --- M10: a --via command that dies before the first frame stops claiming
718 # a connection existed. ssh's own stderr still passes through untouched. 883 # a connection existed. ssh's own stderr still passes through untouched.
@@ -726,7 +891,7 @@ grep -q "transport command failed before a session started" "$OUT.via" || {
726 echo "e2e FAIL: --via death message:"; cat "$OUT.via"; exit 1; } 891 echo "e2e FAIL: --via death message:"; cat "$OUT.via"; exit 1; }
727 grep -q "connection to muxd lost" "$OUT.via" && { 892 grep -q "connection to muxd lost" "$OUT.via" && {
728 echo "e2e FAIL: the old lie is still printed"; cat "$OUT.via"; exit 1; } 893 echo "e2e FAIL: the old lie is still printed"; cat "$OUT.via"; exit 1; }
729 rm -f "$OUT.via" 894 rm_swept "$OUT.via"
730 ok "--via failure says what happened" 895 ok "--via failure says what happened"
731 896
732 # --- M7: a transport that dies before any session must exit, not retry. The 897 # --- M7: a transport that dies before any session must exit, not retry. The
@@ -770,7 +935,7 @@ grep -q "mux: socket path too long" "$OUT.longc" || { echo "e2e FAIL:"; cat "$OU
770 grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; } 935 grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; }
771 "$MUXD" --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; } 936 "$MUXD" --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; }
772 937
773 rm -f "$OUT.dead" "$OUT.long" "$OUT.longc" 938 rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc"
774 939
775 # --- M7: aborting a reconnect exits cleanly. The client establishes a real 940 # --- M7: aborting a reconnect exits cleanly. The client establishes a real
776 # session (so reconnect is allowed), its daemon is then killed under it, and 941 # session (so reconnect is allowed), its daemon is then killed under it, and
@@ -800,7 +965,7 @@ set -e
800 grep -q "detached while reconnecting" "$OUT.abort" || { 965 grep -q "detached while reconnecting" "$OUT.abort" || {
801 echo "e2e FAIL: abort during reconnect lost its message; got:"; cat "$OUT.abort"; exit 1; 966 echo "e2e FAIL: abort during reconnect lost its message; got:"; cat "$OUT.abort"; exit 1;
802 } 967 }
803 rm -f "$OUT.abort" "$SOCK2" 968 rm_swept "$OUT.abort" "$SOCK2"
804 969
805 # --- M7 Scenario A: kill the transport mid-session; the client must resume 970 # --- M7 Scenario A: kill the transport mid-session; the client must resume
806 # by DELTA. The proxy is the transport, so killing it stands in for an ssh 971 # by DELTA. The proxy is the transport, so killing it stands in for an ssh
@@ -854,7 +1019,7 @@ SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/
854 exit 1; 1019 exit 1;
855 } 1020 }
856 assert_converged "$OUT.m7" "$SOCK" "delta resume" 1021 assert_converged "$OUT.m7" "$SOCK" "delta resume"
857 rm -f "$OUT.m7" "$OUT.m7.err" 1022 rm_swept "$OUT.m7" "$OUT.m7.err"
858 1023
859 # --- M7 Scenario B: kill the DAEMON under an attached client and start a new 1024 # --- M7 Scenario B: kill the DAEMON under an attached client and start a new
860 # one on the same path. The seq the client holds belongs to a session that no 1025 # one on the same path. The seq the client holds belongs to a session that no
@@ -915,7 +1080,7 @@ SNAPS_NEW=$("$MUXD" stats --sock "$SOCK3" | sed -n 's/.*snapshots=\([0-9]*\).*/\
915 exit 1; 1080 exit 1;
916 } 1081 }
917 assert_converged "$OUT.m7b" "$SOCK3" "epoch resync" 1082 assert_converged "$OUT.m7b" "$SOCK3" "epoch resync"
918 rm -f "$OUT.m7b" "$OUT.m7b.err" 1083 rm_swept "$OUT.m7b" "$OUT.m7b.err"
919 1084
920 # --- M8: the --quic flags. Every refusal must cost nothing — no session 1085 # --- M8: the --quic flags. Every refusal must cost nothing — no session
921 # socket, no shell, no stack trace — and the accepted case must leave a 1086 # socket, no shell, no stack trace — and the accepted case must leave a
@@ -1382,7 +1547,7 @@ kill "$D10PID" 2>/dev/null || true
1382 D10PID="" 1547 D10PID=""
1383 ok "daemon honours MUX_KEY_FILE, and --key beats it" 1548 ok "daemon honours MUX_KEY_FILE, and --key beats it"
1384 1549
1385 rm -f "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKEY.wrong" \ 1550 rm_swept "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKEY.wrong" \
1386 "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err" 1551 "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err"
1387 1552
1388 # --- M10: muxd start — detached spawn, no-op rerun, race, pinned lines. 1553 # --- M10: muxd start — detached spawn, no-op rerun, race, pinned lines.
@@ -1920,7 +2085,7 @@ grep -q "pc-stdout" "$OUT.pc3" || {
1920 grep -q "pc-stderr" "$OUT.pc3.err" || { 2085 grep -q "pc-stderr" "$OUT.pc3.err" || {
1921 echo "e2e FAIL: the child's stderr reached neither the capture nor --err" 2086 echo "e2e FAIL: the child's stderr reached neither the capture nor --err"
1922 cat -v "$OUT.pc3.err"; exit 1; } 2087 cat -v "$OUT.pc3.err"; exit 1; }
1923 rm -f "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err" \ 2088 rm_swept "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$OUT.pc3" "$OUT.pc3.err" \
1924 "$PCLOG" "$PCLOG.2" "$PCLOG.3" 2089 "$PCLOG" "$PCLOG.2" "$PCLOG.3"
1925 ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, stderr stays off the capture" 2090 ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly, stderr stays off the capture"
1926 2091
@@ -2088,7 +2253,7 @@ set -e
2088 # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon 2253 # 95-wide row still WRAPPED at 90 into "...0" + "00007" while the daemon
2089 # had rejoined it into one row at 100. 2254 # had rejoined it into one row at 100.
2090 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30 2255 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 100 30
2091 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \ 2256 rm_swept "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
2092 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d" 2257 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d"
2093 # Closed here like every other per-scenario daemon, not left to the trap: 2258 # Closed here like every other per-scenario daemon, not left to the trap:
2094 # tp1 runs below and would otherwise share the box with a daemon nobody is 2259 # tp1 runs below and would otherwise share the box with a daemon nobody is
@@ -2261,12 +2426,12 @@ printf '\033[10;1Hpty-doctor-glyphs' >> "$OUT.tp1.doc"
2261 if converged_quiet "$OUT.tp1.doc" "$SOCK13"; then 2426 if converged_quiet "$OUT.tp1.doc" "$SOCK13"; then
2262 echo "e2e FAIL: convergence control did not fire on a doctored pty capture"; exit 1 2427 echo "e2e FAIL: convergence control did not fire on a doctored pty capture"; exit 1
2263 fi 2428 fi
2264 rm -f "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \ 2429 rm_swept "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \
2265 "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \ 2430 "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \
2266 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt" 2431 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt"
2267 kill "$D13PID" 2>/dev/null || true 2432 kill "$D13PID" 2>/dev/null || true
2268 D13PID="" 2433 D13PID=""
2269 rm -f "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH" 2434 rm_swept "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH"
2270 ok "reconnect while scrolled: view restored, prediction resumed" 2435 ok "reconnect while scrolled: view restored, prediction resumed"
2271 2436
2272 # --- M13: attach auto-start (proxy) + muxd stop ------------------------ 2437 # --- M13: attach auto-start (proxy) + muxd stop ------------------------
@@ -2347,7 +2512,7 @@ set -e
2347 echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; } 2512 echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; }
2348 grep -q "nothing listening on $SOCK14" "$OUT.stop2" || { 2513 grep -q "nothing listening on $SOCK14" "$OUT.stop2" || {
2349 echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; } 2514 echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; }
2350 rm -f "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" "$OUT.stop" "$OUT.stop2" 2515 rm_swept "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" "$OUT.stop" "$OUT.stop2"
2351 ok "attach auto-start via proxy; muxd stop tears it down" 2516 ok "attach auto-start via proxy; muxd stop tears it down"
2352 2517
2353 # --- M13: local mux auto-start, under the pty fixture ------------------ 2518 # --- M13: local mux auto-start, under the pty fixture ------------------
@@ -2411,7 +2576,7 @@ grep -q '^muxd: stopped' "$OUT.stop" || {
2411 echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; } 2576 echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; }
2412 wait_pid_gone "$PAPID" "pty leg: stop reported stopped" 2577 wait_pid_gone "$PAPID" "pty leg: stop reported stopped"
2413 PAPID="" 2578 PAPID=""
2414 rm -f "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop" 2579 rm_swept "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop"
2415 ok "local mux auto-start under the pty fixture" 2580 ok "local mux auto-start under the pty fixture"
2416 2581
2417 # --- M14: the ssh→QUIC handoff ----------------------------------------- 2582 # --- M14: the ssh→QUIC handoff -----------------------------------------
@@ -2886,7 +3051,7 @@ assert_stopped "$SOCK16" "$HAPID" "handoff daemon" "$OUT.stop"
2886 HAPID="" 3051 HAPID=""
2887 assert_stopped "$SOCK17" "$HDPID" "key-mismatch daemon" "$OUT.stop" 3052 assert_stopped "$SOCK17" "$HDPID" "key-mismatch daemon" "$OUT.stop"
2888 HDPID="" 3053 HDPID=""
2889 rm -f "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ 3054 rm_swept "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \
2890 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD" 3055 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD"
2891 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" 3056 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2"
2892 3057
@@ -3083,32 +3248,18 @@ assert_stopped "$SOCK20" "$D17PID" "web tear: the restarted daemon" "$OUT.websto
3083 D17PID="" 3248 D17PID=""
3084 ok "hub narrates the tear; the replica re-attaches across an epoch" 3249 ok "hub narrates the tear; the replica re-attaches across an epoch"
3085 3250
3086 # ---- whole-suite leak sweep (hygiene kit, 6a) ----
3087 # The long-lived daemon has served every scenario that wanted it; stop it 3251 # The long-lived daemon has served every scenario that wanted it; stop it
3088 # NOW so its allocator verdict is written before the sweep reads. SIGTERM 3252 # NOW so its allocator verdict is written while the suite is still running
3089 # runs the clean-shutdown path, so the defer chain (and the verdict) runs. 3253 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
3254 # (and the verdict) runs.
3255 #
3256 # The sweep that READS that verdict is in the EXIT trap, not here: this line
3257 # is only reached by a run that passed, and a leak deserves reporting on the
3258 # runs that did not (see leak_sweep). Everything below it is a pin, and the
3259 # trap fires after all of them either way.
3090 kill "$DPID" 2>/dev/null || true 3260 kill "$DPID" 2>/dev/null || true
3091 wait "$DPID" 2>/dev/null || true 3261 wait "$DPID" 2>/dev/null || true
3092 DPID="" 3262 DPID=""
3093 # The sweep must have something to read: d1.d is created at daemon-one
3094 # spawn and survives to the trap, so its absence means the capture
3095 # convention broke and this gate is reading nothing.
3096 [ -e "$OUT.d1.d" ] || { echo "e2e FAIL: leak sweep found no captures to read"; exit 1; }
3097 # Every capture this suite wrote — daemon stderr AND client output — is a
3098 # lifecycle log now: any binary that leaked printed a grep-able marker.
3099 if grep -q "LEAK:" "$OUT".* 2>/dev/null; then
3100 echo "e2e FAIL: a binary reported leaked allocations:"
3101 grep -H "LEAK:" "$OUT".*
3102 exit 1
3103 fi
3104 # The detached (`muxd start`) daemons log via XDG_STATE_HOME; the file is
3105 # truncated at every spawn, so this asserts the LAST such daemon only —
3106 # stated, not hidden.
3107 if [ -f "$XDG_STATE_HOME/mux/muxd.log" ] && grep -q "LEAK:" "$XDG_STATE_HOME/mux/muxd.log"; then
3108 echo "e2e FAIL: a detached daemon reported leaked allocations:"
3109 grep -H "LEAK:" "$XDG_STATE_HOME/mux/muxd.log"
3110 exit 1
3111 fi
3112 3263
3113 # The pins. Literals, not variables set from counting something else — 3264 # The pins. Literals, not variables set from counting something else —
3114 # "assert the literal, never the constant the code under test reads" 3265 # "assert the literal, never the constant the code under test reads"
tools/deadcode.sh
Old New
@@ -18,8 +18,18 @@ for f in src/*.zig test/*.zig; do
18 | awk '{print $NF}' \ 18 | awk '{print $NF}' \
19 | while read -r name; do 19 | while read -r name; do
20 case "$name" in main|panic|std_options) continue ;; esac 20 case "$name" in main|panic|std_options) continue ;; esac
21 if ! grep -rlw --include='*.zig' "$name" src test build.zig \ 21 # The first grep lists the files the name appears in; the second
22 | grep -qv "^$f\$"; then 22 # asks whether ANY of those lines is a file other than this one,
23 # which is what `-v` on multi-line input means and is exactly the
24 # question here — the defining file is always in the list, so
25 # "referenced outside" is "some line is not $f".
26 #
27 # -xF, not "^$f\$": the pattern is a PATH, and a filename holding
28 # a `.` or a `[` would otherwise be a regex matching files that
29 # are not it. The names in $name are identifiers by construction
30 # and get -F for the same reason rather than a second exception.
31 if ! grep -rlwF --include='*.zig' -- "$name" src test build.zig \
32 | grep -qvxF -- "$f"; then
23 echo "$f: pub $name is referenced nowhere outside its file" 33 echo "$f: pub $name is referenced nowhere outside its file"
24 fi 34 fi
25 done 35 done
tools/valgrind-quic.sh
Old New
@@ -30,19 +30,53 @@ XDG_CONFIG_HOME="$TMP/cfg"; export XDG_CONFIG_HOME
30 "$MUXD" keygen > /dev/null 30 "$MUXD" keygen > /dev/null
31 KEY="$TMP/cfg/mux/key" 31 KEY="$TMP/cfg/mux/key"
32 SOCK="$TMP/vg.sock" 32 SOCK="$TMP/vg.sock"
33 # muxd's own stdout+stderr, kept apart from valgrind's --log-file so the two
34 # accounts stay readable — and so the bind refusal below can be read back.
35 DLOG="$TMP/muxd.log"
36 # The port is derived from the pid, which makes it a GUESS in two ways: this
37 # band is inside the ephemeral range on a box whose ip_local_port_range has
38 # been lowered, so an outgoing connection can already hold the number, and
39 # two runs whose pids are congruent mod 900 derive the same one. muxd sets no
40 # SO_REUSEADDR and refuses to share a UDP port, so both show up identically —
41 # the daemon says `a daemon is already listening on udp ...` and exits — and
42 # the answer to both is the next candidate. The port this script goes on to
43 # use is therefore the one a daemon BOUND, never the one it first derived.
33 PORT=$((47000 + ($$ % 900))) 44 PORT=$((47000 + ($$ % 900)))
34 valgrind --leak-check=full --error-exitcode=99 --log-file="$TMP/vg.log" \ 45 PORT_STEP=13
35 "$MUXD" run --sock "$SOCK" --shell /bin/sh \ 46 PORT_TRIES=8
36 --quic "127.0.0.1:$PORT" --key "$KEY" & 47 tries=0
37 VGPID=$! 48 while :; do
38 # valgrind start is SLOW; give the bind a full minute. Bail early if the 49 : > "$DLOG"
39 # daemon dies under valgrind instead of waiting out the whole timeout. 50 valgrind --leak-check=full --error-exitcode=99 --log-file="$TMP/vg.log" \
40 i=0 51 "$MUXD" run --sock "$SOCK" --shell /bin/sh \
41 while [ ! -S "$SOCK" ] && [ "$i" -lt 600 ]; do 52 --quic "127.0.0.1:$PORT" --key "$KEY" > "$DLOG" 2>&1 &
42 kill -0 "$VGPID" 2>/dev/null || break 53 VGPID=$!
43 sleep 0.1; i=$((i + 1)) 54 # valgrind start is SLOW; give the bind a full minute. Bail early if the
55 # daemon dies under valgrind instead of waiting out the whole timeout.
56 i=0
57 while [ ! -S "$SOCK" ] && [ "$i" -lt 600 ]; do
58 kill -0 "$VGPID" 2>/dev/null || break
59 sleep 0.1; i=$((i + 1))
60 done
61 if [ -S "$SOCK" ]; then break; fi
62 # No socket. A daemon that is still RUNNING is not a port problem — it is
63 # one that will not come up, and `wait`ing on it would hang this script.
64 if kill -0 "$VGPID" 2>/dev/null; then
65 echo "daemon never bound under valgrind (still up after ${i}x100ms):"
66 cat "$DLOG" 2>/dev/null; cat "$TMP/vg.log" 2>/dev/null; exit 1
67 fi
68 wait "$VGPID" 2>/dev/null || true
69 VGPID=""
70 grep -q "listening on udp" "$DLOG" || {
71 echo "daemon died under valgrind:"
72 cat "$DLOG" 2>/dev/null; cat "$TMP/vg.log" 2>/dev/null; exit 1; }
73 tries=$((tries + 1))
74 [ "$tries" -lt "$PORT_TRIES" ] || {
75 echo "$tries candidate udp ports, up to $PORT, were all taken"; exit 1; }
76 PORT=$((PORT + PORT_STEP))
77 echo "udp port taken; retrying on $PORT"
44 done 78 done
45 [ -S "$SOCK" ] || { echo "daemon never bound under valgrind:"; cat "$TMP/vg.log" 2>/dev/null; exit 1; } 79 echo "muxd is up under valgrind on udp $PORT"
46 # One real command over QUIC — handshake, frames, teardown — with a 80 # One real command over QUIC — handshake, frames, teardown — with a
47 # timeout sized for valgrind's clock, then a clean stop so every exit 81 # timeout sized for valgrind's clock, then a clean stop so every exit
48 # path (and the allocator teardown) runs. 82 # path (and the allocator teardown) runs.