f5ffc31d
feat(web): TARGET#NAME names a tile's session; the browser appends it at attach
a73x 2026-08-15 12:21
Commit message
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 | })(); |