69be7cc3
feat: one wall, one argv dialect — both binaries take both --sock spellings
a73x 2026-08-19 17:17
Commit message
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 |