a73x

03fcbc76

refactor: the daemon CLI says what a flag does, not what it replaced

a73x   2026-08-30 19:36

Commit message
refactor: the daemon CLI says what a flag does, not what it replaced

57 essays to 7 in main.zig. Flag docs state the scope rule and the
failure it prevents; the paragraphs listing what adding a subcommand
"used to mean", which release probed for which string, and what an
earlier sizing got wrong are gone.

2471 -> 2242 lines, 834 -> 605 comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUFuYHqU9wr4J5NC8EkWV

docscheck.blocks
Old New
@@ -12,7 +12,7 @@ hosts.zig 5
12 interact.zig 24 12 interact.zig 24
13 keymap.zig 1 13 keymap.zig 1
14 layout.zig 8 14 layout.zig 8
15 main.zig 57 15 main.zig 7
16 muxa.zig 22 16 muxa.zig 22
17 mux_main.zig 16 17 mux_main.zig 16
18 mux.zig 2 18 mux.zig 2
src/cli/main.zig
Old New
@@ -38,14 +38,9 @@ const usage =
38 38
39 const Cmd = enum { dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; 39 const Cmd = enum { dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade };
40 40
41 /// One row per verb. Adding a subcommand used to mean editing the usage 41 /// One row per verb: everything but the dispatch body and the prose reads off
42 /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled 42 /// this table. The two legs a table cannot check itself have their own — the
43 /// no-flags exception, and main's uses_socket switch — of which only the 43 /// comptime block below, and "usage names every subcommand".
44 /// dispatch switch was compiler-checked. Everything except the dispatch body
45 /// and the prose now reads off this table, and the two legs a table cannot
46 /// check itself have their own: the comptime block below (every Cmd has
47 /// exactly one row) and "usage names every subcommand" (every row is in the
48 /// prose, on its own line).
49 const Spec = struct { 44 const Spec = struct {
50 name: []const u8, 45 name: []const u8,
51 cmd: Cmd, 46 cmd: Cmd,
@@ -53,15 +48,10 @@ const Spec = struct {
53 /// length guard in `main` reads this, and answering `false` means a 48 /// length guard in `main` reads this, and answering `false` means a
54 /// doomed path cannot refuse a command that never touches a socket. 49 /// doomed path cannot refuse a command that never touches a socket.
55 uses_socket: bool, 50 uses_socket: bool,
56 /// What the verb does with the words after its name. `all` runs the 51 /// What the verb does with the words after its name. `all` runs the shared
57 /// shared flag loop; `none` refuses any argument at all; `ignored` 52 /// flag loop, `none` refuses any argument, `ignored` returns before the
58 /// returns before the loop, so nothing after the name is read. 53 /// loop. `ignored` is a CONTRACT: trailing arguments are accepted and
59 /// 54 /// vanish, which e2e pins for `mux d --version` with an over-long `--sock`.
60 /// `ignored` is a contract, not an oversight: trailing arguments are
61 /// ACCEPTED and vanish silently. `mux d --version` with an over-long
62 /// `--sock` exits 0 today, pinned in e2e.sh ("--version refused over
63 /// sock length"). Narrowing it to `none` would turn that into a
64 /// refusal.
65 flags: enum { none, all, ignored }, 55 flags: enum { none, all, ignored },
66 }; 56 };
67 57
@@ -125,11 +115,9 @@ const Opts = struct {
125 /// Not a flag, and the leading underscore is what says so: cliflags.parse 115 /// Not a flag, and the leading underscore is what says so: cliflags.parse
126 /// skips it, the verb having been settled by the row above. 116 /// skips it, the verb having been settled by the row above.
127 _cmd: Cmd, 117 _cmd: Cmd,
128 /// `start`'s alone, and structurally so: `-d` is a WORD `parseArgs` 118 /// `start`'s alone, structurally: `-d` is a WORD `parseArgs` peels at
129 /// peels at args[2], never a flag in this table, so no other verb can 119 /// args[2], never a flag here, so no other verb sees it. Set means fork and
130 /// see it and no `--sock -d` can hide one. Set means fork, hand the 120 /// wait for the socket; off is the foreground daemon.
131 /// child args[3..] verbatim, and wait until the socket answers. Off is
132 /// the foreground daemon — the process that binds is the one typed.
133 _detach: bool = false, 121 _detach: bool = false,
134 sock: ?[]const u8 = null, 122 sock: ?[]const u8 = null,
135 shell: ?[]const u8 = null, 123 shell: ?[]const u8 = null,
@@ -148,11 +136,9 @@ const Opts = struct {
148 /// is offered to the type: `""` is the wire's own default spelling — no 136 /// is offered to the type: `""` is the wire's own default spelling — no
149 /// tail at all — and would fail a rule written for a name a user typed. 137 /// tail at all — and would fail a rule written for a name a user typed.
150 session: ?proto.SessionName = null, 138 session: ?proto.SessionName = null,
151 /// The inherited manifest descriptor an upgrade exec'd us with. Not a 139 /// The inherited manifest descriptor an upgrade exec'd us with — the old
152 /// user flag: the old daemon writes it into our argv. Its presence is 140 /// daemon writes it into our argv, not a user flag. Its presence makes
153 /// what makes `start` an ADOPTION rather than a start, and it is also 141 /// `start` an ADOPTION, and excuses this process from resolving a path.
154 /// what excuses this process from resolving a socket path — the
155 /// manifest names the socket the inherited listener is already bound to.
156 resume_fd: ?std.posix.fd_t = null, 142 resume_fd: ?std.posix.fd_t = null,
157 /// The old daemon's dry run: parse the manifest to the end and exit 0 143 /// The old daemon's dry run: parse the manifest to the end and exit 0
158 /// having adopted nothing. A candidate that cannot read the manifest 144 /// having adopted nothing. A candidate that cannot read the manifest
@@ -166,13 +152,10 @@ const Opts = struct {
166 /// `upgrade`'s one exception to the strictly-newer rule. It exists for 152 /// `upgrade`'s one exception to the strictly-newer rule. It exists for
167 /// the e2e leg, which has only one binary to upgrade with. 153 /// the e2e leg, which has only one binary to upgrade with.
168 allow_same_version: bool = false, 154 allow_same_version: bool = false,
169 /// `endpoint`'s alone, and `parseArgs` refuses it on every other verb: 155 /// `endpoint`'s alone, refused on every other verb: ensure a daemon on the
170 /// ensure a daemon on the socket, then announce as usual. It is what 156 /// socket, then announce as usual. It makes a cold `mux HOST` ONE ssh run,
171 /// makes a cold `mux HOST` ONE ssh run — the client used to read the 157 /// and "a read never starts a daemon" holds by ARGV — a poll spells the
172 /// refusal, run `mux d start -d` over a second ssh, and ask again over a 158 /// bare verb and starts nothing, an ask spells this.
173 /// third. The decision is the REMOTE's now, and "a read never starts a
174 /// daemon" holds by argv: a poll spells `mux d endpoint` and starts
175 /// nothing, an ask spells this.
176 start: bool = false, 159 start: bool = false,
177 }; 160 };
178 161
@@ -214,13 +197,9 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
214 197
215 var o: Opts = .{ ._cmd = spec.cmd }; 198 var o: Opts = .{ ._cmd = spec.cmd };
216 199
217 // `-d` is a word in ONE position — `mux d start -d …`, exactly where 200 // `-d` is a word in ONE position and not a flag in `Opts` at all: this
218 // the usage line draws it — and not a flag in `Opts` at all. That is 201 // line is its only reader and it reads one slot, so `mux d dump -d` is an
219 // the whole scoping rule: `mux d dump -d` and `mux d start --sock P 202 // unknown argument for free rather than by a refusal on every verb.
220 // -d` are `unknown argument: -d` because this line is the only reader
221 // of the word and it reads one slot. A flag would have to be refused
222 // on every other verb by hand, and stripped back out of the child's
223 // line by a walk that re-derives cliflags' arity.
224 const flag_args = if (spec.cmd == .start and args.len > 2 and 203 const flag_args = if (spec.cmd == .start and args.len > 2 and
225 (std.mem.eql(u8, args[2], "-d") or std.mem.eql(u8, args[2], "--detach"))) 204 (std.mem.eql(u8, args[2], "-d") or std.mem.eql(u8, args[2], "--detach")))
226 blk: { 205 blk: {
@@ -240,13 +219,9 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
240 .bad_value => |f| return .{ .err = .{ .bad_value = f } }, 219 .bad_value => |f| return .{ .err = .{ .bad_value = f } },
241 } 220 }
242 221
243 // `--start` names the one verb it configures, so every other verb has 222 // Every other verb REFUSES `--start` rather than ignoring it: `mux d start
244 // to refuse it rather than ignore it: `mux d stats --start` and `mux d 223 // --start` is the reachable typo, and a flag that vanishes there tells a
245 // start --start` are the reachable typos, and a flag that vanishes 224 // user they asked for something when they asked for nothing.
246 // there tells a user they asked for something when they asked for
247 // nothing. Reported as the unknown argument it is on that verb — the
248 // shared refusal, so the exit code and the page are the ones every
249 // other mistyped flag gets.
250 if (o.start and spec.cmd != .endpoint) return .{ .err = .{ .unknown_arg = "--start" } }; 225 if (o.start and spec.cmd != .endpoint) return .{ .err = .{ .unknown_arg = "--start" } };
251 226
252 // A key with nowhere to listen is a mistake parse can see the whole of. 227 // A key with nowhere to listen is a mistake parse can see the whole of.
@@ -300,16 +275,10 @@ pub fn main(args: []const [:0]const u8) !u8 {
300 .ok => |o| o, 275 .ok => |o| o,
301 }; 276 };
302 277
303 // The same row that exempts `--version` and `keygen` from the sun_path 278 // A verb that touches no socket gets no path rather than one it must
304 // bound below exempts them from HAVING a path: the default can now 279 // survive resolving: the default can refuse, and a version string must
305 // refuse (sockpath.defaultSockPath), and a version string must never 280 // never fail on the environment. A resuming `run` is exempt outright —
306 // fail on the environment. So a verb that touches no socket gets no 281 // its path is in the manifest, under a listener already bound to it.
307 // path rather than a path it must first survive resolving.
308 // A resuming `run` is exempt from the socket path entirely: the exec
309 // that started it passed only `--resume-fd`, and the path it must serve
310 // is in the manifest, under a listener that is already bound to it.
311 // Resolving the default here would refuse an upgrade on any daemon
312 // started with `--sock` outside XDG_RUNTIME_DIR.
313 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null; 282 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null;
314 const sock_path = if (o.sock) |s| 283 const sock_path = if (o.sock) |s|
315 try alloc.dupe(u8, s) 284 try alloc.dupe(u8, s)
@@ -319,14 +288,9 @@ pub fn main(args: []const [:0]const u8) !u8 {
319 try sockpath.defaultOrExplain(alloc, "mux d") orelse return 1; 288 try sockpath.defaultOrExplain(alloc, "mux d") orelse return 1;
320 defer alloc.free(sock_path); 289 defer alloc.free(sock_path);
321 290
322 // The sun_path bound (sockpath.tooLong). Checked here, once, 291 // The sun_path bound, checked once before any command acts: otherwise a
323 // before any command acts: the alternative is a spawned daemon that can 292 // spawned daemon can never answer and the story is a 2 s timeout about a
324 // never answer and a 2s timeout story about a path doomed at parse. 293 // path doomed at parse. Which verbs are exempt is their ROWS' business.
325 // `--version` and `keygen` are dispatched from the switch below, i.e.
326 // after this point, so they are exempted by their rows rather than by
327 // order — neither touches the socket, and neither should be refused
328 // over it. Which verbs those are is their rows' business, not this
329 // line's.
330 if (uses_socket and sockpath.tooLong("mux d", sock_path)) return 1; 294 if (uses_socket and sockpath.tooLong("mux d", sock_path)) return 1;
331 295
332 switch (o._cmd) { 296 switch (o._cmd) {
@@ -364,19 +328,15 @@ fn shellIntegrationEnabled(env: ?[]const u8) bool {
364 /// cannot make this process eat the machine. 328 /// cannot make this process eat the machine.
365 const manifest_read_max = 64 * 1024 * 1024; 329 const manifest_read_max = 64 * 1024 * 1024;
366 330
367 /// Set on the environment the rollback exec builds, and read at the top of 331 /// Set by the rollback exec and read at the top of the next adoption: two
368 /// the next adoption. Two binaries that both refuse one manifest would 332 /// binaries that both refuse one manifest would otherwise exec each other
369 /// otherwise trade the sessions forever, exec'ing each other at full speed. 333 /// forever. A VARIABLE, because the binary exec'd back is older than this one
370 /// A VARIABLE rather than a flag because the binary being exec'd back is by 334 /// — it ignores an unknown variable and dies on an unknown flag.
371 /// definition older than this one: an unknown variable is ignored by any
372 /// binary, an unknown flag is fatal usage.
373 const rollback_marker = "MUX_UPGRADE_ROLLBACK"; 335 const rollback_marker = "MUX_UPGRADE_ROLLBACK";
374 336
375 /// The environment's spelling of `--resume-fail-at`. The flag alone cannot 337 /// The environment's spelling of `--resume-fail-at`. The flag alone cannot be
376 /// be driven by a real upgrade: the exec builds a fixed argv 338 /// driven by a real upgrade — the exec builds a fixed argv — but the daemon's
377 /// (`run --resume-fd N`), so an e2e leg has no way to put a word in it. The 339 /// ENVIRONMENT crosses it untouched, which is how a test arms the abort.
378 /// daemon's ENVIRONMENT crosses the exec untouched, which is how the leg
379 /// arms the abort — on the daemon it starts, before any upgrade.
380 const fail_at_env = "MUX_RESUME_FAIL_AT"; 340 const fail_at_env = "MUX_RESUME_FAIL_AT";
381 341
382 /// The flag beats the environment, as `--key` beats `MUX_KEY_FILE`: more 342 /// The flag beats the environment, as `--key` beats `MUX_KEY_FILE`: more
@@ -493,12 +453,9 @@ fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 {
493 }; 453 };
494 defer parsed.deinit(); 454 defer parsed.deinit();
495 455
496 // `--check` is the old daemon's dry run and this is the whole of it: the 456 // `--check` is the old daemon's dry run: read the manifest to the end,
497 // candidate proves it can read the manifest to the end, exits 0, and 457 // exit 0, adopt nothing. Before the abort flag, which is about adoption —
498 // adopts nothing — the daemon that wrote it is still serving. Before 458 // a probe that rolled back would exec out of a CHILD of a live daemon.
499 // the abort flag, which is about adoption and so has nothing to abort
500 // here: a probe that rolled back would exec the old binary out of a
501 // CHILD of the daemon that is still running.
502 if (o.check) return 0; 459 if (o.check) return 0;
503 460
504 const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env)); 461 const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env));
@@ -531,12 +488,9 @@ fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 {
531 return try srv.run(); 488 return try srv.run();
532 } 489 }
533 490
534 /// `mux d start`: the daemon, in the foreground. Every code this function picks 491 /// `mux d start`: the daemon, in the foreground. Every code this picks is a
535 /// is a boot failure — an operator mistake caught before anything bound. 492 /// boot failure caught before anything bound; reaching `srv.run()` means the
536 /// Reaching `srv.run()` means the daemon served, and it answers 0 whenever 493 /// daemon served, and it answers 0. No session's exit is reported here.
537 /// something asks it to stop. No session's exit is ever reported here: a
538 /// shell's code goes to that shell's own clients (`exit_status`), and an
539 /// emptied daemon is one with nothing on it rather than one that is leaving.
540 fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { 494 fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
541 // Address and key are settled before anything binds: a mistyped address 495 // Address and key are settled before anything binds: a mistyped address
542 // or an unreadable key must not first leave a session socket and a live 496 // or an unreadable key must not first leave a session socket and a live
@@ -551,11 +505,9 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
551 ); 505 );
552 return 1; 506 return 1;
553 }; 507 };
554 // --key, then MUX_KEY_FILE, then the default path if it exists — 508 // --key, then MUX_KEY_FILE, then the default if it exists — the same
555 // `xdg.resolveKeyPath`, the same order and the same three-way answer 509 // three-way answer every `quic://` gets. Only a daemon ASKED for QUIC
556 // every other binary's `quic://` gets. The default is only reached 510 // reaches for the default, so an absent one is a message.
557 // for by a daemon that was asked for QUIC, so an absent one is a
558 // message rather than a silent skip.
559 var key_owned: ?[]const u8 = null; 511 var key_owned: ?[]const u8 = null;
560 defer if (key_owned) |p| alloc.free(p); 512 defer if (key_owned) |p| alloc.free(p);
561 const key_path = switch (try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)))) { 513 const key_path = switch (try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)))) {
@@ -574,15 +526,10 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
574 }, 526 },
575 }; 527 };
576 quic_key = quic.Key.load(key_path) catch |err| switch (err) { 528 quic_key = quic.Key.load(key_path) catch |err| switch (err) {
577 // The three the user can act on, in quic.zig's words — the one 529 // The three the user can act on, in quic.zig's words — its one
578 // owner of them, because `mux d endpoint`, the daemon's 530 // owner, since three callers print the same sentences. Anything
579 // `endpoint_req` and the client print the same sentences. 531 // else propagates: `run` is a foreground start that may fail, where
580 // 532 // an announce path must stay on ssh and so flattens instead.
581 // Anything else still propagates rather than being flattened
582 // into a refusal line: `run` is a foreground start that may
583 // fail, so the error goes up. The announce paths cannot do
584 // that — they must stay on ssh — which is why their catch-all
585 // prints the body's fourth sentence and this one does not.
586 error.KeyFileMissing, 533 error.KeyFileMissing,
587 error.KeyFilePermissive, 534 error.KeyFilePermissive,
588 error.KeyFileMalformed, 535 error.KeyFileMalformed,
@@ -648,15 +595,10 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
648 .shell_integration = shell_integration, 595 .shell_integration = shell_integration,
649 .version = build_options.version, 596 .version = build_options.version,
650 }) catch |err| switch (err) { 597 }) catch |err| switch (err) {
651 // All of these mean "that path is not ours to take", and all 598 // All of these mean "that path is not ours to take", and all are
652 // are ordinary operator mistakes rather than daemon bugs: say 599 // operator mistakes: one line, no stack trace. AddressInUse is the same
653 // so in one line and exit, no stack trace. 600 // race found one syscall later, and the loser is told the truth — by
654 // 601 // the time it reads the message, a daemon IS running.
655 // AddressInUse is the same situation found one syscall later:
656 // two daemons starting at once can both see an empty path and
657 // both try to bind it. The loser has simply lost a dead heat,
658 // and telling it "a daemon is already running" is exactly
659 // right — by the time it reads the message, one is.
660 error.DaemonAlreadyRunning, error.AddressInUse => { 602 error.DaemonAlreadyRunning, error.AddressInUse => {
661 std.debug.print("mux d: a daemon is already running on {s}\n", .{sock_path}); 603 std.debug.print("mux d: a daemon is already running on {s}\n", .{sock_path});
662 return 1; 604 return 1;
@@ -683,10 +625,8 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
683 } 625 }
684 626
685 /// Whether a frame of the wanted type with NO payload is the answer. 627 /// Whether a frame of the wanted type with NO payload is the answer.
686 /// `mux d upgrade` reads a status byte out of its reply, so an empty one is 628 /// `mux d upgrade` reads a status byte out of its reply, so an empty one is a
687 /// a peer that cannot answer, and the old loop kept waiting for a real 629 /// peer that cannot answer; `dump` and `stats` print whatever arrived.
688 /// answer to the deadline. `dump` and `stats` print whatever arrived, empty
689 /// included: for them the frame IS the answer.
690 const EmptyPayload = enum { is_the_answer, keeps_waiting }; 630 const EmptyPayload = enum { is_the_answer, keeps_waiting };
691 631
692 /// Send one frame, then read until `want` arrives; null is every way of 632 /// Send one frame, then read until `want` arrives; null is every way of
@@ -714,14 +654,10 @@ fn askOnce(
714 if ((std.posix.poll(&fds, @intCast(left)) catch return null) == 0) return null; 654 if ((std.posix.poll(&fds, @intCast(left)) catch return null) == 0) return null;
715 if (fds[0].revents == 0) continue; 655 if (fds[0].revents == 0) continue;
716 } 656 }
717 // Blocking, and bounded only by the poll that said bytes are here: 657 // Blocking, bounded only by the poll that said bytes are here: a
718 // a daemon that wrote half a seven-byte frame and then stopped 658 // daemon that wrote half a frame and stopped holds this past the
719 // would hold this past the deadline. That daemon is this same 659 // deadline — but that daemon is this binary, writing it in one call. A
720 // binary on the same box, writing that frame in one call — the 660 // partial frame is an ERROR, not a null, or it reads as an absent one.
721 // hazard is worth naming and not worth a state machine. A frame it
722 // CAN read the length of but not the rest of is an error, not a
723 // null: `mux d dump` prints that name, where a null would spell a
724 // broken daemon exactly like an absent one.
725 const frame = (try proto.readFrame(alloc, fd)) orelse return null; 661 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
726 if (frame.type == want and 662 if (frame.type == want and
727 !(empty == .keeps_waiting and frame.payload.len == 0)) return frame; 663 !(empty == .keeps_waiting and frame.payload.len == 0)) return frame;
@@ -768,37 +704,27 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
768 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply); 704 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply);
769 } 705 }
770 706
771 /// Ask the daemon on `sock_path` to exit, then wait until the PROCESS is 707 /// Ask the daemon on `sock_path` to exit, then wait until the PROCESS is gone,
772 /// gone, not just the path. Exit 0 covers both "stopped" and "nothing 708 /// not just the path. Exit 0 covers both "stopped" and "nothing there" — the
773 /// there" — the state the user asked for is the state they got, which is 709 /// state asked for is the state got, which makes the verb safe to script.
774 /// what makes the verb safe to script (`mux d start -d`'s re-runnability, 710 /// `mux d stop:` prefixes a refusal, plain `mux d:` a lifecycle verdict.
775 /// mirrored).
776 ///
777 /// Prefixes split the way `start`'s do: `mux d stop:` for a refusal or a
778 /// report about this command, plain `mux d:` for a lifecycle verdict.
779 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 711 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
780 const stream = std.net.connectUnixSocket(sock_path) catch { 712 const stream = std.net.connectUnixSocket(sock_path) catch {
781 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path}); 713 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path});
782 return 0; 714 return 0;
783 }; 715 };
784 // A daemon that dies between connect and write reached the asked-for 716 // A daemon that dies between connect and write reached the asked-for state
785 // state on its own; the poll below confirms it either way. But whether 717 // anyway. Whether the frame LANDED is still kept: a log holds nothing about
786 // the frame LANDED is kept: a daemon can accept a connection and close 718 // a request the daemon never saw, so pointing there sends the reader to an
787 // it before the request arrives, and a log holds nothing about a
788 // request it never saw — pointing there would send the reader to an
789 // empty page. 719 // empty page.
790 const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false; 720 const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false;
791 const peer = peerPid(stream.handle); 721 const peer = peerPid(stream.handle);
792 stream.close(); 722 stream.close();
793 723
794 // Probe-first, deadline-second — `forkDaemon`'s poll shape, so the 724 // Probe-first, deadline-second, so the final window is still probed and no
795 // final window before the deadline is still probed and the failure 725 // failure line is printed about an interval nobody checked. Only the
796 // line is never printed about an interval nobody checked. 726 // shutdown unlink can produce a connect refusal — a wedged event loop still
797 // `sockpath.answers` is the connect-refusal test: a live listener's backlog 727 // accepts from the backlog — which makes refusal the true signal.
798 // accepts even when its event loop is wedged (up to backlog depth —
799 // past ~128 pending connects AF_UNIX blocks rather than refuses, so
800 // this loop would wait, not lie), and only the shutdown unlink can
801 // produce a refusal, which makes it the true signal.
802 const stop_deadline_ms: i64 = 2000; 728 const stop_deadline_ms: i64 = 2000;
803 const t0 = std.time.milliTimestamp(); 729 const t0 = std.time.milliTimestamp();
804 while (true) { 730 while (true) {
@@ -833,13 +759,10 @@ fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
833 return if (cred.pid > 0) cred.pid else null; 759 return if (cred.pid > 0) cred.pid else null;
834 } 760 }
835 761
836 /// A socket gone quiet is the unlink, and the unlink is the FIRST thing a 762 /// A socket gone quiet is the unlink, which is the FIRST thing a stopping
837 /// stopping daemon does; reaping its shells (one TERM grace when one 763 /// daemon does — reaping its shells and deleting its dirs come after. Saying
838 /// ignores it) and deleting its dirs come after. "stopped" said at the 764 /// "stopped" there hands a supervisor a daemon still running. The bound is the
839 /// unlink handed a scripted `mux d start -d`, or a supervisor's "is it down", 765 /// reap's own grace: a daemon still here after it is wedged, and that is a report.
840 /// a daemon still running. The bound is the reap's own grace with room to
841 /// spare — a daemon still here after it is wedged in teardown, and that
842 /// is a report, not a wait.
843 fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { 766 fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 {
844 const pid = peer orelse { 767 const pid = peer orelse {
845 std.debug.print("mux d: stopped\n", .{}); 768 std.debug.print("mux d: stopped\n", .{});
@@ -864,12 +787,9 @@ fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 {
864 return 0; 787 return 0;
865 } 788 }
866 789
867 /// Ask the daemon on `sock_path` to become THIS binary. The new binary is 790 /// Ask the daemon on `sock_path` to become THIS binary. The new binary asks,
868 /// the one that asks: it knows its own version and its own path, and the 791 /// because it knows its own version and path; the daemon decides. `mux d
869 /// daemon is the one that decides. 792 /// upgrade:` prefixes a refusal, plain `mux d:` the lifecycle verdict.
870 ///
871 /// Prefixes split as `stop`'s do: `mux d upgrade:` for a refusal or a report
872 /// about this command, plain `mux d:` for the lifecycle verdict.
873 fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { 793 fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 {
874 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 794 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
875 const exe = std.fs.selfExePath(&exe_buf) catch { 795 const exe = std.fs.selfExePath(&exe_buf) catch {
@@ -917,14 +837,9 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool)
917 // check failed, and paraphrasing here would lose the versions. 837 // check failed, and paraphrasing here would lose the versions.
918 std.debug.print("mux d upgrade: refused: {s}\n", .{frame.payload[1..]}); 838 std.debug.print("mux d upgrade: refused: {s}\n", .{frame.payload[1..]});
919 // One refusal gets a translation, because the daemon saying it 839 // One refusal gets a translation, because the daemon saying it
920 // cannot know why. v0.0.1-15 and older probe the candidate for 840 // cannot know why: v0.0.1-15 and older probe for `muxd <version>`
921 // `muxd <version>` and this binary answers `mux <version>` — 841 // and this binary answers `mux <version>`. Conditional wording —
922 // the four binaries became one. Conditional wording, because 842 // a new daemon says the same about any candidate.
923 // nothing here knows the daemon's version: a NEW daemon says
924 // the same words about a candidate that prints anything else,
925 // and telling that user about v15 would be a wrong diagnosis.
926 // If the daemon's wording ever changes the hint simply stops
927 // appearing; the verbatim line above is what the user acts on.
928 if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch")) 843 if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch"))
929 std.debug.print( 844 std.debug.print(
930 "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++ 845 "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++
@@ -973,14 +888,10 @@ fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 {
973 888
974 const log_hint_len = std.fs.max_path_bytes + 64; 889 const log_hint_len = std.fs.max_path_bytes + 64;
975 890
976 /// The "where the rest of the story is" clause, or "" when there is no 891 /// The "where the rest of the story is" clause, or "" when there is no path to
977 /// path to name. `stopCmd` and `reportNoListener` are read by someone 892 /// name — these lines are read by someone who is not at that box. Only when
978 /// who is not at that box. 893 /// the path resolves, so an absent HOME does not replace the finding with a
979 /// 894 /// trace; and the hedge stays in the words, since a foreground start logs to stderr.
980 /// Only when the path resolves: an absent HOME (a container, a systemd
981 /// unit) must not replace the finding that matters with an error trace.
982 /// And the hedge stays in the words: a foreground `mux d start` logs to
983 /// its own stderr, so naming the xdg path unconditionally would guess.
984 fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 { 895 fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
985 const log = xdg.logPath(alloc) catch return ""; 896 const log = xdg.logPath(alloc) catch return "";
986 defer alloc.free(log); 897 defer alloc.free(log);
@@ -996,60 +907,30 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
996 907
997 /// `mux d proxy` with a one-line preamble: ensure a key, ask the daemon for 908 /// `mux d proxy` with a one-line preamble: ensure a key, ask the daemon for
998 /// its QUIC port, print `endpoint <port> <hex-key>` (or `endpoint none`) as 909 /// its QUIC port, print `endpoint <port> <hex-key>` (or `endpoint none`) as
999 /// the FIRST bytes on `out_fd`, then become exactly the proxy byte pump. 910 /// the FIRST bytes on `out_fd`, then become the proxy byte pump. This is what
1000 /// 911 /// `mux HOST` runs over ssh, and what its wall polls — told apart by `start`
1001 /// `out_fd` is a parameter so a test can read the announce back instead of 912 /// alone: an ASKED dial spells `--start`, a poll spells the bare verb.
1002 /// writing over the runner's stdout, and every production caller passes
1003 /// STDOUT_FILENO — which is what makes "the announce, then the frames, on
1004 /// one stream" true, since `proxy.run` below writes the pump to the real
1005 /// stdout and knows nothing of this argument.
1006 /// This is what `mux HOST` runs over ssh — and what its wall polls. The two
1007 /// are told apart by `start`, not by anything the client does with the
1008 /// answer: an ASKED dial spells `--start` and gets a daemon ensured, a poll
1009 /// spells the bare verb and an empty box stays empty at exit 1.
1010 /// 913 ///
1011 /// The announce is mandatory in both directions. The client blocks on one 914 /// The announce is mandatory. The client blocks on one newline-terminated
1012 /// newline-terminated line, and the daemon side of the frame protocol 915 /// line and the daemon sends nothing unprompted, so silence here is a slow
1013 /// sends nothing unprompted — so silence here is indistinguishable from a 916 /// ssh and would HANG the attach rather than degrade it.
1014 /// slow ssh and would hang the attach rather than degrade it.
1015 /// 917 ///
1016 /// The two kinds of failure therefore end differently. A SOFT one — no 918 /// So the two failures end differently. A SOFT one — no usable key, no
1017 /// usable key, no listener — announces `endpoint none` and pumps anyway: 919 /// listener — announces `endpoint none` and pumps anyway, since the ssh
1018 /// the ssh session is real and carries the whole session. A HARD one — no 920 /// session is real. A HARD one exits, and the client reads EOF. Announcing
1019 /// daemon to pump to, a stdout that will not take the announce — exits, 921 /// none and THEN exiting is the one dishonest option: it claims a working
1020 /// and the client reads EOF on the pipe, which the client's announce reader 922 /// session at the moment that session goes away.
1021 /// already tells apart from a line (`handoff.ReadLineError`). Announcing none and THEN
1022 /// exiting is the one dishonest option available: it would tell the client
1023 /// it has a working ssh session at the moment that session goes away.
1024 /// 923 ///
1025 /// stdout carries the announce and then frames, nothing else: every 924 /// stdout carries the announce and then frames, nothing else — every
1026 /// human-facing word here goes to stderr, which ssh already carries to the 925 /// human-facing word goes to stderr, which ssh already carries.
1027 /// user's terminal. A stray stdout byte ahead of the announce would land
1028 /// in the middle of the client's parse.
1029 ///
1030 /// The pump that follows keeps speaking in its own name: a socket that
1031 /// disappears between the ask and the attach is reported by proxy.zig as
1032 /// `mux d proxy: cannot connect to …`. That is deliberate — reusing
1033 /// `proxy.run` is the whole design, and the line names the code that
1034 /// failed rather than the verb that was typed.
1035 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t, start: bool) !u8 { 926 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t, start: bool) !u8 {
1036 // The ask, and the whole of what `--start` means: ensure a daemon, then 927 // The whole of what `--start` means: ensure a daemon, then answer as
1037 // answer as always. 928 // always. `--sock` is the ONE forwarded run flag and is not optional — the
1038 // 929 // path probed has to be the path bound, or this starts a daemon on the
1039 // `--sock` is the ONE run flag forwarded, and it is not optional: the 930 // DEFAULT socket and exits 1, a stray daemon nothing reports.
1040 // path this verb is about to probe has to be the path the daemon binds.
1041 // Without it (`&.{}`) a `mux d endpoint --sock PATH --start` starts a
1042 // daemon on the DEFAULT socket, probes PATH, finds nothing and exits 1
1043 // — a stray daemon nothing reports. The client's own attach spells it
1044 // for the same reason. Nothing else is: a bare daemon binds its QUIC
1045 // listener when the announce below asks it to, which is how a plain
1046 // `mux d start -d` already produces a port.
1047 // 931 //
1048 // A spawn that failed has already said so on stderr, which ssh carries 932 // A failed spawn has already said so on stderr, so returning here leaves
1049 // to the asker; returning here leaves the announce UNWRITTEN, and an 933 // the announce UNWRITTEN — the "no daemon" shape the client reads.
1050 // absent announce is the "no daemon" shape the client already reads as
1051 // a failed handoff. Announcing first and dying after is the one
1052 // dishonest option, for the reason the doc comment above gives.
1053 if (start) { 934 if (start) {
1054 const sock_z = try alloc.dupeZ(u8, sock_path); 935 const sock_z = try alloc.dupeZ(u8, sock_path);
1055 defer alloc.free(sock_z); 936 defer alloc.free(sock_z);
@@ -1057,11 +938,8 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
1057 if (startDetached(alloc, &run_args, sock_path, "mux d endpoint") == null) return 1; 938 if (startDetached(alloc, &run_args, sock_path, "mux d endpoint") == null) return 1;
1058 } 939 }
1059 940
1060 // Silent otherwise, and that is the whole of the verb's manners: the 941 // Silent otherwise: the wall runs this over ssh once a second per host, so
1061 // wall runs this over ssh once a second per listed host, and a word on 942 // a word on stderr here is a word on the wall's alternate screen.
1062 // stderr here is a word on the wall's alternate screen. The poll spells
1063 // the verb WITHOUT `--start`, which is what keeps a read from starting
1064 // anything — the rule is argv's now, not a branch in the client.
1065 if (!sockpath.answers(sock_path)) return 1; 943 if (!sockpath.answers(sock_path)) return 1;
1066 944
1067 // The announce goes out on the same stdout the pump is about to use, 945 // The announce goes out on the same stdout the pump is about to use,
@@ -1070,43 +948,32 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
1070 // would be leaning on. 948 // would be leaning on.
1071 proxy.ignoreSigpipe(); 949 proxy.ignoreSigpipe();
1072 950
1073 // Key first, then the ask, and the order is load-bearing: the daemon's 951 // Key first, then the ask: the daemon's lazy bind takes the default key
1074 // lazy bind takes the default key path only if the file already exists 952 // path only if the file already EXISTS. Creating it here is what lets a
1075 // and never creates one (server.zig endpointPortFrom). Creating it here 953 // first-ever attach to a fresh box produce coordinates.
1076 // first is what lets a first-ever attach to a fresh box produce
1077 // coordinates instead of announcing none and being told to run keygen.
1078 const key = announceKey(alloc); 954 const key = announceKey(alloc);
1079 const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path); 955 const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path);
1080 956
1081 var line_buf: [handoff.announce_max_len]u8 = undefined; 957 var line_buf: [handoff.announce_max_len]u8 = undefined;
1082 const line: []const u8 = blk: { 958 const line: []const u8 = blk: {
1083 const k = key orelse break :blk handoff.announce_none; 959 const k = key orelse break :blk handoff.announce_none;
1084 // 0 is `endpoint_reply`'s "could not" — no key the daemon could 960 // 0 is `endpoint_reply`'s "could not", and turning it into the negative
1085 // load, no bind, no reply — and turning it into the negative 961 // announce is this caller's job. `formatAnnounce` refuses port 0 rather
1086 // announce is this caller's job. formatAnnounce refuses port 0 962 // than doing it quietly, so an omission fails here and not on another box.
1087 // rather than doing it quietly, so an omission here would fail on
1088 // this line instead of arriving on another machine as a parse error
1089 // about a message we wrote.
1090 if (port == 0) { 963 if (port == 0) {
1091 reportNoListener(alloc, sock_path); 964 reportNoListener(alloc, sock_path);
1092 break :blk handoff.announce_none; 965 break :blk handoff.announce_none;
1093 } 966 }
1094 // Unreachable in fact — port 0 is gone by here, and line_buf is 967 // Unreachable in fact, but a fallback rather than `unreachable`: a
1095 // sized by the same constant that bounds the grammar — but written 968 // panic on the remote box takes down a session ssh was about to carry,
1096 // as a fallback rather than `unreachable` because of what the two 969 // to report a bug about a preamble. Announcing none costs only QUIC.
1097 // do if that ever stops being true. A panic on the remote box takes
1098 // down a session ssh was about to carry perfectly well, to report a
1099 // bug about a preamble; announcing none costs the user QUIC and
1100 // nothing else, which is what every other unhappy path here does.
1101 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch 970 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch
1102 handoff.announce_none; 971 handoff.announce_none;
1103 }; 972 };
1104 proto.writeAllFd(out_fd, line) catch |err| { 973 proto.writeAllFd(out_fd, line) catch |err| {
1105 // stdout is the pipe the pump is about to need, so there is no 974 // stdout is the pipe the pump is about to need, so there is no session
1106 // session left to fall back to — only a line about why. The error 975 // left to fall back to. The error is NAMED, not guessed: EPIPE is
1107 // is named rather than guessed at: EPIPE (the ssh client gave up 976 // likely, but a full disk and a closed fd want different reactions.
1108 // first) is the likely one, but a full disk under a redirect and a
1109 // closed fd reach here too, and they want different reactions.
1110 std.debug.print( 977 std.debug.print(
1111 "mux d endpoint: cannot write the announce to stdout: {s}\n", 978 "mux d endpoint: cannot write the announce to stdout: {s}\n",
1112 .{@errorName(err)}, 979 .{@errorName(err)},
@@ -1169,20 +1036,14 @@ const KeyResult = union(enum) {
1169 load_failed: struct { path: []const u8, err: anyerror }, 1036 load_failed: struct { path: []const u8, err: anyerror },
1170 }; 1037 };
1171 1038
1172 /// MUX_KEY_FILE, then the default path — the same order the daemon's lazy 1039 /// MUX_KEY_FILE, then the default path — the daemon's lazy bind order, because
1173 /// bind uses, because the announce hands a client the key it will 1040 /// the announce hands a client the key it will authenticate WITH against
1174 /// authenticate WITH against a listener holding whatever the daemon 1041 /// whatever that daemon loaded. Two spellings would attach to nothing.
1175 /// loaded. Two spellings of "the key" would attach to nothing.
1176 ///
1177 /// Only the default is created when absent: that is the mosh-server move,
1178 /// and it is what lets a first attach to a fresh box produce coordinates
1179 /// at all. A MUX_KEY_FILE that is set but missing names a file the user
1180 /// manages, and writing one there would be a credential appearing where
1181 /// nobody asked for it — so that path is loaded, never created.
1182 /// 1042 ///
1183 /// Environment handed in and nothing printed, so the order and the 1043 /// Only the DEFAULT is created when absent, which is what lets a first attach
1184 /// create-vs-load distinction are testable: the `*From` discipline xdg.zig 1044 /// to a fresh box produce coordinates. A MUX_KEY_FILE that is set but missing
1185 /// set and server.zig's `endpointPortFrom` follows. 1045 /// names a file the user manages: writing there is a credential nobody asked
1046 /// for. Environment handed in and nothing printed, so both halves are testable.
1186 fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult { 1047 fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
1187 if (env) |p| return if (quic.Key.load(p)) |k| 1048 if (env) |p| return if (quic.Key.load(p)) |k|
1188 .{ .key = k } 1049 .{ .key = k }
@@ -1190,11 +1051,9 @@ fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
1190 .{ .load_failed = .{ .path = p, .err = err } }; 1051 .{ .load_failed = .{ .path = p, .err = err } };
1191 1052
1192 const path = dflt orelse return .no_path; 1053 const path = dflt orelse return .no_path;
1193 // KeyExists is the ordinary case and no news: the key is already there 1054 // KeyExists is no news: the key is there and the load below wanted it. Any
1194 // and the load below is what wanted it. Any OTHER create failure is 1055 // OTHER create failure is kept, because the create holds the reason — an
1195 // kept, because if the load then fails too it is the create that holds 1056 // unwritable directory — where the load reports only the symptom.
1196 // the reason — an unwritable config directory, a full disk — while the
1197 // load can only report the file's absence, which is its symptom.
1198 const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_| 1057 const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_|
1199 null 1058 null
1200 else |err| if (err == error.KeyExists) null else err; 1059 else |err| if (err == error.KeyExists) null else err;
@@ -1218,13 +1077,9 @@ fn reportKeyRefusal(path: []const u8, err: anyerror) void {
1218 ); 1077 );
1219 } 1078 }
1220 1079
1221 /// One observer round-trip: `endpoint_req`, then a bounded wait. 0 is 1080 /// One observer round-trip: `endpoint_req`, then a bounded wait. 0 is every
1222 /// every failure — nothing listening, no reply, a reply we do not 1081 /// failure, because the caller treats them alike. The bound turns an old
1223 /// understand — because the caller treats them alike. 1082 /// daemon's silence into the announce-none path instead of a hang.
1224 ///
1225 /// The bound converts an old daemon's silence (`MsgType` is
1226 /// non-exhaustive, so a binary from before the verb ignores it) into the
1227 /// announce-none path instead of a hang.
1228 fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { 1083 fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
1229 const stream = std.net.connectUnixSocket(sock_path) catch return 0; 1084 const stream = std.net.connectUnixSocket(sock_path) catch return 0;
1230 defer stream.close(); 1085 defer stream.close();
@@ -1241,13 +1096,10 @@ fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
1241 return proto.decodeEndpointReply(frame.payload) catch 0; 1096 return proto.decodeEndpointReply(frame.payload) catch 0;
1242 } 1097 }
1243 1098
1244 /// `mux d start`: the daemon, in this process, or — under `-d` — in a 1099 /// `mux d start`: the daemon in this process, or under `-d` in a child of it.
1245 /// child of it. `forwarded` is everything after `start`; under `-d` its 1100 /// `forwarded` is everything after `start`, so the child's line is the rest
1246 /// first word IS the `-d`, so the child's line is the rest of it 1101 /// verbatim and a flag that parses here behaves identically there. What parse
1247 /// verbatim and a flag that parses here behaves identically there. 1102 /// cannot validate surfaces in the daemon's log, which the failure path names.
1248 /// parseArgs has already validated the flags in THIS process; what it
1249 /// cannot validate (a bad bind address, a missing key file) surfaces in
1250 /// the daemon's log, which the failure path names.
1251 fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 { 1103 fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
1252 if (!o._detach) return run(alloc, o, sock_path); 1104 if (!o._detach) return run(alloc, o, sock_path);
1253 // A `-d` still in the child's argv would fork again, and its child 1105 // A `-d` still in the child's argv would fork again, and its child
@@ -1262,11 +1114,8 @@ fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded:
1262 return 0; 1114 return 0;
1263 } 1115 }
1264 1116
1265 /// How long a spawn gets to answer. One number for both starters, because 1117 /// How long a spawn gets to answer. One number for both starters: it describes
1266 /// a user who waited two seconds for `mux d start -d` must not wait a 1118 /// how long a daemon takes to bind, which is not a fact about which verb asked.
1267 /// different two seconds for the attach that starts the same daemon the
1268 /// same way: it describes how long the daemon takes to bind, which is not
1269 /// a fact about which verb asked for it.
1270 const start_deadline_ms: u32 = 2000; 1119 const start_deadline_ms: u32 = 2000;
1271 1120
1272 const Started = enum { already_running, started }; 1121 const Started = enum { already_running, started };
@@ -1309,14 +1158,10 @@ fn forkDaemon(
1309 deadline_ms: u32, 1158 deadline_ms: u32,
1310 log_path_override: ?[]const u8, 1159 log_path_override: ?[]const u8,
1311 ) StartError!Started { 1160 ) StartError!Started {
1312 // The one fork under src/, and build.zig's rule 6 is what keeps it 1161 // The one fork under src/, kept that way by build.zig's rule 6: a client
1313 // that way: the daemon's flags, its log and its refusals are the 1162 // that forked a daemon would be a second decider on its flags, log and
1314 // daemon's own business, and a client that forked one would be a 1163 // refusals without seeing any of them. `NeverAnswered` does not kill the
1315 // second decider on all three without being able to see any of them. 1164 // pid — a daemon up at 2.5 s is there for the retry.
1316 //
1317 // `NeverAnswered` does not kill the spawned pid: a daemon up at 2.5s
1318 // is there for the retry. Racers sort themselves out — the loser exits
1319 // on DaemonAlreadyRunning.
1320 if (sockpath.answers(sock_path)) return .already_running; 1165 if (sockpath.answers(sock_path)) return .already_running;
1321 1166
1322 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed; 1167 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
@@ -1330,23 +1175,18 @@ fn forkDaemon(
1330 defer alloc.free(log_path); 1175 defer alloc.free(log_path);
1331 if (std.fs.path.dirname(log_path)) |dir| 1176 if (std.fs.path.dirname(log_path)) |dir|
1332 std.fs.cwd().makePath(dir) catch return error.SpawnFailed; 1177 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
1333 // APPEND, always, and opened by hand rather than through createFile 1178 // APPEND, always, opened by hand because `createFile` cannot ask for
1334 // because O_APPEND is what createFile cannot ask for. One xdg log 1179 // O_APPEND. One xdg log serves every socket on the box, so truncating would
1335 // serves every socket on the box, so truncating would zero a daemon 1180 // zero a live daemon's — and merely not truncating is worse, since the
1336 // somebody else is still writing to; the kernel's atomic seek-to-end 1181 // child's fd would start at zero and overwrite from the front.
1337 // is the whole of what makes the shared file safe. "Do not truncate"
1338 // alone would be worse than truncating — the child's fd would start at
1339 // offset zero and overwrite the log from the front.
1340 const log: std.fs.File = .{ 1182 const log: std.fs.File = .{
1341 .handle = std.posix.open(log_path, .{ 1183 .handle = std.posix.open(log_path, .{
1342 .ACCMODE = .WRONLY, 1184 .ACCMODE = .WRONLY,
1343 .CREAT = true, 1185 .CREAT = true,
1344 .APPEND = true, 1186 .APPEND = true,
1345 // createFile set this for free and posix.O does not, which is 1187 // `createFile` sets this for free and `posix.O` does not. Without
1346 // exactly how hand-rolling the open lost it. Without it the 1188 // it the fd rides the exec below into the daemon and a second into
1347 // original fd survives the exec below into the long-lived daemon 1189 // the user's shell; fds 1 and 2 survive only because dup2 clears it.
1348 // and rides a second exec into the user's shell; fds 1 and 2 are
1349 // safe only because dup2 clears FD_CLOEXEC on its targets.
1350 .CLOEXEC = true, 1190 .CLOEXEC = true,
1351 }, 0o600) catch return error.SpawnFailed, 1191 }, 0o600) catch return error.SpawnFailed,
1352 }; 1192 };
@@ -1379,14 +1219,9 @@ fn forkDaemon(
1379 if (pid == 0) { 1219 if (pid == 0) {
1380 // Child: its own session, no controlling terminal, stdio detached. 1220 // Child: its own session, no controlling terminal, stdio detached.
1381 // Nothing here may allocate or return — only exec or _exit. 1221 // Nothing here may allocate or return — only exec or _exit.
1382 // 1222 // `exit_group`, never `std.posix.exit`: we link libc, so the latter runs
1383 // exit_group, never std.posix.exit: we link libc, so the latter is 1223 // atexit handlers and flushes stdio buffers INHERITED from the parent,
1384 // exit(3), which runs atexit handlers (Zig's runtime, wolfSSL's) 1224 // writing the parent's pending output a second time.
1385 // and flushes stdio buffers — buffers this process inherited from
1386 // the PARENT at fork, so the parent's pending output would be
1387 // written a second time by its own child. exit_group is the raw
1388 // syscall and skips all of it. Only reachable if dup2 or exec
1389 // fails, which is exactly when the least machinery should run.
1390 _ = std.os.linux.setsid(); 1225 _ = std.os.linux.setsid();
1391 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch 1226 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch
1392 std.os.linux.exit_group(127); 1227 std.os.linux.exit_group(127);
@@ -1394,18 +1229,11 @@ fn forkDaemon(
1394 std.os.linux.exit_group(127); 1229 std.os.linux.exit_group(127);
1395 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch 1230 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
1396 std.os.linux.exit_group(127); 1231 std.os.linux.exit_group(127);
1397 // An exec rather than simply running the daemon in this fork, and 1232 // An exec rather than running the daemon in this fork, and not by
1398 // that is not a preference: `std.debug.MemoryAccessor` caches the 1233 // preference: `std.debug.MemoryAccessor` caches the pid it reads memory
1399 // pid it reads memory through, so a forked child's first 1234 // through, so a child's first DebugAllocator stack trace calls
1400 // DebugAllocator stack trace calls `process_vm_readv` on the 1235 // `process_vm_readv` on the PARENT and panics. `execveZ` returns an
1401 // parent, gets ESRCH, and panics on `unreachable // own pid is 1236 // error set because success does not return; 127 is "cannot exec".
1402 // always valid`. Measured, deterministic, and invisible until the
1403 // daemon has been up long enough to allocate.
1404 //
1405 // execveZ's return type IS an error set — there is no success value,
1406 // because success does not return. 127 is the shell's "cannot exec",
1407 // and the parent learns the same thing either way: the socket never
1408 // answers, and the log names what happened.
1409 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) { 1237 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
1410 else => std.os.linux.exit_group(127), 1238 else => std.os.linux.exit_group(127),
1411 } 1239 }
@@ -1414,12 +1242,9 @@ fn forkDaemon(
1414 1242
1415 // Parent: poll. Dots only on a tty so scripted output stays pinnable. 1243 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
1416 var next_dot: i64 = t0 + 250; 1244 var next_dot: i64 = t0 + 250;
1417 // A pid owes us exactly one reap. Calling waitpid again after it has 1245 // A pid owes us exactly one reap: a second `waitpid` gets ECHILD, which
1418 // been reaped gets ECHILD, which std.posix.waitpid answers with 1246 // `std.posix.waitpid` answers with `unreachable`. That panic would land
1419 // `unreachable` — so the second call is not an error to handle but a 1247 // precisely on the path that exists to report a child that died young.
1420 // panic, and the panic lands precisely on the path that exists to
1421 // report a child that died young (a missing key file, a bad bind
1422 // address). Tracking the reap is what keeps that path a message.
1423 var reaped = false; 1248 var reaped = false;
1424 while (true) { 1249 while (true) {
1425 if (sockpath.answers(sock_path)) { 1250 if (sockpath.answers(sock_path)) {
@@ -1473,13 +1298,10 @@ fn startDetached(alloc: std.mem.Allocator, run_args: []const [:0]const u8, sock_
1473 .tty = std.posix.isatty(std.posix.STDERR_FILENO), 1298 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
1474 }; 1299 };
1475 return forkDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, null) catch |err| { 1300 return forkDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, null) catch |err| {
1476 // NeverAnswered's failure line, with the log path, was already 1301 // `NeverAnswered`'s failure line was already printed by Progress. A
1477 // printed by Progress — a second line would say the same thing 1302 // spawn that never happened has nothing printed yet, and
1478 // worse. A spawn that never happened has nothing printed yet, and 1303 // `std.debug.print` rather than `emitFmt` because an exe path can run to
1479 // std.debug.print rather than progress.emitFmt because an exe path 1304 // `max_path_bytes` and a fixed buffer would drop the whole line.
1480 // can run to max_path_bytes and emitFmt's fixed buffer would drop
1481 // the whole line rather than shorten it. The RESOLVED path, so the
1482 // line names a file an operator can stat.
1483 if (err == error.SpawnFailed) 1305 if (err == error.SpawnFailed)
1484 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) }); 1306 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) });
1485 return null; 1307 return null;
@@ -1506,10 +1328,8 @@ fn keygen(alloc: std.mem.Allocator) !u8 {
1506 } 1328 }
1507 1329
1508 // --------------------------------------------------------------------------- 1330 // ---------------------------------------------------------------------------
1509 // Tests. These run because `exe_mod` was added to build.zig's test loop in 1331 // Tests. These run only because `exe_mod` is in build.zig's test loop: a test
1510 // the same commit; before that, a test written here would have compiled and 1332 // written here without it compiles and silently never executes.
1511 // silently never executed (the hazard recorded in decisions.md, which cost
1512 // mux_main.zig five invisible tests).
1513 // --------------------------------------------------------------------------- 1333 // ---------------------------------------------------------------------------
1514 1334
1515 /// The tests must speak argsAlloc's type: a slice of 1335 /// The tests must speak argsAlloc's type: a slice of
@@ -1625,16 +1445,10 @@ test "parseArgs: a value-taking flag at the end of argv names itself" {
1625 } 1445 }
1626 1446
1627 // The leg the table cannot check itself: `usage` is hand-tuned prose, so a 1447 // The leg the table cannot check itself: `usage` is hand-tuned prose, so a
1628 // verb added as a row and forgotten in the text would ship undocumented. 1448 // verb added as a row and forgotten in the text would ship undocumented. This
1629 // This pins the CROSS-CHECK, not the wording — the text stays free to say 1449 // pins the CROSS-CHECK, not the wording. Anchored to the command POSITION,
1630 // whatever it likes about a subcommand as long as it lists it. 1450 // because the prose says these words in passing — `endpoint`'s parenthetical
1631 // 1451 // contains "proxy", which an unanchored search would call documented.
1632 // Anchored to the command position rather than to a bare name, because the
1633 // prose says these words in passing: `endpoint`'s parenthetical contains
1634 // "proxy", so deleting the whole `mux d proxy` line still leaves the word in
1635 // the text, and an unanchored search would call that documented.
1636 // `inline for` is what makes `s.name` comptime, so the anchor can be built
1637 // with `++`.
1638 test "usage names every subcommand" { 1452 test "usage names every subcommand" {
1639 inline for (specs) |s| { 1453 inline for (specs) |s| {
1640 const named = std.mem.indexOf(u8, usage, "\n mux d " ++ s.name) != null; 1454 const named = std.mem.indexOf(u8, usage, "\n mux d " ++ s.name) != null;
@@ -1695,11 +1509,8 @@ test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdo
1695 try std.testing.expect(parse(&.{ "d", "dump", "-h", "--sock", "/x" }).err == .help); 1509 try std.testing.expect(parse(&.{ "d", "dump", "-h", "--sock", "/x" }).err == .help);
1696 try std.testing.expect(parse(&.{ "d", "start", "--sock", "--help" }).err == .help); 1510 try std.testing.expect(parse(&.{ "d", "start", "--sock", "--help" }).err == .help);
1697 1511
1698 // The code, asked of `usageCode` rather than of `usageExit`: the latter 1512 // Asked of `usageCode`, not `usageExit`: the latter writes to STDOUT, which
1699 // writes the usage to STDOUT, which under `zig build test` is the build 1513 // under `zig build test` is the runner's IPC channel, and the step hangs.
1700 // runner's own IPC channel, and the step hangs forever. 0 is the whole
1701 // difference between an answer and a refusal, so a refusal is asserted
1702 // beside it.
1703 try std.testing.expectEqual(@as(u8, 0), usageCode(.help)); 1514 try std.testing.expectEqual(@as(u8, 0), usageCode(.help));
1704 try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command)); 1515 try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command));
1705 } 1516 }
@@ -1770,15 +1581,9 @@ test "parseArgs: endpoint is a command and takes --sock" {
1770 try std.testing.expect(s.ok._cmd == .endpoint); 1581 try std.testing.expect(s.ok._cmd == .endpoint);
1771 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1582 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
1772 1583
1773 // An unknown flag is refused, as it is for every command: a client of 1584 // An unknown flag is refused, as for every command. Only UNKNOWN, though:
1774 // some later version asking this binary for something it cannot do 1585 // the value-taking flags share one loop, so `endpoint --cols 100` parses
1775 // gets a legible refusal rather than silence. 1586 // and is ignored. `keygen` is the sole verb that narrows its own surface.
1776 //
1777 // Only UNKNOWN, though. The value-taking flags share one loop, so
1778 // `endpoint --cols 100` parses and is then ignored — `--sock` is the
1779 // only one this command reads. `keygen` is the sole verb that narrows
1780 // its own surface, and widening that rule to `proxy` and `endpoint`
1781 // together is its own change, not this one's.
1782 try std.testing.expect(parse(&.{ "d", "endpoint", "--quiet" }).err == .unknown_arg); 1587 try std.testing.expect(parse(&.{ "d", "endpoint", "--quiet" }).err == .unknown_arg);
1783 try std.testing.expect(parse(&.{ "d", "endpoint", "--cols", "100" }) == .ok); 1588 try std.testing.expect(parse(&.{ "d", "endpoint", "--cols", "100" }) == .ok);
1784 const missing = parse(&.{ "d", "endpoint", "--sock" }); 1589 const missing = parse(&.{ "d", "endpoint", "--sock" });
@@ -1798,22 +1603,11 @@ test "parseArgs: a verb-scoped word is refused on every verb but its own" {
1798 // to keep dead. 1603 // to keep dead.
1799 try std.testing.expect(!parse(&.{ "d", "endpoint" }).ok.start); 1604 try std.testing.expect(!parse(&.{ "d", "endpoint" }).ok.start);
1800 1605
1801 // Every OTHER verb, off the table, not a chosen one: a flag scoped by 1606 // Every OTHER verb, off the TABLE: a flag scoped by a hand-written
1802 // a hand-written `!= .endpoint` is scoped correctly for whichever verb 1607 // `!= .endpoint` is scoped for whichever verb the author thought of, and a
1803 // the author happened to think of, and a verb added later inherits 1608 // verb added later inherits nothing. `.ignored` rows are excluded because
1804 // nothing. `mux d start --start` is the reading that matters — it is 1609 // their row says so. `-d` walks the same table, so this asks whether the
1805 // the plausible typo, and accepting it silently would tell a user they 1610 // structure holds rather than whether a refusal was remembered.
1806 // configured something when they configured nothing.
1807 //
1808 // `.ignored` rows are excluded because their row says so: trailing
1809 // words after `--version`/`--help` are ACCEPTED and vanish (see Spec),
1810 // and e2e pins that for an over-long `--sock`. Narrowing them here
1811 // would be that contract's change, not this flag's.
1812 //
1813 // `-d` walks the same table for the same reason. It is not in `Opts`
1814 // at all — the only reader is `parseArgs`' one slot — so this loop is
1815 // asking whether that structure holds, not whether a refusal was
1816 // remembered for each verb.
1817 const scoped = .{ .{ "--start", Cmd.endpoint }, .{ "-d", Cmd.start } }; 1611 const scoped = .{ .{ "--start", Cmd.endpoint }, .{ "-d", Cmd.start } };
1818 inline for (specs) |s| { 1612 inline for (specs) |s| {
1819 if (s.flags == .ignored) continue; 1613 if (s.flags == .ignored) continue;
@@ -1993,11 +1787,9 @@ test "announceKeyFrom: a default that cannot be created reports the create, not
1993 // directory itself needs write on the tmp ROOT, which is untouched. 1787 // directory itself needs write on the tmp ROOT, which is untouched.
1994 // Emptiness is not an assumption — it is the assertion below. 1788 // Emptiness is not an assumption — it is the assertion below.
1995 1789
1996 // Swallowing the create error leaves the load to speak, and all it can 1790 // Swallowing the create error leaves the load to say `no such key file`,
1997 // say is `no such key file` — which names the symptom and sends 1791 // which names the symptom and sends the reader looking for a file when the
1998 // someone reading it over ssh to look for a file, when the real story 1792 // story is a directory they cannot write.
1999 // is a directory they cannot write. The distinction is the whole
2000 // reason the create's error is retained.
2001 const r = announceKeyFrom(null, dflt); 1793 const r = announceKeyFrom(null, dflt);
2002 try std.testing.expect(r == .create_failed); 1794 try std.testing.expect(r == .create_failed);
2003 try std.testing.expectEqual(error.AccessDenied, r.create_failed.err); 1795 try std.testing.expectEqual(error.AccessDenied, r.create_failed.err);
@@ -2011,13 +1803,9 @@ test "askEndpointPort: a socket nobody serves answers 0, quickly" {
2011 var buf: [280]u8 = undefined; 1803 var buf: [280]u8 = undefined;
2012 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); 1804 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
2013 1805
2014 // Zero is the announce-none path, and getting there fast is the point: 1806 // Zero is the announce-none path, and reaching it FAST is the point: the
2015 // the connect refusal is immediate, so the bounded wait below it is 1807 // connect refusal is immediate, so the bounded wait is never entered. One
2016 // never entered. An implementation that reached for the deadline 1808 // that polled to the deadline would answer the same 0 two seconds later.
2017 // first — polling for a reply that cannot come, retrying the connect
2018 // until the budget ran out — would answer the same 0 two seconds
2019 // later, and every `mux d endpoint` against a daemon that died between
2020 // the ensure and the ask would pay it.
2021 const t0 = std.time.milliTimestamp(); 1809 const t0 = std.time.milliTimestamp();
2022 try std.testing.expectEqual(@as(u16, 0), askEndpointPort(std.testing.allocator, sock)); 1810 try std.testing.expectEqual(@as(u16, 0), askEndpointPort(std.testing.allocator, sock));
2023 try std.testing.expect(std.time.milliTimestamp() - t0 < 500); 1811 try std.testing.expect(std.time.milliTimestamp() - t0 < 500);
@@ -2039,11 +1827,9 @@ test "endpointCmd: a box with no daemon is refused, never started — the wall p
2039 1827
2040 try std.testing.expectEqual( 1828 try std.testing.expectEqual(
2041 @as(u8, 1), 1829 @as(u8, 1),
2042 // `false` is the poll's spelling, and it is the one under test: the 1830 // `false` is the poll's spelling and the one under test: the asking
2043 // asking spelling forks a daemon, which no unit test may do here — 1831 // spelling forks a daemon, and `startDetached` takes the XDG log path
2044 // `startDetached` takes the XDG log path with no override, so a 1832 // with no override. That half is e2e's.
2045 // `true` would truncate the operator's live `muxd.log`. That half
2046 // is e2e's (`e2e_04_handoff.sh` cold leg, `e2e_09_hosts.sh`).
2047 try endpointCmd(std.testing.allocator, sock, out.handle, false), 1833 try endpointCmd(std.testing.allocator, sock, out.handle, false),
2048 ); 1834 );
2049 // The verb READS a box without `--start`. Starting a daemon here gave a 1835 // The verb READS a box without `--start`. Starting a daemon here gave a
@@ -2057,10 +1843,8 @@ test "askOnce: a deadline gives up on silence, and no deadline waits out a late
2057 const alloc = std.testing.allocator; 1843 const alloc = std.testing.allocator;
2058 1844
2059 // A socket pair stands in for the daemon: this end asks, the test end 1845 // A socket pair stands in for the daemon: this end asks, the test end
2060 // decides whether anything answers and when. 1846 // decides whether anything answers and when. A `zig build test` that prints
2061 // 1847 // nothing and never returns is this block ignoring the deadline.
2062 // A `zig build test` that prints nothing and never returns is this
2063 // block: the deadline is being ignored, and there is no reply coming.
2064 { 1848 {
2065 var pair: [2]i32 = undefined; 1849 var pair: [2]i32 = undefined;
2066 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); 1850 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
@@ -2150,15 +1934,10 @@ test "oneShotQuery: a socket nobody serves is exit 1" {
2150 var buf: [280]u8 = undefined; 1934 var buf: [280]u8 = undefined;
2151 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); 1935 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
2152 1936
2153 // The opposite verdict from `stopCmd` below, on the identical input, and 1937 // The opposite verdict from `stopCmd` on identical input, and both are
2154 // both are right: `stop` asked for a state the absence already satisfies, 1938 // right: `stop` asked for a state the absence satisfies, while `dump` and
2155 // while `dump` and `stats` asked a question nothing answered. Sharing one 1939 // `stats` asked a question nothing answered. Only the EXIT is pinned — the
2156 // round-trip between the two query verbs must not quietly make it three. 1940 // line naming the verb goes to stderr, so nothing here can assert it.
2157 //
2158 // The exit is all this pins. Both verbs are run because both must reach
2159 // that verdict, but the line naming the verb goes to stderr rather than
2160 // being returned, so nothing here can assert it — the way `lostMsg` and
2161 // `keyRefusalBody` are asserted is by being pure, and this is not.
2162 try std.testing.expectEqual( 1941 try std.testing.expectEqual(
2163 @as(u8, 1), 1942 @as(u8, 1),
2164 try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply), 1943 try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply),
@@ -2207,11 +1986,9 @@ test "waitPidGone: returns only once the OS has no such process" {
2207 } 1986 }
2208 1987
2209 test "shellIntegrationEnabled: an unset environment means off" { 1988 test "shellIntegrationEnabled: an unset environment means off" {
2210 // The daily-driver default. The injection is not free — under zsh the 1989 // The daily-driver default. The injection is not free — the zsh shim costs
2211 // ZDOTDIR shim costs the user their ~/.zshenv, and under bash the DEBUG 1990 // the user their ~/.zshenv and the bash one displaces their DEBUG trap —
2212 // trap displaces theirs (atuin, bash-preexec) — and what it buys is a 1991 // and only `mux a` reads what it buys.
2213 // knowable exit code, which only `mux a` reads. A user who never asked
2214 // for it must not pay for it.
2215 try std.testing.expect(!shellIntegrationEnabled(null)); 1992 try std.testing.expect(!shellIntegrationEnabled(null));
2216 } 1993 }
2217 1994
@@ -2365,11 +2142,9 @@ test "start -d: a binary that never binds is NeverAnswered, pid left alive" {
2365 try f.chmod(0o755); 2142 try f.chmod(0o755);
2366 f.close(); 2143 f.close();
2367 2144
2368 // The log goes somewhere disposable. xdg.logPath reads XDG_STATE_HOME 2145 // The log goes somewhere disposable: `xdg.logPath` reads XDG_STATE_HOME at
2369 // at call time and Zig tests cannot setenv, so leaving this null would 2146 // call time and Zig tests cannot setenv, so null appends to a LIVE daemon's
2370 // append to the real `~/.local/state/mux/muxd.log` — a LIVE daemon's 2147 // log. The nested `logs/` also proves the parent is created, not assumed.
2371 // log, grown by `make test`. The nested `logs/` component also proves
2372 // the parent directory is created rather than assumed.
2373 const t0 = std.time.milliTimestamp(); 2148 const t0 = std.time.milliTimestamp();
2374 try std.testing.expectError(error.NeverAnswered, forkDaemon( 2149 try std.testing.expectError(error.NeverAnswered, forkDaemon(
2375 std.testing.allocator, 2150 std.testing.allocator,
@@ -2417,14 +2192,10 @@ test "start -d: the child execs the path it was HANDED, never a name off PATH" {
2417 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()}); 2192 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
2418 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()}); 2193 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
2419 2194
2420 // `$0` is the kernel's answer to "which file did you exec": for a 2195 // `$0` is the kernel's answer to "which file did you exec", so a spawn that
2421 // shebang script it is the script, whatever argv[0] the caller wrote. 2196 // searched PATH cannot pass: nothing resolves by NAME to a file in a fresh
2422 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so 2197 // tmp dir. `$*` pins the other half — the daemon is asked for `d start`,
2423 // a spawn that searched PATH cannot pass this. `$*` pins the other half 2198 // which is also what makes it legible in `ps`.
2424 // — the daemon is asked for `d start`, which is also what makes a daemon
2425 // legible in `ps`. Production hands `spawn.selfExe` and nothing else, so
2426 // the same mechanism cannot reach a sibling: `mux` used to hunt PATH for
2427 // a `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
2428 var script: [512]u8 = undefined; 2199 var script: [512]u8 = undefined;
2429 try tmp.dir.writeFile(.{ 2200 try tmp.dir.writeFile(.{
2430 .sub_path = "stub.sh", 2201 .sub_path = "stub.sh",