2c823d71
fix: muxd stop returns when the process is gone, not when the socket is
a73x 2026-08-26 15:48
Commit message
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 | } |