ae9d7790
test: leak sweep survives failure; ports are observations
a73x 2026-08-14 12:46
Commit message
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. |