a73x

ab0ecad2

feat: DebugAllocator verdicts print a grep-able LEAK marker; e2e/agent assert absence

a73x   2026-08-14 08:46

Commit message
feat: DebugAllocator verdicts print a grep-able LEAK marker; e2e/agent assert absence

A print, never a panic or exit-code change — muxd's exit code carries
the shell's. Every e2e daemon now captures stderr; the suite sweeps all
captures at the end (the detached-daemon log is truncated per spawn, so
only the last one is covered there — stated). agent.sh checks after
each clean stop, post process-exit to avoid racing the verdict.
Deliberate-leak proof: marker appears, e2e fails at the sweep.

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

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.