ab0ecad2
feat: DebugAllocator verdicts print a grep-able LEAK marker; e2e/agent assert absence
a73x 2026-08-14 08:46
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -279,7 +279,8 @@ fn parseBindAddr(s: []const u8) !std.net.Address { | |||
| 279 | 279 | ||
| 280 | pub fn main() !u8 { | 280 | pub fn main() !u8 { |
| 281 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 281 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 282 | defer _ = gpa.deinit(); | 282 | defer if (gpa.deinit() == .leak) |
| 283 | std.debug.print("muxd: LEAK: allocations outlived deinit\n", .{}); | ||
| 283 | const alloc = gpa.allocator(); | 284 | const alloc = gpa.allocator(); |
| 284 | 285 | ||
| 285 | const args = try std.process.argsAlloc(alloc); | 286 | const args = try std.process.argsAlloc(alloc); |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -132,7 +132,8 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 132 | 132 | ||
| 133 | pub fn main() !u8 { | 133 | pub fn main() !u8 { |
| 134 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 134 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 135 | defer _ = gpa.deinit(); | 135 | defer if (gpa.deinit() == .leak) |
| 136 | std.debug.print("mux: LEAK: allocations outlived deinit\n", .{}); | ||
| 136 | const alloc = gpa.allocator(); | 137 | const alloc = gpa.allocator(); |
| 137 | 138 | ||
| 138 | const args = try std.process.argsAlloc(alloc); | 139 | const args = try std.process.argsAlloc(alloc); |
src/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -113,7 +113,8 @@ fn parseArgs( | |||
| 113 | 113 | ||
| 114 | pub fn main() !u8 { | 114 | pub fn main() !u8 { |
| 115 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 115 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 116 | defer _ = gpa.deinit(); | 116 | defer if (gpa.deinit() == .leak) |
| 117 | std.debug.print("muxweb: LEAK: allocations outlived deinit\n", .{}); | ||
| 117 | const alloc = gpa.allocator(); | 118 | const alloc = gpa.allocator(); |
| 118 | 119 | ||
| 119 | const args = try std.process.argsAlloc(alloc); | 120 | const args = try std.process.argsAlloc(alloc); |
test/agent.sh
| Old | New | ||
|---|---|---|---|
| @@ -199,6 +199,23 @@ wait_for() { | |||
| 199 | return 1 | 199 | return 1 |
| 200 | } | 200 | } |
| 201 | 201 | ||
| 202 | # 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 | ||
| 204 | # truncated at every spawn, so the check must run NOW, before the next | ||
| 205 | # scenario's daemon comes up — and only after the process is actually | ||
| 206 | # gone, or the grep races the exit path it is asserting about. | ||
| 207 | leakcheck() { | ||
| 208 | _i=0 | ||
| 209 | while kill -0 "$1" 2>/dev/null && [ "$_i" -lt 100 ]; do | ||
| 210 | sleep 0.05 | ||
| 211 | _i=$((_i + 1)) | ||
| 212 | done | ||
| 213 | _dl="$XDG_STATE_HOME/mux/muxd.log" | ||
| 214 | [ -f "$_dl" ] || return 0 | ||
| 215 | grep -q "LEAK:" "$_dl" || return 0 | ||
| 216 | why "daemon leaked: $(grep 'LEAK:' "$_dl" | head -1)" | ||
| 217 | } | ||
| 218 | |||
| 202 | # Start a detached daemon and hand back the pid IT reported. Never a pid this | 219 | # Start a detached daemon and hand back the pid IT reported. Never a pid this |
| 203 | # script guessed from a process name: the suite kills what it started, and a | 220 | # script guessed from a process name: the suite kills what it started, and a |
| 204 | # name match can only ever name a bystander. | 221 | # name match can only ever name a bystander. |
| @@ -287,6 +304,7 @@ scen_marks() { | |||
| 287 | want "$TMP/m3" output "\"$_mark\"" || return 1 | 304 | want "$TMP/m3" output "\"$_mark\"" || return 1 |
| 288 | 305 | ||
| 289 | "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1 | 306 | "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1 |
| 307 | leakcheck "$D_MARKS" || return 1 | ||
| 290 | D_MARKS="" | 308 | D_MARKS="" |
| 291 | return 0 | 309 | return 0 |
| 292 | } | 310 | } |
| @@ -439,6 +457,7 @@ scen_settle() { | |||
| 439 | want "$TMP/s1" exit_code null || return 1 | 457 | want "$TMP/s1" exit_code null || return 1 |
| 440 | 458 | ||
| 441 | "$MUXD" stop --sock "$SOCK_SETTLE" >/dev/null 2>&1 | 459 | "$MUXD" stop --sock "$SOCK_SETTLE" >/dev/null 2>&1 |
| 460 | leakcheck "$D_SETTLE" || return 1 | ||
| 442 | D_SETTLE="" | 461 | D_SETTLE="" |
| 443 | return 0 | 462 | return 0 |
| 444 | } | 463 | } |
| @@ -658,6 +677,7 @@ scen_keepalive() { | |||
| 658 | why "duration_ms=$_dur — the wait did not survive the 15s idle timeout" || return 1 | 677 | why "duration_ms=$_dur — the wait did not survive the 15s idle timeout" || return 1 |
| 659 | 678 | ||
| 660 | "$MUXD" stop --sock "$SOCK_QUIET" >/dev/null 2>&1 | 679 | "$MUXD" stop --sock "$SOCK_QUIET" >/dev/null 2>&1 |
| 680 | leakcheck "$D_QUIET" || return 1 | ||
| 661 | D_QUIET="" | 681 | D_QUIET="" |
| 662 | return 0 | 682 | return 0 |
| 663 | } | 683 | } |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -493,6 +493,8 @@ cleanup() { | |||
| 493 | "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.pr" \ | 493 | "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.pr" \ |
| 494 | "$OUT.p1.err" "$OUT.pb.err" "$OUT.pw.err" "$OUT.rw.err" "$OUT.pr.err" \ | 494 | "$OUT.p1.err" "$OUT.pb.err" "$OUT.pw.err" "$OUT.rw.err" "$OUT.pr.err" \ |
| 495 | "$OUT.p1.d" "$OUT.pw.d" "$OUT.rw.d" "$OUT.pr.d" \ | 495 | "$OUT.p1.d" "$OUT.pw.d" "$OUT.rw.d" "$OUT.pr.d" \ |
| 496 | "$OUT.d1.d" "$OUT.d2.d" "$OUT.d3a.d" "$OUT.d3b.d" "$OUT.d4.d" \ | ||
| 497 | "$OUT.d9.d" "$OUT.d10.d" "$OUT.d17.d" \ | ||
| 496 | "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ | 498 | "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ |
| 497 | "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" "$OUT.st" \ | 499 | "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" "$OUT.st" \ |
| 498 | "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \ | 500 | "$OUT.b" "$OUT.via" "$OUT.dead" "$OUT.abort" "$OUT.m7" "$OUT.m7b" \ |
| @@ -589,10 +591,10 @@ SUM2=$(sha256sum "$KEYOUT") | |||
| 589 | [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; } | 591 | [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; } |
| 590 | ok "keygen creates once, 0600 in a 0700 dir, refuses twice" | 592 | ok "keygen creates once, 0600 in a 0700 dir, refuses twice" |
| 591 | 593 | ||
| 592 | "$MUXD" run --sock "$SOCK" --shell /bin/sh & | 594 | "$MUXD" run --sock "$SOCK" --shell /bin/sh > "$OUT.d1.d" 2>&1 & |
| 593 | DPID=$! | 595 | DPID=$! |
| 594 | 596 | ||
| 595 | wait_sock "$SOCK" "" "socket never appeared" | 597 | wait_sock "$SOCK" "$OUT.d1.d" "socket never appeared" |
| 596 | 598 | ||
| 597 | # Client with piped stdio: types a command, waits, detaches with Ctrl-\ (034). | 599 | # Client with piped stdio: types a command, waits, detaches with Ctrl-\ (034). |
| 598 | { printf 'printf "e2e-%%s\\n" works\n'; sleep 2; printf '\034'; } | \ | 600 | { printf 'printf "e2e-%%s\\n" works\n'; sleep 2; printf '\034'; } | \ |
| @@ -776,9 +778,9 @@ rm -f "$OUT.dead" "$OUT.long" "$OUT.longc" | |||
| 776 | # of the transport would abort here instead (SIGABRT = 134). | 778 | # of the transport would abort here instead (SIGABRT = 134). |
| 777 | # No convergence: the client exits while reconnecting and its daemon is dead, | 779 | # No convergence: the client exits while reconnecting and its daemon is dead, |
| 778 | # so there is nothing left to dump against. | 780 | # so there is nothing left to dump against. |
| 779 | "$MUXD" run --sock "$SOCK2" --shell /bin/sh & | 781 | "$MUXD" run --sock "$SOCK2" --shell /bin/sh > "$OUT.d2.d" 2>&1 & |
| 780 | D2PID=$! | 782 | D2PID=$! |
| 781 | wait_sock "$SOCK2" "" "second socket never appeared" | 783 | wait_sock "$SOCK2" "$OUT.d2.d" "second socket never appeared" |
| 782 | 784 | ||
| 783 | set +e | 785 | set +e |
| 784 | { printf 'echo m7-abort-live\n'; sleep 3; printf '\034'; sleep 2; } | \ | 786 | { printf 'echo m7-abort-live\n'; sleep 3; printf '\034'; sleep 2; } | \ |
| @@ -858,9 +860,9 @@ rm -f "$OUT.m7" "$OUT.m7.err" | |||
| 858 | # one on the same path. The seq the client holds belongs to a session that no | 860 | # one on the same path. The seq the client holds belongs to a session that no |
| 859 | # longer exists, so the epoch fence must refuse it and serve a snapshot of the | 861 | # longer exists, so the epoch fence must refuse it and serve a snapshot of the |
| 860 | # fresh session instead. Needs its own daemon, since this one gets killed. | 862 | # fresh session instead. Needs its own daemon, since this one gets killed. |
| 861 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh & | 863 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh > "$OUT.d3a.d" 2>&1 & |
| 862 | D3PID=$! | 864 | D3PID=$! |
| 863 | wait_sock "$SOCK3" "" "restart-scenario socket never appeared" | 865 | wait_sock "$SOCK3" "$OUT.d3a.d" "restart-scenario socket never appeared" |
| 864 | 866 | ||
| 865 | set +e | 867 | set +e |
| 866 | { sleep 0.5; printf 'printf "m7b-%%s\\n" pre-restart\n'; sleep 8; \ | 868 | { sleep 0.5; printf 'printf "m7b-%%s\\n" pre-restart\n'; sleep 8; \ |
| @@ -877,9 +879,9 @@ wait_for "$OUT.m7b" "m7b-pre-restart" 20 || { | |||
| 877 | # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here. | 879 | # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here. |
| 878 | kill -9 "$D3PID" | 880 | kill -9 "$D3PID" |
| 879 | sleep 0.5 | 881 | sleep 0.5 |
| 880 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh & | 882 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh > "$OUT.d3b.d" 2>&1 & |
| 881 | D3PID=$! | 883 | D3PID=$! |
| 882 | wait_sock "$SOCK3" "" "daemon did not rebind the stale socket" | 884 | wait_sock "$SOCK3" "$OUT.d3b.d" "daemon did not rebind the stale socket" |
| 883 | 885 | ||
| 884 | set +e | 886 | set +e |
| 885 | wait "$M7BPID" | 887 | wait "$M7BPID" |
| @@ -993,9 +995,9 @@ ok "no key anywhere is refused, and says how to make one" | |||
| 993 | 995 | ||
| 994 | # The accepted case. | 996 | # The accepted case. |
| 995 | "$MUXD" run --sock "$SOCK4" --shell /bin/sh \ | 997 | "$MUXD" run --sock "$SOCK4" --shell /bin/sh \ |
| 996 | --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000 & | 998 | --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000 > "$OUT.d4.d" 2>&1 & |
| 997 | D4PID=$! | 999 | D4PID=$! |
| 998 | wait_sock "$SOCK4" "" "--quic daemon never bound its session socket" | 1000 | wait_sock "$SOCK4" "$OUT.d4.d" "--quic daemon never bound its session socket" |
| 999 | 1001 | ||
| 1000 | # The UDP port is actually held. /proc/net/udp rather than ss or lsof: it is | 1002 | # The UDP port is actually held. /proc/net/udp rather than ss or lsof: it is |
| 1001 | # always there on the platform this daemon runs on, and needs no privileges. | 1003 | # always there on the platform this daemon runs on, and needs no privileges. |
| @@ -1331,9 +1333,9 @@ D4PID="" | |||
| 1331 | # automated coverage at all: envKey() reads the real environment, so | 1333 | # automated coverage at all: envKey() reads the real environment, so |
| 1332 | # only a daemon started with it set can prove the path works. | 1334 | # only a daemon started with it set can prove the path works. |
| 1333 | env MUX_KEY_FILE="$QKEY" "$MUXD" run --sock "$SOCK9" --shell /bin/sh \ | 1335 | env MUX_KEY_FILE="$QKEY" "$MUXD" run --sock "$SOCK9" --shell /bin/sh \ |
| 1334 | --quic "127.0.0.1:$QPORT2" --quic-idle-ms 3000 & | 1336 | --quic "127.0.0.1:$QPORT2" --quic-idle-ms 3000 > "$OUT.d9.d" 2>&1 & |
| 1335 | D9PID=$! | 1337 | D9PID=$! |
| 1336 | wait_sock "$SOCK9" "" "MUX_KEY_FILE daemon never bound its session socket" | 1338 | wait_sock "$SOCK9" "$OUT.d9.d" "MUX_KEY_FILE daemon never bound its session socket" |
| 1337 | 1339 | ||
| 1338 | set +e | 1340 | set +e |
| 1339 | { printf 'printf "envkey-%%s\\n" ok\n'; sleep 2; printf '\034'; } | \ | 1341 | { printf 'printf "envkey-%%s\\n" ok\n'; sleep 2; printf '\034'; } | \ |
| @@ -1358,9 +1360,9 @@ D9PID="" | |||
| 1358 | # in run() — pickKey's unit test pins the ordering inside the function | 1360 | # in run() — pickKey's unit test pins the ordering inside the function |
| 1359 | # and is blind to the order they are handed to it. | 1361 | # and is blind to the order they are handed to it. |
| 1360 | env MUX_KEY_FILE="$QKEY.wrong" "$MUXD" run --sock "$SOCK10" --shell /bin/sh \ | 1362 | env MUX_KEY_FILE="$QKEY.wrong" "$MUXD" run --sock "$SOCK10" --shell /bin/sh \ |
| 1361 | --quic "127.0.0.1:$QPORT3" --key "$QKEY" --quic-idle-ms 3000 & | 1363 | --quic "127.0.0.1:$QPORT3" --key "$QKEY" --quic-idle-ms 3000 > "$OUT.d10.d" 2>&1 & |
| 1362 | D10PID=$! | 1364 | D10PID=$! |
| 1363 | wait_sock "$SOCK10" "" "--key-beats-env daemon never bound its session socket" | 1365 | wait_sock "$SOCK10" "$OUT.d10.d" "--key-beats-env daemon never bound its session socket" |
| 1364 | 1366 | ||
| 1365 | set +e | 1367 | set +e |
| 1366 | { printf 'printf "flagwins-%%s\\n" ok\n'; sleep 2; printf '\034'; } | \ | 1368 | { printf 'printf "flagwins-%%s\\n" ok\n'; sleep 2; printf '\034'; } | \ |
| @@ -2723,9 +2725,9 @@ chmod 600 "$HKEY" | |||
| 2723 | # flag. It does not — the 15000 that bounds an overrunning dial is the | 2725 | # flag. It does not — the 15000 that bounds an overrunning dial is the |
| 2724 | # CLIENT's `quic_idle_ms_default`, which this daemon cannot influence. | 2726 | # CLIENT's `quic_idle_ms_default`, which this daemon cannot influence. |
| 2725 | "$MUXD" run --sock "$SOCK17" --shell /bin/sh \ | 2727 | "$MUXD" run --sock "$SOCK17" --shell /bin/sh \ |
| 2726 | --quic "127.0.0.1:$HQPORT" --key "$HKEY" & | 2728 | --quic "127.0.0.1:$HQPORT" --key "$HKEY" > "$OUT.d17.d" 2>&1 & |
| 2727 | HDPID=$! | 2729 | HDPID=$! |
| 2728 | wait_sock "$SOCK17" "" "key-mismatch daemon never bound" | 2730 | wait_sock "$SOCK17" "$OUT.d17.d" "key-mismatch daemon never bound" |
| 2729 | HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") | 2731 | HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") |
| 2730 | HT0=$(date +%s%N) | 2732 | HT0=$(date +%s%N) |
| 2731 | { printf 'printf "fallback-%%s\\n" ok\n'; sleep 6; printf '\034'; } | \ | 2733 | { printf 'printf "fallback-%%s\\n" ok\n'; sleep 6; printf '\034'; } | \ |
| @@ -3081,6 +3083,29 @@ assert_stopped "$SOCK20" "$D17PID" "web tear: the restarted daemon" "$OUT.websto | |||
| 3081 | D17PID="" | 3083 | D17PID="" |
| 3082 | ok "hub narrates the tear; the replica re-attaches across an epoch" | 3084 | ok "hub narrates the tear; the replica re-attaches across an epoch" |
| 3083 | 3085 | ||
| 3086 | # ---- whole-suite leak sweep (hygiene kit, 6a) ---- | ||
| 3087 | # 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 | ||
| 3089 | # runs the clean-shutdown path, so the defer chain (and the verdict) runs. | ||
| 3090 | kill "$DPID" 2>/dev/null || true | ||
| 3091 | wait "$DPID" 2>/dev/null || true | ||
| 3092 | DPID="" | ||
| 3093 | # Every capture this suite wrote — daemon stderr AND client output — is a | ||
| 3094 | # lifecycle log now: any binary that leaked printed a grep-able marker. | ||
| 3095 | if grep -q "LEAK:" "$OUT".* 2>/dev/null; then | ||
| 3096 | echo "e2e FAIL: a binary reported leaked allocations:" | ||
| 3097 | grep -H "LEAK:" "$OUT".* | ||
| 3098 | exit 1 | ||
| 3099 | fi | ||
| 3100 | # The detached (`muxd start`) daemons log via XDG_STATE_HOME; the file is | ||
| 3101 | # truncated at every spawn, so this asserts the LAST such daemon only — | ||
| 3102 | # stated, not hidden. | ||
| 3103 | if [ -f "$XDG_STATE_HOME/mux/muxd.log" ] && grep -q "LEAK:" "$XDG_STATE_HOME/mux/muxd.log"; then | ||
| 3104 | echo "e2e FAIL: a detached daemon reported leaked allocations:" | ||
| 3105 | grep -H "LEAK:" "$XDG_STATE_HOME/mux/muxd.log" | ||
| 3106 | exit 1 | ||
| 3107 | fi | ||
| 3108 | |||
| 3084 | # The pins. Literals, not variables set from counting something else — | 3109 | # The pins. Literals, not variables set from counting something else — |
| 3085 | # "assert the literal, never the constant the code under test reads" | 3110 | # "assert the literal, never the constant the code under test reads" |
| 3086 | # (decisions.md, M10). 20 scenario checkpoints; 33 convergence points. | 3111 | # (decisions.md, M10). 20 scenario checkpoints; 33 convergence points. |