a73x

531b8767

feat: webhub Hub — runtime wall, stable tile ids, checkout/release

a73x   2026-08-18 17:58

Commit message
feat: webhub Hub — runtime wall, stable tile ids, checkout/release

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" {