a73x

c5656224

feat: muxd start — the setsid-nohup incantation becomes a subcommand

a73x   2026-08-09 14:02

Commit message
feat: muxd start — the setsid-nohup incantation becomes a subcommand

docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -1153,7 +1153,7 @@ git commit -m "feat: spawn.ensureDaemon — probe, detach, poll; the start/auto-
1153 - Modify: `src/main.zig` (usage, `Cmd`, `parseArgs`, dispatch, new `startCmd`; parse test) 1153 - Modify: `src/main.zig` (usage, `Cmd`, `parseArgs`, dispatch, new `startCmd`; parse test)
1154 - Modify: `test/e2e.sh` (start scenario: fresh start, marker, rerun no-op, race, default-key QUIC attach) 1154 - Modify: `test/e2e.sh` (start scenario: fresh start, marker, rerun no-op, race, default-key QUIC attach)
1155 1155
1156 - [ ] **Step 1: Failing parse test** 1156 - [x] **Step 1: Failing parse test**
1157 1157
1158 ```zig 1158 ```zig
1159 test "parseArgs: start takes run's flags" { 1159 test "parseArgs: start takes run's flags" {
@@ -1167,7 +1167,7 @@ test "parseArgs: start takes run's flags" {
1167 1167
1168 Run: `make test` — compile error on `.start` (failing state). 1168 Run: `make test` — compile error on `.start` (failing state).
1169 1169
1170 - [ ] **Step 2: Implement** 1170 - [x] **Step 2: Implement**
1171 1171
1172 `src/main.zig`. `Cmd`: add `start`: 1172 `src/main.zig`. `Cmd`: add `start`:
1173 1173
@@ -1226,12 +1226,12 @@ fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const
1226 1226
1227 Usage: add `\\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up)`. 1227 Usage: add `\\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up)`.
1228 1228
1229 - [ ] **Step 3: Run tests, expect pass** 1229 - [x] **Step 3: Run tests, expect pass**
1230 1230
1231 Run: `make test` 1231 Run: `make test`
1232 Expected: pass. 1232 Expected: pass.
1233 1233
1234 - [ ] **Step 4: e2e scenarios** 1234 - [x] **Step 4: e2e scenarios**
1235 1235
1236 `test/e2e.sh`, new block. Declare near the other SOCK vars: `SOCK8="${TMPDIR:-/tmp}/muxd-e2e-start-$$.sock"` and `SPID=""`, add `[ -n "$SPID" ] && kill "$SPID" 2>/dev/null` to the trap (kill by tracked pid) plus `wait` tolerance mirroring the existing daemons' cleanup. 1236 `test/e2e.sh`, new block. Declare near the other SOCK vars: `SOCK8="${TMPDIR:-/tmp}/muxd-e2e-start-$$.sock"` and `SPID=""`, add `[ -n "$SPID" ] && kill "$SPID" 2>/dev/null` to the trap (kill by tracked pid) plus `wait` tolerance mirroring the existing daemons' cleanup.
1237 1237
@@ -1312,16 +1312,16 @@ echo "e2e OK: keygen + start --quic + mux quic:// with no --key anywhere"
1312 1312
1313 Track `GPID` in the trap like `SPID`. 1313 Track `GPID` in the trap like `SPID`.
1314 1314
1315 - [ ] **Step 5: Run e2e** 1315 - [x] **Step 5: Run e2e**
1316 1316
1317 Run: `make build && make e2e` 1317 Run: `make build && make e2e`
1318 Expected: both new OK lines; suite passes. 1318 Expected: both new OK lines; suite passes.
1319 1319
1320 - [ ] **Step 6: Mutation checks** 1320 - [x] **Step 6: Mutation checks**
1321 1321
1322 (a) Race/loser handling: in `server.zig`'s `claimSockPath`, invert the live-daemon refusal (`return error.DaemonAlreadyRunning` → `return`); `make build && make e2e`: the race scenario or the rerun scenario MUST fail (two daemons, marker lost). Restore. (b) Pinned lines: drop `pid={d}` from the up-line; e2e MUST fail on the empty `SPID`. Restore. 1322 (a) Race/loser handling: in `server.zig`'s `claimSockPath`, invert the live-daemon refusal (`return error.DaemonAlreadyRunning` → `return`); `make build && make e2e`: the race scenario or the rerun scenario MUST fail (two daemons, marker lost). Restore. (b) Pinned lines: drop `pid={d}` from the up-line; e2e MUST fail on the empty `SPID`. Restore.
1323 1323
1324 - [ ] **Step 7: Commit** 1324 - [x] **Step 7: Commit**
1325 1325
1326 ```bash 1326 ```bash
1327 git add src/main.zig test/e2e.sh 1327 git add src/main.zig test/e2e.sh
src/main.zig
Old New
@@ -8,6 +8,7 @@ const proxy = @import("proxy");
8 const quic = @import("quic"); 8 const quic = @import("quic");
9 const build_options = @import("build_options"); 9 const build_options = @import("build_options");
10 const xdg = @import("xdg"); 10 const xdg = @import("xdg");
11 const spawn = @import("spawn");
11 12
12 const usage = 13 const usage =
13 \\usage: 14 \\usage:
@@ -17,6 +18,7 @@ const usage =
17 \\ muxd stats [--sock PATH] 18 \\ muxd stats [--sock PATH]
18 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) 19 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket)
19 \\ muxd keygen (write a fresh key to ~/.config/mux/key) 20 \\ muxd keygen (write a fresh key to ~/.config/mux/key)
21 \\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up)
20 \\ muxd --version 22 \\ muxd --version
21 \\ 23 \\
22 ; 24 ;
@@ -41,7 +43,7 @@ fn envKey() ?[]const u8 {
41 /// for what the number means and why it lives there. 43 /// for what the number means and why it lives there.
42 const default_quic_idle_ms: u32 = quic.default_idle_ms; 44 const default_quic_idle_ms: u32 = quic.default_idle_ms;
43 45
44 const Cmd = enum { run, dump, stats, proxy, version, keygen }; 46 const Cmd = enum { run, dump, stats, proxy, version, keygen, start };
45 47
46 /// Everything the command line can say, once. Parsed away from `main` so it 48 /// Everything the command line can say, once. Parsed away from `main` so it
47 /// can be tested without a process to exit from — the same reason 49 /// can be tested without a process to exit from — the same reason
@@ -92,6 +94,8 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
92 .proxy 94 .proxy
93 else if (std.mem.eql(u8, args[1], "keygen")) 95 else if (std.mem.eql(u8, args[1], "keygen"))
94 .keygen 96 .keygen
97 else if (std.mem.eql(u8, args[1], "start"))
98 .start
95 else 99 else
96 return .{ .err = .{ .unknown_command = args[1] } }; 100 return .{ .err = .{ .unknown_command = args[1] } };
97 101
@@ -232,6 +236,7 @@ pub fn main() !u8 {
232 return 0; 236 return 0;
233 }, 237 },
234 .keygen => return keygen(alloc), 238 .keygen => return keygen(alloc),
239 .start => return startCmd(alloc, sock_path, args[2..]),
235 .run => return run(alloc, o, sock_path), 240 .run => return run(alloc, o, sock_path),
236 .dump => return dump(alloc, sock_path, o.vt), 241 .dump => return dump(alloc, sock_path, o.vt),
237 .stats => return stats(alloc, sock_path), 242 .stats => return stats(alloc, sock_path),
@@ -410,6 +415,42 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
410 return 1; 415 return 1;
411 } 416 }
412 417
418 /// `muxd start` = ensureDaemon under an explicit flag. Everything after
419 /// `start` is forwarded to `run` verbatim — no re-serialization, so a flag
420 /// that parses here behaves identically there. parseArgs has already
421 /// validated the flags in THIS process; what it cannot validate (a bad
422 /// bind address, a missing key file) surfaces in the daemon's log, which
423 /// the failure path names.
424 fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
425 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
426 const exe = std.fs.selfExePath(&exe_buf) catch {
427 std.debug.print("muxd: cannot find own binary via /proc/self/exe\n", .{});
428 return 1;
429 };
430 const progress: spawn.Progress = .{
431 .fd = std.posix.STDERR_FILENO,
432 .prefix = "muxd",
433 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
434 };
435 // null log path: the xdg default is the whole point for a real daemon.
436 const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, 2000, null) catch |err| switch (err) {
437 // The failure line, with the log path, was already printed by
438 // Progress — a second line here would say the same thing worse.
439 error.NeverAnswered => return 1,
440 error.BinaryNotFound, error.SpawnFailed => {
441 std.debug.print("muxd: could not spawn {s}: {s}\n", .{ exe, @errorName(err) });
442 return 1;
443 },
444 };
445 if (r == .already_running) {
446 std.debug.print(
447 "muxd: already running on {s} (stop it first if you meant different flags)\n",
448 .{sock_path},
449 );
450 }
451 return 0;
452 }
453
413 fn keygen(alloc: std.mem.Allocator) !u8 { 454 fn keygen(alloc: std.mem.Allocator) !u8 {
414 const path = try xdg.keyPath(alloc); 455 const path = try xdg.keyPath(alloc);
415 defer alloc.free(path); 456 defer alloc.free(path);
@@ -608,3 +649,11 @@ test "pickKey: --key beats MUX_KEY_FILE beats the default path" {
608 // Nothing named anywhere is the triad-message case, not a silent skip. 649 // Nothing named anywhere is the triad-message case, not a silent skip.
609 try std.testing.expect(pickKey(null, null, null) == null); 650 try std.testing.expect(pickKey(null, null, null) == null);
610 } 651 }
652
653 test "parseArgs: start takes run's flags" {
654 const r = parse(&.{ "muxd", "start", "--sock", "/tmp/x.sock", "--cols", "100" });
655 try std.testing.expect(r == .ok);
656 try std.testing.expect(r.ok.cmd == .start);
657 try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?);
658 try std.testing.expectEqual(@as(u16, 100), r.ok.cols);
659 }
test/e2e.sh
Old New
@@ -49,6 +49,16 @@ SOCK10="${TMPDIR:-/tmp}/muxd-e2e-flagwins-$$.sock"
49 D10PID="" 49 D10PID=""
50 QPORT2=$(( 26000 + ($$ % 4000) )) 50 QPORT2=$(( 26000 + ($$ % 4000) ))
51 QPORT3=$(( 31000 + ($$ % 4000) )) 51 QPORT3=$(( 31000 + ($$ % 4000) ))
52 # M10 `muxd start`. These daemons are spawned DETACHED, so the suite never
53 # holds their pids as shell jobs — it reads them off the up-line and kills
54 # by that tracked pid, never by name.
55 SOCK8="${TMPDIR:-/tmp}/muxd-e2e-start-$$.sock"
56 SOCK8T="${TMPDIR:-/tmp}/muxd-e2e-trunc-$$.sock"
57 SOCK11="${TMPDIR:-/tmp}/muxd-e2e-goal-$$.sock"
58 QPORT4=$(( 36000 + ($$ % 4000) ))
59 SPID=""
60 TPID=""
61 GPID=""
52 62
53 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 63 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
54 # such line on exit; every field is a key=value pair, so a rename or reorder 64 # such line on exit; every field is a key=value pair, so a rename or reorder
@@ -84,6 +94,16 @@ wait_for() {
84 return 1 94 return 1
85 } 95 }
86 96
97 # Poll until nothing answers on a socket path (2s). Keyed off the daemon's
98 # own liveness rather than a fixed sleep, same reasoning as wait_for.
99 wait_gone() {
100 _i=0
101 while "$MUXD" dump --sock "$1" > /dev/null 2>&1; do
102 _i=$((_i + 1)); [ "$_i" -lt 40 ] || { echo "e2e FAIL: daemon on $1 never died"; exit 1; }
103 sleep 0.05
104 done
105 }
106
87 # The transport child for a given socket: a process named muxd running the 107 # The transport child for a given socket: a process named muxd running the
88 # `proxy` subcommand on that path. NEVER `pkill -f proxy` — the client's own 108 # `proxy` subcommand on that path. NEVER `pkill -f proxy` — the client's own
89 # argv contains the --via command string, so a pattern kill takes out the 109 # argv contains the --via command string, so a pattern kill takes out the
@@ -110,7 +130,13 @@ cleanup() {
110 [ -n "$D7PID" ] && kill "$D7PID" 2>/dev/null || true 130 [ -n "$D7PID" ] && kill "$D7PID" 2>/dev/null || true
111 [ -n "$D9PID" ] && kill "$D9PID" 2>/dev/null || true 131 [ -n "$D9PID" ] && kill "$D9PID" 2>/dev/null || true
112 [ -n "$D10PID" ] && kill "$D10PID" 2>/dev/null || true 132 [ -n "$D10PID" ] && kill "$D10PID" 2>/dev/null || true
113 rm -f "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ 133 # Detached daemons: killed by the pid their own up-line reported.
134 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true
135 [ -n "$TPID" ] && kill "$TPID" 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" \
138 "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" \
139 "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \
114 "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \ 140 "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \
115 "$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" \
116 "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ 142 "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \
@@ -809,6 +835,115 @@ echo "e2e OK: daemon honours MUX_KEY_FILE, and --key beats it"
809 835
810 rm -f "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKEY.wrong" 836 rm -f "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKEY.wrong"
811 837
838 # --- M10: muxd start — detached spawn, no-op rerun, race, pinned lines.
839 #
840 # Every scenario below asserts a MARKER through the session, never just $?.
841 # A client that fails to authenticate exits 0 by way of "aborted before
842 # attaching", so an exit code cannot distinguish a working session from a
843 # key or transport regression — only bytes coming back out of the shell can.
844 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start"
845 grep -q '^muxd: starting' "$OUT.start" || {
846 echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; }
847 grep -q ' up (' "$OUT.start" || {
848 echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; }
849 SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
850 [ -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; }
852 # Non-tty stderr: exactly two lines, no dots.
853 [ "$(wc -l < "$OUT.start")" = "2" ] || {
854 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; }
855
856 # The daemon it started serves a session (marker in, marker in dump).
857 { printf 'printf "start-%%s\\n" works\n'; sleep 2; printf '\034'; } | \
858 "$MUX" --sock "$SOCK8" > "$OUT.s8" 2>&1
859 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
860 echo "e2e FAIL: auto-started daemon lost the marker"; cat "$OUT.s8"; exit 1; }
861
862 # The log the failure path names really is there, and is no more readable
863 # than the key is: it carries whatever the daemon says about its own start.
864 MUXLOG="$XDG_STATE_HOME/mux/muxd.log"
865 [ -f "$MUXLOG" ] || { echo "e2e FAIL: no daemon log at $MUXLOG"; exit 1; }
866 LPERMS=$(stat -c %a "$MUXLOG")
867 [ "$LPERMS" = "600" ] || { echo "e2e FAIL: daemon log perms $LPERMS, want 600"; exit 1; }
868
869 # Rerun: silent no-op beyond the already-running line, exit 0, same daemon.
870 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start2"
871 grep -q "already running on $SOCK8" "$OUT.start2" || {
872 echo "e2e FAIL: rerun did not say already running"; cat "$OUT.start2"; exit 1; }
873 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
874 echo "e2e FAIL: rerun replaced the daemon (marker gone)"; exit 1; }
875
876 # Truncated per spawn, not appended to: the log holds the CURRENT daemon's
877 # output, so a stale one must not be what an operator reads after a
878 # restart. Padded, then a real spawn on a fresh path must shrink it.
879 dd if=/dev/zero bs=1024 count=8 2>/dev/null >> "$MUXLOG"
880 PADDED=$(stat -c %s "$MUXLOG")
881 "$MUXD" start --sock "$SOCK8T" 2> "$OUT.start"
882 TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
883 [ -n "$TPID" ] || { echo "e2e FAIL: truncation spawn reported no pid"; exit 1; }
884 SHRUNK=$(stat -c %s "$MUXLOG")
885 [ "$SHRUNK" -lt "$PADDED" ] || {
886 echo "e2e FAIL: log not truncated on spawn ($PADDED -> $SHRUNK)"; exit 1; }
887 kill "$TPID" 2>/dev/null || true
888 TPID=""
889
890 # Race: two concurrent starts, both exit 0, still one session (the marker
891 # survives — a second daemon on the path would have started a fresh shell).
892 kill "$SPID" && wait_gone "$SOCK8"
893 SPID=""
894 "$MUXD" start --sock "$SOCK8" 2> "$OUT.ra" & RA=$!
895 "$MUXD" start --sock "$SOCK8" 2> "$OUT.rb" & RB=$!
896 set +e
897 wait "$RA"; RCA=$?
898 wait "$RB"; RCB=$?
899 set -e
900 { [ "$RCA" = "0" ] && [ "$RCB" = "0" ]; } || {
901 echo "e2e FAIL: race: exits $RCA/$RCB"; cat "$OUT.ra" "$OUT.rb"; exit 1; }
902 { printf 'printf "race-%%s\\n" one\n'; sleep 2; printf '\034'; } | \
903 "$MUX" --sock "$SOCK8" > /dev/null 2>&1
904 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
905 echo "e2e FAIL: race: session unusable"; exit 1; }
906 SPID=$(cat "$OUT.ra" "$OUT.rb" | sed -n 's/.* pid=\([0-9]*\).*/\1/p' | while read -r p; do
907 kill -0 "$p" 2>/dev/null && echo "$p"; done | head -1)
908
909 # What actually makes two concurrent starts safe is that the LOSER exits
910 # instead of unlinking the winner's socket and binding over it. The race
911 # above cannot see that — both starts report success either way, and the
912 # marker comes back from whichever daemon owns the path — so it is pinned
913 # head-on: a second `muxd run` against a live socket must refuse.
914 set +e
915 timeout 10 "$MUXD" run --sock "$SOCK8" --shell /bin/sh > "$OUT.rb" 2>&1
916 RCS=$?
917 set -e
918 [ "$RCS" -eq 1 ] || {
919 echo "e2e FAIL: second muxd run on a live socket exited $RCS (want 1; 124 means it took the path)"
920 cat "$OUT.rb"; exit 1; }
921 grep -q "already running" "$OUT.rb" || {
922 echo "e2e FAIL: second muxd run refused, but not as already-running"; cat "$OUT.rb"; exit 1; }
923 # ...and the session the winner was serving is undisturbed.
924 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
925 echo "e2e FAIL: the refused daemon disturbed the live session"; exit 1; }
926
927 kill "$SPID" 2>/dev/null || true
928 SPID=""
929 echo "e2e OK: muxd start — spawn, no-op rerun, log truncation, race"
930
931 # --- 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).
933 # No --key on either side — the key is the one `muxd keygen` wrote at the
934 # hermetic default path near the top of this suite.
935 "$MUXD" start --sock "$SOCK11" --quic "127.0.0.1:$QPORT4" 2> "$OUT.goal"
936 GPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.goal")
937 [ -n "$GPID" ] || { echo "e2e FAIL: goal start reported no pid"; cat "$OUT.goal"; exit 1; }
938 { printf 'printf "goal-%%s\\n" quic\n'; sleep 2; printf '\034'; } | \
939 timeout 30 "$MUX" "quic://127.0.0.1:$QPORT4" > "$OUT.g9" 2>&1
940 "$MUXD" dump --sock "$SOCK11" | grep -q "goal-quic" || {
941 echo "e2e FAIL: no-key-flag QUIC attach did not reach the session"
942 cat "$OUT.g9"; exit 1; }
943 kill "$GPID" 2>/dev/null || true
944 GPID=""
945 echo "e2e OK: keygen + start --quic + mux quic:// with no --key anywhere"
946
812 # ---- M9: prediction --------------------------------------------------- 947 # ---- M9: prediction ---------------------------------------------------
813 # 948 #
814 # Every scenario here asserts COUNTERS, because the screen cannot tell a 949 # Every scenario here asserts COUNTERS, because the screen cannot tell a