531b8767
feat: webhub Hub — runtime wall, stable tile ids, checkout/release
a73x 2026-08-18 17:58
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -239,7 +239,7 @@ 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" }, .quic_tests = true }, | 242 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg" }, .quic_tests = true }, |
| 243 | // sockpath is the sun_path bound only; the client binds no socket itself. | 243 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 244 | // protocol is the session-name validator alone (validSessionName): a bad | 244 | // protocol is the session-name validator alone (validSessionName): a bad |
| 245 | // --session has to be a usage error here, at parse, not bytes some | 245 | // --session has to be a usage error here, at parse, not bytes some |
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,9 @@ | |||
| 12 | const std = @import("std"); | 12 | const std = @import("std"); |
| 13 | const proto = @import("protocol"); | 13 | const proto = @import("protocol"); |
| 14 | const client = @import("client"); | 14 | const client = @import("client"); |
| 15 | const wall = @import("wall"); | ||
| 16 | const handoff = @import("handoff"); | ||
| 17 | const xdg = @import("xdg"); | ||
| 15 | 18 | ||
| 16 | pub const default_port: u16 = 7681; | 19 | pub const default_port: u16 = 7681; |
| 17 | 20 | ||
| @@ -41,17 +44,361 @@ pub fn originAllowed(origin: ?[]const u8, port: u16) bool { | |||
| 41 | return false; | 44 | return false; |
| 42 | } | 45 | } |
| 43 | 46 | ||
| 44 | /// `/ws/<idx>` → the tile index, or null for anything else (including an | 47 | /// `/ws/<id>` → the tile id, or null when the path is not ours or the id |
| 45 | /// index that fails to parse or overruns the tile list — the caller sees | 48 | /// does not parse. Deliberately no range opinion: ids are hub-assigned and |
| 46 | /// null and 404s rather than indexing air). | 49 | /// go sparse the moment a tile is removed, so "is this id live" is a |
| 47 | pub fn wsTileIndex(path: []const u8, tile_count: usize) ?usize { | 50 | /// question only the Hub's map can answer, under its mutex. |
| 51 | pub fn wsTileId(path: []const u8) ?u32 { | ||
| 48 | const prefix = "/ws/"; | 52 | const prefix = "/ws/"; |
| 49 | if (!std.mem.startsWith(u8, path, prefix)) return null; | 53 | if (!std.mem.startsWith(u8, path, prefix)) return null; |
| 50 | const idx = std.fmt.parseInt(usize, path[prefix.len..], 10) catch return null; | 54 | return std.fmt.parseInt(u32, path[prefix.len..], 10) catch null; |
| 51 | if (idx >= tile_count) return null; | ||
| 52 | return idx; | ||
| 53 | } | 55 | } |
| 54 | 56 | ||
| 57 | /// One runtime tile. Owns an arena holding its resolved target and label | ||
| 58 | /// strings; the arena dies with the tile, which is why pumps must | ||
| 59 | /// checkout a COPY rather than borrow. | ||
| 60 | const HubTile = struct { | ||
| 61 | id: u32, | ||
| 62 | arena: std.heap.ArenaAllocator, | ||
| 63 | target: client.Target, | ||
| 64 | label: []const u8, | ||
| 65 | session: []const u8, | ||
| 66 | /// Registered while a pump owns a WS for this tile. removeTile uses | ||
| 67 | /// it to shutdown(2) the socket, which unblocks the pump's reads; | ||
| 68 | /// the pump unregisters (under the hub mutex) BEFORE serveConn | ||
| 69 | /// closes the fd, so a shutdown can never hit a recycled fd. | ||
| 70 | ws_fd: ?std.posix.fd_t = null, | ||
| 71 | }; | ||
| 72 | |||
| 73 | /// 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 | /// wall file did not accept. | ||
| 76 | pub const AddError = wall.ParseError || error{ MissingKey, OutOfMemory, PersistFailed }; | ||
| 77 | |||
| 78 | /// 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 | ||
| 80 | /// line means. Allocates into `arena` (the tile's own). | ||
| 81 | fn resolveTile( | ||
| 82 | arena: std.mem.Allocator, | ||
| 83 | spelling: []const u8, | ||
| 84 | key: ?[]const u8, | ||
| 85 | idle_ms: u32, | ||
| 86 | ) (wall.ParseError || error{ MissingKey, OutOfMemory })!struct { | ||
| 87 | target: client.Target, | ||
| 88 | label: []const u8, | ||
| 89 | session: []const u8, | ||
| 90 | } { | ||
| 91 | const p = try wall.parseSpelling(spelling); | ||
| 92 | // The label is the user's spelling verbatim, `#NAME` included — the | ||
| 93 | // page shows what was typed, not a rebuilt approximation of it. | ||
| 94 | const label = try arena.dupe(u8, spelling); | ||
| 95 | const session = try arena.dupe(u8, p.session); | ||
| 96 | const target: client.Target = switch (p.spec) { | ||
| 97 | .sock => |path| .{ .sock = try arena.dupe(u8, path) }, | ||
| 98 | .host => |h| blk: { | ||
| 99 | const hd = try arena.dupe(u8, h); | ||
| 100 | const r = try handoff.recipeFor(arena, hd); | ||
| 101 | break :blk .{ .hand = .{ | ||
| 102 | .host = hd, | ||
| 103 | .ssh_cmd = r.ssh_cmd, | ||
| 104 | .cache_path = r.cache_path, | ||
| 105 | .idle_ms = idle_ms, | ||
| 106 | } }; | ||
| 107 | }, | ||
| 108 | .quic => |hp| blk: { | ||
| 109 | const key_path = switch (xdg.resolveKeyPath(arena, key) catch |err| switch (err) { | ||
| 110 | // No HOME is no default key path, which is the same | ||
| 111 | // outcome for this caller as a default that isn't there: | ||
| 112 | // nothing to authenticate the dial with. | ||
| 113 | error.NoHome => return error.MissingKey, | ||
| 114 | else => |e| return e, | ||
| 115 | }) { | ||
| 116 | // `.given` borrows from argv/env, which outlives nothing | ||
| 117 | // in particular from the tile's point of view. | ||
| 118 | .given => |kp| try arena.dupe(u8, kp), | ||
| 119 | .default => |kp| kp, | ||
| 120 | .missing => return error.MissingKey, | ||
| 121 | }; | ||
| 122 | break :blk .{ .quic = .{ | ||
| 123 | .host_port = try arena.dupe(u8, hp), | ||
| 124 | .key_path = key_path, | ||
| 125 | .idle_ms = idle_ms, | ||
| 126 | } }; | ||
| 127 | }, | ||
| 128 | }; | ||
| 129 | return .{ .target = target, .label = label, .session = session }; | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Every string field copied into `arena`, every scalar by value: what a | ||
| 133 | /// pump holds must outlive the tile it came from, and a field left out | ||
| 134 | /// would arrive as its default — a handshake budget quietly shortened by | ||
| 135 | /// a copy nobody suspected. | ||
| 136 | /// | ||
| 137 | /// Field by field, with the shapes pinned below, because most of these | ||
| 138 | /// fields HAVE defaults: a new one added to client.zig would compile here | ||
| 139 | /// in silence and be wrong at runtime. The counts make that a build error. | ||
| 140 | fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target { | ||
| 141 | comptime { | ||
| 142 | std.debug.assert(@typeInfo(client.Target).@"union".fields.len == 4); | ||
| 143 | std.debug.assert(@typeInfo(client.QuicTarget).@"struct".fields.len == 4); | ||
| 144 | std.debug.assert(@typeInfo(client.HandoffTarget).@"struct".fields.len == 6); | ||
| 145 | } | ||
| 146 | return switch (t) { | ||
| 147 | .sock => |s| .{ .sock = try arena.dupe(u8, s) }, | ||
| 148 | .via => |s| .{ .via = try arena.dupe(u8, s) }, | ||
| 149 | .quic => |q| .{ .quic = .{ | ||
| 150 | .host_port = try arena.dupe(u8, q.host_port), | ||
| 151 | .key_path = try arena.dupe(u8, q.key_path), | ||
| 152 | .idle_ms = q.idle_ms, | ||
| 153 | .deadline_ms = q.deadline_ms, | ||
| 154 | } }, | ||
| 155 | .hand => |h| .{ .hand = .{ | ||
| 156 | .host = try arena.dupe(u8, h.host), | ||
| 157 | .ssh_cmd = try arena.dupe(u8, h.ssh_cmd), | ||
| 158 | .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null, | ||
| 159 | .deadline_ms = h.deadline_ms, | ||
| 160 | .idle_ms = h.idle_ms, | ||
| 161 | .report_fallback = h.report_fallback, | ||
| 162 | } }, | ||
| 163 | }; | ||
| 164 | } | ||
| 165 | |||
| 166 | /// The wall at runtime: the persisted spellings, their resolved targets, | ||
| 167 | /// and the ids the browser names them by. | ||
| 168 | /// | ||
| 169 | /// Ids are handed out once and never reused, because the browser holds | ||
| 170 | /// them across a mutation it did not make: an index would silently | ||
| 171 | /// re-point a `/ws/<n>` at a different machine the moment another device | ||
| 172 | /// removed a tile. The wall file stays index-ordered — order is the whole | ||
| 173 | /// content of a wall — so `tiles` is kept parallel to `wall_state.targets` | ||
| 174 | /// and the id lives only here. | ||
| 175 | pub const Hub = struct { | ||
| 176 | alloc: std.mem.Allocator, | ||
| 177 | /// One lock for the whole hub. Mutations are rare (a human clicking) | ||
| 178 | /// and every one of them touches wall, tiles and the file together; | ||
| 179 | /// finer locking would buy nothing and cost an invariant. | ||
| 180 | mutex: std.Thread.Mutex = .{}, | ||
| 181 | tiles: std.ArrayList(HubTile) = .empty, | ||
| 182 | wall_state: wall.Wall, | ||
| 183 | next_id: u32 = 0, | ||
| 184 | /// null = don't persist (tests). Persist failures on a mutation are | ||
| 185 | /// PersistFailed, not silence: a wall the user thinks is saved and | ||
| 186 | /// isn't would be the worst kind of quiet. | ||
| 187 | state_path: ?[]const u8, | ||
| 188 | key: ?[]const u8, | ||
| 189 | idle_ms: u32, | ||
| 190 | |||
| 191 | /// Takes ownership of `w` — resolved or not, `deinit` frees it. | ||
| 192 | pub fn init( | ||
| 193 | alloc: std.mem.Allocator, | ||
| 194 | w: wall.Wall, | ||
| 195 | state_path: ?[]const u8, | ||
| 196 | key: ?[]const u8, | ||
| 197 | idle_ms: u32, | ||
| 198 | ) !Hub { | ||
| 199 | var self = Hub{ | ||
| 200 | .alloc = alloc, | ||
| 201 | .wall_state = w, | ||
| 202 | .state_path = state_path, | ||
| 203 | .key = key, | ||
| 204 | .idle_ms = idle_ms, | ||
| 205 | }; | ||
| 206 | errdefer self.deinit(); | ||
| 207 | for (self.wall_state.targets.items) |spelling| { | ||
| 208 | const tile = try self.makeTile(spelling); | ||
| 209 | errdefer { | ||
| 210 | var a = tile.arena; | ||
| 211 | a.deinit(); | ||
| 212 | } | ||
| 213 | try self.tiles.append(alloc, tile); | ||
| 214 | } | ||
| 215 | return self; | ||
| 216 | } | ||
| 217 | |||
| 218 | pub fn deinit(self: *Hub) void { | ||
| 219 | for (self.tiles.items) |*t| t.arena.deinit(); | ||
| 220 | self.tiles.deinit(self.alloc); | ||
| 221 | self.wall_state.deinit(self.alloc); | ||
| 222 | } | ||
| 223 | |||
| 224 | /// Resolve one spelling into a tile with its own arena. Takes no lock | ||
| 225 | /// itself: mutating callers hold the mutex, and init runs before the | ||
| 226 | /// Hub is reachable from any other thread. | ||
| 227 | fn makeTile(self: *Hub, spelling: []const u8) !HubTile { | ||
| 228 | var arena = std.heap.ArenaAllocator.init(self.alloc); | ||
| 229 | errdefer arena.deinit(); | ||
| 230 | const r = try resolveTile(arena.allocator(), spelling, self.key, self.idle_ms); | ||
| 231 | const id = self.next_id; | ||
| 232 | self.next_id += 1; | ||
| 233 | return .{ | ||
| 234 | .id = id, | ||
| 235 | .arena = arena, | ||
| 236 | .target = r.target, | ||
| 237 | .label = r.label, | ||
| 238 | .session = r.session, | ||
| 239 | }; | ||
| 240 | } | ||
| 241 | |||
| 242 | /// Caller holds the mutex. | ||
| 243 | fn indexOf(self: *Hub, id: u32) ?usize { | ||
| 244 | for (self.tiles.items, 0..) |t, i| if (t.id == id) return i; | ||
| 245 | return null; | ||
| 246 | } | ||
| 247 | |||
| 248 | /// Caller holds the mutex. A wall we cannot write is a mutation the | ||
| 249 | /// user must be told did not stick. | ||
| 250 | fn persist(self: *Hub) error{PersistFailed}!void { | ||
| 251 | const path = self.state_path orelse return; | ||
| 252 | wall.save(&self.wall_state, path) catch return error.PersistFailed; | ||
| 253 | } | ||
| 254 | |||
| 255 | pub fn addTile(self: *Hub, spelling: []const u8) AddError!u32 { | ||
| 256 | self.mutex.lock(); | ||
| 257 | defer self.mutex.unlock(); | ||
| 258 | |||
| 259 | const idx = try self.wall_state.add(self.alloc, spelling); | ||
| 260 | errdefer self.wall_state.remove(self.alloc, idx); | ||
| 261 | var tile = try self.makeTile(spelling); | ||
| 262 | errdefer tile.arena.deinit(); | ||
| 263 | try self.tiles.append(self.alloc, tile); | ||
| 264 | errdefer _ = self.tiles.pop(); | ||
| 265 | try self.persist(); | ||
| 266 | return tile.id; | ||
| 267 | } | ||
| 268 | |||
| 269 | /// False for an id that is already gone — a second device removing | ||
| 270 | /// the same tile is a race, not an error. | ||
| 271 | pub fn removeTile(self: *Hub, id: u32) bool { | ||
| 272 | self.mutex.lock(); | ||
| 273 | defer self.mutex.unlock(); | ||
| 274 | |||
| 275 | const idx = self.indexOf(id) orelse return false; | ||
| 276 | var tile = self.tiles.orderedRemove(idx); | ||
| 277 | // Wake the pump before freeing anything it might still be reading | ||
| 278 | // for: shutdown unblocks its recv, and it unregisters the fd | ||
| 279 | // itself. Its target is a copy in its own arena, so the arena | ||
| 280 | // dying here cannot pull the ground from under it. | ||
| 281 | // The raw syscall, ignoring errno: std.posix.shutdown calls BADF | ||
| 282 | // and NOTSOCK unreachable, and neither is a reason to abort a | ||
| 283 | // removal — a registered fd whose pump is already unwinding is | ||
| 284 | // exactly the race this wakes up, and it has nothing left to say. | ||
| 285 | if (tile.ws_fd) |fd| _ = std.os.linux.shutdown(fd, std.os.linux.SHUT.RDWR); | ||
| 286 | tile.arena.deinit(); | ||
| 287 | // Same index, by construction: tiles is parallel to the wall. | ||
| 288 | self.wall_state.remove(self.alloc, idx); | ||
| 289 | // A bool cannot carry PersistFailed. Say it on stderr rather than | ||
| 290 | // swallow it — the tile IS gone from this hub either way, and a | ||
| 291 | // wall file that disagrees is exactly what the user needs told. | ||
| 292 | self.persist() catch std.debug.print( | ||
| 293 | "muxweb: wall not saved after removing tile {d}\n", | ||
| 294 | .{id}, | ||
| 295 | ); | ||
| 296 | return true; | ||
| 297 | } | ||
| 298 | |||
| 299 | /// `ids` must name every live tile exactly once — a browser working | ||
| 300 | /// from a view that has since changed gets Stale and refetches rather | ||
| 301 | /// than imposing an order built around a tile that no longer exists. | ||
| 302 | pub fn reorderTiles(self: *Hub, ids: []const u32) error{ Stale, OutOfMemory, PersistFailed }!void { | ||
| 303 | self.mutex.lock(); | ||
| 304 | defer self.mutex.unlock(); | ||
| 305 | |||
| 306 | const n = self.tiles.items.len; | ||
| 307 | if (ids.len != n) return error.Stale; | ||
| 308 | const order = try self.alloc.alloc(usize, n); | ||
| 309 | defer self.alloc.free(order); | ||
| 310 | var seen = try self.alloc.alloc(bool, n); | ||
| 311 | defer self.alloc.free(seen); | ||
| 312 | @memset(seen, false); | ||
| 313 | for (ids, 0..) |id, dst| { | ||
| 314 | const src = self.indexOf(id) orelse return error.Stale; | ||
| 315 | if (seen[src]) return error.Stale; | ||
| 316 | seen[src] = true; | ||
| 317 | order[dst] = src; | ||
| 318 | } | ||
| 319 | const old = try self.alloc.dupe(HubTile, self.tiles.items); | ||
| 320 | defer self.alloc.free(old); | ||
| 321 | // The wall goes first: it is the one that can refuse, and it | ||
| 322 | // refuses only on a permutation we have already proven good. | ||
| 323 | self.wall_state.reorder(self.alloc, order) catch |err| switch (err) { | ||
| 324 | error.BadOrder => return error.Stale, | ||
| 325 | error.OutOfMemory => return error.OutOfMemory, | ||
| 326 | }; | ||
| 327 | for (order, 0..) |src, dst| self.tiles.items[dst] = old[src]; | ||
| 328 | // A failed save leaves the new order live and the file behind it: | ||
| 329 | // the hub is the running truth, and the next successful mutation | ||
| 330 | // writes the whole wall anyway. The caller is told, which is the | ||
| 331 | // part that matters. (addTile differs — there the rollback costs | ||
| 332 | // nothing and a POST that errors is cleaner having changed | ||
| 333 | // nothing at all.) | ||
| 334 | try self.persist(); | ||
| 335 | } | ||
| 336 | |||
| 337 | /// 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 | ||
| 339 | /// gone — a browser can always dial a tile another device just removed. | ||
| 340 | pub fn checkoutTarget( | ||
| 341 | self: *Hub, | ||
| 342 | id: u32, | ||
| 343 | ws_fd: std.posix.fd_t, | ||
| 344 | arena: std.mem.Allocator, | ||
| 345 | ) ?client.Target { | ||
| 346 | self.mutex.lock(); | ||
| 347 | defer self.mutex.unlock(); | ||
| 348 | |||
| 349 | const idx = self.indexOf(id) orelse return null; | ||
| 350 | // Copy first: a failed copy must not leave an fd registered for a | ||
| 351 | // pump that never starts. | ||
| 352 | const copy = copyTarget(arena, self.tiles.items[idx].target) catch return null; | ||
| 353 | // FIRST registration wins: two browsers on one wall run two pumps | ||
| 354 | // per tile, and overwriting would leave removeTile able to wake | ||
| 355 | // only the last one — worse, the untracked pump's release would | ||
| 356 | // then clear the tracked one's fd and removeTile would wake | ||
| 357 | // nobody. The second pump still runs and still serves its browser; | ||
| 358 | // it is simply not the shutdown-tracked one, and its own WS read | ||
| 359 | // ends it when the browser goes away. Best-effort single-fd | ||
| 360 | // tracking — a full fd list per tile is deliberately deferred | ||
| 361 | // until two-browser removal latency is shown to matter. | ||
| 362 | if (self.tiles.items[idx].ws_fd == null) self.tiles.items[idx].ws_fd = ws_fd; | ||
| 363 | return copy; | ||
| 364 | } | ||
| 365 | |||
| 366 | /// Unregister, but only the fd this caller registered: a second pump | ||
| 367 | /// releasing must not clear the tracked pump's registration. Must run | ||
| 368 | /// BEFORE the caller closes the fd, or a concurrent removeTile could | ||
| 369 | /// shutdown a number the kernel has already handed to somebody else. | ||
| 370 | pub fn releaseTile(self: *Hub, id: u32, ws_fd: std.posix.fd_t) void { | ||
| 371 | self.mutex.lock(); | ||
| 372 | defer self.mutex.unlock(); | ||
| 373 | const idx = self.indexOf(id) orelse return; | ||
| 374 | if (self.tiles.items[idx].ws_fd) |fd| { | ||
| 375 | if (fd == ws_fd) self.tiles.items[idx].ws_fd = null; | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | /// The tile list the page renders and attaches by, in wall order. | ||
| 380 | pub fn json(self: *Hub, alloc: std.mem.Allocator) ![]u8 { | ||
| 381 | self.mutex.lock(); | ||
| 382 | defer self.mutex.unlock(); | ||
| 383 | |||
| 384 | var out: std.ArrayList(u8) = .empty; | ||
| 385 | errdefer out.deinit(alloc); | ||
| 386 | try out.append(alloc, '['); | ||
| 387 | for (self.tiles.items, 0..) |t, i| { | ||
| 388 | if (i > 0) try out.append(alloc, ','); | ||
| 389 | try out.print(alloc, "{{\"id\":{d},\"label\":", .{t.id}); | ||
| 390 | // The same escape road tilesJson uses: a label is the user's | ||
| 391 | // spelling, which is theirs to make unparseable. | ||
| 392 | try appendJsonString(alloc, &out, t.label); | ||
| 393 | try out.appendSlice(alloc, ",\"session\":"); | ||
| 394 | try appendJsonString(alloc, &out, t.session); | ||
| 395 | try out.append(alloc, '}'); | ||
| 396 | } | ||
| 397 | try out.append(alloc, ']'); | ||
| 398 | return out.toOwnedSlice(alloc); | ||
| 399 | } | ||
| 400 | }; | ||
| 401 | |||
| 55 | /// The embedded page, injected by the exe root (webhub_main @embedFiles | 402 | /// The embedded page, injected by the exe root (webhub_main @embedFiles |
| 56 | /// them; tests inject fakes). | 403 | /// them; tests inject fakes). |
| 57 | pub const Assets = struct { | 404 | pub const Assets = struct { |
| @@ -575,7 +922,14 @@ pub fn serveConn( | |||
| 575 | var req = server.receiveHead() catch return; | 922 | var req = server.receiveHead() catch return; |
| 576 | const path = req.head.target; | 923 | const path = req.head.target; |
| 577 | 924 | ||
| 578 | if (wsTileIndex(path, targets.len)) |idx| { | 925 | // The argv-driven flow still names tiles by index, and here id == |
| 926 | // index because nothing removes a tile; the range check that used | ||
| 927 | // to live in the path parser lives here until the Hub owns this. | ||
| 928 | const wanted: ?usize = if (wsTileId(path)) |id| | ||
| 929 | (if (id < targets.len) @as(usize, id) else null) | ||
| 930 | else | ||
| 931 | null; | ||
| 932 | if (wanted) |idx| { | ||
| 579 | // Origin BEFORE upgrade, always: the refusal must happen while | 933 | // Origin BEFORE upgrade, always: the refusal must happen while |
| 580 | // this is still HTTP, so a hostile page gets a 403 and never a | 934 | // this is still HTTP, so a hostile page gets a 403 and never a |
| 581 | // socket. std's upgradeRequested does not look at Origin. | 935 | // socket. std's upgradeRequested does not look at Origin. |
| @@ -659,15 +1013,110 @@ test "origin: exactly our two spellings pass, everything else refuses" { | |||
| 659 | } | 1013 | } |
| 660 | } | 1014 | } |
| 661 | 1015 | ||
| 662 | test "ws path: /ws/<idx> in range, null for everything else" { | 1016 | test "ws path by id: parses, no range opinion" { |
| 663 | try std.testing.expectEqual(@as(?usize, 0), wsTileIndex("/ws/0", 3)); | 1017 | try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0")); |
| 664 | try std.testing.expectEqual(@as(?usize, 2), wsTileIndex("/ws/2", 3)); | 1018 | try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41")); |
| 665 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/3", 3)); // over | 1019 | try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/")); |
| 666 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/", 3)); | 1020 | try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/x")); |
| 667 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/x", 3)); | 1021 | try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/-1")); |
| 668 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/-1", 3)); | 1022 | try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0")); |
| 669 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/wsx/0", 3)); | 1023 | } |
| 670 | try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/", 3)); | 1024 | |
| 1025 | test "hub: ids are stable across remove and reorder; json is wall order" { | ||
| 1026 | const alloc = std.testing.allocator; | ||
| 1027 | var w = wall.Wall{}; | ||
| 1028 | _ = try w.add(alloc, "--sock /tmp/a"); | ||
| 1029 | _ = try w.add(alloc, "--sock /tmp/b#s"); | ||
| 1030 | var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); | ||
| 1031 | defer hub.deinit(); | ||
| 1032 | |||
| 1033 | const j0 = try hub.json(alloc); | ||
| 1034 | defer alloc.free(j0); | ||
| 1035 | try std.testing.expectEqualStrings( | ||
| 1036 | \\[{"id":0,"label":"--sock /tmp/a","session":""},{"id":1,"label":"--sock /tmp/b#s","session":"s"}] | ||
| 1037 | , j0); | ||
| 1038 | |||
| 1039 | const id2 = try hub.addTile("--sock /tmp/c"); | ||
| 1040 | try std.testing.expectEqual(@as(u32, 2), id2); | ||
| 1041 | try std.testing.expect(hub.removeTile(1)); | ||
| 1042 | try std.testing.expect(!hub.removeTile(1)); // already gone | ||
| 1043 | try hub.reorderTiles(&.{ 2, 0 }); | ||
| 1044 | try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view | ||
| 1045 | |||
| 1046 | const j1 = try hub.json(alloc); | ||
| 1047 | defer alloc.free(j1); | ||
| 1048 | try std.testing.expectEqualStrings( | ||
| 1049 | \\[{"id":2,"label":"--sock /tmp/c","session":""},{"id":0,"label":"--sock /tmp/a","session":""}] | ||
| 1050 | , j1); | ||
| 1051 | } | ||
| 1052 | |||
| 1053 | test "hub: checkout copies the target into the caller's arena; release unregisters" { | ||
| 1054 | const alloc = std.testing.allocator; | ||
| 1055 | var w = wall.Wall{}; | ||
| 1056 | _ = try w.add(alloc, "--sock /tmp/a"); | ||
| 1057 | var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); | ||
| 1058 | defer hub.deinit(); | ||
| 1059 | |||
| 1060 | var arena = std.heap.ArenaAllocator.init(alloc); | ||
| 1061 | defer arena.deinit(); | ||
| 1062 | const t = hub.checkoutTarget(0, 7, arena.allocator()).?; | ||
| 1063 | // 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. | ||
| 1065 | try std.testing.expect(hub.removeTile(0)); | ||
| 1066 | try std.testing.expectEqualStrings("/tmp/a", t.sock); | ||
| 1067 | 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())); | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { | ||
| 1072 | const alloc = std.testing.allocator; | ||
| 1073 | var w = wall.Wall{}; | ||
| 1074 | _ = try w.add(alloc, "--sock /tmp/a"); | ||
| 1075 | var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); | ||
| 1076 | defer hub.deinit(); | ||
| 1077 | |||
| 1078 | var arena = std.heap.ArenaAllocator.init(alloc); | ||
| 1079 | defer arena.deinit(); | ||
| 1080 | |||
| 1081 | // Two browsers on one wall: both pumps get a target and both serve. | ||
| 1082 | const first = hub.checkoutTarget(0, 7, arena.allocator()).?; | ||
| 1083 | const second = hub.checkoutTarget(0, 8, arena.allocator()).?; | ||
| 1084 | try std.testing.expectEqualStrings("/tmp/a", first.sock); | ||
| 1085 | try std.testing.expectEqualStrings("/tmp/a", second.sock); | ||
| 1086 | |||
| 1087 | // The first registration is the tracked one, and the untracked pump's | ||
| 1088 | // release must leave it alone — clearing it would leave removeTile | ||
| 1089 | // with nobody to wake. | ||
| 1090 | try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd); | ||
| 1091 | hub.releaseTile(0, 8); | ||
| 1092 | try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd); | ||
| 1093 | hub.releaseTile(0, 7); | ||
| 1094 | try std.testing.expectEqual(@as(?std.posix.fd_t, null), hub.tiles.items[0].ws_fd); | ||
| 1095 | } | ||
| 1096 | |||
| 1097 | test "hub: addTile persists; a reloaded wall matches" { | ||
| 1098 | const alloc = std.testing.allocator; | ||
| 1099 | var tmp = std.testing.tmpDir(.{}); | ||
| 1100 | defer tmp.cleanup(); | ||
| 1101 | const dir_path = try tmp.dir.realpathAlloc(alloc, "."); | ||
| 1102 | defer alloc.free(dir_path); | ||
| 1103 | const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{dir_path}); | ||
| 1104 | defer alloc.free(path); | ||
| 1105 | |||
| 1106 | { | ||
| 1107 | var hub = try Hub.init(alloc, wall.Wall{}, path, null, client.quic_idle_ms_default); | ||
| 1108 | defer hub.deinit(); | ||
| 1109 | _ = try hub.addTile("--sock /tmp/a"); | ||
| 1110 | _ = try hub.addTile("--sock /tmp/b"); | ||
| 1111 | _ = try hub.addTile("--sock /tmp/c"); | ||
| 1112 | try std.testing.expect(hub.removeTile(1)); | ||
| 1113 | try hub.reorderTiles(&.{ 2, 0 }); | ||
| 1114 | } | ||
| 1115 | var r = try wall.load(alloc, path); | ||
| 1116 | defer r.deinit(alloc); | ||
| 1117 | try std.testing.expectEqual(@as(usize, 2), r.targets.items.len); | ||
| 1118 | try std.testing.expectEqualStrings("--sock /tmp/c", r.targets.items[0]); | ||
| 1119 | try std.testing.expectEqualStrings("--sock /tmp/a", r.targets.items[1]); | ||
| 671 | } | 1120 | } |
| 672 | 1121 | ||
| 673 | test "routes: the three assets with their content types, 404 for the rest" { | 1122 | test "routes: the three assets with their content types, 404 for the rest" { |