a73x

83c02b92

feat: muxweb runtime wall — POST/DELETE/PUT /tiles, argv seeds or file restores

a73x   2026-08-18 18:00

Commit message
feat: muxweb runtime wall — POST/DELETE/PUT /tiles, argv seeds or file restores

build.zig
Old New
@@ -239,7 +239,9 @@ const mod_table = [_]ModSpec{
239 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route 239 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
240 // table, WS endpoint naming. Assets are injected (the exe root 240 // table, WS endpoint naming. Assets are injected (the exe root
241 // @embedFiles them), so its tests build no artifacts. 241 // @embedFiles them), so its tests build no artifacts.
242 .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg" }, .quic_tests = true }, 242 // sockpath is the sun_path bound a `--sock` tile is refused against —
243 // the check argv used to make before the Hub owned resolution.
244 .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true },
243 // sockpath is the sun_path bound only; the client binds no socket itself. 245 // sockpath is the sun_path bound only; the client binds no socket itself.
244 // protocol is the session-name validator alone (validSessionName): a bad 246 // protocol is the session-name validator alone (validSessionName): a bad
245 // --session has to be a usage error here, at parse, not bytes some 247 // --session has to be a usage error here, at parse, not bytes some
@@ -253,12 +255,12 @@ const mod_table = [_]ModSpec{
253 // daemon itself never touches. 255 // daemon itself never touches.
254 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 256 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
255 // ---- layer 4 ---- 257 // ---- layer 4 ----
256 // The HOST tile's recipe comes from the same owner mux_main uses, and 258 // wall owns the spelling grammar and the state file, so argv is parsed
257 // sockpath is the sun_path bound its --sock tiles are refused against. 259 // by the SAME rules the page's POST /tiles and the restored file are —
258 // protocol is here for one function — validSessionName — so a TARGET#NAME 260 // one grammar, not three. Resolution itself now lives in the Hub, so
259 // tile is refused by the SAME rule `mux --session` and `muxa --session` 261 // handoff/protocol left with it; sockpath stays for the one startup
260 // use, rather than by a second spelling of "printable, no space". 262 // message that names the sun_path bound.
261 .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "protocol", "xdg", "handoff", "sockpath" }, .quic_tests = true }, 263 .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true },
262 }; 264 };
263 265
264 /// Comptime row lookup. Every hand-written module name in this file goes 266 /// Comptime row lookup. Every hand-written module name in this file goes
src/webhub.zig
Old New
@@ -15,6 +15,7 @@ const client = @import("client");
15 const wall = @import("wall"); 15 const wall = @import("wall");
16 const handoff = @import("handoff"); 16 const handoff = @import("handoff");
17 const xdg = @import("xdg"); 17 const xdg = @import("xdg");
18 const sockpath = @import("sockpath");
18 19
19 pub const default_port: u16 = 7681; 20 pub const default_port: u16 = 7681;
20 21
@@ -71,9 +72,13 @@ const HubTile = struct {
71 }; 72 };
72 73
73 /// What a spelling can fail to become. `MissingKey` is a `quic://` entry 74 /// What a spelling can fail to become. `MissingKey` is a `quic://` entry
74 /// with no key to prove itself with; `PersistFailed` is a mutation the 75 /// with no key to prove itself with; `SockPathTooLong` is a path sun_path
75 /// wall file did not accept. 76 /// cannot hold; `PersistFailed` is a mutation the wall file did not accept.
76 pub const AddError = wall.ParseError || error{ MissingKey, OutOfMemory, PersistFailed }; 77 pub const AddError = wall.ParseError || ResolveError || error{PersistFailed};
78
79 /// The half of AddError a spelling can fail at resolution time — shared
80 /// with `Hub.init`, which resolves the whole wall before serving.
81 pub const ResolveError = error{ MissingKey, SockPathTooLong, OutOfMemory };
77 82
78 /// Spelling → client.Target. The same resolution argv gets at startup, 83 /// Spelling → client.Target. The same resolution argv gets at startup,
79 /// so a tile added at runtime means exactly what one typed on the command 84 /// so a tile added at runtime means exactly what one typed on the command
@@ -83,7 +88,7 @@ fn resolveTile(
83 spelling: []const u8, 88 spelling: []const u8,
84 key: ?[]const u8, 89 key: ?[]const u8,
85 idle_ms: u32, 90 idle_ms: u32,
86 ) (wall.ParseError || error{ MissingKey, OutOfMemory })!struct { 91 ) (wall.ParseError || ResolveError)!struct {
87 target: client.Target, 92 target: client.Target,
88 label: []const u8, 93 label: []const u8,
89 session: []const u8, 94 session: []const u8,
@@ -94,7 +99,15 @@ fn resolveTile(
94 const label = try arena.dupe(u8, spelling); 99 const label = try arena.dupe(u8, spelling);
95 const session = try arena.dupe(u8, p.session); 100 const session = try arena.dupe(u8, p.session);
96 const target: client.Target = switch (p.spec) { 101 const target: client.Target = switch (p.spec) {
97 .sock => |path| .{ .sock = try arena.dupe(u8, path) }, 102 // sun_path is a fixed array in the kernel's struct: a longer path
103 // cannot be dialed at all, so it is refused here rather than at a
104 // connect that fails with a truncated name nobody typed. muxweb's
105 // argv path made this check before the Hub existed; a POSTed
106 // spelling gets the same answer.
107 .sock => |path| if (path.len > sockpath.max_sun_path)
108 return error.SockPathTooLong
109 else
110 .{ .sock = try arena.dupe(u8, path) },
98 .host => |h| blk: { 111 .host => |h| blk: {
99 const hd = try arena.dupe(u8, h); 112 const hd = try arena.dupe(u8, h);
100 const r = try handoff.recipeFor(arena, hd); 113 const r = try handoff.recipeFor(arena, hd);
@@ -266,13 +279,16 @@ pub const Hub = struct {
266 return tile.id; 279 return tile.id;
267 } 280 }
268 281
269 /// False for an id that is already gone — a second device removing 282 /// `UnknownId` for an id that is already gone — a second device
270 /// the same tile is a race, not an error. 283 /// removing the same tile is a race the caller may ignore (404), not a
271 pub fn removeTile(self: *Hub, id: u32) bool { 284 /// failure. `PersistFailed` says the tile IS gone from this hub and the
285 /// wall file disagrees, which the user must be told: silence there
286 /// means the tile reappears at the next start with no explanation.
287 pub fn removeTile(self: *Hub, id: u32) error{ UnknownId, PersistFailed }!void {
272 self.mutex.lock(); 288 self.mutex.lock();
273 defer self.mutex.unlock(); 289 defer self.mutex.unlock();
274 290
275 const idx = self.indexOf(id) orelse return false; 291 const idx = self.indexOf(id) orelse return error.UnknownId;
276 var tile = self.tiles.orderedRemove(idx); 292 var tile = self.tiles.orderedRemove(idx);
277 // Wake the pump before freeing anything it might still be reading 293 // Wake the pump before freeing anything it might still be reading
278 // for: shutdown unblocks its recv, and it unregisters the fd 294 // for: shutdown unblocks its recv, and it unregisters the fd
@@ -286,14 +302,10 @@ pub const Hub = struct {
286 tile.arena.deinit(); 302 tile.arena.deinit();
287 // Same index, by construction: tiles is parallel to the wall. 303 // Same index, by construction: tiles is parallel to the wall.
288 self.wall_state.remove(self.alloc, idx); 304 self.wall_state.remove(self.alloc, idx);
289 // A bool cannot carry PersistFailed. Say it on stderr rather than 305 // No rollback, unlike addTile: the tile's arena is already freed and
290 // swallow it — the tile IS gone from this hub either way, and a 306 // its pump already woken, so there is nothing left to put back. The
291 // wall file that disagrees is exactly what the user needs told. 307 // removal stands and the caller is told the file did not take it.
292 self.persist() catch std.debug.print( 308 return self.persist();
293 "muxweb: wall not saved after removing tile {d}\n",
294 .{id},
295 );
296 return true;
297 } 309 }
298 310
299 /// `ids` must name every live tile exactly once — a browser working 311 /// `ids` must name every live tile exactly once — a browser working
@@ -335,21 +347,24 @@ pub const Hub = struct {
335 } 347 }
336 348
337 /// The pump's copy of the target, in the pump's own arena, plus the 349 /// The pump's copy of the target, in the pump's own arena, plus the
338 /// fd registration that lets removeTile reach it. Null when the id is 350 /// fd registration that lets removeTile reach it. UnknownId when the
339 /// gone — a browser can always dial a tile another device just removed. 351 /// id is gone — a browser can always dial a tile another device just
352 /// removed — kept distinct from OutOfMemory so the HTTP layer can
353 /// answer 404 for one and 500 for the other instead of folding a
354 /// memory failure into "no such tile".
340 pub fn checkoutTarget( 355 pub fn checkoutTarget(
341 self: *Hub, 356 self: *Hub,
342 id: u32, 357 id: u32,
343 ws_fd: std.posix.fd_t, 358 ws_fd: std.posix.fd_t,
344 arena: std.mem.Allocator, 359 arena: std.mem.Allocator,
345 ) ?client.Target { 360 ) error{ UnknownId, OutOfMemory }!client.Target {
346 self.mutex.lock(); 361 self.mutex.lock();
347 defer self.mutex.unlock(); 362 defer self.mutex.unlock();
348 363
349 const idx = self.indexOf(id) orelse return null; 364 const idx = self.indexOf(id) orelse return error.UnknownId;
350 // Copy first: a failed copy must not leave an fd registered for a 365 // Copy first: a failed copy must not leave an fd registered for a
351 // pump that never starts. 366 // pump that never starts.
352 const copy = copyTarget(arena, self.tiles.items[idx].target) catch return null; 367 const copy = try copyTarget(arena, self.tiles.items[idx].target);
353 // FIRST registration wins: two browsers on one wall run two pumps 368 // FIRST registration wins: two browsers on one wall run two pumps
354 // per tile, and overwriting would leave removeTile able to wake 369 // per tile, and overwriting would leave removeTile able to wake
355 // only the last one — worse, the untracked pump's release would 370 // only the last one — worse, the untracked pump's release would
@@ -901,13 +916,26 @@ pub fn tilesJson(
901 return out.toOwnedSlice(alloc); 916 return out.toOwnedSlice(alloc);
902 } 917 }
903 918
919 /// `3,0,2` → ids. Empty, junk, or trailing garbage refuse: the body is
920 /// machine-written by our own page, so anything malformed is a bug
921 /// worth surfacing, not input to repair.
922 pub fn parseIdList(alloc: std.mem.Allocator, body: []const u8) error{ Bad, OutOfMemory }![]u32 {
923 var out: std.ArrayList(u32) = .empty;
924 errdefer out.deinit(alloc);
925 var it = std.mem.splitScalar(u8, std.mem.trim(u8, body, " \t\r\n"), ',');
926 while (it.next()) |part| {
927 const id = std.fmt.parseInt(u32, part, 10) catch return error.Bad;
928 try out.append(alloc, id);
929 }
930 if (out.items.len == 0) return error.Bad;
931 return out.toOwnedSlice(alloc);
932 }
933
904 pub fn serveConn( 934 pub fn serveConn(
905 alloc: std.mem.Allocator, 935 alloc: std.mem.Allocator,
906 stream: std.net.Stream, 936 stream: std.net.Stream,
907 port: u16, 937 port: u16,
908 targets: []const client.Target, 938 hub: *Hub,
909 labels: []const []const u8,
910 sessions: []const []const u8,
911 assets: Assets, 939 assets: Assets,
912 ) void { 940 ) void {
913 defer stream.close(); 941 defer stream.close();
@@ -920,24 +948,23 @@ pub fn serveConn(
920 948
921 while (true) { 949 while (true) {
922 var req = server.receiveHead() catch return; 950 var req = server.receiveHead() catch return;
951 // `path` BORROWS the head buffer — it is a slice, not a copy — so
952 // every use of it below must happen before a body reader touches
953 // that buffer (readerExpectContinue → readerExpectNone reuses it).
954 // The mutating verbs read a body; they must read `path` first.
923 const path = req.head.target; 955 const path = req.head.target;
956 const method = req.head.method;
924 957
925 // The argv-driven flow still names tiles by index, and here id == 958 var origin: ?[]const u8 = null;
926 // index because nothing removes a tile; the range check that used 959 var it = req.iterateHeaders();
927 // to live in the path parser lives here until the Hub owns this. 960 while (it.next()) |h| {
928 const wanted: ?usize = if (wsTileId(path)) |id| 961 if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value;
929 (if (id < targets.len) @as(usize, id) else null) 962 }
930 else 963
931 null; 964 if (wsTileId(path)) |id| {
932 if (wanted) |idx| {
933 // Origin BEFORE upgrade, always: the refusal must happen while 965 // Origin BEFORE upgrade, always: the refusal must happen while
934 // this is still HTTP, so a hostile page gets a 403 and never a 966 // this is still HTTP, so a hostile page gets a 403 and never a
935 // socket. std's upgradeRequested does not look at Origin. 967 // socket. std's upgradeRequested does not look at Origin.
936 var origin: ?[]const u8 = null;
937 var it = req.iterateHeaders();
938 while (it.next()) |h| {
939 if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value;
940 }
941 if (!originAllowed(origin, port)) { 968 if (!originAllowed(origin, port)) {
942 req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; 969 req.respond("forbidden\n", .{ .status = .forbidden }) catch {};
943 return; 970 return;
@@ -952,14 +979,44 @@ pub fn serveConn(
952 return; 979 return;
953 }, 980 },
954 }; 981 };
982 // The pump owns a COPY of the target: the tile (and its arena)
983 // may be removed mid-pump, and the shutdown() that kicks us out
984 // must never race a free of our own strings.
985 var pump_arena = std.heap.ArenaAllocator.init(alloc);
986 defer pump_arena.deinit();
987 // Checkout BEFORE the upgrade, so an id that is not there is
988 // answered in HTTP — a 404 the page can read, rather than a 101
989 // followed by a silent close it can only guess at. The fd is
990 // the same number either way: the upgrade rides this socket.
991 // One name for the fd we register with, so the release below
992 // provably names the same number it checked out under.
993 const ws_fd = stream.handle;
994 const target = hub.checkoutTarget(id, ws_fd, pump_arena.allocator()) catch |err| switch (err) {
995 // Removed between the page's GET and this dial: the browser
996 // refetches /tiles and stops asking for it.
997 error.UnknownId => {
998 req.respond("no such tile\n", .{ .status = .not_found }) catch {};
999 return;
1000 },
1001 error.OutOfMemory => {
1002 req.respond("out of memory\n", .{ .status = .internal_server_error }) catch {};
1003 return;
1004 },
1005 };
1006 // Deferred, not called after pumpTile: an upgrade that fails
1007 // must unregister too. Registered AFTER `defer stream.close()`,
1008 // so it runs BEFORE it — the ordering that keeps a concurrent
1009 // removeTile from shutting down an fd the kernel has already
1010 // handed to somebody else.
1011 defer hub.releaseTile(id, ws_fd);
955 var ws = req.respondWebSocket(.{ .key = key }) catch return; 1012 var ws = req.respondWebSocket(.{ .key = key }) catch return;
956 ws.flush() catch return; 1013 ws.flush() catch return;
957 pumpTile(alloc, &ws, stream.handle, targets[idx]); 1014 pumpTile(alloc, &ws, ws_fd, target);
958 return; 1015 return;
959 } 1016 }
960 1017
961 if (std.mem.eql(u8, path, "/tiles")) { 1018 if (std.mem.eql(u8, path, "/tiles") and method == .GET) {
962 const json = tilesJson(alloc, labels, sessions) catch return; 1019 const json = hub.json(alloc) catch return;
963 defer alloc.free(json); 1020 defer alloc.free(json);
964 req.respond(json, .{ 1021 req.respond(json, .{
965 .extra_headers = &.{ 1022 .extra_headers = &.{
@@ -970,6 +1027,122 @@ pub fn serveConn(
970 continue; 1027 continue;
971 } 1028 }
972 1029
1030 // Mutations: Origin-gated, always — a text/plain POST is a CSRF
1031 // "simple request" any web page can fire at localhost without a
1032 // preflight; the gate is what keeps this page's power this page's.
1033 // GET above stays ungated: we send no CORS headers, so a hostile
1034 // page can make the request but never read the answer.
1035 //
1036 // Exact match only: a prefix match would route `/tilesgarbage`
1037 // into a mutation and `/tiles/` or `/tiles?x=1` into a confusing
1038 // 403/405 here instead of the asset router's plain 404. The API
1039 // defines exactly "/tiles" (POST/PUT) and "/tiles/<id>" (DELETE);
1040 // reject everything else by not matching it at all.
1041 const tiles_root = std.mem.eql(u8, path, "/tiles");
1042 const tile_id_suffix = if (std.mem.startsWith(u8, path, "/tiles/") and path.len > "/tiles/".len)
1043 path["/tiles/".len..]
1044 else
1045 null;
1046 if (tiles_root or tile_id_suffix != null) {
1047 if (!originAllowed(origin, port)) {
1048 req.respond("forbidden\n", .{ .status = .forbidden }) catch {};
1049 return;
1050 }
1051 switch (method) {
1052 .POST => { // body = one spelling
1053 if (!tiles_root) {
1054 req.respond("not found\n", .{ .status = .not_found }) catch return;
1055 continue;
1056 }
1057 var body_buf: [512]u8 = undefined;
1058 const rdr = req.readerExpectContinue(&body_buf) catch return;
1059 const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return;
1060 defer alloc.free(body_raw);
1061 const spelling = std.mem.trim(u8, body_raw, " \t\r\n");
1062 const id = hub.addTile(spelling) catch |err| {
1063 const status: std.http.Status, const msg: []const u8 = switch (err) {
1064 error.BadSession => .{ .bad_request, "bad session name after '#'\n" },
1065 error.EmptySpec => .{ .bad_request, "empty target\n" },
1066 error.BadByte => .{ .bad_request, "control byte in target\n" },
1067 error.MissingKey => .{ .bad_request, "no key for quic:// target (muxd keygen, or MUX_KEY_FILE)\n" },
1068 error.SockPathTooLong => .{ .bad_request, "socket path too long\n" },
1069 // The tile is NOT live: addTile rolls back on a
1070 // failed save, so 500 is the whole truth here.
1071 error.PersistFailed => .{ .internal_server_error, "wall not saved\n" },
1072 error.OutOfMemory => return,
1073 };
1074 req.respond(msg, .{ .status = status }) catch {};
1075 continue;
1076 };
1077 var buf: [32]u8 = undefined;
1078 const resp = std.fmt.bufPrint(&buf, "{{\"id\":{d}}}", .{id}) catch unreachable;
1079 req.respond(resp, .{ .extra_headers = &.{
1080 .{ .name = "content-type", .value = "application/json" },
1081 } }) catch return;
1082 },
1083 .DELETE => { // path = /tiles/<id>
1084 const id_str = tile_id_suffix orelse {
1085 req.respond("not found\n", .{ .status = .not_found }) catch return;
1086 continue;
1087 };
1088 const id = std.fmt.parseInt(u32, id_str, 10) catch {
1089 req.respond("not found\n", .{ .status = .not_found }) catch return;
1090 continue;
1091 };
1092 hub.removeTile(id) catch |err| {
1093 const status: std.http.Status, const msg: []const u8 = switch (err) {
1094 error.UnknownId => .{ .not_found, "not found\n" },
1095 // The tile IS gone from the running hub; only the
1096 // file disagrees. 500 so the user learns the wall
1097 // will come back on the next start.
1098 error.PersistFailed => .{ .internal_server_error, "wall not saved\n" },
1099 };
1100 req.respond(msg, .{ .status = status }) catch return;
1101 continue;
1102 };
1103 req.respond("", .{ .status = .no_content }) catch return;
1104 },
1105 .PUT => { // body = CSV of ids, the FULL new order
1106 if (!tiles_root) {
1107 req.respond("not found\n", .{ .status = .not_found }) catch return;
1108 continue;
1109 }
1110 var body_buf: [512]u8 = undefined;
1111 const rdr = req.readerExpectContinue(&body_buf) catch return;
1112 const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return;
1113 defer alloc.free(body_raw);
1114 const ids = parseIdList(alloc, body_raw) catch {
1115 req.respond("bad id list\n", .{ .status = .bad_request }) catch return;
1116 continue;
1117 };
1118 defer alloc.free(ids);
1119 hub.reorderTiles(ids) catch |err| switch (err) {
1120 error.Stale => {
1121 // A stale view: hand back the truth so the page
1122 // can reconcile and retry.
1123 const json = hub.json(alloc) catch return;
1124 defer alloc.free(json);
1125 req.respond(json, .{ .status = .conflict, .extra_headers = &.{
1126 .{ .name = "content-type", .value = "application/json" },
1127 } }) catch return;
1128 continue;
1129 },
1130 // The new order IS live (reorderTiles does not roll
1131 // back) — only the file lags, which the next
1132 // successful mutation rewrites whole.
1133 error.PersistFailed => {
1134 req.respond("wall not saved\n", .{ .status = .internal_server_error }) catch return;
1135 continue;
1136 },
1137 error.OutOfMemory => return,
1138 };
1139 req.respond("", .{ .status = .no_content }) catch return;
1140 },
1141 else => req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return,
1142 }
1143 continue;
1144 }
1145
973 if (route(assets, path)) |asset| { 1146 if (route(assets, path)) |asset| {
974 req.respond(asset.body, .{ 1147 req.respond(asset.body, .{
975 .extra_headers = &.{ 1148 .extra_headers = &.{
@@ -1038,8 +1211,8 @@ test "hub: ids are stable across remove and reorder; json is wall order" {
1038 1211
1039 const id2 = try hub.addTile("--sock /tmp/c"); 1212 const id2 = try hub.addTile("--sock /tmp/c");
1040 try std.testing.expectEqual(@as(u32, 2), id2); 1213 try std.testing.expectEqual(@as(u32, 2), id2);
1041 try std.testing.expect(hub.removeTile(1)); 1214 try hub.removeTile(1);
1042 try std.testing.expect(!hub.removeTile(1)); // already gone 1215 try std.testing.expectError(error.UnknownId, hub.removeTile(1)); // already gone
1043 try hub.reorderTiles(&.{ 2, 0 }); 1216 try hub.reorderTiles(&.{ 2, 0 });
1044 try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view 1217 try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view
1045 1218
@@ -1059,13 +1232,13 @@ test "hub: checkout copies the target into the caller's arena; release unregiste
1059 1232
1060 var arena = std.heap.ArenaAllocator.init(alloc); 1233 var arena = std.heap.ArenaAllocator.init(alloc);
1061 defer arena.deinit(); 1234 defer arena.deinit();
1062 const t = hub.checkoutTarget(0, 7, arena.allocator()).?; 1235 const t = try hub.checkoutTarget(0, 7, arena.allocator());
1063 // The copy must survive the tile's death: remove frees the tile's own 1236 // The copy must survive the tile's death: remove frees the tile's own
1064 // arena, and the pump's strings must not be in it. 1237 // arena, and the pump's strings must not be in it.
1065 try std.testing.expect(hub.removeTile(0)); 1238 try hub.removeTile(0);
1066 try std.testing.expectEqualStrings("/tmp/a", t.sock); 1239 try std.testing.expectEqualStrings("/tmp/a", t.sock);
1067 hub.releaseTile(0, 7); // gone id: a no-op, not a crash 1240 hub.releaseTile(0, 7); // gone id: a no-op, not a crash
1068 try std.testing.expectEqual(@as(?client.Target, null), hub.checkoutTarget(0, 7, arena.allocator())); 1241 try std.testing.expectError(error.UnknownId, hub.checkoutTarget(0, 7, arena.allocator()));
1069 } 1242 }
1070 1243
1071 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { 1244 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" {
@@ -1079,8 +1252,8 @@ test "hub: two pumps on one tile — the first fd stays tracked, the second's re
1079 defer arena.deinit(); 1252 defer arena.deinit();
1080 1253
1081 // Two browsers on one wall: both pumps get a target and both serve. 1254 // Two browsers on one wall: both pumps get a target and both serve.
1082 const first = hub.checkoutTarget(0, 7, arena.allocator()).?; 1255 const first = try hub.checkoutTarget(0, 7, arena.allocator());
1083 const second = hub.checkoutTarget(0, 8, arena.allocator()).?; 1256 const second = try hub.checkoutTarget(0, 8, arena.allocator());
1084 try std.testing.expectEqualStrings("/tmp/a", first.sock); 1257 try std.testing.expectEqualStrings("/tmp/a", first.sock);
1085 try std.testing.expectEqualStrings("/tmp/a", second.sock); 1258 try std.testing.expectEqualStrings("/tmp/a", second.sock);
1086 1259
@@ -1109,7 +1282,7 @@ test "hub: addTile persists; a reloaded wall matches" {
1109 _ = try hub.addTile("--sock /tmp/a"); 1282 _ = try hub.addTile("--sock /tmp/a");
1110 _ = try hub.addTile("--sock /tmp/b"); 1283 _ = try hub.addTile("--sock /tmp/b");
1111 _ = try hub.addTile("--sock /tmp/c"); 1284 _ = try hub.addTile("--sock /tmp/c");
1112 try std.testing.expect(hub.removeTile(1)); 1285 try hub.removeTile(1);
1113 try hub.reorderTiles(&.{ 2, 0 }); 1286 try hub.reorderTiles(&.{ 2, 0 });
1114 } 1287 }
1115 var r = try wall.load(alloc, path); 1288 var r = try wall.load(alloc, path);
@@ -1323,6 +1496,17 @@ test "drain browser: the reader's OWN bytes decide, with or without a readable e
1323 } 1496 }
1324 } 1497 }
1325 1498
1499 test "parseIdList: happy path and refusals" {
1500 const alloc = std.testing.allocator;
1501 const ids = try parseIdList(alloc, "3,0,2\n");
1502 defer alloc.free(ids);
1503 try std.testing.expectEqualSlices(u32, &.{ 3, 0, 2 }, ids);
1504 try std.testing.expectError(error.Bad, parseIdList(alloc, ""));
1505 try std.testing.expectError(error.Bad, parseIdList(alloc, "1,,2"));
1506 try std.testing.expectError(error.Bad, parseIdList(alloc, "1,x"));
1507 try std.testing.expectError(error.Bad, parseIdList(alloc, "-1"));
1508 }
1509
1326 test "tiles json: label/session objects, order preserved" { 1510 test "tiles json: label/session objects, order preserved" {
1327 const alloc = std.testing.allocator; 1511 const alloc = std.testing.allocator;
1328 { 1512 {
src/webhub_main.zig
Old New
@@ -1,25 +1,31 @@
1 //! muxweb — the hub binary (M-web Task 7). `muxweb TARGET [TARGET ...] 1 //! muxweb — the hub binary (M-web Task 7). `muxweb [TARGET ...]
2 //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one 2 //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one
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). A `#NAME` suffix on a TARGET names the daemon session that tile 6 //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile
7 //! attaches to, which is how one host becomes two tiles. The TARGET string 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 8 //! is the tile's label, suffix and all.
9 //! file, per the standing non-goal. 9 //!
10 //! The wall is now a persisted list the page edits at runtime, so argv is
11 //! an OVERRIDE, not the only source: with targets, argv becomes the wall
12 //! and is saved; without, the last run's wall is restored. The standing
13 //! non-goal (no config file) survives — the state file is written by the
14 //! program, never by hand.
10 15
11 const std = @import("std"); 16 const std = @import("std");
12 const client = @import("client"); 17 const client = @import("client");
13 const webhub = @import("webhub"); 18 const webhub = @import("webhub");
14 const proto = @import("protocol"); 19 const wall = @import("wall");
15 const build_options = @import("build_options"); 20 const build_options = @import("build_options");
16 const xdg = @import("xdg"); 21 const xdg = @import("xdg");
17 const handoff = @import("handoff");
18 const sockpath = @import("sockpath"); 22 const sockpath = @import("sockpath");
19 23
20 const usage = 24 const usage =
21 \\usage: muxweb TARGET[#SESSION] [TARGET ...] [--port N] 25 \\usage: muxweb [TARGET[#SESSION] ...] [--port N]
22 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] 26 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT]
27 \\ with no TARGET the wall from the last run is restored; with TARGETs
28 \\ argv replaces it and becomes the saved wall
23 \\ #SESSION names the daemon session the tile attaches to (default: the 29 \\ #SESSION names the daemon session the tile attaches to (default: the
24 \\ default session) — the same host twice, two sessions, two tiles 30 \\ default session) — the same host twice, two sessions, two tiles
25 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key 31 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
@@ -30,61 +36,48 @@ const usage =
30 \\ 36 \\
31 ; 37 ;
32 38
33 /// One tile as the command line spelled it. Building the full 39 /// Validate one wall spelling and take an owned copy of it.
34 /// client.Target needs an allocator and the environment, so the parse
35 /// records spellings and main resolves them — the same split mux_main
36 /// uses, for the same testability reason.
37 const TileSpec = struct {
38 /// The transport spelling with any `#NAME` suffix already taken off:
39 /// what the dial is built from.
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 };
56 };
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 /// 40 ///
63 /// A bad name is refused HERE, at usage-error altitude, rather than 41 /// A bad spelling is refused HERE, at usage-error altitude, rather than
64 /// downstream where it would arrive as a rejected attach in one tile with 42 /// 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: 43 /// 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. 44 /// 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 } { 45 ///
68 const hash = std.mem.lastIndexOfScalar(u8, arg, '#') orelse 46 /// wall.parseSpelling is the ONE grammar — the same one the state file and
69 return .{ .spec = arg, .session = "" }; 47 /// the hub's POST /tiles are read with, so what argv accepts is exactly
70 const name = arg[hash + 1 ..]; 48 /// what the page can add.
71 if (!proto.validSessionName(name)) { 49 fn addSpelling(
72 std.debug.print( 50 alloc: std.mem.Allocator,
73 "muxweb: tile {s}: bad session name after '#' (printable ASCII, no space, no '/', 1-{d} bytes)\n", 51 list: *std.ArrayList([]const u8),
74 .{ arg, proto.session_name_max }, 52 spelling: []const u8,
75 ); 53 ) ParseError!void {
54 _ = wall.parseSpelling(spelling) catch |err| {
55 std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) {
56 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
57 error.EmptySpec => "empty target",
58 error.BadByte => "control byte in target",
59 } });
76 return error.Usage; 60 return error.Usage;
77 } 61 };
78 return .{ .spec = arg[0..hash], .session = name }; 62 const copy = try alloc.dupe(u8, spelling);
63 errdefer alloc.free(copy);
64 try list.append(alloc, copy);
79 } 65 }
80 66
81 const Parsed = struct { 67 const Parsed = struct {
82 tiles: std.ArrayList(TileSpec), 68 /// One wall spelling per tile, in argv order — the same string that
69 /// reaches the state file, the resolver and the page's label. Owned
70 /// uniformly rather than half-borrowed from argv, because `--sock PATH`
71 /// has to synthesize its `--sock ` prefix and one ownership rule beats
72 /// two. That prefix is now part of a sock tile's label: the label IS
73 /// the spelling.
74 tiles: std.ArrayList([]const u8),
83 port: u16 = webhub.default_port, 75 port: u16 = webhub.default_port,
84 key: ?[]const u8 = null, 76 key: ?[]const u8 = null,
85 idle_ms: u32 = client.quic_idle_ms_default, 77 idle_ms: u32 = client.quic_idle_ms_default,
86 78
87 fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { 79 fn deinit(self: *Parsed, alloc: std.mem.Allocator) void {
80 for (self.tiles.items) |t| alloc.free(t);
88 self.tiles.deinit(alloc); 81 self.tiles.deinit(alloc);
89 } 82 }
90 }; 83 };
@@ -107,7 +100,7 @@ fn parseArgs(
107 env_key: ?[]const u8, 100 env_key: ?[]const u8,
108 ) ParseError!ParseResult { 101 ) ParseError!ParseResult {
109 var p = Parsed{ .tiles = .empty }; 102 var p = Parsed{ .tiles = .empty };
110 errdefer p.tiles.deinit(alloc); 103 errdefer p.deinit(alloc);
111 var key: ?[]const u8 = null; 104 var key: ?[]const u8 = null;
112 105
113 var i: usize = 1; 106 var i: usize = 1;
@@ -117,23 +110,16 @@ fn parseArgs(
117 // The one non-error early return, so the one that still frees 110 // The one non-error early return, so the one that still frees
118 // for itself: errdefer does not run on the way out with a 111 // for itself: errdefer does not run on the way out with a
119 // result in hand. 112 // result in hand.
120 p.tiles.deinit(alloc); 113 p.deinit(alloc);
121 return .version; 114 return .version;
122 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { 115 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
123 i += 1; 116 i += 1;
124 // The `#NAME` split is on the TARGET, and --sock's target is its 117 // The flag and its value become ONE spelling — `--sock ` is
125 // VALUE, not the flag. 118 // part of the grammar wall.zig reads, not a shape only argv
126 const s = try splitSession(args[i]); 119 // has. Two spellings of the same tile would be two parsers.
127 // Same refusal the bare-host and quic:// arms make: `--sock '#b'` 120 const s = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i]});
128 // splits to an empty path, and an empty path is a usage mistake, 121 defer alloc.free(s);
129 // not something to carry to a connect that fails later and 122 try addSpelling(alloc, &p.tiles, s);
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 });
137 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { 123 } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) {
138 i += 1; 124 i += 1;
139 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; 125 p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage;
@@ -150,29 +136,17 @@ fn parseArgs(
150 const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage; 136 const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage;
151 if (n == 0) return error.Usage; 137 if (n == 0) return error.Usage;
152 p.idle_ms = n; 138 p.idle_ms = n;
153 } else if (std.mem.startsWith(u8, a, "quic://")) { 139 } else if (std.mem.startsWith(u8, a, "quic://") or (a.len > 0 and a[0] != '-')) {
154 const s = try splitSession(a); 140 // Bare HOST and quic:// are already wall spellings verbatim.
155 const hp = s.spec["quic://".len..]; 141 try addSpelling(alloc, &p.tiles, a);
156 if (hp.len == 0) return error.Usage;
157 try p.tiles.append(alloc, .{
158 .spec = .{ .quic = hp },
159 .label = a,
160 .session = s.session,
161 });
162 } else if (a.len > 0 and a[0] != '-') {
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 });
170 } else { 142 } else {
171 return error.Usage; 143 return error.Usage;
172 } 144 }
173 } 145 }
174 146
175 if (p.tiles.items.len == 0) return error.Usage; 147 // No targets is not a usage error any more: it asks for the wall the
148 // last run persisted. main decides what an empty argv means; the parse
149 // only reports what was on the line.
176 p.key = xdg.pickKey(key, env_key); 150 p.key = xdg.pickKey(key, env_key);
177 return .{ .serve = p }; 151 return .{ .serve = p };
178 } 152 }
@@ -204,71 +178,63 @@ pub fn main() !u8 {
204 }; 178 };
205 defer parsed.deinit(alloc); 179 defer parsed.deinit(alloc);
206 180
207 // Resolve spellings into dialable Targets through the SAME owners
208 // mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — so the
209 // two binaries cannot drift on what a bare HOST or a `quic://` means.
210 // Labels are the argv spellings verbatim.
211 //
212 // An arena, because every string built here lives exactly as long as 181 // An arena, because every string built here lives exactly as long as
213 // the hub does: the Targets are handed to every tile thread and the 182 // the hub does — the state path, the wall it starts from, and the
214 // labels to every /tiles response, so nothing is ever freed early and 183 // Hub's own allocations — so nothing is ever freed early and the
215 // the hand-rolled list of pointers-to-free was a lifetime nobody 184 // hand-rolled list of pointers-to-free was a lifetime nobody needed to
216 // needed to track. Which resolutions borrow from argv and which 185 // track. The process exits from inside the accept loop, so "as long as
217 // allocate stops mattering: both end at the same deinit. 186 // the hub" is "until exit".
218 var arena_state = std.heap.ArenaAllocator.init(alloc); 187 var arena_state = std.heap.ArenaAllocator.init(alloc);
219 defer arena_state.deinit(); 188 defer arena_state.deinit();
220 const arena = arena_state.allocator(); 189 const arena = arena_state.allocator();
221 190
222 var targets: std.ArrayList(client.Target) = .empty; 191 const state_path = try wall.statePath(arena);
223 var labels: std.ArrayList([]const u8) = .empty; 192 var w: wall.Wall = undefined;
224 // Parallel to labels and targets, index for index: /tiles hands the 193 if (parsed.tiles.items.len == 0) {
225 // browser both, and the browser is the one that attaches. 194 // No argv: the wall is whatever the last run persisted.
226 var sessions: std.ArrayList([]const u8) = .empty; 195 w = wall.load(arena, state_path) catch |err| {
227 196 // The file may have been hand-edited into a line that no longer
228 for (parsed.tiles.items) |tile| { 197 // parses. Naming it beats a stack trace: the fix is in the file.
229 // The label is the argv spelling verbatim, `#NAME` included, for 198 std.debug.print("muxweb: cannot read wall {s}: {s}\n", .{ state_path, @errorName(err) });
230 // every spelling — including quic://, which used to rebuild its own. 199 return 2;
231 try labels.append(arena, tile.label); 200 };
232 try sessions.append(arena, tile.session); 201 } else {
233 switch (tile.spec) { 202 // Argv present: the explicit override. It becomes the persisted wall.
234 .sock => |path| { 203 w = wall.Wall{};
235 if (path.len > sockpath.max_sun_path) { 204 for (parsed.tiles.items) |s| _ = try w.add(arena, s);
236 std.debug.print("muxweb: socket path too long ({d} bytes, max {d}): {s}\n", .{
237 path.len, sockpath.max_sun_path, path,
238 });
239 return 2;
240 }
241 try targets.append(arena, .{ .sock = path });
242 },
243 .host => |h| {
244 const r = try handoff.recipeFor(arena, h);
245 try targets.append(arena, .{ .hand = .{
246 .host = h,
247 .ssh_cmd = r.ssh_cmd,
248 .cache_path = r.cache_path,
249 .idle_ms = parsed.idle_ms,
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 } 205 }
271 206
207 // The Hub resolves every spelling into a dialable Target through the
208 // SAME owners mux_main uses — handoff.recipeFor and xdg.resolveKeyPath —
209 // so the two binaries cannot drift on what a bare HOST or a `quic://`
210 // means, and a tile POSTed by the page means what one typed on the
211 // command line.
212 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.idle_ms) catch |err| switch (err) {
213 error.MissingKey => {
214 std.debug.print(
215 "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n",
216 .{},
217 );
218 return 2;
219 },
220 error.SockPathTooLong => {
221 std.debug.print(
222 "muxweb: socket path too long (max {d} bytes)\n",
223 .{sockpath.max_sun_path},
224 );
225 return 2;
226 },
227 else => return err,
228 };
229 defer hub.deinit();
230 // Argv is the override, so it is also what the next run restores. Saved
231 // only when there WAS argv: a restore that rewrites what it just read
232 // would turn a read failure into a lost wall.
233 if (parsed.tiles.items.len != 0) wall.save(&hub.wall_state, state_path) catch |err| {
234 std.debug.print("muxweb: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) });
235 return 2;
236 };
237
272 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; 238 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable;
273 var listener = addr.listen(.{ .reuse_address = true }) catch |err| { 239 var listener = addr.listen(.{ .reuse_address = true }) catch |err| {
274 std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); 240 std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) });
@@ -277,9 +243,11 @@ pub fn main() !u8 {
277 defer listener.deinit(); 243 defer listener.deinit();
278 244
279 // The tile list, one line each, then the door: everything a script 245 // The tile list, one line each, then the door: everything a script
280 // (or a user) needs to know the hub is up and what it serves. 246 // (or a user) needs to know the hub is up and what it serves. The id
281 for (labels.items, 0..) |label, i| { 247 // is the hub's, not a position — it is what `/ws/<id>` names, and the
282 std.debug.print("muxweb: tile {d}: {s}\n", .{ i, label }); 248 // page can add and remove tiles under it while this line stays true.
249 for (hub.tiles.items) |t| {
250 std.debug.print("muxweb: tile {d}: {s}\n", .{ t.id, t.label });
283 } 251 }
284 std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{ 252 std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{
285 parsed.port, 253 parsed.port,
@@ -295,7 +263,7 @@ pub fn main() !u8 {
295 while (true) { 263 while (true) {
296 const conn = listener.accept() catch continue; 264 const conn = listener.accept() catch continue;
297 const th = std.Thread.spawn(.{}, webhub.serveConn, .{ 265 const th = std.Thread.spawn(.{}, webhub.serveConn, .{
298 alloc, conn.stream, parsed.port, targets.items, labels.items, sessions.items, assets, 266 alloc, conn.stream, parsed.port, &hub, assets,
299 }) catch { 267 }) catch {
300 conn.stream.close(); 268 conn.stream.close();
301 continue; 269 continue;
@@ -319,22 +287,29 @@ test "parse: three spellings become three tiles in argv order, port and key bind
319 var r = (try parseArgs(alloc, &args, null)).serve; 287 var r = (try parseArgs(alloc, &args, null)).serve;
320 defer r.deinit(alloc); 288 defer r.deinit(alloc);
321 try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); 289 try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len);
322 try std.testing.expectEqualStrings("box1", r.tiles.items[0].spec.host); 290 try std.testing.expectEqualStrings("box1", r.tiles.items[0]);
323 try std.testing.expectEqualStrings("/tmp/a.sock", r.tiles.items[1].spec.sock); 291 // `--sock PATH` is ONE spelling from here on, prefix included — that
324 try std.testing.expectEqualStrings("h:4433", r.tiles.items[2].spec.quic); 292 // string is the label, the wall line, and the resolver's input alike.
293 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r.tiles.items[1]);
294 try std.testing.expectEqualStrings("quic://h:4433", r.tiles.items[2]);
325 try std.testing.expectEqual(@as(u16, 8000), r.port); 295 try std.testing.expectEqual(@as(u16, 8000), r.port);
326 try std.testing.expectEqualStrings("/k", r.key.?); 296 try std.testing.expectEqualStrings("/k", r.key.?);
327 } 297 }
328 298
329 test "parse: zero targets, bad flags, and flag-beats-env" { 299 test "parse: zero targets, bad flags, and flag-beats-env" {
330 const alloc = std.testing.allocator; 300 const alloc = std.testing.allocator;
331 // Every refusal arrives as error.Usage — and the testing allocator is 301 // No targets is an empty argv wall, not a refusal: restore-from-file
332 // the other half of this pin: a refusal that leaked the tile list 302 // semantics live in main, which is the only place that can read a file.
333 // would fail the test that provoked it, which is what the single 303 {
334 // errdefer now guarantees on all seven paths. 304 var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve;
305 defer r.deinit(alloc);
306 try std.testing.expectEqual(@as(usize, 0), r.tiles.items.len);
307 }
308 // Every other refusal arrives as error.Usage — and the testing
309 // allocator is the other half of this pin: a refusal that leaked the
310 // tile list (whose strings are now owned) would fail the test that
311 // provoked it, which is what the single errdefer guarantees.
335 // 312 //
336 // No targets is a usage error, not an empty wall.
337 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null));
338 // A flag with no value is a usage mistake, not a transport. 313 // A flag with no value is a usage mistake, not a transport.
339 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)); 314 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null));
340 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)); 315 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null));
@@ -376,7 +351,7 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
376 } 351 }
377 } 352 }
378 353
379 test "tiles: #NAME splits off the session; the label keeps the full spelling" { 354 test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" {
380 const alloc = std.testing.allocator; 355 const alloc = std.testing.allocator;
381 const args = [_][:0]const u8{ 356 const args = [_][:0]const u8{
382 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", 357 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c",
@@ -385,41 +360,26 @@ test "tiles: #NAME splits off the session; the label keeps the full spelling" {
385 defer r.deinit(alloc); 360 defer r.deinit(alloc);
386 try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len); 361 try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len);
387 362
388 // Every spelling splits the same way, and the label is the argv string 363 // The session SPLIT is wall.parseSpelling's, tested there. What is
389 // verbatim: the user asked for `host#b`, so that is the tile's name on 364 // this parse's own is that the argv string arrives intact: the user
390 // screen — the wall says which session it is showing without anyone 365 // asked for `host#b`, so that is the wall line, and therefore the
391 // having to decorate it. 366 // tile's name on screen — nobody decorates it on the way.
392 try std.testing.expectEqualStrings("host", r.tiles.items[0].spec.host); 367 try std.testing.expectEqualStrings("host#b", r.tiles.items[0]);
393 try std.testing.expectEqualStrings("b", r.tiles.items[0].session); 368 try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1]);
394 try std.testing.expectEqualStrings("host#b", r.tiles.items[0].label); 369 // The flag and its value become one spelling; the `#NAME` rides on the
395 370 // VALUE, where the user put it.
396 try std.testing.expectEqualStrings("h:1", r.tiles.items[1].spec.quic); 371 try std.testing.expectEqualStrings("--sock /tmp/x#b", r.tiles.items[2]);
397 try std.testing.expectEqualStrings("b", r.tiles.items[1].session); 372 try std.testing.expectEqualStrings("plainhost", r.tiles.items[3]);
398 try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1].label); 373 try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4]);
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 } 374 }
417 375
418 test "tiles: a bad session name after # is a usage error" { 376 test "tiles: a bad session name after # is still a usage error at parse" {
419 const alloc = std.testing.allocator; 377 const alloc = std.testing.allocator;
420 // These refusals print a line naming the tile before returning, so the 378 // The refusal stays HERE, at argv altitude, rather than surfacing later
421 // muxweb: lines in this test's output are the point, not noise: with 379 // as one tile that will not attach. These print a line naming the tile
422 // several tiles on the line, `usage` alone would not say which one. 380 // before returning, so the muxweb: lines in this test's output are the
381 // point, not noise: with several tiles on the line, `usage` alone would
382 // not say which one.
423 try std.testing.expectError( 383 try std.testing.expectError(
424 error.Usage, 384 error.Usage,
425 parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null), 385 parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null),