ea53a65f
refactor: muxweb parses into Parsed, not through a copy
a73x 2026-08-27 07:13
Commit message
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" { |