a73x

c518814b

feat: cliflags lets a field type parse itself; idle-ms and session names do

a73x   2026-08-27 07:49

Commit message
feat: cliflags lets a field type parse itself; idle-ms and session names do

A field whose type declares `parseCLI` is parsed by that function, so the
rule for a value travels with the flag instead of being copied into a
post-check every caller has to remember. `bad_number` becomes `bad_value`:
an integer that will not parse and a type that refuses a word are the same
thing to a caller — the value was refused.

`quic.IdleMs` refuses zero (ngtcp2 reads it as no timeout at all) and
`protocol.SessionName` refuses a name no tool could address; between them
that deletes four `== 0` post-checks, three `validSessionName` post-checks,
and muxd's `bad_session_name` arm. IdleMs sits in `quic` beside the number
it defaults to and rides the existing quic -> quic_client -> client
re-export chain, because muxd and muxa are at or below `client`'s layer and
cannot import it.

muxd's refusal now names the flag rather than the value, which is the half
that says WHICH word on the line was refused; no e2e leg pinned the old
wording (`mux wall add`'s "bad session name" comes from wall.zig).

src/cli/flags.zig
Old New
@@ -3,7 +3,8 @@
3 //! a flag and there is no second list to keep in step. The grammar is 3 //! a flag and there is no second list to keep in step. The grammar is
4 //! `--flag VALUE`, space-separated, with a bare `--` ending the flags and 4 //! `--flag VALUE`, space-separated, with a bare `--` ending the flags and
5 //! everything after it payload. What a flag MEANS stays with the caller, in 5 //! everything after it payload. What a flag MEANS stays with the caller, in
6 //! post-checks over the parsed struct. 6 //! post-checks over the parsed struct, or in a field TYPE that declares
7 //! `parseCLI` — a rule one type owns is a rule no new caller can forget.
7 const std = @import("std"); 8 const std = @import("std");
8 9
9 pub const Outcome = union(enum) { 10 pub const Outcome = union(enum) {
@@ -17,8 +18,8 @@ pub const Outcome = union(enum) {
17 unknown_arg: []const u8, 18 unknown_arg: []const u8,
18 /// A value-taking flag at the end of argv, with nothing left to consume. 19 /// A value-taking flag at the end of argv, with nothing left to consume.
19 missing_value: []const u8, 20 missing_value: []const u8,
20 /// An integer field whose value did not parse. 21 /// The field's type would not hold the word after the flag.
21 bad_number: []const u8, 22 bad_value: []const u8,
22 }; 23 };
23 24
24 /// An optional field is its child type: null is a default, not an arity. 25 /// An optional field is its child type: null is a default, not an arity.
@@ -39,7 +40,10 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
39 comptime for (fields) |f| { 40 comptime for (fields) |f| {
40 if (f.name[0] == '_') continue; 41 if (f.name[0] == '_') continue;
41 const B = Bare(f.type); 42 const B = Bare(f.type);
42 if (B != bool and B != []const u8 and @typeInfo(B) != .int) 43 // A struct is an arity only through `parseCLI`: it takes one word
44 // like a string, and answers `error.Invalid` for what it cannot hold.
45 const owns = @typeInfo(B) == .@"struct" and @hasDecl(B, "parseCLI");
46 if (B != bool and B != []const u8 and @typeInfo(B) != .int and !owns)
43 @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type)); 47 @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type));
44 }; 48 };
45 comptime { 49 comptime {
@@ -83,7 +87,8 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
83 if (i + 1 >= args.len) return .{ .missing_value = a }; 87 if (i + 1 >= args.len) return .{ .missing_value = a };
84 i += 1; 88 i += 1;
85 @field(dst, f.name) = switch (@typeInfo(B)) { 89 @field(dst, f.name) = switch (@typeInfo(B)) {
86 .int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_number = a }, 90 .int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_value = a },
91 .@"struct" => B.parseCLI(args[i]) catch return .{ .bad_value = a },
87 else => args[i], 92 else => args[i],
88 }; 93 };
89 } 94 }
@@ -267,17 +272,17 @@ test "parse: a flag given twice, the last wins" {
267 try std.testing.expectEqual(@as(u16, 42), o.cols); 272 try std.testing.expectEqual(@as(u16, 42), o.cols);
268 } 273 }
269 274
270 test "parse: a non-number for an integer field is bad_number naming the flag" { 275 test "parse: a value the field's type refuses is bad_value naming the flag" {
271 var o: Demo = .{}; 276 var o: Demo = .{};
272 const r = parse(Demo, &o, &.{ "--cols", "wide" }); 277 const r = parse(Demo, &o, &.{ "--cols", "wide" });
273 try std.testing.expect(r == .bad_number); 278 try std.testing.expect(r == .bad_value);
274 try std.testing.expectEqualStrings("--cols", r.bad_number); 279 try std.testing.expectEqualStrings("--cols", r.bad_value);
275 280
276 // Out of the field's range, and negative into an unsigned, are the same 281 // Out of the field's range, and negative into an unsigned, are the same
277 // mistake: the flag cannot hold what was typed. 282 // mistake: the flag cannot hold what was typed.
278 var p: Demo = .{}; 283 var p: Demo = .{};
279 try std.testing.expect(parse(Demo, &p, &.{ "--cols", "99999" }) == .bad_number); 284 try std.testing.expect(parse(Demo, &p, &.{ "--cols", "99999" }) == .bad_value);
280 try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_number); 285 try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value);
281 } 286 }
282 287
283 test "flagName: underscores become dashes" { 288 test "flagName: underscores become dashes" {
@@ -449,3 +454,45 @@ test "assertDocumented: an alias documents its field" {
449 ; 454 ;
450 comptime assertDocumented(Positional, text, &.{}); 455 comptime assertDocumented(Positional, text, &.{});
451 } 456 }
457
458 test "parse: a field type with parseCLI owns its value, and its refusal is bad_value" {
459 // Stands in for `quic.IdleMs`: a type that owns the rule for its own
460 // values, so the rule travels with the field instead of being copied
461 // into a post-check every caller can forget.
462 const Port = struct {
463 n: u16 = 8080,
464
465 pub fn parseCLI(s: []const u8) error{Invalid}!@This() {
466 const n = std.fmt.parseInt(u16, s, 10) catch return error.Invalid;
467 if (n == 0) return error.Invalid;
468 return .{ .n = n };
469 }
470 };
471 const Typed = struct { port: Port = .{}, alt: ?Port = null };
472
473 var o: Typed = .{};
474 try std.testing.expect(parse(Typed, &o, &.{ "--port", "9000", "--alt", "81" }) == .ok);
475 try std.testing.expectEqual(@as(u16, 9000), o.port.n);
476 try std.testing.expectEqual(@as(u16, 81), o.alt.?.n);
477
478 // Untouched, each field keeps the default its declaration gave it — the
479 // TYPE's for the plain one, null for the optional.
480 var d: Typed = .{};
481 try std.testing.expect(parse(Typed, &d, &.{}) == .ok);
482 try std.testing.expectEqual(@as(u16, 8080), d.port.n);
483 try std.testing.expect(d.alt == null);
484
485 // 0 fits a u16 and the TYPE still refuses it: the rule being enforced is
486 // parseCLI's, not the integer parse's. The outcome names the FLAG, which
487 // is the half that says which word on the line was refused.
488 const r = parse(Typed, &d, &.{ "--port", "0" });
489 try std.testing.expect(r == .bad_value);
490 try std.testing.expectEqualStrings("--port", r.bad_value);
491 try std.testing.expect(parse(Typed, &d, &.{ "--port", "wat" }) == .bad_value);
492 // Left at its default by a refused word, never half-set.
493 try std.testing.expectEqual(@as(u16, 8080), d.port.n);
494
495 // It takes one value the way a string field does, so at the end of argv
496 // it is missing_value and not a bare flag quietly set.
497 try std.testing.expect(parse(Typed, &d, &.{"--port"}) == .missing_value);
498 }
src/cli/main.zig
Old New
@@ -47,10 +47,6 @@ fn envKey() ?[]const u8 {
47 return if (v.len == 0) null else v; 47 return if (v.len == 0) null else v;
48 } 48 }
49 49
50 /// The daemon's name for the shared default; see `quic.default_idle_ms`
51 /// for what the number means and why it lives there.
52 const default_quic_idle_ms: u32 = quic.default_idle_ms;
53
54 const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; 50 const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade };
55 51
56 /// One row per verb. Adding a subcommand used to mean editing the usage 52 /// One row per verb. Adding a subcommand used to mean editing the usage
@@ -152,13 +148,12 @@ const Opts = struct {
152 /// `--key` without `--quic` is settled here, because that one has no 148 /// `--key` without `--quic` is settled here, because that one has no
153 /// reading that makes it sensible. 149 /// reading that makes it sensible.
154 key: ?[]const u8 = null, 150 key: ?[]const u8 = null,
155 /// u32 rather than u64 so that an absurd value is a parse failure rather 151 /// The type refuses zero and anything wider than u32 — see `quic.IdleMs`.
156 /// than an overflow where it is multiplied out to nanoseconds. 152 quic_idle_ms: quic.IdleMs = .{},
157 quic_idle_ms: u32 = default_quic_idle_ms,
158 /// The session `dump` names. Optional so that only a name that was TYPED 153 /// The session `dump` names. Optional so that only a name that was TYPED
159 /// is validated: `""` is the wire's own default spelling — no tail at 154 /// is offered to the type: `""` is the wire's own default spelling — no
160 /// all — and would pass a check written against the empty string. 155 /// tail at all — and would fail a rule written for a name a user typed.
161 session: ?[]const u8 = null, 156 session: ?proto.SessionName = null,
162 /// The inherited manifest descriptor an upgrade exec'd us with. Not a 157 /// The inherited manifest descriptor an upgrade exec'd us with. Not a
163 /// user flag: the old daemon writes it into our argv. Its presence is 158 /// user flag: the old daemon writes it into our argv. Its presence is
164 /// what makes `run` an ADOPTION rather than a start, and it is also 159 /// what makes `run` an ADOPTION rather than a start, and it is also
@@ -196,12 +191,9 @@ const Usage = union(enum) {
196 unknown_arg: []const u8, 191 unknown_arg: []const u8,
197 /// A flag at the end of argv with nothing left to consume. 192 /// A flag at the end of argv with nothing left to consume.
198 missing_value: []const u8, 193 missing_value: []const u8,
199 /// The flag whose value would not parse as the number it wants. 194 /// The flag whose value its own type would not hold.
200 bad_number: []const u8, 195 bad_value: []const u8,
201 key_without_quic, 196 key_without_quic,
202 /// The name itself, not the flag: `muxd: bad session name: {s}` names
203 /// what was typed, which is the actionable half.
204 bad_session_name: []const u8,
205 }; 197 };
206 198
207 const ParseResult = union(enum) { ok: Opts, err: Usage }; 199 const ParseResult = union(enum) { ok: Opts, err: Usage };
@@ -228,20 +220,9 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
228 .version => return .{ .ok = .{ ._cmd = .version } }, 220 .version => return .{ .ok = .{ ._cmd = .version } },
229 .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, 221 .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } },
230 .missing_value => |f| return .{ .err = .{ .missing_value = f } }, 222 .missing_value => |f| return .{ .err = .{ .missing_value = f } },
231 .bad_number => |f| return .{ .err = .{ .bad_number = f } }, 223 .bad_value => |f| return .{ .err = .{ .bad_value = f } },
232 }
233
234 // Refused here rather than carried to the wire as a payload nothing could
235 // ever look up — the same "check before it becomes a frame" the socket
236 // path length guard follows.
237 if (o.session) |name| {
238 if (!proto.validSessionName(name)) return .{ .err = .{ .bad_session_name = name } };
239 } 224 }
240 225
241 // Zero is refused because ngtcp2 reads it as "no idle timeout", the
242 // opposite of what the flag says.
243 if (o.quic_idle_ms == 0) return .{ .err = .{ .bad_number = "--quic-idle-ms" } };
244
245 // A key with nowhere to listen is a mistake parse can see the whole of. 226 // A key with nowhere to listen is a mistake parse can see the whole of.
246 // The mirror case is NOT one: `--quic` with no `--key` may still be 227 // The mirror case is NOT one: `--quic` with no `--key` may still be
247 // answered by MUX_KEY_FILE or the default key path, neither of which 228 // answered by MUX_KEY_FILE or the default key path, neither of which
@@ -263,12 +244,11 @@ fn usageExit(u: Usage) u8 {
263 .unknown_command => std.debug.print("{s}", .{usage}), 244 .unknown_command => std.debug.print("{s}", .{usage}),
264 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), 245 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }),
265 .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }), 246 .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }),
266 .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }), 247 .bad_value => |f| std.debug.print("muxd: {s} was given a value it cannot hold\n{s}", .{ f, usage }),
267 .key_without_quic => std.debug.print( 248 .key_without_quic => std.debug.print(
268 "muxd: --key without --quic has nothing to listen on; name both or neither\n", 249 "muxd: --key without --quic has nothing to listen on; name both or neither\n",
269 .{}, 250 .{},
270 ), 251 ),
271 .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }),
272 } 252 }
273 return usageCode(u); 253 return usageCode(u);
274 } 254 }
@@ -375,7 +355,7 @@ pub fn main() !u8 {
375 .keygen => return keygen(alloc), 355 .keygen => return keygen(alloc),
376 .start => return startCmd(alloc, sock_path, args[2..]), 356 .start => return startCmd(alloc, sock_path, args[2..]),
377 .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path), 357 .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path),
378 .dump => return dump(alloc, sock_path, o.vt, o.session orelse ""), 358 .dump => return dump(alloc, sock_path, o.vt, if (o.session) |n| n.name else ""),
379 .stats => return stats(alloc, sock_path), 359 .stats => return stats(alloc, sock_path),
380 .stop => return stopCmd(alloc, sock_path), 360 .stop => return stopCmd(alloc, sock_path),
381 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), 361 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version),
@@ -620,7 +600,7 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
620 // syscall further along. 600 // syscall further along.
621 var listener: ?*quic_server.Listener = null; 601 var listener: ?*quic_server.Listener = null;
622 if (quic_bind) |addr| { 602 if (quic_bind) |addr| {
623 listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms) catch |err| switch (err) { 603 listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms.ms) catch |err| switch (err) {
624 // The QUIC edition of "a daemon is already running", refused for 604 // The QUIC edition of "a daemon is already running", refused for
625 // the same reason: the listener sets no SO_REUSEADDR, so rather 605 // the same reason: the listener sets no SO_REUSEADDR, so rather
626 // than silently splitting a port's datagrams with the daemon 606 // than silently splitting a port's datagrams with the daemon
@@ -1299,13 +1279,15 @@ test "parseArgs: subcommands and their existing flags" {
1299 test "parse: dump --session rides into the payload" { 1279 test "parse: dump --session rides into the payload" {
1300 const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); 1280 const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
1301 try std.testing.expect(d == .ok); 1281 try std.testing.expect(d == .ok);
1302 try std.testing.expectEqualStrings("b", d.ok.session.?); 1282 try std.testing.expectEqualStrings("b", d.ok.session.?.name);
1303 1283
1304 // A name no tool could ever address is refused at parse — usage on 1284 // A name no tool could ever address is refused at parse — usage on
1305 // stderr, never carried to the wire as a payload nothing can look up. 1285 // stderr, never carried to the wire as a payload nothing can look up.
1286 // The flag is what the refusal names now, so a user is told which of
1287 // several values on the line was the one refused.
1306 const bad = parse(&.{ "muxd", "dump", "--session", "has space" }); 1288 const bad = parse(&.{ "muxd", "dump", "--session", "has space" });
1307 try std.testing.expect(bad.err == .bad_session_name); 1289 try std.testing.expect(bad.err == .bad_value);
1308 try std.testing.expectEqualStrings("has space", bad.err.bad_session_name); 1290 try std.testing.expectEqualStrings("--session", bad.err.bad_value);
1309 } 1291 }
1310 1292
1311 test "parseArgs: --key without --quic is refused; --quic alone defers to main" { 1293 test "parseArgs: --key without --quic is refused; --quic alone defers to main" {
@@ -1332,30 +1314,30 @@ test "parseArgs: --key without --quic is refused; --quic alone defers to main" {
1332 1314
1333 test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" { 1315 test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" {
1334 const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" }); 1316 const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" });
1335 // Spelled out rather than written `default_quic_idle_ms`: asserting 1317 // Spelled out rather than written `quic.default_idle_ms`: asserting
1336 // against the same constant the parser reads would hold for any value, 1318 // against the same constant the parser reads would hold for any value,
1337 // so it could never catch the number changing. 1319 // so it could never catch the number changing.
1338 try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms); 1320 try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms.ms);
1339 1321
1340 const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" }); 1322 const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" });
1341 try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms); 1323 try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms.ms);
1342 1324
1343 // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone 1325 // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone
1344 // typing a timeout of zero is asking for, so it is refused rather than 1326 // typing a timeout of zero is asking for, so it is refused rather than
1345 // silently inverted. 1327 // silently inverted.
1346 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_number); 1328 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_value);
1347 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_number); 1329 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_value);
1348 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_number); 1330 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_value);
1349 // Wider than u32: refused at the parse rather than overflowing where it 1331 // Wider than u32: refused at the parse rather than overflowing where it
1350 // is multiplied out to nanoseconds. 1332 // is multiplied out to nanoseconds.
1351 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_number); 1333 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_value);
1352 // The idle flag alone does not turn QUIC on, and must not smuggle the 1334 // The idle flag alone does not turn QUIC on, and must not smuggle the
1353 // both-or-neither rule past the check. 1335 // both-or-neither rule past the check.
1354 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok); 1336 try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok);
1355 1337
1356 // Same treatment for the numbers that were already here. 1338 // Same treatment for the numbers that were already here.
1357 try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_number); 1339 try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_value);
1358 try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_number); 1340 try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_value);
1359 } 1341 }
1360 1342
1361 test "parseArgs: a value-taking flag at the end of argv names itself" { 1343 test "parseArgs: a value-taking flag at the end of argv names itself" {
@@ -1556,7 +1538,7 @@ test "parseArgs: run --resume-fd N --check is the old daemon's dry run" {
1556 1538
1557 // A number, like --cols: an fd that is not one would be read as a 1539 // A number, like --cols: an fd that is not one would be read as a
1558 // descriptor the daemon never passed. 1540 // descriptor the daemon never passed.
1559 try std.testing.expect(parse(&.{ "muxd", "run", "--resume-fd", "x" }).err == .bad_number); 1541 try std.testing.expect(parse(&.{ "muxd", "run", "--resume-fd", "x" }).err == .bad_value);
1560 1542
1561 const f = parse(&.{ "muxd", "run", "--resume-fd", "3", "--resume-fail-at", "session" }); 1543 const f = parse(&.{ "muxd", "run", "--resume-fd", "3", "--resume-fail-at", "session" });
1562 try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?); 1544 try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?);
src/cli/mux_main.zig
Old New
@@ -183,10 +183,11 @@ const Opts = struct {
183 sock: ?[]const u8 = null, 183 sock: ?[]const u8 = null,
184 via: ?[]const u8 = null, 184 via: ?[]const u8 = null,
185 key: ?[]const u8 = null, 185 key: ?[]const u8 = null,
186 /// Optional so that only a name that was TYPED is validated: `""` is the 186 /// Optional so that only a name that was TYPED reaches the type: `""` is
187 /// wire's own default spelling and would fail a check written for a name. 187 /// the wire's own default spelling and would fail a rule written for a
188 session: ?[]const u8 = null, 188 /// name a user typed.
189 quic_idle_ms: u32 = client.quic_idle_ms_default, 189 session: ?proto.SessionName = null,
190 quic_idle_ms: client.IdleMs = .{},
190 agent: bool = false, 191 agent: bool = false,
191 /// The three below are not flags, and the leading underscore is what 192 /// The three below are not flags, and the leading underscore is what
192 /// says so: they are what `positional` saw. 193 /// says so: they are what `positional` saw.
@@ -224,22 +225,11 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult {
224 .ok => {}, 225 .ok => {},
225 .help => return .help, 226 .help => return .help,
226 .version => return .version, 227 .version => return .version,
227 .unknown_arg, .missing_value, .bad_number => return .usage_error, 228 .unknown_arg, .missing_value, .bad_value => return .usage_error,
228 } 229 }
229 230
230 if (o._conflict) return .conflict; 231 if (o._conflict) return .conflict;
231 232
232 // A name that cannot be spelled must not become wire bytes: caught here,
233 // at usage-error altitude, rather than downstream where it would look
234 // like a rejected attach.
235 if (o.session) |name| {
236 if (!proto.validSessionName(name)) return .usage_error;
237 }
238
239 // Zero means "no idle timeout" to ngtcp2, the inverse of what anyone
240 // typing a timeout of zero is asking for.
241 if (o.quic_idle_ms == 0) return .usage_error;
242
243 // Every pairing of the four is two transports for one session. 233 // Every pairing of the four is two transports for one session.
244 const named: u8 = @as(u8, @intFromBool(o.sock != null)) + 234 const named: u8 = @as(u8, @intFromBool(o.sock != null)) +
245 @intFromBool(o.via != null) + @intFromBool(o._host != null) + 235 @intFromBool(o.via != null) + @intFromBool(o._host != null) +
@@ -251,7 +241,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult {
251 // escape hatch — it applies whichever spelling wins. So does `agent`, 241 // escape hatch — it applies whichever spelling wins. So does `agent`,
252 // and for the same reason: an offer to answer for this client's agent is 242 // and for the same reason: an offer to answer for this client's agent is
253 // about the client, not the wire it reached the daemon over. 243 // about the client, not the wire it reached the daemon over.
254 const session = o.session orelse ""; 244 const session = if (o.session) |n| n.name else "";
255 245
256 if (o._quic) |host_port| { 246 if (o._quic) |host_port| {
257 // Neither spelling of the key being set is not a refusal: main has a 247 // Neither spelling of the key being set is not a refusal: main has a
@@ -259,7 +249,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult {
259 return .{ .quic = .{ 249 return .{ .quic = .{
260 .host_port = host_port, 250 .host_port = host_port,
261 .key = xdg.pickKey(o.key, env_key), 251 .key = xdg.pickKey(o.key, env_key),
262 .idle_ms = o.quic_idle_ms, 252 .idle_ms = o.quic_idle_ms.ms,
263 .session = session, 253 .session = session,
264 .agent = o.agent, 254 .agent = o.agent,
265 } }; 255 } };
@@ -269,7 +259,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult {
269 // listener was meant, here it is one env var away from being set for 259 // listener was meant, here it is one env var away from being set for
270 // every invocation in a shell, and refusing `mux --sock ...` because 260 // every invocation in a shell, and refusing `mux --sock ...` because
271 // MUX_KEY_FILE happens to be exported would be absurd. 261 // MUX_KEY_FILE happens to be exported would be absurd.
272 if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms, .session = session, .agent = o.agent } }; 262 if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } };
273 return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } }; 263 return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } };
274 } 264 }
275 265
@@ -466,7 +456,7 @@ pub fn main() !u8 {
466 /// documents it here. 456 /// documents it here.
467 const WallOpts = struct { 457 const WallOpts = struct {
468 key: ?[]const u8 = null, 458 key: ?[]const u8 = null,
469 quic_idle_ms: u32 = client.quic_idle_ms_default, 459 quic_idle_ms: client.IdleMs = .{},
470 _argv: wall.Argv, 460 _argv: wall.Argv,
471 461
472 pub fn positional(self: *WallOpts, w: []const u8) bool { 462 pub fn positional(self: *WallOpts, w: []const u8) bool {
@@ -515,7 +505,7 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
515 .ok => {}, 505 .ok => {},
516 .help => return cliflags.help(usage), 506 .help => return cliflags.help(usage),
517 .version => return cliflags.version("mux", build_options.version), 507 .version => return cliflags.version("mux", build_options.version),
518 .missing_value, .bad_number => { 508 .missing_value, .bad_value => {
519 std.debug.print("{s}", .{usage}); 509 std.debug.print("{s}", .{usage});
520 return 2; 510 return 2;
521 }, 511 },
@@ -524,14 +514,8 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
524 return 2; 514 return 2;
525 }, 515 },
526 } 516 }
527 // Zero means "no idle timeout" to ngtcp2, the inverse of what anyone
528 // typing a timeout of zero is asking for.
529 if (w_opts.quic_idle_ms == 0) {
530 std.debug.print("{s}", .{usage});
531 return 2;
532 }
533 const key = w_opts.key; 517 const key = w_opts.key;
534 const idle_ms = w_opts.quic_idle_ms; 518 const idle_ms = w_opts.quic_idle_ms.ms;
535 var spellings = w_opts._argv.tiles; 519 var spellings = w_opts._argv.tiles;
536 520
537 var from_file = false; 521 var from_file = false;
src/cli/muxa.zig
Old New
@@ -57,10 +57,10 @@ const Opts = struct {
57 // would turn every await into an unbounded wait. 57 // would turn every await into an unbounded wait.
58 timeout: u32 = 30_000, 58 timeout: u32 = 30_000,
59 vt: bool = false, 59 vt: bool = false,
60 /// Optional so that only a name that was TYPED is validated: `""` is 60 /// Optional so that only a name that was TYPED reaches the type: `""`
61 /// the wire's own default spelling and would fail a check written for 61 /// is the wire's own default spelling and would fail a rule written
62 /// a name. `sessionName` is what the frames actually carry. 62 /// for a name. `sessionName` is what the frames actually carry.
63 session: ?[]const u8 = null, 63 session: ?proto.SessionName = null,
64 /// Null until `parseArgs` reads argv[1]; a parse that returned an Opts 64 /// Null until `parseArgs` reads argv[1]; a parse that returned an Opts
65 /// has one. 65 /// has one.
66 _verb: ?Verb = null, 66 _verb: ?Verb = null,
@@ -71,7 +71,7 @@ const Opts = struct {
71 // attached-tail equality rule (server.zig) never sees a mismatch 71 // attached-tail equality rule (server.zig) never sees a mismatch
72 // out of this binary. Empty is the wire's own default spelling, so 72 // out of this binary. Empty is the wire's own default spelling, so
73 // a bare `muxa status` builds the frames it always did. 73 // a bare `muxa status` builds the frames it always did.
74 return o.session orelse ""; 74 return if (o.session) |n| n.name else "";
75 } 75 }
76 76
77 /// The verb's argument. A second is a mistake: no verb here takes two. 77 /// The verb's argument. A second is a mistake: no verb here takes two.
@@ -106,14 +106,9 @@ fn parseArgs(args: []const [:0]const u8) ParseError!Opts {
106 .ok => {}, 106 .ok => {},
107 .help => return error.Help, 107 .help => return error.Help,
108 .version => return error.Version, 108 .version => return error.Version,
109 .unknown_arg, .missing_value, .bad_number => return error.Usage, 109 .unknown_arg, .missing_value, .bad_value => return error.Usage,
110 } 110 }
111 111
112 // Refused here rather than carried to the wire as a payload nothing
113 // could ever look up: usage exit (2), not a frame.
114 if (o.session) |name| {
115 if (!proto.validSessionName(name)) return error.Usage;
116 }
117 // Name ONE transport. A `--sock` silently ignored beside a `--quic` 112 // Name ONE transport. A `--sock` silently ignored beside a `--quic`
118 // would send an agent's frames somewhere other than the socket it 113 // would send an agent's frames somewhere other than the socket it
119 // named, and the two answers differ — this is the mistake `mux` 114 // named, and the two answers differ — this is the mistake `mux`
src/cli/webhub_main.zig
Old New
@@ -49,7 +49,7 @@ const usage =
49 const Parsed = struct { 49 const Parsed = struct {
50 port: u16 = webhub.default_port, 50 port: u16 = webhub.default_port,
51 key: ?[]const u8 = null, 51 key: ?[]const u8 = null,
52 quic_idle_ms: u32 = client.quic_idle_ms_default, 52 quic_idle_ms: client.IdleMs = .{},
53 _argv: wall.Argv, 53 _argv: wall.Argv,
54 54
55 pub fn positional(self: *Parsed, w: []const u8) bool { 55 pub fn positional(self: *Parsed, w: []const u8) bool {
@@ -101,7 +101,7 @@ fn parseArgs(
101 } 101 }
102 switch (outcome) { 102 switch (outcome) {
103 .ok => {}, 103 .ok => {},
104 .unknown_arg, .missing_value, .bad_number => return error.Usage, 104 .unknown_arg, .missing_value, .bad_value => return error.Usage,
105 // The two non-error early returns, so the two that still free for 105 // The two non-error early returns, so the two that still free for
106 // themselves: errdefer does not run on the way out with a result 106 // themselves: errdefer does not run on the way out with a result
107 // in hand. 107 // in hand.
@@ -115,7 +115,6 @@ fn parseArgs(
115 // like `--quic-idle-ms 0` and for the same reason: the number inverts 115 // like `--quic-idle-ms 0` and for the same reason: the number inverts
116 // what typing it means. 116 // what typing it means.
117 if (p.port == 0) return error.Usage; 117 if (p.port == 0) return error.Usage;
118 if (p.quic_idle_ms == 0) return error.Usage;
119 118
120 // No targets is not a usage error any more: it asks for the wall the 119 // No targets is not a usage error any more: it asks for the wall the
121 // last run persisted. main decides what an empty argv means; the parse 120 // last run persisted. main decides what an empty argv means; the parse
@@ -178,7 +177,7 @@ pub fn main() !u8 {
178 // so the two binaries cannot drift on what a bare HOST or a `quic://` 177 // so the two binaries cannot drift on what a bare HOST or a `quic://`
179 // means, and a tile POSTed by the page means what one typed on the 178 // means, and a tile POSTed by the page means what one typed on the
180 // command line. 179 // command line.
181 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.quic_idle_ms) catch |err| switch (err) { 180 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) {
182 error.MissingKey => { 181 error.MissingKey => {
183 std.debug.print( 182 std.debug.print(
184 "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", 183 "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n",
src/client.zig
Old New
@@ -199,6 +199,7 @@ pub fn lostMsg(target: Target, session_epoch: u64) []const u8 {
199 /// The client's name for the shared default; see `quic.default_idle_ms` 199 /// The client's name for the shared default; see `quic.default_idle_ms`
200 /// for what the number means and why it lives there. 200 /// for what the number means and why it lives there.
201 pub const quic_idle_ms_default: u32 = quic_client.default_idle_ms; 201 pub const quic_idle_ms_default: u32 = quic_client.default_idle_ms;
202 pub const IdleMs = quic_client.IdleMs;
202 203
203 /// What a QUIC attach was asked for: where to dial and what key to prove 204 /// What a QUIC attach was asked for: where to dial and what key to prove
204 /// ourselves with. 205 /// ourselves with.
src/protocol.zig
Old New
@@ -853,6 +853,17 @@ pub fn validSessionName(name: []const u8) bool {
853 return true; 853 return true;
854 } 854 }
855 855
856 /// A `--session` field of this type is refused at the parse, so no caller
857 /// carries an unspellable name as far as the wire.
858 pub const SessionName = struct {
859 name: []const u8,
860
861 pub fn parseCLI(s: []const u8) error{Invalid}!SessionName {
862 if (!validSessionName(s)) return error.Invalid;
863 return .{ .name = s };
864 }
865 };
866
856 pub fn encodeAttach(cols: u16, rows: u16, have_seq: u64, have_epoch: u64) [attach_len]u8 { 867 pub fn encodeAttach(cols: u16, rows: u16, have_seq: u64, have_epoch: u64) [attach_len]u8 {
857 var buf: [attach_len]u8 = undefined; 868 var buf: [attach_len]u8 = undefined;
858 std.mem.writeInt(u16, buf[0..2], cols, .little); 869 std.mem.writeInt(u16, buf[0..2], cols, .little);
src/quic.zig
Old New
@@ -50,6 +50,19 @@ pub const default_port: u16 = 4433;
50 /// binaries default to are two numbers, and they drift in silence. 50 /// binaries default to are two numbers, and they drift in silence.
51 pub const default_idle_ms: u32 = 15_000; 51 pub const default_idle_ms: u32 = 15_000;
52 52
53 /// Zero means NO idle timeout to ngtcp2, the inverse of what anyone typing
54 /// zero means, so `--quic-idle-ms`'s own type refuses it — once, rather
55 /// than in a post-check each binary copies.
56 pub const IdleMs = struct {
57 ms: u32 = default_idle_ms,
58
59 pub fn parseCLI(s: []const u8) error{Invalid}!IdleMs {
60 const ms = std.fmt.parseInt(u32, s, 10) catch return error.Invalid;
61 if (ms == 0) return error.Invalid;
62 return .{ .ms = ms };
63 }
64 };
65
53 pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting 66 pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting
54 67
55 pub const psk_identity: [*:0]const u8 = "mux"; 68 pub const psk_identity: [*:0]const u8 = "mux";
src/quic_client.zig
Old New
@@ -30,6 +30,7 @@ pub const Key = quic.Key;
30 pub const key_len = quic.key_len; 30 pub const key_len = quic.key_len;
31 pub const default_port = quic.default_port; 31 pub const default_port = quic.default_port;
32 pub const default_idle_ms = quic.default_idle_ms; 32 pub const default_idle_ms = quic.default_idle_ms;
33 pub const IdleMs = quic.IdleMs;
33 /// The refusal words, re-exported for the same reason `Key` is: client.zig 34 /// The refusal words, re-exported for the same reason `Key` is: client.zig
34 /// prints the daemon's sentences for a key the daemon would also refuse, 35 /// prints the daemon's sentences for a key the daemon would also refuse,
35 /// and it reaches the vocabulary module through this one. 36 /// and it reaches the vocabulary module through this one.