a73x

2c823d71

fix: muxd stop returns when the process is gone, not when the socket is

a73x   2026-08-26 15:48

Commit message
fix: muxd stop returns when the process is gone, not when the socket is

The unlink is the first thing a stopping daemon does; reaping its shells
(one TERM grace when one ignores it) and deleting its dirs come after. A
stop that said "stopped" at the unlink handed a scripted `muxd start`,
or a supervisor's "is it down", a daemon still running — and this suite
papered the gap with `wait_pid_gone` after every stop. The pid comes from
the kernel (SO_PEERCRED on the stop connection), not from the daemon, so
the wait works against a daemon that predates it.

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

src/main.zig
Old New
@@ -769,10 +769,11 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
769 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply); 769 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply);
770 } 770 }
771 771
772 /// Ask the daemon on `sock_path` to exit, then wait for the socket to stop 772 /// Ask the daemon on `sock_path` to exit, then wait until the PROCESS is
773 /// answering. Exit 0 covers both "stopped" and "nothing there" — the state 773 /// gone, not just the path. Exit 0 covers both "stopped" and "nothing
774 /// the user asked for is the state they got, which is what makes the verb 774 /// there" — the state the user asked for is the state they got, which is
775 /// safe to script (`muxd start`'s re-runnability, mirrored). 775 /// what makes the verb safe to script (`muxd start`'s re-runnability,
776 /// mirrored).
776 /// 777 ///
777 /// Prefixes split the way `start`'s do: `muxd stop:` for a refusal or a 778 /// Prefixes split the way `start`'s do: `muxd stop:` for a refusal or a
778 /// report about this command, plain `muxd:` for a lifecycle verdict. 779 /// report about this command, plain `muxd:` for a lifecycle verdict.
@@ -788,6 +789,7 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
788 // request it never saw — pointing there would send the reader to an 789 // request it never saw — pointing there would send the reader to an
789 // empty page. 790 // empty page.
790 const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false; 791 const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false;
792 const peer = peerPid(stream.handle);
791 stream.close(); 793 stream.close();
792 794
793 // Probe-first, deadline-second — ensureDaemon's poll shape (spawn.zig), 795 // Probe-first, deadline-second — ensureDaemon's poll shape (spawn.zig),
@@ -801,10 +803,7 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
801 const stop_deadline_ms: i64 = 2000; 803 const stop_deadline_ms: i64 = 2000;
802 const t0 = std.time.milliTimestamp(); 804 const t0 = std.time.milliTimestamp();
803 while (true) { 805 while (true) {
804 if (!spawn.probe(sock_path)) { 806 if (!spawn.probe(sock_path)) return waitPidGone(peer, sock_path);
805 std.debug.print("muxd: stopped\n", .{});
806 return 0;
807 }
808 if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break; 807 if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break;
809 std.Thread.sleep(50 * std.time.ns_per_ms); 808 std.Thread.sleep(50 * std.time.ns_per_ms);
810 } 809 }
@@ -824,6 +823,48 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
824 return 1; 823 return 1;
825 } 824 }
826 825
826 /// The daemon's pid from the kernel, not from the daemon: `stop`'s promise
827 /// is about a process, and only the OS can vouch for one. Null when the
828 /// kernel cannot name the peer (another pid namespace reports 0), and then
829 /// the socket's silence is all there is to wait on.
830 fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
831 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t };
832 var cred: Ucred = undefined;
833 std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null;
834 return if (cred.pid > 0) cred.pid else null;
835 }
836
837 /// A socket gone quiet is the unlink, and the unlink is the FIRST thing a
838 /// stopping daemon does; reaping its shells (one TERM grace when one
839 /// ignores it) and deleting its dirs come after. "stopped" said at the
840 /// unlink handed a scripted `muxd start`, or a supervisor's "is it down",
841 /// a daemon still running. The bound is the reap's own grace with room to
842 /// spare — a daemon still here after it is wedged in teardown, and that
843 /// is a report, not a wait.
844 fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 {
845 const pid = peer orelse {
846 std.debug.print("muxd: stopped\n", .{});
847 return 0;
848 };
849 const gone_deadline_ms: i64 = 3000;
850 const t0 = std.time.milliTimestamp();
851 // Only a live process answers signal 0 with success. ESRCH is the
852 // answer wanted; EPERM means the pid was reused by someone else's
853 // process, and the daemon is just as gone.
854 while (std.posix.kill(pid, 0)) |_| {
855 if (std.time.milliTimestamp() - t0 >= gone_deadline_ms) {
856 std.debug.print(
857 "muxd stop: {s} is closed, but pid {d} is still running {d}s later\n",
858 .{ sock_path, pid, @divTrunc(gone_deadline_ms, 1000) },
859 );
860 return 1;
861 }
862 std.Thread.sleep(20 * std.time.ns_per_ms);
863 } else |_| {}
864 std.debug.print("muxd: stopped\n", .{});
865 return 0;
866 }
867
827 /// Ask the daemon on `sock_path` to become THIS binary. The new binary is 868 /// Ask the daemon on `sock_path` to become THIS binary. The new binary is
828 /// the one that asks: it knows its own version and its own path, and the 869 /// the one that asks: it knows its own version and its own path, and the
829 /// daemon is the one that decides. 870 /// daemon is the one that decides.
@@ -1725,6 +1766,34 @@ test "stopCmd: a socket path with nothing on it is exit 0, not a failure" {
1725 try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock)); 1766 try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock));
1726 } 1767 }
1727 1768
1769 test "peerPid: the kernel names the peer" {
1770 // Both ends of a socketpair are this process, so the only right answer
1771 // is our own pid — and it comes from the kernel, not from anything the
1772 // peer said about itself.
1773 var sp: [2]i32 = undefined;
1774 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
1775 defer std.posix.close(sp[0]);
1776 defer std.posix.close(sp[1]);
1777 try std.testing.expectEqual(std.os.linux.getpid(), peerPid(sp[0]).?);
1778 }
1779
1780 test "waitPidGone: returns only once the OS has no such process" {
1781 // A grandchild, deliberately: a child of ours would linger as a zombie
1782 // that signal 0 still finds, which is the trap this test would fall
1783 // into if it held the dimension constant. The shell prints the pid and
1784 // exits; the sleeper is reparented and dies on its own clock.
1785 var child = std.process.Child.init(&.{ "sh", "-c", "sleep 0.3 & echo $!" }, std.testing.allocator);
1786 child.stdout_behavior = .Pipe;
1787 try child.spawn();
1788 var buf: [32]u8 = undefined;
1789 const n = try child.stdout.?.readAll(&buf);
1790 _ = try child.wait();
1791 const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, buf[0..n], "\n "), 10);
1792 try std.posix.kill(pid, 0); // alive when we start, or the wait proves nothing
1793 try std.testing.expectEqual(@as(u8, 0), waitPidGone(pid, "(test)"));
1794 try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0));
1795 }
1796
1728 test "shellIntegrationEnabled: an unset environment means off" { 1797 test "shellIntegrationEnabled: an unset environment means off" {
1729 // The daily-driver default. The injection is not free — under zsh the 1798 // The daily-driver default. The injection is not free — under zsh the
1730 // ZDOTDIR shim costs the user their ~/.zshenv, and under bash the DEBUG 1799 // ZDOTDIR shim costs the user their ~/.zshenv, and under bash the DEBUG
test/e2e.sh
Old New
@@ -452,6 +452,7 @@ SOCK69="${TMPDIR:-/tmp}/muxd-e2e-upgrade-$$.sock"
452 SOCK70="${TMPDIR:-/tmp}/muxd-e2e-uproll-$$.sock" 452 SOCK70="${TMPDIR:-/tmp}/muxd-e2e-uproll-$$.sock"
453 SOCK71="${TMPDIR:-/tmp}/muxd-e2e-upagent-$$.sock" 453 SOCK71="${TMPDIR:-/tmp}/muxd-e2e-upagent-$$.sock"
454 SOCK72="${TMPDIR:-/tmp}/muxd-e2e-upquic-$$.sock" 454 SOCK72="${TMPDIR:-/tmp}/muxd-e2e-upquic-$$.sock"
455 SOCK73="${TMPDIR:-/tmp}/muxd-e2e-stopgone-$$.sock"
455 # The next 4000-wide band DOWN from the hydrate leg's 6000: every base from 456 # The next 4000-wide band DOWN from the hydrate leg's 6000: every base from
456 # 11000 up is taken, and the 61000+ tail above the ephemeral range is spoken 457 # 11000 up is taken, and the 61000+ tail above the ephemeral range is spoken
457 # for by the two browser ports. A collision here reads as this leg's daemon 458 # for by the two browser ports. A collision here reads as this leg's daemon
@@ -473,6 +474,7 @@ D69PID=""
473 D70PID="" 474 D70PID=""
474 D71PID="" 475 D71PID=""
475 D72PID="" 476 D72PID=""
477 D73PID=""
476 UPAGPID="" 478 UPAGPID=""
477 UPPCPID="" 479 UPPCPID=""
478 UPCPID="" 480 UPCPID=""
@@ -1444,6 +1446,7 @@ cleanup() {
1444 [ -n "${D70PID:-}" ] && kill "$D70PID" 2>/dev/null || true 1446 [ -n "${D70PID:-}" ] && kill "$D70PID" 2>/dev/null || true
1445 [ -n "${D71PID:-}" ] && kill "$D71PID" 2>/dev/null || true 1447 [ -n "${D71PID:-}" ] && kill "$D71PID" 2>/dev/null || true
1446 [ -n "${D72PID:-}" ] && kill "$D72PID" 2>/dev/null || true 1448 [ -n "${D72PID:-}" ] && kill "$D72PID" 2>/dev/null || true
1449 [ -n "${D73PID:-}" ] && kill "$D73PID" 2>/dev/null || true
1447 [ -n "${UPPCPID:-}" ] && kill "$UPPCPID" 2>/dev/null || true 1450 [ -n "${UPPCPID:-}" ] && kill "$UPPCPID" 2>/dev/null || true
1448 [ -n "${UPCPID:-}" ] && kill "$UPCPID" 2>/dev/null || true 1451 [ -n "${UPCPID:-}" ] && kill "$UPCPID" 2>/dev/null || true
1449 # The ssh-agents the forwarding legs start. Not mux processes and so not 1452 # The ssh-agents the forwarding legs start. Not mux processes and so not
@@ -1507,6 +1510,7 @@ cleanup() {
1507 [ -S "$SOCK70" ] && "$MUXD" stop --sock "$SOCK70" 2>/dev/null || true 1510 [ -S "$SOCK70" ] && "$MUXD" stop --sock "$SOCK70" 2>/dev/null || true
1508 [ -S "$SOCK71" ] && "$MUXD" stop --sock "$SOCK71" 2>/dev/null || true 1511 [ -S "$SOCK71" ] && "$MUXD" stop --sock "$SOCK71" 2>/dev/null || true
1509 [ -S "$SOCK72" ] && "$MUXD" stop --sock "$SOCK72" 2>/dev/null || true 1512 [ -S "$SOCK72" ] && "$MUXD" stop --sock "$SOCK72" 2>/dev/null || true
1513 [ -S "$SOCK73" ] && "$MUXD" stop --sock "$SOCK73" 2>/dev/null || true
1510 1514
1511 # ---- the leak sweep (hygiene kit, 6a) ---- 1515 # ---- the leak sweep (hygiene kit, 6a) ----
1512 # Here rather than at the bottom of the file, which `set -e` reaches only 1516 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1525,7 +1529,7 @@ cleanup() {
1525 "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \ 1529 "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \
1526 "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID" "$D65PID" \ 1530 "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID" "$D65PID" \
1527 "$D66PID" "$D67PID" "$D68PID" "$D69PID" "$D70PID" \ 1531 "$D66PID" "$D67PID" "$D68PID" "$D69PID" "$D70PID" \
1528 "$D71PID" "$D72PID" 1532 "$D71PID" "$D72PID" "$D73PID"
1529 _leak=0 1533 _leak=0
1530 leak_sweep "$_rc" || _leak=1 1534 leak_sweep "$_rc" || _leak=1
1531 1535
@@ -1644,7 +1648,8 @@ cleanup() {
1644 "$OUT.uagup" "$OUT.uagstop" "$SOCK71" \ 1648 "$OUT.uagup" "$OUT.uagstop" "$SOCK71" \
1645 "$UPAGENT" "$UPAGKEY" "$UPAGKEY.pub" \ 1649 "$UPAGENT" "$UPAGKEY" "$UPAGKEY.pub" \
1646 "$OUT.uqc.d" "$OUT.uqc" "$OUT.uqc.err" "$OUT.uqc.in" "$OUT.uqup" \ 1650 "$OUT.uqc.d" "$OUT.uqc" "$OUT.uqc.err" "$OUT.uqc.in" "$OUT.uqup" \
1647 "$OUT.uqsta" "$OUT.uqstop" "$SOCK72" "$UPKEY" 1651 "$OUT.uqsta" "$OUT.uqstop" "$SOCK72" "$UPKEY" \
1652 "$OUT.sg" "$OUT.sg.d" "$OUT.sg.stop" "$OUT.sg.sh" "$SOCK73"
1648 # The upgrade leg's private HOME, for the reason it has one: a session 1653 # The upgrade leg's private HOME, for the reason it has one: a session
1649 # shell that read the developer's rc files would set its own title. 1654 # shell that read the developer's rc files would set its own title.
1650 rm -rf "$UPHOME" 1655 rm -rf "$UPHOME"
@@ -9599,8 +9604,38 @@ assert_stopped "$SOCK72" "$D72PID" "quic-upgrade" "$OUT.uqstop"
9599 D72PID="" 9604 D72PID=""
9600 ok "a QUIC client is served again within a breath of the exec, not after the idle timeout" 9605 ok "a QUIC client is served again within a breath of the exec, not after the idle timeout"
9601 9606
9602 [ "$OK_COUNT" = "81" ] || { 9607 # --- muxd stop returns when the PROCESS is gone, not when the path is
9603 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 81 —" 9608 #
9609 # The unlink is the first thing a stopping daemon does; reaping its shells
9610 # and deleting its dirs come after. A stop that said "stopped" at the
9611 # unlink handed a scripted `muxd start`, or a supervisor's "is it down",
9612 # a daemon still running — `wait_pid_gone` after every stop in this suite
9613 # was that gap, papered. Shells that ignore TERM make the window a real
9614 # grace rather than a race the assertion could win by luck, and two
9615 # sessions make it a table's grace, not one shell's.
9616 printf '#!/bin/sh\ntrap "" HUP TERM\nwhile :; do sleep 1; done\n' > "$OUT.sg.sh"
9617 chmod +x "$OUT.sg.sh"
9618 "$MUXD" run --sock "$SOCK73" --shell "$OUT.sg.sh" > "$OUT.sg.d" 2>&1 &
9619 D73PID=$!
9620 wait_sock "$SOCK73" "$OUT.sg.d" "stop-gone daemon never bound"
9621 pipe_mux "$OUT.sg" "" timeout 60 "$MUX" --sock "$SOCK73" --session second
9622 sleep 0.5
9623 pipe_detach "stop-gone: the second session's client"
9624 [ "$("$MUXD" stats --sock "$SOCK73" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "2" ] || {
9625 echo "e2e FAIL: stop-gone: wanted two sessions of stubborn shells"; exit 1; }
9626 "$MUXD" stop --sock "$SOCK73" 2> "$OUT.sg.stop" || {
9627 echo "e2e FAIL: stop-gone: stop failed"; cat "$OUT.sg.stop"; exit 1; }
9628 # Asked of the OS the instant stop returns — no wait, no retry.
9629 if kill -0 "$D73PID" 2>/dev/null; then
9630 echo "e2e FAIL: stop said stopped, but pid $D73PID is still running"; cat "$OUT.sg.stop"; exit 1
9631 fi
9632 grep -q '^muxd: stopped' "$OUT.sg.stop" || {
9633 echo "e2e FAIL: stop-gone: stop did not report stopped"; cat "$OUT.sg.stop"; exit 1; }
9634 D73PID=""
9635 ok "muxd stop returns when the process is gone, not when the socket is"
9636
9637 [ "$OK_COUNT" = "82" ] || {
9638 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 82 —"
9604 echo " a scenario was added (update the pin) or silently lost" 9639 echo " a scenario was added (update the pin) or silently lost"
9605 exit 1 9640 exit 1
9606 } 9641 }