9bd43fcf
fix: muxd start reports a dead child instead of panicking on it
a73x 2026-08-09 14:30
Commit message
src/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -124,6 +124,13 @@ pub fn ensureDaemon( | |||
| 124 | 124 | ||
| 125 | // Parent: poll. Dots only on a tty so scripted output stays pinnable. | 125 | // Parent: poll. Dots only on a tty so scripted output stays pinnable. |
| 126 | var next_dot: i64 = t0 + 250; | 126 | var next_dot: i64 = t0 + 250; |
| 127 | // A pid owes us exactly one reap. Calling waitpid again after it has | ||
| 128 | // been reaped gets ECHILD, which std.posix.waitpid answers with | ||
| 129 | // `unreachable` — so the second call is not an error to handle but a | ||
| 130 | // panic, and the panic lands precisely on the path that exists to | ||
| 131 | // report a child that died young (a missing key file, a bad bind | ||
| 132 | // address). Tracking the reap is what keeps that path a message. | ||
| 133 | var reaped = false; | ||
| 127 | while (true) { | 134 | while (true) { |
| 128 | if (probe(sock_path)) { | 135 | if (probe(sock_path)) { |
| 129 | const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0; | 136 | const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0; |
| @@ -145,7 +152,9 @@ pub fn ensureDaemon( | |||
| 145 | // Reap if the child exited (loser of a start race, or a refused | 152 | // Reap if the child exited (loser of a start race, or a refused |
| 146 | // flag): its socket-owner sibling answers the next probe either | 153 | // flag): its socket-owner sibling answers the next probe either |
| 147 | // way, and an unreaped child would sit as a zombie until we exit. | 154 | // way, and an unreaped child would sit as a zombie until we exit. |
| 148 | _ = std.posix.waitpid(pid, std.posix.W.NOHANG); | 155 | // Once is enough, and once is all that is safe — see `reaped`. |
| 156 | if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid) | ||
| 157 | reaped = true; | ||
| 149 | std.Thread.sleep(50 * std.time.ns_per_ms); | 158 | std.Thread.sleep(50 * std.time.ns_per_ms); |
| 150 | } | 159 | } |
| 151 | } | 160 | } |
| @@ -214,6 +223,40 @@ test "ensureDaemon: missing binary is BinaryNotFound before any fork" { | |||
| 214 | )); | 223 | )); |
| 215 | } | 224 | } |
| 216 | 225 | ||
| 226 | test "ensureDaemon: a child that dies young is reported, not panicked on" { | ||
| 227 | var tmp = try testtmp.TmpDir.make(); | ||
| 228 | defer tmp.cleanup(); | ||
| 229 | var pbuf: [128]u8 = undefined; | ||
| 230 | var sbuf: [128]u8 = undefined; | ||
| 231 | var lbuf: [128]u8 = undefined; | ||
| 232 | const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()}); | ||
| 233 | const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()}); | ||
| 234 | const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()}); | ||
| 235 | |||
| 236 | // Exits at once, binding nothing — a daemon refusing a flag, or one | ||
| 237 | // whose key file is missing. The poll loop therefore reaps it on an | ||
| 238 | // early pass and keeps polling to the deadline, which is where a | ||
| 239 | // second waitpid would get ECHILD and panic. | ||
| 240 | try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" }); | ||
| 241 | const f = try tmp.dir.openFile("dies.sh", .{}); | ||
| 242 | try f.chmod(0o755); | ||
| 243 | f.close(); | ||
| 244 | |||
| 245 | try std.testing.expectError(error.NeverAnswered, ensureDaemon( | ||
| 246 | std.testing.allocator, | ||
| 247 | stub, | ||
| 248 | &.{}, | ||
| 249 | sock, | ||
| 250 | silentProgress(), | ||
| 251 | 300, | ||
| 252 | log, | ||
| 253 | )); | ||
| 254 | // The log is still there to be named by the failure line: a child that | ||
| 255 | // died is exactly when an operator goes looking for it. | ||
| 256 | const log_st = try std.fs.cwd().statFile(log); | ||
| 257 | try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777))); | ||
| 258 | } | ||
| 259 | |||
| 217 | test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" { | 260 | test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" { |
| 218 | var tmp = try testtmp.TmpDir.make(); | 261 | var tmp = try testtmp.TmpDir.make(); |
| 219 | defer tmp.cleanup(); | 262 | defer tmp.cleanup(); |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -135,7 +135,7 @@ cleanup() { | |||
| 135 | [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true | 135 | [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true |
| 136 | [ -n "$GPID" ] && kill "$GPID" 2>/dev/null || true | 136 | [ -n "$GPID" ] && kill "$GPID" 2>/dev/null || true |
| 137 | rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \ | 137 | rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \ |
| 138 | "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" \ | 138 | "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \ |
| 139 | "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ | 139 | "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ |
| 140 | "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \ | 140 | "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \ |
| 141 | "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.pr" \ | 141 | "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.pr" \ |
| @@ -846,6 +846,11 @@ grep -q '^muxd: starting' "$OUT.start" || { | |||
| 846 | echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; } | 846 | echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; } |
| 847 | grep -q ' up (' "$OUT.start" || { | 847 | grep -q ' up (' "$OUT.start" || { |
| 848 | echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; } | 848 | echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; } |
| 849 | # Known gap, accepted: between the spawn above and this capture the daemon | ||
| 850 | # is running with no pid the trap can reach, so a failure in the two | ||
| 851 | # assertions in that window leaks it. Narrow, only on an already-failing | ||
| 852 | # run, and closing it would mean parsing the pid before asserting the lines | ||
| 853 | # that prove the pid is there. | ||
| 849 | SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start") | 854 | SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start") |
| 850 | [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; } | 855 | [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; } |
| 851 | kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; } | 856 | kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; } |
| @@ -928,6 +933,28 @@ kill "$SPID" 2>/dev/null || true | |||
| 928 | SPID="" | 933 | SPID="" |
| 929 | echo "e2e OK: muxd start — spawn, no-op rerun, log truncation, race" | 934 | echo "e2e OK: muxd start — spawn, no-op rerun, log truncation, race" |
| 930 | 935 | ||
| 936 | # --- M10: a start whose daemon dies young REPORTS it. This is the first-run | ||
| 937 | # mistake the failure line exists for — `ssh HOST 'muxd start --quic 0.0.0.0'` | ||
| 938 | # before the key was ever scp'd — so it must be a message, not a panic. The | ||
| 939 | # child exits on the missing key, the poll loop reaps it, and polling | ||
| 940 | # continues to the deadline; a second waitpid there gets ECHILD, which the | ||
| 941 | # stdlib answers with `unreachable`, i.e. exit 134 and a stack trace. | ||
| 942 | DEADCFG="${TMPDIR:-/tmp}/mux-e2e-deadchild-$$" | ||
| 943 | set +e | ||
| 944 | env XDG_CONFIG_HOME="$DEADCFG" timeout 30 "$MUXD" start --sock "$SOCK8T" \ | ||
| 945 | --quic "127.0.0.1:1" --key /nonexistent > "$OUT.dead" 2>&1 | ||
| 946 | DRC=$? | ||
| 947 | set -e | ||
| 948 | [ "$DRC" -eq 1 ] || { | ||
| 949 | echo "e2e FAIL: start with a doomed child exited $DRC (want 1; 134 is the waitpid panic)" | ||
| 950 | cat "$OUT.dead"; exit 1; } | ||
| 951 | grep -q "did not answer" "$OUT.dead" || { | ||
| 952 | echo "e2e FAIL: doomed start printed no deadline line"; cat "$OUT.dead"; exit 1; } | ||
| 953 | grep -q "log: .*muxd\.log" "$OUT.dead" || { | ||
| 954 | echo "e2e FAIL: deadline line does not name the log"; cat "$OUT.dead"; exit 1; } | ||
| 955 | rm -rf "$DEADCFG" | ||
| 956 | echo "e2e OK: a start whose daemon dies young says so, with the log path" | ||
| 957 | |||
| 931 | # --- M10: the goal commands, minus ssh: keygen'd default key on both ends, | 958 | # --- M10: the goal commands, minus ssh: keygen'd default key on both ends, |
| 932 | # explicit loopback port (4433 on the suite machine is somebody's daemon). | 959 | # explicit loopback port (4433 on the suite machine is somebody's daemon). |
| 933 | # No --key on either side — the key is the one `muxd keygen` wrote at the | 960 | # No --key on either side — the key is the one `muxd keygen` wrote at the |