a73x

44755603

feat: `mux d endpoint --start` ensures a daemon before it announces

a73x   2026-08-29 14:38

Commit message
feat: `mux d endpoint --start` ensures a daemon before it announces

The cold ssh->QUIC handoff costs three ssh runs because the CLIENT
decides to start: `mux d endpoint` exits 1, the client reaps the code to
tell that refusal from ssh's own 255, runs `mux d start`, then asks
again. Move the decision to the remote and it is one run, whose first
stdout line is the coordinates — mosh-server's shape.

The rule "a read never starts a daemon" then holds BY ARGV rather than
by a branch in the client: a wall poll spells `mux d endpoint` and an
empty box stays empty, an asked dial spells `--start`.

`--start` is endpoint's alone. Every other verb refuses it as the
unknown argument it is there — `mux d start --start` and `mux d stats
--start` are the reachable typos, and a flag that vanished would tell a
user they asked for something when they asked for nothing. The test
drives the whole `specs` table rather than a chosen verb, so a verb
added later inherits the refusal instead of a coincidence.

No run flags are forwarded to the ensure: a bare daemon binds its QUIC
listener when the announce asks, which is how a plain `mux d start`
already answers with a real port. A spawn that failed leaves the
announce unwritten, which is the "no daemon" shape the client already
reads as a failed handoff.

The asking path is pinned in e2e, not here: `spawn.ensure` takes the XDG
log path with no override, so a unit test that passed `--start` would
truncate the operator's live `muxd.log`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/cli/main.zig
Old New
@@ -26,7 +26,7 @@ const usage =
26 \\ mux d stats [--sock PATH] 26 \\ mux d stats [--sock PATH]
27 \\ mux d stop [--sock PATH] (ask the daemon on PATH to exit) 27 \\ mux d stop [--sock PATH] (ask the daemon on PATH to exit)
28 \\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket) 28 \\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket)
29 \\ mux d endpoint [--sock PATH] (proxy that first announces QUIC port+key) 29 \\ mux d endpoint [--sock PATH] [--start] (announce QUIC port+key, then proxy)
30 \\ mux d keygen (write a fresh key to ~/.config/mux/key) 30 \\ mux d keygen (write a fresh key to ~/.config/mux/key)
31 \\ mux d start [run's flags] (spawn a daemon detached; no-op if one is up) 31 \\ mux d start [run's flags] (spawn a daemon detached; no-op if one is up)
32 \\ mux d upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live) 32 \\ mux d upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live)
@@ -161,6 +161,14 @@ const Opts = struct {
161 /// `upgrade`'s one exception to the strictly-newer rule. It exists for 161 /// `upgrade`'s one exception to the strictly-newer rule. It exists for
162 /// the e2e leg, which has only one binary to upgrade with. 162 /// the e2e leg, which has only one binary to upgrade with.
163 allow_same_version: bool = false, 163 allow_same_version: bool = false,
164 /// `endpoint`'s alone, and `parseArgs` refuses it on every other verb:
165 /// ensure a daemon on the socket, then announce as usual. It is what
166 /// makes a cold `mux HOST` ONE ssh run — the client used to read the
167 /// refusal, run `mux d start` over a second ssh, and ask again over a
168 /// third. The decision is the REMOTE's now, and "a read never starts a
169 /// daemon" holds by argv: a poll spells `mux d endpoint` and starts
170 /// nothing, an ask spells this.
171 start: bool = false,
164 }; 172 };
165 173
166 // The three flags left out are written into argv by the OLD daemon on an 174 // The three flags left out are written into argv by the OLD daemon on an
@@ -212,6 +220,15 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
212 .bad_value => |f| return .{ .err = .{ .bad_value = f } }, 220 .bad_value => |f| return .{ .err = .{ .bad_value = f } },
213 } 221 }
214 222
223 // `--start` names the one verb it configures, so every other verb has
224 // to refuse it rather than ignore it: `mux d stats --start` and `mux d
225 // start --start` are the reachable typos, and a flag that vanishes
226 // there tells a user they asked for something when they asked for
227 // nothing. Reported as the unknown argument it is on that verb — the
228 // shared refusal, so the exit code and the page are the ones every
229 // other mistyped flag gets.
230 if (o.start and spec.cmd != .endpoint) return .{ .err = .{ .unknown_arg = "--start" } };
231
215 // A key with nowhere to listen is a mistake parse can see the whole of. 232 // A key with nowhere to listen is a mistake parse can see the whole of.
216 // The mirror case is NOT one: `--quic` with no `--key` may still be 233 // The mirror case is NOT one: `--quic` with no `--key` may still be
217 // answered by MUX_KEY_FILE or the default key path, neither of which 234 // answered by MUX_KEY_FILE or the default key path, neither of which
@@ -310,7 +327,7 @@ pub fn main(args: []const [:0]const u8) !u8 {
310 // what `mux --via 'ssh HOST mux d proxy'` shows a user whose remote 327 // what `mux --via 'ssh HOST mux d proxy'` shows a user whose remote
311 // has none — README's `ssh HOST 'mux d start'` is the answer. 328 // has none — README's `ssh HOST 'mux d start'` is the answer.
312 .proxy => return proxy.run(sock_path), 329 .proxy => return proxy.run(sock_path),
313 .endpoint => return endpointCmd(alloc, sock_path, std.posix.STDOUT_FILENO), 330 .endpoint => return endpointCmd(alloc, sock_path, std.posix.STDOUT_FILENO, o.start),
314 } 331 }
315 } 332 }
316 333
@@ -964,8 +981,10 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
964 /// STDOUT_FILENO — which is what makes "the announce, then the frames, on 981 /// STDOUT_FILENO — which is what makes "the announce, then the frames, on
965 /// one stream" true, since `proxy.run` below writes the pump to the real 982 /// one stream" true, since `proxy.run` below writes the pump to the real
966 /// stdout and knows nothing of this argument. 983 /// stdout and knows nothing of this argument.
967 /// This is what `mux HOST` runs over ssh — and what its wall polls, which 984 /// This is what `mux HOST` runs over ssh — and what its wall polls. The two
968 /// is why no daemon means exit 1 and not a daemon. 985 /// are told apart by `start`, not by anything the client does with the
986 /// answer: an ASKED dial spells `--start` and gets a daemon ensured, a poll
987 /// spells the bare verb and an empty box stays empty at exit 1.
969 /// 988 ///
970 /// The announce is mandatory in both directions. The client blocks on one 989 /// The announce is mandatory in both directions. The client blocks on one
971 /// newline-terminated line, and the daemon side of the frame protocol 990 /// newline-terminated line, and the daemon side of the frame protocol
@@ -991,11 +1010,40 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
991 /// `mux d proxy: cannot connect to …`. That is deliberate — reusing 1010 /// `mux d proxy: cannot connect to …`. That is deliberate — reusing
992 /// `proxy.run` is the whole design, and the line names the code that 1011 /// `proxy.run` is the whole design, and the line names the code that
993 /// failed rather than the verb that was typed. 1012 /// failed rather than the verb that was typed.
994 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t) !u8 { 1013 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t, start: bool) !u8 {
995 // Silent, and that is the whole of the verb's new manners: the wall 1014 // The ask, and the whole of what `--start` means: ensure a daemon, then
996 // runs this over ssh once a second per listed host, and a word on 1015 // answer as always.
997 // stderr here is a word on the wall's alternate screen. The client 1016 //
998 // that ASKED for the session narrates instead, and runs `mux d start`. 1017 // `--sock` is the ONE run flag forwarded, and it is not optional: the
1018 // path this verb is about to probe has to be the path the daemon binds.
1019 // Without it (`&.{}`) a `mux d endpoint --sock PATH --start` starts a
1020 // daemon on the DEFAULT socket, probes PATH, finds nothing and exits 1
1021 // — a stray daemon nothing reports. `spawn.ensureForAttach` forwards it
1022 // for the same reason. Nothing else is: a bare daemon binds its QUIC
1023 // listener when the announce below asks it to, which is how a plain
1024 // `mux d start` already produces a port.
1025 //
1026 // Truncating, like `mux d start`: the user asked for a daemon, so the
1027 // log they go on to read must be about the one they just got.
1028 //
1029 // A spawn that failed has already said so on stderr, which ssh carries
1030 // to the asker; returning here leaves the announce UNWRITTEN, and an
1031 // absent announce is the "no daemon" shape the client already reads as
1032 // a failed handoff. Announcing first and dying after is the one
1033 // dishonest option, for the reason the doc comment above gives.
1034 if (start) {
1035 const sock_z = try alloc.dupeZ(u8, sock_path);
1036 defer alloc.free(sock_z);
1037 const run_args = [_][:0]const u8{ "--sock", sock_z };
1038 if (spawn.ensure(alloc, &run_args, sock_path, "mux d endpoint", .{ .truncate = true }) == null)
1039 return 1;
1040 }
1041
1042 // Silent otherwise, and that is the whole of the verb's manners: the
1043 // wall runs this over ssh once a second per listed host, and a word on
1044 // stderr here is a word on the wall's alternate screen. The poll spells
1045 // the verb WITHOUT `--start`, which is what keeps a read from starting
1046 // anything — the rule is argv's now, not a branch in the client.
999 if (!spawn.probe(sock_path)) return 1; 1047 if (!spawn.probe(sock_path)) return 1;
1000 1048
1001 // The announce goes out on the same stdout the pump is about to use, 1049 // The announce goes out on the same stdout the pump is about to use,
@@ -1228,6 +1276,12 @@ fn parse(comptime argv: []const [:0]const u8) ParseResult {
1228 return parseArgs(argv); 1276 return parseArgs(argv);
1229 } 1277 }
1230 1278
1279 /// `specs` holds plain slices; a table-driven test needs argsAlloc's type.
1280 fn nameZ(comptime name: []const u8) [:0]const u8 {
1281 const buf = name ++ "\x00";
1282 return buf[0..name.len :0];
1283 }
1284
1231 test "parseArgs: subcommands and their existing flags" { 1285 test "parseArgs: subcommands and their existing flags" {
1232 const r = parse(&.{ "d", "run" }); 1286 const r = parse(&.{ "d", "run" });
1233 try std.testing.expect(r == .ok); 1287 try std.testing.expect(r == .ok);
@@ -1460,6 +1514,43 @@ test "parseArgs: endpoint is a command and takes --sock" {
1460 try std.testing.expectEqualStrings("--sock", missing.err.missing_value); 1514 try std.testing.expectEqualStrings("--sock", missing.err.missing_value);
1461 } 1515 }
1462 1516
1517 test "parseArgs: --start belongs to endpoint alone, and every other verb refuses it" {
1518 // The flag that makes a cold `mux HOST` one ssh run: the client no
1519 // longer decides to start, so the word has to reach this side.
1520 const on = parse(&.{ "d", "endpoint", "--start" });
1521 try std.testing.expect(on == .ok);
1522 try std.testing.expect(on.ok._cmd == .endpoint);
1523 try std.testing.expect(on.ok.start);
1524 // Off unless typed. A reading verb that inherited a true here would
1525 // start a daemon on every wall poll — the bug this whole rule exists
1526 // to keep dead.
1527 try std.testing.expect(!parse(&.{ "d", "endpoint" }).ok.start);
1528
1529 // Every OTHER verb, off the table, not a chosen one: a flag scoped by
1530 // a hand-written `!= .endpoint` is scoped correctly for whichever verb
1531 // the author happened to think of, and a verb added later inherits
1532 // nothing. `mux d start --start` is the reading that matters — it is
1533 // the plausible typo, and accepting it silently would tell a user they
1534 // configured something when they configured nothing.
1535 //
1536 // `.ignored` rows are excluded because their row says so: trailing
1537 // words after `--version`/`--help` are ACCEPTED and vanish (see Spec),
1538 // and e2e pins that for an over-long `--sock`. Narrowing them here
1539 // would be that contract's change, not this flag's.
1540 inline for (specs) |s| {
1541 if (s.cmd == .endpoint or s.flags == .ignored) continue;
1542 const r = parseArgs(&.{ "d", nameZ(s.name), "--start" });
1543 // expect() alone prints "expected true", which does not say which
1544 // verb let the flag through.
1545 if (r != .err) std.debug.print(
1546 "`mux d {s} --start` was accepted; --start is endpoint's alone\n",
1547 .{s.name},
1548 );
1549 try std.testing.expect(r == .err);
1550 try std.testing.expectEqual(@as(u8, 2), usageCode(r.err));
1551 }
1552 }
1553
1463 test "parseArgs: run --resume-fd N --check is the old daemon's dry run" { 1554 test "parseArgs: run --resume-fd N --check is the old daemon's dry run" {
1464 const r = parse(&.{ "d", "run", "--resume-fd", "7", "--check" }); 1555 const r = parse(&.{ "d", "run", "--resume-fd", "7", "--check" });
1465 try std.testing.expect(r == .ok); 1556 try std.testing.expect(r == .ok);
@@ -1666,11 +1757,16 @@ test "endpointCmd: a box with no daemon is refused, never started — the wall p
1666 1757
1667 try std.testing.expectEqual( 1758 try std.testing.expectEqual(
1668 @as(u8, 1), 1759 @as(u8, 1),
1669 try endpointCmd(std.testing.allocator, sock, out.handle), 1760 // `false` is the poll's spelling, and it is the one under test: the
1761 // asking spelling forks a daemon, which no unit test may do here —
1762 // `spawn.ensure` takes the XDG log path with no override, so a
1763 // `true` would truncate the operator's live `muxd.log`. That half
1764 // is e2e's (`e2e_04_handoff.sh` cold leg, `e2e_09_hosts.sh`).
1765 try endpointCmd(std.testing.allocator, sock, out.handle, false),
1670 ); 1766 );
1671 // The verb READS a box. Starting a daemon here gave a listed machine 1767 // The verb READS a box without `--start`. Starting a daemon here gave a
1672 // one (and a shell in session 0) from a poll, and undid a `mux d stop` 1768 // listed machine one (and a shell in session 0) from a poll, and undid a
1673 // on the next cycle a second later. 1769 // `mux d stop` on the next cycle a second later.
1674 try std.testing.expect(!spawn.probe(sock)); 1770 try std.testing.expect(!spawn.probe(sock));
1675 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size); 1771 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size);
1676 } 1772 }
src/cli/spawn.zig
Old New
@@ -63,11 +63,10 @@ pub const LogSpec = struct {
63 /// machine, and writing over it from a unit test would punch a hole in 63 /// machine, and writing over it from a unit test would punch a hole in
64 /// a live daemon's log. 64 /// a live daemon's log.
65 path: ?[]const u8 = null, 65 path: ?[]const u8 = null,
66 /// True only for `muxd start`, the one caller whose user asked for a 66 /// True for the two verbs whose user asked for a daemon — `mux d start`
67 /// (re)start and is owed a log about the daemon they just started 67 /// and `mux d endpoint --start`; both probe first, so a daemon already
68 /// rather than a stale one. Auto-start appends instead: an attach is 68 /// writing its log is never truncated under it. Auto-start appends
69 /// not a restart, and one `mux` over ssh must not zero the log of an 69 /// instead: an attach is not a restart.
70 /// interactive daemon that is still writing into it.
71 truncate: bool, 70 truncate: bool,
72 }; 71 };
73 72
src/xdg.zig
Old New
@@ -76,8 +76,7 @@ pub fn keyPathFrom(alloc: std.mem.Allocator, xdg_config_home: ?[]const u8, home:
76 return pathFrom(alloc, xdg_config_home, home, ".config", "key"); 76 return pathFrom(alloc, xdg_config_home, home, ".config", "key");
77 } 77 }
78 78
79 /// Truncated by `mux d start`, appended by auto-starts 79 /// Truncated by a verb that asks for a daemon; spawn.zig owns which and why.
80 /// (spawn.zig owns why).
81 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 { 80 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 {
82 return statePath(alloc, "muxd.log"); 81 return statePath(alloc, "muxd.log");
83 } 82 }
test/e2e.sh
Old New
@@ -170,8 +170,8 @@ done
170 # one of those and adds a convergence point would be pinning a fact every 170 # one of those and adds a convergence point would be pinning a fact every
171 # leg above already establishes. 171 # leg above already establishes.
172 172
173 [ "$OK_COUNT" = "89" ] || { 173 [ "$OK_COUNT" = "90" ] || {
174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 89 —" 174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 90 —"
175 echo " a scenario was added (update the pin) or silently lost" 175 echo " a scenario was added (update the pin) or silently lost"
176 exit 1 176 exit 1
177 } 177 }
test/e2e_01_boot.sh
Old New
@@ -926,6 +926,83 @@ softkill "$SPID" || true
926 SPID="" 926 SPID=""
927 ok "mux d start — spawn, no-op rerun, log truncation, race" 927 ok "mux d start — spawn, no-op rerun, log truncation, race"
928 928
929 # --- `mux d endpoint --start` ensures the daemon ON THE SOCKET IT PROBES.
930 #
931 # The verb a cold `mux HOST` runs over ssh: it ensures a daemon and then
932 # announces that daemon's QUIC coordinates on the same stdout, which is what
933 # makes the attach one ssh login instead of three. What it forwards to the
934 # ensure is the whole of this scenario, and `--sock` is the only flag that
935 # may be: forwarding nothing starts the daemon on the runtime dir's DEFAULT
936 # socket, probes the named one, finds nothing and exits 1 — a daemon on a
937 # path nobody asked for, and no announce to say it happened.
938 #
939 # A runtime dir of this leg's own is what makes that default socket
940 # observable at all: on the suite's shared one it is indistinguishable from
941 # some other leg's daemon. Short, because a unix path is capped at 107
942 # bytes and this one carries a socket name.
943 ESRUN="${TMPDIR:-/tmp}/mux-e2e-esr-$$"
944 defer_rm "$ESRUN"
945 ESSTATE="${TMPDIR:-/tmp}/mux-e2e-ess-$$"
946 defer_rm "$ESSTATE"
947 mkdir -p "$ESRUN" "$ESSTATE"
948 ESOWN="$ESRUN/own.sock"
949 ESDEF="$ESRUN/muxd.sock"
950 defer_sock "$ESOWN" "$ESDEF"
951 # stdin from /dev/null: this verb becomes `mux d proxy` once it has
952 # announced, and a pump with nothing left to read ends by itself. The
953 # announce is the first bytes of that stdout either way, which is the
954 # contract the client depends on.
955 set +e
956 env XDG_RUNTIME_DIR="$ESRUN" XDG_STATE_HOME="$ESSTATE" SHELL=/bin/sh timeout 30 \
957 "$MUX" d endpoint --sock "$ESOWN" --start < /dev/null > "$OUT.esa" 2> "$OUT.esa.err"
958 ESRC=$?
959 set -e
960 [ "$ESRC" -eq 0 ] || {
961 echo "e2e FAIL: mux d endpoint --sock ... --start exited $ESRC (want 0)"
962 cat "$OUT.esa.err"; exit 1; }
963 # (a) The named socket is bound, and bound by a `mux` — asked of the OS by
964 # INODE, not by a name. A `mux` running anywhere would satisfy a name check
965 # while binding something else entirely; the listening inode for this path
966 # out of /proc/net/unix, found among that pid's open fds, cannot.
967 ESPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.esa.err" | head -1)
968 defer_kill "$ESPID"
969 [ -n "$ESPID" ] || {
970 echo "e2e FAIL: endpoint --start printed no up-line pid"; cat "$OUT.esa.err"; exit 1; }
971 [ -S "$ESOWN" ] || {
972 echo "e2e FAIL: endpoint --start bound nothing at the socket it was given"
973 ls -la "$ESRUN"; exit 1; }
974 ESINO=$(awk -v p="$ESOWN" '$NF == p {print $7}' /proc/net/unix | head -1)
975 [ -n "$ESINO" ] || {
976 echo "e2e FAIL: the kernel lists no listening socket at $ESOWN"; exit 1; }
977 readlink /proc/"$ESPID"/fd/* 2>/dev/null | grep -qx "socket:\[$ESINO\]" || {
978 echo "e2e FAIL: pid $ESPID does not hold the socket at $ESOWN — the daemon"
979 echo " that answered is not the daemon --start reported starting"
980 exit 1; }
981 ESCOMM=$(cat "/proc/$ESPID/comm")
982 [ "$ESCOMM" = "mux" ] || {
983 echo "e2e FAIL: the socket at $ESOWN is served by comm '$ESCOMM', want 'mux'"; exit 1; }
984 # (b) ...and the announce came back on stdout, first line, in the grammar
985 # `handoff.parseAnnounce` reads. Either arm is a pass: this box may or may
986 # not have a key to announce, and "which" is another scenario's business.
987 ESLINE=$(head -1 "$OUT.esa")
988 printf '%s\n' "$ESLINE" | grep -qE '^endpoint (none|[1-9][0-9]* [0-9a-f]{64})$' || {
989 echo "e2e FAIL: endpoint --start announced '$ESLINE', which is not an announce"
990 cat -v "$OUT.esa" | head -3; exit 1; }
991 # (c) ...and NOTHING appeared on this runtime dir's default socket. This is
992 # the assertion the forwarded `--sock` exists for: without it the daemon
993 # lands here instead, and every check above fails with it.
994 [ ! -e "$ESDEF" ] || {
995 echo "e2e FAIL: endpoint --start bound the DEFAULT socket $ESDEF as well as"
996 echo " the one it was given — the ensure did not forward --sock"
997 ls -la "$ESRUN"; exit 1; }
998 ESSOCKS=$(ls -1 "$ESRUN"/*.sock 2>/dev/null | wc -l)
999 [ "$ESSOCKS" -eq 1 ] || {
1000 echo "e2e FAIL: endpoint --start left $ESSOCKS sockets under $ESRUN, want 1"
1001 ls -la "$ESRUN"; exit 1; }
1002 assert_stopped "$ESOWN" "$ESPID" "endpoint --start daemon" "$OUT.esstop"
1003 ESPID=""
1004 ok "mux d endpoint --start ensures a daemon on the socket it announces, and on no other"
1005
929 # --- M10: a start whose daemon dies young REPORTS it. This is the first-run 1006 # --- M10: a start whose daemon dies young REPORTS it. This is the first-run
930 # mistake the failure line exists for — `ssh HOST 'mux d start --quic 0.0.0.0'` 1007 # mistake the failure line exists for — `ssh HOST 'mux d start --quic 0.0.0.0'`
931 # before the key was ever scp'd — so it must be a message, not a panic. The 1008 # before the key was ever scp'd — so it must be a message, not a panic. The