a73x

9bd43fcf

fix: muxd start reports a dead child instead of panicking on it

a73x   2026-08-09 14:30

Commit message
fix: muxd start reports a dead child instead of panicking on it

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