a73x

f5ffc31d

feat(web): TARGET#NAME names a tile's session; the browser appends it at attach

a73x   2026-08-15 12:21

Commit message
feat(web): TARGET#NAME names a tile's session; the browser appends it at attach

muxweb's TARGET grows an optional `#SESSION` suffix — split at the LAST
'#', since a session name can never hold one — and the tile's label keeps
the full spelling the user typed, so the wall says which session it is
showing without anyone decorating it. /tiles answers objects,
{"label":…,"session":…}.

The hub still never attaches: it is a byte pump, so the name travels to
the BROWSER, which appends it after the payload wasm_core built. That
keeps mux.js's single attach site single and leaves wasm_core untouched —
the name is transport dressing, not replica state. An empty session
appends nothing, so an unnamed tile sends exactly the pre-M18 bytes.
test/wsclient.zig, the headless twin, gains `attach C R [NAME]`.

Every spelling refuses an empty target at usage altitude, `--sock`
included: `muxweb --sock '#b'` names no socket, and failing at connect
instead would report the wrong layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

build.zig
Old New
@@ -246,7 +246,10 @@ const mod_table = [_]ModSpec{
246 // ---- layer 4 ---- 246 // ---- layer 4 ----
247 // The HOST tile's recipe comes from the same owner mux_main uses, and 247 // The HOST tile's recipe comes from the same owner mux_main uses, and
248 // sockpath is the sun_path bound its --sock tiles are refused against. 248 // sockpath is the sun_path bound its --sock tiles are refused against.
249 .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "xdg", "handoff", "sockpath" }, .quic_tests = true }, 249 // protocol is here for one function — validSessionName — so a TARGET#NAME
250 // tile is refused by the SAME rule `mux --session` and `muxa --session`
251 // use, rather than by a second spelling of "printable, no space".
252 .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "protocol", "xdg", "handoff", "sockpath" }, .quic_tests = true },
250 }; 253 };
251 254
252 /// Comptime row lookup. Every hand-written module name in this file goes 255 /// Comptime row lookup. Every hand-written module name in this file goes
src/webhub.zig
Old New
@@ -503,9 +503,9 @@ fn dialLoop(
503 /// One accepted connection, start to finish (M-web Task 7). Static 503 /// One accepted connection, start to finish (M-web Task 7). Static
504 /// requests loop for keep-alive; a WebSocket upgrade consumes the 504 /// requests loop for keep-alive; a WebSocket upgrade consumes the
505 /// connection into a tile pump and never returns to HTTP. 505 /// connection into a tile pump and never returns to HTTP.
506 /// `/tiles`: the runtime half the embedded page cannot know — the tile 506 /// `/tiles`: the runtime half the embedded page cannot know — one object
507 /// labels, in index order, as a JSON array. Escaping covers the two 507 /// per tile in index order, `{"label":…,"session":…}`. Escaping covers the
508 /// bytes JSON cannot carry raw in a string plus control chars; labels 508 /// two bytes JSON cannot carry raw in a string plus control chars; labels
509 /// are argv (hosts, paths), not hostile input, but a path with a quote 509 /// are argv (hosts, paths), not hostile input, but a path with a quote
510 /// in it must not break the page. 510 /// in it must not break the page.
511 /// 511 ///
@@ -514,20 +514,41 @@ fn dialLoop(
514 /// while muxa spells the short forms `\n`, `\r`, `\t`. Both are valid JSON 514 /// while muxa spells the short forms `\n`, `\r`, `\t`. Both are valid JSON
515 /// and parse identically, but the bytes differ, and each is pinned by its 515 /// and parse identically, but the bytes differ, and each is pinned by its
516 /// own test. Sharing one would rewrite one side's output for no gain. 516 /// own test. Sharing one would rewrite one side's output for no gain.
517 pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 { 517 fn appendJsonString(alloc: std.mem.Allocator, out: *std.ArrayList(u8), s: []const u8) !void {
518 try out.append(alloc, '"');
519 for (s) |c| switch (c) {
520 '"' => try out.appendSlice(alloc, "\\\""),
521 '\\' => try out.appendSlice(alloc, "\\\\"),
522 0x00...0x1f => try out.print(alloc, "\\u{x:0>4}", .{c}),
523 else => try out.append(alloc, c),
524 };
525 try out.append(alloc, '"');
526 }
527
528 /// `sessions` is parallel to `labels`, index for index — the caller builds
529 /// them in one pass, and /ws/<idx> indexes the same order. The session is a
530 /// field of its own rather than something the page digs back out of the
531 /// label: the label is the user's spelling, which is theirs to make
532 /// unparseable.
533 pub fn tilesJson(
534 alloc: std.mem.Allocator,
535 labels: []const []const u8,
536 sessions: []const []const u8,
537 ) ![]u8 {
538 std.debug.assert(labels.len == sessions.len);
518 var out: std.ArrayList(u8) = .empty; 539 var out: std.ArrayList(u8) = .empty;
519 errdefer out.deinit(alloc); 540 errdefer out.deinit(alloc);
520 try out.append(alloc, '['); 541 try out.append(alloc, '[');
521 for (labels, 0..) |label, i| { 542 for (labels, sessions, 0..) |label, session, i| {
522 if (i > 0) try out.append(alloc, ','); 543 if (i > 0) try out.append(alloc, ',');
523 try out.append(alloc, '"'); 544 try out.appendSlice(alloc, "{\"label\":");
524 for (label) |c| switch (c) { 545 // One helper for both strings: two copies of the escape loop is two
525 '"' => try out.appendSlice(alloc, "\\\""), 546 // things to keep in step, and the session's bytes reach the page by
526 '\\' => try out.appendSlice(alloc, "\\\\"), 547 // exactly the same road the label's do.
527 0x00...0x1f => try out.print(alloc, "\\u{x:0>4}", .{c}), 548 try appendJsonString(alloc, &out, label);
528 else => try out.append(alloc, c), 549 try out.appendSlice(alloc, ",\"session\":");
529 }; 550 try appendJsonString(alloc, &out, session);
530 try out.append(alloc, '"'); 551 try out.append(alloc, '}');
531 } 552 }
532 try out.append(alloc, ']'); 553 try out.append(alloc, ']');
533 return out.toOwnedSlice(alloc); 554 return out.toOwnedSlice(alloc);
@@ -539,6 +560,7 @@ pub fn serveConn(
539 port: u16, 560 port: u16,
540 targets: []const client.Target, 561 targets: []const client.Target,
541 labels: []const []const u8, 562 labels: []const []const u8,
563 sessions: []const []const u8,
542 assets: Assets, 564 assets: Assets,
543 ) void { 565 ) void {
544 defer stream.close(); 566 defer stream.close();
@@ -583,7 +605,7 @@ pub fn serveConn(
583 } 605 }
584 606
585 if (std.mem.eql(u8, path, "/tiles")) { 607 if (std.mem.eql(u8, path, "/tiles")) {
586 const json = tilesJson(alloc, labels) catch return; 608 const json = tilesJson(alloc, labels, sessions) catch return;
587 defer alloc.free(json); 609 defer alloc.free(json);
588 req.respond(json, .{ 610 req.respond(json, .{
589 .extra_headers = &.{ 611 .extra_headers = &.{
@@ -852,38 +874,67 @@ test "drain browser: the reader's OWN bytes decide, with or without a readable e
852 } 874 }
853 } 875 }
854 876
855 test "tiles json: order preserved, the unrepresentable bytes escaped" { 877 test "tiles json: label/session objects, order preserved" {
856 const alloc = std.testing.allocator; 878 const alloc = std.testing.allocator;
857 { 879 {
858 const j = try tilesJson(alloc, &.{}); 880 const j = try tilesJson(alloc, &.{}, &.{});
859 defer alloc.free(j); 881 defer alloc.free(j);
860 try std.testing.expectEqualStrings("[]", j); 882 try std.testing.expectEqualStrings("[]", j);
861 } 883 }
862 { 884 {
863 // Index order IS the tile order — /ws/<idx> indexes the same list. 885 // Index order IS the tile order — /ws/<idx> indexes the same list.
864 const j = try tilesJson(alloc, &.{ "box1", "box2", "box3" }); 886 // The session rides beside the label rather than inside it: the page
887 // shows one and attaches with the other.
888 const j = try tilesJson(alloc, &.{ "box1", "box2#b", "box3" }, &.{ "", "b", "c" });
865 defer alloc.free(j); 889 defer alloc.free(j);
866 try std.testing.expectEqualStrings("[\"box1\",\"box2\",\"box3\"]", j); 890 try std.testing.expectEqualStrings(
891 "[{\"label\":\"box1\",\"session\":\"\"}," ++
892 "{\"label\":\"box2#b\",\"session\":\"b\"}," ++
893 "{\"label\":\"box3\",\"session\":\"c\"}]",
894 j,
895 );
867 } 896 }
868 { 897 {
869 // A path with a quote in it must not break the page. 898 // A path with a quote in it must not break the page.
870 const j = try tilesJson(alloc, &.{"/tmp/we\"ird\\path"}); 899 const j = try tilesJson(alloc, &.{"/tmp/we\"ird\\path"}, &.{""});
871 defer alloc.free(j); 900 defer alloc.free(j);
872 try std.testing.expectEqualStrings("[\"/tmp/we\\\"ird\\\\path\"]", j); 901 try std.testing.expectEqualStrings(
902 "[{\"label\":\"/tmp/we\\\"ird\\\\path\",\"session\":\"\"}]",
903 j,
904 );
873 } 905 }
874 { 906 {
875 // Control bytes go to \u00XX, including the ones JSON has short 907 // Control bytes go to \u00XX, including the ones JSON has short
876 // spellings for — one rule, no table to get wrong. 908 // spellings for — one rule, no table to get wrong.
877 const j = try tilesJson(alloc, &.{"a\nb\tc\x00d\x1fe"}); 909 const j = try tilesJson(alloc, &.{"a\nb\tc\x00d\x1fe"}, &.{""});
878 defer alloc.free(j); 910 defer alloc.free(j);
879 try std.testing.expectEqualStrings("[\"a\\u000ab\\u0009c\\u0000d\\u001fe\"]", j); 911 try std.testing.expectEqualStrings(
912 "[{\"label\":\"a\\u000ab\\u0009c\\u0000d\\u001fe\",\"session\":\"\"}]",
913 j,
914 );
880 } 915 }
881 { 916 {
882 // Bytes above 0x7f pass through: labels are argv, and a UTF-8 917 // Bytes above 0x7f pass through: labels are argv, and a UTF-8
883 // hostname stays itself. 918 // hostname stays itself.
884 const j = try tilesJson(alloc, &.{ "", "héllo" }); 919 const j = try tilesJson(alloc, &.{ "", "héllo" }, &.{ "", "" });
920 defer alloc.free(j);
921 try std.testing.expectEqualStrings(
922 "[{\"label\":\"\",\"session\":\"\"},{\"label\":\"héllo\",\"session\":\"\"}]",
923 j,
924 );
925 }
926 {
927 // The session string gets the SAME escaping as the label, byte for
928 // byte — one helper, not two loops that could drift. parseArgs will
929 // never hand these through (validSessionName refuses every one of
930 // them), which is exactly why the escaping has to be pinned here:
931 // nothing downstream would catch it going wrong.
932 const j = try tilesJson(alloc, &.{"t"}, &.{"we\"ird\\\x01"});
885 defer alloc.free(j); 933 defer alloc.free(j);
886 try std.testing.expectEqualStrings("[\"\",\"héllo\"]", j); 934 try std.testing.expectEqualStrings(
935 "[{\"label\":\"t\",\"session\":\"we\\\"ird\\\\\\u0001\"}]",
936 j,
937 );
887 } 938 }
888 } 939 }
889 940
src/webhub_main.zig
Old New
@@ -3,20 +3,25 @@
3 //! WebSocket per tile, dialing each TARGET the way the mux CLI does. 3 //! WebSocket per tile, dialing each TARGET the way the mux CLI does.
4 //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), 4 //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff),
5 //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in 5 //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in
6 //! mux). The TARGET string is the tile's label; the tile list is argv — 6 //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile
7 //! no config file, per the standing non-goal. 7 //! attaches to, which is how one host becomes two tiles. The TARGET string
8 //! is the tile's label, suffix and all; the tile list is argv — no config
9 //! file, per the standing non-goal.
8 10
9 const std = @import("std"); 11 const std = @import("std");
10 const client = @import("client"); 12 const client = @import("client");
11 const webhub = @import("webhub"); 13 const webhub = @import("webhub");
14 const proto = @import("protocol");
12 const build_options = @import("build_options"); 15 const build_options = @import("build_options");
13 const xdg = @import("xdg"); 16 const xdg = @import("xdg");
14 const handoff = @import("handoff"); 17 const handoff = @import("handoff");
15 const sockpath = @import("sockpath"); 18 const sockpath = @import("sockpath");
16 19
17 const usage = 20 const usage =
18 \\usage: muxweb TARGET [TARGET ...] [--port N] 21 \\usage: muxweb TARGET[#SESSION] [TARGET ...] [--port N]
19 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] 22 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT]
23 \\ #SESSION names the daemon session the tile attaches to (default: the
24 \\ default session) — the same host twice, two sessions, two tiles
20 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key 25 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
21 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 26 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
22 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, 27 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only,
@@ -29,12 +34,50 @@ const usage =
29 /// client.Target needs an allocator and the environment, so the parse 34 /// client.Target needs an allocator and the environment, so the parse
30 /// records spellings and main resolves them — the same split mux_main 35 /// records spellings and main resolves them — the same split mux_main
31 /// uses, for the same testability reason. 36 /// uses, for the same testability reason.
32 const TileSpec = union(enum) { 37 const TileSpec = struct {
33 sock: []const u8, 38 /// The transport spelling with any `#NAME` suffix already taken off:
34 host: []const u8, 39 /// what the dial is built from.
35 quic: []const u8, 40 spec: Spec,
41 /// The argv string verbatim, `#NAME` and all. The user asked for
42 /// `host#b`, so that is the tile's name on screen — the wall says which
43 /// session each tile is showing without anyone decorating it, and the
44 /// quic tile no longer needs its label rebuilt from the pieces.
45 label: []const u8,
46 /// The session to attach to, "" for the default — the empty-name-means-
47 /// default rule the whole protocol already runs on. Only the browser
48 /// ever sends it: the hub is a byte pump and never attaches.
49 session: []const u8 = "",
50
51 const Spec = union(enum) {
52 sock: []const u8,
53 host: []const u8,
54 quic: []const u8,
55 };
36 }; 56 };
37 57
58 /// Split `TARGET#NAME`. At the LAST '#', because a session name can never
59 /// hold one (protocol.validSessionName refuses it precisely so this split
60 /// stays decidable) — so any earlier '#' belongs to the target's own
61 /// spelling, a path or a hostname that happens to contain one.
62 ///
63 /// A bad name is refused HERE, at usage-error altitude, rather than
64 /// downstream where it would arrive as a rejected attach in one tile with
65 /// nothing on the hub's console to explain it. The message names the tile:
66 /// with several targets on the line, `usage` alone would not say which.
67 fn splitSession(arg: []const u8) ParseError!struct { spec: []const u8, session: []const u8 } {
68 const hash = std.mem.lastIndexOfScalar(u8, arg, '#') orelse
69 return .{ .spec = arg, .session = "" };
70 const name = arg[hash + 1 ..];
71 if (!proto.validSessionName(name)) {
72 std.debug.print(
73 "muxweb: tile {s}: bad session name after '#' (printable ASCII, no space, no '/', 1-{d} bytes)\n",
74 .{ arg, proto.session_name_max },
75 );
76 return error.Usage;
77 }
78 return .{ .spec = arg[0..hash], .session = name };
79 }
80
38 const Parsed = struct { 81 const Parsed = struct {
39 tiles: std.ArrayList(TileSpec), 82 tiles: std.ArrayList(TileSpec),
40 port: u16 = webhub.default_port, 83 port: u16 = webhub.default_port,
@@ -78,7 +121,19 @@ fn parseArgs(
78 return .version; 121 return .version;
79 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { 122 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
80 i += 1; 123 i += 1;
81 try p.tiles.append(alloc, .{ .sock = args[i] }); 124 // The `#NAME` split is on the TARGET, and --sock's target is its
125 // VALUE, not the flag.
126 const s = try splitSession(args[i]);
127 // Same refusal the bare-host and quic:// arms make: `--sock '#b'`
128 // splits to an empty path, and an empty path is a usage mistake,
129 // not something to carry to a connect that fails later and
130 // further from the typo.
131 if (s.spec.len == 0) return error.Usage;
132 try p.tiles.append(alloc, .{
133 .spec = .{ .sock = s.spec },
134 .label = args[i],
135 .session = s.session,
136 });
82 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { 137 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) {
83 i += 1; 138 i += 1;
84 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; 139 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage;
@@ -96,11 +151,22 @@ fn parseArgs(
96 if (n == 0) return error.Usage; 151 if (n == 0) return error.Usage;
97 p.idle_ms = n; 152 p.idle_ms = n;
98 } else if (std.mem.startsWith(u8, a, "quic://")) { 153 } else if (std.mem.startsWith(u8, a, "quic://")) {
99 const hp = a["quic://".len..]; 154 const s = try splitSession(a);
155 const hp = s.spec["quic://".len..];
100 if (hp.len == 0) return error.Usage; 156 if (hp.len == 0) return error.Usage;
101 try p.tiles.append(alloc, .{ .quic = hp }); 157 try p.tiles.append(alloc, .{
158 .spec = .{ .quic = hp },
159 .label = a,
160 .session = s.session,
161 });
102 } else if (a.len > 0 and a[0] != '-') { 162 } else if (a.len > 0 and a[0] != '-') {
103 try p.tiles.append(alloc, .{ .host = a }); 163 const s = try splitSession(a);
164 if (s.spec.len == 0) return error.Usage;
165 try p.tiles.append(alloc, .{
166 .spec = .{ .host = s.spec },
167 .label = a,
168 .session = s.session,
169 });
104 } else { 170 } else {
105 return error.Usage; 171 return error.Usage;
106 } 172 }
@@ -155,48 +221,53 @@ pub fn main() !u8 {
155 221
156 var targets: std.ArrayList(client.Target) = .empty; 222 var targets: std.ArrayList(client.Target) = .empty;
157 var labels: std.ArrayList([]const u8) = .empty; 223 var labels: std.ArrayList([]const u8) = .empty;
224 // Parallel to labels and targets, index for index: /tiles hands the
225 // browser both, and the browser is the one that attaches.
226 var sessions: std.ArrayList([]const u8) = .empty;
158 227
159 for (parsed.tiles.items) |spec| switch (spec) { 228 for (parsed.tiles.items) |tile| {
160 .sock => |path| { 229 // The label is the argv spelling verbatim, `#NAME` included, for
161 if (path.len > sockpath.max_sun_path) { 230 // every spelling — including quic://, which used to rebuild its own.
162 std.debug.print("muxweb: socket path too long ({d} bytes, max {d}): {s}\n", .{ 231 try labels.append(arena, tile.label);
163 path.len, sockpath.max_sun_path, path, 232 try sessions.append(arena, tile.session);
164 }); 233 switch (tile.spec) {
165 return 2; 234 .sock => |path| {
166 } 235 if (path.len > sockpath.max_sun_path) {
167 try targets.append(arena, .{ .sock = path }); 236 std.debug.print("muxweb: socket path too long ({d} bytes, max {d}): {s}\n", .{
168 try labels.append(arena, path); 237 path.len, sockpath.max_sun_path, path,
169 }, 238 });
170 .host => |h| {
171 const r = try handoff.recipeFor(arena, h);
172 try targets.append(arena, .{ .hand = .{
173 .host = h,
174 .ssh_cmd = r.ssh_cmd,
175 .cache_path = r.cache_path,
176 .idle_ms = parsed.idle_ms,
177 } });
178 try labels.append(arena, h);
179 },
180 .quic => |hp| {
181 const key_path = switch (try xdg.resolveKeyPath(arena, parsed.key)) {
182 .given, .default => |p| p,
183 .missing => |p| {
184 std.debug.print(
185 "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
186 .{ hp, p },
187 );
188 return 2; 239 return 2;
189 }, 240 }
190 }; 241 try targets.append(arena, .{ .sock = path });
191 try targets.append(arena, .{ .quic = .{ 242 },
192 .host_port = hp, 243 .host => |h| {
193 .key_path = key_path, 244 const r = try handoff.recipeFor(arena, h);
194 .idle_ms = parsed.idle_ms, 245 try targets.append(arena, .{ .hand = .{
195 } }); 246 .host = h,
196 const label = try std.fmt.allocPrint(arena, "quic://{s}", .{hp}); 247 .ssh_cmd = r.ssh_cmd,
197 try labels.append(arena, label); 248 .cache_path = r.cache_path,
198 }, 249 .idle_ms = parsed.idle_ms,
199 }; 250 } });
251 },
252 .quic => |hp| {
253 const key_path = switch (try xdg.resolveKeyPath(arena, parsed.key)) {
254 .given, .default => |p| p,
255 .missing => |p| {
256 std.debug.print(
257 "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
258 .{ hp, p },
259 );
260 return 2;
261 },
262 };
263 try targets.append(arena, .{ .quic = .{
264 .host_port = hp,
265 .key_path = key_path,
266 .idle_ms = parsed.idle_ms,
267 } });
268 },
269 }
270 }
200 271
201 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; 272 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable;
202 var listener = addr.listen(.{ .reuse_address = true }) catch |err| { 273 var listener = addr.listen(.{ .reuse_address = true }) catch |err| {
@@ -224,7 +295,7 @@ pub fn main() !u8 {
224 while (true) { 295 while (true) {
225 const conn = listener.accept() catch continue; 296 const conn = listener.accept() catch continue;
226 const th = std.Thread.spawn(.{}, webhub.serveConn, .{ 297 const th = std.Thread.spawn(.{}, webhub.serveConn, .{
227 alloc, conn.stream, parsed.port, targets.items, labels.items, assets, 298 alloc, conn.stream, parsed.port, targets.items, labels.items, sessions.items, assets,
228 }) catch { 299 }) catch {
229 conn.stream.close(); 300 conn.stream.close();
230 continue; 301 continue;
@@ -248,9 +319,9 @@ test "parse: three spellings become three tiles in argv order, port and key bind
248 var r = (try parseArgs(alloc, &args, null)).serve; 319 var r = (try parseArgs(alloc, &args, null)).serve;
249 defer r.deinit(alloc); 320 defer r.deinit(alloc);
250 try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); 321 try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len);
251 try std.testing.expectEqualStrings("box1", r.tiles.items[0].host); 322 try std.testing.expectEqualStrings("box1", r.tiles.items[0].spec.host);
252 try std.testing.expectEqualStrings("/tmp/a.sock", r.tiles.items[1].sock); 323 try std.testing.expectEqualStrings("/tmp/a.sock", r.tiles.items[1].spec.sock);
253 try std.testing.expectEqualStrings("h:4433", r.tiles.items[2].quic); 324 try std.testing.expectEqualStrings("h:4433", r.tiles.items[2].spec.quic);
254 try std.testing.expectEqual(@as(u16, 8000), r.port); 325 try std.testing.expectEqual(@as(u16, 8000), r.port);
255 try std.testing.expectEqualStrings("/k", r.key.?); 326 try std.testing.expectEqualStrings("/k", r.key.?);
256 } 327 }
@@ -271,6 +342,10 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
271 // load-bearing rather than theoretical. 342 // load-bearing rather than theoretical.
272 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--wat" }, null)); 343 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--wat" }, null));
273 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "quic://" }, null)); 344 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "quic://" }, null));
345 // A `#NAME` that ate the whole target: all three transport spellings
346 // refuse an empty spec, so `--sock '#b'` fails at usage altitude rather
347 // than at a connect to the empty path, far from the typo.
348 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--sock", "#b" }, null));
274 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--quic-idle-ms", "0" }, null)); 349 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--quic-idle-ms", "0" }, null));
275 // Port 0 means "kernel, you pick" — but the hub announces the port it 350 // Port 0 means "kernel, you pick" — but the hub announces the port it
276 // was asked for, so the door it prints is not the door it opened. 351 // was asked for, so the door it prints is not the door it opened.
@@ -301,6 +376,71 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
301 } 376 }
302 } 377 }
303 378
379 test "tiles: #NAME splits off the session; the label keeps the full spelling" {
380 const alloc = std.testing.allocator;
381 const args = [_][:0]const u8{
382 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c",
383 };
384 var r = (try parseArgs(alloc, &args, null)).serve;
385 defer r.deinit(alloc);
386 try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len);
387
388 // Every spelling splits the same way, and the label is the argv string
389 // verbatim: the user asked for `host#b`, so that is the tile's name on
390 // screen — the wall says which session it is showing without anyone
391 // having to decorate it.
392 try std.testing.expectEqualStrings("host", r.tiles.items[0].spec.host);
393 try std.testing.expectEqualStrings("b", r.tiles.items[0].session);
394 try std.testing.expectEqualStrings("host#b", r.tiles.items[0].label);
395
396 try std.testing.expectEqualStrings("h:1", r.tiles.items[1].spec.quic);
397 try std.testing.expectEqualStrings("b", r.tiles.items[1].session);
398 try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1].label);
399
400 // The split is on the TARGET, and --sock's target is its VALUE.
401 try std.testing.expectEqualStrings("/tmp/x", r.tiles.items[2].spec.sock);
402 try std.testing.expectEqualStrings("b", r.tiles.items[2].session);
403 try std.testing.expectEqualStrings("/tmp/x#b", r.tiles.items[2].label);
404
405 // No '#' is the default session — the empty name, which is exactly the
406 // pre-M18 bytes on the wire.
407 try std.testing.expectEqualStrings("plainhost", r.tiles.items[3].spec.host);
408 try std.testing.expectEqualStrings("", r.tiles.items[3].session);
409 try std.testing.expectEqualStrings("plainhost", r.tiles.items[3].label);
410
411 // The LAST '#' wins: a name can never hold one (validSessionName), so
412 // anything earlier belongs to the target's own spelling.
413 try std.testing.expectEqualStrings("a#b", r.tiles.items[4].spec.host);
414 try std.testing.expectEqualStrings("c", r.tiles.items[4].session);
415 try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4].label);
416 }
417
418 test "tiles: a bad session name after # is a usage error" {
419 const alloc = std.testing.allocator;
420 // These refusals print a line naming the tile before returning, so the
421 // muxweb: lines in this test's output are the point, not noise: with
422 // several tiles on the line, `usage` alone would not say which one.
423 try std.testing.expectError(
424 error.Usage,
425 parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null),
426 );
427 // A bare trailing '#' asks for the empty name. It is the default ON THE
428 // WIRE but not a name a user may spell, so typing it is a mistake.
429 try std.testing.expectError(
430 error.Usage,
431 parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#" }, null),
432 );
433 // Same rule through --sock's value and through quic://.
434 try std.testing.expectError(
435 error.Usage,
436 parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock", "/tmp/x#bad name" }, null),
437 );
438 try std.testing.expectError(
439 error.Usage,
440 parseArgs(alloc, &[_][:0]const u8{ "muxweb", "quic://h:1#a/b" }, null),
441 );
442 }
443
304 test "version short-circuits everything else on the line" { 444 test "version short-circuits everything else on the line" {
305 const alloc = std.testing.allocator; 445 const alloc = std.testing.allocator;
306 const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null); 446 const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null);
test/wsclient.zig
Old New
@@ -7,8 +7,9 @@
7 //! decodeEscapes, expect deadlines in ms, exit codes 2/3/4. 7 //! decodeEscapes, expect deadlines in ms, exit codes 2/3/4.
8 //! 8 //!
9 //! Verbs: 9 //! Verbs:
10 //! attach C R send an attach quoting the replica's resume args 10 //! attach C R [NAME] send an attach quoting the replica's resume args,
11 //! attachfresh C R same, quoting (0,0) 11 //! to session NAME (default: the default session)
12 //! attachfresh C R [NAME] same, quoting (0,0)
12 //! send BYTES input frame (escapes decoded) 13 //! send BYTES input frame (escapes decoded)
13 //! resize C R resize frame 14 //! resize C R resize frame
14 //! expectgrid NEEDLE MS poll the replica's plain dump for NEEDLE 15 //! expectgrid NEEDLE MS poll the replica's plain dump for NEEDLE
@@ -125,6 +126,12 @@ const Client = struct {
125 /// fixture ever sends quotes THIS, never the grid — see sendAttach. 126 /// fixture ever sends quotes THIS, never the grid — see sendAttach.
126 att_cols: u16 = 0, 127 att_cols: u16 = 0,
127 att_rows: u16 = 0, 128 att_rows: u16 = 0,
129 /// The session the last script-level `attach` named, "" for the default.
130 /// Held for the same reason the size is: a re-attach this fixture sends
131 /// on its own (the resync path) has to land on the SAME session, or the
132 /// scenario would silently start diffing a different terminal.
133 att_name: [proto.session_name_max]u8 = @splat(0),
134 att_name_len: usize = 0,
128 135
129 fn sendMessage(self: *Client, payload: []const u8) void { 136 fn sendMessage(self: *Client, payload: []const u8) void {
130 self.sendRaw(0x82, payload); // FIN | binary 137 self.sendRaw(0x82, payload); // FIN | binary
@@ -163,12 +170,23 @@ const Client = struct {
163 /// 170 ///
164 /// `fresh` quotes (0,0) instead of the replica's resume coordinates — 171 /// `fresh` quotes (0,0) instead of the replica's resume coordinates —
165 /// what a resync needs, since there the replica is the suspect part. 172 /// what a resync needs, since there the replica is the suspect part.
166 fn sendAttach(self: *Client, cols: u16, rows: u16, fresh: bool) void { 173 ///
174 /// `name` is the session, "" for the default, and it goes on the wire
175 /// the way mux.js puts it there: bytes appended after the fixed 20,
176 /// nothing the replica ever sees. An empty name appends nothing, so the
177 /// unnamed attach is byte-for-byte the pre-M18 one.
178 fn sendAttach(self: *Client, cols: u16, rows: u16, fresh: bool, name: []const u8) void {
167 self.att_cols = cols; 179 self.att_cols = cols;
168 self.att_rows = rows; 180 self.att_rows = rows;
181 // copyForwards, not @memcpy: the resync re-attach passes THIS field
182 // back in as the argument, and @memcpy calls a source that aliases
183 // the destination undefined behaviour (it panics on it in Debug).
184 std.mem.copyForwards(u8, self.att_name[0..name.len], name);
185 self.att_name_len = name.len;
169 const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.rep.attachArgs(); 186 const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.rep.attachArgs();
170 const att = proto.encodeAttach(cols, rows, q.have_seq, q.have_epoch); 187 var buf: [proto.attach_max_len]u8 = undefined;
171 self.sendFrame(@intFromEnum(proto.MsgType.attach), &att); 188 const att = proto.encodeAttachNamed(&buf, cols, rows, q.have_seq, q.have_epoch, name);
189 self.sendFrame(@intFromEnum(proto.MsgType.attach), att);
172 } 190 }
173 191
174 /// Pump whatever is on the socket into the reader and apply every 192 /// Pump whatever is on the socket into the reader and apply every
@@ -237,8 +255,9 @@ const Client = struct {
237 if (applied == .resync) { 255 if (applied == .resync) {
238 // Mirror the browser: a garbled delta re-attaches fresh, 256 // Mirror the browser: a garbled delta re-attaches fresh,
239 // at the TILE's size — never at rep.grid, which is the 257 // at the TILE's size — never at rep.grid, which is the
240 // authoritative grid this tile is not allowed to move. 258 // authoritative grid this tile is not allowed to move —
241 self.sendAttach(self.att_cols, self.att_rows, true); 259 // and to the SAME session the script named.
260 self.sendAttach(self.att_cols, self.att_rows, true, self.att_name[0..self.att_name_len]);
242 } 261 }
243 } 262 }
244 // Everything else (exit_status, pty_mode, scrollback) is visible 263 // Everything else (exit_status, pty_mode, scrollback) is visible
@@ -404,11 +423,16 @@ pub fn main() !void {
404 var it = std.mem.tokenizeScalar(u8, rest, ' '); 423 var it = std.mem.tokenizeScalar(u8, rest, ' ');
405 const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{}); 424 const cols = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{});
406 const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{}); 425 const rows = std.fmt.parseInt(u16, it.next() orelse fatal(EXIT_USAGE, "attach C R", .{}), 10) catch fatal(EXIT_USAGE, "attach C R", .{});
407 // Exactly two, like ptyclient's resize: a third token means the 426 // The optional third token is the session; absent means the
408 // operator meant something this verb does not do, and silently 427 // default, the empty name. Still no fourth: a stray token means
409 // dropping it is how a scenario ends up asserting nothing. 428 // the operator meant something this verb does not do, and
410 if (it.next() != null) fatal(EXIT_USAGE, "attach C R takes exactly two arguments", .{}); 429 // silently dropping it is how a scenario ends up asserting
411 cl.sendAttach(cols, rows, std.mem.eql(u8, verb, "attachfresh")); 430 // nothing (ptyclient's resize, same rule).
431 const name = it.next() orelse "";
432 if (name.len > 0 and !proto.validSessionName(name))
433 fatal(EXIT_USAGE, "attach C R [NAME]: bad session name {s}", .{name});
434 if (it.next() != null) fatal(EXIT_USAGE, "attach C R [NAME] takes at most three arguments", .{});
435 cl.sendAttach(cols, rows, std.mem.eql(u8, verb, "attachfresh"), name);
412 } else if (std.mem.eql(u8, verb, "send")) { 436 } else if (std.mem.eql(u8, verb, "send")) {
413 const bytes = decodeEscapes(alloc, rest) catch fatal(EXIT_USAGE, "bad escape in send", .{}); 437 const bytes = decodeEscapes(alloc, rest) catch fatal(EXIT_USAGE, "bad escape in send", .{});
414 defer alloc.free(bytes); 438 defer alloc.free(bytes);
@@ -566,7 +590,10 @@ test "the resync re-attach quotes the TILE's size, never the grid it learned" {
566 var cl = Client{ .alloc = alloc, .sock = fds[1], .rep = Replica.init(alloc, eng) }; 590 var cl = Client{ .alloc = alloc, .sock = fds[1], .rep = Replica.init(alloc, eng) };
567 defer cl.reader.buf.deinit(alloc); 591 defer cl.reader.buf.deinit(alloc);
568 592
569 cl.sendAttach(1, 1, false); 593 // Named, so the resync's re-attach is pinned to land on the same
594 // session as well as the same size: a tile that healed onto the default
595 // session would be diffing a different terminal from then on.
596 cl.sendAttach(1, 1, false, "b");
570 597
571 // The daemon's answer: a unicast snapshot carrying the true grid. 598 // The daemon's answer: a unicast snapshot carrying the true grid.
572 var snap: std.ArrayList(u8) = .empty; 599 var snap: std.ArrayList(u8) = .empty;
@@ -630,6 +657,8 @@ test "the resync re-attach quotes the TILE's size, never the grid it learned" {
630 // ...and fresh: what we hold is what was garbled. 657 // ...and fresh: what we hold is what was garbled.
631 try std.testing.expectEqual(@as(u64, 0), last.have_seq); 658 try std.testing.expectEqual(@as(u64, 0), last.have_seq);
632 try std.testing.expectEqual(@as(u64, 0), last.have_epoch); 659 try std.testing.expectEqual(@as(u64, 0), last.have_epoch);
660 // ...and still on the session the script named.
661 try std.testing.expectEqualStrings("b", last.name);
633 } 662 }
634 663
635 test "the dump this exits with is the daemon's own dump format" { 664 test "the dump this exits with is the daemon's own dump format" {
web/mux.js
Old New
@@ -87,9 +87,13 @@ const METRICS = (() => {
87 let compiledCore = null; // one compile, one instance per tile 87 let compiledCore = null; // one compile, one instance per tile
88 88
89 class Tile { 89 class Tile {
90 constructor(idx, label, wallEl) { 90 constructor(idx, label, wallEl, session) {
91 this.idx = idx; 91 this.idx = idx;
92 this.label = label; 92 this.label = label;
93 // The daemon session this tile attaches to, '' for the default. It is
94 // the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug
95 // back out of the label — see sendAttach for what happens to it.
96 this.session = session || '';
93 this.zoomed = false; 97 this.zoomed = false;
94 this.scrollPages = 0; 98 this.scrollPages = 0;
95 this.gotState = false; // JS mirror of the CLI's state_since_attach 99 this.gotState = false; // JS mirror of the CLI's state_since_attach
@@ -255,7 +259,24 @@ class Tile {
255 const cols = this.zoomed ? this.zoomCols() : 1; 259 const cols = this.zoomed ? this.zoomCols() : 1;
256 const rows = this.zoomed ? this.zoomRows() : 1; 260 const rows = this.zoomed ? this.zoomRows() : 1;
257 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); 261 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
258 if (n > 0) { this.gotState = false; this.sendFrame(MSG.attach, this.outBytes()); } 262 if (n > 0) {
263 // The session name is transport dressing, appended AFTER the payload
264 // the replica built for itself — which is why wasm_core knows nothing
265 // about it. Everything past the attach's fixed 20 bytes is the name
266 // (protocol.encodeAttachNamed does exactly this append), so a tile
267 // with no session sends exactly the bytes it sent before M18: the
268 // empty name IS the default session, on the wire and here.
269 // outBytes() already copies out of wasm memory, so the join below is
270 // reading a buffer nothing else can move under it.
271 let payload = this.outBytes();
272 if (this.session) {
273 const name = new TextEncoder().encode(this.session);
274 const joined = new Uint8Array(payload.length + name.length);
275 joined.set(payload); joined.set(name, payload.length);
276 payload = joined;
277 }
278 this.gotState = false; this.sendFrame(MSG.attach, payload);
279 }
259 } 280 }
260 sendKey(keyId, cp, mods) { 281 sendKey(keyId, cp, mods) {
261 const n = this.core.mux_key_encode(keyId, cp, mods); 282 const n = this.core.mux_key_encode(keyId, cp, mods);
@@ -687,17 +708,20 @@ window.addEventListener('resize', () => {
687 // --- boot --- 708 // --- boot ---
688 (async function boot() { 709 (async function boot() {
689 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); 710 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
690 const labels = await (await fetch('/tiles')).json(); 711 // Each entry is {label, session}: the label is what the tile calls
712 // itself, the session is what it attaches to. Two entries can name the
713 // same host and differ only in the session — that is the whole point.
714 const tilesCfg = await (await fetch('/tiles')).json();
691 const wall = document.getElementById('wall'); 715 const wall = document.getElementById('wall');
692 for (let i = 0; i < labels.length; i++) { 716 for (let i = 0; i < tilesCfg.length; i++) {
693 const tile = new Tile(i, labels[i], wall); 717 const tile = new Tile(i, tilesCfg[i].label, wall, tilesCfg[i].session);
694 tiles.push(tile); 718 tiles.push(tile);
695 // start() is async: instantiate or mux_init can fail, and an unhandled 719 // start() is async: instantiate or mux_init can fail, and an unhandled
696 // rejection left the tile stuck on 'connecting' with the reason only 720 // rejection left the tile stuck on 'connecting' with the reason only
697 // in the console's rejection noise. 721 // in the console's rejection noise.
698 tile.start().catch((err) => { 722 tile.start().catch((err) => {
699 tile.setStatus('gone', 'gone'); 723 tile.setStatus('gone', 'gone');
700 console.error(`mux tile ${i} (${labels[i]}): start failed`, err); 724 console.error(`mux tile ${i} (${tilesCfg[i].label}): start failed`, err);
701 }); 725 });
702 } 726 }
703 })(); 727 })();