a73x

69be7cc3

feat: one wall, one argv dialect — both binaries take both --sock spellings

a73x   2026-08-19 17:17

Commit message
feat: one wall, one argv dialect — both binaries take both --sock spellings

The wall grammar is one string per tile, and that is what the state file
holds. But argv had grown two dialects for it: `muxweb --sock PATH` spelled
the sock tile as a flag with a following value, while `mux wall` took every
argument whole and so needed `'--sock PATH'` quoted. The same wall, typed
two ways depending on which binary was reading.

wall.zig owns the grammar, so it now owns the normalization too: one
`spellingFromArgv` step both argv loops call, joining a bare `--sock` with
the next argument and passing everything else through. A trailing `--sock`
is a named usage error rather than a read off the end of argv.

Each binary keeps its own flags untouched; `--sock` was never one of
`mux wall`'s, so nothing it already accepted changed meaning. The state
file is unaffected — this is argv only, and a line from the file can now
be pasted back onto either command line verbatim.

README.md
Old New
@@ -133,7 +133,9 @@ box adds a tile, `×` removes one (which detaches — the session and
133 everything in it keep running), `+` starts a new session on that tile's host, 133 everything in it keep running), `+` starts a new session on that tile's host,
134 and tiles drag into whatever order you want. The result is saved to 134 and tiles drag into whatever order you want. The result is saved to
135 `$XDG_STATE_HOME/mux/wall` (`~/.local/state/mux/wall`), one spelling per 135 `$XDG_STATE_HOME/mux/wall` (`~/.local/state/mux/wall`), one spelling per
136 line, so the next bare `muxweb` comes back to the same wall. 136 line, so the next bare `muxweb` comes back to the same wall. That saved
137 spelling — `--sock PATH` in one piece — is also accepted on the command
138 line, so a line from the file can be pasted back verbatim.
137 139
138 Localhost only, deliberately: to see it from another machine, forward it — 140 Localhost only, deliberately: to see it from another machine, forward it —
139 `ssh -L 7681:127.0.0.1:7681 HOST`. 141 `ssh -L 7681:127.0.0.1:7681 HOST`.
@@ -143,6 +145,7 @@ The same wall, without a browser:
143 ```sh 145 ```sh
144 mux wall # the saved wall, as stripes in this terminal 146 mux wall # the saved wall, as stripes in this terminal
145 mux wall HOST '--sock /tmp/s.sock#b' # ...or state it (one spelling per argument) 147 mux wall HOST '--sock /tmp/s.sock#b' # ...or state it (one spelling per argument)
148 mux wall HOST --sock /tmp/s.sock#b # ...`--sock PATH` unquoted works too, as in muxweb
146 ``` 149 ```
147 150
148 Read-only: each stripe is a live session (label bar + the rows around its 151 Read-only: each stripe is a live session (label bar + the rows around its
src/mux_main.zig
Old New
@@ -30,7 +30,8 @@ const usage =
30 \\ mux wall [SPELLING...] shows several sessions at once, read-only, 30 \\ mux wall [SPELLING...] shows several sessions at once, read-only,
31 \\ one stripe each; `q` or Ctrl-\ leaves. SPELLING is the wall grammar 31 \\ one stripe each; `q` or Ctrl-\ leaves. SPELLING is the wall grammar
32 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], 32 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION],
33 \\ one argument per tile); with none, the muxweb wall file is shown. 33 \\ one argument per tile, but `--sock PATH` may also be two arguments
34 \\ as in muxweb); with none, the muxweb wall file is shown.
34 \\ 35 \\
35 ; 36 ;
36 37
@@ -362,7 +363,18 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
362 } 363 }
363 idle_ms = n; 364 idle_ms = n;
364 } else { 365 } else {
365 try spellings.append(arena, a); 366 // Every other argument is a tile. `--sock` is not one of this
367 // command's own flags, so wall may claim it and its path as
368 // one spelling — muxweb's dialect, accepted here too.
369 const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) {
370 error.MissingSockPath => {
371 std.debug.print("mux: wall target '--sock' names no path\n", .{});
372 return 2;
373 },
374 else => |e| return e,
375 };
376 i += n.consumed - 1;
377 try spellings.append(arena, n.spelling);
366 } 378 }
367 } 379 }
368 380
src/wall.zig
Old New
@@ -9,6 +9,10 @@
9 //! The session splits at the LAST '#' because validSessionName refuses 9 //! The session splits at the LAST '#' because validSessionName refuses
10 //! '#', so any earlier one belongs to the target's own spelling. 10 //! '#', so any earlier one belongs to the target's own spelling.
11 //! 11 //!
12 //! argv normalization lives here too (`spellingFromArgv`): both binaries
13 //! accept `--sock PATH` as two arguments or as one, and both arrive at
14 //! the single spelling above.
15 //!
12 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, order 16 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, order
13 //! is wall order. Every mutation rewrites it atomically (temp + rename); 17 //! is wall order. Every mutation rewrites it atomically (temp + rename);
14 //! two concurrent writers resolve as last-rename-wins, acceptable for a 18 //! two concurrent writers resolve as last-rename-wins, acceptable for a
@@ -68,6 +72,34 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed {
68 return .{ .spec = .{ .host = spec_str }, .session = session }; 72 return .{ .spec = .{ .host = spec_str }, .session = session };
69 } 73 }
70 74
75 pub const ArgvError = error{MissingSockPath} || std.mem.Allocator.Error;
76
77 /// One argv element (plus, for `--sock`, the one after it) becomes one
78 /// spelling in the grammar above.
79 ///
80 /// Two dialects grew for the same wall: `muxweb --sock PATH` spells the
81 /// sock tile as a flag with a following value, while `mux wall` takes each
82 /// argument as a whole spelling and so needs `'--sock PATH'` quoted. Both
83 /// binaries call this, so both accept both, and neither owns a second
84 /// parser: a bare `--sock` joins with the next argument, anything else —
85 /// `--sock PATH` already in one piece, HOST, quic:// — passes through.
86 ///
87 /// The result is always an owned copy so ownership does not depend on
88 /// which spelling arrived; `consumed` is how far the caller's index moves.
89 pub fn spellingFromArgv(
90 alloc: std.mem.Allocator,
91 args: []const [:0]const u8,
92 i: usize,
93 ) ArgvError!struct { spelling: []u8, consumed: usize } {
94 if (std.mem.eql(u8, args[i], "--sock")) {
95 // A trailing `--sock` names no path: a usage mistake, reported as
96 // one rather than read off the end of argv.
97 if (i + 1 >= args.len) return error.MissingSockPath;
98 return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 };
99 }
100 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
101 }
102
71 pub const Wall = struct { 103 pub const Wall = struct {
72 /// Owned copies, wall order. The spelling IS the label downstream. 104 /// Owned copies, wall order. The spelling IS the label downstream.
73 targets: std.ArrayList([]u8) = .empty, 105 targets: std.ArrayList([]u8) = .empty,
@@ -157,6 +189,42 @@ pub fn statePathFrom(
157 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/wall", .{h}); 189 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/wall", .{h});
158 } 190 }
159 191
192 test "spellingFromArgv: both --sock dialects reach the same spelling" {
193 const alloc = std.testing.allocator;
194 const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" };
195
196 // Two arguments joined, and one argument passed through: same string,
197 // which is the point — the state file only ever holds this one.
198 const joined = try spellingFromArgv(alloc, &argv, 0);
199 defer alloc.free(joined.spelling);
200 try std.testing.expectEqualStrings("--sock /tmp/x.sock#b", joined.spelling);
201 try std.testing.expectEqual(@as(usize, 2), joined.consumed);
202
203 const whole = try spellingFromArgv(alloc, &argv, 2);
204 defer alloc.free(whole.spelling);
205 try std.testing.expectEqualStrings("--sock /tmp/x.sock#b", whole.spelling);
206 try std.testing.expectEqual(@as(usize, 1), whole.consumed);
207
208 // #SESSION rides both paths intact, down to the split.
209 try std.testing.expectEqualStrings("b", (try parseSpelling(joined.spelling)).session);
210 try std.testing.expectEqualStrings("/tmp/x.sock", (try parseSpelling(whole.spelling)).spec.sock);
211
212 // Host and quic spellings are already whole; nothing is consumed after.
213 const host = try spellingFromArgv(alloc, &argv, 3);
214 defer alloc.free(host.spelling);
215 try std.testing.expectEqualStrings("host#b", host.spelling);
216 try std.testing.expectEqual(@as(usize, 1), host.consumed);
217 const q = try spellingFromArgv(alloc, &argv, 4);
218 defer alloc.free(q.spelling);
219 try std.testing.expectEqualStrings("quic://h:4433#b", q.spelling);
220 try std.testing.expectEqual(@as(usize, 1), q.consumed);
221 }
222
223 test "spellingFromArgv: a trailing --sock is a usage error, not a read off the end" {
224 const argv = [_][:0]const u8{ "host", "--sock" };
225 try std.testing.expectError(error.MissingSockPath, spellingFromArgv(std.testing.allocator, &argv, 1));
226 }
227
160 test "parseSpelling: three spellings classify; session splits at the LAST '#'" { 228 test "parseSpelling: three spellings classify; session splits at the LAST '#'" {
161 try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host); 229 try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host);
162 try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session); 230 try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session);
src/webhub_main.zig
Old New
@@ -24,6 +24,8 @@ const sockpath = @import("sockpath");
24 const usage = 24 const usage =
25 \\usage: muxweb [TARGET[#SESSION] ...] [--port N] 25 \\usage: muxweb [TARGET[#SESSION] ...] [--port N]
26 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] 26 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT]
27 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the
28 \\ spelling the wall file holds; `mux wall` takes both too
27 \\ with no TARGET the wall from the last run is restored; with TARGETs 29 \\ with no TARGET the wall from the last run is restored; with TARGETs
28 \\ argv replaces it and becomes the saved wall 30 \\ argv replaces it and becomes the saved wall
29 \\ #SESSION names the daemon session the tile attaches to (default: the 31 \\ #SESSION names the daemon session the tile attaches to (default: the
@@ -112,14 +114,18 @@ fn parseArgs(
112 // result in hand. 114 // result in hand.
113 p.deinit(alloc); 115 p.deinit(alloc);
114 return .version; 116 return .version;
115 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { 117 } else if (std.mem.eql(u8, a, "--sock") or std.mem.startsWith(u8, a, "--sock ")) {
116 i += 1;
117 // The flag and its value become ONE spelling — `--sock ` is 118 // The flag and its value become ONE spelling — `--sock ` is
118 // part of the grammar wall.zig reads, not a shape only argv 119 // part of the grammar wall.zig reads, not a shape only argv
119 // has. Two spellings of the same tile would be two parsers. 120 // has. Two spellings of the same tile would be two parsers.
120 const s = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i]}); 121 // wall owns the join so `mux wall` accepts the same two forms.
121 defer alloc.free(s); 122 const n = wall.spellingFromArgv(alloc, args, i) catch |err| switch (err) {
122 try addSpelling(alloc, &p.tiles, s); 123 error.MissingSockPath => return error.Usage,
124 else => |e| return e,
125 };
126 defer alloc.free(n.spelling);
127 i += n.consumed - 1;
128 try addSpelling(alloc, &p.tiles, n.spelling);
123 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { 129 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) {
124 i += 1; 130 i += 1;
125 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; 131 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage;
@@ -296,6 +302,16 @@ test "parse: three spellings become three tiles in argv order, port and key bind
296 try std.testing.expectEqualStrings("/k", r.key.?); 302 try std.testing.expectEqualStrings("/k", r.key.?);
297 } 303 }
298 304
305 test "parse: a quoted '--sock PATH#SESSION' is the same tile as the two-argument form" {
306 const alloc = std.testing.allocator;
307 // The wall file's own spelling, pasted straight onto the command line:
308 // muxweb used to refuse it while `mux wall` required it.
309 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null)).serve;
310 defer r.deinit(alloc);
311 try std.testing.expectEqual(@as(usize, 1), r.tiles.items.len);
312 try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r.tiles.items[0]);
313 }
314
299 test "parse: zero targets, bad flags, and flag-beats-env" { 315 test "parse: zero targets, bad flags, and flag-beats-env" {
300 const alloc = std.testing.allocator; 316 const alloc = std.testing.allocator;
301 // No targets is an empty argv wall, not a refusal: restore-from-file 317 // No targets is an empty argv wall, not a refusal: restore-from-file
test/e2e.sh
Old New
@@ -4364,10 +4364,12 @@ curl -s "$DWORIG/tiles" | grep -q "^\[{\"id\":0,\"label\":\"--sock $SOCK25#b\""
4364 curl -s "$DWORIG/tiles"; exit 1; } 4364 curl -s "$DWORIG/tiles"; exit 1; }
4365 4365
4366 # Restart WITH argv: the explicit override replaces the file rather than 4366 # Restart WITH argv: the explicit override replaces the file rather than
4367 # appending to it, and is itself saved as the next run's wall. 4367 # appending to it, and is itself saved as the next run's wall. The tile is
4368 # spelled as ONE quoted argument — the wall file's own spelling, handed
4369 # back to the binary that wrote it; the grep below is what makes it a pin.
4368 kill "$W4PID" 2>/dev/null || true 4370 kill "$W4PID" 2>/dev/null || true
4369 wait_pid_gone "$W4PID" "dyn wall: restored hub killed by tracked pid" 4371 wait_pid_gone "$W4PID" "dyn wall: restored hub killed by tracked pid"
4370 XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --sock "$SOCK25" --port "$WPORT4" > "$OUT.dwh3" 2>&1 & 4372 XDG_STATE_HOME="$DWSTATE" "$MUXWEB" "--sock $SOCK25" --port "$WPORT4" > "$OUT.dwh3" 2>&1 &
4371 W4PID=$! 4373 W4PID=$!
4372 wait_for "$OUT.dwh3" "serving" 10 || { 4374 wait_for "$OUT.dwh3" "serving" 10 || {
4373 echo "e2e FAIL: dyn wall: overriding hub never reported serving"; cat "$OUT.dwh3"; exit 1; } 4375 echo "e2e FAIL: dyn wall: overriding hub never reported serving"; cat "$OUT.dwh3"; exit 1; }
@@ -4401,6 +4403,10 @@ ok "the wall is runtime state: add, remove, reorder, restore, argv overrides"
4401 # stream — snapshot marker and live marker in one ordered repaint — so 4403 # stream — snapshot marker and live marker in one ordered repaint — so
4402 # every needle below is downstream of the cwa-pin match by construction 4404 # every needle below is downstream of the cwa-pin match by construction
4403 # (a's local-socket snapshot paints in well under 5s). 4405 # (a's local-socket snapshot paints in well under 5s).
4406 #
4407 # The two tiles are spelled DIFFERENTLY on purpose: `--sock PATH` as two
4408 # arguments (muxweb's dialect) and as one quoted spelling (the wall file's).
4409 # Both must reach the same tile, so both are pinned by this one leg.
4404 "$MUXD" run --sock "$SOCK26" --shell /bin/sh > "$OUT.cwall.d" 2>&1 & 4410 "$MUXD" run --sock "$SOCK26" --shell /bin/sh > "$OUT.cwall.d" 2>&1 &
4405 D23PID=$! 4411 D23PID=$!
4406 wait_sock "$SOCK26" "$OUT.cwall.d" "CLI wall daemon never bound" 4412 wait_sock "$SOCK26" "$OUT.cwall.d" "CLI wall daemon never bound"
@@ -4417,7 +4423,7 @@ wait_grid "$SOCK26" "cwb-pin" "CLI wall: session b's marker" b
4417 CWINJPID=$! 4423 CWINJPID=$!
4418 set +e 4424 set +e
4419 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.cwcap" --err "$OUT.cwcap.err" -- \ 4425 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.cwcap" --err "$OUT.cwcap.err" -- \
4420 "$MUX" wall "--sock $SOCK26#a" "--sock $SOCK26#b" > "$OUT.cwpc" 2>&1 <<'EOF' 4426 "$MUX" wall --sock "$SOCK26#a" "--sock $SOCK26#b" > "$OUT.cwpc" 2>&1 <<'EOF'
4421 expect cwa-pin 15000 4427 expect cwa-pin 15000
4422 expect cwb-pin 20000 4428 expect cwb-pin 20000
4423 expect cwlive-pin 5000 4429 expect cwlive-pin 5000