03fcbc76
refactor: the daemon CLI says what a flag does, not what it replaced
a73x 2026-08-30 19:36
Commit message
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", |