a73x

0ba8f95d

feat: muxd's flags are read off the Opts struct

a73x   2026-08-27 05:49

Commit message
feat: muxd's flags are read off the Opts struct

build.zig
Old New
@@ -151,6 +151,9 @@ const mod_table = [_]ModSpec{
151 // the one exception and does not weaken that — it hands its tests a 151 // the one exception and does not weaken that — it hands its tests a
152 // short directory to put a socket in and knows nothing about the bytes. 152 // short directory to put a socket in and knows nothing about the bytes.
153 .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} }, 153 .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} },
154 // Reflection over a caller's options struct, so it imports nothing: the
155 // struct is the flag table and the parser learns it at comptime.
156 .{ .name = "cliflags", .path = "src/cli/flags.zig", .layer = 0 },
154 // ---- layer 1: single-hop over the leaves ---- 157 // ---- layer 1: single-hop over the leaves ----
155 // Platform-neutral semantic decoding for terminal mode samples and 158 // Platform-neutral semantic decoding for terminal mode samples and
156 // side-channel events. The borrowed clipboard slice stays tied to the 159 // side-channel events. The borrowed clipboard slice stays tied to the
@@ -272,7 +275,7 @@ const mod_table = [_]ModSpec{
272 // the sun_path bound, checked before any verb acts on the path; and the 275 // the sun_path bound, checked before any verb acts on the path; and the
273 // keygen round-trip test needs a directory to generate into, which the 276 // keygen round-trip test needs a directory to generate into, which the
274 // daemon itself never touches. 277 // daemon itself never touches.
275 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath", "upgrade" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 278 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath", "upgrade", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
276 // ---- layer 4 ---- 279 // ---- layer 4 ----
277 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route 280 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
278 // table, WS endpoint naming. Assets are injected (the exe root 281 // table, WS endpoint naming. Assets are injected (the exe root
@@ -557,9 +560,11 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
557 // an argument, not a rule. 560 // an argument, not a rule.
558 var checked: std.ArrayList([]const u8) = .empty; 561 var checked: std.ArrayList([]const u8) = .empty;
559 zigFilesIn(b, "src", &checked); 562 zigFilesIn(b, "src", &checked);
563 zigFilesIn(b, "src/cli", &checked);
560 zigFilesIn(b, "tools", &checked); 564 zigFilesIn(b, "tools", &checked);
561 var indexed: std.ArrayList([]const u8) = .empty; 565 var indexed: std.ArrayList([]const u8) = .empty;
562 zigFilesIn(b, "src", &indexed); 566 zigFilesIn(b, "src", &indexed);
567 zigFilesIn(b, "src/cli", &indexed);
563 zigFilesIn(b, "test", &indexed); 568 zigFilesIn(b, "test", &indexed);
564 zigFilesIn(b, "tools", &indexed); 569 zigFilesIn(b, "tools", &indexed);
565 // build.zig is cited by name in src/main.zig's comments and is a real 570 // build.zig is cited by name in src/main.zig's comments and is a real
@@ -618,7 +623,7 @@ const test_order = [_][]const u8{
618 "webhub", "wallview", "sockpath", "muxa", "server", "client", "proxy", 623 "webhub", "wallview", "sockpath", "muxa", "server", "client", "proxy",
619 "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", "predict", 624 "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", "predict",
620 "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", 625 "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout",
621 "render", "ptyclient", "webhub_main", "wsclient", 626 "render", "ptyclient", "webhub_main", "wsclient", "cliflags",
622 }; 627 };
623 628
624 comptime { 629 comptime {
docscheck.budget
Old New
@@ -5,6 +5,7 @@ cmd.zig 0
5 delta.zig 0 5 delta.zig 0
6 docscheck.zig 0 6 docscheck.zig 0
7 engine.zig 0 7 engine.zig 0
8 flags.zig 0
8 handoff.zig 0 9 handoff.zig 0
9 interact.zig 0 10 interact.zig 0
10 keymap.zig 0 11 keymap.zig 0
src/cli/flags.zig
Old New
@@ -0,0 +1,213 @@
1 //! A flag parser that reads its table off a struct: the field's TYPE is the
2 //! flag's arity and the field's NAME is its spelling, so adding a field adds
3 //! a flag and there is no second list to keep in step. The grammar is
4 //! `--flag VALUE`, space-separated. What a flag MEANS stays with the caller,
5 //! in post-checks over the parsed struct.
6 const std = @import("std");
7
8 pub const Outcome = union(enum) {
9 ok,
10 /// `--help` or `-h`, wherever it sits: the caller prints its usage to
11 /// stdout and exits 0.
12 help,
13 unknown_arg: []const u8,
14 /// A value-taking flag at the end of argv, with nothing left to consume.
15 missing_value: []const u8,
16 /// An integer field whose value did not parse.
17 bad_number: []const u8,
18 };
19
20 /// An optional field is its child type: null is a default, not an arity.
21 fn Bare(comptime F: type) type {
22 return switch (@typeInfo(F)) {
23 .optional => |o| o.child,
24 else => F,
25 };
26 }
27
28 /// A flag given twice: the last wins. Nothing stops the scan — every word is
29 /// a flag or a mistake, because a positional here could only be a typo.
30 pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
31 const fields = @typeInfo(T).@"struct".fields;
32 comptime for (fields) |f| {
33 if (f.name[0] == '_') continue;
34 const B = Bare(f.type);
35 if (B != bool and B != []const u8 and @typeInfo(B) != .int)
36 @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type));
37 };
38
39 // Asked for before anything is read, so that a `--help` sitting where a
40 // value belongs still answers with the usage instead of being eaten.
41 for (args) |a| {
42 if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) return .help;
43 }
44
45 var i: usize = 0;
46 while (i < args.len) : (i += 1) {
47 const a = args[i];
48 var known = false;
49 inline for (fields) |f| {
50 if (f.name[0] != '_' and !known and std.mem.eql(u8, a, comptime flagName(f.name))) {
51 known = true;
52 const B = Bare(f.type);
53 if (B == bool) {
54 @field(dst, f.name) = true;
55 } else {
56 if (i + 1 >= args.len) return .{ .missing_value = a };
57 i += 1;
58 @field(dst, f.name) = switch (@typeInfo(B)) {
59 .int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_number = a },
60 else => args[i],
61 };
62 }
63 }
64 }
65 if (!known) return .{ .unknown_arg = a };
66 }
67 return .ok;
68 }
69
70 pub fn flagName(comptime field: []const u8) []const u8 {
71 comptime var name: []const u8 = "--";
72 inline for (field) |c| name = name ++ [_]u8{if (c == '_') '-' else c};
73 return name;
74 }
75
76 /// A flag is named only where the word ENDS: `--sock` must not be answered
77 /// by prose that says `--socket`.
78 pub fn documented(name: []const u8, usage: []const u8) bool {
79 if (name.len == 0 or usage.len < name.len) return false;
80 var at: usize = 0;
81 while (at + name.len <= usage.len) : (at += 1) {
82 if (!std.mem.eql(u8, usage[at..][0..name.len], name)) continue;
83 const end = at + name.len;
84 if (end == usage.len) return true;
85 switch (usage[end]) {
86 ' ', '\n', ']' => return true,
87 else => {},
88 }
89 }
90 return false;
91 }
92
93 /// Kills parser-to-prose drift at build time: a flag added to T and forgotten
94 /// in the text fails the compile. The mirror leg is NOT claimed — prose may
95 /// name a flag no field has, and only a reader will notice.
96 pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void {
97 @setEvalBranchQuota(50_000);
98 comptime for (@typeInfo(T).@"struct".fields) |f| {
99 if (f.name[0] == '_') continue;
100 var skip = false;
101 for (hidden) |h| {
102 if (std.mem.eql(u8, h, f.name)) skip = true;
103 }
104 if (skip) continue;
105 if (!documented(flagName(f.name), usage))
106 @compileError("cliflags: usage text never names " ++ flagName(f.name));
107 };
108 }
109
110 const Demo = struct {
111 _cmd: u8 = 0,
112 vt: bool = false,
113 sock: ?[]const u8 = null,
114 shell: []const u8 = "/bin/sh",
115 cols: u16 = 80,
116 quic_idle_ms: u32 = 15_000,
117 };
118
119 test "parse: a bool field is a bare flag, a string field takes the next word, an integer field parses it" {
120 var o: Demo = .{};
121 const args: []const [:0]const u8 = &.{ "--vt", "--sock", "/tmp/x.sock", "--shell", "/bin/dash", "--cols", "120" };
122 try std.testing.expect(parse(Demo, &o, args) == .ok);
123 try std.testing.expect(o.vt);
124 try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);
125 try std.testing.expectEqualStrings("/bin/dash", o.shell);
126 try std.testing.expectEqual(@as(u16, 120), o.cols);
127
128 // Untouched fields keep the struct's own defaults.
129 var d: Demo = .{};
130 try std.testing.expect(parse(Demo, &d, &.{}) == .ok);
131 try std.testing.expect(!d.vt);
132 try std.testing.expect(d.sock == null);
133 try std.testing.expectEqual(@as(u32, 15_000), d.quic_idle_ms);
134 }
135
136 test "parse: a value flag at the end of argv is missing_value, not unknown_arg" {
137 var o: Demo = .{};
138 const r = parse(Demo, &o, &.{"--sock"});
139 try std.testing.expect(r == .missing_value);
140 try std.testing.expectEqualStrings("--sock", r.missing_value);
141 }
142
143 test "parse: an unknown flag names itself" {
144 var o: Demo = .{};
145 const r = parse(Demo, &o, &.{ "--vt", "--wat" });
146 try std.testing.expect(r == .unknown_arg);
147 try std.testing.expectEqualStrings("--wat", r.unknown_arg);
148
149 // A bare word is a mistake too: there are no positional arguments here.
150 var p: Demo = .{};
151 try std.testing.expect(parse(Demo, &p, &.{"run"}) == .unknown_arg);
152 }
153
154 test "parse: --help and -h are the help outcome, before or after other flags" {
155 var o: Demo = .{};
156 try std.testing.expect(parse(Demo, &o, &.{"--help"}) == .help);
157 try std.testing.expect(parse(Demo, &o, &.{"-h"}) == .help);
158 try std.testing.expect(parse(Demo, &o, &.{ "--vt", "--help" }) == .help);
159 try std.testing.expect(parse(Demo, &o, &.{ "--help", "--wat" }) == .help);
160 // Even where a value would be read: help outranks the grammar.
161 try std.testing.expect(parse(Demo, &o, &.{ "--sock", "--help" }) == .help);
162 }
163
164 test "parse: a leading-underscore field is not a flag" {
165 var o: Demo = .{};
166 const r = parse(Demo, &o, &.{ "--cmd", "run" });
167 try std.testing.expect(r == .unknown_arg);
168 try std.testing.expectEqualStrings("--cmd", r.unknown_arg);
169 try std.testing.expectEqual(@as(u8, 0), o._cmd);
170 }
171
172 test "parse: a flag given twice, the last wins" {
173 var o: Demo = .{};
174 try std.testing.expect(parse(Demo, &o, &.{ "--cols", "100", "--cols", "42" }) == .ok);
175 try std.testing.expectEqual(@as(u16, 42), o.cols);
176 }
177
178 test "parse: a non-number for an integer field is bad_number naming the flag" {
179 var o: Demo = .{};
180 const r = parse(Demo, &o, &.{ "--cols", "wide" });
181 try std.testing.expect(r == .bad_number);
182 try std.testing.expectEqualStrings("--cols", r.bad_number);
183
184 // Out of the field's range, and negative into an unsigned, are the same
185 // mistake: the flag cannot hold what was typed.
186 var p: Demo = .{};
187 try std.testing.expect(parse(Demo, &p, &.{ "--cols", "99999" }) == .bad_number);
188 try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_number);
189 }
190
191 test "flagName: underscores become dashes" {
192 try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms"));
193 try std.testing.expectEqualStrings("--vt", flagName("vt"));
194 }
195
196 test "documented: a prefix of a longer flag does not count" {
197 try std.testing.expect(!documented("--sock", " muxd run [--socket PATH]\n"));
198 try std.testing.expect(documented("--sock", " muxd run [--sock PATH]\n"));
199 try std.testing.expect(documented("--vt", " muxd dump [--vt]\n"));
200 try std.testing.expect(documented("--vt", " muxd dump --vt\n"));
201 try std.testing.expect(documented("--vt", " muxd dump --vt"));
202 try std.testing.expect(!documented("--rows", "nothing here\n"));
203 }
204
205 test "assertDocumented: every visible flag is named in the prose" {
206 const text =
207 \\ demo [--vt] [--sock PATH] [--cols N] [--quic-idle-ms N]
208 \\
209 ;
210 // `shell` stands in for muxd's machine-written flags: hidden from the
211 // prose on purpose, so the assertion must not demand it.
212 comptime assertDocumented(Demo, text, &.{"shell"});
213 }
src/main.zig
Old New
@@ -13,6 +13,7 @@ const spawn = @import("spawn");
13 const handoff = @import("handoff"); 13 const handoff = @import("handoff");
14 const sockpath = @import("sockpath"); 14 const sockpath = @import("sockpath");
15 const upgrade = @import("upgrade"); 15 const upgrade = @import("upgrade");
16 const cliflags = @import("cliflags");
16 17
17 const usage = 18 const usage =
18 \\usage: 19 \\usage:
@@ -28,6 +29,7 @@ const usage =
28 \\ muxd upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live) 29 \\ muxd upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live)
29 \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's) 30 \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's)
30 \\ muxd --version 31 \\ muxd --version
32 \\ muxd --help
31 \\ 33 \\
32 ; 34 ;
33 35
@@ -49,7 +51,7 @@ fn envKey() ?[]const u8 {
49 /// for what the number means and why it lives there. 51 /// for what the number means and why it lives there.
50 const default_quic_idle_ms: u32 = quic.default_idle_ms; 52 const default_quic_idle_ms: u32 = quic.default_idle_ms;
51 53
52 const Cmd = enum { run, dump, stats, proxy, endpoint, version, keygen, start, stop, upgrade }; 54 const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade };
53 55
54 /// One row per verb. Adding a subcommand used to mean editing the usage 56 /// One row per verb. Adding a subcommand used to mean editing the usage
55 /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled 57 /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled
@@ -84,6 +86,9 @@ const specs = [_]Spec{
84 // types, but it is a command: it names what the process does instead of 86 // types, but it is a command: it names what the process does instead of
85 // configuring one, and answers from the binary alone. 87 // configuring one, and answers from the binary alone.
86 .{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored }, 88 .{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored },
89 // Spelled as a flag for the same reason, and `.ignored` for a second:
90 // asking for the usage must never be refused over the words next to it.
91 .{ .name = "--help", .cmd = .help, .uses_socket = false, .flags = .ignored },
87 .{ .name = "run", .cmd = .run, .uses_socket = true, .flags = .all }, 92 .{ .name = "run", .cmd = .run, .uses_socket = true, .flags = .all },
88 .{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all }, 93 .{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all },
89 .{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all }, 94 .{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all },
@@ -133,7 +138,9 @@ fn specForCmd(cmd: Cmd) Spec {
133 /// can be tested without a process to exit from — the same reason 138 /// can be tested without a process to exit from — the same reason
134 /// mux_main.zig's parser is its own function. 139 /// mux_main.zig's parser is its own function.
135 const Opts = struct { 140 const Opts = struct {
136 cmd: Cmd, 141 /// Not a flag, and the leading underscore is what says so: cliflags.parse
142 /// skips it, the verb having been settled by the row above.
143 _cmd: Cmd,
137 sock: ?[]const u8 = null, 144 sock: ?[]const u8 = null,
138 shell: ?[]const u8 = null, 145 shell: ?[]const u8 = null,
139 cols: u16 = 80, 146 cols: u16 = 80,
@@ -145,11 +152,13 @@ const Opts = struct {
145 /// `--key` without `--quic` is settled here, because that one has no 152 /// `--key` without `--quic` is settled here, because that one has no
146 /// reading that makes it sensible. 153 /// reading that makes it sensible.
147 key: ?[]const u8 = null, 154 key: ?[]const u8 = null,
155 /// u32 rather than u64 so that an absurd value is a parse failure rather
156 /// than an overflow where it is multiplied out to nanoseconds.
148 quic_idle_ms: u32 = default_quic_idle_ms, 157 quic_idle_ms: u32 = default_quic_idle_ms,
149 /// The session `dump` names. Empty is the wire's own default spelling 158 /// The session `dump` names. Optional so that only a name that was TYPED
150 /// — no tail at all — so a caller that never passes `--session` builds 159 /// is validated: `""` is the wire's own default spelling — no tail at
151 /// byte-identical payloads to before this flag existed. 160 /// all — and would pass a check written against the empty string.
152 session: []const u8 = "", 161 session: ?[]const u8 = null,
153 /// The inherited manifest descriptor an upgrade exec'd us with. Not a 162 /// The inherited manifest descriptor an upgrade exec'd us with. Not a
154 /// user flag: the old daemon writes it into our argv. Its presence is 163 /// user flag: the old daemon writes it into our argv. Its presence is
155 /// what makes `run` an ADOPTION rather than a start, and it is also 164 /// what makes `run` an ADOPTION rather than a start, and it is also
@@ -170,10 +179,19 @@ const Opts = struct {
170 allow_same_version: bool = false, 179 allow_same_version: bool = false,
171 }; 180 };
172 181
173 /// A refusal, carrying whatever `main` needs to print one line about it. 182 // The three flags left out are written into argv by the OLD daemon on an
174 /// None of these is a daemon bug, so none of them gets a stack trace. 183 // upgrade and never typed by a hand, so the prose does not offer them.
184 comptime {
185 cliflags.assertDocumented(Opts, usage, &.{ "resume_fd", "check", "resume_fail_at" });
186 }
187
188 /// What `main` prints instead of running the command. Every case but `help`
189 /// is a refusal; none of them is a daemon bug, so none gets a stack trace.
175 const Usage = union(enum) { 190 const Usage = union(enum) {
176 no_command, 191 no_command,
192 /// `--help` on a subcommand. The answer the user asked for, so it is the
193 /// one case `usageExit` exits 0 on.
194 help,
177 unknown_command: []const u8, 195 unknown_command: []const u8,
178 unknown_arg: []const u8, 196 unknown_arg: []const u8,
179 /// A flag at the end of argv with nothing left to consume. 197 /// A flag at the end of argv with nothing left to consume.
@@ -195,79 +213,31 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
195 // The one place a verb's flag class is enforced; which class each verb 213 // The one place a verb's flag class is enforced; which class each verb
196 // is in is stated once, in its row. 214 // is in is stated once, in its row.
197 switch (spec.flags) { 215 switch (spec.flags) {
198 .ignored => return .{ .ok = .{ .cmd = spec.cmd } }, 216 .ignored => return .{ .ok = .{ ._cmd = spec.cmd } },
199 .none => if (args.len > 2) return .{ .err = .{ .unknown_arg = args[2] } }, 217 .none => if (args.len > 2) return .{ .err = .{ .unknown_arg = args[2] } },
200 .all => {}, 218 .all => {},
201 } 219 }
202 220
203 var o: Opts = .{ .cmd = spec.cmd }; 221 var o: Opts = .{ ._cmd = spec.cmd };
204 var i: usize = 2; 222 switch (cliflags.parse(Opts, &o, args[2..])) {
205 while (i < args.len) : (i += 1) { 223 .ok => {},
206 const a = args[i]; 224 .help => return .{ .err = .help },
207 if (std.mem.eql(u8, a, "--vt")) { 225 .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } },
208 o.vt = true; 226 .missing_value => |f| return .{ .err = .{ .missing_value = f } },
209 continue; 227 .bad_number => |f| return .{ .err = .{ .bad_number = f } },
210 } 228 }
211 if (std.mem.eql(u8, a, "--check")) { 229
212 o.check = true; 230 // Refused here rather than carried to the wire as a payload nothing could
213 continue; 231 // ever look up — the same "check before it becomes a frame" the socket
214 } 232 // path length guard follows.
215 if (std.mem.eql(u8, a, "--allow-same-version")) { 233 if (o.session) |name| {
216 o.allow_same_version = true; 234 if (!proto.validSessionName(name)) return .{ .err = .{ .bad_session_name = name } };
217 continue;
218 }
219 // Every remaining flag takes a value, so the missing-value case is
220 // answered once here rather than at each arm — a flag with nothing
221 // after it used to fall through to "unknown argument", which named
222 // the wrong mistake.
223 const takes_value = std.mem.eql(u8, a, "--sock") or
224 std.mem.eql(u8, a, "--shell") or
225 std.mem.eql(u8, a, "--cols") or
226 std.mem.eql(u8, a, "--rows") or
227 std.mem.eql(u8, a, "--quic") or
228 std.mem.eql(u8, a, "--key") or
229 std.mem.eql(u8, a, "--quic-idle-ms") or
230 std.mem.eql(u8, a, "--session") or
231 std.mem.eql(u8, a, "--resume-fd") or
232 std.mem.eql(u8, a, "--resume-fail-at");
233 if (!takes_value) return .{ .err = .{ .unknown_arg = a } };
234 if (i + 1 >= args.len) return .{ .err = .{ .missing_value = a } };
235 i += 1;
236 const v = args[i];
237 if (std.mem.eql(u8, a, "--sock")) {
238 o.sock = v;
239 } else if (std.mem.eql(u8, a, "--shell")) {
240 o.shell = v;
241 } else if (std.mem.eql(u8, a, "--quic")) {
242 o.quic = v;
243 } else if (std.mem.eql(u8, a, "--key")) {
244 o.key = v;
245 } else if (std.mem.eql(u8, a, "--session")) {
246 // Refused here rather than carried to the wire as a payload
247 // nothing could ever look up — the same "check before it
248 // becomes a frame" the socket-path length guard follows.
249 if (!proto.validSessionName(v)) return .{ .err = .{ .bad_session_name = v } };
250 o.session = v;
251 } else if (std.mem.eql(u8, a, "--resume-fail-at")) {
252 o.resume_fail_at = v;
253 } else if (std.mem.eql(u8, a, "--resume-fd")) {
254 o.resume_fd = std.fmt.parseInt(std.posix.fd_t, v, 10) catch
255 return .{ .err = .{ .bad_number = a } };
256 } else if (std.mem.eql(u8, a, "--cols")) {
257 o.cols = std.fmt.parseInt(u16, v, 10) catch return .{ .err = .{ .bad_number = a } };
258 } else if (std.mem.eql(u8, a, "--rows")) {
259 o.rows = std.fmt.parseInt(u16, v, 10) catch return .{ .err = .{ .bad_number = a } };
260 } else if (std.mem.eql(u8, a, "--quic-idle-ms")) {
261 // u32 rather than u64 so that an absurd value is a parse
262 // failure here instead of an overflow where it is multiplied
263 // out to nanoseconds. Zero is refused because ngtcp2 reads it
264 // as "no idle timeout", the opposite of what the flag says.
265 const n = std.fmt.parseInt(u32, v, 10) catch return .{ .err = .{ .bad_number = a } };
266 if (n == 0) return .{ .err = .{ .bad_number = a } };
267 o.quic_idle_ms = n;
268 }
269 } 235 }
270 236
237 // Zero is refused because ngtcp2 reads it as "no idle timeout", the
238 // opposite of what the flag says.
239 if (o.quic_idle_ms == 0) return .{ .err = .{ .bad_number = "--quic-idle-ms" } };
240
271 // A key with nowhere to listen is a mistake parse can see the whole of. 241 // A key with nowhere to listen is a mistake parse can see the whole of.
272 // The mirror case is NOT one: `--quic` with no `--key` may still be 242 // The mirror case is NOT one: `--quic` with no `--key` may still be
273 // answered by MUX_KEY_FILE or the default key path, neither of which 243 // answered by MUX_KEY_FILE or the default key path, neither of which
@@ -277,8 +247,18 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
277 return .{ .ok = o }; 247 return .{ .ok = o };
278 } 248 }
279 249
250 /// The code alone: a test can ask it without a process.
251 fn usageCode(u: Usage) u8 {
252 return if (u == .help) 0 else 2;
253 }
254
280 fn usageExit(u: Usage) u8 { 255 fn usageExit(u: Usage) u8 {
281 switch (u) { 256 switch (u) {
257 // stdout, unlike every refusal below: a usage someone asked for is
258 // output, and they may well have piped it into a pager.
259 .help => {
260 _ = std.posix.write(std.posix.STDOUT_FILENO, usage) catch {};
261 },
282 .no_command => std.debug.print("{s}", .{usage}), 262 .no_command => std.debug.print("{s}", .{usage}),
283 .unknown_command => std.debug.print("{s}", .{usage}), 263 .unknown_command => std.debug.print("{s}", .{usage}),
284 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), 264 .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }),
@@ -290,7 +270,7 @@ fn usageExit(u: Usage) u8 {
290 ), 270 ),
291 .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }), 271 .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }),
292 } 272 }
293 return 2; 273 return usageCode(u);
294 } 274 }
295 275
296 /// `HOST[:PORT]` where HOST is a literal address — `127.0.0.1:4433`, 276 /// `HOST[:PORT]` where HOST is a literal address — `127.0.0.1:4433`,
@@ -350,7 +330,7 @@ pub fn main() !u8 {
350 // is in the manifest, under a listener that is already bound to it. 330 // is in the manifest, under a listener that is already bound to it.
351 // Resolving the default here would refuse an upgrade on any daemon 331 // Resolving the default here would refuse an upgrade on any daemon
352 // started with `--sock` outside XDG_RUNTIME_DIR. 332 // started with `--sock` outside XDG_RUNTIME_DIR.
353 const uses_socket = specForCmd(o.cmd).uses_socket and o.resume_fd == null; 333 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null;
354 const sock_path = if (o.sock) |s| 334 const sock_path = if (o.sock) |s|
355 try alloc.dupe(u8, s) 335 try alloc.dupe(u8, s)
356 else if (!uses_socket) 336 else if (!uses_socket)
@@ -386,7 +366,7 @@ pub fn main() !u8 {
386 return 1; 366 return 1;
387 } 367 }
388 368
389 switch (o.cmd) { 369 switch (o._cmd) {
390 // The socket path resolved above is unused here and unchecked (see 370 // The socket path resolved above is unused here and unchecked (see
391 // the length guard above): asking a binary its version must work 371 // the length guard above): asking a binary its version must work
392 // with no daemon and no runtime dir. 372 // with no daemon and no runtime dir.
@@ -396,10 +376,11 @@ pub fn main() !u8 {
396 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; 376 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
397 return 0; 377 return 0;
398 }, 378 },
379 .help => return usageExit(.help),
399 .keygen => return keygen(alloc), 380 .keygen => return keygen(alloc),
400 .start => return startCmd(alloc, sock_path, args[2..]), 381 .start => return startCmd(alloc, sock_path, args[2..]),
401 .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path), 382 .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path),
402 .dump => return dump(alloc, sock_path, o.vt, o.session), 383 .dump => return dump(alloc, sock_path, o.vt, o.session orelse ""),
403 .stats => return stats(alloc, sock_path), 384 .stats => return stats(alloc, sock_path),
404 .stop => return stopCmd(alloc, sock_path), 385 .stop => return stopCmd(alloc, sock_path),
405 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), 386 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version),
@@ -1297,17 +1278,18 @@ fn parse(comptime argv: []const [:0]const u8) ParseResult {
1297 test "parseArgs: subcommands and their existing flags" { 1278 test "parseArgs: subcommands and their existing flags" {
1298 const r = parse(&.{ "muxd", "run" }); 1279 const r = parse(&.{ "muxd", "run" });
1299 try std.testing.expect(r == .ok); 1280 try std.testing.expect(r == .ok);
1300 try std.testing.expect(r.ok.cmd == .run); 1281 try std.testing.expect(r.ok._cmd == .run);
1301 try std.testing.expect(r.ok.sock == null); 1282 try std.testing.expect(r.ok.sock == null);
1302 try std.testing.expectEqual(@as(u16, 80), r.ok.cols); 1283 try std.testing.expectEqual(@as(u16, 80), r.ok.cols);
1303 try std.testing.expectEqual(@as(u16, 24), r.ok.rows); 1284 try std.testing.expectEqual(@as(u16, 24), r.ok.rows);
1304 1285
1305 const d = parse(&.{ "muxd", "dump", "--vt", "--sock", "/tmp/x.sock" }); 1286 const d = parse(&.{ "muxd", "dump", "--vt", "--sock", "/tmp/x.sock" });
1306 try std.testing.expect(d.ok.cmd == .dump); 1287 try std.testing.expect(d.ok._cmd == .dump);
1307 try std.testing.expect(d.ok.vt); 1288 try std.testing.expect(d.ok.vt);
1308 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); 1289 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?);
1309 // No --session named: the wire's own default spelling, empty. 1290 // No --session named: nothing to validate, and `dump` spells the absence
1310 try std.testing.expectEqualStrings("", d.ok.session); 1291 // on the wire as the empty tail.
1292 try std.testing.expect(d.ok.session == null);
1311 1293
1312 const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" }); 1294 const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" });
1313 try std.testing.expectEqual(@as(u16, 120), g.ok.cols); 1295 try std.testing.expectEqual(@as(u16, 120), g.ok.cols);
@@ -1322,7 +1304,7 @@ test "parseArgs: subcommands and their existing flags" {
1322 test "parse: dump --session rides into the payload" { 1304 test "parse: dump --session rides into the payload" {
1323 const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); 1305 const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
1324 try std.testing.expect(d == .ok); 1306 try std.testing.expect(d == .ok);
1325 try std.testing.expectEqualStrings("b", d.ok.session); 1307 try std.testing.expectEqualStrings("b", d.ok.session.?);
1326 1308
1327 // A name no tool could ever address is refused at parse — usage on 1309 // A name no tool could ever address is refused at parse — usage on
1328 // stderr, never carried to the wire as a payload nothing can look up. 1310 // stderr, never carried to the wire as a payload nothing can look up.
@@ -1478,13 +1460,34 @@ test "keygen: a generated key loads through quic.Key.load" {
1478 test "parseArgs: --version is a command, not a flag on one" { 1460 test "parseArgs: --version is a command, not a flag on one" {
1479 const r = parse(&.{ "muxd", "--version" }); 1461 const r = parse(&.{ "muxd", "--version" });
1480 try std.testing.expect(r == .ok); 1462 try std.testing.expect(r == .ok);
1481 try std.testing.expect(r.ok.cmd == .version); 1463 try std.testing.expect(r.ok._cmd == .version);
1464 }
1465
1466 test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" {
1467 const bare = parse(&.{ "muxd", "--help" });
1468 try std.testing.expect(bare == .ok);
1469 try std.testing.expect(bare.ok._cmd == .help);
1470
1471 // On a subcommand it is an outcome of the flag parse rather than a row,
1472 // and it must outrank the grammar: `--sock` here is still waiting for a
1473 // value, and asking for the usage is not a way to mistype one.
1474 try std.testing.expect(parse(&.{ "muxd", "run", "--help" }).err == .help);
1475 try std.testing.expect(parse(&.{ "muxd", "dump", "-h", "--sock", "/x" }).err == .help);
1476 try std.testing.expect(parse(&.{ "muxd", "run", "--sock", "--help" }).err == .help);
1477
1478 // The code, asked of `usageCode` rather than of `usageExit`: the latter
1479 // writes the usage to STDOUT, which under `zig build test` is the build
1480 // runner's own IPC channel, and the step hangs forever. 0 is the whole
1481 // difference between an answer and a refusal, so a refusal is asserted
1482 // beside it.
1483 try std.testing.expectEqual(@as(u8, 0), usageCode(.help));
1484 try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command));
1482 } 1485 }
1483 1486
1484 test "parseArgs: keygen takes no flags" { 1487 test "parseArgs: keygen takes no flags" {
1485 const r = parse(&.{ "muxd", "keygen" }); 1488 const r = parse(&.{ "muxd", "keygen" });
1486 try std.testing.expect(r == .ok); 1489 try std.testing.expect(r == .ok);
1487 try std.testing.expect(r.ok.cmd == .keygen); 1490 try std.testing.expect(r.ok._cmd == .keygen);
1488 try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg); 1491 try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg);
1489 } 1492 }
1490 1493
@@ -1499,7 +1502,7 @@ test "pickKey: --key beats MUX_KEY_FILE beats the default path" {
1499 test "parseArgs: start takes run's flags" { 1502 test "parseArgs: start takes run's flags" {
1500 const r = parse(&.{ "muxd", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); 1503 const r = parse(&.{ "muxd", "start", "--sock", "/tmp/x.sock", "--cols", "100" });
1501 try std.testing.expect(r == .ok); 1504 try std.testing.expect(r == .ok);
1502 try std.testing.expect(r.ok.cmd == .start); 1505 try std.testing.expect(r.ok._cmd == .start);
1503 try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?); 1506 try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?);
1504 try std.testing.expectEqual(@as(u16, 100), r.ok.cols); 1507 try std.testing.expectEqual(@as(u16, 100), r.ok.cols);
1505 } 1508 }
@@ -1507,22 +1510,22 @@ test "parseArgs: start takes run's flags" {
1507 test "parseArgs: stop is a command and takes --sock" { 1510 test "parseArgs: stop is a command and takes --sock" {
1508 const r = parse(&.{ "muxd", "stop" }); 1511 const r = parse(&.{ "muxd", "stop" });
1509 try std.testing.expect(r == .ok); 1512 try std.testing.expect(r == .ok);
1510 try std.testing.expect(r.ok.cmd == .stop); 1513 try std.testing.expect(r.ok._cmd == .stop);
1511 try std.testing.expect(r.ok.sock == null); 1514 try std.testing.expect(r.ok.sock == null);
1512 1515
1513 const s = parse(&.{ "muxd", "stop", "--sock", "/tmp/x.sock" }); 1516 const s = parse(&.{ "muxd", "stop", "--sock", "/tmp/x.sock" });
1514 try std.testing.expect(s.ok.cmd == .stop); 1517 try std.testing.expect(s.ok._cmd == .stop);
1515 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1518 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
1516 } 1519 }
1517 1520
1518 test "parseArgs: endpoint is a command and takes --sock" { 1521 test "parseArgs: endpoint is a command and takes --sock" {
1519 const r = parse(&.{ "muxd", "endpoint" }); 1522 const r = parse(&.{ "muxd", "endpoint" });
1520 try std.testing.expect(r == .ok); 1523 try std.testing.expect(r == .ok);
1521 try std.testing.expect(r.ok.cmd == .endpoint); 1524 try std.testing.expect(r.ok._cmd == .endpoint);
1522 try std.testing.expect(r.ok.sock == null); 1525 try std.testing.expect(r.ok.sock == null);
1523 1526
1524 const s = parse(&.{ "muxd", "endpoint", "--sock", "/tmp/x.sock" }); 1527 const s = parse(&.{ "muxd", "endpoint", "--sock", "/tmp/x.sock" });
1525 try std.testing.expect(s.ok.cmd == .endpoint); 1528 try std.testing.expect(s.ok._cmd == .endpoint);
1526 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1529 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
1527 1530
1528 // An unknown flag is refused, as it is for every command: a client of 1531 // An unknown flag is refused, as it is for every command: a client of
@@ -1544,7 +1547,7 @@ test "parseArgs: endpoint is a command and takes --sock" {
1544 test "parseArgs: run --resume-fd N --check is the old daemon's dry run" { 1547 test "parseArgs: run --resume-fd N --check is the old daemon's dry run" {
1545 const r = parse(&.{ "muxd", "run", "--resume-fd", "7", "--check" }); 1548 const r = parse(&.{ "muxd", "run", "--resume-fd", "7", "--check" });
1546 try std.testing.expect(r == .ok); 1549 try std.testing.expect(r == .ok);
1547 try std.testing.expect(r.ok.cmd == .run); 1550 try std.testing.expect(r.ok._cmd == .run);
1548 try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.ok.resume_fd.?); 1551 try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.ok.resume_fd.?);
1549 try std.testing.expect(r.ok.check); 1552 try std.testing.expect(r.ok.check);
1550 1553
@@ -1584,13 +1587,13 @@ test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it"
1584 test "parseArgs: upgrade is a command, and same-version is a flag it takes" { 1587 test "parseArgs: upgrade is a command, and same-version is a flag it takes" {
1585 const r = parse(&.{ "muxd", "upgrade" }); 1588 const r = parse(&.{ "muxd", "upgrade" });
1586 try std.testing.expect(r == .ok); 1589 try std.testing.expect(r == .ok);
1587 try std.testing.expect(r.ok.cmd == .upgrade); 1590 try std.testing.expect(r.ok._cmd == .upgrade);
1588 // Off unless asked: the skew rule is strictly-newer, and an operator who 1591 // Off unless asked: the skew rule is strictly-newer, and an operator who
1589 // did not name the exception must not get it. 1592 // did not name the exception must not get it.
1590 try std.testing.expect(!r.ok.allow_same_version); 1593 try std.testing.expect(!r.ok.allow_same_version);
1591 1594
1592 const s = parse(&.{ "muxd", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" }); 1595 const s = parse(&.{ "muxd", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" });
1593 try std.testing.expect(s.ok.cmd == .upgrade); 1596 try std.testing.expect(s.ok._cmd == .upgrade);
1594 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1597 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
1595 try std.testing.expect(s.ok.allow_same_version); 1598 try std.testing.expect(s.ok.allow_same_version);
1596 } 1599 }
@@ -1624,7 +1627,7 @@ test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abor
1624 try file.writeAll(buf.items); 1627 try file.writeAll(buf.items);
1625 1628
1626 const code = try resumeRun(alloc, .{ 1629 const code = try resumeRun(alloc, .{
1627 .cmd = .run, 1630 ._cmd = .run,
1628 .check = true, 1631 .check = true,
1629 .resume_fd = memfd, 1632 .resume_fd = memfd,
1630 .resume_fail_at = "daemon", 1633 .resume_fail_at = "daemon",