7a530237
Clarify CLI parsing types and comments
a73x 2026-08-31 10:12
Commit message
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; |