a73x

ea53a65f

refactor: muxweb parses into Parsed, not through a copy

a73x   2026-08-27 07:13

Commit message
refactor: muxweb parses into Parsed, not through a copy

Opts and Parsed carried the same three fields; parseArgs copied one into
the other in three places and the copy was the point of the wrapper
errdefer. Parsed is the flag table now, so `--quic-idle-ms` is the field
that answers to it and there is nowhere left to forget a field.

src/cli/webhub_main.zig
Old New
@@ -41,21 +41,26 @@ const usage =
41 \\ 41 \\
42 ; 42 ;
43 43
44 /// The command line, read off the struct: the field's type is the flag's
45 /// arity and its name is the flag's spelling. What a flag MEANS stays in
46 /// the post-checks below. This IS the parse result — a second struct to
47 /// copy the same three fields into bought nothing but three places to
48 /// forget one.
44 const Parsed = struct { 49 const Parsed = struct {
45 /// One wall spelling per tile, in argv order — the same string that
46 /// reaches the state file, the resolver and the page's label. Owned
47 /// uniformly rather than half-borrowed from argv, because `--sock PATH`
48 /// has to synthesize its `--sock ` prefix and one ownership rule beats
49 /// two. That prefix is now part of a sock tile's label: the label IS
50 /// the spelling.
51 tiles: std.ArrayList([]const u8),
52 port: u16 = webhub.default_port, 50 port: u16 = webhub.default_port,
53 key: ?[]const u8 = null, 51 key: ?[]const u8 = null,
54 idle_ms: u32 = client.quic_idle_ms_default, 52 quic_idle_ms: u32 = client.quic_idle_ms_default,
53 _argv: wall.Argv,
55 54
56 fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { 55 pub fn positional(self: *Parsed, w: []const u8) bool {
57 for (self.tiles.items) |t| alloc.free(t); 56 return self._argv.positional(w);
58 self.tiles.deinit(alloc); 57 }
58 pub fn extra(self: *Parsed, rest: []const [:0]const u8) usize {
59 return self._argv.extra(rest);
60 }
61
62 fn deinit(self: *Parsed) void {
63 self._argv.deinit();
59 } 64 }
60 }; 65 };
61 66
@@ -65,25 +70,8 @@ const ParseResult = union(enum) {
65 help, 70 help,
66 }; 71 };
67 72
68 /// The command line, read off the struct: the field's type is the flag's
69 /// arity and its name is the flag's spelling. Everything a flag is FOR
70 /// stays in the post-checks below.
71 const Opts = struct {
72 port: u16 = webhub.default_port,
73 key: ?[]const u8 = null,
74 quic_idle_ms: u32 = client.quic_idle_ms_default,
75 _argv: wall.Argv,
76
77 pub fn positional(self: *Opts, w: []const u8) bool {
78 return self._argv.positional(w);
79 }
80 pub fn extra(self: *Opts, rest: []const [:0]const u8) usize {
81 return self._argv.extra(rest);
82 }
83 };
84
85 comptime { 73 comptime {
86 cliflags.assertDocumented(Opts, usage, &.{}); 74 cliflags.assertDocumented(Parsed, usage, &.{});
87 } 75 }
88 76
89 /// A usage mistake is an ERROR, not a third result: it is the one outcome 77 /// A usage mistake is an ERROR, not a third result: it is the one outcome
@@ -98,19 +86,15 @@ fn parseArgs(
98 args: []const [:0]const u8, 86 args: []const [:0]const u8,
99 env_key: ?[]const u8, 87 env_key: ?[]const u8,
100 ) ParseError!ParseResult { 88 ) ParseError!ParseResult {
101 var o = Opts{ ._argv = .{ .alloc = alloc } }; 89 var p = Parsed{ ._argv = .{ .alloc = alloc } };
102 var p = Parsed{ .tiles = .empty }; 90 errdefer p.deinit();
103 errdefer {
104 p.tiles = o._argv.tiles;
105 p.deinit(alloc);
106 }
107 91
108 const outcome = cliflags.parse(Opts, &o, args[1..]); 92 const outcome = cliflags.parse(Parsed, &p, args[1..]);
109 // Read before the outcome: a hook that refused for a REASON has already 93 // Read before the outcome: a hook that refused for a REASON has already
110 // named it, and that reason outranks the bare "unknown word" cliflags 94 // named it, and that reason outranks the bare "unknown word" cliflags
111 // saw when the hook said no. The message names the tile — with several 95 // saw when the hook said no. The message names the tile — with several
112 // targets on the line, `usage` alone would not say which. 96 // targets on the line, `usage` alone would not say which.
113 if (o._argv.err) |e| { 97 if (p._argv.err) |e| {
114 if (e.err == error.OutOfMemory) return error.OutOfMemory; 98 if (e.err == error.OutOfMemory) return error.OutOfMemory;
115 std.debug.print("muxweb: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) }); 99 std.debug.print("muxweb: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) });
116 return error.Usage; 100 return error.Usage;
@@ -122,8 +106,7 @@ fn parseArgs(
122 // 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
123 // in hand. 107 // in hand.
124 .help, .version => { 108 .help, .version => {
125 p.tiles = o._argv.tiles; 109 p.deinit();
126 p.deinit(alloc);
127 return if (outcome == .help) .help else .version; 110 return if (outcome == .help) .help else .version;
128 }, 111 },
129 } 112 }
@@ -131,16 +114,13 @@ fn parseArgs(
131 // asked for as the door to open — a door nobody could find. Refused 114 // asked for as the door to open — a door nobody could find. Refused
132 // 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
133 // what typing it means. 116 // what typing it means.
134 if (o.port == 0) return error.Usage; 117 if (p.port == 0) return error.Usage;
135 if (o.quic_idle_ms == 0) return error.Usage; 118 if (p.quic_idle_ms == 0) return error.Usage;
136 119
137 // No targets is not a usage error any more: it asks for the wall the 120 // No targets is not a usage error any more: it asks for the wall the
138 // last run persisted. main decides what an empty argv means; the parse 121 // last run persisted. main decides what an empty argv means; the parse
139 // only reports what was on the line. 122 // only reports what was on the line.
140 p.tiles = o._argv.tiles; 123 p.key = xdg.pickKey(p.key, env_key);
141 p.port = o.port;
142 p.idle_ms = o.quic_idle_ms;
143 p.key = xdg.pickKey(o.key, env_key);
144 return .{ .serve = p }; 124 return .{ .serve = p };
145 } 125 }
146 126
@@ -175,7 +155,7 @@ pub fn main() !u8 {
175 }, 155 },
176 .serve => |p| p, 156 .serve => |p| p,
177 }; 157 };
178 defer parsed.deinit(alloc); 158 defer parsed.deinit();
179 159
180 // An arena, because every string built here lives exactly as long as 160 // An arena, because every string built here lives exactly as long as
181 // the hub does — the state path, the wall it starts from, and the 161 // the hub does — the state path, the wall it starts from, and the
@@ -189,7 +169,7 @@ pub fn main() !u8 {
189 169
190 const state_path = try wall.statePath(arena); 170 const state_path = try wall.statePath(arena);
191 var w: wall.Wall = undefined; 171 var w: wall.Wall = undefined;
192 if (parsed.tiles.items.len == 0) { 172 if (parsed._argv.tiles.items.len == 0) {
193 // No argv: the wall is whatever the last run persisted. 173 // No argv: the wall is whatever the last run persisted.
194 w = wall.load(arena, state_path) catch |err| { 174 w = wall.load(arena, state_path) catch |err| {
195 // The file may have been hand-edited into a line that no longer 175 // The file may have been hand-edited into a line that no longer
@@ -200,7 +180,7 @@ pub fn main() !u8 {
200 } else { 180 } else {
201 // Argv present: the explicit override. It becomes the persisted wall. 181 // Argv present: the explicit override. It becomes the persisted wall.
202 w = wall.Wall{}; 182 w = wall.Wall{};
203 for (parsed.tiles.items) |s| _ = try w.add(arena, s); 183 for (parsed._argv.tiles.items) |s| _ = try w.add(arena, s);
204 } 184 }
205 185
206 // The Hub resolves every spelling into a dialable Target through the 186 // The Hub resolves every spelling into a dialable Target through the
@@ -208,7 +188,7 @@ pub fn main() !u8 {
208 // so the two binaries cannot drift on what a bare HOST or a `quic://` 188 // so the two binaries cannot drift on what a bare HOST or a `quic://`
209 // means, and a tile POSTed by the page means what one typed on the 189 // means, and a tile POSTed by the page means what one typed on the
210 // command line. 190 // command line.
211 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.idle_ms) catch |err| switch (err) { 191 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.quic_idle_ms) catch |err| switch (err) {
212 error.MissingKey => { 192 error.MissingKey => {
213 std.debug.print( 193 std.debug.print(
214 "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", 194 "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n",
@@ -238,7 +218,7 @@ pub fn main() !u8 {
238 // 218 //
239 // Written only when there WAS argv, still: a restore that rewrote what 219 // Written only when there WAS argv, still: a restore that rewrote what
240 // it just read would turn a read failure into a lost wall. 220 // it just read would turn a read failure into a lost wall.
241 if (parsed.tiles.items.len != 0) for (hub.wall_state.targets.items) |spelling| { 221 if (parsed._argv.tiles.items.len != 0) for (hub.wall_state.targets.items) |spelling| {
242 _ = wall.record(alloc, state_path, spelling) catch |err| { 222 _ = wall.record(alloc, state_path, spelling) catch |err| {
243 std.debug.print("muxweb: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) }); 223 std.debug.print("muxweb: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) });
244 return 2; 224 return 2;
@@ -295,13 +275,13 @@ test "parse: three spellings become three tiles in argv order, port and key bind
295 "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000", 275 "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000",
296 }; 276 };
297 var r = (try parseArgs(alloc, &args, null)).serve; 277 var r = (try parseArgs(alloc, &args, null)).serve;
298 defer r.deinit(alloc); 278 defer r.deinit();
299 try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); 279 try std.testing.expectEqual(@as(usize, 3), r._argv.tiles.items.len);
300 try std.testing.expectEqualStrings("box1", r.tiles.items[0]); 280 try std.testing.expectEqualStrings("box1", r._argv.tiles.items[0]);
301 // `--sock PATH` is ONE spelling from here on, prefix included — that 281 // `--sock PATH` is ONE spelling from here on, prefix included — that
302 // string is the label, the wall line, and the resolver's input alike. 282 // string is the label, the wall line, and the resolver's input alike.
303 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r.tiles.items[1]); 283 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.tiles.items[1]);
304 try std.testing.expectEqualStrings("quic://h:4433", r.tiles.items[2]); 284 try std.testing.expectEqualStrings("quic://h:4433", r._argv.tiles.items[2]);
305 try std.testing.expectEqual(@as(u16, 8000), r.port); 285 try std.testing.expectEqual(@as(u16, 8000), r.port);
306 try std.testing.expectEqualStrings("/k", r.key.?); 286 try std.testing.expectEqualStrings("/k", r.key.?);
307 } 287 }
@@ -311,9 +291,9 @@ test "parse: a quoted '--sock PATH#SESSION' is the same tile as the two-argument
311 // The wall file's own spelling, pasted straight onto the command line: 291 // The wall file's own spelling, pasted straight onto the command line:
312 // muxweb used to refuse it while `mux wall` required it. 292 // muxweb used to refuse it while `mux wall` required it.
313 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null)).serve; 293 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null)).serve;
314 defer r.deinit(alloc); 294 defer r.deinit();
315 try std.testing.expectEqual(@as(usize, 1), r.tiles.items.len); 295 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len);
316 try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r.tiles.items[0]); 296 try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r._argv.tiles.items[0]);
317 } 297 }
318 298
319 test "parse: zero targets, bad flags, and flag-beats-env" { 299 test "parse: zero targets, bad flags, and flag-beats-env" {
@@ -322,8 +302,8 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
322 // semantics live in main, which is the only place that can read a file. 302 // semantics live in main, which is the only place that can read a file.
323 { 303 {
324 var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve; 304 var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve;
325 defer r.deinit(alloc); 305 defer r.deinit();
326 try std.testing.expectEqual(@as(usize, 0), r.tiles.items.len); 306 try std.testing.expectEqual(@as(usize, 0), r._argv.tiles.items.len);
327 } 307 }
328 // Every other refusal arrives as error.Usage — and the testing 308 // Every other refusal arrives as error.Usage — and the testing
329 // allocator is the other half of this pin: a refusal that leaked the 309 // allocator is the other half of this pin: a refusal that leaked the
@@ -349,24 +329,24 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
349 // not the flag. 329 // not the flag.
350 { 330 {
351 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null)).serve; 331 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null)).serve;
352 defer r.deinit(alloc); 332 defer r.deinit();
353 try std.testing.expectEqual(@as(u16, 1), r.port); 333 try std.testing.expectEqual(@as(u16, 1), r.port);
354 } 334 }
355 // Env fills in when --key is absent; --key wins when both are set. 335 // Env fills in when --key is absent; --key wins when both are set.
356 { 336 {
357 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; 337 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve;
358 defer r.deinit(alloc); 338 defer r.deinit();
359 try std.testing.expectEqualStrings("/env-key", r.key.?); 339 try std.testing.expectEqualStrings("/env-key", r.key.?);
360 } 340 }
361 { 341 {
362 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve; 342 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve;
363 defer r.deinit(alloc); 343 defer r.deinit();
364 try std.testing.expectEqualStrings("/flag-key", r.key.?); 344 try std.testing.expectEqualStrings("/flag-key", r.key.?);
365 } 345 }
366 // Empty either way means unset. 346 // Empty either way means unset.
367 { 347 {
368 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve; 348 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve;
369 defer r.deinit(alloc); 349 defer r.deinit();
370 try std.testing.expectEqual(@as(?[]const u8, null), r.key); 350 try std.testing.expectEqual(@as(?[]const u8, null), r.key);
371 } 351 }
372 } 352 }
@@ -377,20 +357,20 @@ test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" {
377 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", 357 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c",
378 }; 358 };
379 var r = (try parseArgs(alloc, &args, null)).serve; 359 var r = (try parseArgs(alloc, &args, null)).serve;
380 defer r.deinit(alloc); 360 defer r.deinit();
381 try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len); 361 try std.testing.expectEqual(@as(usize, 5), r._argv.tiles.items.len);
382 362
383 // The session SPLIT is wall.parseSpelling's, tested there. What is 363 // The session SPLIT is wall.parseSpelling's, tested there. What is
384 // this parse's own is that the argv string arrives intact: the user 364 // this parse's own is that the argv string arrives intact: the user
385 // asked for `host#b`, so that is the wall line, and therefore the 365 // asked for `host#b`, so that is the wall line, and therefore the
386 // tile's name on screen — nobody decorates it on the way. 366 // tile's name on screen — nobody decorates it on the way.
387 try std.testing.expectEqualStrings("host#b", r.tiles.items[0]); 367 try std.testing.expectEqualStrings("host#b", r._argv.tiles.items[0]);
388 try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1]); 368 try std.testing.expectEqualStrings("quic://h:1#b", r._argv.tiles.items[1]);
389 // The flag and its value become one spelling; the `#NAME` rides on the 369 // The flag and its value become one spelling; the `#NAME` rides on the
390 // VALUE, where the user put it. 370 // VALUE, where the user put it.
391 try std.testing.expectEqualStrings("--sock /tmp/x#b", r.tiles.items[2]); 371 try std.testing.expectEqualStrings("--sock /tmp/x#b", r._argv.tiles.items[2]);
392 try std.testing.expectEqualStrings("plainhost", r.tiles.items[3]); 372 try std.testing.expectEqualStrings("plainhost", r._argv.tiles.items[3]);
393 try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4]); 373 try std.testing.expectEqualStrings("a#b#c", r._argv.tiles.items[4]);
394 } 374 }
395 375
396 test "tiles: a bad session name after # is still a usage error at parse" { 376 test "tiles: a bad session name after # is still a usage error at parse" {
@@ -429,9 +409,9 @@ test "help is an answer, not a refusal, and -- fences the tiles from the flags"
429 // Past `--` a word is a tile whatever it is spelled like: the escape a 409 // Past `--` a word is a tile whatever it is spelled like: the escape a
430 // host whose name reads as a flag would otherwise have none of. 410 // host whose name reads as a flag would otherwise have none of.
431 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--", "host" }, null)).serve; 411 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--", "host" }, null)).serve;
432 defer r.deinit(alloc); 412 defer r.deinit();
433 try std.testing.expectEqual(@as(usize, 1), r.tiles.items.len); 413 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len);
434 try std.testing.expectEqualStrings("host", r.tiles.items[0]); 414 try std.testing.expectEqualStrings("host", r._argv.tiles.items[0]);
435 } 415 }
436 416
437 test "version short-circuits everything else on the line" { 417 test "version short-circuits everything else on the line" {