a73x

2ce4a84a

refactor: wall.Argv is the one argv collector for muxweb and mux wall

a73x   2026-08-27 07:11

Commit message
refactor: wall.Argv is the one argv collector for muxweb and mux wall

muxweb grew a _alloc/_tiles/_err collector for its flag table and `mux
wall` grew a second one beside it; the reason strings for a refused
spelling were spelled twice more, in webhub_main.addSpelling and in
mux_main.spellingReason. wall.zig owns the grammar, so it owns the argv
side of it too: one Argv with the cliflags hooks, one `reason`.

Behaviour change: `mux wall` validated nothing at parse — a bad spelling
survived to the resolve loop and was refused there, AFTER the wall file
had been read, as `bad wall target 'h#bad name': BadSession`. It now
refuses at usage altitude in the hub's words. Pinned in mux_main.

The wallMain paragraph riding on WallOpts's doc block moves to wallMain,
where it was always about: WallOpts is two forwarding one-liners now and
the prose outweighed it.

src/cli/mux_main.zig
Old New
@@ -471,54 +471,19 @@ pub fn main() !u8 {
471 } 471 }
472 } 472 }
473 473
474 /// `mux wall`: gather spellings (argv, or with none the saved wall — the
475 /// attach history mux itself writes, muxweb's too via argv and POST
476 /// /tiles), resolve each through the one grammar, hand the lot to
477 /// wallview.run. Resolution allocates into an arena because run() never
478 /// returns on the success path (it exits the process — see wallview.run);
479 /// only the early usage-error paths come back through the defers here.
480 /// `mux wall`'s own line: two flags, and every other word a tile. Shares 474 /// `mux wall`'s own line: two flags, and every other word a tile. Shares
481 /// `usage` with the attach line, so the prose that documents `--key` there 475 /// `usage` with the attach line, so the prose that documents `--key` there
482 /// documents it here. 476 /// documents it here.
483 const WallOpts = struct { 477 const WallOpts = struct {
484 key: ?[]const u8 = null, 478 key: ?[]const u8 = null,
485 quic_idle_ms: u32 = client.quic_idle_ms_default, 479 quic_idle_ms: u32 = client.quic_idle_ms_default,
486 _arena: std.mem.Allocator, 480 _argv: wall.Argv,
487 _tiles: std.ArrayList([]const u8) = .empty,
488 /// A hook answers yes or no, so one that failed for a reason has
489 /// nowhere to say so: wallMain owns every message this command prints.
490 _err: ?anyerror = null,
491
492 pub fn positional(self: *WallOpts, word: []const u8) bool {
493 const copy = self._arena.dupe(u8, word) catch |e| {
494 self._err = e;
495 return false;
496 };
497 self._tiles.append(self._arena, copy) catch |e| {
498 self._err = e;
499 return false;
500 };
501 return true;
502 }
503 481
504 /// `--sock` is not one of this command's own flags, so wall may claim 482 pub fn positional(self: *WallOpts, w: []const u8) bool {
505 /// it and its path as one spelling — muxweb's dialect, accepted here 483 return self._argv.positional(w);
506 /// too, and joined by wall so both binaries read one grammar. 484 }
507 pub fn extra(self: *WallOpts, rest: []const [:0]const u8) usize { 485 pub fn extra(self: *WallOpts, rest: []const [:0]const u8) usize {
508 const n = wall.spellingFromArgv(self._arena, rest, 0) catch |err| switch (err) { 486 return self._argv.extra(rest);
509 // Not a target this command refuses but one it never saw:
510 // left for cliflags to name as the unknown flag it is.
511 error.FlagLikeTarget => return 0,
512 else => |e| {
513 self._err = e;
514 return 0;
515 },
516 };
517 self._tiles.append(self._arena, n.spelling) catch |e| {
518 self._err = e;
519 return 0;
520 };
521 return n.consumed;
522 } 487 }
523 }; 488 };
524 489
@@ -526,6 +491,12 @@ comptime {
526 cliflags.assertDocumented(WallOpts, usage, &.{}); 491 cliflags.assertDocumented(WallOpts, usage, &.{});
527 } 492 }
528 493
494 /// `mux wall`: gather spellings (argv, or with none the saved wall — the
495 /// attach history mux itself writes, muxweb's too via argv and POST
496 /// /tiles), resolve each through the one grammar, hand the lot to
497 /// wallview.run. Resolution allocates into an arena because run() never
498 /// returns on the success path (it exits the process — see wallview.run);
499 /// only the early usage-error paths come back through the defers here.
529 fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { 500 fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
530 var arena_state = std.heap.ArenaAllocator.init(alloc); 501 var arena_state = std.heap.ArenaAllocator.init(alloc);
531 defer arena_state.deinit(); 502 defer arena_state.deinit();
@@ -540,18 +511,16 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
540 (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) 511 (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm")))
541 return wallEdit(arena, args[0], args[1..]); 512 return wallEdit(arena, args[0], args[1..]);
542 513
543 var w_opts = WallOpts{ ._arena = arena }; 514 var w_opts = WallOpts{ ._argv = .{ .alloc = arena } };
544 const outcome = cliflags.parse(WallOpts, &w_opts, args); 515 const outcome = cliflags.parse(WallOpts, &w_opts, args);
545 // Read before the outcome: a hook that refused for a REASON has already 516 // Read before the outcome: a hook that refused for a REASON has already
546 // named it, and that reason outranks the bare "unknown word" cliflags 517 // named it, and that reason outranks the bare "unknown word" cliflags
547 // saw when the hook said no. 518 // saw when the hook said no.
548 if (w_opts._err) |e| switch (e) { 519 if (w_opts._argv.err) |e| {
549 error.MissingSockPath => { 520 if (e.err == error.OutOfMemory) return e.err;
550 std.debug.print("mux: wall target '--sock' names no path\n", .{}); 521 std.debug.print("mux: wall target '{s}': {s}\n", .{ e.word, wall.reason(e.err) });
551 return 2; 522 return 2;
552 }, 523 }
553 else => return e,
554 };
555 switch (outcome) { 524 switch (outcome) {
556 .ok => {}, 525 .ok => {},
557 .help => { 526 .help => {
@@ -583,7 +552,7 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
583 } 552 }
584 const key = w_opts.key; 553 const key = w_opts.key;
585 const idle_ms = w_opts.quic_idle_ms; 554 const idle_ms = w_opts.quic_idle_ms;
586 var spellings = w_opts._tiles; 555 var spellings = w_opts._argv.tiles;
587 556
588 var from_file = false; 557 var from_file = false;
589 if (spellings.items.len == 0) { 558 if (spellings.items.len == 0) {
@@ -628,18 +597,6 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
628 return wallview.run(arena, resolved, .{ .hydrated = from_file }); 597 return wallview.run(arena, resolved, .{ .hydrated = from_file });
629 } 598 }
630 599
631 /// Why one spelling cannot be a tile, in the words the hub already uses
632 /// (webhub_main.addSpelling): one grammar, one vocabulary for refusing it.
633 fn spellingReason(err: anyerror) []const u8 {
634 return switch (err) {
635 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
636 error.EmptySpec => "empty target",
637 error.BadByte => "control byte in target",
638 error.SockPathTooLong => "socket path too long to bind",
639 else => @errorName(err),
640 };
641 }
642
643 /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. 600 /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials.
644 fn wallEdit( 601 fn wallEdit(
645 arena: std.mem.Allocator, 602 arena: std.mem.Allocator,
@@ -679,11 +636,11 @@ fn wallEdit(
679 // bound is a tile that could never dial, and ADD time is the only 636 // bound is a tile that could never dial, and ADD time is the only
680 // moment the user is still looking at what they typed. 637 // moment the user is still looking at what they typed.
681 const p = wall.parseSpelling(s) catch |err| { 638 const p = wall.parseSpelling(s) catch |err| {
682 std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(err) }); 639 std.debug.print("mux: wall add: {s}: {s}\n", .{ s, wall.reason(err) });
683 return 2; 640 return 2;
684 }; 641 };
685 if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) { 642 if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) {
686 std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(error.SockPathTooLong) }); 643 std.debug.print("mux: wall add: {s}: {s}\n", .{ s, wall.reason(error.SockPathTooLong) });
687 return 2; 644 return 2;
688 } 645 }
689 }; 646 };
@@ -806,6 +763,23 @@ test "parseArgs: naming two transports is a conflict, however it is spelled" {
806 try std.testing.expectEqualStrings("ssh b", v2.attach.via.?); 763 try std.testing.expectEqualStrings("ssh b", v2.attach.via.?);
807 } 764 }
808 765
766 test "wall: a bad spelling is refused at parse, before any tile is dialed" {
767 // It used to survive the parse and die in the resolve loop, one
768 // spelling among many, after the wall file had already been read. The
769 // hub refused the same word at usage altitude; now both mouths do,
770 // through wall.Argv, in wall.reason's words.
771 try std.testing.expectEqual(
772 @as(u8, 2),
773 try wallMain(std.testing.allocator, &[_][:0]const u8{"h#bad name"}),
774 );
775 // A flag-shaped word is still the unknown flag cliflags names, not a
776 // tile this refused: the two refusals have different owners.
777 try std.testing.expectEqual(
778 @as(u8, 2),
779 try wallMain(std.testing.allocator, &[_][:0]const u8{ "-A", "host" }),
780 );
781 }
782
809 test "parseArgs: unknown flags and valueless flags are usage errors" { 783 test "parseArgs: unknown flags and valueless flags are usage errors" {
810 try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); 784 try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error);
811 try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); 785 try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error);
src/cli/webhub_main.zig
Old New
@@ -41,30 +41,6 @@ const usage =
41 \\ 41 \\
42 ; 42 ;
43 43
44 /// Refused at usage altitude; downstream it is a rejected attach in
45 /// one tile, unexplained.
46 fn addSpelling(
47 alloc: std.mem.Allocator,
48 list: *std.ArrayList([]const u8),
49 spelling: []const u8,
50 ) ParseError!void {
51 // The ONE grammar: argv, the state file and POST /tiles are all read
52 // with this, so what argv accepts is exactly what the page can add.
53 // The message names the tile — with several targets on the line,
54 // `usage` alone would not say which.
55 _ = wall.parseSpelling(spelling) catch |err| {
56 std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) {
57 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
58 error.EmptySpec => "empty target",
59 error.BadByte => "control byte in target",
60 } });
61 return error.Usage;
62 };
63 const copy = try alloc.dupe(u8, spelling);
64 errdefer alloc.free(copy);
65 try list.append(alloc, copy);
66 }
67
68 const Parsed = struct { 44 const Parsed = struct {
69 /// One wall spelling per tile, in argv order — the same string that 45 /// One wall spelling per tile, in argv order — the same string that
70 /// reaches the state file, the resolver and the page's label. Owned 46 /// reaches the state file, the resolver and the page's label. Owned
@@ -96,39 +72,13 @@ const Opts = struct {
96 port: u16 = webhub.default_port, 72 port: u16 = webhub.default_port,
97 key: ?[]const u8 = null, 73 key: ?[]const u8 = null,
98 quic_idle_ms: u32 = client.quic_idle_ms_default, 74 quic_idle_ms: u32 = client.quic_idle_ms_default,
99 _alloc: std.mem.Allocator, 75 _argv: wall.Argv,
100 _tiles: std.ArrayList([]const u8) = .empty,
101 /// A hook answers yes or no, so a hook that fails for a reason has
102 /// nowhere to say so: it leaves it here for `parseArgs` to re-raise.
103 _err: ?ParseError = null,
104
105 /// Bare HOST and quic:// are already wall spellings verbatim.
106 pub fn positional(self: *Opts, word: []const u8) bool {
107 addSpelling(self._alloc, &self._tiles, word) catch |e| {
108 self._err = e;
109 return false;
110 };
111 return true;
112 }
113 76
114 /// `--sock PATH` is one tile in two words, a grammar no flag table can 77 pub fn positional(self: *Opts, w: []const u8) bool {
115 /// hold. wall owns the join so `mux wall` accepts the same two forms. 78 return self._argv.positional(w);
79 }
116 pub fn extra(self: *Opts, rest: []const [:0]const u8) usize { 80 pub fn extra(self: *Opts, rest: []const [:0]const u8) usize {
117 const n = wall.spellingFromArgv(self._alloc, rest, 0) catch |err| switch (err) { 81 return self._argv.extra(rest);
118 // Not a tile this program refuses, but one it never saw: left
119 // for cliflags to name as the unknown flag it is.
120 error.FlagLikeTarget => return 0,
121 else => |e| {
122 self._err = if (e == error.MissingSockPath) error.Usage else error.OutOfMemory;
123 return 0;
124 },
125 };
126 defer self._alloc.free(n.spelling);
127 addSpelling(self._alloc, &self._tiles, n.spelling) catch |e| {
128 self._err = e;
129 return 0;
130 };
131 return n.consumed;
132 } 82 }
133 }; 83 };
134 84
@@ -148,18 +98,23 @@ fn parseArgs(
148 args: []const [:0]const u8, 98 args: []const [:0]const u8,
149 env_key: ?[]const u8, 99 env_key: ?[]const u8,
150 ) ParseError!ParseResult { 100 ) ParseError!ParseResult {
151 var o = Opts{ ._alloc = alloc }; 101 var o = Opts{ ._argv = .{ .alloc = alloc } };
152 var p = Parsed{ .tiles = .empty }; 102 var p = Parsed{ .tiles = .empty };
153 errdefer { 103 errdefer {
154 p.tiles = o._tiles; 104 p.tiles = o._argv.tiles;
155 p.deinit(alloc); 105 p.deinit(alloc);
156 } 106 }
157 107
158 const outcome = cliflags.parse(Opts, &o, args[1..]); 108 const outcome = cliflags.parse(Opts, &o, args[1..]);
159 // Read before the outcome: a hook that refused for a REASON has already 109 // Read before the outcome: a hook that refused for a REASON has already
160 // named it, and that reason outranks the bare "unknown word" cliflags 110 // named it, and that reason outranks the bare "unknown word" cliflags
161 // saw when the hook said no. 111 // saw when the hook said no. The message names the tile — with several
162 if (o._err) |e| return e; 112 // targets on the line, `usage` alone would not say which.
113 if (o._argv.err) |e| {
114 if (e.err == error.OutOfMemory) return error.OutOfMemory;
115 std.debug.print("muxweb: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) });
116 return error.Usage;
117 }
163 switch (outcome) { 118 switch (outcome) {
164 .ok => {}, 119 .ok => {},
165 .unknown_arg, .missing_value, .bad_number => return error.Usage, 120 .unknown_arg, .missing_value, .bad_number => return error.Usage,
@@ -167,7 +122,7 @@ fn parseArgs(
167 // themselves: errdefer does not run on the way out with a result 122 // themselves: errdefer does not run on the way out with a result
168 // in hand. 123 // in hand.
169 .help, .version => { 124 .help, .version => {
170 p.tiles = o._tiles; 125 p.tiles = o._argv.tiles;
171 p.deinit(alloc); 126 p.deinit(alloc);
172 return if (outcome == .help) .help else .version; 127 return if (outcome == .help) .help else .version;
173 }, 128 },
@@ -182,7 +137,7 @@ fn parseArgs(
182 // No targets is not a usage error any more: it asks for the wall the 137 // No targets is not a usage error any more: it asks for the wall the
183 // last run persisted. main decides what an empty argv means; the parse 138 // last run persisted. main decides what an empty argv means; the parse
184 // only reports what was on the line. 139 // only reports what was on the line.
185 p.tiles = o._tiles; 140 p.tiles = o._argv.tiles;
186 p.port = o.port; 141 p.port = o.port;
187 p.idle_ms = o.quic_idle_ms; 142 p.idle_ms = o.quic_idle_ms;
188 p.key = xdg.pickKey(o.key, env_key); 143 p.key = xdg.pickKey(o.key, env_key);
src/wall.zig
Old New
@@ -98,6 +98,77 @@ pub fn spellingFromArgv(
98 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; 98 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
99 } 99 }
100 100
101 /// Why one spelling cannot be a tile, in ONE vocabulary: argv, the state
102 /// file and the hub's POST body are refused in the same words, so a user
103 /// who learns the message in one mouth reads it in the others. Errors
104 /// from outside this grammar (the resolver's) keep an arm here for the
105 /// same reason: the caller printing it does not care which layer said no.
106 pub fn reason(err: anyerror) []const u8 {
107 return switch (err) {
108 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
109 error.EmptySpec => "empty target",
110 error.BadByte => "control byte in target",
111 error.MissingSockPath => "names no path",
112 error.SockPathTooLong => "socket path too long to bind",
113 else => @errorName(err),
114 };
115 }
116
117 /// The argv side of the grammar, one collector for both binaries' flag
118 /// tables: bare words and `--sock PATH` become owned spellings, each
119 /// validated HERE at usage altitude rather than downstream as one tile
120 /// that will not attach. `positional` and `extra` are the names cliflags
121 /// finds by reflection.
122 pub const Argv = struct {
123 alloc: std.mem.Allocator,
124 tiles: std.ArrayList([]const u8) = .empty,
125 /// A hook answers yes or no, so one that refused for a REASON has
126 /// nowhere to say so: it leaves the word and the why for the caller,
127 /// whose message can then name WHICH tile of several was refused.
128 err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null,
129
130 pub fn deinit(self: *Argv) void {
131 for (self.tiles.items) |t| self.alloc.free(t);
132 self.tiles.deinit(self.alloc);
133 }
134
135 /// Bare HOST and quic:// are already wall spellings verbatim.
136 pub fn positional(self: *Argv, word: []const u8) bool {
137 return self.take(word);
138 }
139
140 /// `--sock PATH` is one tile in two words, a grammar no flag table can
141 /// hold. A flag-shaped word is refused with NO record: it is not a
142 /// target this program rejects but one it never saw, left for cliflags
143 /// to name as the unknown flag it is.
144 pub fn extra(self: *Argv, rest: []const [:0]const u8) usize {
145 const n = spellingFromArgv(self.alloc, rest, 0) catch |e| {
146 if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e);
147 return 0;
148 };
149 defer self.alloc.free(n.spelling);
150 return if (self.take(n.spelling)) n.consumed else 0;
151 }
152
153 fn take(self: *Argv, spelling: []const u8) bool {
154 // Appended before it is judged, so a refusal's `word` points into a
155 // copy this list owns: `--sock PATH` is joined into the caller's
156 // temporary, and a message naming it must not outlive that.
157 const copy = self.alloc.dupe(u8, spelling) catch return self.refuse("", error.OutOfMemory);
158 self.tiles.append(self.alloc, copy) catch {
159 self.alloc.free(copy);
160 return self.refuse("", error.OutOfMemory);
161 };
162 _ = parseSpelling(copy) catch |e| return self.refuse(copy, e);
163 return true;
164 }
165
166 fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool {
167 self.err = .{ .word = word, .err = e };
168 return false;
169 }
170 };
171
101 pub const Wall = struct { 172 pub const Wall = struct {
102 /// Owned copies, wall order. The spelling IS the label downstream. 173 /// Owned copies, wall order. The spelling IS the label downstream.
103 targets: std.ArrayList([]u8) = .empty, 174 targets: std.ArrayList([]u8) = .empty,
@@ -291,6 +362,53 @@ pub fn saveLayout(path: []const u8, bytes: []const u8) !void {
291 return saveBytes(path, bytes); 362 return saveBytes(path, bytes);
292 } 363 }
293 364
365 test "Argv: bare words and both --sock dialects all become owned spellings" {
366 var a = Argv{ .alloc = std.testing.allocator };
367 defer a.deinit();
368 try std.testing.expect(a.positional("box1"));
369 try std.testing.expectEqual(@as(usize, 2), a.extra(&[_][:0]const u8{ "--sock", "/tmp/a.sock" }));
370 try std.testing.expectEqual(@as(usize, 1), a.extra(&[_][:0]const u8{"--sock /tmp/b.sock#w"}));
371 try std.testing.expect(a.err == null);
372 try std.testing.expectEqual(@as(usize, 3), a.tiles.items.len);
373 try std.testing.expectEqualStrings("box1", a.tiles.items[0]);
374 // The two-word dialect is JOINED: one spelling from here on, prefix
375 // included, because that string is the label and the wall line too.
376 try std.testing.expectEqualStrings("--sock /tmp/a.sock", a.tiles.items[1]);
377 try std.testing.expectEqualStrings("--sock /tmp/b.sock#w", a.tiles.items[2]);
378 }
379
380 test "Argv: a refusal records WHICH word and why; a flag-shaped word records nothing" {
381 var a = Argv{ .alloc = std.testing.allocator };
382 defer a.deinit();
383 try std.testing.expect(!a.positional("h#bad name"));
384 try std.testing.expectEqualStrings("h#bad name", a.err.?.word);
385 try std.testing.expect(a.err.?.err == error.BadSession);
386
387 // The joined spelling is what the message names, and it must still be
388 // readable after `extra` freed the temporary it was joined into.
389 var b = Argv{ .alloc = std.testing.allocator };
390 defer b.deinit();
391 try std.testing.expectEqual(@as(usize, 0), b.extra(&[_][:0]const u8{ "--sock", "/tmp/x#bad name" }));
392 try std.testing.expectEqualStrings("--sock /tmp/x#bad name", b.err.?.word);
393 try std.testing.expect(b.err.?.err == error.BadSession);
394
395 // A trailing `--sock` names no path — a usage mistake this collector
396 // owns, so it IS recorded, against the flag word the user typed.
397 var c = Argv{ .alloc = std.testing.allocator };
398 defer c.deinit();
399 try std.testing.expectEqual(@as(usize, 0), c.extra(&[_][:0]const u8{"--sock"}));
400 try std.testing.expectEqualStrings("--sock", c.err.?.word);
401 try std.testing.expect(c.err.?.err == error.MissingSockPath);
402
403 // `-A` is not a target this refuses but one it never saw: no record,
404 // so cliflags names it the unknown flag it is.
405 var d = Argv{ .alloc = std.testing.allocator };
406 defer d.deinit();
407 try std.testing.expectEqual(@as(usize, 0), d.extra(&[_][:0]const u8{ "-A", "host" }));
408 try std.testing.expect(d.err == null);
409 try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len);
410 }
411
294 test "spellingFromArgv: both --sock dialects reach the same spelling" { 412 test "spellingFromArgv: both --sock dialects reach the same spelling" {
295 const alloc = std.testing.allocator; 413 const alloc = std.testing.allocator;
296 const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" }; 414 const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" };