a73x

7a530237

Clarify CLI parsing types and comments

a73x   2026-08-31 10:12

Commit message
Clarify CLI parsing types and comments

src/cli/flags.zig
Old New
@@ -1,15 +1,14 @@
1 //! A flag parser that reads its table off a struct: a field's TYPE is its 1 //! A flag parser whose schema is a struct: each field name determines the flag
2 //! flag's arity and its NAME is the spelling, so adding a field adds a flag and 2 //! spelling and each field type determines whether the flag takes a value.
3 //! there is no second list. The grammar is `--flag VALUE`, with a bare `--` 3 //! The grammar is `--flag VALUE`, with `--` ending flag parsing. Semantic
4 //! ending the flags. What a flag MEANS stays with the caller — in post-checks, 4 //! validation remains with the caller or with a field type that implements
5 //! or in a field type declaring `parseCLI`, which no new caller can forget. 5 //! `parseCLI`.
6 // folder rule 5 exemption: `/bin/sh` here is a shell NAME the daemon execs 6 // folder rule 5 exemption: `/bin/sh` is the fallback executable name passed as
7 // as argv[0] when nothing else names one, not a `-c` line for a shell to 7 // argv[0], not a command string interpreted with `sh -c`.
8 // parse.
9 8
10 const std = @import("std"); 9 const std = @import("std");
11 10
12 pub const Outcome = union(enum) { 11 pub const ParseOutcome = union(enum) {
13 ok, 12 ok,
14 /// `--help` or `-h`, wherever it sits: the caller prints its usage to 13 /// `--help` or `-h`, wherever it sits: the caller prints its usage to
15 /// stdout and exits 0. 14 /// stdout and exits 0.
@@ -24,10 +23,8 @@ pub const Outcome = union(enum) {
24 bad_value: []const u8, 23 bad_value: []const u8,
25 }; 24 };
26 25
27 /// The three answers a mode's `main` gives for a line that produced no 26 /// The simplified outcomes returned by `parseStrict`: invalid syntax, a help
28 /// options: one refusal for every way of mistyping a flag, because the 27 /// request, or a version request.
29 /// answer to all of them is the same usage page, and the two questions that
30 /// are output rather than diagnostics.
31 pub const ParseError = error{ Usage, Help, Version }; 28 pub const ParseError = error{ Usage, Help, Version };
32 29
33 /// `parse` as an error union, for the callers that want a `try` instead of 30 /// `parse` as an error union, for the callers that want a `try` instead of
@@ -41,14 +38,14 @@ pub fn parseStrict(comptime T: type, dst: *T, args: []const [:0]const u8) ParseE
41 } 38 }
42 } 39 }
43 40
44 /// The exit a mode makes of a `parseStrict` refusal. A mode with a refusal 41 /// Print the appropriate response for a `parseStrict` error and return its exit
45 /// of its own answers that one first and hands the rest here. 42 /// code. Callers handle mode-specific errors before calling this function.
46 pub fn exitFor(e: ParseError, usage: []const u8, prog: []const u8, ver: []const u8) u8 { 43 pub fn exitFor(e: ParseError, usage: []const u8, prog: []const u8, ver: []const u8) u8 {
47 return exitForTo(e, usage, prog, ver, std.posix.STDOUT_FILENO, std.posix.STDERR_FILENO); 44 return exitForTo(e, usage, prog, ver, std.posix.STDOUT_FILENO, std.posix.STDERR_FILENO);
48 } 45 }
49 46
50 /// `exitFor` against named fds, so a test can assert WHICH fd an arm 47 /// `exitFor` with explicit output descriptors, allowing tests to verify which
51 /// chose, over pipes it owns and the runner does not. 48 /// stream each response uses.
52 pub fn exitForTo( 49 pub fn exitForTo(
53 e: ParseError, 50 e: ParseError,
54 usage: []const u8, 51 usage: []const u8,
@@ -60,8 +57,7 @@ pub fn exitForTo(
60 return switch (e) { 57 return switch (e) {
61 error.Help => helpTo(out, usage), 58 error.Help => helpTo(out, usage),
62 error.Version => versionTo(out, prog, ver), 59 error.Version => versionTo(out, prog, ver),
63 // stderr, like every refusal (`helpTo` says why the other two 60 // Usage errors are diagnostics; help and version are requested output.
64 // are not).
65 error.Usage => blk: { 61 error.Usage => blk: {
66 writeTo(err, usage); 62 writeTo(err, usage);
67 break :blk 2; 63 break :blk 2;
@@ -74,17 +70,17 @@ fn Bare(comptime F: type) type {
74 return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F; 70 return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F;
75 } 71 }
76 72
77 /// A flag given twice: the last wins. A bare word goes to `T.positional` when T 73 /// Parse flags into `dst`. Repeated flags use the last value. Bare words are
78 /// declares one, and without that decl there are no positional arguments — a 74 /// passed to `T.positional` when present, and otherwise are unknown arguments.
79 /// bare word could only be a typo. A flag-shaped word this table does not own 75 /// Unknown flag-shaped words are passed to `T.extra`, which returns the number
80 /// goes to `T.extra`, which answers with the words it consumed, or 0. 76 /// of arguments it consumed or zero to reject the flag.
81 pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { 77 pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) ParseOutcome {
82 const fields = @typeInfo(T).@"struct".fields; 78 const fields = @typeInfo(T).@"struct".fields;
83 comptime for (fields) |f| { 79 comptime for (fields) |f| {
84 if (f.name[0] == '_') continue; 80 if (f.name[0] == '_') continue;
85 const B = Bare(f.type); 81 const B = Bare(f.type);
86 // A struct is an arity only through `parseCLI`: it takes one word 82 // Struct fields take one value only when their type implements
87 // like a string, and answers `error.Invalid` for what it cannot hold. 83 // `parseCLI`; invalid values return `error.Invalid`.
88 const owns = @typeInfo(B) == .@"struct" and @hasDecl(B, "parseCLI"); 84 const owns = @typeInfo(B) == .@"struct" and @hasDecl(B, "parseCLI");
89 if (B != bool and B != []const u8 and @typeInfo(B) != .int and !owns) 85 if (B != bool and B != []const u8 and @typeInfo(B) != .int and !owns)
90 @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type)); 86 @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type));
@@ -94,9 +90,10 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
94 @compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`"); 90 @compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`");
95 }; 91 };
96 92
97 // Asked before anything is read, so a `--help` sitting where a value belongs 93 // Help and version take precedence over syntax errors, even when they occur
98 // still answers with the usage. It stops at `--`, because past there the 94 // where a value would otherwise be required. Scanning stops at `--`, after
99 // words are PAYLOAD: `mux a send -- --help` types `--help` at a session. 95 // which words are payload; for example, `mux a send -- --help` sends the
96 // literal text `--help` to the session.
100 for (args) |a| { 97 for (args) |a| {
101 if (std.mem.eql(u8, a, "--")) break; 98 if (std.mem.eql(u8, a, "--")) break;
102 if (isHelp(a)) return .help; 99 if (isHelp(a)) return .help;
@@ -132,17 +129,15 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
132 } 129 }
133 }; 130 };
134 if (!known) { 131 if (!known) {
135 // Only a word with no leading dash is offered, until `--` says 132 // Before `--`, only words without a leading dash are positional.
136 // every word after it is one: an unnamed flag is a mistake, never 133 // After `--`, every word is positional. A hook that returns false
137 // a value. A refusal makes the word unknown — one the program 134 // leaves the word as an unknown argument.
138 // will not take is one it does not know.
139 if ((payload or (a.len > 0 and a[0] != '-')) and @hasDecl(T, "positional")) { 135 if ((payload or (a.len > 0 and a[0] != '-')) and @hasDecl(T, "positional")) {
140 if (dst.positional(a)) continue; 136 if (dst.positional(a)) continue;
141 } 137 }
142 // A flag this table does not own may still be the program's, in a 138 // `T.extra` handles flag grammars that cannot be represented by
143 // grammar the table cannot express — `--sock PATH` is one wall 139 // fields, such as a two-word `--sock PATH` target. Returning zero
144 // tile to `mux wall`, two words for one thing. The hook says how 140 // leaves the flag as an unknown argument.
145 // many words it took; taking none is a refusal, not a silent skip.
146 if (!payload and a.len > 0 and a[0] == '-' and @hasDecl(T, "extra")) { 141 if (!payload and a.len > 0 and a[0] == '-' and @hasDecl(T, "extra")) {
147 const n = dst.extra(args[i..]); 142 const n = dst.extra(args[i..]);
148 if (n > 0) { 143 if (n > 0) {
@@ -180,9 +175,8 @@ pub fn help(usage: []const u8) u8 {
180 } 175 }
181 176
182 fn helpTo(fd: std.posix.fd_t, usage: []const u8) u8 { 177 fn helpTo(fd: std.posix.fd_t, usage: []const u8) u8 {
183 // stdout, unlike every refusal: a usage someone ASKED for is output, 178 // Requested help and version text goes to stdout so it can be piped. Usage
184 // and they may well have piped it into a pager. version shares both 179 // errors go to stderr in `exitForTo`.
185 // the rule and the reason.
186 writeTo(fd, usage); 180 writeTo(fd, usage);
187 return 0; 181 return 0;
188 } 182 }
@@ -199,7 +193,7 @@ fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 {
199 return 0; 193 return 0;
200 } 194 }
201 195
202 /// A short write is not an error: a usage page down a pipe can take two. 196 /// Retry short writes until all bytes are written or the descriptor fails.
203 fn writeTo(fd: std.posix.fd_t, bytes: []const u8) void { 197 fn writeTo(fd: std.posix.fd_t, bytes: []const u8) void {
204 var off: usize = 0; 198 var off: usize = 0;
205 while (off < bytes.len) off += std.posix.write(fd, bytes[off..]) catch return; 199 while (off < bytes.len) off += std.posix.write(fd, bytes[off..]) catch return;
@@ -211,8 +205,8 @@ pub fn flagName(comptime field: []const u8) []const u8 {
211 return name; 205 return name;
212 } 206 }
213 207
214 /// A flag is named only where the word ENDS: `--sock` must not be answered 208 /// Match a complete flag name, so documenting `--socket` does not satisfy a
215 /// by prose that says `--socket`. 209 /// check for `--sock`.
216 pub fn documented(name: []const u8, usage: []const u8) bool { 210 pub fn documented(name: []const u8, usage: []const u8) bool {
217 var at: usize = 0; 211 var at: usize = 0;
218 while (std.mem.indexOfPos(u8, usage, at, name)) |i| : (at = i + 1) { 212 while (std.mem.indexOfPos(u8, usage, at, name)) |i| : (at = i + 1) {
@@ -226,8 +220,7 @@ pub fn documented(name: []const u8, usage: []const u8) bool {
226 return false; 220 return false;
227 } 221 }
228 222
229 /// Either spelling answers for the field: prose that offers `-A` and never 223 /// A documented alias, such as `-A`, also documents its underlying field.
230 /// `--agent` has documented the flag its readers will type.
231 fn documentedField(comptime T: type, comptime field: []const u8, comptime usage: []const u8) bool { 224 fn documentedField(comptime T: type, comptime field: []const u8, comptime usage: []const u8) bool {
232 if (documented(flagName(field), usage)) return true; 225 if (documented(flagName(field), usage)) return true;
233 if (@hasDecl(T, "aliases")) { 226 if (@hasDecl(T, "aliases")) {
@@ -238,9 +231,9 @@ fn documentedField(comptime T: type, comptime field: []const u8, comptime usage:
238 return false; 231 return false;
239 } 232 }
240 233
241 /// Kills parser-to-prose drift at build time: a flag added to T and forgotten 234 /// Fail compilation when a visible field has no corresponding flag in the
242 /// in the text fails the compile. The mirror leg is NOT claimed — prose may 235 /// usage text. This checks fields against prose, but does not detect prose that
243 /// name a flag no field has, and only a reader will notice. 236 /// names a nonexistent field.
244 pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void { 237 pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void {
245 @setEvalBranchQuota(200_000); 238 @setEvalBranchQuota(200_000);
246 comptime for (@typeInfo(T).@"struct".fields) |f| { 239 comptime for (@typeInfo(T).@"struct".fields) |f| {
@@ -255,7 +248,7 @@ pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime h
255 }; 248 };
256 } 249 }
257 250
258 const Demo = struct { 251 const DemoOptions = struct {
259 _cmd: u8 = 0, 252 _cmd: u8 = 0,
260 vt: bool = false, 253 vt: bool = false,
261 sock: ?[]const u8 = null, 254 sock: ?[]const u8 = null,
@@ -265,95 +258,94 @@ const Demo = struct {
265 }; 258 };
266 259
267 test "parse: a bool field is a bare flag, a string field takes the next word, an integer field parses it" { 260 test "parse: a bool field is a bare flag, a string field takes the next word, an integer field parses it" {
268 var o: Demo = .{}; 261 var o: DemoOptions = .{};
269 const args: []const [:0]const u8 = &.{ "--vt", "--sock", "/tmp/x.sock", "--shell", "/bin/dash", "--cols", "120" }; 262 const args: []const [:0]const u8 = &.{ "--vt", "--sock", "/tmp/x.sock", "--shell", "/bin/dash", "--cols", "120" };
270 try std.testing.expect(parse(Demo, &o, args) == .ok); 263 try std.testing.expect(parse(DemoOptions, &o, args) == .ok);
271 try std.testing.expect(o.vt); 264 try std.testing.expect(o.vt);
272 try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?); 265 try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);
273 try std.testing.expectEqualStrings("/bin/dash", o.shell); 266 try std.testing.expectEqualStrings("/bin/dash", o.shell);
274 try std.testing.expectEqual(@as(u16, 120), o.cols); 267 try std.testing.expectEqual(@as(u16, 120), o.cols);
275 268
276 // Untouched fields keep the struct's own defaults. 269 // Untouched fields keep the struct's own defaults.
277 var d: Demo = .{}; 270 var d: DemoOptions = .{};
278 try std.testing.expect(parse(Demo, &d, &.{}) == .ok); 271 try std.testing.expect(parse(DemoOptions, &d, &.{}) == .ok);
279 try std.testing.expect(!d.vt); 272 try std.testing.expect(!d.vt);
280 try std.testing.expect(d.sock == null); 273 try std.testing.expect(d.sock == null);
281 try std.testing.expectEqual(@as(u32, 15_000), d.quic_idle_ms); 274 try std.testing.expectEqual(@as(u32, 15_000), d.quic_idle_ms);
282 } 275 }
283 276
284 test "parse: a value flag at the end of argv is missing_value, not unknown_arg" { 277 test "parse: a value flag at the end of argv is missing_value, not unknown_arg" {
285 var o: Demo = .{}; 278 var o: DemoOptions = .{};
286 const r = parse(Demo, &o, &.{"--sock"}); 279 const r = parse(DemoOptions, &o, &.{"--sock"});
287 try std.testing.expect(r == .missing_value); 280 try std.testing.expect(r == .missing_value);
288 try std.testing.expectEqualStrings("--sock", r.missing_value); 281 try std.testing.expectEqualStrings("--sock", r.missing_value);
289 } 282 }
290 283
291 test "parse: an unknown flag names itself" { 284 test "parse: an unknown flag names itself" {
292 var o: Demo = .{}; 285 var o: DemoOptions = .{};
293 const r = parse(Demo, &o, &.{ "--vt", "--wat" }); 286 const r = parse(DemoOptions, &o, &.{ "--vt", "--wat" });
294 try std.testing.expect(r == .unknown_arg); 287 try std.testing.expect(r == .unknown_arg);
295 try std.testing.expectEqualStrings("--wat", r.unknown_arg); 288 try std.testing.expectEqualStrings("--wat", r.unknown_arg);
296 289
297 // A bare word is a mistake too: there are no positional arguments here. 290 // A bare word is a mistake too: there are no positional arguments here.
298 var p: Demo = .{}; 291 var p: DemoOptions = .{};
299 try std.testing.expect(parse(Demo, &p, &.{"run"}) == .unknown_arg); 292 try std.testing.expect(parse(DemoOptions, &p, &.{"run"}) == .unknown_arg);
300 } 293 }
301 294
302 test "parse: --help and -h are the help outcome, before or after other flags" { 295 test "parse: --help and -h are the help outcome, before or after other flags" {
303 var o: Demo = .{}; 296 var o: DemoOptions = .{};
304 try std.testing.expect(parse(Demo, &o, &.{"--help"}) == .help); 297 try std.testing.expect(parse(DemoOptions, &o, &.{"--help"}) == .help);
305 try std.testing.expect(parse(Demo, &o, &.{"-h"}) == .help); 298 try std.testing.expect(parse(DemoOptions, &o, &.{"-h"}) == .help);
306 try std.testing.expect(parse(Demo, &o, &.{ "--vt", "--help" }) == .help); 299 try std.testing.expect(parse(DemoOptions, &o, &.{ "--vt", "--help" }) == .help);
307 try std.testing.expect(parse(Demo, &o, &.{ "--help", "--wat" }) == .help); 300 try std.testing.expect(parse(DemoOptions, &o, &.{ "--help", "--wat" }) == .help);
308 // Even where a value would be read: help outranks the grammar. 301 // Even where a value would be read: help outranks the grammar.
309 try std.testing.expect(parse(Demo, &o, &.{ "--sock", "--help" }) == .help); 302 try std.testing.expect(parse(DemoOptions, &o, &.{ "--sock", "--help" }) == .help);
310 } 303 }
311 304
312 test "parse: a leading-underscore field is not a flag" { 305 test "parse: a leading-underscore field is not a flag" {
313 var o: Demo = .{}; 306 var o: DemoOptions = .{};
314 const r = parse(Demo, &o, &.{ "--cmd", "run" }); 307 const r = parse(DemoOptions, &o, &.{ "--cmd", "run" });
315 try std.testing.expect(r == .unknown_arg); 308 try std.testing.expect(r == .unknown_arg);
316 try std.testing.expectEqualStrings("--cmd", r.unknown_arg); 309 try std.testing.expectEqualStrings("--cmd", r.unknown_arg);
317 try std.testing.expectEqual(@as(u8, 0), o._cmd); 310 try std.testing.expectEqual(@as(u8, 0), o._cmd);
318 } 311 }
319 312
320 test "parse: a flag given twice, the last wins" { 313 test "parse: a flag given twice, the last wins" {
321 var o: Demo = .{}; 314 var o: DemoOptions = .{};
322 try std.testing.expect(parse(Demo, &o, &.{ "--cols", "100", "--cols", "42" }) == .ok); 315 try std.testing.expect(parse(DemoOptions, &o, &.{ "--cols", "100", "--cols", "42" }) == .ok);
323 try std.testing.expectEqual(@as(u16, 42), o.cols); 316 try std.testing.expectEqual(@as(u16, 42), o.cols);
324 } 317 }
325 318
326 test "parse: a value the field's type refuses is bad_value naming the flag" { 319 test "parse: a value rejected by the field type is bad_value naming the flag" {
327 var o: Demo = .{}; 320 var o: DemoOptions = .{};
328 const r = parse(Demo, &o, &.{ "--cols", "wide" }); 321 const r = parse(DemoOptions, &o, &.{ "--cols", "wide" });
329 try std.testing.expect(r == .bad_value); 322 try std.testing.expect(r == .bad_value);
330 try std.testing.expectEqualStrings("--cols", r.bad_value); 323 try std.testing.expectEqualStrings("--cols", r.bad_value);
331 324
332 // Out of the field's range, and negative into an unsigned, are the same 325 // Out of the field's range, and negative into an unsigned, are the same
333 // mistake: the flag cannot hold what was typed. 326 // mistake: the flag cannot hold what was typed.
334 var p: Demo = .{}; 327 var p: DemoOptions = .{};
335 try std.testing.expect(parse(Demo, &p, &.{ "--cols", "99999" }) == .bad_value); 328 try std.testing.expect(parse(DemoOptions, &p, &.{ "--cols", "99999" }) == .bad_value);
336 try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value); 329 try std.testing.expect(parse(DemoOptions, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value);
337 } 330 }
338 331
339 test "parseStrict: every way of mistyping a flag is one Usage; help and version keep their own" { 332 test "parseStrict: syntax errors become Usage; help and version remain distinct" {
340 var o: Demo = .{}; 333 var o: DemoOptions = .{};
341 try parseStrict(Demo, &o, &.{ "--vt", "--cols", "120" }); 334 try parseStrict(DemoOptions, &o, &.{ "--vt", "--cols", "120" });
342 try std.testing.expect(o.vt); 335 try std.testing.expect(o.vt);
343 try std.testing.expectEqual(@as(u16, 120), o.cols); 336 try std.testing.expectEqual(@as(u16, 120), o.cols);
344 337
345 try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{"--wat"})); 338 try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{"--wat"}));
346 try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{"--sock"})); 339 try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{"--sock"}));
347 try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{ "--cols", "wide" })); 340 try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{ "--cols", "wide" }));
348 try std.testing.expectError(error.Help, parseStrict(Demo, &o, &.{"-h"})); 341 try std.testing.expectError(error.Help, parseStrict(DemoOptions, &o, &.{"-h"}));
349 try std.testing.expectError(error.Version, parseStrict(Demo, &o, &.{"--version"})); 342 try std.testing.expectError(error.Version, parseStrict(DemoOptions, &o, &.{"--version"}));
350 } 343 }
351 344
352 test "exitFor: an answer the user asked for is stdout rc 0, a refusal is stderr rc 2" { 345 test "exitFor: requested output uses stdout rc 0; usage errors use stderr rc 2" {
353 // Pipes the test owns, never the process's own fds: the fd each arm 346 // Capture explicit descriptors so the test verifies stream selection as
354 // picks is the only choice `exitFor` makes, so a run that writes to 347 // well as content. This matters to `mux a`, whose stdout must contain only
355 // fd 1 and looks away asserts nothing. A usage page on stdout would 348 // its single JSON response.
356 // put a second thing in `mux a`'s one-JSON-object stream.
357 const Run = struct { 349 const Run = struct {
358 out: []const u8, 350 out: []const u8,
359 err: []const u8, 351 err: []const u8,
@@ -415,10 +407,10 @@ test "assertDocumented: every visible flag is named in the prose" {
415 ; 407 ;
416 // `shell` stands in for the daemon's machine-written flags: hidden from the 408 // `shell` stands in for the daemon's machine-written flags: hidden from the
417 // prose on purpose, so the assertion must not demand it. 409 // prose on purpose, so the assertion must not demand it.
418 comptime assertDocumented(Demo, text, &.{"shell"}); 410 comptime assertDocumented(DemoOptions, text, &.{"shell"});
419 } 411 }
420 412
421 const Positional = struct { 413 const PositionalOptions = struct {
422 vt: bool = false, 414 vt: bool = false,
423 agent: bool = false, 415 agent: bool = false,
424 sock: ?[]const u8 = null, 416 sock: ?[]const u8 = null,
@@ -427,7 +419,7 @@ const Positional = struct {
427 419
428 pub const aliases = .{ .{ "-A", "agent" }, .{ "-s", "sock" } }; 420 pub const aliases = .{ .{ "-A", "agent" }, .{ "-s", "sock" } };
429 421
430 pub fn positional(self: *Positional, word: []const u8) bool { 422 pub fn positional(self: *PositionalOptions, word: []const u8) bool {
431 if (word.len == 0) return false; 423 if (word.len == 0) return false;
432 if (word[0] == '!') { 424 if (word[0] == '!') {
433 self._refused = true; 425 self._refused = true;
@@ -439,90 +431,89 @@ const Positional = struct {
439 }; 431 };
440 432
441 test "parse: a bare word goes to the program's positional hook" { 433 test "parse: a bare word goes to the program's positional hook" {
442 var o: Positional = .{}; 434 var o: PositionalOptions = .{};
443 try std.testing.expect(parse(Positional, &o, &.{ "--vt", "vm1" }) == .ok); 435 try std.testing.expect(parse(PositionalOptions, &o, &.{ "--vt", "vm1" }) == .ok);
444 try std.testing.expectEqualStrings("vm1", o._host.?); 436 try std.testing.expectEqualStrings("vm1", o._host.?);
445 437
446 // A word the program will not take is unknown to it, and the hook has 438 // A positional hook that returns false leaves the word as unknown after
447 // already seen it — a refusal is a decision, not a filter. 439 // recording that it was inspected.
448 var r: Positional = .{}; 440 var r: PositionalOptions = .{};
449 const bad = parse(Positional, &r, &.{"!nope"}); 441 const bad = parse(PositionalOptions, &r, &.{"!nope"});
450 try std.testing.expect(bad == .unknown_arg); 442 try std.testing.expect(bad == .unknown_arg);
451 try std.testing.expectEqualStrings("!nope", bad.unknown_arg); 443 try std.testing.expectEqualStrings("!nope", bad.unknown_arg);
452 try std.testing.expect(r._refused); 444 try std.testing.expect(r._refused);
453 445
454 // A dashed word is never offered: an unnamed flag is a mistake, and the 446 // A dashed word is never offered: an unnamed flag is a mistake, and the
455 // hook must not get the chance to read it as a value. 447 // hook must not get the chance to read it as a value.
456 var d: Positional = .{}; 448 var d: PositionalOptions = .{};
457 try std.testing.expect(parse(Positional, &d, &.{"--wat"}) == .unknown_arg); 449 try std.testing.expect(parse(PositionalOptions, &d, &.{"--wat"}) == .unknown_arg);
458 try std.testing.expect(d._host == null); 450 try std.testing.expect(d._host == null);
459 } 451 }
460 452
461 test "parse: an alias is the field's flag, with the field's arity" { 453 test "parse: an alias is the field's flag, with the field's arity" {
462 var o: Positional = .{}; 454 var o: PositionalOptions = .{};
463 try std.testing.expect(parse(Positional, &o, &.{ "-A", "-s", "/tmp/x.sock" }) == .ok); 455 try std.testing.expect(parse(PositionalOptions, &o, &.{ "-A", "-s", "/tmp/x.sock" }) == .ok);
464 try std.testing.expect(o.agent); 456 try std.testing.expect(o.agent);
465 try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?); 457 try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);
466 458
467 // The long spelling still works, and an alias that takes a value is 459 // The long spelling still works, and an alias that takes a value is
468 // missing_value at the end of argv like the flag it stands for. 460 // missing_value at the end of argv like the flag it stands for.
469 var l: Positional = .{}; 461 var l: PositionalOptions = .{};
470 try std.testing.expect(parse(Positional, &l, &.{"--agent"}) == .ok); 462 try std.testing.expect(parse(PositionalOptions, &l, &.{"--agent"}) == .ok);
471 try std.testing.expect(l.agent); 463 try std.testing.expect(l.agent);
472 try std.testing.expect(parse(Positional, &l, &.{"-s"}) == .missing_value); 464 try std.testing.expect(parse(PositionalOptions, &l, &.{"-s"}) == .missing_value);
473 465
474 // A struct with no aliases decl is unmoved by another struct's. 466 // Aliases belong only to the type that declares them.
475 var demo: Demo = .{}; 467 var demo: DemoOptions = .{};
476 try std.testing.expect(parse(Demo, &demo, &.{"-A"}) == .unknown_arg); 468 try std.testing.expect(parse(DemoOptions, &demo, &.{"-A"}) == .unknown_arg);
477 } 469 }
478 470
479 test "parse: --version is its own outcome, wherever it sits" { 471 test "parse: --version is its own outcome, wherever it sits" {
480 var o: Demo = .{}; 472 var o: DemoOptions = .{};
481 try std.testing.expect(parse(Demo, &o, &.{"--version"}) == .version); 473 try std.testing.expect(parse(DemoOptions, &o, &.{"--version"}) == .version);
482 try std.testing.expect(parse(Demo, &o, &.{ "--vt", "--version" }) == .version); 474 try std.testing.expect(parse(DemoOptions, &o, &.{ "--vt", "--version" }) == .version);
483 // Even beside a word that would otherwise be refused, or where a value 475 // Version takes precedence over an adjacent unknown flag or missing value.
484 // belongs: asking a binary its version is not a way to mistype a flag. 476 try std.testing.expect(parse(DemoOptions, &o, &.{ "--version", "--wat" }) == .version);
485 try std.testing.expect(parse(Demo, &o, &.{ "--version", "--wat" }) == .version); 477 try std.testing.expect(parse(DemoOptions, &o, &.{ "--sock", "--version" }) == .version);
486 try std.testing.expect(parse(Demo, &o, &.{ "--sock", "--version" }) == .version);
487 } 478 }
488 479
489 test "parse: a bare -- ends the flags and every word after it is payload" { 480 test "parse: a bare -- ends the flags and every word after it is payload" {
490 var o: Positional = .{}; 481 var o: PositionalOptions = .{};
491 try std.testing.expect(parse(Positional, &o, &.{ "--vt", "--", "--vt" }) == .ok); 482 try std.testing.expect(parse(PositionalOptions, &o, &.{ "--vt", "--", "--vt" }) == .ok);
492 // The flag before the fence was read; the same word after it was not. 483 // The flag before the fence was read; the same word after it was not.
493 try std.testing.expect(o.vt); 484 try std.testing.expect(o.vt);
494 try std.testing.expectEqualStrings("--vt", o._host.?); 485 try std.testing.expectEqualStrings("--vt", o._host.?);
495 486
496 // A struct with no positional hook has nowhere to put payload, so the 487 // A struct with no positional hook has nowhere to put payload, so the
497 // fence buys it nothing: the word after is still a word it cannot take. 488 // fence buys it nothing: the word after is still a word it cannot take.
498 var d: Demo = .{}; 489 var d: DemoOptions = .{};
499 const bad = parse(Demo, &d, &.{ "--", "run" }); 490 const bad = parse(DemoOptions, &d, &.{ "--", "run" });
500 try std.testing.expect(bad == .unknown_arg); 491 try std.testing.expect(bad == .unknown_arg);
501 try std.testing.expectEqualStrings("run", bad.unknown_arg); 492 try std.testing.expectEqualStrings("run", bad.unknown_arg);
502 } 493 }
503 494
504 test "parse: --help after -- is payload, not a request for the usage" { 495 test "parse: --help after -- is payload, not a request for the usage" {
505 var o: Positional = .{}; 496 var o: PositionalOptions = .{};
506 try std.testing.expect(parse(Positional, &o, &.{ "--", "--help" }) == .ok); 497 try std.testing.expect(parse(PositionalOptions, &o, &.{ "--", "--help" }) == .ok);
507 try std.testing.expectEqualStrings("--help", o._host.?); 498 try std.testing.expectEqualStrings("--help", o._host.?);
508 499
509 var v: Positional = .{}; 500 var v: PositionalOptions = .{};
510 try std.testing.expect(parse(Positional, &v, &.{ "--", "--version" }) == .ok); 501 try std.testing.expect(parse(PositionalOptions, &v, &.{ "--", "--version" }) == .ok);
511 try std.testing.expectEqualStrings("--version", v._host.?); 502 try std.testing.expectEqualStrings("--version", v._host.?);
512 503
513 // Before the fence it is still help: the fence moves, it does not repeal. 504 // Before `--`, the same word is still parsed as help.
514 var b: Positional = .{}; 505 var b: PositionalOptions = .{};
515 try std.testing.expect(parse(Positional, &b, &.{ "--help", "--", "x" }) == .help); 506 try std.testing.expect(parse(PositionalOptions, &b, &.{ "--help", "--", "x" }) == .help);
516 } 507 }
517 508
518 const Extra = struct { 509 const ExtraOptions = struct {
519 vt: bool = false, 510 vt: bool = false,
520 _sock: ?[]const u8 = null, 511 _sock: ?[]const u8 = null,
521 _seen: ?[]const u8 = null, 512 _seen: ?[]const u8 = null,
522 513
523 /// Stands in for the wall grammar: `--sock PATH` is one target spelled in 514 /// Model the wall grammar, where `--sock PATH` is one target represented by
524 /// two words, and nothing else here is the program's to claim. 515 /// two command-line arguments. No other flag is accepted by this hook.
525 pub fn extra(self: *Extra, rest: []const [:0]const u8) usize { 516 pub fn extra(self: *ExtraOptions, rest: []const [:0]const u8) usize {
526 self._seen = rest[0]; 517 self._seen = rest[0];
527 if (!std.mem.eql(u8, rest[0], "--sock")) return 0; 518 if (!std.mem.eql(u8, rest[0], "--sock")) return 0;
528 if (rest.len < 2) return 0; 519 if (rest.len < 2) return 0;
@@ -532,28 +523,28 @@ const Extra = struct {
532 }; 523 };
533 524
534 test "parse: a flag the table does not own goes to the program's extra hook" { 525 test "parse: a flag the table does not own goes to the program's extra hook" {
535 var o: Extra = .{}; 526 var o: ExtraOptions = .{};
536 try std.testing.expect(parse(Extra, &o, &.{ "--sock", "/tmp/x.sock", "--vt" }) == .ok); 527 try std.testing.expect(parse(ExtraOptions, &o, &.{ "--sock", "/tmp/x.sock", "--vt" }) == .ok);
537 try std.testing.expectEqualStrings("/tmp/x.sock", o._sock.?); 528 try std.testing.expectEqualStrings("/tmp/x.sock", o._sock.?);
538 // Parsing resumes AFTER the words the hook took, not inside them. 529 // Parsing resumes after every argument consumed by the hook.
539 try std.testing.expect(o.vt); 530 try std.testing.expect(o.vt);
540 531
541 // A refusal is the program's decision, reported as the word it refused. 532 // Returning zero reports the original flag as unknown.
542 var r: Extra = .{}; 533 var r: ExtraOptions = .{};
543 const bad = parse(Extra, &r, &.{"--wat"}); 534 const bad = parse(ExtraOptions, &r, &.{"--wat"});
544 try std.testing.expect(bad == .unknown_arg); 535 try std.testing.expect(bad == .unknown_arg);
545 try std.testing.expectEqualStrings("--wat", bad.unknown_arg); 536 try std.testing.expectEqualStrings("--wat", bad.unknown_arg);
546 try std.testing.expectEqualStrings("--wat", r._seen.?); 537 try std.testing.expectEqualStrings("--wat", r._seen.?);
547 538
548 // Past the fence the hook is not consulted: payload is not a flag. 539 // Past the fence the hook is not consulted: payload is not a flag.
549 var p: Extra = .{}; 540 var p: ExtraOptions = .{};
550 const fenced = parse(Extra, &p, &.{ "--", "--sock", "/tmp/y.sock" }); 541 const fenced = parse(ExtraOptions, &p, &.{ "--", "--sock", "/tmp/y.sock" });
551 try std.testing.expect(fenced == .unknown_arg); 542 try std.testing.expect(fenced == .unknown_arg);
552 try std.testing.expect(p._sock == null); 543 try std.testing.expect(p._sock == null);
553 544
554 // Without the decl, an unowned flag is unknown as it always was. 545 // Without the decl, an unowned flag is unknown as it always was.
555 var d: Demo = .{}; 546 var d: DemoOptions = .{};
556 try std.testing.expect(parse(Demo, &d, &.{"--wat"}) == .unknown_arg); 547 try std.testing.expect(parse(DemoOptions, &d, &.{"--wat"}) == .unknown_arg);
557 } 548 }
558 549
559 test "assertDocumented: an alias documents its field" { 550 test "assertDocumented: an alias documents its field" {
@@ -561,13 +552,12 @@ test "assertDocumented: an alias documents its field" {
561 \\ demo [--vt] [-A] [--sock PATH] 552 \\ demo [--vt] [-A] [--sock PATH]
562 \\ 553 \\
563 ; 554 ;
564 comptime assertDocumented(Positional, text, &.{}); 555 comptime assertDocumented(PositionalOptions, text, &.{});
565 } 556 }
566 557
567 test "parse: a field type with parseCLI owns its value, and its refusal is bad_value" { 558 test "parse: a field type with parseCLI validates its value as bad_value" {
568 // Stands in for `quic.IdleMs`: a type that owns the rule for its own 559 // Model `quic.IdleMs`: validation travels with the field type instead of
569 // values, so the rule travels with the field instead of being copied 560 // being duplicated in each caller.
570 // into a post-check every caller can forget.
571 const Port = struct { 561 const Port = struct {
572 n: u16 = 8080, 562 n: u16 = 8080,
573 563
@@ -584,21 +574,20 @@ test "parse: a field type with parseCLI owns its value, and its refusal is bad_v
584 try std.testing.expectEqual(@as(u16, 9000), o.port.n); 574 try std.testing.expectEqual(@as(u16, 9000), o.port.n);
585 try std.testing.expectEqual(@as(u16, 81), o.alt.?.n); 575 try std.testing.expectEqual(@as(u16, 81), o.alt.?.n);
586 576
587 // Untouched, each field keeps the default its declaration gave it — the 577 // Untouched fields retain their declared defaults: the type's value for the
588 // TYPE's for the plain one, null for the optional. 578 // required field and null for the optional field.
589 var d: Typed = .{}; 579 var d: Typed = .{};
590 try std.testing.expect(parse(Typed, &d, &.{}) == .ok); 580 try std.testing.expect(parse(Typed, &d, &.{}) == .ok);
591 try std.testing.expectEqual(@as(u16, 8080), d.port.n); 581 try std.testing.expectEqual(@as(u16, 8080), d.port.n);
592 try std.testing.expect(d.alt == null); 582 try std.testing.expect(d.alt == null);
593 583
594 // 0 fits a u16 and the TYPE still refuses it: the rule being enforced is 584 // Zero fits in u16 but fails `parseCLI`; the result identifies the flag
595 // parseCLI's, not the integer parse's. The outcome names the FLAG, which 585 // whose value was invalid.
596 // is the half that says which word on the line was refused.
597 const r = parse(Typed, &d, &.{ "--port", "0" }); 586 const r = parse(Typed, &d, &.{ "--port", "0" });
598 try std.testing.expect(r == .bad_value); 587 try std.testing.expect(r == .bad_value);
599 try std.testing.expectEqualStrings("--port", r.bad_value); 588 try std.testing.expectEqualStrings("--port", r.bad_value);
600 try std.testing.expect(parse(Typed, &d, &.{ "--port", "wat" }) == .bad_value); 589 try std.testing.expect(parse(Typed, &d, &.{ "--port", "wat" }) == .bad_value);
601 // Left at its default by a refused word, never half-set. 590 // A rejected value leaves the field at its default.
602 try std.testing.expectEqual(@as(u16, 8080), d.port.n); 591 try std.testing.expectEqual(@as(u16, 8080), d.port.n);
603 592
604 // It takes one value the way a string field does, so at the end of argv 593 // It takes one value the way a string field does, so at the end of argv
src/cli/main.zig
Old New
@@ -1,8 +1,8 @@
1 //! `mux d` — the daemon mode. `start` hosts the session; `dump` prints the 1 //! `mux d` — the daemon mode. `start` hosts the session; `dump` prints the
2 //! authoritative grid over the protocol (debug aid, also used by e2e); 2 //! authoritative grid over the protocol (debug aid, also used by e2e);
3 //! `proxy` exposes the session socket over stdio for `mux --via`. 3 //! `proxy` exposes the session socket over stdio for `mux --via`.
4 // folder rule 5 exemption: the daemon's whole job is to spawn the user's 4 // folder rule 5 exemption: spawning the user's shell is the daemon's purpose;
5 // shell, and `/bin/sh` is the name it falls back to when $SHELL says nothing. 5 // `/bin/sh` is the executable fallback when `$SHELL` is unset.
6 6
7 const std = @import("std"); 7 const std = @import("std");
8 const Server = @import("daemon").Server; 8 const Server = @import("daemon").Server;
@@ -36,88 +36,79 @@ const usage =
36 \\ 36 \\
37 ; 37 ;
38 38
39 const Cmd = enum { dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; 39 const DaemonCommand = enum { dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade };
40 40
41 /// One row per verb: everything but the dispatch body and the prose reads off 41 /// Metadata for one daemon command. Dispatch-independent behavior is derived
42 /// this table. The two legs a table cannot check itself have their own — the 42 /// from this table and checked for completeness at compile time.
43 /// comptime block below, and "usage names every subcommand". 43 const CommandSpec = struct {
44 const Spec = struct {
45 name: []const u8, 44 name: []const u8,
46 cmd: Cmd, 45 cmd: DaemonCommand,
47 /// Whether the resolved socket path is this verb's business — the 46 /// Whether the command uses the resolved socket path. Commands that do not
48 /// length guard in `main` reads this, and answering `false` means a 47 /// touch a socket must not fail because the default path is invalid.
49 /// doomed path cannot refuse a command that never touches a socket.
50 uses_socket: bool, 48 uses_socket: bool,
51 /// What the verb does with the words after its name. `all` runs the shared 49 /// How trailing arguments are handled. `all` runs the shared parser,
52 /// flag loop, `none` refuses any argument, `ignored` returns before the 50 /// `none` rejects every argument, and `ignored` skips parsing entirely.
53 /// loop. `ignored` is a CONTRACT: trailing arguments are accepted and 51 /// The ignored behavior is compatibility-tested for help and version.
54 /// vanish, which e2e pins for `mux d --version` with an over-long `--sock`.
55 flags: enum { none, all, ignored }, 52 flags: enum { none, all, ignored },
56 }; 53 };
57 54
58 const specs = [_]Spec{ 55 const specs = [_]CommandSpec{
59 // First as the odd one out — the lookup is exact-match, so position 56 // Table order has no effect because lookup is by exact name. Version and
60 // carries no meaning. Spelled as a flag because that is what everyone 57 // help are represented as commands even though users spell them as flags.
61 // types, but it is a command: it names what the process does instead of
62 // configuring one, and answers from the binary alone.
63 .{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored }, 58 .{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored },
64 // Spelled as a flag for the same reason, and `.ignored` for a second: 59 // Help ignores adjacent arguments so requested usage is not replaced by a
65 // asking for the usage must never be refused over the words next to it. 60 // syntax diagnostic.
66 .{ .name = "--help", .cmd = .help, .uses_socket = false, .flags = .ignored }, 61 .{ .name = "--help", .cmd = .help, .uses_socket = false, .flags = .ignored },
67 .{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all }, 62 .{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all },
68 .{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all }, 63 .{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all },
69 .{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all }, 64 .{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all },
70 .{ .name = "proxy", .cmd = .proxy, .uses_socket = true, .flags = .all }, 65 .{ .name = "proxy", .cmd = .proxy, .uses_socket = true, .flags = .all },
71 .{ .name = "endpoint", .cmd = .endpoint, .uses_socket = true, .flags = .all }, 66 .{ .name = "endpoint", .cmd = .endpoint, .uses_socket = true, .flags = .all },
72 // keygen configures nothing: its one output is the default path, and a 67 // `keygen` accepts no options because it always writes the default path.
73 // flag here would be a request this command cannot honor.
74 .{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none }, 68 .{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none },
75 .{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all }, 69 .{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all },
76 .{ .name = "upgrade", .cmd = .upgrade, .uses_socket = true, .flags = .all }, 70 .{ .name = "upgrade", .cmd = .upgrade, .uses_socket = true, .flags = .all },
77 }; 71 };
78 72
79 comptime { 73 comptime {
80 for (std.enums.values(Cmd)) |c| { 74 for (std.enums.values(DaemonCommand)) |c| {
81 var rows = 0; 75 var rows = 0;
82 for (specs) |s| { 76 for (specs) |s| {
83 if (s.cmd == c) rows += 1; 77 if (s.cmd == c) rows += 1;
84 } 78 }
85 // Two rows for one Cmd is as wrong as none, and quieter: the second 79 // Require exactly one row per command. Duplicate rows would make one
86 // is unreachable through `specForName` only if its name is dead, and 80 // name unreachable and make `specForCmd` depend on table order.
87 // `specForCmd` would answer with whichever came first. 81 if (rows == 0) @compileError("DaemonCommand has no row in specs: " ++ @tagName(c));
88 if (rows == 0) @compileError("Cmd has no row in specs: " ++ @tagName(c)); 82 if (rows > 1) @compileError("DaemonCommand has more than one row in specs: " ++ @tagName(c));
89 if (rows > 1) @compileError("Cmd has more than one row in specs: " ++ @tagName(c));
90 } 83 }
91 } 84 }
92 85
93 /// No prefix matching: `ru` is a typo, and guessing which verb it 86 /// Look up an exact command name; prefixes such as `ru` remain syntax errors.
94 /// meant is how a typo becomes a daemon. 87 fn specForName(name: []const u8) ?CommandSpec {
95 fn specForName(name: []const u8) ?Spec {
96 for (specs) |s| { 88 for (specs) |s| {
97 if (std.mem.eql(u8, name, s.name)) return s; 89 if (std.mem.eql(u8, name, s.name)) return s;
98 } 90 }
99 return null; 91 return null;
100 } 92 }
101 93
102 /// Unreachable is honest because the comptime check fails the build 94 /// Return the row for a command. The compile-time table check makes failure
103 /// for a Cmd with no row. 95 /// unreachable.
104 fn specForCmd(cmd: Cmd) Spec { 96 fn specForCmd(cmd: DaemonCommand) CommandSpec {
105 for (specs) |s| { 97 for (specs) |s| {
106 if (s.cmd == cmd) return s; 98 if (s.cmd == cmd) return s;
107 } 99 }
108 unreachable; 100 unreachable;
109 } 101 }
110 102
111 /// Everything the command line can say, once. Parsed away from `main` so it 103 /// Parsed arguments for a daemon command. Parsing is separate from `main` so
112 /// can be tested without a process to exit from — the same reason 104 /// syntax and defaults can be unit tested without process exit.
113 /// mux_main.zig's parser is its own function. 105 const DaemonArguments = struct {
114 const Opts = struct { 106 /// Selected before flag parsing; the leading underscore excludes it from
115 /// Not a flag, and the leading underscore is what says so: cliflags.parse 107 /// generated flags.
116 /// skips it, the verb having been settled by the row above. 108 _cmd: DaemonCommand,
117 _cmd: Cmd, 109 /// Set only when `start` has `-d` or `--detach` in its first argument
118 /// `start`'s alone, structurally: `-d` is a WORD `parseArgs` peels at 110 /// position. True starts a child and waits for its socket; false runs in the
119 /// args[2], never a flag here, so no other verb sees it. Set means fork and 111 /// foreground.
120 /// wait for the socket; off is the foreground daemon.
121 _detach: bool = false, 112 _detach: bool = false,
122 sock: ?[]const u8 = null, 113 sock: ?[]const u8 = null,
123 shell: ?[]const u8 = null, 114 shell: ?[]const u8 = null,
@@ -125,52 +116,45 @@ const Opts = struct {
125 rows: u16 = 24, 116 rows: u16 = 24,
126 vt: bool = false, 117 vt: bool = false,
127 quic: ?[]const u8 = null, 118 quic: ?[]const u8 = null,
128 /// Null does NOT mean "no key": it means the command line named none, 119 /// An explicit key path. Null still allows `MUX_KEY_FILE` or the default
129 /// and `start` still has MUX_KEY_FILE and the default path to try. Only 120 /// path to be resolved later. `parseArgs` rejects only the inverse case,
130 /// `--key` without `--quic` is settled here, because that one has no 121 /// `--key` without `--quic`.
131 /// reading that makes it sensible.
132 key: ?[]const u8 = null, 122 key: ?[]const u8 = null,
133 /// The type refuses zero and anything wider than u32 — see `quic.IdleMs`. 123 /// `quic.IdleMs` rejects zero and values outside u32.
134 quic_idle_ms: quic.IdleMs = .{}, 124 quic_idle_ms: quic.IdleMs = .{},
135 /// The session `dump` names. Optional so that only a name that was TYPED 125 /// Session selected by `dump`. Validation applies only to an explicitly
136 /// is offered to the type: `""` is the wire's own default spelling — no 126 /// supplied name; the wire uses an empty tail for the default session.
137 /// tail at all — and would fail a rule written for a name a user typed.
138 session: ?proto.SessionName = null, 127 session: ?proto.SessionName = null,
139 /// The inherited manifest descriptor an upgrade exec'd us with — the old 128 /// Manifest descriptor supplied by the old daemon during upgrade. Its
140 /// daemon writes it into our argv, not a user flag. Its presence makes 129 /// presence makes `start` adopt existing state instead of resolving a new
141 /// `start` an ADOPTION, and excuses this process from resolving a path. 130 /// socket path.
142 resume_fd: ?std.posix.fd_t = null, 131 resume_fd: ?std.posix.fd_t = null,
143 /// The old daemon's dry run: parse the manifest to the end and exit 0 132 /// Validate the complete manifest without adopting it. The old daemon uses
144 /// having adopted nothing. A candidate that cannot read the manifest 133 /// this child-process check before relinquishing service.
145 /// must fail HERE, in a child, while the old daemon is still serving.
146 check: bool = false, 134 check: bool = false,
147 /// Test-only: abort adoption after the named section (`daemon`, 135 /// Test-only failure injection after the named manifest section. It verifies
148 /// `session`) so the rollback exec has something to trigger it. The 136 /// rollback after a candidate has read the manifest but cannot adopt it.
149 /// rollback leg is the only thing that can prove a daemon survives a
150 /// candidate that reads the manifest and then cannot use it.
151 resume_fail_at: ?[]const u8 = null, 137 resume_fail_at: ?[]const u8 = null,
152 /// `upgrade`'s one exception to the strictly-newer rule. It exists for 138 /// Allow the upgrade e2e test to reuse the same binary instead of requiring
153 /// the e2e leg, which has only one binary to upgrade with. 139 /// a strictly newer version.
154 allow_same_version: bool = false, 140 allow_same_version: bool = false,
155 /// `endpoint`'s alone, refused on every other verb: ensure a daemon on the 141 /// `endpoint`-only option that starts the daemon before announcing it. A
156 /// socket, then announce as usual. It makes a cold `mux HOST` ONE ssh run, 142 /// direct cold attach uses this flag; background polls omit it and remain
157 /// and "a read never starts a daemon" holds by ARGV — a poll spells the 143 /// read-only.
158 /// bare verb and starts nothing, an ask spells this.
159 start: bool = false, 144 start: bool = false,
160 }; 145 };
161 146
162 // The three flags left out are written into argv by the OLD daemon on an 147 // These upgrade-only flags are generated by the old daemon and intentionally
163 // upgrade and never typed by a hand, so the prose does not offer them. 148 // omitted from user-facing usage text.
164 comptime { 149 comptime {
165 cliflags.assertDocumented(Opts, usage, &.{ "resume_fd", "check", "resume_fail_at" }); 150 cliflags.assertDocumented(DaemonArguments, usage, &.{ "resume_fd", "check", "resume_fail_at" });
166 } 151 }
167 152
168 /// What `main` prints instead of running the command. Every case but `help` 153 /// Usage or help response produced instead of executing a daemon command.
169 /// is a refusal; none of them is a daemon bug, so none gets a stack trace. 154 /// Invalid syntax is reported without a stack trace; help exits successfully.
170 const Usage = union(enum) { 155 const UsageResponse = union(enum) {
171 no_command, 156 no_command,
172 /// `--help` on a subcommand. The answer the user asked for, so it is the 157 /// Requested help, the only response that exits successfully.
173 /// one case `usageExit` exits 0 on.
174 help, 158 help,
175 unknown_command: []const u8, 159 unknown_command: []const u8,
176 unknown_arg: []const u8, 160 unknown_arg: []const u8,
@@ -181,25 +165,25 @@ const Usage = union(enum) {
181 key_without_quic, 165 key_without_quic,
182 }; 166 };
183 167
184 const ParseResult = union(enum) { ok: Opts, err: Usage }; 168 const DaemonInvocation = union(enum) { command: DaemonArguments, usage: UsageResponse };
185 169
186 fn parseArgs(args: []const [:0]const u8) ParseResult { 170 fn parseArgs(args: []const [:0]const u8) DaemonInvocation {
187 if (args.len < 2) return .{ .err = .no_command }; 171 if (args.len < 2) return .{ .usage = .no_command };
188 const spec = specForName(args[1]) orelse return .{ .err = .{ .unknown_command = args[1] } }; 172 const spec = specForName(args[1]) orelse return .{ .usage = .{ .unknown_command = args[1] } };
189 173
190 // The one place a verb's flag class is enforced; which class each verb 174 // The one place a verb's flag class is enforced; which class each verb
191 // is in is stated once, in its row. 175 // is in is stated once, in its row.
192 switch (spec.flags) { 176 switch (spec.flags) {
193 .ignored => return .{ .ok = .{ ._cmd = spec.cmd } }, 177 .ignored => return .{ .command = .{ ._cmd = spec.cmd } },
194 .none => if (args.len > 2) return .{ .err = .{ .unknown_arg = args[2] } }, 178 .none => if (args.len > 2) return .{ .usage = .{ .unknown_arg = args[2] } },
195 .all => {}, 179 .all => {},
196 } 180 }
197 181
198 var o: Opts = .{ ._cmd = spec.cmd }; 182 var o: DaemonArguments = .{ ._cmd = spec.cmd };
199 183
200 // `-d` is a word in ONE position and not a flag in `Opts` at all: this 184 // `-d` is recognized only in the first argument position after `start`.
201 // line is its only reader and it reads one slot, so `mux d dump -d` is an 185 // Keeping it outside the generic flag struct makes it unknown for every
202 // unknown argument for free rather than by a refusal on every verb. 186 // other command without separate checks.
203 const flag_args = if (spec.cmd == .start and args.len > 2 and 187 const flag_args = if (spec.cmd == .start and args.len > 2 and
204 (std.mem.eql(u8, args[2], "-d") or std.mem.eql(u8, args[2], "--detach"))) 188 (std.mem.eql(u8, args[2], "-d") or std.mem.eql(u8, args[2], "--detach")))
205 blk: { 189 blk: {
@@ -207,38 +191,34 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
207 break :blk args[3..]; 191 break :blk args[3..];
208 } else args[2..]; 192 } else args[2..];
209 193
210 switch (cliflags.parse(Opts, &o, flag_args)) { 194 switch (cliflags.parse(DaemonArguments, &o, flag_args)) {
211 .ok => {}, 195 .ok => {},
212 .help => return .{ .err = .help }, 196 .help => return .{ .usage = .help },
213 // The verb the line opened with is discarded: `--version` anywhere 197 // Version takes precedence anywhere on the line and requires neither a
214 // means the same thing the bare command row means, and answering it 198 // runtime directory nor a running daemon.
215 // must not need a runtime dir or a daemon. 199 .version => return .{ .command = .{ ._cmd = .version } },
216 .version => return .{ .ok = .{ ._cmd = .version } }, 200 .unknown_arg => |a| return .{ .usage = .{ .unknown_arg = a } },
217 .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, 201 .missing_value => |f| return .{ .usage = .{ .missing_value = f } },
218 .missing_value => |f| return .{ .err = .{ .missing_value = f } }, 202 .bad_value => |f| return .{ .usage = .{ .bad_value = f } },
219 .bad_value => |f| return .{ .err = .{ .bad_value = f } },
220 } 203 }
221 204
222 // Every other verb REFUSES `--start` rather than ignoring it: `mux d start 205 // Reject `--start` on every command except `endpoint`, including the likely
223 // --start` is the reachable typo, and a flag that vanishes there tells a 206 // typo `mux d start --start`.
224 // user they asked for something when they asked for nothing. 207 if (o.start and spec.cmd != .endpoint) return .{ .usage = .{ .unknown_arg = "--start" } };
225 if (o.start and spec.cmd != .endpoint) return .{ .err = .{ .unknown_arg = "--start" } };
226 208
227 // A key with nowhere to listen is a mistake parse can see the whole of. 209 // `--key` without `--quic` is always invalid. The reverse is valid because
228 // The mirror case is NOT one: `--quic` with no `--key` may still be 210 // `run` may resolve `MUX_KEY_FILE` or the default key path.
229 // answered by MUX_KEY_FILE or the default key path, neither of which 211 if (o.key != null and o.quic == null) return .{ .usage = .key_without_quic };
230 // parse is allowed to look at, so it defers to `run`.
231 if (o.key != null and o.quic == null) return .{ .err = .key_without_quic };
232 212
233 return .{ .ok = o }; 213 return .{ .command = o };
234 } 214 }
235 215
236 /// The code alone: a test can ask it without a process. 216 /// Return the exit code for a usage response without printing it.
237 fn usageCode(u: Usage) u8 { 217 fn usageCode(u: UsageResponse) u8 {
238 return if (u == .help) 0 else 2; 218 return if (u == .help) 0 else 2;
239 } 219 }
240 220
241 fn usageExit(u: Usage) u8 { 221 fn usageExit(u: UsageResponse) u8 {
242 switch (u) { 222 switch (u) {
243 .help => return cliflags.help(usage), 223 .help => return cliflags.help(usage),
244 .no_command => std.debug.print("{s}", .{usage}), 224 .no_command => std.debug.print("{s}", .{usage}),
@@ -254,16 +234,14 @@ fn usageExit(u: Usage) u8 {
254 return usageCode(u); 234 return usageCode(u);
255 } 235 }
256 236
257 /// No DNS, deliberately: a name resolving to several addresses is a 237 /// Parse a numeric bind address without DNS; a hostname may resolve to several
258 /// question, not an answer, and this one is to BIND. 238 /// addresses and does not identify one deterministic bind target.
259 fn parseBindAddr(s: []const u8) !std.net.Address { 239 fn parseBindAddr(s: []const u8) !std.net.Address {
260 const hp = try quic.splitHostPort(s); 240 const hp = try quic.splitHostPort(s);
261 return std.net.Address.parseIp(hp.host, hp.port); 241 return std.net.Address.parseIp(hp.host, hp.port);
262 } 242 }
263 243
264 /// `mux d`. argv arrives from the dispatcher, which has already eaten the 244 /// Run daemon mode using the argv slice supplied by the top-level dispatcher.
265 /// mode word, rather than from `argsAlloc`: one binary, four mains, and
266 /// only the dispatcher knows where each mode's arguments start.
267 pub fn main(args: []const [:0]const u8) !u8 { 245 pub fn main(args: []const [:0]const u8) !u8 {
268 var gpa: std.heap.DebugAllocator(.{}) = .init; 246 var gpa: std.heap.DebugAllocator(.{}) = .init;
269 defer if (gpa.deinit() == .leak) 247 defer if (gpa.deinit() == .leak)
@@ -271,14 +249,13 @@ pub fn main(args: []const [:0]const u8) !u8 {
271 const alloc = gpa.allocator(); 249 const alloc = gpa.allocator();
272 250
273 const o = switch (parseArgs(args)) { 251 const o = switch (parseArgs(args)) {
274 .err => |u| return usageExit(u), 252 .usage => |u| return usageExit(u),
275 .ok => |o| o, 253 .command => |o| o,
276 }; 254 };
277 255
278 // A verb that touches no socket gets no path rather than one it must 256 // Resolve a socket path only for commands that use one. Version, help, and
279 // survive resolving: the default can refuse, and a version string must 257 // key generation must not depend on runtime-directory state. Resume mode
280 // never fail on the environment. A resuming `run` is exempt outright — 258 // receives its already-bound path through the manifest.
281 // its path is in the manifest, under a listener already bound to it.
282 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null; 259 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null;
283 const sock_path = if (o.sock) |s| 260 const sock_path = if (o.sock) |s|
284 try alloc.dupe(u8, s) 261 try alloc.dupe(u8, s)
@@ -288,15 +265,13 @@ pub fn main(args: []const [:0]const u8) !u8 {
288 try sockpath.defaultOrExplain(alloc, "mux d") orelse return 1; 265 try sockpath.defaultOrExplain(alloc, "mux d") orelse return 1;
289 defer alloc.free(sock_path); 266 defer alloc.free(sock_path);
290 267
291 // The sun_path bound, checked once before any command acts: otherwise a 268 // Reject an overlong Unix socket path before executing a command; otherwise
292 // spawned daemon can never answer and the story is a 2 s timeout about a 269 // detached startup would fail later as a misleading timeout. Command
293 // path doomed at parse. Which verbs are exempt is their ROWS' business. 270 // metadata determines which commands are exempt.
294 if (uses_socket and sockpath.tooLong("mux d", sock_path)) return 1; 271 if (uses_socket and sockpath.tooLong("mux d", sock_path)) return 1;
295 272
296 switch (o._cmd) { 273 switch (o._cmd) {
297 // The socket path resolved above is unused here and unchecked (see 274 // Version does not use or validate the resolved socket path.
298 // the length guard above): asking a binary its version must work
299 // with no daemon and no runtime dir.
300 .version => return cliflags.version("mux", build_options.version), 275 .version => return cliflags.version("mux", build_options.version),
301 .help => return usageExit(.help), 276 .help => return usageExit(.help),
302 .keygen => return keygen(alloc), 277 .keygen => return keygen(alloc),
@@ -308,39 +283,34 @@ pub fn main(args: []const [:0]const u8) !u8 {
308 .stats => return stats(alloc, sock_path), 283 .stats => return stats(alloc, sock_path),
309 .stop => return stopCmd(alloc, sock_path), 284 .stop => return stopCmd(alloc, sock_path),
310 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), 285 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version),
311 // A pump, and only a pump: a daemon starts when someone asks for 286 // Proxy only pumps an existing daemon connection; it never starts a
312 // one. `proxy.run` names the socket it could not reach, which is 287 // daemon. `proxy.run` reports the socket path when connection fails.
313 // what `mux --via 'ssh HOST mux d proxy'` shows a user whose remote
314 // has none — README's `ssh HOST 'mux d start -d'` is the answer.
315 .proxy => return proxy.run(sock_path), 288 .proxy => return proxy.run(sock_path),
316 .endpoint => return endpointCmd(alloc, sock_path, std.posix.STDOUT_FILENO, o.start), 289 .endpoint => return endpointCmd(alloc, sock_path, std.posix.STDOUT_FILENO, o.start),
317 } 290 }
318 } 291 }
319 292
320 /// Opt-IN, `=1` alone: the shim costs a zsh user `~/.zshenv` and a 293 /// Enable shell integration only for the exact value `1`; the integration
321 /// bash user's DEBUG trap. 294 /// changes zsh startup and uses Bash's DEBUG trap.
322 fn shellIntegrationEnabled(env: ?[]const u8) bool { 295 fn shellIntegrationEnabled(env: ?[]const u8) bool {
323 return std.mem.eql(u8, env orelse "", "1"); 296 return std.mem.eql(u8, env orelse "", "1");
324 } 297 }
325 298
326 /// Bigger than any manifest a full session table can produce (one replayed 299 /// Maximum upgrade manifest size: large enough for the full session table but
327 /// viewport each) and small enough that a descriptor that is not a manifest 300 /// bounded so an invalid descriptor cannot cause unbounded allocation.
328 /// cannot make this process eat the machine.
329 const manifest_read_max = 64 * 1024 * 1024; 301 const manifest_read_max = 64 * 1024 * 1024;
330 302
331 /// Set by the rollback exec and read at the top of the next adoption: two 303 /// Rollback marker read by the next adoption attempt. It prevents two binaries
332 /// binaries that both refuse one manifest would otherwise exec each other 304 /// that reject the same manifest from repeatedly executing each other. An
333 /// forever. A VARIABLE, because the binary exec'd back is older than this one 305 /// environment variable remains compatible with older rollback targets that
334 /// — it ignores an unknown variable and dies on an unknown flag. 306 /// would reject an unknown flag.
335 const rollback_marker = "MUX_UPGRADE_ROLLBACK"; 307 const rollback_marker = "MUX_UPGRADE_ROLLBACK";
336 308
337 /// The environment's spelling of `--resume-fail-at`. The flag alone cannot be 309 /// Environment equivalent of the test-only `--resume-fail-at` option. Upgrade
338 /// driven by a real upgrade — the exec builds a fixed argv — but the daemon's 310 /// exec builds fixed argv but preserves this variable for e2e failure injection.
339 /// ENVIRONMENT crosses it untouched, which is how a test arms the abort.
340 const fail_at_env = "MUX_RESUME_FAIL_AT"; 311 const fail_at_env = "MUX_RESUME_FAIL_AT";
341 312
342 /// The flag beats the environment, as `--key` beats `MUX_KEY_FILE`: more 313 /// Prefer the explicit failure-injection flag over its environment fallback.
343 /// specific intent sits higher.
344 fn failAtFrom(flag: ?[]const u8, env: ?[]const u8) []const u8 { 314 fn failAtFrom(flag: ?[]const u8, env: ?[]const u8) []const u8 {
345 return flag orelse env orelse ""; 315 return flag orelse env orelse "";
346 } 316 }
@@ -353,21 +323,19 @@ fn rollbackKeepsEnv(entry: []const u8) bool {
353 return !std.mem.startsWith(u8, entry, fail_at_env ++ "="); 323 return !std.mem.startsWith(u8, entry, fail_at_env ++ "=");
354 } 324 }
355 325
356 /// libc's, because std.c does not declare it and the marker must not 326 /// libc declaration used to remove the rollback marker before serving. Leaving
357 /// outlive the exec that set it — a daemon carrying it would refuse to roll 327 /// it set would disable a later rollback and propagate it to spawned shells.
358 /// back the NEXT upgrade, and would hand it to every shell it spawns.
359 extern "c" fn unsetenv(name: [*:0]const u8) c_int; 328 extern "c" fn unsetenv(name: [*:0]const u8) c_int;
360 329
361 /// Hand the sessions back to the binary that wrote the manifest. 330 /// Execute the previous daemon binary with the still-open manifest descriptor.
362 fn rollback( 331 fn rollback(
363 alloc: std.mem.Allocator, 332 alloc: std.mem.Allocator,
364 writer_path: []const u8, 333 writer_path: []const u8,
365 resume_fd: std.posix.fd_t, 334 resume_fd: std.posix.fd_t,
366 section: []const u8, 335 section: []const u8,
367 ) u8 { 336 ) u8 {
368 // Adoption failed before the pump started, so nothing has changed: 337 // Rollback occurs before serving begins, while all inherited descriptors
369 // every descriptor is still open and still inherited, and the manifest 338 // remain open and the manifest still identifies the previous binary.
370 // names the binary that opened them.
371 std.debug.print( 339 std.debug.print(
372 "mux d start: adoption failed at {s}; exec'ing {s} back\n", 340 "mux d start: adoption failed at {s}; exec'ing {s} back\n",
373 .{ section, writer_path }, 341 .{ section, writer_path },
@@ -395,9 +363,9 @@ fn rollback(
395 const path_z = alloc.dupeZ(u8, writer_path) catch return 1; 363 const path_z = alloc.dupeZ(u8, writer_path) catch return 1;
396 const envp = rollbackEnvp(alloc) catch return 1; 364 const envp = rollbackEnvp(alloc) catch return 1;
397 365
398 // Only a failed exec ends the process, and then the shells get SIGHUP 366 // Successful exec does not return. If rollback exec fails, process exit
399 // as the pty masters close — exactly today's `stop` + `run` outcome, 367 // closes PTY masters and sends the same SIGHUP effect as stop followed by
400 // not a worse one. 368 // a fresh run.
401 const exec_err = std.posix.execveZ(path_z.ptr, &argv, envp); 369 const exec_err = std.posix.execveZ(path_z.ptr, &argv, envp);
402 alloc.free(path_z); 370 alloc.free(path_z);
403 alloc.free(std.mem.span(envp)); 371 alloc.free(std.mem.span(envp));
@@ -408,8 +376,8 @@ fn rollback(
408 return 1; 376 return 1;
409 } 377 }
410 378
411 /// This process's environment plus the marker. Leaked deliberately: the 379 /// Return this process's environment plus the rollback marker. The allocation
412 /// only thing that reads it is the execve on the next line. 380 /// intentionally lives until the immediately following execve.
413 fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 { 381 fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 {
414 var n: usize = 0; 382 var n: usize = 0;
415 while (std.c.environ[n] != null) n += 1; 383 while (std.c.environ[n] != null) n += 1;
@@ -429,11 +397,10 @@ fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 {
429 return envp.ptr; 397 return envp.ptr;
430 } 398 }
431 399
432 /// `mux d start --resume-fd N`: the argv an upgrading daemon exec'd this binary 400 /// Adopt a daemon from the manifest descriptor supplied by an upgrade exec.
433 /// with. Same pid, same children, same descriptors — the manifest names 401 /// The process keeps its pid, children, and descriptors. Reading directly from
434 /// which ones. It is read from the descriptor and never from a path: the 402 /// the anonymous memfd also keeps the embedded QUIC key off disk.
435 /// memfd is anonymous memory, and the QUIC key inside it must not touch disk. 403 fn resumeRun(alloc: std.mem.Allocator, o: DaemonArguments, resume_fd: std.posix.fd_t) !u8 {
436 fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 {
437 // The writer left the offset at the end of what it wrote, and a child 404 // The writer left the offset at the end of what it wrote, and a child
438 // shares the file description with it, so the rewind is ours to do. 405 // shares the file description with it, so the rewind is ours to do.
439 var file = std.fs.File{ .handle = resume_fd }; 406 var file = std.fs.File{ .handle = resume_fd };
@@ -453,9 +420,9 @@ fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 {
453 }; 420 };
454 defer parsed.deinit(); 421 defer parsed.deinit();
455 422
456 // `--check` is the old daemon's dry run: read the manifest to the end, 423 // `--check` validates the complete manifest in a child and adopts nothing.
457 // exit 0, adopt nothing. Before the abort flag, which is about adoption — 424 // It precedes failure injection because a validation child must never roll
458 // a probe that rolled back would exec out of a CHILD of a live daemon. 425 // back or replace the live daemon.
459 if (o.check) return 0; 426 if (o.check) return 0;
460 427
461 const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env)); 428 const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env));
@@ -488,13 +455,11 @@ fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 {
488 return try srv.run(); 455 return try srv.run();
489 } 456 }
490 457
491 /// `mux d start`: the daemon, in the foreground. Every code this picks is a 458 /// Run the daemon in the foreground. Startup failures occur before serving;
492 /// boot failure caught before anything bound; reaching `srv.run()` means the 459 /// once `srv.run` is reached, the daemon returns zero when service ends.
493 /// daemon served, and it answers 0. No session's exit is reported here. 460 fn run(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8) !u8 {
494 fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { 461 // Resolve the QUIC address and key before binding or starting a shell so
495 // Address and key are settled before anything binds: a mistyped address 462 // configuration errors leave no session socket or child process behind.
496 // or an unreadable key must not first leave a session socket and a live
497 // shell behind. Same discipline as the key loader's own refusals.
498 var quic_bind: ?std.net.Address = null; 463 var quic_bind: ?std.net.Address = null;
499 var quic_key: quic.Key = undefined; 464 var quic_key: quic.Key = undefined;
500 if (o.quic) |hostport| { 465 if (o.quic) |hostport| {
@@ -505,9 +470,8 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
505 ); 470 );
506 return 1; 471 return 1;
507 }; 472 };
508 // --key, then MUX_KEY_FILE, then the default if it exists — the same 473 // Resolve `--key`, then `MUX_KEY_FILE`, then the default path, matching
509 // three-way answer every `quic://` gets. Only a daemon ASKED for QUIC 474 // direct QUIC clients. Only QUIC-enabled startup requires a key.
510 // reaches for the default, so an absent one is a message.
511 var key_owned: ?[]const u8 = null; 475 var key_owned: ?[]const u8 = null;
512 defer if (key_owned) |p| alloc.free(p); 476 defer if (key_owned) |p| alloc.free(p);
513 const key_path = switch (try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)))) { 477 const key_path = switch (try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)))) {
@@ -542,17 +506,13 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
542 }; 506 };
543 } 507 }
544 508
545 // The UDP socket is bound BEFORE the session socket, so a port that is 509 // Bind UDP before the session socket so a conflicting QUIC port leaves no
546 // already taken costs nothing: no shell has been started and no socket 510 // shell process or Unix socket behind.
547 // left on disk. It is the same discipline as loading the key first, one
548 // syscall further along.
549 var listener: ?*quic_server.Listener = null; 511 var listener: ?*quic_server.Listener = null;
550 if (quic_bind) |addr| { 512 if (quic_bind) |addr| {
551 listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms.ms) catch |err| switch (err) { 513 listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms.ms) catch |err| switch (err) {
552 // The QUIC edition of "a daemon is already running", refused for 514 // The listener intentionally omits SO_REUSEADDR. Report an occupied
553 // the same reason: the listener sets no SO_REUSEADDR, so rather 515 // QUIC port instead of sharing datagrams with another daemon.
554 // than silently splitting a port's datagrams with the daemon
555 // already there, the second one says so and stops.
556 error.AddressInUse => { 516 error.AddressInUse => {
557 std.debug.print( 517 std.debug.print(
558 "mux d: a daemon is already listening on udp {s}\n", 518 "mux d: a daemon is already listening on udp {s}\n",
@@ -580,9 +540,8 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
580 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); 540 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
581 defer alloc.free(shell_z); 541 defer alloc.free(shell_z);
582 542
583 // Read from the DAEMON's environment, necessarily: the daemon forks the 543 // Shell integration comes from the daemon environment because the daemon
584 // session shell, so by the time anyone could pass a flag through a 544 // starts the session shell before any client can provide options.
585 // client the shell has been running for a while.
586 const shell_integration = shellIntegrationEnabled( 545 const shell_integration = shellIntegrationEnabled(
587 std.posix.getenv("MUX_SHELL_INTEGRATION"), 546 std.posix.getenv("MUX_SHELL_INTEGRATION"),
588 ); 547 );
@@ -624,13 +583,12 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
624 return try srv.run(); 583 return try srv.run();
625 } 584 }
626 585
627 /// Whether a frame of the wanted type with NO payload is the answer. 586 /// Policy for an expected frame with an empty payload. Dump and stats accept an
628 /// `mux d upgrade` reads a status byte out of its reply, so an empty one is a 587 /// empty result, while upgrade requires a status byte and continues waiting.
629 /// peer that cannot answer; `dump` and `stats` print whatever arrived. 588 const EmptyPayloadPolicy = enum { is_the_answer, keeps_waiting };
630 const EmptyPayload = enum { is_the_answer, keeps_waiting };
631 589
632 /// Send one frame, then read until `want` arrives; null is every way of 590 /// Send one request and read until a frame of type `want` arrives. Return null
633 /// not getting it that names nothing; a null `deadline_ms` blocks. 591 /// for timeout, EOF, or other no-reply conditions; a null deadline blocks.
634 fn askOnce( 592 fn askOnce(
635 alloc: std.mem.Allocator, 593 alloc: std.mem.Allocator,
636 fd: std.posix.fd_t, 594 fd: std.posix.fd_t,
@@ -638,10 +596,10 @@ fn askOnce(
638 payload: []const u8, 596 payload: []const u8,
639 want: proto.MsgType, 597 want: proto.MsgType,
640 deadline_ms: ?u32, 598 deadline_ms: ?u32,
641 empty: EmptyPayload, 599 empty: EmptyPayloadPolicy,
642 ) !?proto.Frame { 600 ) !?proto.Frame {
643 // Named apart from the read errors below, because `mux d upgrade` words 601 // Preserve a distinct send error because upgrade reports a request that
644 // the two differently: a request that never landed names the socket. 602 // could not be delivered differently from a missing reply.
645 proto.writeFrame(fd, req, payload) catch return error.RequestNotSent; 603 proto.writeFrame(fd, req, payload) catch return error.RequestNotSent;
646 const deadline: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null; 604 const deadline: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null;
647 while (true) { 605 while (true) {
@@ -654,10 +612,9 @@ fn askOnce(
654 if ((std.posix.poll(&fds, @intCast(left)) catch return null) == 0) return null; 612 if ((std.posix.poll(&fds, @intCast(left)) catch return null) == 0) return null;
655 if (fds[0].revents == 0) continue; 613 if (fds[0].revents == 0) continue;
656 } 614 }
657 // Blocking, bounded only by the poll that said bytes are here: a 615 // The frame read may extend past the poll deadline if a peer writes only
658 // daemon that wrote half a frame and stopped holds this past the 616 // part of a frame. Treat malformed or partial frames as errors rather
659 // deadline — but that daemon is this binary, writing it in one call. A 617 // than as an absent reply.
660 // partial frame is an ERROR, not a null, or it reads as an absent one.
661 const frame = (try proto.readFrame(alloc, fd)) orelse return null; 618 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
662 if (frame.type == want and 619 if (frame.type == want and
663 !(empty == .keeps_waiting and frame.payload.len == 0)) return frame; 620 !(empty == .keeps_waiting and frame.payload.len == 0)) return frame;
@@ -665,8 +622,8 @@ fn askOnce(
665 } 622 }
666 } 623 }
667 624
668 /// Unbounded on purpose: a daemon that has stopped answering must show as 625 /// Perform an unbounded one-shot query. A connected daemon that stops replying
669 /// the hang it is, not as the rc 1 an absent socket gets. 626 /// remains a visible hang rather than being reported like an absent socket.
670 fn oneShotQuery( 627 fn oneShotQuery(
671 alloc: std.mem.Allocator, 628 alloc: std.mem.Allocator,
672 sock_path: []const u8, 629 sock_path: []const u8,
@@ -704,27 +661,24 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
704 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply); 661 return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply);
705 } 662 }
706 663
707 /// Ask the daemon on `sock_path` to exit, then wait until the PROCESS is gone, 664 /// Request daemon shutdown, then wait for the peer process to exit rather than
708 /// not just the path. Exit 0 covers both "stopped" and "nothing there" — the 665 /// only for socket unlink. Both an already-absent daemon and a completed stop
709 /// state asked for is the state got, which makes the verb safe to script. 666 /// return zero, making the command idempotent for scripts.
710 /// `mux d stop:` prefixes a refusal, plain `mux d:` a lifecycle verdict.
711 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 667 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
712 const stream = std.net.connectUnixSocket(sock_path) catch { 668 const stream = std.net.connectUnixSocket(sock_path) catch {
713 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path}); 669 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path});
714 return 0; 670 return 0;
715 }; 671 };
716 // A daemon that dies between connect and write reached the asked-for state 672 // If the daemon exits between connect and write, the requested final state
717 // anyway. Whether the frame LANDED is still kept: a log holds nothing about 673 // is already reached. Track whether the frame was delivered so later
718 // a request the daemon never saw, so pointing there sends the reader to an 674 // diagnostics do not point to logs for a request the daemon never received.
719 // empty page.
720 const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false; 675 const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false;
721 const peer = peerPid(stream.handle); 676 const peer = peerPid(stream.handle);
722 stream.close(); 677 stream.close();
723 678
724 // Probe-first, deadline-second, so the final window is still probed and no 679 // Probe before checking the deadline so the final interval is observed.
725 // failure line is printed about an interval nobody checked. Only the 680 // Connection refusal indicates that the shutdown unlink has completed;
726 // shutdown unlink can produce a connect refusal — a wedged event loop still 681 // a wedged event loop can still accept through the socket backlog.
727 // accepts from the backlog — which makes refusal the true signal.
728 const stop_deadline_ms: i64 = 2000; 682 const stop_deadline_ms: i64 = 2000;
729 const t0 = std.time.milliTimestamp(); 683 const t0 = std.time.milliTimestamp();
730 while (true) { 684 while (true) {
@@ -748,10 +702,8 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
748 return 1; 702 return 1;
749 } 703 }
750 704
751 /// The daemon's pid from the kernel, not from the daemon: `stop`'s promise 705 /// Read the peer pid from the kernel. Return null when the kernel cannot expose
752 /// is about a process, and only the OS can vouch for one. Null when the 706 /// it, such as across a pid namespace; callers then rely on socket shutdown.
753 /// kernel cannot name the peer (another pid namespace reports 0), and then
754 /// the socket's silence is all there is to wait on.
755 fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { 707 fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
756 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; 708 const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t };
757 var cred: Ucred = undefined; 709 var cred: Ucred = undefined;
@@ -759,10 +711,9 @@ fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
759 return if (cred.pid > 0) cred.pid else null; 711 return if (cred.pid > 0) cred.pid else null;
760 } 712 }
761 713
762 /// A socket gone quiet is the unlink, which is the FIRST thing a stopping 714 /// Wait for the peer process after its socket disappears. Socket unlink occurs
763 /// daemon does — reaping its shells and deleting its dirs come after. Saying 715 /// before shell reaping and directory cleanup, so process exit is the reliable
764 /// "stopped" there hands a supervisor a daemon still running. The bound is the 716 /// completion signal for supervisors.
765 /// reap's own grace: a daemon still here after it is wedged, and that is a report.
766 fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { 717 fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 {
767 const pid = peer orelse { 718 const pid = peer orelse {
768 std.debug.print("mux d: stopped\n", .{}); 719 std.debug.print("mux d: stopped\n", .{});
@@ -787,9 +738,9 @@ fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 {
787 return 0; 738 return 0;
788 } 739 }
789 740
790 /// Ask the daemon on `sock_path` to become THIS binary. The new binary asks, 741 /// Ask the daemon on `sock_path` to replace itself with the current binary. The
791 /// because it knows its own version and path; the daemon decides. `mux d 742 /// candidate supplies its version and path; the running daemon decides whether
792 /// upgrade:` prefixes a refusal, plain `mux d:` the lifecycle verdict. 743 /// the upgrade is allowed.
793 fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { 744 fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 {
794 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 745 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
795 const exe = std.fs.selfExePath(&exe_buf) catch { 746 const exe = std.fs.selfExePath(&exe_buf) catch {
@@ -821,14 +772,12 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool)
821 std.debug.print("mux d upgrade: {s} closed before the request landed\n", .{sock_path}); 772 std.debug.print("mux d upgrade: {s} closed before the request landed\n", .{sock_path});
822 return 1; 773 return 1;
823 } 774 }
824 // A reply this side cannot read is no answer, which is where the 775 // Treat an unreadable reply like timeout or EOF: no usable upgrade
825 // expiry and the EOF land too. 776 // response was received.
826 break :ask null; 777 break :ask null;
827 }; 778 };
828 // `.keeps_waiting` above means an empty `upgrade_reply` never reaches 779 // `.keeps_waiting` filters empty upgrade replies. Keep this payload bounds
829 // here: the wait spends the rest of its deadline on a real answer and 780 // check and classify any remaining empty frame as no reply.
830 // expires into the no-reply line below. This arm is the bounds check
831 // `payload[0]` needs, and it lands where silence lands.
832 if (reply) |frame| skip: { 781 if (reply) |frame| skip: {
833 defer frame.deinit(alloc); 782 defer frame.deinit(alloc);
834 if (frame.payload.len == 0) break :skip; 783 if (frame.payload.len == 0) break :skip;
@@ -836,10 +785,9 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool)
836 // The daemon's words, verbatim: it is the side that knows which 785 // The daemon's words, verbatim: it is the side that knows which
837 // check failed, and paraphrasing here would lose the versions. 786 // check failed, and paraphrasing here would lose the versions.
838 std.debug.print("mux d upgrade: refused: {s}\n", .{frame.payload[1..]}); 787 std.debug.print("mux d upgrade: refused: {s}\n", .{frame.payload[1..]});
839 // One refusal gets a translation, because the daemon saying it 788 // Add compatibility context when version probing fails: v0.0.1-15
840 // cannot know why: v0.0.1-15 and older probe for `muxd <version>` 789 // and older expect `muxd <version>`, while this binary reports
841 // and this binary answers `mux <version>`. Conditional wording — 790 // `mux <version>`.
842 // a new daemon says the same about any candidate.
843 if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch")) 791 if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch"))
844 std.debug.print( 792 std.debug.print(
845 "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++ 793 "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++
@@ -860,12 +808,12 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool)
860 return 1; 808 return 1;
861 } 809 }
862 810
863 /// The socket answered, and the answer came from the new image. 811 /// Verify that the upgraded daemon, rather than merely the inherited listening
812 /// socket, is serving protocol requests.
864 fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 { 813 fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 {
865 // Not `sockpath.answers`: the listener fd crosses the exec, so a connect 814 // A connect is insufficient because the listener descriptor survives exec
866 // succeeds throughout the handover — it is served out of the backlog by 815 // and can queue connections throughout handover. A valid reply proves that
867 // whichever image accepts it. Only an ANSWERED frame says the new one 816 // the new image is accepting and processing frames.
868 // is pumping.
869 const stream = std.net.connectUnixSocket(sock_path) catch { 817 const stream = std.net.connectUnixSocket(sock_path) catch {
870 std.debug.print("mux d upgrade: {s} stopped answering after the exec\n", .{sock_path}); 818 std.debug.print("mux d upgrade: {s} stopped answering after the exec\n", .{sock_path});
871 return 1; 819 return 1;
@@ -905,29 +853,23 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
905 ) catch ""; 853 ) catch "";
906 } 854 }
907 855
908 /// `mux d proxy` with a one-line preamble: ensure a key, ask the daemon for 856 /// Run `mux d proxy` after emitting one endpoint announcement line. Direct
909 /// its QUIC port, print `endpoint <port> <hex-key>` (or `endpoint none`) as 857 /// attaches use `--start` to ensure a daemon exists; background wall polls omit
910 /// the FIRST bytes on `out_fd`, then become the proxy byte pump. This is what 858 /// it and remain read-only.
911 /// `mux HOST` runs over ssh, and what its wall polls — told apart by `start`
912 /// alone: an ASKED dial spells `--start`, a poll spells the bare verb.
913 /// 859 ///
914 /// The announce is mandatory. The client blocks on one newline-terminated 860 /// The announcement is mandatory because the client blocks until it reads one
915 /// line and the daemon sends nothing unprompted, so silence here is a slow 861 /// newline-terminated line. Silence would hang the SSH attachment.
916 /// ssh and would HANG the attach rather than degrade it.
917 /// 862 ///
918 /// So the two failures end differently. A SOFT one — no usable key, no 863 /// Recoverable QUIC failures announce `endpoint none` and continue over SSH.
919 /// listener — announces `endpoint none` and pumps anyway, since the ssh 864 /// Failures that also prevent proxying exit and let the client observe EOF;
920 /// session is real. A HARD one exits, and the client reads EOF. Announcing 865 /// they must not announce a working fallback first.
921 /// none and THEN exiting is the one dishonest option: it claims a working
922 /// session at the moment that session goes away.
923 /// 866 ///
924 /// stdout carries the announce and then frames, nothing else — every 867 /// Stdout contains only the announcement and protocol frames. Diagnostics go to
925 /// human-facing word goes to stderr, which ssh already carries. 868 /// stderr, which SSH forwards separately.
926 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t, start: bool) !u8 { 869 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t, start: bool) !u8 {
927 // The whole of what `--start` means: ensure a daemon, then answer as 870 // `--start` ensures a daemon before querying it. Forward the same explicit
928 // always. `--sock` is the ONE forwarded run flag and is not optional — the 871 // socket path so startup cannot bind the default socket while this command
929 // path probed has to be the path bound, or this starts a daemon on the 872 // probes another path.
930 // DEFAULT socket and exits 1, a stray daemon nothing reports.
931 // 873 //
932 // A failed spawn has already said so on stderr, so returning here leaves 874 // A failed spawn has already said so on stderr, so returning here leaves
933 // the announce UNWRITTEN — the "no daemon" shape the client reads. 875 // the announce UNWRITTEN — the "no daemon" shape the client reads.
@@ -942,38 +884,34 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
942 // a word on stderr here is a word on the wall's alternate screen. 884 // a word on stderr here is a word on the wall's alternate screen.
943 if (!sockpath.answers(sock_path)) return 1; 885 if (!sockpath.answers(sock_path)) return 1;
944 886
945 // The announce goes out on the same stdout the pump is about to use, 887 // The announcement and proxy share stdout. Install the proxy's SIGPIPE
946 // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it 888 // handling before either writes so a closed pipe returns EPIPE instead of
947 // from proxy.zig's installer rather than from a std default this file 889 // terminating the process.
948 // would be leaning on.
949 proxy.ignoreSigpipe(); 890 proxy.ignoreSigpipe();
950 891
951 // Key first, then the ask: the daemon's lazy bind takes the default key 892 // Resolve or create the key before asking for the endpoint. The daemon's
952 // path only if the file already EXISTS. Creating it here is what lets a 893 // lazy QUIC bind uses the default path only when the file already exists.
953 // first-ever attach to a fresh box produce coordinates.
954 const key = announceKey(alloc); 894 const key = announceKey(alloc);
955 const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path); 895 const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path);
956 896
957 var line_buf: [handoff.announce_max_len]u8 = undefined; 897 var line_buf: [handoff.announce_max_len]u8 = undefined;
958 const line: []const u8 = blk: { 898 const line: []const u8 = blk: {
959 const k = key orelse break :blk handoff.announce_none; 899 const k = key orelse break :blk handoff.announce_none;
960 // 0 is `endpoint_reply`'s "could not", and turning it into the negative 900 // Endpoint reply zero means no listener. Convert it to the explicit
961 // announce is this caller's job. `formatAnnounce` refuses port 0 rather 901 // negative announcement before formatting, which intentionally rejects
962 // than doing it quietly, so an omission fails here and not on another box. 902 // port zero.
963 if (port == 0) { 903 if (port == 0) {
964 reportNoListener(alloc, sock_path); 904 reportNoListener(alloc, sock_path);
965 break :blk handoff.announce_none; 905 break :blk handoff.announce_none;
966 } 906 }
967 // Unreachable in fact, but a fallback rather than `unreachable`: a 907 // Fall back to SSH instead of panicking if announcement formatting ever
968 // panic on the remote box takes down a session ssh was about to carry, 908 // fails; losing QUIC is safer than terminating the pending session.
969 // to report a bug about a preamble. Announcing none costs only QUIC.
970 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch 909 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch
971 handoff.announce_none; 910 handoff.announce_none;
972 }; 911 };
973 proto.writeAllFd(out_fd, line) catch |err| { 912 proto.writeAllFd(out_fd, line) catch |err| {
974 // stdout is the pipe the pump is about to need, so there is no session 913 // The proxy also requires stdout, so a failed announcement leaves no
975 // left to fall back to. The error is NAMED, not guessed: EPIPE is 914 // usable fallback stream. Report the actual write error.
976 // likely, but a full disk and a closed fd want different reactions.
977 std.debug.print( 915 std.debug.print(
978 "mux d endpoint: cannot write the announce to stdout: {s}\n", 916 "mux d endpoint: cannot write the announce to stdout: {s}\n",
979 .{@errorName(err)}, 917 .{@errorName(err)},
@@ -984,10 +922,8 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
984 return proxy.run(sock_path); 922 return proxy.run(sock_path);
985 } 923 }
986 924
987 /// The daemon is up and answering but has no QUIC listener: no key it 925 /// Report that the daemon is reachable but has no QUIC listener. The detailed
988 /// could load, a bind that failed, or a binary too old to know the 926 /// reason is in the remote daemon log, so include its path when available.
989 /// verb. The reason went to the daemon's log, on a box the reader is
990 /// not sitting at, so the line says where the rest is.
991 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void { 927 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void {
992 var hint: [log_hint_len]u8 = undefined; 928 var hint: [log_hint_len]u8 = undefined;
993 std.debug.print( 929 std.debug.print(
@@ -996,12 +932,11 @@ fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void {
996 ); 932 );
997 } 933 }
998 934
999 /// The key `mux d endpoint` announces, or null with exactly one stderr line 935 /// Resolve the key announced by `mux d endpoint`. Return null after printing one
1000 /// saying why not. The reading of the environment and the deciding live in 936 /// diagnostic when no usable key is available.
1001 /// `announceKeyFrom` below; this half owns the words.
1002 fn announceKey(alloc: std.mem.Allocator) ?quic.Key { 937 fn announceKey(alloc: std.mem.Allocator) ?quic.Key {
1003 // Resolved whether or not it is the one chosen, so the no-HOME case can 938 // Resolve the default path up front so a missing HOME can be distinguished
1004 // be told apart from the have-a-path cases below. 939 // from failures involving an actual path.
1005 const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null; 940 const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null;
1006 defer if (dflt) |p| alloc.free(p); 941 defer if (dflt) |p| alloc.free(p);
1007 942
@@ -1020,14 +955,11 @@ fn announceKey(alloc: std.mem.Allocator) ?quic.Key {
1020 return null; 955 return null;
1021 } 956 }
1022 957
1023 /// What the key resolution decided and why, separated from the printing of 958 /// Result of selecting, creating, and loading the endpoint key. Keeping this
1024 /// it. The daemon's `endpointPortFrom` is this decision's other half: the 959 /// decision separate from diagnostics makes precedence and failures testable.
1025 /// two must agree on which file "the key" names, and an order that quietly 960 const KeyResolution = union(enum) {
1026 /// inverted would show up only as a client authenticating to nothing.
1027 const KeyResult = union(enum) {
1028 key: quic.Key, 961 key: quic.Key,
1029 /// No MUX_KEY_FILE and no HOME to build a default under: there is not 962 /// Neither `MUX_KEY_FILE` nor a HOME-based default path is available.
1030 /// even a path to try.
1031 no_path, 963 no_path,
1032 /// The default key was absent and could not be created. Kept apart 964 /// The default key was absent and could not be created. Kept apart
1033 /// from `load_failed` because it is the cause and the load's 965 /// from `load_failed` because it is the cause and the load's
@@ -1036,24 +968,23 @@ const KeyResult = union(enum) {
1036 load_failed: struct { path: []const u8, err: anyerror }, 968 load_failed: struct { path: []const u8, err: anyerror },
1037 }; 969 };
1038 970
1039 /// MUX_KEY_FILE, then the default path — the daemon's lazy bind order, because 971 /// Load `MUX_KEY_FILE` when supplied, otherwise load or create the default key.
1040 /// the announce hands a client the key it will authenticate WITH against 972 /// This matches daemon lazy-bind precedence so the announced key authenticates
1041 /// whatever that daemon loaded. Two spellings would attach to nothing. 973 /// against the listener that daemon creates.
1042 /// 974 ///
1043 /// Only the DEFAULT is created when absent, which is what lets a first attach 975 /// Only the default is created when absent. An explicit environment path is
1044 /// to a fresh box produce coordinates. A MUX_KEY_FILE that is set but missing 976 /// user-managed and must already exist. Inputs are injected and this function
1045 /// names a file the user manages: writing there is a credential nobody asked 977 /// prints nothing so each branch can be tested directly.
1046 /// for. Environment handed in and nothing printed, so both halves are testable. 978 fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResolution {
1047 fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
1048 if (env) |p| return if (quic.Key.load(p)) |k| 979 if (env) |p| return if (quic.Key.load(p)) |k|
1049 .{ .key = k } 980 .{ .key = k }
1050 else |err| 981 else |err|
1051 .{ .load_failed = .{ .path = p, .err = err } }; 982 .{ .load_failed = .{ .path = p, .err = err } };
1052 983
1053 const path = dflt orelse return .no_path; 984 const path = dflt orelse return .no_path;
1054 // KeyExists is no news: the key is there and the load below wanted it. Any 985 // Ignore `KeyExists` and load the existing key. Preserve other creation
1055 // OTHER create failure is kept, because the create holds the reason — an 986 // failures because they identify causes such as an unwritable directory
1056 // unwritable directory — where the load reports only the symptom. 987 // more accurately than the subsequent missing-file load error.
1057 const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_| 988 const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_|
1058 null 989 null
1059 else |err| if (err == error.KeyExists) null else err; 990 else |err| if (err == error.KeyExists) null else err;
@@ -1066,9 +997,8 @@ fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
1066 .{ .load_failed = .{ .path = path, .err = load_err } }; 997 .{ .load_failed = .{ .path = path, .err = load_err } };
1067 } 998 }
1068 999
1069 /// One line naming the reason: it rides ssh's stderr to someone who is 1000 /// Print a key-loading diagnostic suitable for forwarding over SSH. Reuse
1070 /// not on that box. The words are `quic.keyRefusalBody`'s, so a refusal 1001 /// `quic.keyRefusalBody` so every CLI path reports the same reason.
1071 /// reads the same however the daemon was asked.
1072 fn reportKeyRefusal(path: []const u8, err: anyerror) void { 1002 fn reportKeyRefusal(path: []const u8, err: anyerror) void {
1073 var buf: [quic.key_refusal_len]u8 = undefined; 1003 var buf: [quic.key_refusal_len]u8 = undefined;
1074 std.debug.print( 1004 std.debug.print(
@@ -1077,9 +1007,9 @@ fn reportKeyRefusal(path: []const u8, err: anyerror) void {
1077 ); 1007 );
1078 } 1008 }
1079 1009
1080 /// One observer round-trip: `endpoint_req`, then a bounded wait. 0 is every 1010 /// Request the daemon's QUIC port with a bounded wait. Return zero for any
1081 /// failure, because the caller treats them alike. The bound turns an old 1011 /// failure so old daemons that ignore the request fall back to SSH instead of
1082 /// daemon's silence into the announce-none path instead of a hang. 1012 /// hanging.
1083 fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { 1013 fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
1084 const stream = std.net.connectUnixSocket(sock_path) catch return 0; 1014 const stream = std.net.connectUnixSocket(sock_path) catch return 0;
1085 defer stream.close(); 1015 defer stream.close();
@@ -1096,14 +1026,11 @@ fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
1096 return proto.decodeEndpointReply(frame.payload) catch 0; 1026 return proto.decodeEndpointReply(frame.payload) catch 0;
1097 } 1027 }
1098 1028
1099 /// `mux d start`: the daemon in this process, or under `-d` in a child of it. 1029 /// Run `mux d start` in the current process or spawn it for `-d`. Forward every
1100 /// `forwarded` is everything after `start`, so the child's line is the rest 1030 /// argument after `start` unchanged so foreground and detached parsing match.
1101 /// verbatim and a flag that parses here behaves identically there. What parse 1031 fn startCmd(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
1102 /// cannot validate surfaces in the daemon's log, which the failure path names.
1103 fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
1104 if (!o._detach) return run(alloc, o, sock_path); 1032 if (!o._detach) return run(alloc, o, sock_path);
1105 // A `-d` still in the child's argv would fork again, and its child 1033 // Remove `-d` from child argv to prevent recursive detached spawning.
1106 // again, for as long as the machine lasted.
1107 const r = startDetached(alloc, forwarded[1..], sock_path, "mux d") orelse return 1; 1034 const r = startDetached(alloc, forwarded[1..], sock_path, "mux d") orelse return 1;
1108 if (r == .already_running) { 1035 if (r == .already_running) {
1109 std.debug.print( 1036 std.debug.print(
@@ -1114,54 +1041,50 @@ fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded:
1114 return 0; 1041 return 0;
1115 } 1042 }
1116 1043
1117 /// How long a spawn gets to answer. One number for both starters: it describes 1044 /// Time allowed for a spawned daemon to begin answering on its socket.
1118 /// how long a daemon takes to bind, which is not a fact about which verb asked.
1119 const start_deadline_ms: u32 = 2000; 1045 const start_deadline_ms: u32 = 2000;
1120 1046
1121 const Started = enum { already_running, started }; 1047 const StartOutcome = enum { already_running, started };
1122 1048
1123 const StartError = error{ SpawnFailed, NeverAnswered }; 1049 const StartError = error{ SpawnFailed, NeverAnswered };
1124 1050
1125 /// All of `forkDaemon`'s stderr output belongs to this struct: the caller 1051 /// Destination and formatting policy for detached-start progress. Callers set
1126 /// decides the prefix (the verb the user typed) and whether dots animate. 1052 /// the command prefix and whether a TTY receives animated dots. An already
1127 /// Silence on the already-running path is part of the contract — any 1053 /// running daemon produces no progress output.
1128 /// output at all means something unusual happened. 1054 const StartProgress = struct {
1129 const Progress = struct {
1130 fd: std.posix.fd_t, 1055 fd: std.posix.fd_t,
1131 prefix: []const u8, 1056 prefix: []const u8,
1132 tty: bool, 1057 tty: bool,
1133 1058
1134 fn emit(self: Progress, s: []const u8) void { 1059 fn emit(self: StartProgress, s: []const u8) void {
1135 _ = std.posix.write(self.fd, s) catch {}; 1060 _ = std.posix.write(self.fd, s) catch {};
1136 } 1061 }
1137 1062
1138 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void { 1063 fn emitFmt(self: StartProgress, comptime fmt: []const u8, args: anytype) void {
1139 var buf: [256]u8 = undefined; 1064 var buf: [256]u8 = undefined;
1140 const s = std.fmt.bufPrint(&buf, fmt, args) catch return; 1065 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
1141 self.emit(s); 1066 self.emit(s);
1142 } 1067 }
1143 }; 1068 };
1144 1069
1145 /// Test hook: the pid of the most recent spawn; this file's tests are its 1070 /// Test-only pid of the most recent spawn, used to reap deliberately persistent
1146 /// only readers, reaping the deliberately-orphaned stub by it. Not 1071 /// stub processes. Callers are single-threaded, so synchronization is omitted.
1147 /// synchronized — every caller is single-threaded.
1148 var last_spawned_pid: std.posix.pid_t = 0; 1072 var last_spawned_pid: std.posix.pid_t = 0;
1149 1073
1150 /// `-d`, all of it: probe, fork, exec this image in the child, poll until 1074 /// Implement detached startup: probe the socket, fork, exec this binary, and
1151 /// the socket answers. 1075 /// poll until the daemon answers or the deadline expires.
1152 fn forkDaemon( 1076 fn forkDaemon(
1153 alloc: std.mem.Allocator, 1077 alloc: std.mem.Allocator,
1154 exe_path: []const u8, 1078 exe_path: []const u8,
1155 run_args: []const [:0]const u8, 1079 run_args: []const [:0]const u8,
1156 sock_path: []const u8, 1080 sock_path: []const u8,
1157 progress: Progress, 1081 progress: StartProgress,
1158 deadline_ms: u32, 1082 deadline_ms: u32,
1159 log_path_override: ?[]const u8, 1083 log_path_override: ?[]const u8,
1160 ) StartError!Started { 1084 ) StartError!StartOutcome {
1161 // The one fork under src/, kept that way by build.zig's rule 6: a client 1085 // Daemon-mode code owns the repository's only fork path. `NeverAnswered`
1162 // that forked a daemon would be a second decider on its flags, log and 1086 // leaves the child running because it may finish startup after the caller's
1163 // refusals without seeing any of them. `NeverAnswered` does not kill the 1087 // deadline and be available on retry.
1164 // pid — a daemon up at 2.5 s is there for the retry.
1165 if (sockpath.answers(sock_path)) return .already_running; 1088 if (sockpath.answers(sock_path)) return .already_running;
1166 1089
1167 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed; 1090 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
@@ -1175,18 +1098,17 @@ fn forkDaemon(
1175 defer alloc.free(log_path); 1098 defer alloc.free(log_path);
1176 if (std.fs.path.dirname(log_path)) |dir| 1099 if (std.fs.path.dirname(log_path)) |dir|
1177 std.fs.cwd().makePath(dir) catch return error.SpawnFailed; 1100 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
1178 // APPEND, always, opened by hand because `createFile` cannot ask for 1101 // Always open with O_APPEND. One XDG log serves every daemon socket on the
1179 // O_APPEND. One xdg log serves every socket on the box, so truncating would 1102 // machine, so truncation or writing from offset zero could destroy another
1180 // zero a live daemon's — and merely not truncating is worse, since the 1103 // daemon's log.
1181 // child's fd would start at zero and overwrite from the front.
1182 const log: std.fs.File = .{ 1104 const log: std.fs.File = .{
1183 .handle = std.posix.open(log_path, .{ 1105 .handle = std.posix.open(log_path, .{
1184 .ACCMODE = .WRONLY, 1106 .ACCMODE = .WRONLY,
1185 .CREAT = true, 1107 .CREAT = true,
1186 .APPEND = true, 1108 .APPEND = true,
1187 // `createFile` sets this for free and `posix.O` does not. Without 1109 // Set CLOEXEC explicitly because the raw `posix.open` API does not.
1188 // it the fd rides the exec below into the daemon and a second into 1110 // The descriptor must not propagate through the daemon into session
1189 // the user's shell; fds 1 and 2 survive only because dup2 clears it. 1111 // shells; dup2 intentionally clears CLOEXEC for stdout and stderr.
1190 .CLOEXEC = true, 1112 .CLOEXEC = true,
1191 }, 0o600) catch return error.SpawnFailed, 1113 }, 0o600) catch return error.SpawnFailed,
1192 }; 1114 };
@@ -1194,10 +1116,8 @@ fn forkDaemon(
1194 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed; 1116 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
1195 defer devnull.close(); 1117 defer devnull.close();
1196 1118
1197 // argv for the child: mux d start <forwarded...>, all null-terminated. 1119 // Build null-terminated child argv as `mux d start <forwarded...>`. Keeping
1198 // The mode word is spelled out so `ps` shows a daemon as a daemon — 1120 // the mode word makes the long-lived daemon identifiable in process lists.
1199 // it is the only thing separating the long-lived process from the
1200 // `mux d start -d` that spawned it.
1201 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed; 1121 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
1202 defer alloc.free(exe_z); 1122 defer alloc.free(exe_z);
1203 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch 1123 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
@@ -1217,11 +1137,9 @@ fn forkDaemon(
1217 return error.SpawnFailed; 1137 return error.SpawnFailed;
1218 }; 1138 };
1219 if (pid == 0) { 1139 if (pid == 0) {
1220 // Child: its own session, no controlling terminal, stdio detached. 1140 // Child: create a new session, detach stdio, then exec or call
1221 // Nothing here may allocate or return — only exec or _exit. 1141 // `exit_group`. Avoid `std.posix.exit` because libc atexit handlers could
1222 // `exit_group`, never `std.posix.exit`: we link libc, so the latter runs 1142 // flush buffers inherited from the parent a second time.
1223 // atexit handlers and flushes stdio buffers INHERITED from the parent,
1224 // writing the parent's pending output a second time.
1225 _ = std.os.linux.setsid(); 1143 _ = std.os.linux.setsid();
1226 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch 1144 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch
1227 std.os.linux.exit_group(127); 1145 std.os.linux.exit_group(127);
@@ -1229,18 +1147,17 @@ fn forkDaemon(
1229 std.os.linux.exit_group(127); 1147 std.os.linux.exit_group(127);
1230 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch 1148 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
1231 std.os.linux.exit_group(127); 1149 std.os.linux.exit_group(127);
1232 // An exec rather than running the daemon in this fork, and not by 1150 // Exec a fresh image because `std.debug.MemoryAccessor` caches the pid
1233 // preference: `std.debug.MemoryAccessor` caches the pid it reads memory 1151 // used for memory reads; reusing it after fork can make DebugAllocator
1234 // through, so a child's first DebugAllocator stack trace calls 1152 // inspect the parent and panic. Exit 127 if exec fails.
1235 // `process_vm_readv` on the PARENT and panics. `execveZ` returns an
1236 // error set because success does not return; 127 is "cannot exec".
1237 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) { 1153 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
1238 else => std.os.linux.exit_group(127), 1154 else => std.os.linux.exit_group(127),
1239 } 1155 }
1240 } 1156 }
1241 last_spawned_pid = pid; 1157 last_spawned_pid = pid;
1242 1158
1243 // Parent: poll. Dots only on a tty so scripted output stays pinnable. 1159 // Parent: poll the socket. Animate dots only on a TTY so scripted output is
1160 // stable.
1244 var next_dot: i64 = t0 + 250; 1161 var next_dot: i64 = t0 + 250;
1245 // A pid owes us exactly one reap: a second `waitpid` gets ECHILD, which 1162 // A pid owes us exactly one reap: a second `waitpid` gets ECHILD, which
1246 // `std.posix.waitpid` answers with `unreachable`. That panic would land 1163 // `std.posix.waitpid` answers with `unreachable`. That panic would land
@@ -1276,29 +1193,26 @@ fn forkDaemon(
1276 progress.emit("."); 1193 progress.emit(".");
1277 next_dot = now + 250; 1194 next_dot = now + 250;
1278 } 1195 }
1279 // Reap if the child exited (loser of a start race, or a refused 1196 // Reap an exited child once, including a start-race loser or a child
1280 // flag): its socket-owner sibling answers the next probe either 1197 // with invalid flags. Repeated waitpid calls after reaping would fail.
1281 // way, and an unreaped child would sit as a zombie until we exit.
1282 // Once is enough, and once is all that is safe — see `reaped`.
1283 if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid) 1198 if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid)
1284 reaped = true; 1199 reaped = true;
1285 std.Thread.sleep(50 * std.time.ns_per_ms); 1200 std.Thread.sleep(50 * std.time.ns_per_ms);
1286 } 1201 }
1287 } 1202 }
1288 1203
1289 /// Both starters — `-d` and `endpoint --start` — through one door: this 1204 /// Start a detached daemon for either `-d` or `endpoint --start` using the
1290 /// image, the one deadline, a failure already narrated. Null needs only an 1205 /// current executable and shared deadline. Return null after reporting failure.
1291 /// exit code. 1206 fn startDetached(alloc: std.mem.Allocator, run_args: []const [:0]const u8, sock_path: []const u8, prefix: []const u8) ?StartOutcome {
1292 fn startDetached(alloc: std.mem.Allocator, run_args: []const [:0]const u8, sock_path: []const u8, prefix: []const u8) ?Started {
1293 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 1207 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1294 const exe = spawn.selfExe(&exe_buf); 1208 const exe = spawn.selfExe(&exe_buf);
1295 const progress: Progress = .{ 1209 const progress: StartProgress = .{
1296 .fd = std.posix.STDERR_FILENO, 1210 .fd = std.posix.STDERR_FILENO,
1297 .prefix = prefix, 1211 .prefix = prefix,
1298 .tty = std.posix.isatty(std.posix.STDERR_FILENO), 1212 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
1299 }; 1213 };
1300 return forkDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, null) catch |err| { 1214 return forkDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, null) catch |err| {
1301 // `NeverAnswered`'s failure line was already printed by Progress. A 1215 // `NeverAnswered`'s failure line was already printed by StartProgress. A
1302 // spawn that never happened has nothing printed yet, and 1216 // spawn that never happened has nothing printed yet, and
1303 // `std.debug.print` rather than `emitFmt` because an exe path can run to 1217 // `std.debug.print` rather than `emitFmt` because an exe path can run to
1304 // `max_path_bytes` and a fixed buffer would drop the whole line. 1218 // `max_path_bytes` and a fixed buffer would drop the whole line.
@@ -1334,7 +1248,7 @@ fn keygen(alloc: std.mem.Allocator) !u8 {
1334 1248
1335 /// The tests must speak argsAlloc's type: a slice of 1249 /// The tests must speak argsAlloc's type: a slice of
1336 /// sentinel-terminated strings. 1250 /// sentinel-terminated strings.
1337 fn parse(comptime argv: []const [:0]const u8) ParseResult { 1251 fn parse(comptime argv: []const [:0]const u8) DaemonInvocation {
1338 return parseArgs(argv); 1252 return parseArgs(argv);
1339 } 1253 }
1340 1254
@@ -1346,92 +1260,88 @@ fn nameZ(comptime name: []const u8) [:0]const u8 {
1346 1260
1347 test "parseArgs: subcommands and their existing flags" { 1261 test "parseArgs: subcommands and their existing flags" {
1348 const r = parse(&.{ "d", "start" }); 1262 const r = parse(&.{ "d", "start" });
1349 try std.testing.expect(r == .ok); 1263 try std.testing.expect(r == .command);
1350 try std.testing.expect(r.ok._cmd == .start); 1264 try std.testing.expect(r.command._cmd == .start);
1351 try std.testing.expect(r.ok.sock == null); 1265 try std.testing.expect(r.command.sock == null);
1352 try std.testing.expectEqual(@as(u16, 80), r.ok.cols); 1266 try std.testing.expectEqual(@as(u16, 80), r.command.cols);
1353 try std.testing.expectEqual(@as(u16, 24), r.ok.rows); 1267 try std.testing.expectEqual(@as(u16, 24), r.command.rows);
1354 1268
1355 const d = parse(&.{ "d", "dump", "--vt", "--sock", "/tmp/x.sock" }); 1269 const d = parse(&.{ "d", "dump", "--vt", "--sock", "/tmp/x.sock" });
1356 try std.testing.expect(d.ok._cmd == .dump); 1270 try std.testing.expect(d.command._cmd == .dump);
1357 try std.testing.expect(d.ok.vt); 1271 try std.testing.expect(d.command.vt);
1358 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); 1272 try std.testing.expectEqualStrings("/tmp/x.sock", d.command.sock.?);
1359 // No --session named: nothing to validate, and `dump` spells the absence 1273 // No --session named: nothing to validate, and `dump` spells the absence
1360 // on the wire as the empty tail. 1274 // on the wire as the empty tail.
1361 try std.testing.expect(d.ok.session == null); 1275 try std.testing.expect(d.command.session == null);
1362 1276
1363 const g = parse(&.{ "d", "start", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" }); 1277 const g = parse(&.{ "d", "start", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" });
1364 try std.testing.expectEqual(@as(u16, 120), g.ok.cols); 1278 try std.testing.expectEqual(@as(u16, 120), g.command.cols);
1365 try std.testing.expectEqual(@as(u16, 40), g.ok.rows); 1279 try std.testing.expectEqual(@as(u16, 40), g.command.rows);
1366 try std.testing.expectEqualStrings("/bin/dash", g.ok.shell.?); 1280 try std.testing.expectEqualStrings("/bin/dash", g.command.shell.?);
1367 1281
1368 try std.testing.expect(parse(&.{"d"}).err == .no_command); 1282 try std.testing.expect(parse(&.{"d"}).usage == .no_command);
1369 try std.testing.expect(parse(&.{ "d", "wat" }).err == .unknown_command); 1283 try std.testing.expect(parse(&.{ "d", "wat" }).usage == .unknown_command);
1370 try std.testing.expect(parse(&.{ "d", "start", "--wat" }).err == .unknown_arg); 1284 try std.testing.expect(parse(&.{ "d", "start", "--wat" }).usage == .unknown_arg);
1371 } 1285 }
1372 1286
1373 test "parse: dump --session rides into the payload" { 1287 test "parse: dump --session rides into the payload" {
1374 const d = parse(&.{ "d", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); 1288 const d = parse(&.{ "d", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
1375 try std.testing.expect(d == .ok); 1289 try std.testing.expect(d == .command);
1376 try std.testing.expectEqualStrings("b", d.ok.session.?.name); 1290 try std.testing.expectEqualStrings("b", d.command.session.?.name);
1377 1291
1378 // A name no tool could ever address is refused at parse — usage on 1292 // Reject an unaddressable session name before encoding it. The parse result
1379 // stderr, never carried to the wire as a payload nothing can look up. 1293 // identifies the flag whose value failed validation.
1380 // The flag is what the refusal names now, so a user is told which of
1381 // several values on the line was the one refused.
1382 const bad = parse(&.{ "d", "dump", "--session", "has space" }); 1294 const bad = parse(&.{ "d", "dump", "--session", "has space" });
1383 try std.testing.expect(bad.err == .bad_value); 1295 try std.testing.expect(bad.usage == .bad_value);
1384 try std.testing.expectEqualStrings("--session", bad.err.bad_value); 1296 try std.testing.expectEqualStrings("--session", bad.usage.bad_value);
1385 } 1297 }
1386 1298
1387 test "parseArgs: --key without --quic is refused; --quic alone defers to main" { 1299 test "parseArgs: --key without --quic is refused; --quic alone defers to main" {
1388 const both = parse(&.{ "d", "start", "--quic", "0.0.0.0:4433", "--key", "/k" }); 1300 const both = parse(&.{ "d", "start", "--quic", "0.0.0.0:4433", "--key", "/k" });
1389 try std.testing.expect(both == .ok); 1301 try std.testing.expect(both == .command);
1390 try std.testing.expectEqualStrings("0.0.0.0:4433", both.ok.quic.?); 1302 try std.testing.expectEqualStrings("0.0.0.0:4433", both.command.quic.?);
1391 try std.testing.expectEqualStrings("/k", both.ok.key.?); 1303 try std.testing.expectEqualStrings("/k", both.command.key.?);
1392 1304
1393 // --quic without --key is no longer a parse error: main resolves 1305 // --quic without --key is no longer a parse error: main resolves
1394 // MUX_KEY_FILE and the default path, and parse cannot see either. 1306 // MUX_KEY_FILE and the default path, and parse cannot see either.
1395 const deferred = parse(&.{ "d", "start", "--quic", "0.0.0.0:4433" }); 1307 const deferred = parse(&.{ "d", "start", "--quic", "0.0.0.0:4433" });
1396 try std.testing.expect(deferred == .ok); 1308 try std.testing.expect(deferred == .command);
1397 try std.testing.expect(deferred.ok.key == null); 1309 try std.testing.expect(deferred.command.key == null);
1398 1310
1399 // A key with nowhere to listen is still a mistake with no reading that 1311 // A key with nowhere to listen is still a mistake with no reading that
1400 // makes it sensible, and parse can see the whole of it. 1312 // makes it sensible, and parse can see the whole of it.
1401 try std.testing.expect(parse(&.{ "d", "start", "--key", "/k" }).err == .key_without_quic); 1313 try std.testing.expect(parse(&.{ "d", "start", "--key", "/k" }).usage == .key_without_quic);
1402 1314
1403 // Neither is the ordinary case and must stay silent. 1315 // Neither is the ordinary case and must stay silent.
1404 const neither = parse(&.{ "d", "start" }); 1316 const neither = parse(&.{ "d", "start" });
1405 try std.testing.expect(neither.ok.quic == null); 1317 try std.testing.expect(neither.command.quic == null);
1406 try std.testing.expect(neither.ok.key == null); 1318 try std.testing.expect(neither.command.key == null);
1407 } 1319 }
1408 1320
1409 test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" { 1321 test "parseArgs: --quic-idle-ms defaults, parses, and rejects invalid values" {
1410 const dflt = parse(&.{ "d", "start", "--quic", "127.0.0.1:1", "--key", "/k" }); 1322 const dflt = parse(&.{ "d", "start", "--quic", "127.0.0.1:1", "--key", "/k" });
1411 // Spelled out rather than written `quic.default_idle_ms`: asserting 1323 // Compare with the documented literal so this test detects an accidental
1412 // against the same constant the parser reads would hold for any value, 1324 // change to the source default.
1413 // so it could never catch the number changing. 1325 try std.testing.expectEqual(@as(u32, 15_000), dflt.command.quic_idle_ms.ms);
1414 try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms.ms);
1415 1326
1416 const set = parse(&.{ "d", "start", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" }); 1327 const set = parse(&.{ "d", "start", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" });
1417 try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms.ms); 1328 try std.testing.expectEqual(@as(u32, 2500), set.command.quic_idle_ms.ms);
1418 1329
1419 // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone 1330 // ngtcp2 interprets zero as no timeout, which would invert the user's
1420 // typing a timeout of zero is asking for, so it is refused rather than 1331 // request for an immediate timeout, so reject it.
1421 // silently inverted. 1332 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "0" }).usage == .bad_value);
1422 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "0" }).err == .bad_value); 1333 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "soon" }).usage == .bad_value);
1423 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "soon" }).err == .bad_value); 1334 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "-5" }).usage == .bad_value);
1424 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "-5" }).err == .bad_value); 1335 // Reject values wider than u32 before converting milliseconds to
1425 // Wider than u32: refused at the parse rather than overflowing where it 1336 // nanoseconds.
1426 // is multiplied out to nanoseconds. 1337 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "99999999999" }).usage == .bad_value);
1427 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "99999999999" }).err == .bad_value);
1428 // The idle flag alone does not turn QUIC on, and must not smuggle the 1338 // The idle flag alone does not turn QUIC on, and must not smuggle the
1429 // both-or-neither rule past the check. 1339 // both-or-neither rule past the check.
1430 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "2500" }) == .ok); 1340 try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "2500" }) == .command);
1431 1341
1432 // Same treatment for the numbers that were already here. 1342 // Same treatment for the numbers that were already here.
1433 try std.testing.expect(parse(&.{ "d", "start", "--cols", "wide" }).err == .bad_value); 1343 try std.testing.expect(parse(&.{ "d", "start", "--cols", "wide" }).usage == .bad_value);
1434 try std.testing.expect(parse(&.{ "d", "start", "--rows", "99999" }).err == .bad_value); 1344 try std.testing.expect(parse(&.{ "d", "start", "--rows", "99999" }).usage == .bad_value);
1435 } 1345 }
1436 1346
1437 test "parseArgs: a value-taking flag at the end of argv names itself" { 1347 test "parseArgs: a value-taking flag at the end of argv names itself" {
@@ -1439,16 +1349,14 @@ test "parseArgs: a value-taking flag at the end of argv names itself" {
1439 // rather than the missing value. 1349 // rather than the missing value.
1440 inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms", "--session", "--resume-fd", "--resume-fail-at" }) |flag| { 1350 inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms", "--session", "--resume-fd", "--resume-fail-at" }) |flag| {
1441 const r = parse(&.{ "d", "start", flag }); 1351 const r = parse(&.{ "d", "start", flag });
1442 try std.testing.expect(r.err == .missing_value); 1352 try std.testing.expect(r.usage == .missing_value);
1443 try std.testing.expectEqualStrings(flag, r.err.missing_value); 1353 try std.testing.expectEqualStrings(flag, r.usage.missing_value);
1444 } 1354 }
1445 } 1355 }
1446 1356
1447 // The leg the table cannot check itself: `usage` is hand-tuned prose, so a 1357 // The table cannot verify hand-written usage text by itself. Anchor each check
1448 // verb added as a row and forgotten in the text would ship undocumented. This 1358 // to the command position so incidental mentions, such as "proxy" in another
1449 // pins the CROSS-CHECK, not the wording. Anchored to the command POSITION, 1359 // description, do not count as documentation.
1450 // because the prose says these words in passing — `endpoint`'s parenthetical
1451 // contains "proxy", which an unanchored search would call documented.
1452 test "usage names every subcommand" { 1360 test "usage names every subcommand" {
1453 inline for (specs) |s| { 1361 inline for (specs) |s| {
1454 const named = std.mem.indexOf(u8, usage, "\n mux d " ++ s.name) != null; 1362 const named = std.mem.indexOf(u8, usage, "\n mux d " ++ s.name) != null;
@@ -1459,7 +1367,7 @@ test "usage names every subcommand" {
1459 } 1367 }
1460 } 1368 }
1461 1369
1462 test "parseBindAddr: a hostname is refused, not resolved" { 1370 test "parseBindAddr: a hostname is rejected rather than resolved" {
1463 const a = try parseBindAddr("127.0.0.1:4433"); 1371 const a = try parseBindAddr("127.0.0.1:4433");
1464 try std.testing.expectEqual(@as(u16, 4433), a.getPort()); 1372 try std.testing.expectEqual(@as(u16, 4433), a.getPort());
1465 1373
@@ -1467,8 +1375,8 @@ test "parseBindAddr: a hostname is refused, not resolved" {
1467 try std.testing.expectEqual(@as(u16, 4433), six.getPort()); 1375 try std.testing.expectEqual(@as(u16, 4433), six.getPort());
1468 try std.testing.expect(six.any.family == std.posix.AF.INET6); 1376 try std.testing.expect(six.any.family == std.posix.AF.INET6);
1469 1377
1470 // No DNS at bind time, deliberately: this is the address to bind, and a 1378 // Bind configuration requires one numeric address, not a hostname that may
1471 // name resolving to several is a question rather than an answer. 1379 // resolve to several addresses.
1472 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433"))); 1380 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433")));
1473 } 1381 }
1474 1382
@@ -1485,130 +1393,118 @@ test "keygen: a generated key loads through quic.Key.load" {
1485 1393
1486 test "parseArgs: --version is a command, not a flag on one" { 1394 test "parseArgs: --version is a command, not a flag on one" {
1487 const r = parse(&.{ "d", "--version" }); 1395 const r = parse(&.{ "d", "--version" });
1488 try std.testing.expect(r == .ok); 1396 try std.testing.expect(r == .command);
1489 try std.testing.expect(r.ok._cmd == .version); 1397 try std.testing.expect(r.command._cmd == .version);
1490 1398
1491 // Typed onto a verb it becomes that same command, so `mux d start 1399 // Version takes precedence when attached to another command. Test parsing
1492 // --version` answers instead of refusing an unknown flag. Asserted on 1400 // directly because `main` would write to the test runner's stdout channel.
1493 // the parse rather than on `main`, which writes the version to STDOUT
1494 // and would hang the build runner's IPC.
1495 const on_start = parse(&.{ "d", "start", "--sock", "/x", "--version" }); 1401 const on_start = parse(&.{ "d", "start", "--sock", "/x", "--version" });
1496 try std.testing.expect(on_start == .ok); 1402 try std.testing.expect(on_start == .command);
1497 try std.testing.expect(on_start.ok._cmd == .version); 1403 try std.testing.expect(on_start.command._cmd == .version);
1498 } 1404 }
1499 1405
1500 test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" { 1406 test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" {
1501 const bare = parse(&.{ "d", "--help" }); 1407 const bare = parse(&.{ "d", "--help" });
1502 try std.testing.expect(bare == .ok); 1408 try std.testing.expect(bare == .command);
1503 try std.testing.expect(bare.ok._cmd == .help); 1409 try std.testing.expect(bare.command._cmd == .help);
1504 1410
1505 // On a subcommand it is an outcome of the flag parse rather than a row, 1411 // On a subcommand, help comes from flag parsing and takes precedence over
1506 // and it must outrank the grammar: `--sock` here is still waiting for a 1412 // adjacent syntax such as a missing `--sock` value.
1507 // value, and asking for the usage is not a way to mistype one. 1413 try std.testing.expect(parse(&.{ "d", "start", "--help" }).usage == .help);
1508 try std.testing.expect(parse(&.{ "d", "start", "--help" }).err == .help); 1414 try std.testing.expect(parse(&.{ "d", "dump", "-h", "--sock", "/x" }).usage == .help);
1509 try std.testing.expect(parse(&.{ "d", "dump", "-h", "--sock", "/x" }).err == .help); 1415 try std.testing.expect(parse(&.{ "d", "start", "--sock", "--help" }).usage == .help);
1510 try std.testing.expect(parse(&.{ "d", "start", "--sock", "--help" }).err == .help); 1416
1511 1417 // Use `usageCode` because `usageExit` writes to the test runner's stdout
1512 // Asked of `usageCode`, not `usageExit`: the latter writes to STDOUT, which 1418 // protocol channel.
1513 // under `zig build test` is the runner's IPC channel, and the step hangs.
1514 try std.testing.expectEqual(@as(u8, 0), usageCode(.help)); 1419 try std.testing.expectEqual(@as(u8, 0), usageCode(.help));
1515 try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command)); 1420 try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command));
1516 } 1421 }
1517 1422
1518 test "parseArgs: keygen takes no flags" { 1423 test "parseArgs: keygen takes no flags" {
1519 const r = parse(&.{ "d", "keygen" }); 1424 const r = parse(&.{ "d", "keygen" });
1520 try std.testing.expect(r == .ok); 1425 try std.testing.expect(r == .command);
1521 try std.testing.expect(r.ok._cmd == .keygen); 1426 try std.testing.expect(r.command._cmd == .keygen);
1522 try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).err == .unknown_arg); 1427 try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).usage == .unknown_arg);
1523 } 1428 }
1524 1429
1525 test "parseArgs: -d is a word in `start`'s second slot, and everything after it is the daemon's" { 1430 test "parseArgs: -d is a word in `start`'s second slot, and everything after it is the daemon's" {
1526 const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); 1431 const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" });
1527 try std.testing.expect(r == .ok); 1432 try std.testing.expect(r == .command);
1528 try std.testing.expect(r.ok._cmd == .start); 1433 try std.testing.expect(r.command._cmd == .start);
1529 try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?); 1434 try std.testing.expectEqualStrings("/tmp/x.sock", r.command.sock.?);
1530 try std.testing.expectEqual(@as(u16, 100), r.ok.cols); 1435 try std.testing.expectEqual(@as(u16, 100), r.command.cols);
1531 // Off unless typed: the process that binds is the one that was typed, 1436 // Foreground mode is the default so startup failures remain visible in the
1532 // and a `start` that forked by default would put the daemon somewhere 1437 // invoking shell.
1533 // the user's shell cannot see it fail. 1438 try std.testing.expect(!r.command._detach);
1534 try std.testing.expect(!r.ok._detach); 1439
1535 1440 // Remove only the detach word; forward the remaining argv unchanged. The
1536 // The peel takes the word and nothing else — args[3..] is the child's 1441 // e2e test verifies the child command line through `/proc/PID/cmdline`.
1537 // line verbatim, which e2e_03 pins whole off /proc/PID/cmdline.
1538 const d = parse(&.{ "d", "start", "-d", "--sock", "/tmp/x.sock" }); 1442 const d = parse(&.{ "d", "start", "-d", "--sock", "/tmp/x.sock" });
1539 try std.testing.expect(d.ok._detach); 1443 try std.testing.expect(d.command._detach);
1540 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); 1444 try std.testing.expectEqualStrings("/tmp/x.sock", d.command.sock.?);
1541 try std.testing.expect(parse(&.{ "d", "start", "--detach", "--cols", "100" }).ok._detach); 1445 try std.testing.expect(parse(&.{ "d", "start", "--detach", "--cols", "100" }).command._detach);
1542 1446
1543 // One slot, so a second `-d` is a flag nothing owns rather than a 1447 // A second `-d` is unknown rather than silently removed.
1544 // word silently dropped from the child's line.
1545 const twice = parse(&.{ "d", "start", "-d", "-d" }); 1448 const twice = parse(&.{ "d", "start", "-d", "-d" });
1546 try std.testing.expectEqualStrings("-d", twice.err.unknown_arg); 1449 try std.testing.expectEqualStrings("-d", twice.usage.unknown_arg);
1547 1450
1548 // Late is not a spelling of first. The usage line draws `-d` where it 1451 // Detach is positional and is not recognized after other start arguments.
1549 // is read, and a walk that found it anywhere would have to re-derive
1550 // cliflags' arity to know that the `-d` below is a shell's name.
1551 const late = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "-d" }); 1452 const late = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "-d" });
1552 try std.testing.expect(late == .err); 1453 try std.testing.expect(late == .usage);
1553 try std.testing.expectEqualStrings("-d", late.err.unknown_arg); 1454 try std.testing.expectEqualStrings("-d", late.usage.unknown_arg);
1554 try std.testing.expectEqual(@as(u8, 2), usageCode(late.err)); 1455 try std.testing.expectEqual(@as(u8, 2), usageCode(late.usage));
1555 1456
1556 // A value is never a flag: `--shell -d` names a shell called `-d`, and 1457 // A token consumed as a value is never reinterpreted as a flag.
1557 // it rides into the child untouched because nothing rewrites the line.
1558 const shell = parse(&.{ "d", "start", "-d", "--shell", "-d" }); 1458 const shell = parse(&.{ "d", "start", "-d", "--shell", "-d" });
1559 try std.testing.expect(shell.ok._detach); 1459 try std.testing.expect(shell.command._detach);
1560 try std.testing.expectEqualStrings("-d", shell.ok.shell.?); 1460 try std.testing.expectEqualStrings("-d", shell.command.shell.?);
1561 } 1461 }
1562 1462
1563 test "parseArgs: stop is a command and takes --sock" { 1463 test "parseArgs: stop is a command and takes --sock" {
1564 const r = parse(&.{ "d", "stop" }); 1464 const r = parse(&.{ "d", "stop" });
1565 try std.testing.expect(r == .ok); 1465 try std.testing.expect(r == .command);
1566 try std.testing.expect(r.ok._cmd == .stop); 1466 try std.testing.expect(r.command._cmd == .stop);
1567 try std.testing.expect(r.ok.sock == null); 1467 try std.testing.expect(r.command.sock == null);
1568 1468
1569 const s = parse(&.{ "d", "stop", "--sock", "/tmp/x.sock" }); 1469 const s = parse(&.{ "d", "stop", "--sock", "/tmp/x.sock" });
1570 try std.testing.expect(s.ok._cmd == .stop); 1470 try std.testing.expect(s.command._cmd == .stop);
1571 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1471 try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?);
1572 } 1472 }
1573 1473
1574 test "parseArgs: endpoint is a command and takes --sock" { 1474 test "parseArgs: endpoint is a command and takes --sock" {
1575 const r = parse(&.{ "d", "endpoint" }); 1475 const r = parse(&.{ "d", "endpoint" });
1576 try std.testing.expect(r == .ok); 1476 try std.testing.expect(r == .command);
1577 try std.testing.expect(r.ok._cmd == .endpoint); 1477 try std.testing.expect(r.command._cmd == .endpoint);
1578 try std.testing.expect(r.ok.sock == null); 1478 try std.testing.expect(r.command.sock == null);
1579 1479
1580 const s = parse(&.{ "d", "endpoint", "--sock", "/tmp/x.sock" }); 1480 const s = parse(&.{ "d", "endpoint", "--sock", "/tmp/x.sock" });
1581 try std.testing.expect(s.ok._cmd == .endpoint); 1481 try std.testing.expect(s.command._cmd == .endpoint);
1582 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1482 try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?);
1583 1483
1584 // An unknown flag is refused, as for every command. Only UNKNOWN, though: 1484 // Unknown flags fail, but shared value-taking flags still parse for this
1585 // the value-taking flags share one loop, so `endpoint --cols 100` parses 1485 // command and are ignored. Only `keygen` rejects all trailing arguments.
1586 // and is ignored. `keygen` is the sole verb that narrows its own surface. 1486 try std.testing.expect(parse(&.{ "d", "endpoint", "--quiet" }).usage == .unknown_arg);
1587 try std.testing.expect(parse(&.{ "d", "endpoint", "--quiet" }).err == .unknown_arg); 1487 try std.testing.expect(parse(&.{ "d", "endpoint", "--cols", "100" }) == .command);
1588 try std.testing.expect(parse(&.{ "d", "endpoint", "--cols", "100" }) == .ok);
1589 const missing = parse(&.{ "d", "endpoint", "--sock" }); 1488 const missing = parse(&.{ "d", "endpoint", "--sock" });
1590 try std.testing.expect(missing.err == .missing_value); 1489 try std.testing.expect(missing.usage == .missing_value);
1591 try std.testing.expectEqualStrings("--sock", missing.err.missing_value); 1490 try std.testing.expectEqualStrings("--sock", missing.usage.missing_value);
1592 } 1491 }
1593 1492
1594 test "parseArgs: a verb-scoped word is refused on every verb but its own" { 1493 test "parseArgs: a command-scoped option is rejected by every other command" {
1595 // The flag that makes a cold `mux HOST` one ssh run: the client no 1494 // `--start` lets a cold `mux HOST` start and query the daemon in one SSH
1596 // longer decides to start, so the word has to reach this side. 1495 // invocation.
1597 const on = parse(&.{ "d", "endpoint", "--start" }); 1496 const on = parse(&.{ "d", "endpoint", "--start" });
1598 try std.testing.expect(on == .ok); 1497 try std.testing.expect(on == .command);
1599 try std.testing.expect(on.ok._cmd == .endpoint); 1498 try std.testing.expect(on.command._cmd == .endpoint);
1600 try std.testing.expect(on.ok.start); 1499 try std.testing.expect(on.command.start);
1601 // Off unless typed. A reading verb that inherited a true here would 1500 // Polling omits `--start`; inheriting true would start a daemon during every
1602 // start a daemon on every wall poll — the bug this whole rule exists 1501 // read-only wall poll.
1603 // to keep dead. 1502 try std.testing.expect(!parse(&.{ "d", "endpoint" }).command.start);
1604 try std.testing.expect(!parse(&.{ "d", "endpoint" }).ok.start); 1503
1605 1504 // Check every command-table row so newly added commands cannot accidentally
1606 // Every OTHER verb, off the TABLE: a flag scoped by a hand-written 1505 // inherit command-specific options. Rows that intentionally ignore all
1607 // `!= .endpoint` is scoped for whichever verb the author thought of, and a 1506 // arguments are excluded.
1608 // verb added later inherits nothing. `.ignored` rows are excluded because 1507 const scoped = .{ .{ "--start", DaemonCommand.endpoint }, .{ "-d", DaemonCommand.start } };
1609 // their row says so. `-d` walks the same table, so this asks whether the
1610 // structure holds rather than whether a refusal was remembered.
1611 const scoped = .{ .{ "--start", Cmd.endpoint }, .{ "-d", Cmd.start } };
1612 inline for (specs) |s| { 1508 inline for (specs) |s| {
1613 if (s.flags == .ignored) continue; 1509 if (s.flags == .ignored) continue;
1614 inline for (scoped) |w| { 1510 inline for (scoped) |w| {
@@ -1616,12 +1512,12 @@ test "parseArgs: a verb-scoped word is refused on every verb but its own" {
1616 const r = parseArgs(&.{ "d", nameZ(s.name), w[0] }); 1512 const r = parseArgs(&.{ "d", nameZ(s.name), w[0] });
1617 // expect() alone prints "expected true", which does not 1513 // expect() alone prints "expected true", which does not
1618 // say which verb let which word through. 1514 // say which verb let which word through.
1619 if (r != .err) std.debug.print( 1515 if (r != .usage) std.debug.print(
1620 "`mux d {s} {s}` was accepted; {s} belongs to one verb\n", 1516 "`mux d {s} {s}` was accepted; {s} belongs to one verb\n",
1621 .{ s.name, w[0], w[0] }, 1517 .{ s.name, w[0], w[0] },
1622 ); 1518 );
1623 try std.testing.expect(r == .err); 1519 try std.testing.expect(r == .usage);
1624 try std.testing.expectEqual(@as(u8, 2), usageCode(r.err)); 1520 try std.testing.expectEqual(@as(u8, 2), usageCode(r.usage));
1625 } 1521 }
1626 } 1522 }
1627 } 1523 }
@@ -1629,37 +1525,34 @@ test "parseArgs: a verb-scoped word is refused on every verb but its own" {
1629 1525
1630 test "parseArgs: start --resume-fd N --check is the old daemon's dry run" { 1526 test "parseArgs: start --resume-fd N --check is the old daemon's dry run" {
1631 const r = parse(&.{ "d", "start", "--resume-fd", "7", "--check" }); 1527 const r = parse(&.{ "d", "start", "--resume-fd", "7", "--check" });
1632 try std.testing.expect(r == .ok); 1528 try std.testing.expect(r == .command);
1633 try std.testing.expect(r.ok._cmd == .start); 1529 try std.testing.expect(r.command._cmd == .start);
1634 try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.ok.resume_fd.?); 1530 try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.command.resume_fd.?);
1635 try std.testing.expect(r.ok.check); 1531 try std.testing.expect(r.command.check);
1636 1532
1637 // A number, like --cols: an fd that is not one would be read as a 1533 // The manifest descriptor must parse as an integer.
1638 // descriptor the daemon never passed. 1534 try std.testing.expect(parse(&.{ "d", "start", "--resume-fd", "x" }).usage == .bad_value);
1639 try std.testing.expect(parse(&.{ "d", "start", "--resume-fd", "x" }).err == .bad_value);
1640 1535
1641 const f = parse(&.{ "d", "start", "--resume-fd", "3", "--resume-fail-at", "session" }); 1536 const f = parse(&.{ "d", "start", "--resume-fd", "3", "--resume-fail-at", "session" });
1642 try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?); 1537 try std.testing.expectEqualStrings("session", f.command.resume_fail_at.?);
1643 1538
1644 // Neither flag is the ordinary start, and both must stay off there — 1539 // Ordinary startup must not enter resume or validation mode.
1645 // a `start` that thought it was resuming would adopt nothing and serve
1646 // nothing.
1647 const plain = parse(&.{ "d", "start" }); 1540 const plain = parse(&.{ "d", "start" });
1648 try std.testing.expect(plain.ok.resume_fd == null); 1541 try std.testing.expect(plain.command.resume_fd == null);
1649 try std.testing.expect(!plain.ok.check); 1542 try std.testing.expect(!plain.command.check);
1650 } 1543 }
1651 1544
1652 test "failAtFrom: the flag beats the environment, and neither is no abort" { 1545 test "failAtFrom: the flag beats the environment, and neither is no abort" {
1653 try std.testing.expectEqualStrings("session", failAtFrom("session", "daemon")); 1546 try std.testing.expectEqualStrings("session", failAtFrom("session", "daemon"));
1654 // The environment is how an e2e leg arms an abort at all: the exec 1547 // E2e failure injection uses the environment because upgrade builds fixed
1655 // builds a fixed argv, so there is no flag for it to put a word in. 1548 // argv.
1656 try std.testing.expectEqualStrings("daemon", failAtFrom(null, "daemon")); 1549 try std.testing.expectEqualStrings("daemon", failAtFrom(null, "daemon"));
1657 try std.testing.expectEqualStrings("", failAtFrom(null, null)); 1550 try std.testing.expectEqualStrings("", failAtFrom(null, null));
1658 } 1551 }
1659 1552
1660 test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it" { 1553 test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it" {
1661 // Inherited, the old binary would abort at the same section, find the 1554 // The rollback target must not inherit the failure injection that triggered
1662 // marker, give up, and take every shell with it. 1555 // rollback.
1663 try std.testing.expect(!rollbackKeepsEnv("MUX_RESUME_FAIL_AT=session")); 1556 try std.testing.expect(!rollbackKeepsEnv("MUX_RESUME_FAIL_AT=session"));
1664 try std.testing.expect(rollbackKeepsEnv("MUX_SHELL_INTEGRATION=1")); 1557 try std.testing.expect(rollbackKeepsEnv("MUX_SHELL_INTEGRATION=1"));
1665 // The name is a prefix of nothing else, but a variable that merely 1558 // The name is a prefix of nothing else, but a variable that merely
@@ -1669,16 +1562,15 @@ test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it"
1669 1562
1670 test "parseArgs: upgrade is a command, and same-version is a flag it takes" { 1563 test "parseArgs: upgrade is a command, and same-version is a flag it takes" {
1671 const r = parse(&.{ "d", "upgrade" }); 1564 const r = parse(&.{ "d", "upgrade" });
1672 try std.testing.expect(r == .ok); 1565 try std.testing.expect(r == .command);
1673 try std.testing.expect(r.ok._cmd == .upgrade); 1566 try std.testing.expect(r.command._cmd == .upgrade);
1674 // Off unless asked: the skew rule is strictly-newer, and an operator who 1567 // Strictly newer remains the default unless the exception is explicit.
1675 // did not name the exception must not get it. 1568 try std.testing.expect(!r.command.allow_same_version);
1676 try std.testing.expect(!r.ok.allow_same_version);
1677 1569
1678 const s = parse(&.{ "d", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" }); 1570 const s = parse(&.{ "d", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" });
1679 try std.testing.expect(s.ok._cmd == .upgrade); 1571 try std.testing.expect(s.command._cmd == .upgrade);
1680 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1572 try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?);
1681 try std.testing.expect(s.ok.allow_same_version); 1573 try std.testing.expect(s.command.allow_same_version);
1682 } 1574 }
1683 1575
1684 test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { 1576 test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" {
@@ -1691,10 +1583,8 @@ test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abor
1691 defer buf.deinit(alloc); 1583 defer buf.deinit(alloc);
1692 try upgrade.writeManifest(buf.writer(alloc), alloc, .{ 1584 try upgrade.writeManifest(buf.writer(alloc), alloc, .{
1693 .writer_version = "0.0.1-99", 1585 .writer_version = "0.0.1-99",
1694 // Deliberately a path that cannot exec: a --check that rolled back 1586 // Use a non-executable rollback target so an incorrect rollback from
1695 // would exec the OLD binary out of a probe the old daemon runs as a 1587 // validation fails locally instead of replacing the test runner.
1696 // CHILD, and a rollback target that cannot be exec'd fails this
1697 // test instead of replacing the test runner with it.
1698 .writer_path = "/nonexistent/mux", 1588 .writer_path = "/nonexistent/mux",
1699 .sock_path = "/tmp/mux-resume-check-test.sock", 1589 .sock_path = "/tmp/mux-resume-check-test.sock",
1700 .listener_fd = -1, 1590 .listener_fd = -1,
@@ -1731,15 +1621,12 @@ test "announceKeyFrom: MUX_KEY_FILE wins, and the default it skipped is not crea
1731 1621
1732 const r = announceKeyFrom(env, dflt); 1622 const r = announceKeyFrom(env, dflt);
1733 try std.testing.expect(r == .key); 1623 try std.testing.expect(r == .key);
1734 // The key it returned is the file it was pointed at, not merely some 1624 // The loaded key must match the selected environment file exactly.
1735 // key: the announce is only worth anything if it names the one the
1736 // daemon will authenticate with.
1737 var on_disk: [32]u8 = undefined; 1625 var on_disk: [32]u8 = undefined;
1738 try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes); 1626 try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes);
1739 1627
1740 // The default is not merely unused, it is uncreated. Creating a key 1628 // Selecting an environment key must not create an unused default credential
1741 // beside one the user named would leave a credential nobody asked for 1629 // that a later daemon might select.
1742 // and, worse, one the daemon might later pick up instead.
1743 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{})); 1630 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{}));
1744 } 1631 }
1745 1632
@@ -1751,15 +1638,13 @@ test "announceKeyFrom: the default is created when absent, and no path at all is
1751 var dbuf: [280]u8 = undefined; 1638 var dbuf: [280]u8 = undefined;
1752 const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); 1639 const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()});
1753 1640
1754 // The mosh-server move: a fresh box gets a key rather than a lecture. 1641 // First use creates the default key automatically.
1755 const made = announceKeyFrom(null, dflt); 1642 const made = announceKeyFrom(null, dflt);
1756 try std.testing.expect(made == .key); 1643 try std.testing.expect(made == .key);
1757 const st = try std.fs.cwd().statFile(dflt); 1644 const st = try std.fs.cwd().statFile(dflt);
1758 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777))); 1645 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777)));
1759 1646
1760 // A second call loads the SAME key rather than rotating it: the 1647 // Subsequent calls load the same key instead of rotating it.
1761 // announce must name what the daemon will authenticate with, and this
1762 // process runs once per attach.
1763 const again = announceKeyFrom(null, dflt); 1648 const again = announceKeyFrom(null, dflt);
1764 try std.testing.expect(again == .key); 1649 try std.testing.expect(again == .key);
1765 try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes); 1650 try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes);
@@ -1811,15 +1696,14 @@ test "askEndpointPort: a socket nobody serves answers 0, quickly" {
1811 try std.testing.expect(std.time.milliTimestamp() - t0 < 500); 1696 try std.testing.expect(std.time.milliTimestamp() - t0 < 500);
1812 } 1697 }
1813 1698
1814 test "endpointCmd: a box with no daemon is refused, never started — the wall polls this verb once a second per host" { 1699 test "endpointCmd: polling an absent daemon does not start one" {
1815 const testtmp = @import("testtmp"); 1700 const testtmp = @import("testtmp");
1816 var tmp = try testtmp.TmpDir.make(); 1701 var tmp = try testtmp.TmpDir.make();
1817 defer tmp.cleanup(); 1702 defer tmp.cleanup();
1818 var buf: [280]u8 = undefined; 1703 var buf: [280]u8 = undefined;
1819 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); 1704 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
1820 // A file rather than the runner's stdout, so "wrote no announce" is a 1705 // Capture output in a file so the test can verify that no announcement was
1821 // fact this can read back: the announce is the first bytes of a 1706 // written without touching the test runner's stdout.
1822 // session, and there is no session here to have any.
1823 var out_buf: [280]u8 = undefined; 1707 var out_buf: [280]u8 = undefined;
1824 const out_path = try std.fmt.bufPrint(&out_buf, "{s}/announce", .{tmp.path()}); 1708 const out_path = try std.fmt.bufPrint(&out_buf, "{s}/announce", .{tmp.path()});
1825 const out = try std.fs.cwd().createFile(out_path, .{}); 1709 const out = try std.fs.cwd().createFile(out_path, .{});
@@ -1827,14 +1711,12 @@ test "endpointCmd: a box with no daemon is refused, never started — the wall p
1827 1711
1828 try std.testing.expectEqual( 1712 try std.testing.expectEqual(
1829 @as(u8, 1), 1713 @as(u8, 1),
1830 // `false` is the poll's spelling and the one under test: the asking 1714 // False models background polling. The true startup path requires the
1831 // spelling forks a daemon, and `startDetached` takes the XDG log path 1715 // production XDG log path and is covered by e2e tests.
1832 // with no override. That half is e2e's.
1833 try endpointCmd(std.testing.allocator, sock, out.handle, false), 1716 try endpointCmd(std.testing.allocator, sock, out.handle, false),
1834 ); 1717 );
1835 // The verb READS a box without `--start`. Starting a daemon here gave a 1718 // A read-only poll must not create a daemon or default session, especially
1836 // listed machine one (and a shell in session 0) from a poll, and undid a 1719 // immediately after an explicit stop.
1837 // `mux d stop` on the next cycle a second later.
1838 try std.testing.expect(!sockpath.answers(sock)); 1720 try std.testing.expect(!sockpath.answers(sock));
1839 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size); 1721 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size);
1840 } 1722 }
@@ -1842,9 +1724,8 @@ test "endpointCmd: a box with no daemon is refused, never started — the wall p
1842 test "askOnce: a deadline gives up on silence, and no deadline waits out a late reply" { 1724 test "askOnce: a deadline gives up on silence, and no deadline waits out a late reply" {
1843 const alloc = std.testing.allocator; 1725 const alloc = std.testing.allocator;
1844 1726
1845 // A socket pair stands in for the daemon: this end asks, the test end 1727 // A socket pair models the daemon and controls whether and when a reply
1846 // decides whether anything answers and when. A `zig build test` that prints 1728 // arrives.
1847 // nothing and never returns is this block ignoring the deadline.
1848 { 1729 {
1849 var pair: [2]i32 = undefined; 1730 var pair: [2]i32 = undefined;
1850 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); 1731 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
@@ -1852,14 +1733,11 @@ test "askOnce: a deadline gives up on silence, and no deadline waits out a late
1852 defer std.posix.close(pair[1]); 1733 defer std.posix.close(pair[1]);
1853 const t0 = std.time.milliTimestamp(); 1734 const t0 = std.time.milliTimestamp();
1854 try std.testing.expect((try askOnce(alloc, pair[0], .stats_req, "", .stats_reply, 100, .is_the_answer)) == null); 1735 try std.testing.expect((try askOnce(alloc, pair[0], .stats_req, "", .stats_reply, 100, .is_the_answer)) == null);
1855 // Waited the budget out rather than reading the silence as an 1736 // Verify that silence consumes the deadline before returning null.
1856 // answer: the bounded callers turn "no reply" into a diagnosis
1857 // (an old daemon, a failed exec), which a fast null would fake.
1858 try std.testing.expect(std.time.milliTimestamp() - t0 >= 100); 1737 try std.testing.expect(std.time.milliTimestamp() - t0 >= 100);
1859 } 1738 }
1860 1739
1861 // The unbounded claim: a daemon that is slow to answer is WAITED for 1740 // Without a deadline, wait for a delayed reply.
1862 // (`oneShotQuery` owns the why).
1863 { 1741 {
1864 var pair: [2]i32 = undefined; 1742 var pair: [2]i32 = undefined;
1865 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); 1743 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
@@ -1867,8 +1745,7 @@ test "askOnce: a deadline gives up on silence, and no deadline waits out a late
1867 const Late = struct { 1745 const Late = struct {
1868 fn run(fd: std.posix.fd_t) void { 1746 fn run(fd: std.posix.fd_t) void {
1869 std.Thread.sleep(150 * std.time.ns_per_ms); 1747 std.Thread.sleep(150 * std.time.ns_per_ms);
1870 // Another type first: the wait is for the type asked for, 1748 // Ignore an unrelated frame before returning the requested type.
1871 // not for the next thing the daemon happens to say.
1872 proto.writeFrame(fd, .stats_req, "") catch {}; 1749 proto.writeFrame(fd, .stats_req, "") catch {};
1873 proto.writeFrame(fd, .stats_reply, "late") catch {}; 1750 proto.writeFrame(fd, .stats_reply, "late") catch {};
1874 std.posix.close(fd); 1751 std.posix.close(fd);
@@ -1887,9 +1764,8 @@ test "askOnce: a deadline gives up on silence, and no deadline waits out a late
1887 test "askOnce: an empty payload is the answer for stats and not for upgrade" { 1764 test "askOnce: an empty payload is the answer for stats and not for upgrade" {
1888 const alloc = std.testing.allocator; 1765 const alloc = std.testing.allocator;
1889 1766
1890 // Both readings off ONE conversation: the same two frames, asked for 1767 // Exercise both policies against the same pair of reply frames.
1891 // twice, so a helper that hard-coded either answer fails one arm. 1768 for ([_]EmptyPayloadPolicy{ .is_the_answer, .keeps_waiting }) |empty| {
1892 for ([_]EmptyPayload{ .is_the_answer, .keeps_waiting }) |empty| {
1893 var pair: [2]i32 = undefined; 1769 var pair: [2]i32 = undefined;
1894 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); 1770 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
1895 defer std.posix.close(pair[0]); 1771 defer std.posix.close(pair[0]);
@@ -1899,8 +1775,8 @@ test "askOnce: an empty payload is the answer for stats and not for upgrade" {
1899 1775
1900 const frame = (try askOnce(alloc, pair[0], .upgrade_req, "", .upgrade_reply, 500, empty)).?; 1776 const frame = (try askOnce(alloc, pair[0], .upgrade_req, "", .upgrade_reply, 500, empty)).?;
1901 defer frame.deinit(alloc); 1777 defer frame.deinit(alloc);
1902 // `mux d upgrade` indexes `payload[0]` for the status byte, so an 1778 // Upgrade requires payload[0] for its status and therefore skips the
1903 // empty reply is a peer it cannot read, not an answer — it waits. 1779 // empty reply; stats accepts it.
1904 switch (empty) { 1780 switch (empty) {
1905 .is_the_answer => try std.testing.expectEqual(@as(usize, 0), frame.payload.len), 1781 .is_the_answer => try std.testing.expectEqual(@as(usize, 0), frame.payload.len),
1906 .keeps_waiting => try std.testing.expectEqualSlices(u8, &.{0}, frame.payload), 1782 .keeps_waiting => try std.testing.expectEqualSlices(u8, &.{0}, frame.payload),
@@ -1909,10 +1785,8 @@ test "askOnce: an empty payload is the answer for stats and not for upgrade" {
1909 } 1785 }
1910 1786
1911 test "askOnce: a frame this side cannot read is an error, never silence" { 1787 test "askOnce: a frame this side cannot read is an error, never silence" {
1912 // `mux d dump` and `mux d stats` hand this to std's main, which prints 1788 // Preserve corrupt-frame errors so dump and stats distinguish a broken
1913 // `error: <name>` before the rc 1 — the reading of a corrupt frame the 1789 // daemon response from an absent daemon.
1914 // verbs had before the round-trip was shared. Mapping it to null would
1915 // spell a broken daemon exactly like an absent one.
1916 var pair: [2]i32 = undefined; 1790 var pair: [2]i32 = undefined;
1917 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); 1791 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
1918 defer std.posix.close(pair[0]); 1792 defer std.posix.close(pair[0]);
@@ -1934,10 +1808,8 @@ test "oneShotQuery: a socket nobody serves is exit 1" {
1934 var buf: [280]u8 = undefined; 1808 var buf: [280]u8 = undefined;
1935 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); 1809 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
1936 1810
1937 // The opposite verdict from `stopCmd` on identical input, and both are 1811 // Unlike idempotent stop, dump and stats require a daemon response and
1938 // right: `stop` asked for a state the absence satisfies, while `dump` and 1812 // therefore return one for an absent socket.
1939 // `stats` asked a question nothing answered. Only the EXIT is pinned — the
1940 // line naming the verb goes to stderr, so nothing here can assert it.
1941 try std.testing.expectEqual( 1813 try std.testing.expectEqual(
1942 @as(u8, 1), 1814 @as(u8, 1),
1943 try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply), 1815 try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply),
@@ -1958,9 +1830,8 @@ test "stopCmd: a socket path with nothing on it is exit 0, not a failure" {
1958 } 1830 }
1959 1831
1960 test "peerPid: the kernel names the peer" { 1832 test "peerPid: the kernel names the peer" {
1961 // Both ends of a socketpair are this process, so the only right answer 1833 // Both socketpair endpoints belong to this process, so kernel credentials
1962 // is our own pid — and it comes from the kernel, not from anything the 1834 // must report the current pid.
1963 // peer said about itself.
1964 var sp: [2]i32 = undefined; 1835 var sp: [2]i32 = undefined;
1965 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); 1836 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
1966 defer std.posix.close(sp[0]); 1837 defer std.posix.close(sp[0]);
@@ -1969,10 +1840,9 @@ test "peerPid: the kernel names the peer" {
1969 } 1840 }
1970 1841
1971 test "waitPidGone: returns only once the OS has no such process" { 1842 test "waitPidGone: returns only once the OS has no such process" {
1972 // A grandchild, deliberately: a child of ours would linger as a zombie 1843 // Use a grandchild because a direct child can remain as a zombie that signal
1973 // that signal 0 still finds, which is the trap this test would fall 1844 // zero still finds. The shell exits after printing the sleeper pid, leaving
1974 // into if it held the dimension constant. The shell prints the pid and 1845 // the reparented process to terminate independently.
1975 // exits; the sleeper is reparented and dies on its own clock.
1976 var child = std.process.Child.init(&.{ "sh", "-c", "sleep 0.3 & echo $!" }, std.testing.allocator); 1846 var child = std.process.Child.init(&.{ "sh", "-c", "sleep 0.3 & echo $!" }, std.testing.allocator);
1977 child.stdout_behavior = .Pipe; 1847 child.stdout_behavior = .Pipe;
1978 try child.spawn(); 1848 try child.spawn();
@@ -1986,25 +1856,23 @@ test "waitPidGone: returns only once the OS has no such process" {
1986 } 1856 }
1987 1857
1988 test "shellIntegrationEnabled: an unset environment means off" { 1858 test "shellIntegrationEnabled: an unset environment means off" {
1989 // The daily-driver default. The injection is not free — the zsh shim costs 1859 // Default off because the zsh integration changes `~/.zshenv` handling and
1990 // the user their ~/.zshenv and the bash one displaces their DEBUG trap — 1860 // the Bash integration replaces the DEBUG trap.
1991 // and only `mux a` reads what it buys.
1992 try std.testing.expect(!shellIntegrationEnabled(null)); 1861 try std.testing.expect(!shellIntegrationEnabled(null));
1993 } 1862 }
1994 1863
1995 test "shellIntegrationEnabled: `1` and nothing else turns it on" { 1864 test "shellIntegrationEnabled: `1` and nothing else turns it on" {
1996 try std.testing.expect(shellIntegrationEnabled("1")); 1865 try std.testing.expect(shellIntegrationEnabled("1"));
1997 // Every other spelling is off, including the one that used to mean off 1866 // Every other value remains off, including stale `=0` configurations from
1998 // when this variable was an opt-OUT: a stale `=0` in someone's profile 1867 // the previous opt-out behavior.
1999 // still reads as off, which is the safe direction for an inversion.
2000 try std.testing.expect(!shellIntegrationEnabled("0")); 1868 try std.testing.expect(!shellIntegrationEnabled("0"));
2001 try std.testing.expect(!shellIntegrationEnabled("")); 1869 try std.testing.expect(!shellIntegrationEnabled(""));
2002 try std.testing.expect(!shellIntegrationEnabled("true")); 1870 try std.testing.expect(!shellIntegrationEnabled("true"));
2003 try std.testing.expect(!shellIntegrationEnabled("yes")); 1871 try std.testing.expect(!shellIntegrationEnabled("yes"));
2004 } 1872 }
2005 1873
2006 fn silentProgress() Progress { 1874 fn silentProgress() StartProgress {
2007 // Progress that writes to /dev/null keeps test output clean while the 1875 // StartProgress that writes to /dev/null keeps test output clean while the
2008 // pinned-output case below captures a pipe instead. 1876 // pinned-output case below captures a pipe instead.
2009 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable; 1877 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
2010 return .{ .fd = f.handle, .prefix = "test", .tty = false }; 1878 return .{ .fd = f.handle, .prefix = "test", .tty = false };
@@ -2021,10 +1889,10 @@ test "start -d: an answering socket is already_running, nothing spawned" {
2021 var server = try addr.listen(.{}); 1889 var server = try addr.listen(.{});
2022 defer server.deinit(); 1890 defer server.deinit();
2023 1891
2024 // Progress captured through a pipe: already_running must print NOTHING. 1892 // Capture progress and verify that the already-running path is silent.
2025 const pipe = try std.posix.pipe(); 1893 const pipe = try std.posix.pipe();
2026 defer std.posix.close(pipe[0]); 1894 defer std.posix.close(pipe[0]);
2027 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false }; 1895 const progress: StartProgress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
2028 1896
2029 const r = try forkDaemon( 1897 const r = try forkDaemon(
2030 std.testing.allocator, 1898 std.testing.allocator,
@@ -2035,7 +1903,7 @@ test "start -d: an answering socket is already_running, nothing spawned" {
2035 200, 1903 200,
2036 null, 1904 null,
2037 ); 1905 );
2038 try std.testing.expectEqual(Started.already_running, r); 1906 try std.testing.expectEqual(StartOutcome.already_running, r);
2039 1907
2040 std.posix.close(pipe[1]); 1908 std.posix.close(pipe[1]);
2041 var out: [64]u8 = undefined; 1909 var out: [64]u8 = undefined;
@@ -2058,9 +1926,8 @@ test "start -d: a socket this process may not reach is not `already running`" {
2058 defer server.deinit(); 1926 defer server.deinit();
2059 try std.posix.fchmodat(std.posix.AT.FDCWD, sock, 0, 0); 1927 try std.posix.fchmodat(std.posix.AT.FDCWD, sock, 0, 0);
2060 1928
2061 // Past the read and into the spawn — SpawnFailed is the missing 1929 // Reaching `SpawnFailed` proves the inaccessible socket was not mistaken for
2062 // binary talking, which is proof the probe did not answer for it. 1930 // an already-running daemon.
2063 // `already_running` here is `mux d start -d` exiting 0 with no daemon.
2064 try std.testing.expectError(error.SpawnFailed, forkDaemon( 1931 try std.testing.expectError(error.SpawnFailed, forkDaemon(
2065 std.testing.allocator, 1932 std.testing.allocator,
2066 "/no/such/mux", 1933 "/no/such/mux",
@@ -2100,10 +1967,8 @@ test "start -d: a child that dies young is reported, not panicked on" {
2100 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()}); 1967 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
2101 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()}); 1968 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
2102 1969
2103 // Exits at once, binding nothing — a daemon refusing a flag, or one 1970 // Model a child that exits before binding. The poll loop must reap it once
2104 // whose key file is missing. The poll loop therefore reaps it on an 1971 // and continue to the deadline without a second waitpid call.
2105 // early pass and keeps polling to the deadline, which is where a
2106 // second waitpid would get ECHILD and panic.
2107 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" }); 1972 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
2108 const f = try tmp.dir.openFile("dies.sh", .{}); 1973 const f = try tmp.dir.openFile("dies.sh", .{});
2109 try f.chmod(0o755); 1974 try f.chmod(0o755);
@@ -2118,8 +1983,7 @@ test "start -d: a child that dies young is reported, not panicked on" {
2118 300, 1983 300,
2119 log, 1984 log,
2120 )); 1985 ));
2121 // The log is still there to be named by the failure line: a child that 1986 // The log must remain available for the startup-failure diagnostic.
2122 // died is exactly when an operator goes looking for it.
2123 const log_st = try std.fs.cwd().statFile(log); 1987 const log_st = try std.fs.cwd().statFile(log);
2124 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777))); 1988 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
2125 } 1989 }
@@ -2135,16 +1999,15 @@ test "start -d: a binary that never binds is NeverAnswered, pid left alive" {
2135 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()}); 1999 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
2136 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()}); 2000 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
2137 2001
2138 // A stand-in daemon that stays alive and binds nothing. `exec` so the 2002 // Exec the sleeper so the tracked pid belongs to the persistent process,
2139 // pid this tracked IS the sleeper, not a parent shell of it. 2003 // not an intermediate shell.
2140 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" }); 2004 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
2141 const f = try tmp.dir.openFile("stub.sh", .{}); 2005 const f = try tmp.dir.openFile("stub.sh", .{});
2142 try f.chmod(0o755); 2006 try f.chmod(0o755);
2143 f.close(); 2007 f.close();
2144 2008
2145 // The log goes somewhere disposable: `xdg.logPath` reads XDG_STATE_HOME at 2009 // Override the log path because tests cannot change XDG_STATE_HOME. The
2146 // call time and Zig tests cannot setenv, so null appends to a LIVE daemon's 2010 // nested path also verifies parent-directory creation.
2147 // log. The nested `logs/` also proves the parent is created, not assumed.
2148 const t0 = std.time.milliTimestamp(); 2011 const t0 = std.time.milliTimestamp();
2149 try std.testing.expectError(error.NeverAnswered, forkDaemon( 2012 try std.testing.expectError(error.NeverAnswered, forkDaemon(
2150 std.testing.allocator, 2013 std.testing.allocator,
@@ -2155,18 +2018,15 @@ test "start -d: a binary that never binds is NeverAnswered, pid left alive" {
2155 300, 2018 300,
2156 log, 2019 log,
2157 )); 2020 ));
2158 // The log is created before the fork, so it exists even when the child 2021 // Startup creates the log before fork, so it exists even when the child
2159 // never writes to it — that is what makes it the place to look when a 2022 // writes nothing.
2160 // spawn fails.
2161 const log_st = try std.fs.cwd().statFile(log); 2023 const log_st = try std.fs.cwd().statFile(log);
2162 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777))); 2024 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
2163 // It waited the deadline out rather than bailing early... 2025 // It waited the deadline out rather than bailing early...
2164 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300); 2026 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
2165 2027
2166 // ...and did NOT kill the spawned process. `last_spawned_pid` is how 2028 // Deadline expiry must not kill the spawned process. Use the exact tracked
2167 // the test learns which pid that is: killing by anything else — a name 2029 // pid for cleanup to avoid affecting unrelated processes.
2168 // match, a process sweep — could take out a bystander, so the pid the
2169 // spawner tracked is the only handle allowed.
2170 try std.testing.expect(last_spawned_pid != 0); 2030 try std.testing.expect(last_spawned_pid != 0);
2171 // waitpid with NOHANG returning pid 0 means "child exists, still 2031 // waitpid with NOHANG returning pid 0 means "child exists, still
2172 // running", which is the assertion; a reaped or dead child returns its 2032 // running", which is the assertion; a reaped or dead child returns its
@@ -2179,7 +2039,7 @@ test "start -d: a binary that never binds is NeverAnswered, pid left alive" {
2179 _ = std.posix.waitpid(last_spawned_pid, 0); 2039 _ = std.posix.waitpid(last_spawned_pid, 0);
2180 } 2040 }
2181 2041
2182 test "start -d: the child execs the path it was HANDED, never a name off PATH" { 2042 test "start -d: the child executes the supplied path without searching PATH" {
2183 const testtmp = @import("testtmp"); 2043 const testtmp = @import("testtmp");
2184 var tmp = try testtmp.TmpDir.make(); 2044 var tmp = try testtmp.TmpDir.make();
2185 defer tmp.cleanup(); 2045 defer tmp.cleanup();
@@ -2192,10 +2052,8 @@ test "start -d: the child execs the path it was HANDED, never a name off PATH" {
2192 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()}); 2052 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
2193 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()}); 2053 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
2194 2054
2195 // `$0` is the kernel's answer to "which file did you exec", so a spawn that 2055 // Record `$0` and `$*` to verify both the exact executable path and the
2196 // searched PATH cannot pass: nothing resolves by NAME to a file in a fresh 2056 // explicit `d start` mode words used for process listings.
2197 // tmp dir. `$*` pins the other half — the daemon is asked for `d start`,
2198 // which is also what makes it legible in `ps`.
2199 var script: [512]u8 = undefined; 2057 var script: [512]u8 = undefined;
2200 try tmp.dir.writeFile(.{ 2058 try tmp.dir.writeFile(.{
2201 .sub_path = "stub.sh", 2059 .sub_path = "stub.sh",
@@ -2225,18 +2083,16 @@ test "start -d: the child execs the path it was HANDED, never a name off PATH" {
2225 } 2083 }
2226 2084
2227 var got_buf: [std.fs.max_path_bytes]u8 = undefined; 2085 var got_buf: [std.fs.max_path_bytes]u8 = undefined;
2228 // Read through the failure rather than around it: a child that never 2086 // Convert a missing marker into a readable mismatch so the assertion still
2229 // ran the stub at all must fail as a MISMATCH naming what it became, 2087 // identifies an unexpected executable.
2230 // not as a FileNotFound three frames inside std.
2231 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>"; 2088 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
2232 var want_buf: [200]u8 = undefined; 2089 var want_buf: [200]u8 = undefined;
2233 const want = try std.fmt.bufPrint(&want_buf, "{s}|d start", .{stub}); 2090 const want = try std.fmt.bufPrint(&want_buf, "{s}|d start", .{stub});
2234 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n")); 2091 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
2235 } 2092 }
2236 2093
2237 // Forces semantic analysis of every pub decl under `zig build test`, so an 2094 // Ensure every public declaration is semantically analyzed during tests;
2238 // unreferenced decl must at least compile (the silent-module-loss hazard, 2095 // `std.meta.declarations` does not include private declarations.
2239 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
2240 test { 2096 test {
2241 std.testing.refAllDeclsRecursive(@This()); 2097 std.testing.refAllDeclsRecursive(@This());
2242 } 2098 }
src/cli/mux.zig
Old New
@@ -1,11 +1,10 @@
1 //! mux — the one binary. The first word picks a mode: `d` the daemon, `a` the 1 //! Top-level dispatcher for the `mux` binary. The first argument selects `d`
2 //! JSON agent surface, `web` the browser hub, anything else the client. This 2 //! (daemon), `a` (JSON agent), or `web` (browser hub); every other argument is
3 //! file owns the mode word and nothing else — no second flag grammar, no 3 //! handled by the client. There is no top-level flag grammar or command alias,
4 //! argv[0] dispatch, no alias. A word that is not a mode is a TARGET: `mux run` 4 //! so `mux run` treats `run` as a hostname.
5 //! names a host called "run".
6 //! 5 //!
7 //! The fifth mode has no word, because ssh gives it none: `SSH_ASKPASS` is 6 //! The ssh askpass mode has no command word. It is selected through the
8 //! exec'd with the prompt as argv[1]. Its VARIABLE is the word instead. 7 //! `SSH_ASKPASS` environment and receives the prompt as argv[1].
9 const std = @import("std"); 8 const std = @import("std");
10 const daemon = @import("main.zig"); 9 const daemon = @import("main.zig");
11 const agent = @import("agent"); 10 const agent = @import("agent");
@@ -13,19 +12,19 @@ const hub = @import("webhub_main.zig");
13 const client = @import("mux_main.zig"); 12 const client = @import("mux_main.zig");
14 const askpass = @import("client").askpass; 13 const askpass = @import("client").askpass;
15 14
16 /// The whole grammar, as a value, so the one decision this file makes can be 15 /// The dispatch decision as a value, allowing it to be tested without exiting
17 /// asked without a process to exit from. 16 /// a process.
18 const Mode = enum { daemon, agent, hub, client, askpass }; 17 const DispatchMode = enum { daemon, agent, hub, client, askpass };
19 18
20 fn modeOf(args: []const [:0]const u8, ask_sock: ?[]const u8) Mode { 19 fn modeOf(args: []const [:0]const u8, ask_sock: ?[]const u8) DispatchMode {
21 if (args.len < 2) return .client; 20 if (args.len < 2) return .client;
22 if (std.mem.eql(u8, args[1], "d")) return .daemon; 21 if (std.mem.eql(u8, args[1], "d")) return .daemon;
23 if (std.mem.eql(u8, args[1], "a")) return .agent; 22 if (std.mem.eql(u8, args[1], "a")) return .agent;
24 if (std.mem.eql(u8, args[1], "web")) return .hub; 23 if (std.mem.eql(u8, args[1], "web")) return .hub;
25 // A mode WORD wins over the variable: the ssh a wall dial spawned runs 24 // An explicit mode takes precedence over the askpass environment. This
26 // a remote `mux d endpoint`, and a hand-typed `mux d` inside that tree 25 // keeps remote `mux d endpoint` invocations and commands typed inside an
27 // must not turn into somebody's password prompt. The arity is the rest 26 // SSH process tree from being mistaken for password prompts. Askpass is
28 // of it — ssh execs its helper with exactly one argument. 27 // valid only for the single prompt argument supplied by SSH.
29 if (ask_sock != null and args.len == 2) return .askpass; 28 if (ask_sock != null and args.len == 2) return .askpass;
30 return .client; 29 return .client;
31 } 30 }
@@ -39,10 +38,9 @@ pub fn main() !u8 {
39 const args = try std.process.argsAlloc(alloc); 38 const args = try std.process.argsAlloc(alloc);
40 defer std.process.argsFree(alloc, args); 39 defer std.process.argsFree(alloc, args);
41 40
42 // Each named mode is handed a slice whose [0] is the word the user typed 41 // Named modes receive a slice beginning with their mode word, matching the
43 // and whose [1..] is its own line — the shape every one of these parsers 42 // argv shape expected by their parsers. The client has no mode word and
44 // already reads, from back when [0] was the program name. The client 43 // therefore receives the complete argv.
45 // gets argv whole, because it has no word of its own to skip.
46 const ask_sock = std.posix.getenv(askpass.sock_env); 44 const ask_sock = std.posix.getenv(askpass.sock_env);
47 const ask_kind = askpass.Kind.of(std.posix.getenv(askpass.prompt_env)); 45 const ask_kind = askpass.Kind.of(std.posix.getenv(askpass.prompt_env));
48 return switch (modeOf(args, ask_sock)) { 46 return switch (modeOf(args, ask_sock)) {
@@ -50,55 +48,51 @@ pub fn main() !u8 {
50 .agent => agent.main(args[1..]), 48 .agent => agent.main(args[1..]),
51 .hub => hub.main(args[1..]), 49 .hub => hub.main(args[1..]),
52 .client => client.main(args), 50 .client => client.main(args),
53 // ssh reads the answer off this fd and logs in with it, so nothing else 51 // SSH reads the response from stdout, so that stream must contain only
54 // may be written there. The second variable is ssh's own word for WHAT 52 // the askpass result. The prompt-kind environment controls whether the
55 // it is asking, which decides whether the wall stars the answer. 53 // wall masks the displayed response.
56 .askpass => askpass.helperMain(args[1], ask_sock.?, ask_kind, std.posix.STDOUT_FILENO), 54 .askpass => askpass.helperMain(args[1], ask_sock.?, ask_kind, std.posix.STDOUT_FILENO),
57 }; 55 };
58 } 56 }
59 57
60 test "modeOf: the three mode words, and nothing else" { 58 test "modeOf: the three mode words, and nothing else" {
61 try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "start" }, null)); 59 try std.testing.expectEqual(DispatchMode.daemon, modeOf(&.{ "mux", "d", "start" }, null));
62 try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" }, null)); 60 try std.testing.expectEqual(DispatchMode.agent, modeOf(&.{ "mux", "a", "status" }, null));
63 try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" }, null)); 61 try std.testing.expectEqual(DispatchMode.hub, modeOf(&.{ "mux", "web" }, null));
64 try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"}, null)); 62 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{"mux"}, null));
65 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box" }, null)); 63 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{ "mux", "box" }, null));
66 } 64 }
67 65
68 test "modeOf: the helper is named by ssh's variable, and a mode word still wins" { 66 test "modeOf: the helper is named by ssh's variable, and a mode word still wins" {
69 const sock = "/run/user/1000/mux-ask-7.sock"; 67 const sock = "/run/user/1000/mux-ask-7.sock";
70 // The whole of what ssh hands a helper: one argument, the prompt. 68 // The whole of what ssh hands a helper: one argument, the prompt.
71 try std.testing.expectEqual(Mode.askpass, modeOf(&.{ "mux", "box's password: " }, sock)); 69 try std.testing.expectEqual(DispatchMode.askpass, modeOf(&.{ "mux", "box's password: " }, sock));
72 // Without the variable the SAME argv is a host called "box's password: ", 70 // Without the askpass environment, the same argv is treated as a hostname.
73 // which is what makes a hand-typed `mux HOST` unreachable from here. 71 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{ "mux", "box's password: " }, null));
74 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box's password: " }, null));
75 // Inside the tree, a mode word is still a mode: the remote command the 72 // Inside the tree, a mode word is still a mode: the remote command the
76 // wall's ssh runs is `mux d endpoint`, and a user typing one at that 73 // wall's ssh runs is `mux d endpoint`, and a user typing one at that
77 // ssh's far end would otherwise get a prompt helper. 74 // ssh's far end would otherwise get a prompt helper.
78 try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "endpoint" }, sock)); 75 try std.testing.expectEqual(DispatchMode.daemon, modeOf(&.{ "mux", "d", "endpoint" }, sock));
79 try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" }, sock)); 76 try std.testing.expectEqual(DispatchMode.agent, modeOf(&.{ "mux", "a", "status" }, sock));
80 try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" }, sock)); 77 try std.testing.expectEqual(DispatchMode.hub, modeOf(&.{ "mux", "web" }, sock));
81 // Arity: ssh passes one argument and never two, so a longer line in a 78 // SSH askpass supplies exactly one prompt argument. Longer invocations are
82 // tree that happens to carry the variable is the client it looks like. 79 // dispatched to the client even if they inherit the environment variable.
83 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box", "-A" }, sock)); 80 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{ "mux", "box", "-A" }, sock));
84 try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"}, sock)); 81 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{"mux"}, sock));
85 } 82 }
86 83
87 test "modeOf: `run` is a host, and no daemon verb has a top-level alias" { 84 test "modeOf: `run` is a host, and no daemon verb has a top-level alias" {
88 // No bare `run` bridge for a v0.0.1-15 daemon's upgrade exec: that daemon 85 // There is no compatibility alias for the old bare `run` command. Older
89 // demands `muxd <version>` from the candidate first, which this binary does 86 // daemons reject this binary during their version check before executing
90 // not print, so it refuses before any exec. The rule has no exception — a 87 // it, and every unrecognized mode word remains a client target.
91 // word that is not a mode is a transport the user named. 88 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{ "mux", "run" }, null));
92 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" }, null)); 89 try std.testing.expectEqual(DispatchMode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" }, null));
93 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" }, null));
94 } 90 }
95 91
96 test { 92 test {
97 std.testing.refAllDeclsRecursive(@This()); 93 std.testing.refAllDeclsRecursive(@This());
98 // Three of the four mains are child FILES of this root, so their suites 94 // These imports make the child modules' tests reachable from this root test
99 // reach the runner through these lines and nothing else; drop one and 95 // artifact. The agent and webhub modules also have separate test artifacts.
100 // that main's argument parser goes untested against a green tree.
101 // `agent` and `webhub` are rows of their own and run as ones.
102 _ = @import("main.zig"); 96 _ = @import("main.zig");
103 _ = @import("mux_main.zig"); 97 _ = @import("mux_main.zig");
104 _ = @import("webhub_main.zig"); 98 _ = @import("webhub_main.zig");
src/cli/mux_main.zig
Old New
@@ -1,11 +1,11 @@
1 //! `mux` with no mode letter — the client. `--sock PATH` attaches to a local 1 //! Client mode for `mux`. `--sock PATH` attaches to a local daemon, `--via CMD`
2 //! daemon, `--via CMD` over CMD's stdio (argv words, exec'd, no shell), and a 2 //! uses the command's stdio without a shell, and a bare host uses the
3 //! bare HOST runs the ssh→QUIC handoff. 3 //! SSH-to-QUIC handoff.
4 //! 4 //!
5 //! Bare `mux` is the WALL: every session every listed daemon has live. Naming a 5 //! Bare `mux` opens a wall containing every live session on each listed daemon.
6 //! transport is the same wall, entered zoomed on that daemon. What lives up 6 //! Naming a transport opens the same wall focused on that daemon. This module
7 //! here is argv, the refusals that must happen before a dial, the auto-start 7 //! handles argument validation, local daemon startup, and the `hosts`
8 //! and the `hosts` subcommand — the command line rather than the session. 8 //! subcommand before handing connections to the wall.
9 const std = @import("std"); 9 const std = @import("std");
10 const client = @import("client"); 10 const client = @import("client");
11 const proto = @import("term").protocol; 11 const proto = @import("term").protocol;
@@ -19,10 +19,8 @@ const hosts = @import("client").hosts;
19 const cliflags = @import("cliflags"); 19 const cliflags = @import("cliflags");
20 const TmpDir = @import("testtmp").TmpDir; 20 const TmpDir = @import("testtmp").TmpDir;
21 21
22 /// The whole tree's root page. The four modes come first because the mode 22 /// Root help page for the binary. It lists the mode words first, then documents
23 /// word is the first thing typed; the rest of the page is the client's, 23 /// the default client mode; named modes provide their own `--help` pages.
24 /// because the client is what `mux` with no mode word runs. Each other mode
25 /// documents itself under its own `--help`.
26 const usage = 24 const usage =
27 \\usage: mux [TARGET ...] attach, or the wall (this page) 25 \\usage: mux [TARGET ...] attach, or the wall (this page)
28 \\ mux d VERB ... the daemon: run start stop stats dump proxy 26 \\ mux d VERB ... the daemon: run start stop stats dump proxy
@@ -71,97 +69,113 @@ const usage =
71 \\ 69 \\
72 ; 70 ;
73 71
74 /// The transport the command line asked for, one arm per spelling, so the 72 /// A successfully parsed connection specification.
75 /// parse can be tested without a process to exit from. Everything that is 73 ///
76 /// NOT a transport — the two refusals, and the two questions about the 74 /// Keeping parsing separate from printing and process exit makes it easy to
77 /// binary itself — is an error below. 75 /// test. Invalid usage, conflicting transports, `--help`, and `--version`
78 const ParseResult = union(enum) { 76 /// are returned as `ParseError` values instead.
79 /// At most one of these is set; both null means the default local socket. 77 const ConnectionSpec = union(enum) {
80 /// `session` defaults to "" (empty), the wire-compatible name that puts 78 /// Attach through a Unix socket or a command supplied with `--via`.
81 /// exactly the old bytes on the wire — see encodeAttachNamed. 79 /// If both are null, use the default local socket.
82 attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null, session: []const u8 = "", agent: bool = false }, 80 ///
83 /// A bare hostname; the ssh recipe is built from it in main, where there is 81 /// An empty session name preserves the original wire encoding for the
84 /// an allocator. `idle_ms` rides along because the handoff ends in a QUIC 82 /// default session; see `encodeAttachNamed`.
85 /// link like any other, and dropping it makes `--quic-idle-ms` a no-op. 83 attach: struct {
86 host: struct { name: []const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, 84 sock: ?[]const u8 = null,
87 /// A direct QUIC attach. The key is resolved in main, where the 85 via: ?[]const u8 = null,
88 /// environment can be consulted. 86 session: []const u8 = "",
89 quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, 87 agent: bool = false,
88 },
89
90 /// Connect to a bare hostname through SSH.
91 ///
92 /// `main` builds the SSH command because doing so requires an allocator.
93 /// The resulting handoff uses QUIC, so its idle timeout is retained here.
94 host: struct {
95 name: []const u8,
96 idle_ms: u32,
97 session: []const u8 = "",
98 agent: bool = false,
99 },
100
101 /// Connect directly to a `quic://HOST[:PORT]` endpoint.
102 ///
103 /// `key` is optional here because `main` can resolve the default key using
104 /// the environment and filesystem.
105 quic: struct {
106 host_port: []const u8,
107 key: ?[]const u8,
108 idle_ms: u32,
109 session: []const u8 = "",
110 agent: bool = false,
111 },
90 }; 112 };
91 113
92 /// Conflict is this mode's own refusal: more than one transport named, a 114 /// Extend the shared CLI errors with the case where more than one transport is
93 /// request that cannot be honoured rather than one to reconcile. The other 115 /// specified.
94 /// three are `cliflags.exitFor`'s.
95 const ParseError = cliflags.ParseError || error{Conflict}; 116 const ParseError = cliflags.ParseError || error{Conflict};
96 117
97 /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing. `ssh-add -l` sends 118 /// ssh-agent frame for `SSH_AGENTC_REQUEST_IDENTITIES`, the same request used by
98 /// exactly this, which is why every agent answers it — with a list or a failure, 119 /// `ssh-add -l`. Any protocol reply proves that an agent is present. Agent
99 /// and either is proof of an agent. The one place mux knows any ssh-agent bytes, 120 /// forwarding remains byte-transparent outside this client-side probe.
100 /// and it belongs to the CLIENT: the forwarding path stays opaque end to end.
101 const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 }; 121 const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 };
102 122
103 /// How long a probe waits before deciding it cannot tell. A refusal is a hangup 123 /// Maximum wait for the agent probe. The daemon immediately closes a forwarded
104 /// on an already-accepted connection and arrives in microseconds, so this is not 124 /// agent socket when no client is offering an agent; a slow reply may still come
105 /// asked to separate refused from slow — only to outlast a real agent's round 125 /// from a real agent or hardware token.
106 /// trip, including one forwarded out of an outer session over a link.
107 const agent_probe_ms = 500; 126 const agent_probe_ms = 500;
108 127
109 /// Whether an ssh-agent is actually there to forward: a REQUEST and a reply, 128 /// Probe whether `path` leads to an ssh-agent by sending an identities request.
110 /// not a dial. Inside a mux session `SSH_AUTH_SOCK` names the daemon's own 129 /// A connect alone is insufficient inside a mux session because the daemon
111 /// socket, which accepts every connection and only then looks for a client — so 130 /// accepts the socket before checking for an offering client. EOF means no
112 /// a bare connect passes even when nobody is offering, waving through exactly 131 /// agent is available; a timeout is treated as reachable because real agents
113 /// the silent offerer this refuses. Fails open on silence, closed on a hangup: 132 /// and hardware tokens may reply slowly.
114 /// a hardware token is slow and is still an agent.
115 fn agentReachable(path: []const u8) bool { 133 fn agentReachable(path: []const u8) bool {
116 const fd = client.connectAgent(path) orelse return false; 134 const fd = client.connectAgent(path) orelse return false;
117 defer std.posix.close(fd); 135 defer std.posix.close(fd);
118 136
119 // MSG_NOSIGNAL rather than a `write`: the peer may already be gone, and 137 // Suppress SIGPIPE because this probe runs before the client installs signal
120 // the preflight runs before the client installs any signal handling, so 138 // handling and the peer may already have closed the socket.
121 // an EPIPE has to arrive as an error and not as a fatal signal.
122 _ = std.posix.send(fd, &agent_request_identities, std.posix.MSG.NOSIGNAL) catch return false; 139 _ = std.posix.send(fd, &agent_request_identities, std.posix.MSG.NOSIGNAL) catch return false;
123 140
124 var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; 141 var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
125 const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true; 142 const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true;
126 if (ready == 0) return true; 143 if (ready == 0) return true;
127 var reply: [1]u8 = undefined; 144 var reply: [1]u8 = undefined;
128 // Zero bytes is EOF: accepted, then hung up without answering. That is 145 // EOF after a successful connect is how the daemon reports that no attached
129 // the daemon with no offerer behind it, and the only shape refused here. 146 // client is offering an agent.
130 const n = std.posix.read(fd, &reply) catch return false; 147 const n = std.posix.read(fd, &reply) catch return false;
131 return n != 0; 148 return n != 0;
132 } 149 }
133 150
134 /// Built from `proto.session_env` so the message and the planter cannot 151 /// Build the diagnostic from `proto.session_env` so it stays synchronized with
135 /// disagree about the spelling. 152 /// the environment variable set for sessions.
136 const self_attach_refusal = 153 const self_attach_refusal =
137 "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n"; 154 "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n";
138 155
139 /// The attach line, read off the struct: the field's type is the flag's 156 /// Raw client arguments. Field names and types define flag syntax; `parseArgs`
140 /// arity and its name is the flag's spelling. What a flag MEANS stays here, 157 /// performs cross-field and transport validation.
141 /// in the post-checks below. 158 const ClientArguments = struct {
142 const Opts = struct {
143 sock: ?[]const u8 = null, 159 sock: ?[]const u8 = null,
144 via: ?[]const u8 = null, 160 via: ?[]const u8 = null,
145 key: ?[]const u8 = null, 161 key: ?[]const u8 = null,
146 /// Optional so that only a name that was TYPED reaches the type: `""` is 162 /// Optional so validation applies only to an explicitly supplied name. An
147 /// the wire's own default spelling and would fail a rule written for a 163 /// empty string is reserved for the wire encoding of the default session.
148 /// name a user typed.
149 session: ?proto.SessionName = null, 164 session: ?proto.SessionName = null,
150 quic_idle_ms: client.IdleMs = .{}, 165 quic_idle_ms: client.IdleMs = .{},
151 agent: bool = false, 166 agent: bool = false,
152 /// The three below are not flags, and the leading underscore is what 167 /// Leading underscores exclude these parser bookkeeping fields from flag
153 /// says so: they are what `positional` saw. 168 /// generation. They are populated by `positional`.
154 _host: ?[]const u8 = null, 169 _host: ?[]const u8 = null,
155 _quic: ?[]const u8 = null, 170 _quic: ?[]const u8 = null,
156 _targets: u8 = 0, 171 _targets: u8 = 0,
157 172
158 pub const aliases = .{.{ "-A", "agent" }}; 173 pub const aliases = .{.{ "-A", "agent" }};
159 174
160 /// A bare word is a host to hop to, `quic://...` a transport spelling. 175 /// Parse a bare hostname or `quic://...` target. Count every positional
161 /// Counted, not judged: parseArgs owns the one sum that refuses two of 176 /// target so `parseArgs` can reject multiple transports. An empty
162 /// anything. `quic://` with nothing after it names no host at all, and 177 /// `quic://` target is invalid immediately.
163 /// is refused here as the usage mistake it is. 178 pub fn positional(self: *ClientArguments, word: []const u8) bool {
164 pub fn positional(self: *Opts, word: []const u8) bool {
165 if (std.mem.startsWith(u8, word, "quic://")) { 179 if (std.mem.startsWith(u8, word, "quic://")) {
166 const host_port = word["quic://".len..]; 180 const host_port = word["quic://".len..];
167 if (host_port.len == 0) return false; 181 if (host_port.len == 0) return false;
@@ -173,27 +187,25 @@ const Opts = struct {
173 }; 187 };
174 188
175 comptime { 189 comptime {
176 cliflags.assertDocumented(Opts, usage, &.{}); 190 cliflags.assertDocumented(ClientArguments, usage, &.{});
177 } 191 }
178 192
179 fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseResult { 193 fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ConnectionSpec {
180 var o: Opts = .{}; 194 var o: ClientArguments = .{};
181 try cliflags.parseStrict(Opts, &o, args[1..]); 195 try cliflags.parseStrict(ClientArguments, &o, args[1..]);
182 196
183 // Every pairing is two transports for one session, and two bare words 197 // At most one socket, via command, hostname, or QUIC endpoint may be named.
184 // are a pairing too — which is why positional counts, not latches. 198 // Counting positional targets also detects two bare host arguments.
185 const named: u8 = @as(u8, @intFromBool(o.sock != null)) + 199 const named: u8 = @as(u8, @intFromBool(o.sock != null)) +
186 @intFromBool(o.via != null) + o._targets; 200 @intFromBool(o.via != null) + o._targets;
187 if (named > 1) return error.Conflict; 201 if (named > 1) return error.Conflict;
188 202
189 // Rides every transport, unlike `--key`: a session name authenticates 203 // Session selection and agent forwarding apply to every transport.
190 // nothing, so it applies whichever spelling wins. So does `agent` — an offer
191 // to answer for this client's agent is about the client, not the wire.
192 const session = if (o.session) |n| n.name else ""; 204 const session = if (o.session) |n| n.name else "";
193 205
194 if (o._quic) |host_port| { 206 if (o._quic) |host_port| {
195 // Neither spelling of the key being set is not a refusal: main has a 207 // A missing explicit key is valid because `main` can resolve the
196 // default path to try, and parse cannot look at the filesystem. 208 // environment or default path using the filesystem.
197 return .{ .quic = .{ 209 return .{ .quic = .{
198 .host_port = host_port, 210 .host_port = host_port,
199 .key = xdg.pickKey(o.key, env_key), 211 .key = xdg.pickKey(o.key, env_key),
@@ -202,30 +214,27 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseR
202 .agent = o.agent, 214 .agent = o.agent,
203 } }; 215 } };
204 } 216 }
205 // A key with no `quic://` has nothing to authenticate and is IGNORED rather 217 // Ignore key configuration for non-QUIC transports. In particular, an
206 // than refused: it is one env var away from being set for every invocation, 218 // exported `MUX_KEY_FILE` must not break local or SSH connections.
207 // and refusing `mux --sock ...` over an exported MUX_KEY_FILE is absurd.
208 if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } }; 219 if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } };
209 return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } }; 220 return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } };
210 } 221 }
211 222
212 /// The bare `mux`: no mode letter, so the words are a transport or 223 /// Run the client mode. Unlike named modes, it receives the complete argv from
213 /// nothing. argv is the dispatcher's, program name and all. 224 /// the top-level dispatcher.
214 pub fn main(args: []const [:0]const u8) !u8 { 225 pub fn main(args: []const [:0]const u8) !u8 {
215 var gpa: std.heap.DebugAllocator(.{}) = .init; 226 var gpa: std.heap.DebugAllocator(.{}) = .init;
216 defer if (gpa.deinit() == .leak) 227 defer if (gpa.deinit() == .leak)
217 std.debug.print("mux: LEAK: allocations outlived deinit\n", .{}); 228 std.debug.print("mux: LEAK: allocations outlived deinit\n", .{});
218 const alloc = gpa.allocator(); 229 const alloc = gpa.allocator();
219 230
220 // A subcommand, checked before the flag parse: `hosts` edits or reads a 231 // Dispatch `hosts` before transport parsing because it operates on the
221 // file and dials no session, so it is not a transport spelling. 232 // state file and never opens a session.
222 if (args.len > 1 and std.mem.eql(u8, args[1], "hosts")) 233 if (args.len > 1 and std.mem.eql(u8, args[1], "hosts"))
223 return hostsMain(alloc, args[2..], null); 234 return hostsMain(alloc, args[2..], null);
224 235
225 // Read off argv rather than off the parse: `mux` and `mux --sock 236 // Bare `mux` opens the wall, while an explicit default socket opens an
226 // <default>` produce the same ParseResult, and only one of them is the 237 // attachment even though both would produce the same `ConnectionSpec`.
227 // wall. Anything else typed is a transport the user named, and an
228 // explicitly named transport is an attach.
229 if (args.len == 1) return wallOfHosts(alloc); 238 if (args.len == 1) return wallOfHosts(alloc);
230 239
231 const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) { 240 const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) {
@@ -239,10 +248,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
239 else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version), 248 else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version),
240 }; 249 };
241 250
242 // `-A` is a promise a client with no agent cannot keep. Left to attach it 251 // Validate `-A` before dialing. Advertising an unavailable agent would make
243 // offers anyway — an offer is a declaration, not a capability — so every 252 // the daemon route agent requests to a client that cannot answer them.
244 // dial is refused in silence, and it can out-rank a client that WOULD have
245 // answered. Refused here, at the altitude the flag was typed at.
246 const wants_agent = switch (parsed) { 253 const wants_agent = switch (parsed) {
247 .host => |h| h.agent, 254 .host => |h| h.agent,
248 .quic => |q| q.agent, 255 .quic => |q| q.agent,
@@ -290,18 +297,16 @@ pub fn main(args: []const [:0]const u8) !u8 {
290 } }, q.session, q.key, q.idle_ms, q.agent); 297 } }, q.session, q.key, q.idle_ms, q.agent);
291 }, 298 },
292 .host => |h| { 299 .host => |h| {
293 // The handoff recipe: ssh fetches the coordinates and carries the 300 // The handoff recipe uses SSH to obtain coordinates and as a
294 // session if QUIC cannot, while a warm attach dials from the cache 301 // fallback transport. A cached endpoint can skip SSH entirely.
295 // and spawns no ssh. The hub builds its HOST tiles from this call.
296 const r = try handoff.recipeFor(alloc, h.name, false); 302 const r = try handoff.recipeFor(alloc, h.name, false);
297 defer r.deinit(alloc); 303 defer r.deinit(alloc);
298 // The entry dial: `mux HOST` is the user asking in person for that 304 // A direct `mux HOST` invocation may start the remote daemon and
299 // box — the one place a start and a fallback line are owed to 305 // report fallback progress. The host picker's Enter path does the
300 // somebody sitting there. The picker's Enter is the other `true`. 306 // same.
301 var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true); 307 var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true);
302 // The one caller that relays ssh's stderr onward. There is no 308 // Relay SSH stderr for this foreground attach; no alternate-screen
303 // wall yet and no alternate screen to corrupt, and the user is 309 // UI is active yet.
304 // sitting in front of the wait those bytes describe.
305 target.narrate = true; 310 target.narrate = true;
306 return wall.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent); 311 return wall.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent);
307 }, 312 },
@@ -324,25 +329,25 @@ pub fn main(args: []const [:0]const u8) !u8 {
324 } 329 }
325 } 330 }
326 331
327 /// Whether the wall has to start the local daemon itself. Asked of the OS, 332 /// Return true when the hosts file includes the local socket but no process is
328 /// not of the file: the daemon dies on every reboot while its line lives on. 333 /// currently answering on it.
329 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { 334 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool {
330 var buf: [std.fs.max_path_bytes + "--sock ".len]u8 = undefined; 335 var buf: [std.fs.max_path_bytes + "--sock ".len]u8 = undefined;
331 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false; 336 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false;
332 return h.has(line) and !sockpath.answers(sock); 337 return h.has(line) and !sockpath.answers(sock);
333 } 338 }
334 339
335 /// The local socket's one door: `mux --sock PATH` and an empty hosts file. 340 /// Attach through a local Unix socket, used by both `mux --sock PATH` and the
341 /// empty-hosts-file fallback.
336 fn attachLocal( 342 fn attachLocal(
337 alloc: std.mem.Allocator, 343 alloc: std.mem.Allocator,
338 sock_path: []const u8, 344 sock_path: []const u8,
339 session: []const u8, 345 session: []const u8,
340 agent: bool, 346 agent: bool,
341 ) !u8 { 347 ) !u8 {
342 // Before the dial: the refusal is about where this process is STANDING, and 348 // Reject attaching a shell to the same session it already occupies. This
343 // nothing below changes the answer. Here rather than `parseArgs` because the 349 // check runs after resolving the default socket path and applies only to a
344 // default socket path resolves here. The USER's attach only — the chords 350 // direct client attach; wall-created tiles do not call this function.
345 // grow tiles from inside the wall and never come back through this.
346 if (wall.showsSelf( 351 if (wall.showsSelf(
347 .{ .sock = sock_path }, 352 .{ .sock = sock_path },
348 session, 353 session,
@@ -364,10 +369,8 @@ fn attachLocal(
364 ); 369 );
365 } 370 }
366 371
367 /// Ask the daemon to start itself: `mux d start -d --sock PATH`, this image, 372 /// Start a detached local daemon by executing this binary as
368 /// stdio inherited. An ASK, not a fork — the daemon owns its flags, its log and 373 /// `mux d start -d --sock PATH`. Inherited stdio preserves daemon diagnostics.
369 /// its refusals, and they reach the user because this process lent it fd 2.
370 /// Bare beyond `--sock`: a listener must be asked for, never appear.
371 fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool { 374 fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
372 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 375 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
373 const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path }; 376 const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path };
@@ -379,27 +382,26 @@ fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
379 return term == .Exited and term.Exited == 0; 382 return term == .Exited and term.Exited == 0;
380 } 383 }
381 384
382 /// `mux hosts add`'s own line: no flags of its own, every word a host 385 /// Parsed host arguments for `mux hosts add`. Every positional word is a host
383 /// spelling. It shares `usage` so a flag added here has to be documented 386 /// spelling; this command defines no independent flags.
384 /// there like any other. 387 const HostsArguments = struct {
385 const HostsOpts = struct {
386 _argv: hosts.Argv, 388 _argv: hosts.Argv,
387 389
388 pub fn positional(self: *HostsOpts, w: []const u8) bool { 390 pub fn positional(self: *HostsArguments, w: []const u8) bool {
389 return self._argv.positional(w); 391 return self._argv.positional(w);
390 } 392 }
391 pub fn extra(self: *HostsOpts, rest: []const [:0]const u8) usize { 393 pub fn extra(self: *HostsArguments, rest: []const [:0]const u8) usize {
392 return self._argv.extra(rest); 394 return self._argv.extra(rest);
393 } 395 }
394 }; 396 };
395 397
396 comptime { 398 comptime {
397 cliflags.assertDocumented(HostsOpts, usage, &.{}); 399 cliflags.assertDocumented(HostsArguments, usage, &.{});
398 } 400 }
399 401
400 /// `mux`: the wall of daemons. An empty file is the local one, so a first run 402 /// Open the daemon wall. An empty hosts file falls back to the local socket.
401 /// is still just a shell. Resolution allocates into an arena because `wall.run` 403 /// Resolved host data uses an arena because `wall.run` does not return on
402 /// never returns on the success path. 404 /// success.
403 fn wallOfHosts(alloc: std.mem.Allocator) !u8 { 405 fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
404 var arena_state = std.heap.ArenaAllocator.init(alloc); 406 var arena_state = std.heap.ArenaAllocator.init(alloc);
405 defer arena_state.deinit(); 407 defer arena_state.deinit();
@@ -408,18 +410,15 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
408 const path = try hosts.statePath(arena); 410 const path = try hosts.statePath(arena);
409 const h = hosts.load(arena, path) catch |err| return refuseFile(arena, "mux", path, err); 411 const h = hosts.load(arena, path) catch |err| return refuseFile(arena, "mux", path, err);
410 if (h.lines.items.len == 0) { 412 if (h.lines.items.len == 0) {
411 // The local daemon is a host like any other; what is special about 413 // On first use, attach to the default local daemon. `wall.runAttach`
412 // it is only that `mux` reaches for it when nothing is listed. The 414 // records the socket only after the connection succeeds.
413 // line itself is written once the attach answers, by
414 // `wall.runAttach`.
415 const sock_path = try sockpath.defaultOrExplain(arena, "mux") orelse return 1; 415 const sock_path = try sockpath.defaultOrExplain(arena, "mux") orelse return 1;
416 return attachLocal(alloc, sock_path, "", false); 416 return attachLocal(alloc, sock_path, "", false);
417 } 417 }
418 418
419 // A LISTED local daemon is auto-started too: it dies on every reboot while 419 // Restart a listed local daemon after reboot when its persistent hosts-file
420 // its line lives on, and the wall shows live sessions only — so its owner 420 // entry remains but its socket is inactive. If startup fails, wall polling
421 // opens on an entirely empty wall. Failure is not a refusal; the poller 421 // continues and can discover a daemon started by another process.
422 // keeps redialling, so a daemon started elsewhere shows up.
423 if (sockpath.defaultSockPath(arena) catch null) |sock| { 422 if (sockpath.defaultSockPath(arena) catch null) |sock| {
424 if (localNeedsStart(&h, sock)) _ = try startLocalDaemon(alloc, sock); 423 if (localNeedsStart(&h, sock)) _ = try startLocalDaemon(alloc, sock);
425 } 424 }
@@ -427,10 +426,8 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
427 const key = std.posix.getenv(xdg.key_env); 426 const key = std.posix.getenv(xdg.key_env);
428 const specs = try arena.alloc(wall.HostSpec, h.lines.items.len); 427 const specs = try arena.alloc(wall.HostSpec, h.lines.items.len);
429 for (specs, h.lines.items) |*s, line| { 428 for (specs, h.lines.items) |*s, line| {
430 // Refused outright, unlike the same bad line under `mux HOST`: here 429 // Fail the entire wall when a persisted host cannot be parsed; silently
431 // the wall IS what was asked for, and a wall silently missing one of 430 // omitting an explicitly configured daemon would misrepresent the file.
432 // the machines the user wrote down is the lie this whole file exists
433 // to stop telling.
434 s.* = wall.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { 431 s.* = wall.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
435 std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) }); 432 std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
436 return 2; 433 return 2;
@@ -439,9 +436,8 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
439 return wall.run(arena, specs, .{ .key = key }); 436 return wall.run(arena, specs, .{ .key = key });
440 } 437 }
441 438
442 /// What a count is worth waiting for. It bounds the DAEMON's answer, not 439 /// Timeout for a daemon's session-count reply. SSH setup is intentionally not
443 /// an ssh that stops to ask the user something: a HOST spelling whose ssh 440 /// covered because it may wait for interactive authentication.
444 /// prompts still holds the listing there.
445 const hosts_list_ms = 2000; 441 const hosts_list_ms = 2000;
446 442
447 /// `mux hosts [add|rm SPELLING...]`; a null `state_path` is the real file. 443 /// `mux hosts [add|rm SPELLING...]`; a null `state_path` is the real file.
@@ -456,10 +452,8 @@ fn hostsMain(
456 const path = state_path orelse try hosts.statePath(arena); 452 const path = state_path orelse try hosts.statePath(arena);
457 453
458 if (args.len == 0) return hostsList(arena, path, std.posix.STDOUT_FILENO); 454 if (args.len == 0) return hostsList(arena, path, std.posix.STDOUT_FILENO);
459 // Before the verb check, and wherever it sits — the rule `cliflags.parse` 455 // Help and version take precedence over verb validation, matching the
460 // already follows inside `add`. Asking what a command does is not 456 // shared flag parser and avoiding a misleading unknown-verb diagnostic.
461 // mistyping its name, and "add or rm, not '--help'" sends the user
462 // looking for a subcommand they never meant.
463 for (args) |a| { 457 for (args) |a| {
464 if (cliflags.isHelp(a)) return cliflags.help(usage); 458 if (cliflags.isHelp(a)) return cliflags.help(usage);
465 if (cliflags.isVersion(a)) return cliflags.version("mux", build_options.version); 459 if (cliflags.isVersion(a)) return cliflags.version("mux", build_options.version);
@@ -472,14 +466,12 @@ fn hostsMain(
472 return hostsEdit(arena, adding, args[1..], path); 466 return hostsEdit(arena, adding, args[1..], path);
473 } 467 }
474 468
475 /// One line per host, and beside it what that daemon has live RIGHT NOW — 469 /// Print each configured host with its current live-session count. Counts come
476 /// asked of the daemon, never counted out of the file. The counts are the 470 /// from the daemon because the hosts file contains no session state.
477 /// whole point of the verb: the file knows nothing about sessions.
478 fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) !u8 { 471 fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) !u8 {
479 // Verbatim, not through `hosts.load`: this is the command a user runs 472 // Load lines verbatim so invalid hand-edited entries remain visible and can
480 // to SEE a line they have to fix, and a strict read would refuse to 473 // be passed back to `mux hosts rm`. This read-only operation does not risk
481 // show it to them. Nothing here writes the file, so there is no 474 // rewriting unrecognized content.
482 // half-understood content to protect.
483 const lines = hosts.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err); 475 const lines = hosts.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err);
484 const key = std.posix.getenv(xdg.key_env); 476 const key = std.posix.getenv(xdg.key_env);
485 for (lines.items) |line| { 477 for (lines.items) |line| {
@@ -489,8 +481,8 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
489 }; 481 };
490 var out: [proto.sessions_text_max]u8 = undefined; 482 var out: [proto.sessions_text_max]u8 = undefined;
491 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| { 483 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| {
492 // Out of memory is this machine's fault, and printing 484 // Propagate local allocation failure rather than reporting the
493 // `[unreachable]` for it would blame a box that is up. 485 // remote daemon as unreachable.
494 if (err == error.OutOfMemory) return err; 486 if (err == error.OutOfMemory) return err;
495 printRow(out_fd, line, "\t[unreachable]\n", .{}); 487 printRow(out_fd, line, "\t[unreachable]\n", .{});
496 continue; 488 continue;
@@ -500,35 +492,32 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
500 return 0; 492 return 0;
501 } 493 }
502 494
503 /// One listing row: the file's line verbatim, then a formatted verdict. 495 /// Print one hosts-file line verbatim followed by its formatted status.
504 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void { 496 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void {
505 // The line is written straight through rather than formatted into a buffer 497 // Write the host separately because lines may be up to one MiB while the
506 // with the verdict: `loadLines` accepts lines up to a MiB and `hosts rm` 498 // status buffer is small. Truncating a line would make it impossible to
507 // matches byte for byte, so an overflowed row vanishes from the one command 499 // copy that exact value into `mux hosts rm`.
508 // whose job is showing the user a line to type back.
509 proto.writeAllFd(fd, line) catch return; 500 proto.writeAllFd(fd, line) catch return;
510 printOut(fd, fmt, args); 501 printOut(fd, fmt, args);
511 } 502 }
512 503
513 /// A listing someone ASKED for is output, like `--help`; every refusal 504 /// Write requested listing output to the supplied stdout-like descriptor;
514 /// above and below stays on stderr. 505 /// diagnostics remain on stderr.
515 fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void { 506 fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void {
516 // The fd is a parameter so a test can assert the format without writing 507 // An explicit descriptor lets tests capture output without interfering
517 // over the test runner's own stdout. 508 // with the test runner's stdout protocol.
518 var buf: [512]u8 = undefined; 509 var buf: [512]u8 = undefined;
519 const s = std.fmt.bufPrint(&buf, fmt, args) catch return; 510 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
520 proto.writeAllFd(fd, s) catch {}; 511 proto.writeAllFd(fd, s) catch {};
521 } 512 }
522 513
523 /// One refusal for the one thing that can be wrong with this file, wherever 514 /// Report a hosts-file failure. Parse errors print the invalid lines and return
524 /// it is read: the reason, then the line that has it. A line the grammar 515 /// exit code 2; I/O and allocation failures return exit code 1.
525 /// will not hold is the user's to fix (2); a file that could not be read at
526 /// all is not their spelling (1).
527 fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err: anyerror) u8 { 516 fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err: anyerror) u8 {
528 std.debug.print("{s}: {s}: {s}\n", .{ who, path, hosts.reason(err) }); 517 std.debug.print("{s}: {s}: {s}\n", .{ who, path, hosts.reason(err) });
529 // Whose fault it was is decided by the error, never by re-parsing the 518 // Classify the original error before reading the file again. Otherwise an
530 // file: an `OutOfMemory` on a file that also holds one stale line used 519 // allocation failure in a file that also contains invalid syntax could be
531 // to print "OutOfMemory" and then exit 2 with a list of lines to fix. 520 // misreported as a user-correctable parse error.
532 if (!hosts.isParse(err)) return 1; 521 if (!hosts.isParse(err)) return 1;
533 const lines = hosts.loadLines(arena, path) catch return 2; 522 const lines = hosts.loadLines(arena, path) catch return 2;
534 for (lines.items) |l| { 523 for (lines.items) |l| {
@@ -537,8 +526,8 @@ fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err:
537 return 2; 526 return 2;
538 } 527 }
539 528
540 /// Sessions in a `sessions_reply` body: one name per line, however the 529 /// Count newline-separated session names in a `sessions_reply`, with or without
541 /// daemon punctuated the end of it. 530 /// a trailing newline.
542 fn countSessions(list: []const u8) usize { 531 fn countSessions(list: []const u8) usize {
543 var n: usize = 0; 532 var n: usize = 0;
544 var it = std.mem.tokenizeScalar(u8, list, '\n'); 533 var it = std.mem.tokenizeScalar(u8, list, '\n');
@@ -546,7 +535,8 @@ fn countSessions(list: []const u8) usize {
546 return n; 535 return n;
547 } 536 }
548 537
549 /// `mux hosts add|rm SPELLING...`: file operations only, neither verb dials. 538 /// Add or remove host spellings in one state-file update. Neither operation
539 /// connects to a daemon.
550 fn hostsEdit( 540 fn hostsEdit(
551 arena: std.mem.Allocator, 541 arena: std.mem.Allocator,
552 adding: bool, 542 adding: bool,
@@ -557,14 +547,12 @@ fn hostsEdit(
557 var spellings: std.ArrayList([]const u8) = .empty; 547 var spellings: std.ArrayList([]const u8) = .empty;
558 548
559 if (adding) { 549 if (adding) {
560 // Judged at usage altitude: `add` is authored intent, and the 550 // Validate new entries while their command-line spelling is available
561 // moment the user is still looking at what they typed is the only 551 // so diagnostics can identify the exact invalid target.
562 // one where naming the rule helps. 552 var o = HostsArguments{ ._argv = .{ .alloc = arena } };
563 var o = HostsOpts{ ._argv = .{ .alloc = arena } }; 553 const outcome = cliflags.parse(HostsArguments, &o, args);
564 const outcome = cliflags.parse(HostsOpts, &o, args); 554 // Prefer the host parser's specific recorded error over the generic
565 // Read before the outcome: a hook that refused for a REASON has 555 // unknown-argument result returned when its hook rejects a word.
566 // already named it, and that reason outranks the bare "unknown
567 // word" cliflags saw when the hook said no.
568 if (o._argv.err) |e| { 556 if (o._argv.err) |e| {
569 if (e.err == error.OutOfMemory) return e.err; 557 if (e.err == error.OutOfMemory) return e.err;
570 std.debug.print("mux hosts add: '{s}': {s}\n", .{ e.word, hosts.reason(e.err) }); 558 std.debug.print("mux hosts add: '{s}': {s}\n", .{ e.word, hosts.reason(e.err) });
@@ -585,10 +573,8 @@ fn hostsEdit(
585 } 573 }
586 spellings = o._argv.list; 574 spellings = o._argv.list;
587 } else { 575 } else {
588 // Verbatim, NOT through the grammar: `rm` is the one command whose 576 // Parse only argv grouping for `rm`; do not validate host syntax. This
589 // job is removing a line, so it has to reach a hand-edited line the 577 // allows removal of hand-edited lines that the current grammar rejects.
590 // grammar refuses — which is why `hosts.forget` reads the file that
591 // way too.
592 var i: usize = 0; 578 var i: usize = 0;
593 while (i < args.len) : (i += 1) { 579 while (i < args.len) : (i += 1) {
594 const n = hosts.spellingFromArgv(arena, args, i) catch |err| switch (err) { 580 const n = hosts.spellingFromArgv(arena, args, i) catch |err| switch (err) {
@@ -613,8 +599,8 @@ fn hostsEdit(
613 599
614 if (adding) return hostsAdd(arena, spellings.items, path); 600 if (adding) return hostsAdd(arena, spellings.items, path);
615 601
616 // One read-modify-write for the whole line, like `add`: an IO error on 602 // Apply all removals in one read-modify-write so an I/O error cannot leave
617 // the third of four must not leave the first two applied. 603 // only a prefix of the requested changes committed.
618 const gone = try arena.alloc(bool, spellings.items.len); 604 const gone = try arena.alloc(bool, spellings.items.len);
619 @memset(gone, false); 605 @memset(gone, false);
620 hosts.forgetMany(arena, path, spellings.items, gone) catch |err| { 606 hosts.forgetMany(arena, path, spellings.items, gone) catch |err| {
@@ -623,48 +609,43 @@ fn hostsEdit(
623 }; 609 };
624 var rc: u8 = 0; 610 var rc: u8 = 0;
625 for (spellings.items, gone) |s, g| { 611 for (spellings.items, gone) |s, g| {
626 // Removing what is not there is reported and non-zero — a script 612 // A missing entry makes the command nonzero so scripts do not mistake a
627 // that thinks it dropped a host should learn it was spelled 613 // differently spelled host for a successful removal. Other removals
628 // differently. The rest of the line still applies. 614 // still apply.
629 if (!g) { 615 if (!g) {
630 std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s}); 616 std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s});
631 rc = 1; 617 rc = 1;
632 } 618 }
633 } 619 }
634 // `forget` matches on the EXACT line, so a hand-edited one can only be 620 // `forgetMany` matches exact lines. On failure, print the file so a
635 // removed by typing it byte for byte. Showing the file is what makes 621 // hand-edited entry can be copied byte for byte.
636 // that possible without opening an editor.
637 if (rc != 0) showFile(arena, path); 622 if (rc != 0) showFile(arena, path);
638 return rc; 623 return rc;
639 } 624 }
640 625
641 /// The file verbatim, when the next thing the user must do is name one of 626 /// Print the hosts file verbatim for use with an exact-match removal.
642 /// its lines back at the program.
643 fn showFile(arena: std.mem.Allocator, path: []const u8) void { 627 fn showFile(arena: std.mem.Allocator, path: []const u8) void {
644 const lines = hosts.loadLines(arena, path) catch return; 628 const lines = hosts.loadLines(arena, path) catch return;
645 for (lines.items) |l| std.debug.print(" {s}\n", .{l}); 629 for (lines.items) |l| std.debug.print(" {s}\n", .{l});
646 } 630 }
647 631
648 /// Every spelling is checked BEFORE any of them is written, and the file is 632 /// Validate every spelling before saving all additions in one write, preventing
649 /// written ONCE: an IO error on the third of four must not leave the first 633 /// partial updates when validation or I/O fails.
650 /// two applied and the rest not.
651 fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []const u8) !u8 { 634 fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []const u8) !u8 {
652 for (spellings) |s| { 635 for (spellings) |s| {
653 const spec = hosts.parse(s) catch |err| { 636 const spec = hosts.parse(s) catch |err| {
654 std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) }); 637 std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) });
655 return 2; 638 return 2;
656 }; 639 };
657 // The one refusal that belongs to the transport rather than the grammar: 640 // Reject Unix socket paths that cannot fit in `sun_path`; such an entry
658 // a sun_path that cannot be bound is a host that could never dial, and 641 // could never be dialed successfully.
659 // add time is the only moment the user still sees what they typed.
660 if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2; 642 if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2;
661 } 643 }
662 // Strict: growing a file whose existing content is not understood would 644 // Refuse to extend a file containing unrecognized entries because saving it
663 // re-save garbage as if it had been read. 645 // would legitimize or alter content the parser did not understand.
664 var h = hosts.load(arena, path) catch |err| 646 var h = hosts.load(arena, path) catch |err|
665 return refuseFile(arena, "mux hosts add", path, err); 647 return refuseFile(arena, "mux hosts add", path, err);
666 // Already listed is not a failure: `add` states what the wall should 648 // Adding an existing host is idempotent.
667 // contain, and afterwards it does.
668 for (spellings) |s| _ = try h.add(arena, s); 649 for (spellings) |s| _ = try h.add(arena, s);
669 hosts.save(&h, path) catch |err| { 650 hosts.save(&h, path) catch |err| {
670 std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) }); 651 std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) });
@@ -673,21 +654,19 @@ fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []con
673 return 0; 654 return 0;
674 } 655 }
675 656
676 /// parseArgs takes what argsAlloc produces; the tests must match the type. 657 /// Test helper using the sentinel-terminated argv shape consumed by `parseArgs`.
677 fn parse(comptime argv: []const [:0]const u8) ParseError!ParseResult { 658 fn parse(comptime argv: []const [:0]const u8) ParseError!ConnectionSpec {
678 return parseArgs(argv, null); 659 return parseArgs(argv, null);
679 } 660 }
680 661
681 /// The same, with `MUX_KEY_FILE` set to `env`. 662 /// The same, with `MUX_KEY_FILE` set to `env`.
682 fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseError!ParseResult { 663 fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseError!ConnectionSpec {
683 return parseArgs(argv, env); 664 return parseArgs(argv, env);
684 } 665 }
685 666
686 test "parseArgs: `mux run --resume-fd N` is refused — there is no bare `run`" { 667 test "parseArgs: `mux run --resume-fd N` is rejected because bare `run` is a host" {
687 // The other half of mux.zig's `modeOf: run is a host` pin, and the half 668 // The dispatcher treats `run` as a hostname, leaving `--resume-fd` as an
688 // a user sees: with no alias, the word reaches this parser as a host 669 // unknown client flag. This complements the dispatcher-level test.
689 // spelling and the flag beside it is one no client has. The refusal
690 // prints `usage`, which opens with the four modes.
691 try std.testing.expectError(error.Usage, parse(&.{ "mux", "run", "--resume-fd", "5" })); 670 try std.testing.expectError(error.Usage, parse(&.{ "mux", "run", "--resume-fd", "5" }));
692 } 671 }
693 672
@@ -714,14 +693,12 @@ test "parseArgs: a bare word is a host to hop to" {
714 const h = try parse(&.{ "mux", "vm1" }); 693 const h = try parse(&.{ "mux", "vm1" });
715 try std.testing.expect(h == .host); 694 try std.testing.expect(h == .host);
716 try std.testing.expectEqualStrings("vm1", h.host.name); 695 try std.testing.expectEqualStrings("vm1", h.host.name);
717 // Spelled out rather than written `client.quic_idle_ms_default` — see 696 // Use the documented literal so this test detects an accidental change to
718 // the quic:// test for why asserting against the parser's own constant 697 // the parser's default constant.
719 // could never catch the number changing.
720 try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms); 698 try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms);
721 699
722 // The user@host form is just as bare a word; nothing parses inside it, 700 // Preserve `user@host` as an opaque SSH hostname so SSH configuration such
723 // which is what lets ssh's own config (aliases, ports, ProxyJump) keep 701 // as aliases, ports, and ProxyJump continues to apply.
724 // working untouched.
725 const u = try parse(&.{ "mux", "ubuntu@sandbox-9b70e9" }); 702 const u = try parse(&.{ "mux", "ubuntu@sandbox-9b70e9" });
726 try std.testing.expect(u == .host); 703 try std.testing.expect(u == .host);
727 try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name); 704 try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name);
@@ -732,9 +709,8 @@ test "parseArgs: naming two transports is a conflict, however it is spelled" {
732 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" })); 709 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" }));
733 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "--via", "ssh box mux d proxy" })); 710 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "--via", "ssh box mux d proxy" }));
734 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/a", "--via", "c" })); 711 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/a", "--via", "c" }));
735 // Two of the same kind is the same ambiguity as two different kinds — 712 // Two positional targets conflict just like two different transports.
736 // for the two spellings that carry no flag. A flag repeated is not 713 // Repeating a flag is distinct: the shared parser keeps its last value.
737 // ambiguous, it is corrected: the last value wins, as everywhere else.
738 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "vm2" })); 714 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "vm2" }));
739 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "quic://b:2" })); 715 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "quic://b:2" }));
740 const s2 = try parse(&.{ "mux", "--sock", "/a", "--sock", "/b" }); 716 const s2 = try parse(&.{ "mux", "--sock", "/a", "--sock", "/b" });
@@ -748,20 +724,19 @@ test "hosts: add refuses a session by name, rm reports an unlisted host, the fil
748 var tmp = try TmpDir.make(); 724 var tmp = try TmpDir.make();
749 defer tmp.cleanup(); 725 defer tmp.cleanup();
750 var buf: [256]u8 = undefined; 726 var buf: [256]u8 = undefined;
751 // Under `mux/` like the real one: `add` must make the directory it 727 // Use the production directory shape and verify that `add` creates a
752 // writes into, because a first run has no state at all. 728 // missing parent directory on first use.
753 const path = try std.fmt.bufPrint(&buf, "{s}/mux/hosts", .{tmp.path()}); 729 const path = try std.fmt.bufPrint(&buf, "{s}/mux/hosts", .{tmp.path()});
754 730
755 // A `#SESSION` tail is refused at usage altitude, in the words that name 731 // Reject a session suffix before writing any state.
756 // the new rule, and nothing is written.
757 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "box#build" }, path)); 732 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "box#build" }, path));
758 // A flag-shaped word is not a host, and `--sock PATH`'s two words are 733 // A flag-shaped word is not a host, and `--sock PATH`'s two words are
759 // one spelling. 734 // one spelling.
760 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "-A" }, path)); 735 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "-A" }, path));
761 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "box" }, path)); 736 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "box" }, path));
762 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "--sock", "/tmp/x.sock" }, path)); 737 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "--sock", "/tmp/x.sock" }, path));
763 // Removing what is not there is reported and non-zero: a script that 738 // A missing removal target returns nonzero so scripts can detect a spelling
764 // thinks it dropped a host should learn it was spelled differently. 739 // mismatch.
765 try std.testing.expectEqual(@as(u8, 1), try hostsMain(alloc, &[_][:0]const u8{ "rm", "nowhere" }, path)); 740 try std.testing.expectEqual(@as(u8, 1), try hostsMain(alloc, &[_][:0]const u8{ "rm", "nowhere" }, path));
766 741
767 var h = try hosts.load(alloc, path); 742 var h = try hosts.load(alloc, path);
@@ -779,9 +754,8 @@ test "hosts: add refuses a session by name, rm reports an unlisted host, the fil
779 // Neither verb is a subcommand this program has. 754 // Neither verb is a subcommand this program has.
780 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path)); 755 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path));
781 756
782 // `mux hosts --help` is asserted in test/e2e_09_hosts.sh, not here: the 757 // `test/e2e_09_hosts.sh` covers `mux hosts --help`; invoking it here would
783 // answer is a page on fd 1, and a unit test that let it out would write 758 // write the help page to the test runner's stdout protocol.
784 // over the test runner's own protocol stream.
785 } 759 }
786 760
787 test "hosts: a line the file cannot hold exits 2 wherever the file is read" { 761 test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
@@ -790,12 +764,12 @@ test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
790 defer tmp.cleanup(); 764 defer tmp.cleanup();
791 var buf: [256]u8 = undefined; 765 var buf: [256]u8 = undefined;
792 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); 766 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
793 // Two hosts, one of them hand-edited into a spelling the grammar 767 // Include a hand-edited invalid entry to verify that `rm` can repair state
794 // refuses — the state a user is actually in when they reach for `rm`. 768 // the current grammar cannot parse.
795 try hosts.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n"); 769 try hosts.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n");
796 770
797 // The write path and the wall agree: 2, the user's file to fix. `rm` 771 // Both adding and loading reject the invalid file with exit code 2. Removal
798 // reads verbatim and repairs it, and only then does `add` grow it. 772 // reads verbatim, repairs the file, and then additions may resume.
799 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path)); 773 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
800 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box#old" }, path)); 774 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box#old" }, path));
801 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path)); 775 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
@@ -820,21 +794,20 @@ test "hosts list: a line the grammar refuses is named, not a refusal of the list
820 const out = try std.fs.createFileAbsolute(out_path, .{}); 794 const out = try std.fs.createFileAbsolute(out_path, .{});
821 const rc = try hostsList(arena, path, out.handle); 795 const rc = try hostsList(arena, path, out.handle);
822 out.close(); 796 out.close();
823 // The one command a user runs to SEE the bad line must not refuse to run. 797 // Listing must still display an invalid line so it can be repaired.
824 try std.testing.expectEqual(@as(u8, 0), rc); 798 try std.testing.expectEqual(@as(u8, 0), rc);
825 799
826 const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 4096); 800 const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 4096);
827 defer alloc.free(text); 801 defer alloc.free(text);
828 try std.testing.expect(std.mem.indexOf(u8, text, "box#old\t[bad host: names a session") != null); 802 try std.testing.expect(std.mem.indexOf(u8, text, "box#old\t[bad host: names a session") != null);
829 // And the host after it is still asked and still answered for. 803 // An invalid row must not prevent subsequent valid hosts from being polled.
830 try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null); 804 try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null);
831 } 805 }
832 806
833 test "refuseFile: the exit code names whose fault it was, and a bad line in the file does not change it" { 807 test "refuseFile: the exit code names whose fault it was, and a bad line in the file does not change it" {
834 // 2 is "your spelling, here are the lines"; 1 is "this file could not be 808 // Exit code 2 identifies correctable syntax, while exit code 1 identifies
835 // read". Re-parsing the file to choose between them let an allocator 809 // an operational failure. Classification must use the original error even
836 // failure on a file that ALSO holds one stale line print "OutOfMemory" 810 // when the file also contains an invalid line.
837 // and then exit 2 with a repair list for a fault the user cannot fix.
838 const alloc = std.testing.allocator; 811 const alloc = std.testing.allocator;
839 var tmp = try TmpDir.make(); 812 var tmp = try TmpDir.make();
840 defer tmp.cleanup(); 813 defer tmp.cleanup();
@@ -851,19 +824,16 @@ test "refuseFile: the exit code names whose fault it was, and a bad line in the
851 } 824 }
852 825
853 test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" { 826 test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" {
854 // `hosts.loadLines` takes lines up to a MiB and `mux hosts rm` matches 827 // `hosts.loadLines` accepts one-MiB lines and `mux hosts rm` matches exact
855 // byte for byte, so a row this command drops is a line the user can 828 // bytes, so listing must preserve the complete line needed for repair.
856 // neither see nor type back — the two halves of the repair loop have to
857 // agree about which lines exist.
858 const alloc = std.testing.allocator; 829 const alloc = std.testing.allocator;
859 var tmp = try TmpDir.make(); 830 var tmp = try TmpDir.make();
860 defer tmp.cleanup(); 831 defer tmp.cleanup();
861 var buf: [512]u8 = undefined; 832 var buf: [512]u8 = undefined;
862 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); 833 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
863 834
864 // A host the grammar refuses, so the row is produced without a dial 835 // Use an invalid host to avoid dialing; this isolates formatting of a line
865 // and the check is about the printing and nothing else. Long by the 836 // longer than the fixed status buffer.
866 // host name, which is the half a fixed row buffer would have cut.
867 var long: [900]u8 = undefined; 837 var long: [900]u8 = undefined;
868 @memset(&long, 'h'); 838 @memset(&long, 'h');
869 long[899] = '#'; 839 long[899] = '#';
@@ -882,7 +852,7 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m
882 852
883 const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 8192); 853 const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 8192);
884 defer alloc.free(text); 854 defer alloc.free(text);
885 // Whole, and with its verdict: a truncated line is one `rm` cannot take. 855 // The output must contain the entire line and its status suffix.
886 try std.testing.expect(std.mem.startsWith(u8, text, &long)); 856 try std.testing.expect(std.mem.startsWith(u8, text, &long));
887 try std.testing.expect(std.mem.endsWith( 857 try std.testing.expect(std.mem.endsWith(
888 u8, 858 u8,
@@ -902,12 +872,12 @@ test "wall: a LISTED local daemon that nothing answers on is one this wall start
902 872
903 var h: hosts.Hosts = .{}; 873 var h: hosts.Hosts = .{};
904 defer h.deinit(alloc); 874 defer h.deinit(alloc);
905 // Plural, and the local line is not the first: a wall is hosts, and the 875 // Include multiple entries with the local socket later in the file to show
906 // local one is not privileged in the file. 876 // that its position does not affect startup detection.
907 try std.testing.expect(try h.add(alloc, "box")); 877 try std.testing.expect(try h.add(alloc, "box"));
908 try std.testing.expect(try h.add(alloc, line)); 878 try std.testing.expect(try h.add(alloc, line));
909 879
910 // Asked of the OS, not of the file: nothing is bound yet. 880 // The state file contains the socket, but no process is listening yet.
911 try std.testing.expect(localNeedsStart(&h, sock)); 881 try std.testing.expect(localNeedsStart(&h, sock));
912 882
913 const addr = try std.net.Address.initUnix(sock); 883 const addr = try std.net.Address.initUnix(sock);
@@ -933,10 +903,9 @@ test "hosts: a session count is the daemon's lines, not its bytes" {
933 test "parseArgs: unknown flags and valueless flags are usage errors" { 903 test "parseArgs: unknown flags and valueless flags are usage errors" {
934 try std.testing.expectError(error.Usage, parse(&.{ "mux", "--wat" })); 904 try std.testing.expectError(error.Usage, parse(&.{ "mux", "--wat" }));
935 try std.testing.expectError(error.Usage, parse(&.{ "mux", "-x" })); 905 try std.testing.expectError(error.Usage, parse(&.{ "mux", "-x" }));
936 // A flag whose value is missing must not be mistaken for a bare host. 906 // Missing flag values must not be reinterpreted as hostnames. Keep every
937 // Every value-taking flag has to have a row here: one outcome answers 907 // value-taking client flag in this table so newly added flags require a
938 // for all of them, so a flag added without a row is a flag nobody 908 // corresponding test update.
939 // actually checked.
940 inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| { 909 inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| {
941 try std.testing.expectError(error.Usage, parse(&.{ "mux", flag })); 910 try std.testing.expectError(error.Usage, parse(&.{ "mux", flag }));
942 } 911 }
@@ -946,8 +915,8 @@ test "parseArgs: --help is the usage someone asked for, wherever it sits" {
946 try std.testing.expectError(error.Help, parse(&.{ "mux", "--help" })); 915 try std.testing.expectError(error.Help, parse(&.{ "mux", "--help" }));
947 try std.testing.expectError(error.Help, parse(&.{ "mux", "-h" })); 916 try std.testing.expectError(error.Help, parse(&.{ "mux", "-h" }));
948 try std.testing.expectError(error.Help, parse(&.{ "mux", "vm1", "--help" })); 917 try std.testing.expectError(error.Help, parse(&.{ "mux", "vm1", "--help" }));
949 // Even where a value belongs, and beside a line that would otherwise be 918 // Help takes precedence even where a value is missing or another argument
950 // refused: asking for the usage is not a way to mistype a flag. 919 // is invalid.
951 try std.testing.expectError(error.Help, parse(&.{ "mux", "--sock", "--help" })); 920 try std.testing.expectError(error.Help, parse(&.{ "mux", "--sock", "--help" }));
952 try std.testing.expectError(error.Help, parse(&.{ "mux", "--wat", "--help" })); 921 try std.testing.expectError(error.Help, parse(&.{ "mux", "--wat", "--help" }));
953 } 922 }
@@ -957,14 +926,11 @@ test "-A rides every transport spelling" {
957 try std.testing.expect((try parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" })).attach.agent); 926 try std.testing.expect((try parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" })).attach.agent);
958 try std.testing.expect((try parse(&.{ "mux", "quic://h:1", "-A" })).quic.agent); 927 try std.testing.expect((try parse(&.{ "mux", "quic://h:1", "-A" })).quic.agent);
959 try std.testing.expect(!(try parse(&.{ "mux", "somehost" })).host.agent); 928 try std.testing.expect(!(try parse(&.{ "mux", "somehost" })).host.agent);
960 // `-A` is an alias, not a flag of its own, so the field's own spelling 929 // Verify both the short alias and the generated long spelling.
961 // has to work too.
962 try std.testing.expect((try parse(&.{ "mux", "--agent", "--sock", "/tmp/x.sock" })).attach.agent); 930 try std.testing.expect((try parse(&.{ "mux", "--agent", "--sock", "/tmp/x.sock" })).attach.agent);
963 931
964 // The usage says `mux -A` is how the wall gets an agent, and this is 932 // Bare `mux` bypasses parsing and opens the wall without forwarding. Adding
965 // why: `main` sends a BARE `mux` (argv of one) to `wallOfHosts`, which 933 // `-A` produces a default-local attach with agent forwarding enabled.
966 // takes no agent, while `-A` makes argv two words and lands here — an
967 // attach that names no transport, so the default local socket, armed.
968 const armed = try parse(&.{ "mux", "-A" }); 934 const armed = try parse(&.{ "mux", "-A" });
969 try std.testing.expect(armed.attach.agent); 935 try std.testing.expect(armed.attach.agent);
970 try std.testing.expect(armed.attach.sock == null); 936 try std.testing.expect(armed.attach.sock == null);
@@ -976,13 +942,12 @@ test "parseArgs: quic:// is a transport like any other" {
976 try std.testing.expect(q == .quic); 942 try std.testing.expect(q == .quic);
977 try std.testing.expectEqualStrings("box:4433", q.quic.host_port); 943 try std.testing.expectEqualStrings("box:4433", q.quic.host_port);
978 try std.testing.expectEqualStrings("/k", q.quic.key.?); 944 try std.testing.expectEqualStrings("/k", q.quic.key.?);
979 // Spelled out rather than written `client.quic_idle_ms_default`: 945 // Compare with the documented literal rather than the source constant so
980 // asserting against the same constant the parser reads would hold for 946 // the test detects an accidental default change.
981 // any value, so it could never catch the number changing.
982 try std.testing.expectEqual(@as(u32, 15_000), q.quic.idle_ms); 947 try std.testing.expectEqual(@as(u32, 15_000), q.quic.idle_ms);
983 948
984 // Counted with the rest: naming it alongside another transport is the 949 // A QUIC target conflicts with every other explicitly named transport,
985 // same ambiguity as any other pairing, whichever order they arrive in. 950 // regardless of argument order.
986 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" })); 951 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" }));
987 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" })); 952 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" }));
988 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" })); 953 try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" }));
@@ -994,8 +959,8 @@ test "parseArgs: quic:// is a transport like any other" {
994 } 959 }
995 960
996 test "parseArgs: a quic attach without a key defers to main, which resolves it" { 961 test "parseArgs: a quic attach without a key defers to main, which resolves it" {
997 // No --key and no environment: not a refusal any more. main has a 962 // Without an explicit or environment key, parsing defers default-path
998 // default path to try and parse cannot see the filesystem. 963 // resolution to `main`.
999 const q = try parse(&.{ "mux", "quic://a:1" }); 964 const q = try parse(&.{ "mux", "quic://a:1" });
1000 try std.testing.expect(q == .quic); 965 try std.testing.expect(q == .quic);
1001 try std.testing.expect(q.quic.key == null); 966 try std.testing.expect(q.quic.key == null);
@@ -1015,8 +980,8 @@ test "parseArgs: a quic attach without a key defers to main, which resolves it"
1015 const both = try parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key"); 980 const both = try parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key");
1016 try std.testing.expectEqualStrings("/flag.key", both.quic.key.?); 981 try std.testing.expectEqualStrings("/flag.key", both.quic.key.?);
1017 982
1018 // A key with no quic:// is ignored rather than refused: MUX_KEY_FILE 983 // Ignore `MUX_KEY_FILE` for non-QUIC targets so an exported value cannot
1019 // exported in a shell must not break an ordinary local attach. 984 // break an ordinary local attach.
1020 try std.testing.expect((try parseEnv(&.{"mux"}, "/env.key")) == .attach); 985 try std.testing.expect((try parseEnv(&.{"mux"}, "/env.key")) == .attach);
1021 try std.testing.expect((try parse(&.{ "mux", "--key", "/k" })) == .attach); 986 try std.testing.expect((try parse(&.{ "mux", "--key", "/k" })) == .attach);
1022 try std.testing.expect((try parse(&.{ "mux", "--key", "/k", "vm1" })) == .host); 987 try std.testing.expect((try parse(&.{ "mux", "--key", "/k", "vm1" })) == .host);
@@ -1026,10 +991,8 @@ test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" {
1026 const t = try parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" }); 991 const t = try parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" });
1027 try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms); 992 try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms);
1028 993
1029 // A bare HOST ends in a QUIC link too, so the flag has to reach it — 994 // SSH handoff can end in QUIC, so a bare host must retain the configured
1030 // the .host result carried no idle_ms at all and the flag was accepted 995 // idle timeout instead of accepting and discarding the flag.
1031 // and then dropped, which is worse than refusing it. The hub's HOST
1032 // tiles were already right; this is mux catching up.
1033 const h = try parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" }); 996 const h = try parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" });
1034 try std.testing.expect(h == .host); 997 try std.testing.expect(h == .host);
1035 try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms); 998 try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms);
@@ -1045,9 +1008,8 @@ test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" {
1045 test "parseArgs: --version wins wherever it appears" { 1008 test "parseArgs: --version wins wherever it appears" {
1046 try std.testing.expectError(error.Version, parse(&.{ "mux", "--version" })); 1009 try std.testing.expectError(error.Version, parse(&.{ "mux", "--version" }));
1047 try std.testing.expectError(error.Version, parse(&.{ "mux", "--sock", "/x", "--version" })); 1010 try std.testing.expectError(error.Version, parse(&.{ "mux", "--sock", "/x", "--version" }));
1048 // Including beside a line that would otherwise be a conflict or a 1011 // Version takes precedence over conflicts and syntax errors elsewhere on
1049 // mistake: asking a binary its version must answer whatever else is on 1012 // the command line.
1050 // the line.
1051 try std.testing.expectError(error.Version, parse(&.{ "mux", "vm1", "--sock", "/x", "--version" })); 1013 try std.testing.expectError(error.Version, parse(&.{ "mux", "vm1", "--sock", "/x", "--version" }));
1052 try std.testing.expectError(error.Version, parse(&.{ "mux", "--wat", "--version" })); 1014 try std.testing.expectError(error.Version, parse(&.{ "mux", "--wat", "--version" }));
1053 } 1015 }
@@ -1070,23 +1032,20 @@ test "parseArgs: no --session means the empty wire name (older-daemon compat)" {
1070 try std.testing.expectEqualStrings("", s.attach.session); 1032 try std.testing.expectEqualStrings("", s.attach.session);
1071 } 1033 }
1072 1034
1073 // Forces semantic analysis of every pub decl under `zig build test`, so an 1035 // Ensure every public declaration is semantically analyzed during tests;
1074 // unreferenced decl must at least compile (the silent-module-loss hazard, 1036 // `std.meta.declarations` does not include private declarations.
1075 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
1076 test { 1037 test {
1077 std.testing.refAllDeclsRecursive(@This()); 1038 std.testing.refAllDeclsRecursive(@This());
1078 } 1039 }
1079 1040
1080 /// A socket that answers the way a real ssh-agent does, or one that hangs 1041 /// Test server that either returns a valid ssh-agent reply or immediately
1081 /// up the way the daemon does for a session nobody has offered an agent to. 1042 /// closes like a mux session with no forwarded agent. A thread is required
1082 /// A thread because the probe is a round trip — it writes before it reads, 1043 /// because the probe performs a write/read round trip.
1083 /// so a listener nobody is accepting on cannot play either part.
1084 const AgentStub = struct { 1044 const AgentStub = struct {
1085 listener: *std.net.Server, 1045 listener: *std.net.Server,
1086 answer: bool, 1046 answer: bool,
1087 /// Set only when the exact bytes `ssh-add -l` sends arrived. Asserted 1047 /// Set only after receiving the exact identities request used by
1088 /// by the test, because every other expectation here is also satisfied 1048 /// `ssh-add -l`, proving that the probe sent the expected bytes.
1089 /// by a probe that asks nothing and times out.
1090 asked: bool = false, 1049 asked: bool = false,
1091 1050
1092 fn run(self: *AgentStub) void { 1051 fn run(self: *AgentStub) void {
@@ -1095,24 +1054,21 @@ const AgentStub = struct {
1095 if (!self.answer) return; 1054 if (!self.answer) return;
1096 var buf: [64]u8 = undefined; 1055 var buf: [64]u8 = undefined;
1097 const n = std.posix.read(conn.stream.handle, &buf) catch return; 1056 const n = std.posix.read(conn.stream.handle, &buf) catch return;
1098 // Spelled out rather than compared against `agent_request_identities`: 1057 // Compare with a literal because `agent_request_identities` itself is
1099 // the constant IS what is under test, and a test that reads it back 1058 // under test; reusing it here could not detect incorrect bytes.
1100 // would accept any bytes the client decided to send.
1101 self.asked = std.mem.eql(u8, buf[0..n], &[_]u8{ 0, 0, 0, 1, 11 }); 1059 self.asked = std.mem.eql(u8, buf[0..n], &[_]u8{ 0, 0, 0, 1, 11 });
1102 if (!self.asked) return; 1060 if (!self.asked) return;
1103 // SSH_AGENT_IDENTITIES_ANSWER carrying zero keys. An agent holding 1061 // `SSH_AGENT_IDENTITIES_ANSWER` containing zero keys. The probe checks
1104 // nothing still proves an agent is there, which is the whole 1062 // for a protocol response, not for a nonempty key list.
1105 // question — the preflight never looks at the key list.
1106 const reply = [_]u8{ 0, 0, 0, 5, 12, 0, 0, 0, 0 }; 1063 const reply = [_]u8{ 0, 0, 0, 5, 12, 0, 0, 0, 0 };
1107 _ = std.posix.write(conn.stream.handle, &reply) catch {}; 1064 _ = std.posix.write(conn.stream.handle, &reply) catch {};
1108 } 1065 }
1109 }; 1066 };
1110 1067
1111 test "agentReachable: an agent answers; a socket that hangs up is not one" { 1068 test "agentReachable: an agent answers; a socket that hangs up is not one" {
1112 // The states a user is actually in: an agent running, the daemon's own 1069 // Cover a live agent, a mux agent socket with no offering client, a stale
1113 // per-session socket with nobody offering behind it, a variable 1070 // socket path, and an absent environment value. Only the live agent should
1114 // pointing at an agent that has died, and no variable at all. Only the 1071 // allow `-A`.
1115 // first may attach with `-A`.
1116 var tmp = try TmpDir.make(); 1072 var tmp = try TmpDir.make();
1117 defer tmp.cleanup(); 1073 defer tmp.cleanup();
1118 var buf: [128]u8 = undefined; 1074 var buf: [128]u8 = undefined;
@@ -1131,10 +1087,9 @@ test "agentReachable: an agent answers; a socket that hangs up is not one" {
1131 } 1087 }
1132 std.fs.deleteFileAbsolute(sock) catch {}; 1088 std.fs.deleteFileAbsolute(sock) catch {};
1133 1089
1134 // The case a bare connect cannot see, and the reason this is a request 1090 // Inside a session, `SSH_AUTH_SOCK` names the daemon. The connection can
1135 // and not a dial: inside a session `SSH_AUTH_SOCK` names the DAEMON, 1091 // succeed even though the daemon closes it after finding no offering
1136 // which accepts every connection and only then decides it has no 1092 // client, which is why the probe must exchange a request.
1137 // client to route it to. The connect succeeds; the exchange does not.
1138 { 1093 {
1139 const addr = try std.net.Address.initUnix(sock); 1094 const addr = try std.net.Address.initUnix(sock);
1140 var listener = try addr.listen(.{}); 1095 var listener = try addr.listen(.{});
@@ -1146,10 +1101,9 @@ test "agentReachable: an agent answers; a socket that hangs up is not one" {
1146 } 1101 }
1147 std.fs.deleteFileAbsolute(sock) catch {}; 1102 std.fs.deleteFileAbsolute(sock) catch {};
1148 1103
1149 // Fail OPEN on silence, closed only on a hangup. A listener nobody is 1104 // Treat silence as reachable and immediate EOF as unavailable. A slow or
1150 // accepting on is the shape a slow or wedged agent presents, and a 1105 // wedged real agent may time out, while the daemon's no-offerer response is
1151 // slow agent is still an agent; the daemon's refusal is immediate, so 1106 // an immediate close.
1152 // taking too long is not what separates the two.
1153 { 1107 {
1154 const addr = try std.net.Address.initUnix(sock); 1108 const addr = try std.net.Address.initUnix(sock);
1155 var listener = try addr.listen(.{}); 1109 var listener = try addr.listen(.{});
@@ -1157,14 +1111,12 @@ test "agentReachable: an agent answers; a socket that hangs up is not one" {
1157 try std.testing.expect(agentReachable(sock)); 1111 try std.testing.expect(agentReachable(sock));
1158 } 1112 }
1159 1113
1160 // The stale case, and why this DIALS rather than reading the variable: the 1114 // A stale socket file still exists after its agent dies, so the probe must
1161 // agent is gone but its socket file is still there, so the path stats fine 1115 // connect rather than merely checking the path.
1162 // and the connect is refused — what a killed agent leaves behind.
1163 try std.fs.accessAbsolute(sock, .{}); 1116 try std.fs.accessAbsolute(sock, .{});
1164 try std.testing.expect(!agentReachable(sock)); 1117 try std.testing.expect(!agentReachable(sock));
1165 1118
1166 // And the two cheaper absences, so every one of a user's states is 1119 // Also cover an empty path and a path with no socket.
1167 // covered by the one probe.
1168 std.fs.deleteFileAbsolute(sock) catch {}; 1120 std.fs.deleteFileAbsolute(sock) catch {};
1169 try std.testing.expect(!agentReachable(sock)); 1121 try std.testing.expect(!agentReachable(sock));
1170 try std.testing.expect(!agentReachable("")); 1122 try std.testing.expect(!agentReachable(""));
src/cli/muxa.zig
Old New
@@ -1,11 +1,11 @@
1 //! `mux a`: the agent-facing mode. Every verb prints one JSON object on stdout; 1 //! `mux a`: the agent-facing mode. Every verb prints one JSON object on stdout;
2 //! failures print `{"error": "..."}` and exit nonzero. Attaches at 0x0 always — 2 //! failures print `{"error": "..."}` and exit nonzero. Attachments always use
3 //! an agent must never claim the grid out from under the human's size. 3 //! 0x0 so automation cannot replace the human client's terminal dimensions.
4 //! 4 //!
5 //! Five exit codes: 0 the answer, 1 an error object, 2 argv did not parse, 5 //! Exit codes are 0 for a completed request, 1 for a JSON error, 2 for invalid
6 //! 3 the wait timed out, 4 the object could not be written at all. A command's 6 //! arguments, 3 for timeout, and 4 when no JSON object could be written. A
7 //! own code is never this mode's — it is the `exit_code` FIELD, so a `run` 7 //! session command's status appears in the `exit_code` field and does not become
8 //! whose command failed still exits 0 because the question was answered. 8 //! this process's exit code.
9 const std = @import("std"); 9 const std = @import("std");
10 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
11 const sockpath = @import("sockpath"); 11 const sockpath = @import("sockpath");
@@ -29,47 +29,39 @@ const usage =
29 \\ 29 \\
30 ; 30 ;
31 31
32 const Verb = enum { status, capture, send, run, await }; 32 const AgentVerb = enum { status, capture, send, run, await };
33 33
34 const Opts = struct { 34 const AgentArguments = struct {
35 sock: ?[]const u8 = null, 35 sock: ?[]const u8 = null,
36 /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are 36 /// Remote daemon QUIC endpoint. Every verb uses the same frames and JSON
37 /// identical over it — same frames, same JSON — which is the whole 37 /// shape over local and QUIC transports.
38 /// claim: an agent driving a session over a WAN types one more flag.
39 quic: ?[]const u8 = null, 38 quic: ?[]const u8 = null,
40 /// `--key PATH`, the highest-priority spelling of the QUIC key. Null 39 /// Explicit QUIC key path. Null still allows `MUX_KEY_FILE` and the XDG
41 /// does NOT mean "no key": `$MUX_KEY_FILE` and the XDG default are 40 /// default to be resolved after parsing.
42 /// still to be tried, and neither is parse's to look at (xdg.pickKey
43 /// and xdg.resolveKeyPath own that order here as they do for mux).
44 key: ?[]const u8 = null, 41 key: ?[]const u8 = null,
45 settle: u32 = 0, 42 settle: u32 = 0,
46 // Never 0 by default: the daemon reads a 0 timeout on await_req as "no 43 // The daemon interprets zero as an unbounded await, so the client default
47 // bound at all" (documented on AwaitReq), so an agent client that defaulted to 0 44 // must remain nonzero.
48 // would turn every await into an unbounded wait.
49 timeout: u32 = 30_000, 45 timeout: u32 = 30_000,
50 vt: bool = false, 46 vt: bool = false,
51 /// Optional so that only a name that was TYPED reaches the type: `""` 47 /// Validate only an explicitly supplied name. `sessionName` converts null
52 /// is the wire's own default spelling and would fail a rule written 48 /// to the wire's empty-string encoding for the default session.
53 /// for a name. `sessionName` is what the frames actually carry.
54 session: ?proto.SessionName = null, 49 session: ?proto.SessionName = null,
55 /// Null until `positional` meets a verb; a returned Opts has one. 50 /// Null until `positional` meets a verb; a returned AgentArguments has one.
56 _verb: ?Verb = null, 51 _verb: ?AgentVerb = null,
57 _arg: ?[]const u8 = null, 52 _arg: ?[]const u8 = null,
58 53
59 pub fn sessionName(o: Opts) []const u8 { 54 pub fn sessionName(o: AgentArguments) []const u8 {
60 // ONE name for the attach AND every ask after it, so the daemon's 55 // Use the same name for attachment and every subsequent request so the
61 // attached-tail equality rule (server.zig) never sees a mismatch 56 // daemon's attached-tail equality check cannot see a mismatch.
62 // out of this binary. Empty is the wire's own default spelling, so
63 // a bare `mux a status` builds the frames it always did.
64 return if (o.session) |n| n.name else ""; 57 return if (o.session) |n| n.name else "";
65 } 58 }
66 59
67 /// The first bare word is the verb and the next is its argument. A word 60 /// Parse the first positional word as a verb and the next as its optional
68 /// that names no verb is refused where a verb belongs, and a second 61 /// argument. Reject unknown verbs and additional positional arguments.
69 /// argument is refused too: no verb here takes two. 62 pub fn positional(self: *AgentArguments, word: []const u8) bool {
70 pub fn positional(self: *Opts, word: []const u8) bool {
71 if (self._verb == null) { 63 if (self._verb == null) {
72 self._verb = std.meta.stringToEnum(Verb, word) orelse return false; 64 self._verb = std.meta.stringToEnum(AgentVerb, word) orelse return false;
73 return true; 65 return true;
74 } 66 }
75 if (self._arg != null) return false; 67 if (self._arg != null) return false;
@@ -79,22 +71,18 @@ const Opts = struct {
79 }; 71 };
80 72
81 comptime { 73 comptime {
82 cliflags.assertDocumented(Opts, usage, &.{}); 74 cliflags.assertDocumented(AgentArguments, usage, &.{});
83 } 75 }
84 76
85 fn parseArgs(args: []const [:0]const u8) cliflags.ParseError!Opts { 77 fn parseArgs(args: []const [:0]const u8) cliflags.ParseError!AgentArguments {
86 var o: Opts = .{}; 78 var o: AgentArguments = .{};
87 try cliflags.parseStrict(Opts, &o, args[1..]); 79 try cliflags.parseStrict(AgentArguments, &o, args[1..]);
88 if (o._verb == null) return error.Usage; 80 if (o._verb == null) return error.Usage;
89 81
90 // Name ONE transport. A `--sock` silently ignored beside a `--quic` 82 // Require one transport. Silently preferring QUIC over an explicit socket
91 // would send an agent's frames somewhere other than the socket it 83 // would send requests to a different daemon than the caller named.
92 // named, and the two answers differ — this is the mistake `mux`
93 // refuses as `.conflict` for the same reason.
94 if (o.quic != null and o.sock != null) return error.Usage; 84 if (o.quic != null and o.sock != null) return error.Usage;
95 // A key with nothing to authenticate to, refused exactly where the daemon 85 // A key without QUIC has no transport to authenticate and is always invalid.
96 // refuses it: there is no reading of `--key` without `--quic` that
97 // makes it sensible, and the unix socket has no key at all.
98 if (o.key != null and o.quic == null) return error.Usage; 86 if (o.key != null and o.quic == null) return error.Usage;
99 return o; 87 return o;
100 } 88 }
@@ -131,10 +119,8 @@ fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
131 try out.append(alloc, s[i]); 119 try out.append(alloc, s[i]);
132 continue; 120 continue;
133 } 121 }
134 // A backslash with nothing after it is an unfinished escape, and it 122 // A trailing backslash is an incomplete escape and is rejected like an
135 // is refused like any other one we cannot read (\q). Passing it 123 // unknown escape such as `\q`.
136 // through as a literal would be the single case where a typo in an
137 // escape reaches the pty instead of being reported.
138 if (i + 1 >= s.len) return error.BadEscape; 124 if (i + 1 >= s.len) return error.BadEscape;
139 i += 1; 125 i += 1;
140 switch (s[i]) { 126 switch (s[i]) {
@@ -160,14 +146,13 @@ test "decodeEscapes covers the sequences send needs" {
160 defer alloc.free(got); 146 defer alloc.free(got);
161 try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got); 147 try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got);
162 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q")); 148 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q"));
163 // A dangling backslash is an escape the caller did not finish writing, 149 // Reject a dangling backslash rather than passing it through literally.
164 // and it is refused rather than passed through as a literal.
165 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "ok\\")); 150 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "ok\\"));
166 } 151 }
167 152
168 test "parseArgs verbs and flags" { 153 test "parseArgs verbs and flags" {
169 const a1 = [_][:0]const u8{ "a", "status" }; 154 const a1 = [_][:0]const u8{ "a", "status" };
170 try std.testing.expectEqual(Verb.status, (try parseArgs(&a1))._verb.?); 155 try std.testing.expectEqual(AgentVerb.status, (try parseArgs(&a1))._verb.?);
171 const a2 = [_][:0]const u8{ "a", "run", "--timeout", "5000", "make test" }; 156 const a2 = [_][:0]const u8{ "a", "run", "--timeout", "5000", "make test" };
172 const o2 = try parseArgs(&a2); 157 const o2 = try parseArgs(&a2);
173 try std.testing.expectEqual(@as(u32, 5000), o2.timeout); 158 try std.testing.expectEqual(@as(u32, 5000), o2.timeout);
@@ -175,30 +160,26 @@ test "parseArgs verbs and flags" {
175 const a3 = [_][:0]const u8{ "a", "bogus" }; 160 const a3 = [_][:0]const u8{ "a", "bogus" };
176 try std.testing.expectError(error.Usage, parseArgs(&a3)); 161 try std.testing.expectError(error.Usage, parseArgs(&a3));
177 162
178 // The verb is a positional, so a flag may sit before it — one grammar, 163 // The verb is positional, so ordinary flags may precede it.
179 // not a verb slot with rules of its own.
180 const early = [_][:0]const u8{ "a", "--vt", "capture" }; 164 const early = [_][:0]const u8{ "a", "--vt", "capture" };
181 const oe = try parseArgs(&early); 165 const oe = try parseArgs(&early);
182 try std.testing.expectEqual(Verb.capture, oe._verb.?); 166 try std.testing.expectEqual(AgentVerb.capture, oe._verb.?);
183 try std.testing.expect(oe.vt); 167 try std.testing.expect(oe.vt);
184 168
185 // Only the FIRST bare word is read as a verb: a verb-shaped argument is 169 // Only the first positional word is a verb; a later verb-shaped word is the
186 // the verb's argument, which is what `mux a send status` has to mean. 170 // command argument.
187 const shadow = [_][:0]const u8{ "a", "send", "status" }; 171 const shadow = [_][:0]const u8{ "a", "send", "status" };
188 const os = try parseArgs(&shadow); 172 const os = try parseArgs(&shadow);
189 try std.testing.expectEqual(Verb.send, os._verb.?); 173 try std.testing.expectEqual(AgentVerb.send, os._verb.?);
190 try std.testing.expectEqualStrings("status", os._arg.?); 174 try std.testing.expectEqualStrings("status", os._arg.?);
191 175
192 // A line whose every word is a flag names no verb, and is the same 176 // An invocation containing only flags still lacks a required verb.
193 // usage mistake a bare `mux a` is.
194 const verbless = [_][:0]const u8{ "a", "--vt" }; 177 const verbless = [_][:0]const u8{ "a", "--vt" };
195 try std.testing.expectError(error.Usage, parseArgs(&verbless)); 178 try std.testing.expectError(error.Usage, parseArgs(&verbless));
196 } 179 }
197 180
198 test "parseArgs: -- hands the rest to the verb, flags and all" { 181 test "parseArgs: -- hands the rest to the verb, flags and all" {
199 // Without the end-of-flags marker this is an unknown flag and the whole 182 // Without `--`, a dash-prefixed key sequence is an unknown flag.
200 // invocation is refused — the exact shape an agent sends when a key
201 // sequence starts with a dash.
202 const dashed = [_][:0]const u8{ "a", "send", "-n foo" }; 183 const dashed = [_][:0]const u8{ "a", "send", "-n foo" };
203 try std.testing.expectError(error.Usage, parseArgs(&dashed)); 184 try std.testing.expectError(error.Usage, parseArgs(&dashed));
204 185
@@ -207,22 +188,20 @@ test "parseArgs: -- hands the rest to the verb, flags and all" {
207 try std.testing.expectEqual(@as(u32, 50), o.settle); 188 try std.testing.expectEqual(@as(u32, 50), o.settle);
208 try std.testing.expectEqualStrings("-n foo", o._arg.?); 189 try std.testing.expectEqualStrings("-n foo", o._arg.?);
209 190
210 // Past the marker, a flag spelling is just text — and a second 191 // After `--`, flag-shaped words are text, but a third positional argument
211 // positional is still one too many. 192 // is still invalid.
212 const flagish = [_][:0]const u8{ "a", "run", "--", "--timeout" }; 193 const flagish = [_][:0]const u8{ "a", "run", "--", "--timeout" };
213 try std.testing.expectEqualStrings("--timeout", (try parseArgs(&flagish))._arg.?); 194 try std.testing.expectEqualStrings("--timeout", (try parseArgs(&flagish))._arg.?);
214 const two = [_][:0]const u8{ "a", "run", "--", "a", "b" }; 195 const two = [_][:0]const u8{ "a", "run", "--", "a", "b" };
215 try std.testing.expectError(error.Usage, parseArgs(&two)); 196 try std.testing.expectError(error.Usage, parseArgs(&two));
216 197
217 // The marker outranks the help scan too: an agent typing `--help` AT a 198 // After `--`, literal `--help` reaches the session instead of opening usage.
218 // session must reach the pty, not this binary's usage page.
219 const help_payload = [_][:0]const u8{ "a", "send", "--", "--help" }; 199 const help_payload = [_][:0]const u8{ "a", "send", "--", "--help" };
220 try std.testing.expectEqualStrings("--help", (try parseArgs(&help_payload))._arg.?); 200 try std.testing.expectEqualStrings("--help", (try parseArgs(&help_payload))._arg.?);
221 } 201 }
222 202
223 test "mux a: --help and --version are answered wherever they can be typed" { 203 test "mux a: --help and --version are answered wherever they can be typed" {
224 // A bare `mux a --help` types it where a verb would go, and the same 204 // Help is recognized before or after the verb by the same parser pass.
225 // scan answers it after a verb: ONE table, so the two cannot drift.
226 try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "a", "--help" })); 205 try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "a", "--help" }));
227 try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "a", "-h" })); 206 try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "a", "-h" }));
228 try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "a", "status", "--help" })); 207 try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "a", "status", "--help" }));
@@ -245,9 +224,7 @@ test "mux a: --session rides every verb; a bad name is usage, not wire bytes" {
245 const bare = [_][:0]const u8{ "a", "status" }; 224 const bare = [_][:0]const u8{ "a", "status" };
246 try std.testing.expectEqualStrings("", (try parseArgs(&bare)).sessionName()); 225 try std.testing.expectEqualStrings("", (try parseArgs(&bare)).sessionName());
247 226
248 // A name no tool could ever address is refused at parse (the usage 227 // Reject unaddressable session names before sending any protocol frame.
249 // exit, 2) rather than reaching a daemon as a payload nothing can
250 // look up.
251 const bad = [_][:0]const u8{ "a", "status", "--session", "has space" }; 228 const bad = [_][:0]const u8{ "a", "status", "--session", "has space" };
252 try std.testing.expectError(error.Usage, parseArgs(&bad)); 229 try std.testing.expectError(error.Usage, parseArgs(&bad));
253 230
@@ -260,8 +237,8 @@ test "parseArgs: --quic and --key, and the pairs that make no sense" {
260 const q = [_][:0]const u8{ "a", "status", "--quic", "10.0.0.2:4433" }; 237 const q = [_][:0]const u8{ "a", "status", "--quic", "10.0.0.2:4433" };
261 const oq = try parseArgs(&q); 238 const oq = try parseArgs(&q);
262 try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?); 239 try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?);
263 // Not naming a key is not an error here: MUX_KEY_FILE and the XDG 240 // A missing explicit key is valid because runtime resolution may use
264 // default are still to be tried, and parse may look at neither. 241 // `MUX_KEY_FILE` or the XDG default.
265 try std.testing.expectEqual(@as(?[]const u8, null), oq.key); 242 try std.testing.expectEqual(@as(?[]const u8, null), oq.key);
266 243
267 const k = [_][:0]const u8{ "a", "run", "--quic", "box:4433", "--key", "/k", "make test" }; 244 const k = [_][:0]const u8{ "a", "run", "--quic", "box:4433", "--key", "/k", "make test" };
@@ -270,19 +247,16 @@ test "parseArgs: --quic and --key, and the pairs that make no sense" {
270 try std.testing.expectEqualStrings("/k", ok.key.?); 247 try std.testing.expectEqualStrings("/k", ok.key.?);
271 try std.testing.expectEqualStrings("make test", ok._arg.?); 248 try std.testing.expectEqualStrings("make test", ok._arg.?);
272 249
273 // cliflags owns the arity; what is this mode's is that its refusal becomes 250 // The shared parser detects missing values and this mode returns usage
274 // the usage exit and not a dial with a flag's name for a host. 251 // without attempting a connection.
275 const dangling_q = [_][:0]const u8{ "a", "status", "--quic" }; 252 const dangling_q = [_][:0]const u8{ "a", "status", "--quic" };
276 try std.testing.expectError(error.Usage, parseArgs(&dangling_q)); 253 try std.testing.expectError(error.Usage, parseArgs(&dangling_q));
277 254
278 // Two transports named at once: which one an agent's frames went to 255 // Reject two named transports rather than choosing one implicitly.
279 // would be this parser's private business, and it is not entitled to
280 // one — the same refusal `mux` spells as `.conflict`.
281 const both = [_][:0]const u8{ "a", "status", "--sock", "/tmp/s", "--quic", "b:1" }; 256 const both = [_][:0]const u8{ "a", "status", "--sock", "/tmp/s", "--quic", "b:1" };
282 try std.testing.expectError(error.Usage, parseArgs(&both)); 257 try std.testing.expectError(error.Usage, parseArgs(&both));
283 258
284 // A key with nothing to authenticate to, refused exactly where the daemon 259 // Reject a key without a QUIC transport.
285 // refuses it.
286 const lonely_key = [_][:0]const u8{ "a", "status", "--key", "/k" }; 260 const lonely_key = [_][:0]const u8{ "a", "status", "--key", "/k" };
287 try std.testing.expectError(error.Usage, parseArgs(&lonely_key)); 261 try std.testing.expectError(error.Usage, parseArgs(&lonely_key));
288 262
@@ -291,51 +265,44 @@ test "parseArgs: --quic and --key, and the pairs that make no sense" {
291 try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic); 265 try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic);
292 } 266 }
293 267
294 /// A live QUIC connection plus everything a REDIAL of it needs. The 268 /// Live QUIC client plus the endpoint state required for one reconnect. Reuse
295 /// coordinates are kept rather than re-derived: a reconnect happens mid-verb, 269 /// the original address and key so mid-command redial cannot select rotated
296 /// and a second resolution could pick a rotated key and fail the handshake for 270 /// credentials or a different resolved address.
297 /// a reason that has nothing to do with why the first connection died. 271 const QuicConnectionState = struct {
298 const Quic = struct {
299 cl: *quic.Client, 272 cl: *quic.Client,
300 addr: std.net.Address, 273 addr: std.net.Address,
301 key: quic.Key, 274 key: quic.Key,
302 idle_ms: u32, 275 idle_ms: u32,
303 /// Wall-clock milliseconds the FIRST handshake took, which is this 276 /// Duration of the initial handshake, used by `graceMs` as a rough network
304 /// client's only measurement of how far away the daemon is. `graceMs` 277 /// latency measurement.
305 /// turns it into the await grace window; see there.
306 connect_ms: i64, 278 connect_ms: i64,
307 /// The one reconnect, spent or not. Here rather than on `Conn` because only 279 /// Whether the single permitted reconnect has been used. This state belongs
308 /// this arm can reconnect: a socket `Conn` carrying the flag would have no 280 /// only to QUIC connections.
309 /// reachable true, and the guard would restate in code what the type says.
310 reconnected: bool = false, 281 reconnected: bool = false,
311 }; 282 };
312 283
313 const Conn = struct { 284 const AgentConnection = struct {
314 /// Verbs are transport-blind; `--quic` chooses here. 285 /// Verbs are transport-blind; `--quic` chooses here.
315 link: union(enum) { 286 link: union(enum) {
316 fd: std.posix.fd_t, 287 fd: std.posix.fd_t,
317 quic: Quic, 288 quic: QuicConnectionState,
318 }, 289 },
319 /// The allocator the transport works with: the QUIC arm's frame staging and 290 /// Allocator for QUIC frame staging and reconnect state. Frame-returning
320 /// its redials. Distinct in the signature from the one `awaitFrame` takes, 291 /// methods accept their result allocator separately.
321 /// which owns the frame handed BACK — different owners, same arena today.
322 alloc: std.mem.Allocator, 292 alloc: std.mem.Allocator,
323 /// Whether a snapshot has arrived since the last attach — the ONLY thing on 293 /// Whether the current attachment has received a snapshot. The daemon uses
324 /// the wire that tells a refused attach from a session that ended, since the 294 /// the same exit frame for a rejected attach and an ended session; a valid
325 /// daemon spells both as `exit_status 1` and closes. A served attach always 295 /// attach always sends a snapshot first.
326 /// sends the snapshot first, so an `exit_status` before one is a refusal.
327 saw_snapshot: bool = false, 296 saw_snapshot: bool = false,
328 /// The code from the `exit_status` frame that ended a wait. That frame is 297 /// The code from the `exit_status` frame that ended a wait. That frame is
329 /// the session's last word and carries the only copy of the code, so it is 298 /// the session's last word and carries the only copy of the code, so it is
330 /// captured here rather than thrown away with the frame. 299 /// captured here rather than thrown away with the frame.
331 session_exit: ?u8 = null, 300 session_exit: ?u8 = null,
332 /// Why the reconnect could not be made. The error that ends the verb is 301 /// Static error name from a failed reconnect, retained so the final JSON can
333 /// `ConnectionLost`, which is the story's beginning; THIS is how it 302 /// report more than the initial `ConnectionLost` condition.
334 /// finished. An agent told only `QuicHandshakeFailed` goes and checks its
335 /// key. An `@errorName`, so it borrows a static string and owns no storage.
336 reconnect_failure: ?[]const u8 = null, 303 reconnect_failure: ?[]const u8 = null,
337 304
338 fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn { 305 fn open(alloc: std.mem.Allocator, sock_path: []const u8) !AgentConnection {
339 const s = try std.net.connectUnixSocket(sock_path); 306 const s = try std.net.connectUnixSocket(sock_path);
340 return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; 307 return .{ .link = .{ .fd = s.handle }, .alloc = alloc };
341 } 308 }
@@ -348,7 +315,7 @@ const Conn = struct {
348 key: quic.Key, 315 key: quic.Key,
349 idle_ms: u32, 316 idle_ms: u32,
350 deadline_ms: i64, 317 deadline_ms: i64,
351 ) !Conn { 318 ) !AgentConnection {
352 const started = std.time.milliTimestamp(); 319 const started = std.time.milliTimestamp();
353 const cl = try quic.Client.connect(alloc, addr, key, idle_ms); 320 const cl = try quic.Client.connect(alloc, addr, key, idle_ms);
354 errdefer cl.deinit(); 321 errdefer cl.deinit();
@@ -365,7 +332,7 @@ const Conn = struct {
365 }; 332 };
366 } 333 }
367 334
368 fn close(self: *Conn) void { 335 fn close(self: *AgentConnection) void {
369 switch (self.link) { 336 switch (self.link) {
370 .fd => |fd| std.posix.close(fd), 337 .fd => |fd| std.posix.close(fd),
371 .quic => |q| q.cl.deinit(), 338 .quic => |q| q.cl.deinit(),
@@ -374,7 +341,7 @@ const Conn = struct {
374 341
375 /// QUIC widens the grace: the daemon's window opens a flight after 342 /// QUIC widens the grace: the daemon's window opens a flight after
376 /// ours. The cap bounds a slow handshake. 343 /// ours. The cap bounds a slow handshake.
377 fn graceMs(self: *const Conn) i64 { 344 fn graceMs(self: *const AgentConnection) i64 {
378 return switch (self.link) { 345 return switch (self.link) {
379 .fd => await_grace_ms, 346 .fd => await_grace_ms,
380 .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)), 347 .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)),
@@ -385,7 +352,7 @@ const Conn = struct {
385 /// the bytes or fails. The QUIC arm gives it precedence over 352 /// the bytes or fails. The QUIC arm gives it precedence over
386 /// `send_flush_ms`, so a verb asked for a 100ms answer cannot spend 353 /// `send_flush_ms`, so a verb asked for a 100ms answer cannot spend
387 /// five seconds sending. 354 /// five seconds sending.
388 fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void { 355 fn sendFrame(self: *AgentConnection, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void {
389 const wrote: anyerror!void = switch (self.link) { 356 const wrote: anyerror!void = switch (self.link) {
390 .fd => |fd| proto.writeFrame(fd, t, payload), 357 .fd => |fd| proto.writeFrame(fd, t, payload),
391 .quic => self.sendFrameQuic(t, payload, deadline_ms), 358 .quic => self.sendFrameQuic(t, payload, deadline_ms),
@@ -402,11 +369,10 @@ const Conn = struct {
402 }; 369 };
403 } 370 }
404 371
405 /// A refusal is `exit_status` then close, and that close can beat 372 /// Drain a pending attach rejection after a write races with the daemon's
406 /// our next write: the `BrokenPipe` then stands where the refusal 373 /// exit frame and close. Requesting an otherwise unused frame type lets the
407 /// belongs. Rare untraced, certain under ptrace. The want is one 374 /// snapshot/exit classification terminate the wait.
408 /// the daemon never sends: only the snapshot rule ends it. 375 fn refusalPending(self: *AgentConnection) error{AttachRefused}!void {
409 fn refusalPending(self: *Conn) error{AttachRefused}!void {
410 const frame = self.awaitFrame(.attach, std.time.milliTimestamp() + refusal_drain_ms) catch |e| { 376 const frame = self.awaitFrame(.attach, std.time.milliTimestamp() + refusal_drain_ms) catch |e| {
411 if (e == error.AttachRefused) return error.AttachRefused; 377 if (e == error.AttachRefused) return error.AttachRefused;
412 return; 378 return;
@@ -417,7 +383,7 @@ const Conn = struct {
417 /// A half-written frame reads as a corrupt stream, so the short take 383 /// A half-written frame reads as a corrupt stream, so the short take
418 /// is re-offered. 384 /// is re-offered.
419 fn sendFrameQuic( 385 fn sendFrameQuic(
420 self: *Conn, 386 self: *AgentConnection,
421 t: proto.MsgType, 387 t: proto.MsgType,
422 payload: []const u8, 388 payload: []const u8,
423 deadline_ms: i64, 389 deadline_ms: i64,
@@ -427,8 +393,8 @@ const Conn = struct {
427 try proto.appendFrame(&buf, self.alloc, t, payload); 393 try proto.appendFrame(&buf, self.alloc, t, payload);
428 394
429 const q = &self.link.quic; 395 const q = &self.link.quic;
430 // The flush cap is what bounds an UNBOUNDED caller (`--timeout 0`); 396 // The flush cap bounds callers using `--timeout 0`; all other callers
431 // the caller's own deadline bounds every other one. 397 // provide an earlier deadline.
432 const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms); 398 const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms);
433 var off: usize = 0; 399 var off: usize = 0;
434 while (off < buf.items.len) { 400 while (off < buf.items.len) {
@@ -446,11 +412,9 @@ const Conn = struct {
446 } 412 }
447 } 413 }
448 414
449 /// Snapshots and deltas stream past an attached client, so unwanted 415 /// Skip unrelated snapshots and deltas while waiting for `want`. An exit
450 /// frames are skipped. `exit_status` ends the wait instead: the reply 416 /// frame ends the wait as a session outcome rather than a transport error.
451 /// is never coming, and the session ending is an answer, not a 417 fn awaitFrame(self: *AgentConnection, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
452 /// transport failure.
453 fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
454 return switch (self.link) { 418 return switch (self.link) {
455 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms), 419 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms),
456 .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms), 420 .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms),
@@ -460,7 +424,7 @@ const Conn = struct {
460 /// Deadline-bounded wait, unbounded read: harmless where a stall 424 /// Deadline-bounded wait, unbounded read: harmless where a stall
461 /// means a dead daemon. 425 /// means a dead daemon.
462 fn awaitFrameFd( 426 fn awaitFrameFd(
463 self: *Conn, 427 self: *AgentConnection,
464 alloc: std.mem.Allocator, 428 alloc: std.mem.Allocator,
465 want: proto.MsgType, 429 want: proto.MsgType,
466 deadline_ms: i64, 430 deadline_ms: i64,
@@ -480,8 +444,8 @@ const Conn = struct {
480 if (frame.type == .snapshot) self.saw_snapshot = true; 444 if (frame.type == .snapshot) self.saw_snapshot = true;
481 if (frame.type == .exit_status) { 445 if (frame.type == .exit_status) {
482 if (!self.saw_snapshot) return error.AttachRefused; 446 if (!self.saw_snapshot) return error.AttachRefused;
483 // A daemon that spelled the frame without a code still ends 447 // A missing status byte still ends the session, but its code is
484 // the session; null is the honest code, not 0. 448 // unknown rather than zero.
485 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null; 449 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null;
486 return error.SessionExited; 450 return error.SessionExited;
487 } 451 }
@@ -491,7 +455,7 @@ const Conn = struct {
491 /// Drain the buffer before checking `dead`, or bytes that arrived 455 /// Drain the buffer before checking `dead`, or bytes that arrived
492 /// first are lost. 456 /// first are lost.
493 fn awaitFrameQuic( 457 fn awaitFrameQuic(
494 self: *Conn, 458 self: *AgentConnection,
495 alloc: std.mem.Allocator, 459 alloc: std.mem.Allocator,
496 want: proto.MsgType, 460 want: proto.MsgType,
497 deadline_ms: i64, 461 deadline_ms: i64,
@@ -513,9 +477,8 @@ const Conn = struct {
513 return error.SessionExited; 477 return error.SessionExited;
514 } 478 }
515 } 479 }
516 // Not `DaemonGone`: over a network the difference between "the 480 // Over a network, a dead connection cannot distinguish daemon exit
517 // daemon exited" and "the path to it went away" is not ours to 481 // from path failure, so report `ConnectionLost` and allow reconnect.
518 // claim, and the reconnect above only fires on this one.
519 if (q.cl.dead) return error.ConnectionLost; 482 if (q.cl.dead) return error.ConnectionLost;
520 const now = std.time.milliTimestamp(); 483 const now = std.time.milliTimestamp();
521 if (now >= deadline_ms) return error.Timeout; 484 if (now >= deadline_ms) return error.Timeout;
@@ -535,7 +498,7 @@ const Conn = struct {
535 /// what a reader wants is the distance to the daemon, not the cost of 498 /// what a reader wants is the distance to the daemon, not the cost of
536 /// a redial made while the path was still coming back. Nothing reads 499 /// a redial made while the path was still coming back. Nothing reads
537 /// it after this point anyway. 500 /// it after this point anyway.
538 fn reconnect(self: *Conn, deadline_ms: i64) !void { 501 fn reconnect(self: *AgentConnection, deadline_ms: i64) !void {
539 const q = &self.link.quic; 502 const q = &self.link.quic;
540 const cl = try quic.Client.connect(self.alloc, q.addr, q.key, q.idle_ms); 503 const cl = try quic.Client.connect(self.alloc, q.addr, q.key, q.idle_ms);
541 errdefer cl.deinit(); 504 errdefer cl.deinit();
@@ -548,14 +511,13 @@ const Conn = struct {
548 511
549 test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" { 512 test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" {
550 const alloc = std.testing.allocator; 513 const alloc = std.testing.allocator;
551 const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; 514 const local = AgentConnection{ .link = .{ .fd = -1 }, .alloc = alloc };
552 try std.testing.expectEqual(@as(i64, 2_000), local.graceMs()); 515 try std.testing.expectEqual(@as(i64, 2_000), local.graceMs());
553 516
554 // The derivation is 4x the handshake, and it only ever WIDENS the 517 // Grace is four times the handshake duration but never below two seconds.
555 // window: a loopback or LAN daemon keeps the flat 2s. 518 // The calculation depends only on the stored measurement, so no live client
556 // No client: the window is a function of the measurement, not of the 519 // is required.
557 // connection, and nothing here may touch one. 520 var far = AgentConnection{
558 var far = Conn{
559 .link = .{ .quic = .{ 521 .link = .{ .quic = .{
560 .cl = undefined, 522 .cl = undefined,
561 .addr = undefined, 523 .addr = undefined,
@@ -582,10 +544,9 @@ test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" {
582 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs()); 544 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs());
583 } 545 }
584 546
585 /// Drive a fresh connection until it can carry bytes, or give up. A refused 547 /// Drive a fresh QUIC connection until it can carry bytes. Immediate network
586 /// port ends this early, so the common mistake costs milliseconds; a blackholed 548 /// errors return early; a blackholed endpoint is bounded by the caller deadline
587 /// one produces no error at all and the deadline is the only thing that ends 549 /// or the connection's idle timeout.
588 /// it. Even `--timeout 0` terminates, on the connection's own idle timeout.
589 fn waitReady(cl: *quic.Client, deadline_ms: i64) !void { 550 fn waitReady(cl: *quic.Client, deadline_ms: i64) !void {
590 while (true) { 551 while (true) {
591 cl.pump(); 552 cl.pump();
@@ -603,19 +564,15 @@ fn waitReady(cl: *quic.Client, deadline_ms: i64) !void {
603 564
604 test "reconnect: redials the same coordinates, and a dead port is a fast no" { 565 test "reconnect: redials the same coordinates, and a dead port is a fast no" {
605 const alloc = std.testing.allocator; 566 const alloc = std.testing.allocator;
606 // 127.0.0.1:1, where nothing listens: the refusal is REAL — an ICMP 567 // Loopback port 1 returns an immediate ICMP error, exercising dial,
607 // unreachable comes back and the quic client acts on it — which is what 568 // handshake wait, and failure reporting without waiting for a timeout.
608 // lets this exercise the whole redial path (dial, handshake wait,
609 // verdict) in a couple of loopback round trips instead of a timeout.
610 const addr = try std.net.Address.parseIp("127.0.0.1", 1); 569 const addr = try std.net.Address.parseIp("127.0.0.1", 1);
611 const key: quic.Key = .{ .bytes = [_]u8{7} ** quic.key_len }; 570 const key: quic.Key = .{ .bytes = [_]u8{7} ** quic.key_len };
612 571
613 // The dial that stands in for the connection this client had before 572 // Create the initial connection state, then exercise the same reconnect
614 // the network went away. It dies for the same reason the redial will, 573 // path used when an established connection later fails.
615 // which is fine: what is under test is what `reconnect` DOES, and it
616 // does the same thing to a connection that died at second 30.
617 const deadline = std.time.milliTimestamp() + 2_000; 574 const deadline = std.time.milliTimestamp() + 2_000;
618 var conn = Conn{ 575 var conn = AgentConnection{
619 .link = .{ .quic = .{ 576 .link = .{ .quic = .{
620 .cl = try quic.Client.connect(alloc, addr, key, 1_000), 577 .cl = try quic.Client.connect(alloc, addr, key, 1_000),
621 .addr = addr, 578 .addr = addr,
@@ -639,14 +596,12 @@ test "reconnect: redials the same coordinates, and a dead port is a fast no" {
639 // an agent gets rather than one this test made up. 596 // an agent gets rather than one this test made up.
640 conn.reconnect_failure = @errorName(redial); 597 conn.reconnect_failure = @errorName(redial);
641 } 598 }
642 // Fast, because the port refused rather than went quiet. A redial that 599 // An immediate port rejection should return well before the two-second
643 // swallowed the refusal would spend the whole 2s here — and in the 600 // deadline.
644 // field it would spend the agent's remaining deadline.
645 try std.testing.expect(std.time.milliTimestamp() - t0 < 1_000); 601 try std.testing.expect(std.time.milliTimestamp() - t0 < 1_000);
646 602
647 // The whole story, in the order it happened: the wait died because the 603 // Preserve both the initial connection loss and the reconnect failure in
648 // path tore, and it stayed dead because the redial could not complete. 604 // the final diagnostic.
649 // An agent told only the second half goes and checks its key.
650 var buf: [128]u8 = undefined; 605 var buf: [128]u8 = undefined;
651 try std.testing.expectEqualStrings( 606 try std.testing.expectEqualStrings(
652 "connection lost; reconnect failed: QuicHandshakeFailed", 607 "connection lost; reconnect failed: QuicHandshakeFailed",
@@ -654,7 +609,7 @@ test "reconnect: redials the same coordinates, and a dead port is a fast no" {
654 ); 609 );
655 610
656 // A redial that failed is not a reconnect spent — but it is also not a 611 // A redial that failed is not a reconnect spent — but it is also not a
657 // Conn holding a freed client: the old one is torn down only once a 612 // AgentConnection holding a freed client: the old one is torn down only once a
658 // new one is up, so the close above is safe on this path. 613 // new one is up, so the close above is safe on this path.
659 try std.testing.expect(!conn.link.quic.reconnected); 614 try std.testing.expect(!conn.link.quic.reconnected);
660 } 615 }
@@ -665,12 +620,12 @@ test "waitFailDetail: only a lost connection gets a sentence; the rest keep thei
665 620
666 // Every other failure is untouched — the socket arm's reports must 621 // Every other failure is untouched — the socket arm's reports must
667 // read exactly as they did before there was a QUIC arm. 622 // read exactly as they did before there was a QUIC arm.
668 const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; 623 const local = AgentConnection{ .link = .{ .fd = -1 }, .alloc = alloc };
669 try std.testing.expectEqualStrings("Timeout", waitFailDetail(&buf, &local, error.Timeout)); 624 try std.testing.expectEqualStrings("Timeout", waitFailDetail(&buf, &local, error.Timeout));
670 try std.testing.expectEqualStrings("DaemonGone", waitFailDetail(&buf, &local, error.DaemonGone)); 625 try std.testing.expectEqualStrings("DaemonGone", waitFailDetail(&buf, &local, error.DaemonGone));
671 626
672 // A tear with the one reconnect still unspent (nothing tried yet). 627 // A tear with the one reconnect still unspent (nothing tried yet).
673 var far = Conn{ 628 var far = AgentConnection{
674 .link = .{ .quic = .{ 629 .link = .{ .quic = .{
675 .cl = undefined, 630 .cl = undefined,
676 .addr = undefined, 631 .addr = undefined,
@@ -703,16 +658,15 @@ test "waitFailDetail: only a lost connection gets a sentence; the rest keep thei
703 658
704 test "awaitFrame ends a wait on exit_status, keeping the code" { 659 test "awaitFrame ends a wait on exit_status, keeping the code" {
705 const alloc = std.testing.allocator; 660 const alloc = std.testing.allocator;
706 // A pipe stands in for the daemon: awaitFrame polls and reads an fd and 661 // A pipe is sufficient because `awaitFrame` only polls and reads this test
707 // asks nothing else of it. 662 // transport.
708 const pipe = try std.posix.pipe(); 663 const pipe = try std.posix.pipe();
709 defer std.posix.close(pipe[0]); 664 defer std.posix.close(pipe[0]);
710 defer std.posix.close(pipe[1]); 665 defer std.posix.close(pipe[1]);
711 666
712 var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; 667 var conn = AgentConnection{ .link = .{ .fd = pipe[0] }, .alloc = alloc };
713 // The snapshot first, because it is what makes this an ENDING rather 668 // A preceding snapshot proves attachment succeeded, so the exit frame means
714 // than a refusal: the attach was served, so the exit_status after it 669 // the session ended rather than the attach being rejected.
715 // is the session's last word (see the refusal test below).
716 try proto.writeFrame(pipe[1], .snapshot, ""); 670 try proto.writeFrame(pipe[1], .snapshot, "");
717 // A push to skip on the way, then the session's last word. The reply 671 // A push to skip on the way, then the session's last word. The reply
718 // this wait asked for is never coming, and the code is the answer. 672 // this wait asked for is never coming, and the code is the answer.
@@ -736,14 +690,13 @@ test "awaitFrame ends a wait on exit_status, keeping the code" {
736 690
737 test "an exit_status before any snapshot is a refused attach, not a session that ended" { 691 test "an exit_status before any snapshot is a refused attach, not a session that ended" {
738 const alloc = std.testing.allocator; 692 const alloc = std.testing.allocator;
739 // The daemon's whole vocabulary for "no": a refused 0x0 attach is 693 // A rejected 0x0 attach is encoded as `exit_status 1` plus close, identical
740 // `exit_status 1` and a close, byte-identical to a shell's real exit. The 694 // to a real shell exit except that no snapshot precedes it.
741 // SNAPSHOT tells them apart, since a served attach always sends one first.
742 const pipe = try std.posix.pipe(); 695 const pipe = try std.posix.pipe();
743 defer std.posix.close(pipe[0]); 696 defer std.posix.close(pipe[0]);
744 defer std.posix.close(pipe[1]); 697 defer std.posix.close(pipe[1]);
745 698
746 var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; 699 var conn = AgentConnection{ .link = .{ .fd = pipe[0] }, .alloc = alloc };
747 try proto.writeFrame(pipe[1], .exit_status, &[_]u8{1}); 700 try proto.writeFrame(pipe[1], .exit_status, &[_]u8{1});
748 try std.testing.expectError( 701 try std.testing.expectError(
749 error.AttachRefused, 702 error.AttachRefused,
@@ -764,7 +717,7 @@ test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an
764 defer std.posix.close(sp[0]); 717 defer std.posix.close(sp[0]);
765 defer std.posix.close(sp[1]); 718 defer std.posix.close(sp[1]);
766 719
767 var conn = Conn{ .link = .{ .fd = sp[0] }, .alloc = alloc }; 720 var conn = AgentConnection{ .link = .{ .fd = sp[0] }, .alloc = alloc };
768 // The first attach is served: a snapshot arrives and is skipped past on 721 // The first attach is served: a snapshot arrives and is skipped past on
769 // the way to a reply that never comes. 722 // the way to a reply that never comes.
770 try proto.writeFrame(sp[1], .snapshot, ""); 723 try proto.writeFrame(sp[1], .snapshot, "");
@@ -774,9 +727,8 @@ test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an
774 ); 727 );
775 try std.testing.expect(conn.saw_snapshot); 728 try std.testing.expect(conn.saw_snapshot);
776 729
777 // The reconnect's attach, refused. Without the reset in attachZero the 730 // Reject the reconnect attachment. Resetting `saw_snapshot` prevents state
778 // stale `saw_snapshot` reads this as the session ending, and the verb 731 // from the old connection from misclassifying this as a session exit.
779 // reports a shell exit for a session it never reached.
780 try attachZero(&conn, "s", std.time.milliTimestamp() + 2000); 732 try attachZero(&conn, "s", std.time.milliTimestamp() + 2000);
781 try proto.writeFrame(sp[1], .exit_status, &[_]u8{1}); 733 try proto.writeFrame(sp[1], .exit_status, &[_]u8{1});
782 try std.testing.expectError( 734 try std.testing.expectError(
@@ -787,10 +739,9 @@ test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an
787 739
788 test "a refusal that closes the socket before the input write is still reported as the refusal" { 740 test "a refusal that closes the socket before the input write is still reported as the refusal" {
789 const alloc = std.testing.allocator; 741 const alloc = std.testing.allocator;
790 // The refusal is `exit_status` + close, and only timing makes our next 742 // Reproduce the race where the daemon's rejection frame and close arrive
791 // write lose to that close: the socket buffer usually takes the bytes first. 743 // before the next write, causing BrokenPipe unless the pending frame is
792 // Under a ptrace tracer the close wins every time, and BrokenPipe is 744 // drained and classified.
793 // reported INSTEAD — a daemon's "no such session" as a transport failure.
794 var sp: [2]i32 = undefined; 745 var sp: [2]i32 = undefined;
795 try std.testing.expectEqual( 746 try std.testing.expectEqual(
796 @as(usize, 0), 747 @as(usize, 0),
@@ -798,7 +749,7 @@ test "a refusal that closes the socket before the input write is still reported
798 ); 749 );
799 defer std.posix.close(sp[0]); 750 defer std.posix.close(sp[0]);
800 751
801 var conn = Conn{ .link = .{ .fd = sp[0] }, .alloc = alloc }; 752 var conn = AgentConnection{ .link = .{ .fd = sp[0] }, .alloc = alloc };
802 const deadline = std.time.milliTimestamp() + 2000; 753 const deadline = std.time.milliTimestamp() + 2000;
803 // The field ordering: the attach is served, the refusal comes back, 754 // The field ordering: the attach is served, the refusal comes back,
804 // and the close beats the input write that follows it. 755 // and the close beats the input write that follows it.
@@ -827,7 +778,7 @@ test "a refusal that closes the socket before the input write is still reported
827 try std.posix.dup2(cap[1], std.posix.STDOUT_FILENO); 778 try std.posix.dup2(cap[1], std.posix.STDOUT_FILENO);
828 std.posix.close(cap[1]); 779 std.posix.close(cap[1]);
829 780
830 var conn2 = Conn{ .link = .{ .fd = sp2[0] }, .alloc = alloc }; 781 var conn2 = AgentConnection{ .link = .{ .fd = sp2[0] }, .alloc = alloc };
831 const code = try verbSend(alloc, &conn2, "x", "nosuch", std.time.milliTimestamp() + 2000); 782 const code = try verbSend(alloc, &conn2, "x", "nosuch", std.time.milliTimestamp() + 2000);
832 try std.posix.dup2(saved, std.posix.STDOUT_FILENO); 783 try std.posix.dup2(saved, std.posix.STDOUT_FILENO);
833 784
@@ -846,9 +797,8 @@ test "the refused-attach detail names the session and both of the refusal's prod
846 // `fail`'s, pinned through a real verb by the capture test above. 797 // `fail`'s, pinned through a real verb by the capture test above.
847 const named = refusedDetail(&buf, "nosuch", .attach); 798 const named = refusedDetail(&buf, "nosuch", .attach);
848 try std.testing.expect(std.mem.indexOf(u8, named, "so nosuch must already exist") != null); 799 try std.testing.expect(std.mem.indexOf(u8, named, "so nosuch must already exist") != null);
849 // Both conditions, never just absence: the daemon sends the same 800 // Mention both possible causes because the same frame also represents a
850 // refusal when its client table is full, and a detail claiming the 801 // full client table.
851 // session does not exist would be a lie at a daemon that holds it.
852 try std.testing.expect(std.mem.indexOf(u8, named, "room for one more client") != null); 802 try std.testing.expect(std.mem.indexOf(u8, named, "room for one more client") != null);
853 803
854 // The empty name is the wire's default spelling, not a session called 804 // The empty name is the wire's default spelling, not a session called
@@ -860,10 +810,8 @@ test "the refused-attach detail names the session and both of the refusal's prod
860 810
861 test "a refusal a full client table cannot have caused does not blame one" { 811 test "a refusal a full client table cannot have caused does not blame one" {
862 var buf: [768]u8 = undefined; 812 var buf: [768]u8 = undefined;
863 // `status` never attaches, so `exit_status 1` here has one producer: 813 // Status never attaches, so this exit frame means the session lookup failed;
864 // findSession missed. An observer with no slot is closed frameless, so 814 // observer-slot exhaustion closes without a frame.
865 // no fullness of any table can reach this reply — naming a constant
866 // that cannot be involved sends an agent to read `mux d stats`.
867 const q = refusedDetail(&buf, "nosuch", .query); 815 const q = refusedDetail(&buf, "nosuch", .query);
868 try std.testing.expect(std.mem.indexOf(u8, q, "so nosuch must already exist") != null); 816 try std.testing.expect(std.mem.indexOf(u8, q, "so nosuch must already exist") != null);
869 try std.testing.expect(std.mem.indexOf(u8, q, "max_clients") == null); 817 try std.testing.expect(std.mem.indexOf(u8, q, "max_clients") == null);
@@ -873,9 +821,7 @@ test "a refusal a full client table cannot have caused does not blame one" {
873 try std.testing.expect(std.mem.indexOf(u8, q, "refused this attach") == null); 821 try std.testing.expect(std.mem.indexOf(u8, q, "refused this attach") == null);
874 } 822 }
875 823
876 /// The exit code for "the one JSON object never reached stdout". Distinct 824 /// Exit code used when the required JSON object could not be written to stdout.
877 /// from all four codes that mean it DID: an object plus 0/1/3, or a usage
878 /// error whose 2 promises stdout was left empty on purpose.
879 const write_failed_code: u8 = 4; 825 const write_failed_code: u8 = 4;
880 826
881 /// A write that fails must not exit 0: an agent checks the status, 827 /// A write that fails must not exit 0: an agent checks the status,
@@ -932,9 +878,7 @@ test "an unwritable stdout is a distinct exit code, never a silent 0" {
932 try std.testing.expectEqualStrings("{\"reason\":\"timeout\"}\n", buf[0..n]); 878 try std.testing.expectEqualStrings("{\"reason\":\"timeout\"}\n", buf[0..n]);
933 } 879 }
934 880
935 /// Every failure exit goes through here, so stdout carries one JSON object 881 /// Emit the common JSON error shape used by every runtime failure.
936 /// whatever went wrong — a driving agent parses the same shape on both
937 /// paths instead of switching on exit code first.
938 fn fail(msg: []const u8, detail: []const u8) u8 { 882 fn fail(msg: []const u8, detail: []const u8) u8 {
939 var buf: [2048]u8 = undefined; 883 var buf: [2048]u8 = undefined;
940 var fbs = std.io.fixedBufferStream(&buf); 884 var fbs = std.io.fixedBufferStream(&buf);
@@ -995,31 +939,28 @@ fn writeSessionEndedError(writer: anytype, code: ?u8) !void {
995 try writer.writeAll("}\n"); 939 try writer.writeAll("}\n");
996 } 940 }
997 941
998 /// Which ask the daemon refused. `status` and `capture` never attach, so 942 /// Kind of request rejected by the daemon. Status and capture are queries rather
999 /// their `exit_status 1` has exactly one producer — a name the daemon does 943 /// than attachments, which changes the diagnostic for an exit frame received
1000 /// not hold. An observer that cannot be seated is closed without a frame 944 /// before any snapshot.
1001 /// (`acceptConn`), so no table being full can reach them. 945 const RefusedRequest = enum { attach, query };
1002 const Refused = enum { attach, query };
1003 946
1004 /// `failSessionEnded` where the snapshot rule says refusal instead: an 947 /// Report an attach rejection instead of describing a session that never ran as
1005 /// agent told "session ended" goes looking for a shell that never ran. 948 /// having ended.
1006 fn failAttachRefused(name: []const u8, ask: Refused) u8 { 949 fn failAttachRefused(name: []const u8, ask: RefusedRequest) u8 {
1007 var buf: [768]u8 = undefined; 950 var buf: [768]u8 = undefined;
1008 return fail("attach refused", refusedDetail(&buf, name, ask)); 951 return fail("attach refused", refusedDetail(&buf, name, ask));
1009 } 952 }
1010 953
1011 /// A send raises the same refusal `awaitFrame` does, so it reaches the same 954 /// Map attach rejection from both send and receive paths to the same JSON error.
1012 /// producer: a verb spelling its own switch would answer half of them. 955 fn failSend(e: anyerror, name: []const u8, ask: RefusedRequest, who: []const u8, msg: []const u8) u8 {
1013 fn failSend(e: anyerror, name: []const u8, ask: Refused, who: []const u8, msg: []const u8) u8 {
1014 if (e == error.AttachRefused) return failAttachRefused(name, ask); 956 if (e == error.AttachRefused) return failAttachRefused(name, ask);
1015 return failAs(who, msg, @errorName(e)); 957 return failAs(who, msg, @errorName(e));
1016 } 958 }
1017 959
1018 /// An attach's `exit_status 1` before a snapshot has two producers and the 960 /// Format the ambiguous pre-snapshot exit: either the session does not exist or
1019 /// frame does not say which: the name was refused, or no client slot was free. 961 /// the daemon has no free client slot. Do not claim one cause when the frame
1020 /// Naming only absence would print "no session 0" at a daemon that HOLDS 962 /// cannot distinguish them.
1021 /// session 0 and is merely full. A name too long to fit still leaves a detail. 963 fn refusedDetail(buf: *[768]u8, name: []const u8, ask: RefusedRequest) []const u8 {
1022 fn refusedDetail(buf: *[768]u8, name: []const u8, ask: Refused) []const u8 {
1023 const why, const need = switch (ask) { 964 const why, const need = switch (ask) {
1024 .attach => .{ 965 .attach => .{
1025 "the daemon refused this attach: `mux a` joins at 0x0 and never creates, so ", 966 "the daemon refused this attach: `mux a` joins at 0x0 and never creates, so ",
@@ -1030,9 +971,8 @@ fn refusedDetail(buf: *[768]u8, name: []const u8, ask: Refused) []const u8 {
1030 " must already exist", 971 " must already exist",
1031 }, 972 },
1032 }; 973 };
1033 // "" is the wire's default spelling, not a session with no name: a 974 // Display an empty wire name as the default session rather than as missing
1034 // detail reading `so must already exist` sends an agent looking for a 975 // text in the diagnostic.
1035 // name it never typed.
1036 const shown = if (name.len == 0) "the default session" else name; 976 const shown = if (name.len == 0) "the default session" else name;
1037 return std.fmt.bufPrint(buf, "{s}{s}{s}", .{ why, shown, need }) catch 977 return std.fmt.bufPrint(buf, "{s}{s}{s}", .{ why, shown, need }) catch
1038 std.fmt.bufPrint(buf, "{s}the session asked for{s}", .{ why, need }) catch why; 978 std.fmt.bufPrint(buf, "{s}the session asked for{s}", .{ why, need }) catch why;
@@ -1055,19 +995,18 @@ test "the session-ended failure keeps the error+detail shape every failure has"
1055 try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"exit_code\":null") != null); 995 try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"exit_code\":null") != null);
1056 } 996 }
1057 997
1058 /// `mux a`. argv is the dispatcher's, minus the program name. 998 /// Run agent mode using the argv slice supplied by the top-level dispatcher.
1059 pub fn main(args: []const [:0]const u8) !u8 { 999 pub fn main(args: []const [:0]const u8) !u8 {
1060 var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); 1000 var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
1061 defer arena_state.deinit(); 1001 defer arena_state.deinit();
1062 const alloc = arena_state.allocator(); 1002 const alloc = arena_state.allocator();
1063 1003
1064 // This mode's constraint, which `exitFor` is keeping: stdout is one 1004 // Successful and runtime responses use one JSON object on stdout. Shared
1065 // JSON object per invocation, argument errors included. 1005 // argument errors retain the CLI help/version stream behavior.
1066 const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version); 1006 const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version);
1067 1007
1068 // Started BEFORE the connect: over QUIC the handshake is part of the round 1008 // Start the deadline before connecting so QUIC handshake time is included
1069 // trip the caller bounded, and a `--timeout` that began once the connection 1009 // and timeout semantics match local connections.
1070 // was up would promise different things on the two transports.
1071 const deadline = deadlineFor(o.timeout); 1010 const deadline = deadlineFor(o.timeout);
1072 1011
1073 if (o.quic) |host_port| { 1012 if (o.quic) |host_port| {
@@ -1080,8 +1019,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
1080 } 1019 }
1081 1020
1082 const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { 1021 const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) {
1083 // An agent reads replies, not stderr, so this one refuses through 1022 // Runtime path resolution failures use the same JSON shape as other
1084 // the same JSON shape as every other failure here. 1023 // agent-mode failures.
1085 error.NoRuntimeDir => return fail( 1024 error.NoRuntimeDir => return fail(
1086 "no default socket path", 1025 "no default socket path",
1087 "XDG_RUNTIME_DIR is unset; name the socket with --sock", 1026 "XDG_RUNTIME_DIR is unset; name the socket with --sock",
@@ -1089,10 +1028,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
1089 else => |e| return e, 1028 else => |e| return e,
1090 }; 1029 };
1091 1030
1092 var conn = Conn.open(alloc, sock_path) catch |e| { 1031 var conn = AgentConnection.open(alloc, sock_path) catch |e| {
1093 // The path goes in the detail: an agent client pointed at the wrong socket 1032 // Include the socket path so a client can identify which endpoint failed.
1094 // is this binary's likeliest field failure, and an agent reading
1095 // "FileNotFound" alone cannot tell which path it was that missed.
1096 var buf: [256]u8 = undefined; 1033 var buf: [256]u8 = undefined;
1097 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ sock_path, @errorName(e) }) catch 1034 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ sock_path, @errorName(e) }) catch
1098 @errorName(e); 1035 @errorName(e);
@@ -1103,16 +1040,14 @@ pub fn main(args: []const [:0]const u8) !u8 {
1103 return dispatch(alloc, &conn, o, deadline); 1040 return dispatch(alloc, &conn, o, deadline);
1104 } 1041 }
1105 1042
1106 /// The verbs, once. Both transports arrive here with a Conn and nothing 1043 /// Dispatch a parsed agent command over a transport-independent connection.
1107 /// else that distinguishes them, which is the property `--quic` is selling. 1044 fn dispatch(alloc: std.mem.Allocator, conn: *AgentConnection, o: AgentArguments, deadline: i64) !u8 {
1108 fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
1109 return switch (o._verb.?) { 1045 return switch (o._verb.?) {
1110 .status => verbStatus(alloc, conn, o.sessionName(), deadline), 1046 .status => verbStatus(alloc, conn, o.sessionName(), deadline),
1111 .capture => verbCapture(alloc, conn, o.vt, o.sessionName(), deadline), 1047 .capture => verbCapture(alloc, conn, o.vt, o.sessionName(), deadline),
1112 .send => verbSend(alloc, conn, o._arg, o.sessionName(), deadline), 1048 .send => verbSend(alloc, conn, o._arg, o.sessionName(), deadline),
1113 // The one thing `run` needs that `await` does not, checked here so 1049 // Validate `run`'s required command line before sharing the await
1114 // the shared pipeline below can read `cmdline == null` as "this is 1050 // pipeline, where null specifically identifies the `await` command.
1115 // an await" rather than as "a run that was spelled wrong".
1116 .run => if (o._arg) |cmdline| 1051 .run => if (o._arg) |cmdline|
1117 awaitVerb(alloc, conn, o, deadline, cmdline) 1052 awaitVerb(alloc, conn, o, deadline, cmdline)
1118 else 1053 else
@@ -1121,37 +1056,30 @@ fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
1121 }; 1056 };
1122 } 1057 }
1123 1058
1124 /// A QUIC transport, or the exit code standing in for the reason there is 1059 /// Result of opening a QUIC connection: either the connection or an exit code
1125 /// not one. Every refusal here goes through `fail`, so a dial that never 1060 /// after a JSON error has already been emitted.
1126 /// happened prints the same one-JSON-object-on-stdout shape as a verb that 1061 const QuicOpenResult = union(enum) { conn: AgentConnection, exit: u8 };
1127 /// ran — an agent parses one thing whatever went wrong.
1128 const Opened = union(enum) { conn: Conn, exit: u8 };
1129 1062
1130 fn openQuicConn( 1063 fn openQuicConn(
1131 alloc: std.mem.Allocator, 1064 alloc: std.mem.Allocator,
1132 o: Opts, 1065 o: AgentArguments,
1133 host_port: []const u8, 1066 host_port: []const u8,
1134 deadline: i64, 1067 deadline: i64,
1135 ) Opened { 1068 ) QuicOpenResult {
1136 // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists. The 1069 // Reuse XDG helpers for `--key`, `MUX_KEY_FILE`, and default-path precedence
1137 // order is not spelled here on purpose: xdg owns it and mux reads the same 1070 // so every client selects the same credential.
1138 // two functions, since a drifted copy authenticates with a different key.
1139 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env))) catch |e| 1071 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env))) catch |e|
1140 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) }; 1072 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) };
1141 const key_path = switch (res) { 1073 const key_path = switch (res) {
1142 .given, .default => |p| p, 1074 .given, .default => |p| p,
1143 // The path is the detail because it is the actionable half: the 1075 // Include the missing path so callers know which credential to create.
1144 // agent (or the human reading its log) needs to know which file
1145 // `mux d keygen` was supposed to have written.
1146 .missing => |p| return .{ .exit = fail( 1076 .missing => |p| return .{ .exit = fail(
1147 "quic: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`", 1077 "quic: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`",
1148 p, 1078 p,
1149 ) }, 1079 ) },
1150 }; 1080 };
1151 const key = quic.Key.load(key_path) catch |e| { 1081 const key = quic.Key.load(key_path) catch |e| {
1152 // The daemon's words for a key the daemon would also refuse — 1082 // Reuse the daemon's key validation text, including permission checks.
1153 // including the group/other-readable refusal, which this binary
1154 // gets for free by loading the key the same way.
1155 var buf: [quic.key_refusal_len]u8 = undefined; 1083 var buf: [quic.key_refusal_len]u8 = undefined;
1156 return .{ .exit = fail("quic: unusable key", quic.keyRefusalBody(&buf, e, key_path)) }; 1084 return .{ .exit = fail("quic: unusable key", quic.keyRefusalBody(&buf, e, key_path)) };
1157 }; 1085 };
@@ -1161,7 +1089,7 @@ fn openQuicConn(
1161 @errorName(e); 1089 @errorName(e);
1162 return .{ .exit = fail("quic: cannot read HOST:PORT", detail) }; 1090 return .{ .exit = fail("quic: cannot read HOST:PORT", detail) };
1163 }; 1091 };
1164 const conn = Conn.openQuic(alloc, addr, key, quic.default_idle_ms, deadline) catch |e| { 1092 const conn = AgentConnection.openQuic(alloc, addr, key, quic.default_idle_ms, deadline) catch |e| {
1165 var buf: [512]u8 = undefined; 1093 var buf: [512]u8 = undefined;
1166 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch 1094 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch
1167 @errorName(e); 1095 @errorName(e);
@@ -1170,7 +1098,7 @@ fn openQuicConn(
1170 return .{ .conn = conn }; 1098 return .{ .conn = conn };
1171 } 1099 }
1172 1100
1173 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u8 { 1101 fn verbStatus(alloc: std.mem.Allocator, conn: *AgentConnection, session: []const u8, deadline: i64) !u8 {
1174 // status_req's WHOLE payload is the name — this connection never 1102 // status_req's WHOLE payload is the name — this connection never
1175 // attaches (see attachZero's callers; `status` is not one of them), so 1103 // attaches (see attachZero's callers; `status` is not one of them), so
1176 // there is no slot for the daemon to fall back to and the tail is the 1104 // there is no slot for the daemon to fall back to and the tail is the
@@ -1254,7 +1182,7 @@ test "printStatus spells a pending exit code as JSON null" {
1254 ); 1182 );
1255 } 1183 }
1256 1184
1257 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const u8, deadline: i64) !u8 { 1185 fn verbCapture(alloc: std.mem.Allocator, conn: *AgentConnection, vt: bool, session: []const u8, deadline: i64) !u8 {
1258 // vt byte ++ session-name tail, the same shape the daemon's own `dump` sends 1186 // vt byte ++ session-name tail, the same shape the daemon's own `dump` sends
1259 // — and built by the same encoder, so it cannot drift from it. 1187 // — and built by the same encoder, so it cannot drift from it.
1260 var buf: [proto.debug_dump_max_len]u8 = undefined; 1188 var buf: [proto.debug_dump_max_len]u8 = undefined;
@@ -1277,20 +1205,17 @@ fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const
1277 return emit(out.items, 0); 1205 return emit(out.items, 0);
1278 } 1206 }
1279 1207
1280 /// Join claiming NO grid: applySize refuses under 2, so the 0x0 slot 1208 /// Join without claiming terminal dimensions. The 0x0 size cannot create a
1281 /// makes no claim and no human's terminal is resized. `name` is 1209 /// session and does not resize an existing human client's grid.
1282 /// joins-only: a 0x0 attach cannot create, resolveSession demands a 1210 fn attachZero(conn: *AgentConnection, name: []const u8, deadline: i64) !void {
1283 /// real size. 1211 // Clear snapshot state for every attachment, including reconnect, so an
1284 fn attachZero(conn: *Conn, name: []const u8, deadline: i64) !void { 1212 // attach rejection cannot inherit success from the previous connection.
1285 // Cleared here and nowhere else: the reconnect path attaches a SECOND
1286 // time on a connection that has already seen a snapshot, and a stale
1287 // true would read that reconnect's refusal as the session ending.
1288 conn.saw_snapshot = false; 1213 conn.saw_snapshot = false;
1289 var buf: [proto.attach_max_len]u8 = undefined; 1214 var buf: [proto.attach_max_len]u8 = undefined;
1290 try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline); 1215 try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline);
1291 } 1216 }
1292 1217
1293 fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []const u8, deadline: i64) !u8 { 1218 fn verbSend(alloc: std.mem.Allocator, conn: *AgentConnection, arg: ?[]const u8, session: []const u8, deadline: i64) !u8 {
1294 const spec = arg orelse return fail("send: needs BYTES", ""); 1219 const spec = arg orelse return fail("send: needs BYTES", "");
1295 const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e)); 1220 const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e));
1296 defer alloc.free(bytes); 1221 defer alloc.free(bytes);
@@ -1325,13 +1250,12 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []
1325 return emit("{\"sent\":true}\n", 0); 1250 return emit("{\"sent\":true}\n", 0);
1326 } 1251 }
1327 1252
1328 /// How much longer than the daemon this client is willing to wait. The daemon 1253 /// Additional client-side wait beyond the daemon timeout. The daemon starts its
1329 /// starts its own window when it READS the `await_req`, later than this process 1254 /// timer only after reading `await_req`, so equal deadlines would make the
1330 /// started counting — so waiting exactly `timeout_ms` loses that race every 1255 /// client expire before receiving the daemon's timeout response.
1331 /// time, and every timeout surfaces as "no reply" instead of exit 3.
1332 const await_grace_ms = 2_000; 1256 const await_grace_ms = 2_000;
1333 1257
1334 /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the 1258 /// The ceiling on the QUIC arm's derived grace (AgentConnection.graceMs), and the
1335 /// reason it has one is that `connect_ms` has no bound of its own worth 1259 /// reason it has one is that `connect_ms` has no bound of its own worth
1336 /// multiplying by four. 1260 /// multiplying by four.
1337 const grace_cap_ms = 30_000; 1261 const grace_cap_ms = 30_000;
@@ -1373,8 +1297,8 @@ test "the span fetch is bounded even when the run it follows was not" {
1373 /// Ask to be told when the session next comes to rest, and wait for it. 1297 /// Ask to be told when the session next comes to rest, and wait for it.
1374 fn doAwait( 1298 fn doAwait(
1375 alloc: std.mem.Allocator, 1299 alloc: std.mem.Allocator,
1376 conn: *Conn, 1300 conn: *AgentConnection,
1377 o: Opts, 1301 o: AgentArguments,
1378 since_seq: u64, 1302 since_seq: u64,
1379 deadline: i64, 1303 deadline: i64,
1380 ) !proto.AwaitReply { 1304 ) !proto.AwaitReply {
@@ -1394,8 +1318,8 @@ fn doAwait(
1394 /// never `run`'s input. 1318 /// never `run`'s input.
1395 fn awaitReissuing( 1319 fn awaitReissuing(
1396 alloc: std.mem.Allocator, 1320 alloc: std.mem.Allocator,
1397 conn: *Conn, 1321 conn: *AgentConnection,
1398 o: Opts, 1322 o: AgentArguments,
1399 since_seq: u64, 1323 since_seq: u64,
1400 deadline: i64, 1324 deadline: i64,
1401 ) !proto.AwaitReply { 1325 ) !proto.AwaitReply {
@@ -1440,7 +1364,7 @@ fn awaitReissuing(
1440 /// Every error but one is its own name, because `ConnectionLost` is the only one 1364 /// Every error but one is its own name, because `ConnectionLost` is the only one
1441 /// whose name is half the story: the redial failed, the redial was already 1365 /// whose name is half the story: the redial failed, the redial was already
1442 /// spent, or nothing tried to redial. 1366 /// spent, or nothing tried to redial.
1443 fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 { 1367 fn waitFailDetail(buf: []u8, conn: *const AgentConnection, e: anyerror) []const u8 {
1444 if (e != error.ConnectionLost) return @errorName(e); 1368 if (e != error.ConnectionLost) return @errorName(e);
1445 if (conn.reconnect_failure) |why| { 1369 if (conn.reconnect_failure) |why| {
1446 return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch 1370 return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch
@@ -1454,10 +1378,9 @@ fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 {
1454 return "connection lost"; 1378 return "connection lost";
1455 } 1379 }
1456 1380
1457 /// The session's RETURN WATERMARK: the seq of the last command return, 0 if 1381 /// Sequence number of the session's most recent command return, or zero when no
1458 /// none. Handed straight to `since_seq`, where it means "only a return 1382 /// command has returned. It becomes `since_seq` for the subsequent wait.
1459 /// newer than this may answer me". 1383 fn currentSeq(alloc: std.mem.Allocator, conn: *AgentConnection, session: []const u8, deadline: i64) !u64 {
1460 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u64 {
1461 // Same session as the attach that precedes this call — the attached- 1384 // Same session as the attach that precedes this call — the attached-
1462 // tail equality rule (server.zig) demands it. 1385 // tail equality rule (server.zig) demands it.
1463 try conn.sendFrame(.status_req, session, deadline); 1386 try conn.sendFrame(.status_req, session, deadline);
@@ -1520,7 +1443,7 @@ test "stripSgr leaves text, drops SGR and OSC" {
1520 /// output, not a failed run. 1443 /// output, not a failed run.
1521 fn fetchSpan( 1444 fn fetchSpan(
1522 alloc: std.mem.Allocator, 1445 alloc: std.mem.Allocator,
1523 conn: *Conn, 1446 conn: *AgentConnection,
1524 start_row: u32, 1447 start_row: u32,
1525 end_row: u32, 1448 end_row: u32,
1526 deadline: i64, 1449 deadline: i64,
@@ -1584,9 +1507,8 @@ test "printAwaitReply omits output when there is none and spells a missing code
1584 try std.testing.expect(std.mem.indexOf(u8, with.items, "\"output\":\"a\\nb\"") != null); 1507 try std.testing.expect(std.mem.indexOf(u8, with.items, "\"output\":\"a\\nb\"") != null);
1585 } 1508 }
1586 1509
1587 /// The session ran its last command. An ANSWER for `run` and `await` — the 1510 /// Emit the successful `run` or `await` result after the session command ends,
1588 /// command is over and this is how — so it prints on stdout and exits 0, 1511 /// including a nonzero session exit code as data.
1589 /// unlike the other verbs, which have nothing to report and fail.
1590 fn printSessionEnded(writer: anytype, code: ?u8, duration_ms: i64) !void { 1512 fn printSessionEnded(writer: anytype, code: ?u8, duration_ms: i64) !void {
1591 try writer.writeAll("{\"reason\":\"session_ended\",\"exit_code\":"); 1513 try writer.writeAll("{\"reason\":\"session_ended\",\"exit_code\":");
1592 try writeExitCode(writer, code); 1514 try writeExitCode(writer, code);
@@ -1618,8 +1540,8 @@ fn reportSessionEnded(alloc: std.mem.Allocator, code: ?u8, duration_ms: i64) !u8
1618 /// the whole difference. 1540 /// the whole difference.
1619 fn awaitVerb( 1541 fn awaitVerb(
1620 alloc: std.mem.Allocator, 1542 alloc: std.mem.Allocator,
1621 conn: *Conn, 1543 conn: *AgentConnection,
1622 o: Opts, 1544 o: AgentArguments,
1623 deadline: i64, 1545 deadline: i64,
1624 cmdline: ?[]const u8, 1546 cmdline: ?[]const u8,
1625 ) !u8 { 1547 ) !u8 {
@@ -1632,9 +1554,9 @@ fn awaitVerb(
1632 attachZero(conn, o.sessionName(), deadline) catch |e| 1554 attachZero(conn, o.sessionName(), deadline) catch |e|
1633 return failSend(e, o.sessionName(), .attach, who, "attach failed"); 1555 return failSend(e, o.sessionName(), .attach, who, "attach failed");
1634 1556
1635 // BEFORE the input: the watermark has to be the one this command must beat. 1557 // Read the watermark before sending input. A fast command could otherwise
1636 // Read afterwards, a command fast enough to return between the two moves the 1558 // return between those operations and leave the wait targeting a later
1637 // seq past a value we never recorded, and the await waits for a past return. 1559 // sequence.
1638 const since = currentSeq(alloc, conn, o.sessionName(), deadline) catch |e| switch (e) { 1560 const since = currentSeq(alloc, conn, o.sessionName(), deadline) catch |e| switch (e) {
1639 error.AttachRefused => return failAttachRefused(o.sessionName(), .attach), 1561 error.AttachRefused => return failAttachRefused(o.sessionName(), .attach),
1640 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1562 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
@@ -1685,16 +1607,15 @@ fn elapsed(started: i64) i64 {
1685 } 1607 }
1686 1608
1687 /// This client's deadline: the daemon's own bound plus the grace window 1609 /// This client's deadline: the daemon's own bound plus the grace window
1688 /// (await_grace_ms, widened per transport by `Conn.graceMs`). Unbounded 1610 /// (await_grace_ms, widened per transport by `AgentConnection.graceMs`). Unbounded
1689 /// stays unbounded. 1611 /// stays unbounded.
1690 fn awaitDeadline(o: Opts, conn: *const Conn) i64 { 1612 fn awaitDeadline(o: AgentArguments, conn: *const AgentConnection) i64 {
1691 if (o.timeout == 0) return std.math.maxInt(i64); 1613 if (o.timeout == 0) return std.math.maxInt(i64);
1692 return std.time.milliTimestamp() + o.timeout + conn.graceMs(); 1614 return std.time.milliTimestamp() + o.timeout + conn.graceMs();
1693 } 1615 }
1694 1616
1695 // Forces semantic analysis of every pub decl under `zig build test`, so an 1617 // Ensure every public declaration is semantically analyzed during tests;
1696 // unreferenced decl must at least compile (the silent-module-loss hazard, 1618 // `std.meta.declarations` does not include private declarations.
1697 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
1698 test { 1619 test {
1699 std.testing.refAllDeclsRecursive(@This()); 1620 std.testing.refAllDeclsRecursive(@This());
1700 } 1621 }
src/cli/spawn.zig
Old New
@@ -1,26 +1,26 @@
1 //! The file to exec when mux starts another copy of itself — a daemon. 1 //! Resolve the executable used when mux starts a daemon process. Following
2 //! `/proc/self/exe` read through to the real path, so a start runs THIS 2 //! `/proc/self/exe` ensures the new process runs the current binary rather than
3 //! binary and never a `mux` that PATH happens to find. Under src/cli/ 3 //! another `mux` found through `PATH`.
4 //! because asking the OS about the running process is not a client's question.
5 const std = @import("std"); 4 const std = @import("std");
6 5
7 /// The kernel's link to the running image. The fallback only: a start is 6 /// Kernel link to the running executable, used when the resolved path is no
8 /// worth more than the name it will wear. 7 /// longer executable.
9 pub const self_exe = "/proc/self/exe"; 8 pub const self_exe = "/proc/self/exe";
10 9
11 /// The file every production start execs: this image, resolved through 10 /// Return the resolved path of the current executable, falling back to
12 /// the /proc link to the path it names. 11 /// `/proc/self/exe`.
13 pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { 12 pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 {
14 // Resolved, not the link: `comm` is the basename of the filename handed 13 // Prefer the resolved path because process listings derive `comm` from the
15 // to execve, so exec'ing the link names every daemon `exe` in ps and pgrep. 14 // filename passed to execve; executing the link would name every daemon
15 // `exe`.
16 return execOrLink(std.fs.selfExePath(buf) catch return self_exe); 16 return execOrLink(std.fs.selfExePath(buf) catch return self_exe);
17 } 17 }
18 18
19 /// The resolved path if it can still be exec'd, the /proc link if it cannot. 19 /// The resolved path if it can still be exec'd, the /proc link if it cannot.
20 /// Split out so the fallback is assertable without deleting a live binary. 20 /// Split out so the fallback is assertable without deleting a live binary.
21 fn execOrLink(resolved: []const u8) []const u8 { 21 fn execOrLink(resolved: []const u8) []const u8 {
22 // A readlink that SUCCEEDS can still name nothing: `make install` leaves 22 // After `make install`, the resolved path may end in ` (deleted)` and no
23 // `/…/mux (deleted)`, a description. The start outweighs the name. 23 // longer be executable even though readlink succeeded.
24 std.posix.access(resolved, std.posix.X_OK) catch return self_exe; 24 std.posix.access(resolved, std.posix.X_OK) catch return self_exe;
25 return resolved; 25 return resolved;
26 } 26 }
@@ -30,8 +30,8 @@ fn execOrLink(resolved: []const u8) []const u8 {
30 test "selfExe: the exec'd name is a real file, not the /proc link" { 30 test "selfExe: the exec'd name is a real file, not the /proc link" {
31 var buf: [std.fs.max_path_bytes]u8 = undefined; 31 var buf: [std.fs.max_path_bytes]u8 = undefined;
32 const exe = selfExe(&buf); 32 const exe = selfExe(&buf);
33 // comm is the basename of the filename exec'd, so this resolution IS the 33 // The resolved basename becomes the process name shown by tools such as
34 // name the daemon wears in ps, pgrep and killall. 34 // `ps`, `pgrep`, and `killall`.
35 try std.testing.expect(!std.mem.eql(u8, exe, self_exe)); 35 try std.testing.expect(!std.mem.eql(u8, exe, self_exe));
36 try std.posix.access(exe, std.posix.X_OK); 36 try std.posix.access(exe, std.posix.X_OK);
37 } 37 }
src/cli/webhub_main.zig
Old New
@@ -1,10 +1,10 @@
1 //! `mux web` — the hub mode: serves the wall page on 127.0.0.1 and pumps one 1 //! `mux web` — the hub mode: serves the wall page on 127.0.0.1 and pumps one
2 //! WebSocket per tile. 2 //! WebSocket per tile.
3 //! 3 //!
4 //! The wall is the HOSTS FILE, exactly as for the CLI. A HOST on argv is 4 //! The wall is built from the same hosts file as the CLI. Hosts supplied on the
5 //! RECORDED into the file, the same thing `mux HOST` does, and then the FILE is 5 //! command line are recorded in that file before it is loaded. Session suffixes
6 //! the wall. `#SESSION` is refused: nothing here may name a session, because 6 //! are rejected because this command lists daemons and discovers their live
7 //! nothing here may resurrect one. 7 //! sessions rather than opening a named session.
8 8
9 const std = @import("std"); 9 const std = @import("std");
10 const client = @import("client"); 10 const client = @import("client");
@@ -32,66 +32,60 @@ const usage =
32 \\ 32 \\
33 ; 33 ;
34 34
35 /// The command line, read off the struct: a field's type is its flag's arity 35 /// Parsed hub flags and host arguments. Field names and types define the flag
36 /// and its name is the spelling. What a flag MEANS stays in the post-checks 36 /// syntax; `parseArgs` applies the semantic checks afterward.
37 /// below. This IS the parse result — a second struct would be three fields to 37 const HubArguments = struct {
38 /// forget one of.
39 const Parsed = struct {
40 port: u16 = webhub.default_port, 38 port: u16 = webhub.default_port,
41 key: ?[]const u8 = null, 39 key: ?[]const u8 = null,
42 quic_idle_ms: client.IdleMs = .{}, 40 quic_idle_ms: client.IdleMs = .{},
43 _argv: hosts.Argv, 41 _argv: hosts.Argv,
44 42
45 pub fn positional(self: *Parsed, w: []const u8) bool { 43 pub fn positional(self: *HubArguments, w: []const u8) bool {
46 return self._argv.positional(w); 44 return self._argv.positional(w);
47 } 45 }
48 pub fn extra(self: *Parsed, rest: []const [:0]const u8) usize { 46 pub fn extra(self: *HubArguments, rest: []const [:0]const u8) usize {
49 return self._argv.extra(rest); 47 return self._argv.extra(rest);
50 } 48 }
51 49
52 fn deinit(self: *Parsed) void { 50 fn deinit(self: *HubArguments) void {
53 self._argv.deinit(); 51 self._argv.deinit();
54 } 52 }
55 }; 53 };
56 54
57 comptime { 55 comptime {
58 cliflags.assertDocumented(Parsed, usage, &.{}); 56 cliflags.assertDocumented(HubArguments, usage, &.{});
59 } 57 }
60 58
61 /// Only a serving line hands anything back, so everything else is an 59 /// Parsing can fail through the shared CLI errors or while allocating the host
62 /// error: the single errdefer then owns the tile list on every path that 60 /// list. Keeping failures in an error union lets one `errdefer` release that
63 /// does not serve. As union arms they wanted a `deinit` beside each 61 /// list on every unsuccessful path.
64 /// refusing return, every one of them a chance to forget.
65 const ParseError = cliflags.ParseError || std.mem.Allocator.Error; 62 const ParseError = cliflags.ParseError || std.mem.Allocator.Error;
66 63
67 fn parseArgs( 64 fn parseArgs(
68 alloc: std.mem.Allocator, 65 alloc: std.mem.Allocator,
69 args: []const [:0]const u8, 66 args: []const [:0]const u8,
70 env_key: ?[]const u8, 67 env_key: ?[]const u8,
71 ) ParseError!Parsed { 68 ) ParseError!HubArguments {
72 var p = Parsed{ ._argv = .{ .alloc = alloc } }; 69 var p = HubArguments{ ._argv = .{ .alloc = alloc } };
73 errdefer p.deinit(); 70 errdefer p.deinit();
74 71
75 const outcome = cliflags.parseStrict(Parsed, &p, args[1..]); 72 const outcome = cliflags.parseStrict(HubArguments, &p, args[1..]);
76 // Read before the outcome: a hook that refused for a REASON has already 73 // The host parser records a specific reason before returning false to the
77 // named it, and that reason outranks the bare "unknown word" cliflags 74 // generic flag parser. Report that reason first so an invalid command with
78 // saw when the hook said no. The message names the tile — with several 75 // several hosts identifies the offending target.
79 // targets on the line, `usage` alone would not say which.
80 if (p._argv.err) |e| { 76 if (p._argv.err) |e| {
81 if (e.err == error.OutOfMemory) return error.OutOfMemory; 77 if (e.err == error.OutOfMemory) return error.OutOfMemory;
82 std.debug.print("mux web: host {s}: {s}\n", .{ e.word, hosts.reason(e.err) }); 78 std.debug.print("mux web: host {s}: {s}\n", .{ e.word, hosts.reason(e.err) });
83 return error.Usage; 79 return error.Usage;
84 } 80 }
85 try outcome; 81 try outcome;
86 // Port 0 asks the kernel to choose, and the hub prints the port it was 82 // Port zero would make the kernel choose an ephemeral port, but the hub
87 // asked for as the door to open — a door nobody could find. Refused 83 // reports the configured value. Reject it rather than printing an unusable
88 // like `--quic-idle-ms 0` and for the same reason: the number inverts 84 // address.
89 // what typing it means.
90 if (p.port == 0) return error.Usage; 85 if (p.port == 0) return error.Usage;
91 86
92 // No hosts is not a usage error: it asks for whatever the file holds. 87 // An empty host list is valid because `main` will load existing hosts from
93 // main decides what an empty argv means; the parse only reports what 88 // the state file.
94 // was on the line.
95 p.key = xdg.pickKey(p.key, env_key); 89 p.key = xdg.pickKey(p.key, env_key);
96 return p; 90 return p;
97 } 91 }
@@ -109,17 +103,15 @@ pub fn main(args: []const [:0]const u8) !u8 {
109 }; 103 };
110 defer parsed.deinit(); 104 defer parsed.deinit();
111 105
112 // An arena, because every string built here lives exactly as long as the hub 106 // All resolved host data lives until the non-returning accept loop ends, so
113 // does and nothing is ever freed early. The process exits from inside the 107 // one arena owns it for the lifetime of the hub.
114 // accept loop, so "as long as the hub" is "until exit".
115 var arena_state = std.heap.ArenaAllocator.init(alloc); 108 var arena_state = std.heap.ArenaAllocator.init(alloc);
116 defer arena_state.deinit(); 109 defer arena_state.deinit();
117 const arena = arena_state.allocator(); 110 const arena = arena_state.allocator();
118 111
119 const state_path = try hosts.statePath(arena); 112 const state_path = try hosts.statePath(arena);
120 // Argv is RECORDED, never a view of its own: `mux web box` is `mux 113 // Command-line hosts are persisted before loading the wall, making
121 // hosts add box` followed by a hub, exactly as `mux box` is. Written 114 // `mux web box` equivalent to adding `box` and then starting the hub.
122 // before the load, so the load below is the one road onto the wall.
123 for (parsed._argv.list.items) |spelling| { 115 for (parsed._argv.list.items) |spelling| {
124 _ = hosts.record(alloc, state_path, spelling) catch |err| 116 _ = hosts.record(alloc, state_path, spelling) catch |err|
125 return refuseFile(arena, state_path, err); 117 return refuseFile(arena, state_path, err);
@@ -127,16 +119,13 @@ pub fn main(args: []const [:0]const u8) !u8 {
127 var h = hosts.load(arena, state_path) catch |err| return refuseFile(arena, state_path, err); 119 var h = hosts.load(arena, state_path) catch |err| return refuseFile(arena, state_path, err);
128 defer h.deinit(arena); 120 defer h.deinit(arena);
129 if (h.lines.items.len == 0) { 121 if (h.lines.items.len == 0) {
130 // NOT the local socket: nothing asked for a daemon, and a read 122 // Unlike the interactive client, the web hub does not start a local
131 // never starts one. Said once, so an empty wall is a wall the user 123 // daemon when the hosts file is empty. Print guidance once instead.
132 // knows how to fill rather than a page that looks broken.
133 std.debug.print("mux web: no hosts (mux hosts add HOST, or mux HOST)\n", .{}); 124 std.debug.print("mux web: no hosts (mux hosts add HOST, or mux HOST)\n", .{});
134 } 125 }
135 126
136 // The SAME resolver the CLI wall runs on — handoff.recipeFor and 127 // Use the same resolver as the CLI wall so host syntax, key resolution,
137 // xdg.resolveKeyPath through `client.resolveHost` — so the two fronts 128 // and background polling behavior remain consistent between interfaces.
138 // cannot drift on what a bare HOST or a `quic://` means, and the poll
139 // recipe is the one nobody is sitting in front of.
140 const specs = try arena.alloc(client.HostSpec, h.lines.items.len); 129 const specs = try arena.alloc(client.HostSpec, h.lines.items.len);
141 for (specs, h.lines.items) |*spec, line| { 130 for (specs, h.lines.items) |*spec, line| {
142 spec.* = client.resolveHost(arena, line, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) { 131 spec.* = client.resolveHost(arena, line, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) {
@@ -161,9 +150,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
161 }; 150 };
162 defer listener.deinit(); 151 defer listener.deinit();
163 152
164 // The door first, then the tiles as their hosts answer: a tile is a 153 // Print the listening address immediately. Tiles are announced later by
165 // live session on a listed daemon, so the hub does not know one until 154 // `Hub.birth` as polling discovers live sessions.
166 // a poll comes back. `Hub.birth` prints each `tile N:` line.
167 std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{ 155 std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{
168 parsed.port, 156 parsed.port,
169 std.os.linux.getpid(), 157 std.os.linux.getpid(),
@@ -197,9 +185,8 @@ test "parse: three spellings become three hosts in argv order, port and key bind
197 defer r.deinit(); 185 defer r.deinit();
198 try std.testing.expectEqual(@as(usize, 3), r._argv.list.items.len); 186 try std.testing.expectEqual(@as(usize, 3), r._argv.list.items.len);
199 try std.testing.expectEqualStrings("box1", r._argv.list.items[0]); 187 try std.testing.expectEqualStrings("box1", r._argv.list.items[0]);
200 // `--sock PATH` is ONE spelling from here on, prefix included — that 188 // From this point, `--sock PATH` is stored as one string used by the hosts
201 // string is the hosts-file line, the page's label, and the resolver's 189 // file, page label, and resolver.
202 // input alike.
203 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.list.items[1]); 190 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.list.items[1]);
204 try std.testing.expectEqualStrings("quic://h:4433", r._argv.list.items[2]); 191 try std.testing.expectEqualStrings("quic://h:4433", r._argv.list.items[2]);
205 try std.testing.expectEqual(@as(u16, 8000), r.port); 192 try std.testing.expectEqual(@as(u16, 8000), r.port);
@@ -217,28 +204,24 @@ test "parse: a quoted '--sock PATH' is the same host as the two-argument form" {
217 204
218 test "parse: zero hosts, bad flags, and flag-beats-env" { 205 test "parse: zero hosts, bad flags, and flag-beats-env" {
219 const alloc = std.testing.allocator; 206 const alloc = std.testing.allocator;
220 // No hosts is an empty argv, not a refusal: what the FILE holds is 207 // No command-line hosts is valid; `main` is responsible for loading the
221 // main's business, which is the only place that can read one. 208 // hosts file.
222 { 209 {
223 var r = try parseArgs(alloc, &[_][:0]const u8{"web"}, null); 210 var r = try parseArgs(alloc, &[_][:0]const u8{"web"}, null);
224 defer r.deinit(); 211 defer r.deinit();
225 try std.testing.expectEqual(@as(usize, 0), r._argv.list.items.len); 212 try std.testing.expectEqual(@as(usize, 0), r._argv.list.items.len);
226 } 213 }
227 // Every other refusal arrives as `error.Usage`, and the testing allocator is 214 // Syntax failures return `error.Usage`. The testing allocator also verifies
228 // the other half of the pin: a refusal that leaked the owned host list would 215 // that each failure releases any hosts already appended to the list.
229 // fail the test that provoked it.
230 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null)); 216 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null));
231 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null)); 217 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null));
232 // The refusals that had a host on the list already, so the cleanup is 218 // Exercise failures after a host has already been allocated.
233 // load-bearing rather than theoretical.
234 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--wat" }, null)); 219 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--wat" }, null));
235 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "quic://" }, null)); 220 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "quic://" }, null));
236 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--quic-idle-ms", "0" }, null)); 221 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--quic-idle-ms", "0" }, null));
237 // Port 0 means "kernel, you pick" — but the hub announces the port it 222 // Port zero would make the announced address differ from the bound port.
238 // was asked for, so the door it prints is not the door it opened.
239 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "0" }, null)); 223 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "0" }, null));
240 // ...and an ordinary port still binds, so the refusal is the zero and 224 // A nonzero value confirms that the flag itself is accepted.
241 // not the flag.
242 { 225 {
243 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "1" }, null); 226 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "1" }, null);
244 defer r.deinit(); 227 defer r.deinit();
@@ -270,8 +253,7 @@ test "hosts: the spelling reaches the file verbatim" {
270 defer r.deinit(); 253 defer r.deinit();
271 try std.testing.expectEqual(@as(usize, 4), r._argv.list.items.len); 254 try std.testing.expectEqual(@as(usize, 4), r._argv.list.items.len);
272 255
273 // The user asked for `user@box.example.com`, so that is the file's 256 // Preserve the exact hostname for both the state-file entry and tile label.
274 // line and therefore the tile's label — nobody decorates it on the way.
275 try std.testing.expectEqualStrings("user@box.example.com", r._argv.list.items[0]); 257 try std.testing.expectEqualStrings("user@box.example.com", r._argv.list.items[0]);
276 try std.testing.expectEqualStrings("quic://h:1", r._argv.list.items[1]); 258 try std.testing.expectEqualStrings("quic://h:1", r._argv.list.items[1]);
277 // The flag and its value become one spelling. 259 // The flag and its value become one spelling.
@@ -279,11 +261,10 @@ test "hosts: the spelling reaches the file verbatim" {
279 try std.testing.expectEqualStrings("plainhost", r._argv.list.items[3]); 261 try std.testing.expectEqualStrings("plainhost", r._argv.list.items[3]);
280 } 262 }
281 263
282 test "hosts: a '#SESSION' is refused at parse, in every spelling" { 264 test "hosts: a '#SESSION' is rejected during parsing in every spelling" {
283 const alloc = std.testing.allocator; 265 const alloc = std.testing.allocator;
284 // The wall lists DAEMONS, so a `#NAME` is refused at argv altitude rather 266 // Reject `#NAME` while parsing argv because wall entries identify daemons,
285 // than surfacing as a line the strict loader will not take. The `mux web:` 267 // not sessions. The emitted `mux web:` diagnostics identify each bad host.
286 // lines in this test's output are the point, not noise.
287 for ([_][]const u8{ "host#b", "host#has space", "host#", "a#b#c", "quic://h:1#b" }) |bad| { 268 for ([_][]const u8{ "host#b", "host#has space", "host#", "a#b#c", "quic://h:1#b" }) |bad| {
288 var argv = [_][:0]const u8{ "web", undefined }; 269 var argv = [_][:0]const u8{ "web", undefined };
289 var buf: [64]u8 = undefined; 270 var buf: [64]u8 = undefined;
@@ -297,21 +278,20 @@ test "hosts: a '#SESSION' is refused at parse, in every spelling" {
297 error.Usage, 278 error.Usage,
298 parseArgs(alloc, &[_][:0]const u8{ "web", "--sock", "/tmp/x#b" }, null), 279 parseArgs(alloc, &[_][:0]const u8{ "web", "--sock", "/tmp/x#b" }, null),
299 ); 280 );
300 // Punctuation in a HOST is the other refusal this grammar owns: the 281 // Also reject punctuation that would make a hostname unsafe as an SSH
301 // word becomes one argv element of an ssh line. 282 // argument.
302 try std.testing.expectError( 283 try std.testing.expectError(
303 error.Usage, 284 error.Usage,
304 parseArgs(alloc, &[_][:0]const u8{ "web", "box; touch /tmp/pwned" }, null), 285 parseArgs(alloc, &[_][:0]const u8{ "web", "box; touch /tmp/pwned" }, null),
305 ); 286 );
306 } 287 }
307 288
308 test "help is an answer, not a refusal, and -- fences the hosts from the flags" { 289 test "help is requested output, and -- separates hosts from flags" {
309 const alloc = std.testing.allocator; 290 const alloc = std.testing.allocator;
310 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "--help" }, null)); 291 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "--help" }, null));
311 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "-h" }, null)); 292 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "-h" }, null));
312 293
313 // Past `--` a word is a host whatever it is spelled like: the escape a 294 // After `--`, a flag-shaped word is treated as a host.
314 // machine whose name reads as a flag would otherwise have none of.
315 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--", "host" }, null); 295 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--", "host" }, null);
316 defer r.deinit(); 296 defer r.deinit();
317 try std.testing.expectEqual(@as(usize, 1), r._argv.list.items.len); 297 try std.testing.expectEqual(@as(usize, 1), r._argv.list.items.len);
@@ -323,17 +303,14 @@ test "version short-circuits everything else on the line" {
323 try std.testing.expectError(error.Version, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--version", "--bogus" }, null)); 303 try std.testing.expectError(error.Version, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--version", "--bogus" }, null));
324 } 304 }
325 305
326 // Forces semantic analysis of every pub decl under `zig build test`, so an 306 // Ensure every public declaration is semantically analyzed during tests;
327 // unreferenced decl must at least compile (the silent-module-loss hazard, 307 // `std.meta.declarations` does not include private declarations.
328 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
329 test { 308 test {
330 std.testing.refAllDeclsRecursive(@This()); 309 std.testing.refAllDeclsRecursive(@This());
331 } 310 }
332 311
333 /// `mux_main.refuseFile`'s shape, in the words this binary answers in: the 312 /// Report a hosts-file error using this command's prefix. For parse errors,
334 /// file may have been hand-edited into a line the strict loader will not 313 /// print each invalid hand-edited line so the user can repair the file.
335 /// take, and naming the line beats a stack trace because the fix is in the
336 /// file.
337 fn refuseFile(arena: std.mem.Allocator, path: []const u8, err: anyerror) u8 { 314 fn refuseFile(arena: std.mem.Allocator, path: []const u8, err: anyerror) u8 {
338 std.debug.print("mux web: {s}: {s}\n", .{ path, hosts.reason(err) }); 315 std.debug.print("mux web: {s}: {s}\n", .{ path, hosts.reason(err) });
339 if (!hosts.isParse(err)) return 1; 316 if (!hosts.isParse(err)) return 1;