2c7f1c38
refactor: muxa parses its flags with cliflags
a73x 2026-08-27 06:51
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -256,7 +256,7 @@ const mod_table = [_]ModSpec{ | |||
| 256 | // renders: `quic_client` for the remote arm and `xdg` for the one | 256 | // renders: `quic_client` for the remote arm and `xdg` for the one |
| 257 | // key-resolution rule all three binaries obey. Deliberately still no | 257 | // key-resolution rule all three binaries obey. Deliberately still no |
| 258 | // engine and no replica — muxa has nothing to draw. | 258 | // engine and no replica — muxa has nothing to draw. |
| 259 | .{ .name = "muxa", .path = "src/cli/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true }, | 259 | .{ .name = "muxa", .path = "src/cli/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg", "cliflags" }, .quic_tests = true }, |
| 260 | .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, | 260 | .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, |
| 261 | // ---- layer 3 ---- | 261 | // ---- layer 3 ---- |
| 262 | // Dialling, and what a chord means. The client is the only thing that | 262 | // Dialling, and what a chord means. The client is the only thing that |
| @@ -734,6 +734,7 @@ pub fn build(b: *std.Build) void { | |||
| 734 | mux_mod.addImport("build_options", version_opts.createModule()); | 734 | mux_mod.addImport("build_options", version_opts.createModule()); |
| 735 | exe_mod.addImport("build_options", version_opts.createModule()); | 735 | exe_mod.addImport("build_options", version_opts.createModule()); |
| 736 | webhub_main_mod.addImport("build_options", version_opts.createModule()); | 736 | webhub_main_mod.addImport("build_options", version_opts.createModule()); |
| 737 | muxa_mod.addImport("build_options", version_opts.createModule()); | ||
| 737 | // A row with test_imports gets a SEPARATE module for its test twin, and | 738 | // A row with test_imports gets a SEPARATE module for its test twin, and |
| 738 | // build_options is outside the table's jurisdiction — so every such twin | 739 | // build_options is outside the table's jurisdiction — so every such twin |
| 739 | // needs it by hand, or the argument parsers lose the version they print. | 740 | // needs it by hand, or the argument parsers lose the version they print. |
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -20,10 +20,13 @@ const sockpath = @import("sockpath"); | |||
| 20 | const quic_client = @import("quic_client"); | 20 | const quic_client = @import("quic_client"); |
| 21 | const quic = @import("quic"); | 21 | const quic = @import("quic"); |
| 22 | const xdg = @import("xdg"); | 22 | const xdg = @import("xdg"); |
| 23 | const cliflags = @import("cliflags"); | ||
| 24 | const build_options = @import("build_options"); | ||
| 23 | 25 | ||
| 24 | const usage = | 26 | const usage = |
| 25 | \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]] | 27 | \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]] |
| 26 | \\ [--settle MS] [--timeout MS] [--vt] [--session NAME] [args] | 28 | \\ [--settle MS] [--timeout MS] [--vt] [--session NAME] [args] |
| 29 | \\ muxa --help | --version | ||
| 27 | \\NAME must already exist: muxa attaches at 0x0 and never creates a session | 30 | \\NAME must already exist: muxa attaches at 0x0 and never creates a session |
| 28 | \\(`capture` is the exception that stays quiet: it answers in the grid). | 31 | \\(`capture` is the exception that stays quiet: it answers in the grid). |
| 29 | \\verbs: | 32 | \\verbs: |
| @@ -35,8 +38,9 @@ const usage = | |||
| 35 | \\ | 38 | \\ |
| 36 | ; | 39 | ; |
| 37 | 40 | ||
| 41 | const Verb = enum { status, capture, send, run, await }; | ||
| 42 | |||
| 38 | const Opts = struct { | 43 | const Opts = struct { |
| 39 | verb: enum { status, capture, send, run, await }, | ||
| 40 | sock: ?[]const u8 = null, | 44 | sock: ?[]const u8 = null, |
| 41 | /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are | 45 | /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are |
| 42 | /// identical over it — same frames, same JSON — which is the whole | 46 | /// identical over it — same frames, same JSON — which is the whole |
| @@ -47,81 +51,79 @@ const Opts = struct { | |||
| 47 | /// still to be tried, and neither is parse's to look at (xdg.pickKey | 51 | /// still to be tried, and neither is parse's to look at (xdg.pickKey |
| 48 | /// and xdg.resolveKeyPath own that order here as they do for mux). | 52 | /// and xdg.resolveKeyPath own that order here as they do for mux). |
| 49 | key: ?[]const u8 = null, | 53 | key: ?[]const u8 = null, |
| 50 | settle_ms: u32 = 0, | 54 | settle: u32 = 0, |
| 51 | // Never 0 by default: the daemon reads a 0 timeout on await_req as "no | 55 | // Never 0 by default: the daemon reads a 0 timeout on await_req as "no |
| 52 | // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0 | 56 | // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0 |
| 53 | // would turn every await into an unbounded wait. | 57 | // would turn every await into an unbounded wait. |
| 54 | timeout_ms: u32 = 30_000, | 58 | timeout: u32 = 30_000, |
| 55 | vt: bool = false, | 59 | vt: bool = false, |
| 60 | /// Optional so that only a name that was TYPED is validated: `""` is | ||
| 61 | /// the wire's own default spelling and would fail a check written for | ||
| 62 | /// a name. `_session` below is what the frames actually carry. | ||
| 63 | session: ?[]const u8 = null, | ||
| 56 | /// Which session every verb this invocation makes asks about — the | 64 | /// Which session every verb this invocation makes asks about — the |
| 57 | /// attach it opens with AND every ask that follows carry the same | 65 | /// attach it opens with AND every ask that follows carry the same |
| 58 | /// name, which is what keeps the daemon's attached-tail equality rule | 66 | /// name, which is what keeps the daemon's attached-tail equality rule |
| 59 | /// (server.zig) from ever seeing a mismatch out of this binary. Empty | 67 | /// (server.zig) from ever seeing a mismatch out of this binary. Empty |
| 60 | /// is the wire's own default spelling, so a bare `muxa status` builds | 68 | /// is the wire's own default spelling, so a bare `muxa status` builds |
| 61 | /// byte-identical frames to before this flag existed. | 69 | /// byte-identical frames to before this flag existed. |
| 62 | session: []const u8 = "", | 70 | _session: []const u8 = "", |
| 63 | arg: ?[]const u8 = null, | 71 | /// Null until `parseArgs` reads argv[1]; a parse that returned an Opts |
| 72 | /// has one. | ||
| 73 | _verb: ?Verb = null, | ||
| 74 | _arg: ?[]const u8 = null, | ||
| 75 | |||
| 76 | /// The verb's argument. A second is a mistake: no verb here takes two. | ||
| 77 | pub fn positional(self: *Opts, word: []const u8) bool { | ||
| 78 | if (self._arg != null) return false; | ||
| 79 | self._arg = word; | ||
| 80 | return true; | ||
| 81 | } | ||
| 64 | }; | 82 | }; |
| 65 | 83 | ||
| 66 | fn parseArgs(args: []const [:0]const u8) ?Opts { | 84 | comptime { |
| 67 | if (args.len < 2) return null; | 85 | cliflags.assertDocumented(Opts, usage, &.{}); |
| 68 | const verb = std.meta.stringToEnum(@FieldType(Opts, "verb"), args[1]) orelse return null; | 86 | } |
| 69 | var o: Opts = .{ .verb = verb }; | 87 | |
| 70 | var i: usize = 2; | 88 | const ParseError = error{ Usage, Help, Version }; |
| 71 | // Everything after a bare `--` is the positional argument, whatever it | 89 | |
| 72 | // looks like. Agents send byte-strings for their own reasons, and | 90 | fn parseArgs(args: []const [:0]const u8) ParseError!Opts { |
| 73 | // `muxa send -- '-n foo\n'` must reach the pty rather than be read as | 91 | if (args.len < 2) return error.Usage; |
| 74 | // a flag this binary does not have. | 92 | const verb = std.meta.stringToEnum(Verb, args[1]) orelse { |
| 75 | var end_of_flags = false; | 93 | // The verb slot is also the only slot `muxa --help` has. Asked of |
| 76 | while (i < args.len) : (i += 1) { | 94 | // the same table the verbs' flags go through, so the two spellings |
| 77 | const a = args[i]; | 95 | // of asking cannot drift apart; anything else there is a bad verb. |
| 78 | if (end_of_flags) { | 96 | var probe: Opts = .{}; |
| 79 | if (o.arg != null) return null; | 97 | return switch (cliflags.parse(Opts, &probe, args[1..2])) { |
| 80 | o.arg = a; | 98 | .help => error.Help, |
| 81 | } else if (std.mem.eql(u8, a, "--")) { | 99 | .version => error.Version, |
| 82 | end_of_flags = true; | 100 | else => error.Usage, |
| 83 | } else if (std.mem.eql(u8, a, "--sock")) { | 101 | }; |
| 84 | i += 1; | 102 | }; |
| 85 | if (i >= args.len) return null; | 103 | var o: Opts = .{ ._verb = verb }; |
| 86 | o.sock = args[i]; | 104 | switch (cliflags.parse(Opts, &o, args[2..])) { |
| 87 | } else if (std.mem.eql(u8, a, "--quic")) { | 105 | .ok => {}, |
| 88 | i += 1; | 106 | .help => return error.Help, |
| 89 | if (i >= args.len) return null; | 107 | .version => return error.Version, |
| 90 | o.quic = args[i]; | 108 | .unknown_arg, .missing_value, .bad_number => return error.Usage, |
| 91 | } else if (std.mem.eql(u8, a, "--key")) { | 109 | } |
| 92 | i += 1; | 110 | |
| 93 | if (i >= args.len) return null; | 111 | // Refused here rather than carried to the wire as a payload nothing |
| 94 | o.key = args[i]; | 112 | // could ever look up: usage exit (2), not a frame. |
| 95 | } else if (std.mem.eql(u8, a, "--settle")) { | 113 | if (o.session) |name| { |
| 96 | i += 1; | 114 | if (!proto.validSessionName(name)) return error.Usage; |
| 97 | if (i >= args.len) return null; | ||
| 98 | o.settle_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; | ||
| 99 | } else if (std.mem.eql(u8, a, "--timeout")) { | ||
| 100 | i += 1; | ||
| 101 | if (i >= args.len) return null; | ||
| 102 | o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; | ||
| 103 | } else if (std.mem.eql(u8, a, "--vt")) { | ||
| 104 | o.vt = true; | ||
| 105 | } else if (std.mem.eql(u8, a, "--session")) { | ||
| 106 | i += 1; | ||
| 107 | if (i >= args.len) return null; | ||
| 108 | // Refused here rather than carried to the wire as a payload | ||
| 109 | // nothing could ever look up: usage exit (2), not a frame. | ||
| 110 | if (!proto.validSessionName(args[i])) return null; | ||
| 111 | o.session = args[i]; | ||
| 112 | } else if (o.arg == null and a.len > 0 and a[0] != '-') { | ||
| 113 | o.arg = a; | ||
| 114 | } else return null; | ||
| 115 | } | 115 | } |
| 116 | o._session = o.session orelse ""; | ||
| 117 | |||
| 116 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` | 118 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` |
| 117 | // would send an agent's frames somewhere other than the socket it | 119 | // would send an agent's frames somewhere other than the socket it |
| 118 | // named, and the two answers differ — this is the mistake `mux` | 120 | // named, and the two answers differ — this is the mistake `mux` |
| 119 | // refuses as `.conflict` for the same reason. | 121 | // refuses as `.conflict` for the same reason. |
| 120 | if (o.quic != null and o.sock != null) return null; | 122 | if (o.quic != null and o.sock != null) return error.Usage; |
| 121 | // A key with nothing to authenticate to, refused exactly where muxd | 123 | // A key with nothing to authenticate to, refused exactly where muxd |
| 122 | // refuses it: there is no reading of `--key` without `--quic` that | 124 | // refuses it: there is no reading of `--key` without `--quic` that |
| 123 | // makes it sensible, and the unix socket has no key at all. | 125 | // makes it sensible, and the unix socket has no key at all. |
| 124 | if (o.key != null and o.quic == null) return null; | 126 | if (o.key != null and o.quic == null) return error.Usage; |
| 125 | return o; | 127 | return o; |
| 126 | } | 128 | } |
| 127 | 129 | ||
| @@ -193,13 +195,13 @@ test "decodeEscapes covers the sequences send needs" { | |||
| 193 | 195 | ||
| 194 | test "parseArgs verbs and flags" { | 196 | test "parseArgs verbs and flags" { |
| 195 | const a1 = [_][:0]const u8{ "muxa", "status" }; | 197 | const a1 = [_][:0]const u8{ "muxa", "status" }; |
| 196 | try std.testing.expectEqual(@FieldType(Opts, "verb").status, parseArgs(&a1).?.verb); | 198 | try std.testing.expectEqual(Verb.status, (try parseArgs(&a1))._verb.?); |
| 197 | const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" }; | 199 | const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" }; |
| 198 | const o2 = parseArgs(&a2).?; | 200 | const o2 = try parseArgs(&a2); |
| 199 | try std.testing.expectEqual(@as(u32, 5000), o2.timeout_ms); | 201 | try std.testing.expectEqual(@as(u32, 5000), o2.timeout); |
| 200 | try std.testing.expectEqualStrings("make test", o2.arg.?); | 202 | try std.testing.expectEqualStrings("make test", o2._arg.?); |
| 201 | const a3 = [_][:0]const u8{ "muxa", "bogus" }; | 203 | const a3 = [_][:0]const u8{ "muxa", "bogus" }; |
| 202 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&a3)); | 204 | try std.testing.expectError(error.Usage, parseArgs(&a3)); |
| 203 | } | 205 | } |
| 204 | 206 | ||
| 205 | test "parseArgs: -- hands the rest to the verb, flags and all" { | 207 | test "parseArgs: -- hands the rest to the verb, flags and all" { |
| @@ -207,76 +209,101 @@ test "parseArgs: -- hands the rest to the verb, flags and all" { | |||
| 207 | // invocation is refused — the exact shape an agent sends when a key | 209 | // invocation is refused — the exact shape an agent sends when a key |
| 208 | // sequence starts with a dash. | 210 | // sequence starts with a dash. |
| 209 | const dashed = [_][:0]const u8{ "muxa", "send", "-n foo" }; | 211 | const dashed = [_][:0]const u8{ "muxa", "send", "-n foo" }; |
| 210 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dashed)); | 212 | try std.testing.expectError(error.Usage, parseArgs(&dashed)); |
| 211 | 213 | ||
| 212 | const a = [_][:0]const u8{ "muxa", "send", "--settle", "50", "--", "-n foo" }; | 214 | const a = [_][:0]const u8{ "muxa", "send", "--settle", "50", "--", "-n foo" }; |
| 213 | const o = parseArgs(&a).?; | 215 | const o = try parseArgs(&a); |
| 214 | try std.testing.expectEqual(@as(u32, 50), o.settle_ms); | 216 | try std.testing.expectEqual(@as(u32, 50), o.settle); |
| 215 | try std.testing.expectEqualStrings("-n foo", o.arg.?); | 217 | try std.testing.expectEqualStrings("-n foo", o._arg.?); |
| 216 | 218 | ||
| 217 | // Past the marker, a flag spelling is just text — and a second | 219 | // Past the marker, a flag spelling is just text — and a second |
| 218 | // positional is still one too many. | 220 | // positional is still one too many. |
| 219 | const flagish = [_][:0]const u8{ "muxa", "run", "--", "--timeout" }; | 221 | const flagish = [_][:0]const u8{ "muxa", "run", "--", "--timeout" }; |
| 220 | try std.testing.expectEqualStrings("--timeout", parseArgs(&flagish).?.arg.?); | 222 | try std.testing.expectEqualStrings("--timeout", (try parseArgs(&flagish))._arg.?); |
| 221 | const two = [_][:0]const u8{ "muxa", "run", "--", "a", "b" }; | 223 | const two = [_][:0]const u8{ "muxa", "run", "--", "a", "b" }; |
| 222 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two)); | 224 | try std.testing.expectError(error.Usage, parseArgs(&two)); |
| 225 | |||
| 226 | // The marker outranks the help scan too: an agent typing `--help` AT a | ||
| 227 | // session must reach the pty, not this binary's usage page. | ||
| 228 | const help_payload = [_][:0]const u8{ "muxa", "send", "--", "--help" }; | ||
| 229 | try std.testing.expectEqualStrings("--help", (try parseArgs(&help_payload))._arg.?); | ||
| 230 | } | ||
| 231 | |||
| 232 | test "muxa: --help and --version are answered wherever they can be typed" { | ||
| 233 | // The verb slot is the only slot a bare `muxa --help` has, and the flag | ||
| 234 | // position is where anyone types it after finding a verb first. | ||
| 235 | try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "muxa", "--help" })); | ||
| 236 | try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "muxa", "-h" })); | ||
| 237 | try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "muxa", "status", "--help" })); | ||
| 238 | try std.testing.expectError(error.Help, parseArgs(&[_][:0]const u8{ "muxa", "run", "--timeout", "--help" })); | ||
| 239 | try std.testing.expectError(error.Version, parseArgs(&[_][:0]const u8{ "muxa", "--version" })); | ||
| 240 | try std.testing.expectError(error.Version, parseArgs(&[_][:0]const u8{ "muxa", "status", "--version" })); | ||
| 241 | |||
| 242 | // Neither is a verb, so a word that is neither and is no verb either is | ||
| 243 | // still the usage error it always was. | ||
| 244 | try std.testing.expectError(error.Usage, parseArgs(&[_][:0]const u8{ "muxa", "--wat" })); | ||
| 245 | try std.testing.expectError(error.Usage, parseArgs(&[_][:0]const u8{"muxa"})); | ||
| 223 | } | 246 | } |
| 224 | 247 | ||
| 225 | test "muxa: --session rides every verb; a bad name is usage, not wire bytes" { | 248 | test "muxa: --session rides every verb; a bad name is usage, not wire bytes" { |
| 226 | const a = [_][:0]const u8{ "muxa", "status", "--session", "b" }; | 249 | const a = [_][:0]const u8{ "muxa", "status", "--session", "b" }; |
| 227 | const o = parseArgs(&a).?; | 250 | const o = try parseArgs(&a); |
| 228 | try std.testing.expectEqualStrings("b", o.session); | 251 | try std.testing.expectEqualStrings("b", o._session); |
| 229 | 252 | ||
| 230 | // No --session named: the wire's own default spelling, empty. | 253 | // No --session named: the wire's own default spelling, empty. |
| 231 | const bare = [_][:0]const u8{ "muxa", "status" }; | 254 | const bare = [_][:0]const u8{ "muxa", "status" }; |
| 232 | try std.testing.expectEqualStrings("", parseArgs(&bare).?.session); | 255 | try std.testing.expectEqualStrings("", (try parseArgs(&bare))._session); |
| 233 | 256 | ||
| 234 | // A name no tool could ever address is refused at parse (the usage | 257 | // A name no tool could ever address is refused at parse (the usage |
| 235 | // exit, 2) rather than reaching a daemon as a payload nothing can | 258 | // exit, 2) rather than reaching a daemon as a payload nothing can |
| 236 | // look up. | 259 | // look up. |
| 237 | const bad = [_][:0]const u8{ "muxa", "status", "--session", "has space" }; | 260 | const bad = [_][:0]const u8{ "muxa", "status", "--session", "has space" }; |
| 238 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&bad)); | 261 | try std.testing.expectError(error.Usage, parseArgs(&bad)); |
| 262 | |||
| 263 | // Empty is the wire's default, not a name anyone can mean by typing it. | ||
| 264 | const empty = [_][:0]const u8{ "muxa", "status", "--session", "" }; | ||
| 265 | try std.testing.expectError(error.Usage, parseArgs(&empty)); | ||
| 239 | 266 | ||
| 240 | // Dangling like every other value-taking flag. | 267 | // Dangling like every other value-taking flag. |
| 241 | const dangling = [_][:0]const u8{ "muxa", "status", "--session" }; | 268 | const dangling = [_][:0]const u8{ "muxa", "status", "--session" }; |
| 242 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling)); | 269 | try std.testing.expectError(error.Usage, parseArgs(&dangling)); |
| 243 | } | 270 | } |
| 244 | 271 | ||
| 245 | test "parseArgs: --quic and --key, and the pairs that make no sense" { | 272 | test "parseArgs: --quic and --key, and the pairs that make no sense" { |
| 246 | const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" }; | 273 | const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" }; |
| 247 | const oq = parseArgs(&q).?; | 274 | const oq = try parseArgs(&q); |
| 248 | try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?); | 275 | try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?); |
| 249 | // Not naming a key is not an error here: MUX_KEY_FILE and the XDG | 276 | // Not naming a key is not an error here: MUX_KEY_FILE and the XDG |
| 250 | // default are still to be tried, and parse may look at neither. | 277 | // default are still to be tried, and parse may look at neither. |
| 251 | try std.testing.expectEqual(@as(?[]const u8, null), oq.key); | 278 | try std.testing.expectEqual(@as(?[]const u8, null), oq.key); |
| 252 | 279 | ||
| 253 | const k = [_][:0]const u8{ "muxa", "run", "--quic", "box:4433", "--key", "/k", "make test" }; | 280 | const k = [_][:0]const u8{ "muxa", "run", "--quic", "box:4433", "--key", "/k", "make test" }; |
| 254 | const ok = parseArgs(&k).?; | 281 | const ok = try parseArgs(&k); |
| 255 | try std.testing.expectEqualStrings("box:4433", ok.quic.?); | 282 | try std.testing.expectEqualStrings("box:4433", ok.quic.?); |
| 256 | try std.testing.expectEqualStrings("/k", ok.key.?); | 283 | try std.testing.expectEqualStrings("/k", ok.key.?); |
| 257 | try std.testing.expectEqualStrings("make test", ok.arg.?); | 284 | try std.testing.expectEqualStrings("make test", ok._arg.?); |
| 258 | 285 | ||
| 259 | // A flag at the end of argv with no value is refused, like every other | 286 | // A flag at the end of argv with no value is refused, like every other |
| 260 | // value-taking flag this parser has. | 287 | // value-taking flag this parser has. |
| 261 | const dangling_q = [_][:0]const u8{ "muxa", "status", "--quic" }; | 288 | const dangling_q = [_][:0]const u8{ "muxa", "status", "--quic" }; |
| 262 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_q)); | 289 | try std.testing.expectError(error.Usage, parseArgs(&dangling_q)); |
| 263 | const dangling_k = [_][:0]const u8{ "muxa", "status", "--quic", "b:1", "--key" }; | 290 | const dangling_k = [_][:0]const u8{ "muxa", "status", "--quic", "b:1", "--key" }; |
| 264 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_k)); | 291 | try std.testing.expectError(error.Usage, parseArgs(&dangling_k)); |
| 265 | 292 | ||
| 266 | // Two transports named at once: which one an agent's frames went to | 293 | // Two transports named at once: which one an agent's frames went to |
| 267 | // would be this parser's private business, and it is not entitled to | 294 | // would be this parser's private business, and it is not entitled to |
| 268 | // one — the same refusal `mux` spells as `.conflict`. | 295 | // one — the same refusal `mux` spells as `.conflict`. |
| 269 | const both = [_][:0]const u8{ "muxa", "status", "--sock", "/tmp/s", "--quic", "b:1" }; | 296 | const both = [_][:0]const u8{ "muxa", "status", "--sock", "/tmp/s", "--quic", "b:1" }; |
| 270 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&both)); | 297 | try std.testing.expectError(error.Usage, parseArgs(&both)); |
| 271 | 298 | ||
| 272 | // A key with nothing to authenticate to, refused exactly where muxd | 299 | // A key with nothing to authenticate to, refused exactly where muxd |
| 273 | // refuses it. | 300 | // refuses it. |
| 274 | const lonely_key = [_][:0]const u8{ "muxa", "status", "--key", "/k" }; | 301 | const lonely_key = [_][:0]const u8{ "muxa", "status", "--key", "/k" }; |
| 275 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&lonely_key)); | 302 | try std.testing.expectError(error.Usage, parseArgs(&lonely_key)); |
| 276 | 303 | ||
| 277 | // Neither named is the ordinary local case and stays silent. | 304 | // Neither named is the ordinary local case and stays silent. |
| 278 | const neither = [_][:0]const u8{ "muxa", "status" }; | 305 | const neither = [_][:0]const u8{ "muxa", "status" }; |
| 279 | try std.testing.expectEqual(@as(?[]const u8, null), parseArgs(&neither).?.quic); | 306 | try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic); |
| 280 | } | 307 | } |
| 281 | 308 | ||
| 282 | /// A live QUIC connection plus everything a REDIAL of it needs. The dial | 309 | /// A live QUIC connection plus everything a REDIAL of it needs. The dial |
| @@ -1175,11 +1202,26 @@ pub fn main() !u8 { | |||
| 1175 | const alloc = arena_state.allocator(); | 1202 | const alloc = arena_state.allocator(); |
| 1176 | 1203 | ||
| 1177 | const args = try std.process.argsAlloc(alloc); | 1204 | const args = try std.process.argsAlloc(alloc); |
| 1178 | const o = parseArgs(args) orelse { | 1205 | const o = parseArgs(args) catch |e| switch (e) { |
| 1179 | // Usage is diagnostic, so it goes to stderr: stdout stays strictly | 1206 | // Usage is diagnostic, so it goes to stderr: stdout stays strictly |
| 1180 | // one JSON object per invocation, even on the argument-error path. | 1207 | // one JSON object per invocation, even on the argument-error path. |
| 1181 | proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {}; | 1208 | error.Usage => { |
| 1182 | return 2; | 1209 | proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {}; |
| 1210 | return 2; | ||
| 1211 | }, | ||
| 1212 | // A usage someone ASKED for is the answer, not a diagnostic, so it | ||
| 1213 | // goes to stdout — the one place muxa otherwise reserves for JSON, | ||
| 1214 | // and the only two verbs that are allowed to spend it. | ||
| 1215 | error.Help => { | ||
| 1216 | proto.writeAllFd(std.posix.STDOUT_FILENO, usage) catch {}; | ||
| 1217 | return 0; | ||
| 1218 | }, | ||
| 1219 | error.Version => { | ||
| 1220 | var vbuf: [64]u8 = undefined; | ||
| 1221 | const s = std.fmt.bufPrint(&vbuf, "muxa {s}\n", .{build_options.version}) catch unreachable; | ||
| 1222 | proto.writeAllFd(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 1223 | return 0; | ||
| 1224 | }, | ||
| 1183 | }; | 1225 | }; |
| 1184 | 1226 | ||
| 1185 | // Started BEFORE the connect, not after: over QUIC the handshake is | 1227 | // Started BEFORE the connect, not after: over QUIC the handshake is |
| @@ -1187,7 +1229,7 @@ pub fn main() !u8 { | |||
| 1187 | // began counting only once the connection was up would promise | 1229 | // began counting only once the connection was up would promise |
| 1188 | // something different on the two transports. Over a unix socket the | 1230 | // something different on the two transports. Over a unix socket the |
| 1189 | // connect is a syscall, so this moves the instant by microseconds. | 1231 | // connect is a syscall, so this moves the instant by microseconds. |
| 1190 | const deadline = deadlineFor(o.timeout_ms); | 1232 | const deadline = deadlineFor(o.timeout); |
| 1191 | 1233 | ||
| 1192 | if (o.quic) |host_port| { | 1234 | if (o.quic) |host_port| { |
| 1193 | var conn = switch (openQuicConn(alloc, o, host_port, deadline)) { | 1235 | var conn = switch (openQuicConn(alloc, o, host_port, deadline)) { |
| @@ -1238,14 +1280,14 @@ pub fn main() !u8 { | |||
| 1238 | /// The verbs, once. Both transports arrive here with a Conn and nothing | 1280 | /// The verbs, once. Both transports arrive here with a Conn and nothing |
| 1239 | /// else that distinguishes them, which is the property `--quic` is selling. | 1281 | /// else that distinguishes them, which is the property `--quic` is selling. |
| 1240 | fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { | 1282 | fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { |
| 1241 | return switch (o.verb) { | 1283 | return switch (o._verb.?) { |
| 1242 | .status => verbStatus(alloc, conn, o.session, deadline), | 1284 | .status => verbStatus(alloc, conn, o._session, deadline), |
| 1243 | .capture => verbCapture(alloc, conn, o.vt, o.session, deadline), | 1285 | .capture => verbCapture(alloc, conn, o.vt, o._session, deadline), |
| 1244 | .send => verbSend(alloc, conn, o.arg, o.session, deadline), | 1286 | .send => verbSend(alloc, conn, o._arg, o._session, deadline), |
| 1245 | // The one thing `run` needs that `await` does not, checked here so | 1287 | // The one thing `run` needs that `await` does not, checked here so |
| 1246 | // the shared pipeline below can read `cmdline == null` as "this is | 1288 | // the shared pipeline below can read `cmdline == null` as "this is |
| 1247 | // an await" rather than as "a run that was spelled wrong". | 1289 | // an await" rather than as "a run that was spelled wrong". |
| 1248 | .run => if (o.arg) |cmdline| | 1290 | .run => if (o._arg) |cmdline| |
| 1249 | awaitVerb(alloc, conn, o, deadline, cmdline) | 1291 | awaitVerb(alloc, conn, o, deadline, cmdline) |
| 1250 | else | 1292 | else |
| 1251 | fail("run: needs CMDLINE", ""), | 1293 | fail("run: needs CMDLINE", ""), |
| @@ -1533,9 +1575,9 @@ fn doAwait( | |||
| 1533 | var buf: [proto.await_req_max_len]u8 = undefined; | 1575 | var buf: [proto.await_req_max_len]u8 = undefined; |
| 1534 | const payload = proto.encodeAwaitReqNamed(&buf, .{ | 1576 | const payload = proto.encodeAwaitReqNamed(&buf, .{ |
| 1535 | .since_seq = since_seq, | 1577 | .since_seq = since_seq, |
| 1536 | .settle_ms = o.settle_ms, | 1578 | .settle_ms = o.settle, |
| 1537 | .timeout_ms = o.timeout_ms, | 1579 | .timeout_ms = o.timeout, |
| 1538 | }, o.session); | 1580 | }, o._session); |
| 1539 | try conn.sendFrame(.await_req, payload, deadline); | 1581 | try conn.sendFrame(.await_req, payload, deadline); |
| 1540 | const frame = try conn.awaitFrame(.await_reply, deadline); | 1582 | const frame = try conn.awaitFrame(.await_reply, deadline); |
| 1541 | defer frame.deinit(alloc); | 1583 | defer frame.deinit(alloc); |
| @@ -1570,7 +1612,7 @@ fn awaitReissuing( | |||
| 1570 | // daemon that lost our connection lost the client slot with | 1612 | // daemon that lost our connection lost the client slot with |
| 1571 | // it, so an await_req arriving unattached asks about nothing. | 1613 | // it, so an await_req arriving unattached asks about nothing. |
| 1572 | // A failure here is still the reconnect failing. | 1614 | // A failure here is still the reconnect failing. |
| 1573 | attachZero(conn, o.session, deadline) catch |reattach| { | 1615 | attachZero(conn, o._session, deadline) catch |reattach| { |
| 1574 | // A refused re-attach is the daemon's answer, not the tear | 1616 | // A refused re-attach is the daemon's answer, not the tear |
| 1575 | // that got us here: `ConnectionLost` would send an agent | 1617 | // that got us here: `ConnectionLost` would send an agent |
| 1576 | // to check the network for a session that is gone. | 1618 | // to check the network for a session that is gone. |
| @@ -1789,16 +1831,16 @@ fn awaitVerb( | |||
| 1789 | const who = if (cmdline == null) "await" else "run"; | 1831 | const who = if (cmdline == null) "await" else "run"; |
| 1790 | const started = std.time.milliTimestamp(); | 1832 | const started = std.time.milliTimestamp(); |
| 1791 | 1833 | ||
| 1792 | attachZero(conn, o.session, deadline) catch |e| | 1834 | attachZero(conn, o._session, deadline) catch |e| |
| 1793 | return failSend(e, o.session, .attach, who, "attach failed"); | 1835 | return failSend(e, o._session, .attach, who, "attach failed"); |
| 1794 | 1836 | ||
| 1795 | // BEFORE the input, not after: the watermark has to be the one this | 1837 | // BEFORE the input, not after: the watermark has to be the one this |
| 1796 | // command must beat. Read afterwards, a command fast enough to return | 1838 | // command must beat. Read afterwards, a command fast enough to return |
| 1797 | // between the two would have already moved the seq past a value we | 1839 | // between the two would have already moved the seq past a value we |
| 1798 | // never recorded, and the await would sit waiting for a return that | 1840 | // never recorded, and the await would sit waiting for a return that |
| 1799 | // had happened. | 1841 | // had happened. |
| 1800 | const since = currentSeq(alloc, conn, o.session, deadline) catch |e| switch (e) { | 1842 | const since = currentSeq(alloc, conn, o._session, deadline) catch |e| switch (e) { |
| 1801 | error.AttachRefused => return failAttachRefused(o.session, .attach), | 1843 | error.AttachRefused => return failAttachRefused(o._session, .attach), |
| 1802 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | 1844 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), |
| 1803 | else => return failAs(who, "status failed", @errorName(e)), | 1845 | else => return failAs(who, "status failed", @errorName(e)), |
| 1804 | }; | 1846 | }; |
| @@ -1813,11 +1855,11 @@ fn awaitVerb( | |||
| 1813 | return failAs(who, "cannot build the command line", @errorName(e)); | 1855 | return failAs(who, "cannot build the command line", @errorName(e)); |
| 1814 | defer alloc.free(line); | 1856 | defer alloc.free(line); |
| 1815 | conn.sendFrame(.input, line, deadline) catch |e| | 1857 | conn.sendFrame(.input, line, deadline) catch |e| |
| 1816 | return failSend(e, o.session, .attach, who, "input failed"); | 1858 | return failSend(e, o._session, .attach, who, "input failed"); |
| 1817 | } | 1859 | } |
| 1818 | 1860 | ||
| 1819 | const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { | 1861 | const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { |
| 1820 | error.AttachRefused => return failAttachRefused(o.session, .attach), | 1862 | error.AttachRefused => return failAttachRefused(o._session, .attach), |
| 1821 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | 1863 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), |
| 1822 | else => { | 1864 | else => { |
| 1823 | var detail: [128]u8 = undefined; | 1865 | var detail: [128]u8 = undefined; |
| @@ -1848,12 +1890,12 @@ fn elapsed(started: i64) i64 { | |||
| 1848 | return std.time.milliTimestamp() - started; | 1890 | return std.time.milliTimestamp() - started; |
| 1849 | } | 1891 | } |
| 1850 | 1892 | ||
| 1851 | /// This client's deadline: the daemon's own bound plus the grace | 1893 | /// This client's deadline: the daemon's own bound plus the grace window |
| 1852 | /// window (await_grace_ms, widened per transport by `Conn.graceMs`). | 1894 | /// (await_grace_ms, widened per transport by `Conn.graceMs`). Unbounded |
| 1853 | /// An unbounded request stays unbounded. | 1895 | /// stays unbounded. |
| 1854 | fn awaitDeadline(o: Opts, conn: *const Conn) i64 { | 1896 | fn awaitDeadline(o: Opts, conn: *const Conn) i64 { |
| 1855 | if (o.timeout_ms == 0) return std.math.maxInt(i64); | 1897 | if (o.timeout == 0) return std.math.maxInt(i64); |
| 1856 | return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs(); | 1898 | return std.time.milliTimestamp() + o.timeout + conn.graceMs(); |
| 1857 | } | 1899 | } |
| 1858 | 1900 | ||
| 1859 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 1901 | // Forces semantic analysis of every pub decl under `zig build test`, so an |