a73x

0bd41f5a

feat: the hub serves the layout's panes and writes a birth back into it

a73x   2026-09-03 05:20

Commit message
feat: the hub serves the layout's panes and writes a birth back into it

`mux web` is now the same wall as the terminal's: `/tiles` is the layout
file's leaves in tree order, and the poll GRADES them. `applyList` no
longer births — a session someone started elsewhere on a listed daemon is
not a pane until a write to the file makes it one — and no longer
vanishes: a pane whose session the daemon stops listing wears `state:
gone` on `/tiles` and keeps its place and its socket, because only the
file takes a pane off a wall. The one-list grace stays, and a name that
comes back clears the badge; the tile's pump was already redialling on
its own backoff, so nothing needs a doorbell.

`Hub.init` takes the leaves and births one tile each, in file order, so
ids are tree order and `tiles` reads the way the terminal wall paints.
`birth` no longer regroups by host: the old insert-in-host-order was the
whole point of a wall assembled from lists, and it is exactly the order a
layout must not be forced into.

`readLeaves` maps each `HOST#SESSION` spelling to a spec index with the
terminal wall's strictness — an unlisted host, a leaf with no session, a
bad name, or more panes than `layout.max_leaves` refuses the FILE and
names the line, because a hub that silently served part of a wall is a
wall the user cannot see is short. A missing file is an empty wall, not a
refusal. `appendLeaf` is the write: read, insert beside the anchor,
serialize, atomic rename — the same read-modify-write the hosts file
already accepts losing an update to.

`+` on a tile now births AND authors. The birth's round trip runs
unlocked as before; the file write and the new tile then share one hold
of the hub mutex, so a `/tiles` can never fall between them and two
browsers pressing `+` at once cannot each read the file before the other
wrote it.

`layout.max_leaves` states the wall's 32 in the module both fronts read,
so the hub cannot serve a wall no terminal could open.

The `+` refuses before it dials. `roomForLeaf` checks the file for room
and for a leaf already spelling that host and session under the hub
mutex, so a full wall or a second press inside one poll interval is a
502 `WallFull` or a 409 `duplicate` with nothing started. Dialling first
would leave a live session behind a refusal that no wall names.

build.zig
Old New
@@ -229,7 +229,7 @@ const mod_table = [_]ModSpec{
229 // module's root directory is the dirname of its root file, and 229 // module's root directory is the dirname of its root file, and
230 // `@import("../client/webhub.zig")` from src/cli is "import of file 230 // `@import("../client/webhub.zig")` from src/cli is "import of file
231 // outside module path" — a compiler rule, not a table choice. 231 // outside module path" — a compiler rule, not a table choice.
232 .{ .name = "webhub", .path = "src/client/webhub.zig", .imports = &.{ "term", "client" }, .quic_tests = true }, 232 .{ .name = "webhub", .path = "src/client/webhub.zig", .imports = &.{ "term", "client" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
233 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of 233 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
234 // which can be ZOOMED — promoted to the terminal's size and typed 234 // which can be ZOOMED — promoted to the terminal's size and typed
235 // through. It sits beside webhub for the same reason — both are fronts 235 // through. It sits beside webhub for the same reason — both are fronts
src/cli/webhub_main.zig
Old New
@@ -1,10 +1,12 @@
1 //! `mux web` — the hub mode: serves the wall page on 127.0.0.1 and pumps one 1 //! `mux web` — the hub mode: serves the wall page on 127.0.0.1 and pumps one
2 //! WebSocket per tile. 2 //! WebSocket per tile.
3 //! 3 //!
4 //! The wall is built from the same hosts file as the CLI. Hosts supplied on the 4 //! The wall is the LAYOUT file, the same one the terminal wall reads and
5 //! command line are recorded in that file before it is loaded. Session suffixes 5 //! writes: `/tiles` is that file's panes in tree order, and the poll grades
6 //! are rejected because this command lists daemons and discovers their live 6 //! them. Hosts supplied on the command line are recorded in the hosts file
7 //! sessions rather than opening a named session. 7 //! before it is loaded, because a pane may only name a listed daemon.
8 //! Session suffixes are rejected there because a host line names a machine;
9 //! a pane is authored on a wall, not on this command line.
8 10
9 const std = @import("std"); 11 const std = @import("std");
10 const client = @import("client"); 12 const client = @import("client");
@@ -20,10 +22,10 @@ const usage =
20 \\ each HOST is a daemon: HOST | --sock PATH | quic://HOST[:PORT] 22 \\ each HOST is a daemon: HOST | --sock PATH | quic://HOST[:PORT]
21 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the 23 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the
22 \\ spelling the hosts file holds; `mux hosts add` takes both too 24 \\ spelling the hosts file holds; `mux hosts add` takes both too
23 \\ a HOST on the line is added to the hosts file (deduped); the FILE is 25 \\ a HOST on the line is added to the hosts file (deduped); the WALL is
24 \\ the wall either way, and its tiles are those daemons' live sessions 26 \\ the layout file, and its tiles are that file's panes
25 \\ no #SESSION: the wall lists daemons and shows every session they 27 \\ no #SESSION: a host line names a daemon, and a pane is authored on a
26 \\ have — `mux hosts rm` is how a daemon leaves it 28 \\ wall — `mux hosts rm` is how a daemon leaves the file
27 \\ quic:// hosts use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key 29 \\ quic:// hosts use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
28 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 30 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
29 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, 31 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only,
@@ -140,8 +142,25 @@ pub fn main(args: []const [:0]const u8) !u8 {
140 }; 142 };
141 } 143 }
142 144
143 var hub = try webhub.Hub.init(arena, specs); 145 // The layout is the wall. `readLeaves` has already printed the line to
146 // fix when it refuses one, and an empty wall is what the browser gets:
147 // a page that served half a layout would be a wall the user cannot see
148 // is short, and one that refused to start would take the whole hub down
149 // over a file the terminal wall can repair.
150 const layout_path = try hosts.layoutPath(arena);
151 const leaves: []const webhub.Leaf = webhub.readLeaves(arena, layout_path, specs) catch |err| switch (err) {
152 error.BadLayout => &.{},
153 else => return err,
154 };
155 // Only worth saying when there is somewhere for a pane to live: an
156 // empty hosts file already printed its own guidance above.
157 if (leaves.len == 0 and h.lines.items.len != 0) {
158 std.debug.print("mux web: no panes ({s}); add one from a terminal wall\n", .{layout_path});
159 }
160
161 var hub = try webhub.Hub.init(arena, specs, leaves);
144 defer hub.deinit(); 162 defer hub.deinit();
163 hub.layout_path = layout_path;
145 164
146 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; 165 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable;
147 var listener = addr.listen(.{ .reuse_address = true }) catch |err| { 166 var listener = addr.listen(.{ .reuse_address = true }) catch |err| {
@@ -150,8 +169,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
150 }; 169 };
151 defer listener.deinit(); 170 defer listener.deinit();
152 171
153 // Print the listening address immediately. Tiles are announced later by 172 // Print the listening address immediately. The tiles were announced by
154 // `Hub.birth` as polling discovers live sessions. 173 // `Hub.birth` above, one per pane, as the layout was read.
155 std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{ 174 std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{
156 parsed.port, 175 parsed.port,
157 std.os.linux.getpid(), 176 std.os.linux.getpid(),
src/client/layout.zig
Old New
@@ -131,6 +131,15 @@ fn spanDistance(point: u16, start: u16, len: u16) u16 {
131 return 0; 131 return 0;
132 } 132 }
133 133
134 /// The most panes a layout file may carry, and the number `wallview.max_tiles`
135 /// IS: the terminal wall and the browser hub read one file, so a file one
136 /// front writes has to be a file the other can seat, and a wall that silently
137 /// seated part of one is a wall the user cannot see is short. It lives here
138 /// rather than on the wall because the FILE is what both fronts share. The
139 /// parse's own ceiling is 255, a leaf id being a u8; this is the product's
140 /// bound, not the encoding's, and the wall's hard array is why it is small.
141 pub const max_leaves: usize = 32;
142
134 pub const Tree = struct { 143 pub const Tree = struct {
135 alloc: std.mem.Allocator, 144 alloc: std.mem.Allocator,
136 root: ?*Node = null, 145 root: ?*Node = null,
src/client/webhub.zig
Old New
@@ -9,6 +9,11 @@
9 const std = @import("std"); 9 const std = @import("std");
10 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
11 const client = @import("client"); 11 const client = @import("client");
12 // The layout file is the wall, and the hub reads and writes the same one
13 // the terminal wall does: `client.layout` is the grammar, `client.hosts`
14 // the atomic write. Two spellings of either would be two walls.
15 const layout = client.layout;
16 const hosts = client.hosts;
12 17
13 pub const default_port: u16 = 7681; 18 pub const default_port: u16 = 7681;
14 19
@@ -76,17 +81,24 @@ fn pollHubHost(h: *HubHost) void {
76 h.poll.run(h.spec.poll_target, .{ .ctx = h, .keep = HubHost.keep, .wake = HubHost.wake }); 81 h.poll.run(h.spec.poll_target, .{ .ctx = h, .keep = HubHost.keep, .wake = HubHost.wake });
77 } 82 }
78 83
79 /// One runtime tile: a live session on a listed daemon, and nothing the 84 /// One pane of the layout at runtime, and nothing else: the file is the
80 /// browser authored. It owns its session name because the name arrived in 85 /// wall, so a session on a listed daemon that no leaf names is not a tile.
81 /// a poll buffer the next poll overwrites. 86 /// It owns its session name because a spawn's name arrives in a stack
87 /// buffer and a seed's in bytes `readLeaves` frees.
82 const HubTile = struct { 88 const HubTile = struct {
83 id: u32, 89 id: u32,
84 host: usize, 90 host: usize,
85 session: []const u8, 91 session: []const u8,
86 /// Registered while a pump owns a WS for this tile. A vanishing uses 92 /// The poller's grade, and the only thing a list may change about a
87 /// it to shutdown(2) the socket, which unblocks the pump's reads; the 93 /// pane. `.gone` is the daemon saying it has no such session; it is a
88 /// pump unregisters (under the hub mutex) BEFORE serveConn closes the 94 /// badge, never a removal, because only the layout file takes a pane
89 /// fd, so a shutdown can never hit a recycled fd. 95 /// off the wall.
96 state: TileState = .connecting,
97 /// Registered while a pump owns a WS for this tile, FIRST pump wins.
98 /// Two browsers run two pumps on one tile, and the registration is what
99 /// keeps the second's release from clearing the first's fd. The pump
100 /// unregisters (under the hub mutex) BEFORE serveConn closes the fd, so
101 /// nothing here can ever name a number the kernel has recycled.
90 ws_fd: ?std.posix.fd_t = null, 102 ws_fd: ?std.posix.fd_t = null,
91 /// One list's grace. The poll's connect and its pass through the 103 /// One list's grace. The poll's connect and its pass through the
92 /// daemon take milliseconds a daemon mid-answer can be descheduled 104 /// daemon take milliseconds a daemon mid-answer can be descheduled
@@ -95,10 +107,199 @@ const HubTile = struct {
95 missed_once: bool = false, 107 missed_once: bool = false,
96 }; 108 };
97 109
98 /// The wall at runtime: the hosts file's daemons, their live sessions as tiles, 110 /// One pane of the layout, as the hub holds it: which listed host, and the
99 /// and the ids the browser names them by. Ids are handed out once and never 111 /// session on it. `host` indexes the same `specs` the Hub was built from, so
100 /// reused — the browser holds them across changes it did not make, and a 112 /// a leaf stays a match key and never becomes an address.
101 /// recycled one would re-point a `/ws/<n>` at a different shell. 113 pub const Leaf = struct { host: usize, session: []const u8 };
114
115 /// The layout's leaves in tree order, each mapped to a spec index. The same
116 /// strictness as the terminal wall: a leaf the hosts file cannot place, or
117 /// one with no session, refuses the FILE, because a hub that silently served
118 /// part of a wall would be a wall the user cannot see is short. A MISSING
119 /// file is not a refusal — nothing was authored, so there is nothing to fix
120 /// and nothing to name.
121 ///
122 /// The refusal prints here rather than reporting a line to the caller: the
123 /// bytes the line points into are this function's own, and `mux web:` is
124 /// already this file's voice (`Hub.birth` and `Hub.start` print in it).
125 pub fn readLeaves(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec) ![]Leaf {
126 const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
127 error.FileNotFound => return alloc.alloc(Leaf, 0),
128 else => return e,
129 };
130 defer alloc.free(bytes);
131 var bad_line: []const u8 = "";
132 var parsed = layout.parseReporting(alloc, bytes, &bad_line) orelse {
133 refuseLayout(path, bad_line);
134 return error.BadLayout;
135 };
136 defer parsed.deinit(alloc);
137 // The same ceiling the terminal wall refuses past, and for the same
138 // reason: a hub that served a 33rd pane would serve a wall no terminal
139 // could open, and the two fronts read one file.
140 if (parsed.spellings.items.len > layout.max_leaves) {
141 refuseLayout(path, parsed.spellings.items[layout.max_leaves]);
142 return error.BadLayout;
143 }
144 var out: std.ArrayListUnmanaged(Leaf) = .{};
145 errdefer {
146 for (out.items) |l| alloc.free(l.session);
147 out.deinit(alloc);
148 }
149 for (parsed.spellings.items) |sp| {
150 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse {
151 refuseLayout(path, sp);
152 return error.BadLayout;
153 };
154 const sess = sp[cut + 1 ..];
155 if (!proto.validSessionName(sess)) {
156 refuseLayout(path, sp);
157 return error.BadLayout;
158 }
159 // The host part must be ON the hosts file, byte for byte: nothing a
160 // layout names may be resurrected from the layout alone.
161 const hi = for (specs, 0..) |s, i| {
162 if (std.mem.eql(u8, s.spelling, sp[0..cut])) break i;
163 } else {
164 refuseLayout(path, sp);
165 return error.BadLayout;
166 };
167 // A repeated leaf refuses the FILE, exactly as `wall_layout.seedLayout`
168 // refuses it: a host's list names a session once, so a second pane on
169 // the same (host, session) could never bind, and the two fronts must
170 // agree about which files are walls or one of them loses its wall to
171 // a file the other happily served.
172 for (out.items) |l| {
173 if (l.host == hi and std.mem.eql(u8, l.session, sess)) {
174 refuseLayout(path, sp);
175 return error.BadLayout;
176 }
177 }
178 try out.append(alloc, .{ .host = hi, .session = try alloc.dupe(u8, sess) });
179 }
180 return out.toOwnedSlice(alloc);
181 }
182
183 fn refuseLayout(path: []const u8, line: []const u8) void {
184 std.debug.print("mux web: layout ignored ({s}): {s}\n", .{ path, line });
185 }
186
187 /// The mirror of `readLeaves` for a caller that did not hand it an arena.
188 /// `webhub_main` does, and never calls this; a test allocator notices.
189 pub fn freeLeaves(alloc: std.mem.Allocator, leaves: []const Leaf) void {
190 for (leaves) |l| alloc.free(l.session);
191 alloc.free(leaves);
192 }
193
194 /// The layout as a tree, for the two callers that need one: a MISSING file
195 /// is an empty tree, because nothing was authored, and a file that is not a
196 /// layout names the line it gave up on before refusing. `layout.parse` would
197 /// drop that line, and a spawn refused by a bad file must name it the same
198 /// way the start-up read does.
199 fn readTree(alloc: std.mem.Allocator, path: []const u8) !layout.ParsedLayout {
200 const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
201 error.FileNotFound => return .{ .tree = layout.Tree.init(alloc) },
202 else => return e,
203 };
204 defer alloc.free(bytes);
205 var bad_line: []const u8 = "";
206 return layout.parseReporting(alloc, bytes, &bad_line) orelse {
207 refuseLayout(path, bad_line);
208 return error.BadLayout;
209 };
210 }
211
212 /// Where the file already names this pane, if it does. One spelling per
213 /// (host, session): the hosts file's spelling and the session name are what
214 /// a leaf IS, so this answers both "where does the new pane go" and "is it
215 /// already there".
216 fn leafIndex(spellings: []const []const u8, specs: []const client.HostSpec, leaf: Leaf) ?u8 {
217 for (spellings, 0..) |sp, i| {
218 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue;
219 if (std.mem.eql(u8, sp[0..cut], specs[leaf.host].spelling) and
220 std.mem.eql(u8, sp[cut + 1 ..], leaf.session)) return @intCast(i);
221 }
222 return null;
223 }
224
225 /// Whether the file could hold this pane, asked BEFORE anything is born.
226 /// `appendLeaf` refuses the same three ways, but by then the daemon has a
227 /// live session and the browser has a 502: a session nobody asked for, on no
228 /// wall, that only `mux a` or a terminal could find again. `Duplicate` is the
229 /// one that costs most — two `+` inside one poll interval read the same
230 /// session list, so both take the same next free name, and the daemon
231 /// answers the second by ATTACHING to the session the first made. The window
232 /// between this and the append is another writer taking the same file in the
233 /// same instant; `appendLeaf` refuses there too, and the read-modify-write's
234 /// accepted cost is a lost update, never a repeated leaf.
235 pub fn roomForLeaf(
236 alloc: std.mem.Allocator,
237 path: []const u8,
238 specs: []const client.HostSpec,
239 new: Leaf,
240 ) !void {
241 var parsed = try readTree(alloc, path);
242 defer parsed.deinit(alloc);
243 if (parsed.spellings.items.len >= layout.max_leaves) return error.WallFull;
244 if (leafIndex(parsed.spellings.items, specs, new) != null) return error.Duplicate;
245 }
246
247 /// Read-modify-write over the atomic rename `hosts.saveBytes` does. Two
248 /// writers in the same instant lose one update; the hosts file accepts the
249 /// same, and the terminal wall re-reads the file on its next start.
250 ///
251 /// `beside` places the new pane where `Tree.insert` puts a leaf: the next
252 /// sibling of the anchor, which is immediately after it in the depth-first
253 /// walk the file is written in. A null anchor, or one this file does not
254 /// name, falls back to the first leaf rather than refusing: by the time this
255 /// runs the session EXISTS, so the pane has to land somewhere, and an anchor
256 /// the file no longer names means a terminal wall rewrote the file between
257 /// the `+` and here. The terminal wall's own insert always has its focused
258 /// pane in hand and needs no such fallback.
259 pub fn appendLeaf(
260 alloc: std.mem.Allocator,
261 path: []const u8,
262 specs: []const client.HostSpec,
263 beside: ?Leaf,
264 new: Leaf,
265 ) !void {
266 var parsed = try readTree(alloc, path);
267 defer parsed.deinit(alloc);
268
269 // Every caller's host index came off a `Leaf` the hosts file placed;
270 // asserted rather than checked because a stray one is a caller bug, not
271 // a file the user can fix.
272 std.debug.assert(new.host < specs.len);
273 if (beside) |b| std.debug.assert(b.host < specs.len);
274 if (parsed.spellings.items.len >= layout.max_leaves) return error.WallFull;
275 // Before the tree is touched: a file naming one pane twice is a file
276 // `wall_layout.seedLayout` refuses WHOLE, so writing the second leaf here
277 // would cost the next terminal `mux` on this machine its entire wall.
278 if (leafIndex(parsed.spellings.items, specs, new) != null) return error.Duplicate;
279 const new_id: u8 = @intCast(parsed.spellings.items.len);
280 const anchor: ?u8 = if (beside) |b| leafIndex(parsed.spellings.items, specs, b) else null;
281 const new_sp = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ specs[new.host].spelling, new.session });
282 // Owned by `parsed` from here on, so no errdefer: a second free is what
283 // a scope-local one would become the moment the append succeeded.
284 parsed.spellings.append(alloc, new_sp) catch |e| {
285 alloc.free(new_sp);
286 return e;
287 };
288 if (parsed.tree.root == null)
289 try parsed.tree.addFirst(new_id)
290 else
291 try parsed.tree.insert(anchor orelse 0, new_id);
292
293 var buf: std.ArrayListUnmanaged(u8) = .{};
294 defer buf.deinit(alloc);
295 try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc));
296 try hosts.saveBytes(path, buf.items);
297 }
298
299 /// The wall at runtime: the LAYOUT's panes as tiles, in the file's tree
300 /// order, and the ids the browser names them by. Ids are handed out once and
301 /// never reused — the browser holds them across changes it did not make, and
302 /// a recycled one would re-point a `/ws/<n>` at a different shell.
102 pub const Hub = struct { 303 pub const Hub = struct {
103 alloc: std.mem.Allocator, 304 alloc: std.mem.Allocator,
104 /// One lock for the tile list. Every writer is a poller's wake or a 305 /// One lock for the tile list. Every writer is a poller's wake or a
@@ -111,13 +312,35 @@ pub const Hub = struct {
111 /// Whether `start` has handed `hosts` to threads that never stop. Read 312 /// Whether `start` has handed `hosts` to threads that never stop. Read
112 /// by `deinit` alone, and both run on the thread that built the Hub. 313 /// by `deinit` alone, and both run on the thread that built the Hub.
113 polling: bool = false, 314 polling: bool = false,
114 315 /// Borrowed beside `hosts`, because `appendLeaf` spells a pane out of a
115 /// `specs` is borrowed: webhub_main resolves the hosts file into an arena 316 /// spec's own `spelling` and a leaf's `host` indexes this slice.
116 /// that outlives the process's accept loop. 317 specs: []const client.HostSpec,
117 pub fn init(alloc: std.mem.Allocator, specs: []const client.HostSpec) !Hub { 318 /// Where a birth writes the new pane. `webhub_main` sets it after
319 /// `init`, which cannot: the path comes from the environment and the Hub
320 /// is built by value. Empty means nobody handed this hub a layout, and a
321 /// spawn refuses rather than birthing a session no wall would show.
322 layout_path: []const u8 = "",
323
324 /// `specs` and `leaves` are borrowed: webhub_main resolves the hosts file
325 /// and reads the layout into an arena that outlives the accept loop.
326 /// One tile per leaf, ids in leaf order — which is the file's tree order,
327 /// so `/tiles` and the terminal wall lay the same panes out the same way.
328 pub fn init(alloc: std.mem.Allocator, specs: []const client.HostSpec, leaves: []const Leaf) !Hub {
118 const rows = try alloc.alloc(HubHost, specs.len); 329 const rows = try alloc.alloc(HubHost, specs.len);
330 errdefer alloc.free(rows);
119 for (rows, specs) |*r, spec| r.* = .{ .spec = spec }; 331 for (rows, specs) |*r, spec| r.* = .{ .spec = spec };
120 return .{ .alloc = alloc, .hosts = rows }; 332 var self: Hub = .{ .alloc = alloc, .hosts = rows, .specs = specs };
333 errdefer {
334 for (self.tiles.items) |t| alloc.free(t.session);
335 self.tiles.deinit(alloc);
336 }
337 for (leaves) |l| {
338 // A leaf naming no listed host is `readLeaves`' refusal, not a
339 // tile: `birth` and every checkout index `hosts` by this number.
340 if (l.host >= rows.len) return error.BadLayout;
341 try self.birth(l.host, l.session, self.tiles.items.len);
342 }
343 return self;
121 } 344 }
122 345
123 pub fn deinit(self: *Hub) void { 346 pub fn deinit(self: *Hub) void {
@@ -149,73 +372,55 @@ pub const Hub = struct {
149 } 372 }
150 } 373 }
151 374
152 /// One host's answer, turned into births and vanishings. Callable 375 /// One host's answer, turned into GRADES on the panes that host already
153 /// without a thread, which is what lets the rule be tested without a 376 /// owns. It adds nothing: the layout file is the wall, and a session
154 /// daemon: the poller's wake is the only other caller. 377 /// somebody started elsewhere on a listed daemon is not a pane until
378 /// someone writes it into the file. Callable without a thread, which is
379 /// what lets the rule be tested without a daemon: the poller's wake is
380 /// the only other caller.
155 pub fn applyList(self: *Hub, host_idx: usize, list: []const u8, reachable: bool) void { 381 pub fn applyList(self: *Hub, host_idx: usize, list: []const u8, reachable: bool) void {
156 self.mutex.lock(); 382 self.mutex.lock();
157 defer self.mutex.unlock(); 383 defer self.mutex.unlock();
158 // Only a LIST drives the diff. A host that has gone quiet keeps 384 // Only a LIST grades. A host that has gone quiet keeps its panes as
159 // its tiles, which reconnect on their own; vanishing them on a 385 // they were, and they reconnect on their own; grading them on a
160 // failed poll would tear a wall down over one dropped packet, and 386 // failed poll would mark a whole wall gone over one dropped packet,
161 // spending the grace on silence would do it one poll later. 387 // and spending the grace on silence would do it one poll later.
162 if (!reachable) return; 388 if (!reachable) return;
163 // `proto.sessionsIter` carries the trust policy: a peer's reply is 389 for (self.tiles.items) |*t| {
164 // bounded only in total, so it yields only lines that are names. 390 // Only a host's own sessions are its list's to grade: two
165 var it = proto.sessionsIter(list); 391 // daemons may have a session of the same name.
166 while (it.next()) |name| { 392 if (t.host != host_idx) continue;
167 if (self.find(host_idx, name) != null) continue;
168 self.birth(host_idx, name) catch continue;
169 }
170 var i: usize = 0;
171 while (i < self.tiles.items.len) {
172 const t = &self.tiles.items[i];
173 // Only a host's own sessions are its list's to keep or drop:
174 // two daemons may have a session of the same name.
175 if (t.host != host_idx) {
176 i += 1;
177 continue;
178 }
179 if (proto.sessionsHas(list, t.session)) { 393 if (proto.sessionsHas(list, t.session)) {
180 t.missed_once = false; 394 t.missed_once = false;
181 i += 1; 395 // Clearing the badge changes what `/tiles` says and nothing
396 // else. The pump is not parked: after an `exit_status` it
397 // reconnects the transport and idles on it, and the ATTACH is
398 // the browser's to send — the page latches `exited` and
399 // re-attaches on its own `up`. Nothing here is a doorbell.
400 if (t.state == .gone) t.state = .connecting;
182 continue; 401 continue;
183 } 402 }
184 if (!t.missed_once) { 403 if (!t.missed_once) {
185 t.missed_once = true; 404 t.missed_once = true;
186 i += 1;
187 continue; 405 continue;
188 } 406 }
189 self.vanish(i); 407 t.state = .gone;
190 } 408 }
191 } 409 }
192 410
193 /// Caller holds the mutex. 411 /// Caller holds the mutex.
194 fn find(self: *Hub, host_idx: usize, name: []const u8) ?usize {
195 for (self.tiles.items, 0..) |t, i| {
196 if (t.host == host_idx and std.mem.eql(u8, t.session, name)) return i;
197 }
198 return null;
199 }
200
201 /// Caller holds the mutex.
202 fn indexOf(self: *Hub, id: u32) ?usize { 412 fn indexOf(self: *Hub, id: u32) ?usize {
203 for (self.tiles.items, 0..) |t, i| if (t.id == id) return i; 413 for (self.tiles.items, 0..) |t, i| if (t.id == id) return i;
204 return null; 414 return null;
205 } 415 }
206 416
207 /// Caller holds the mutex. Inserted rather than appended, so `tiles` 417 /// Caller holds the mutex. `at` is where the pane goes in `tiles`, which
208 /// reads in HOST order however the pollers happen to be scheduled; 418 /// is the FILE's tree order and nothing else: the seed appends in leaf
209 /// within a host it is the daemon's own list order, because that is 419 /// order, and a spawn puts its new pane immediately after the one it was
210 /// the order `applyList` walks the list in. 420 /// born beside — exactly where `Tree.insert` puts that leaf in the file.
211 fn birth(self: *Hub, host_idx: usize, name: []const u8) !void { 421 fn birth(self: *Hub, host_idx: usize, name: []const u8, at: usize) !void {
212 const own = try self.alloc.dupe(u8, name); 422 const own = try self.alloc.dupe(u8, name);
213 errdefer self.alloc.free(own); 423 errdefer self.alloc.free(own);
214 var at = self.tiles.items.len;
215 for (self.tiles.items, 0..) |t, i| if (t.host > host_idx) {
216 at = i;
217 break;
218 };
219 const id = self.next_id; 424 const id = self.next_id;
220 try self.tiles.insert(self.alloc, at, .{ .id = id, .host = host_idx, .session = own }); 425 try self.tiles.insert(self.alloc, at, .{ .id = id, .host = host_idx, .session = own });
221 self.next_id += 1; 426 self.next_id += 1;
@@ -224,17 +429,6 @@ pub const Hub = struct {
224 std.debug.print("mux web: tile {d}: {s}#{s}\n", .{ id, self.hosts[host_idx].spec.spelling, name }); 429 std.debug.print("mux web: tile {d}: {s}#{s}\n", .{ id, self.hosts[host_idx].spec.spelling, name });
225 } 430 }
226 431
227 /// Caller holds the mutex.
228 fn vanish(self: *Hub, i: usize) void {
229 const t = self.tiles.orderedRemove(i);
230 // Wake the pump before freeing what it might still be reading for.
231 // The raw syscall, ignoring errno: `std.posix.shutdown` calls BADF and
232 // NOTSOCK unreachable, and neither is a reason to abort a removal.
233 if (t.ws_fd) |fd| _ = std.os.linux.shutdown(fd, std.os.linux.SHUT.RDWR);
234 std.debug.print("mux web: tile {d}: gone\n", .{t.id});
235 self.alloc.free(t.session);
236 }
237
238 /// UnknownId: an ended session. 404, and the page refetches. 432 /// UnknownId: an ended session. 404, and the page refetches.
239 pub fn checkoutTile( 433 pub fn checkoutTile(
240 self: *Hub, 434 self: *Hub,
@@ -270,33 +464,80 @@ pub const Hub = struct {
270 } 464 }
271 } 465 }
272 466
273 /// The `+` on a tile: a new session on THAT tile's daemon. The name is 467 /// The `+` on a tile: a new session on THAT tile's daemon, AND a new
274 /// the daemon's own next free one, so the browser and `Ctrl-\ c` count 468 /// pane beside that tile in the layout file. The name is the daemon's
275 /// in the same series. 469 /// own next free one, so the browser and `Ctrl-\ c` count in the same
470 /// series; the file write is what makes the birth a pane rather than a
471 /// stranger the poll would only ever grade.
276 pub fn spawn(self: *Hub, id: u32) !client.SessionName { 472 pub fn spawn(self: *Hub, id: u32) !client.SessionName {
277 self.mutex.lock(); 473 // A hub with no layout has nowhere to author: refuse before the
278 const idx = self.indexOf(id) orelse { 474 // dial rather than leave a session running on a wall that will
279 self.mutex.unlock(); 475 // never show it.
280 return error.UnknownId; 476 if (self.layout_path.len == 0) return error.NoLayout;
281 }; 477 var hi: usize = undefined;
282 const hi = self.tiles.items[idx].host; 478 // Copied, not borrowed: the anchor is read under the lock and used
283 // Unlocked before the dial: a birth is a whole round trip to a 479 // after it, and `SessionName.of` is the copy this file already has.
284 // daemon, and every poller's wake would queue behind it. 480 // Every tile name reached `validSessionName` on its way in, so the
285 self.mutex.unlock(); 481 // unbounded memcpy inside `of` has a bound here.
482 var beside: client.SessionName = undefined;
483 var name: client.SessionName = undefined;
484 {
485 self.mutex.lock();
486 // Unlocked before the dial: a birth is a whole round trip to a
487 // daemon, and every poller's wake would queue behind it.
488 defer self.mutex.unlock();
489 const idx = self.indexOf(id) orelse return error.UnknownId;
490 hi = self.tiles.items[idx].host;
491 beside = client.SessionName.of(self.tiles.items[idx].session);
492 // The name is picked HERE, not after the probe, because the file
493 // has to be asked about the pane this `+` would actually make.
494 // `snapshot` takes the poller's own lock and releases it; the
495 // poller takes that lock and releases it before it ever reaches
496 // this one, so the two orders never nest.
497 var list_buf: [proto.sessions_reply_max]u8 = undefined;
498 var name_buf: [proto.session_name_max]u8 = undefined;
499 name = client.SessionName.of(client.nextFreeName(
500 &name_buf,
501 self.hosts[hi].poll.snapshot(&list_buf),
502 ));
503 // The file is asked whether it can hold THAT pane BEFORE the
504 // daemon is asked to make it. A full wall, an unparseable file
505 // and a pane the file already names are all certain now, and each
506 // would otherwise leave a live session behind a 502 with no wall
507 // to show it.
508 try roomForLeaf(self.alloc, self.layout_path, self.specs, .{
509 .host = hi,
510 .session = name.slice(),
511 });
512 }
286 513
287 const h = &self.hosts[hi]; 514 const h = &self.hosts[hi];
288 var list_buf: [proto.sessions_reply_max]u8 = undefined;
289 var name_buf: [proto.session_name_max]u8 = undefined;
290 const name = client.SessionName.of(client.nextFreeName(&name_buf, h.poll.snapshot(&list_buf)));
291 try client.birthSession(self.alloc, h.spec.target, name.slice(), client.birth_cols, client.birth_rows); 515 try client.birthSession(self.alloc, h.spec.target, name.slice(), client.birth_cols, client.birth_rows);
292 // The tile appears on the next LIST, never on this reply — one 516 // The file write and the tile share ONE hold of the lock: a `/tiles`
293 // road onto the wall — so the poll is asked for now rather than in 517 // between them would answer a wall the file already has a pane the
294 // a second. 518 // hub does not, and two browsers pressing `+` at once would each
519 // read the file before the other wrote it and lose a pane. The hold
520 // spans no dial — the round trip above is over — so the pollers
521 // queue behind a read and a rename, not behind a network.
522 self.mutex.lock();
523 defer self.mutex.unlock();
524 try appendLeaf(self.alloc, self.layout_path, self.specs, .{
525 .host = hi,
526 .session = beside.slice(),
527 }, .{ .host = hi, .session = name.slice() });
528 // Re-found under the lock: `idx` was read before the dial, and the
529 // pane it named may have moved if another spawn landed meanwhile.
530 const at = if (self.indexOf(id)) |now| now + 1 else self.tiles.items.len;
531 try self.birth(hi, name.slice(), at);
532 // The pane is on the wall already; the poke is for the GRADE, which
533 // is the poll's alone and would otherwise be a second stale.
295 h.poll.poke.store(true, .release); 534 h.poll.poke.store(true, .release);
296 return name; 535 return name;
297 } 536 }
298 537
299 /// The tile list the page renders and attaches by, in host order. 538 /// The tile list the page renders and attaches by, in the layout's tree
539 /// order. `state` is the poller's grade, so a page can say why a pane it
540 /// is showing has nothing behind it.
300 pub fn json(self: *Hub, alloc: std.mem.Allocator) ![]u8 { 541 pub fn json(self: *Hub, alloc: std.mem.Allocator) ![]u8 {
301 self.mutex.lock(); 542 self.mutex.lock();
302 defer self.mutex.unlock(); 543 defer self.mutex.unlock();
@@ -314,6 +555,7 @@ pub const Hub = struct {
314 try appendJsonString(alloc, &out, self.hosts[t.host].spec.spelling); 555 try appendJsonString(alloc, &out, self.hosts[t.host].spec.spelling);
315 try out.appendSlice(alloc, ",\"session\":"); 556 try out.appendSlice(alloc, ",\"session\":");
316 try appendJsonString(alloc, &out, t.session); 557 try appendJsonString(alloc, &out, t.session);
558 try out.print(alloc, ",\"state\":\"{s}\"", .{@tagName(t.state)});
317 try out.append(alloc, '}'); 559 try out.append(alloc, '}');
318 } 560 }
319 try out.append(alloc, ']'); 561 try out.append(alloc, ']');
@@ -894,9 +1136,10 @@ pub fn serveConn(
894 req.respond("forbidden\n", .{ .status = .forbidden, .keep_alive = false }) catch {}; 1136 req.respond("forbidden\n", .{ .status = .forbidden, .keep_alive = false }) catch {};
895 return; 1137 return;
896 } 1138 }
897 // Authoring a tile is gone with the wall file: the page shows 1139 // The page authors nothing about the wall as a whole: panes come
898 // what the listed daemons have, so POST /tiles, PUT and DELETE 1140 // from the layout file, and the one door onto it is `+` on a
899 // name nothing this hub can do. One answer for all of them. 1141 // tile, which is POST /tiles/<id>. POST /tiles, PUT and DELETE
1142 // name nothing this hub can do — one answer for all of them.
900 const id_str = tile_id_suffix orelse { 1143 const id_str = tile_id_suffix orelse {
901 req.respond("bad method\n", .{ .status = .method_not_allowed, .keep_alive = false }) catch {}; 1144 req.respond("bad method\n", .{ .status = .method_not_allowed, .keep_alive = false }) catch {};
902 return; 1145 return;
@@ -912,6 +1155,12 @@ pub fn serveConn(
912 const name = hub.spawn(id) catch |err| { 1155 const name = hub.spawn(id) catch |err| {
913 const status: std.http.Status, const msg: []const u8 = switch (err) { 1156 const status: std.http.Status, const msg: []const u8 = switch (err) {
914 error.UnknownId => .{ .not_found, "not found" }, 1157 error.UnknownId => .{ .not_found, "not found" },
1158 // The wall already has this pane: a second `+` inside one
1159 // poll interval reads the same session list and asks for
1160 // the same name. Nothing was born and nothing was
1161 // written, so the page refetches and finds the tile the
1162 // first press made.
1163 error.Duplicate => .{ .conflict, "duplicate" },
915 // The daemon's no, or a box that did not answer: the hub 1164 // The daemon's no, or a box that did not answer: the hub
916 // is up and the birth is not, which is a gateway failure 1165 // is up and the birth is not, which is a gateway failure
917 // and not this server's own. 1166 // and not this server's own.
@@ -1007,111 +1256,465 @@ test "ws path by id: parses, no range opinion" {
1007 try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0")); 1256 try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0"));
1008 } 1257 }
1009 1258
1010 test "hub: a listed name births a tile once; ids are birth order and never reused" { 1259 test "hub: tiles are the layout's leaves in order; a list names born elsewhere add nothing; a missing session reads gone and comes back" {
1011 const alloc = std.testing.allocator; 1260 const alloc = std.testing.allocator;
1012 // TWO hosts, off-origin sessions: a fixture holding either constant is 1261 // TWO hosts, off-origin sessions, and a leaf order that is NOT host
1013 // blind to the dimension it holds. 1262 // order: a fixture holding either constant is blind to the dimension it
1014 var hub = try Hub.init(alloc, &.{ 1263 // holds, and grouping by daemon is exactly the old wall this replaces.
1264 const specs = [_]client.HostSpec{
1015 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, 1265 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1016 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } }, 1266 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1017 }); 1267 };
1268 const leaves = [_]Leaf{ .{ .host = 1, .session = "0" }, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" } };
1269 var hub = try Hub.init(alloc, &specs, &leaves);
1018 defer hub.deinit(); 1270 defer hub.deinit();
1019
1020 hub.applyList(0, "0\nb\n", true);
1021 hub.applyList(1, "work\n", true);
1022 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1023 // The same list again is no news: a tile is born once per name per host.
1024 hub.applyList(0, "0\nb\n", true);
1025 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); 1271 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1026 try std.testing.expectEqualSlices(u32, &.{ 0, 1, 2 }, &.{ 1272 try std.testing.expectEqual(@as(u32, 0), hub.tiles.items[0].id);
1027 hub.tiles.items[0].id, hub.tiles.items[1].id, hub.tiles.items[2].id, 1273 try std.testing.expectEqualStrings("box", specs[hub.tiles.items[0].host].spelling);
1028 });
1029 1274
1030 // A name a peer's reply invented is skipped, not tiled: `encodeAttachNamed` 1275 // A session somebody else started on a listed daemon is not a pane:
1031 // memcpys into a 32-byte tail behind an assert. 1276 // the file is the wall, and only a write to it adds one.
1032 hub.applyList(1, "work\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\n", true); 1277 hub.applyList(0, "0\nb\nstranger\n", true);
1033 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); 1278 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1034 1279
1035 // Gone twice, then back: the browser holds `/ws/<id>` across a
1036 // vanishing it did not make, so a reused id would silently re-point
1037 // that socket at a different shell.
1038 hub.applyList(0, "0\n", true); 1280 hub.applyList(0, "0\n", true);
1039 hub.applyList(0, "0\n", true); 1281 hub.applyList(0, "0\n", true);
1040 try std.testing.expectEqual(@as(usize, 2), hub.tiles.items.len);
1041 hub.applyList(0, "0\nb\n", true);
1042 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); 1282 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1043 try std.testing.expectEqual(@as(u32, 3), hub.tiles.items[1].id); 1283 try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state);
1284 hub.applyList(0, "0\nb\n", true);
1285 try std.testing.expect(hub.tiles.items[2].state != .gone);
1286
1287 const json = try hub.json(alloc);
1288 defer alloc.free(json);
1289 try std.testing.expectEqualStrings(
1290 \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"0","state":"connecting"},{"id":2,"label":"--sock /tmp/a","session":"b","state":"connecting"}]
1291 , json);
1044 } 1292 }
1045 1293
1046 test "hub: a name missing from one list stays, missing from two leaves, and an unreachable answer removes nothing" { 1294 test "hub: a pane missing from one list keeps its grade, missing from two reads gone, and an unreachable answer grades nothing" {
1047 const alloc = std.testing.allocator; 1295 const alloc = std.testing.allocator;
1048 var hub = try Hub.init(alloc, &.{ 1296 const specs = [_]client.HostSpec{
1049 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, 1297 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1050 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } }, 1298 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1051 }); 1299 };
1300 const leaves = [_]Leaf{
1301 .{ .host = 0, .session = "0" },
1302 .{ .host = 0, .session = "b" },
1303 .{ .host = 1, .session = "c" },
1304 };
1305 var hub = try Hub.init(alloc, &specs, &leaves);
1052 defer hub.deinit(); 1306 defer hub.deinit();
1053 hub.applyList(0, "0\nb\n", true);
1054 hub.applyList(1, "0\n", true);
1055 1307
1056 // One miss is the poll's own race with a daemon mid-answer, not an exit. 1308 // One miss is the poll's own race with a daemon mid-answer, not an exit.
1057 hub.applyList(0, "0\n", true); 1309 hub.applyList(0, "0\n", true);
1058 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); 1310 try std.testing.expect(hub.tiles.items[1].state != .gone);
1059 // A blip removes nothing AND clears nothing: an unreachable answer is 1311 // A blip grades nothing AND clears nothing: an unreachable answer is no
1060 // no evidence either way, so the second miss still has to be a LIST. 1312 // evidence either way, so the second miss still has to be a LIST.
1061 hub.applyList(0, "", false); 1313 hub.applyList(0, "", false);
1062 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); 1314 try std.testing.expect(hub.tiles.items[1].state != .gone);
1063 hub.applyList(0, "0\n", true); 1315 hub.applyList(0, "0\n", true);
1064 try std.testing.expectEqual(@as(usize, 2), hub.tiles.items.len); 1316 try std.testing.expectEqual(TileState.gone, hub.tiles.items[1].state);
1317 // Nothing left the wall. A gone pane is still a pane the user authored:
1318 // only a write to the layout file takes one off.
1319 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1320 // Three of host 0's lists have now missed host 1's pane, and none of
1321 // them may grade it: two daemons may have a session of the same name.
1322 try std.testing.expect(hub.tiles.items[2].state != .gone);
1065 1323
1066 // A name that comes back clears the grace, so the next miss gets its own. 1324 // A name that comes back clears the grace, so the next miss gets its own.
1067 hub.applyList(1, "0\nc\n", true); 1325 hub.applyList(1, "c\n", true);
1068 hub.applyList(1, "0\n", true); 1326 hub.applyList(1, "0\n", true);
1069 hub.applyList(1, "0\nc\n", true); 1327 hub.applyList(1, "c\n", true);
1070 hub.applyList(1, "0\n", true); 1328 hub.applyList(1, "0\n", true);
1071 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len); 1329 try std.testing.expect(hub.tiles.items[2].state != .gone);
1072 1330 hub.applyList(1, "0\n", true);
1073 // Only its OWN host's list may drop a tile: two daemons may share a name. 1331 try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state);
1074 hub.applyList(0, "0\n", true); 1332 // And host 1's lists never touched host 0's panes.
1075 hub.applyList(0, "0\n", true); 1333 try std.testing.expect(hub.tiles.items[0].state != .gone);
1076 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1077 } 1334 }
1078 1335
1079 test "hub: json is host order then daemon order; label is the host spelling" { 1336 test "hub: json is the layout's leaf order, not host order; label is the host spelling and the grade rides beside it" {
1080 const alloc = std.testing.allocator; 1337 const alloc = std.testing.allocator;
1081 var hub = try Hub.init(alloc, &.{ 1338 const specs = [_]client.HostSpec{
1082 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, 1339 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1083 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } }, 1340 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1084 }); 1341 };
1342 // A wall that interleaves its daemons is the ordinary one, and the old
1343 // hub regrouped every list by host — the one order this must not be.
1344 const leaves = [_]Leaf{
1345 .{ .host = 1, .session = "0" },
1346 .{ .host = 0, .session = "b" },
1347 .{ .host = 1, .session = "w" },
1348 };
1349 var hub = try Hub.init(alloc, &specs, &leaves);
1085 defer hub.deinit(); 1350 defer hub.deinit();
1086 // Host 1 answers FIRST — the pollers are threads and nothing orders
1087 // them — and the page must still read the file's order.
1088 hub.applyList(1, "0\n", true);
1089 hub.applyList(0, "0\nb\n", true);
1090 1351
1091 const j = try hub.json(alloc); 1352 const j = try hub.json(alloc);
1092 defer alloc.free(j); 1353 defer alloc.free(j);
1093 try std.testing.expectEqualStrings( 1354 try std.testing.expectEqualStrings(
1094 \\[{"id":1,"label":"--sock /tmp/a","session":"0"},{"id":2,"label":"--sock /tmp/a","session":"b"},{"id":0,"label":"box","session":"0"}] 1355 \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"b","state":"connecting"},{"id":2,"label":"box","session":"w","state":"connecting"}]
1095 , j); 1356 , j);
1096 1357
1097 // A wall with no live session anywhere is a shape too, and the only 1358 // A grade reaches the page on the pane it belongs to, and the pane
1098 // one a hand-rolled encoder can emit nothing at all for. 1359 // keeps its place in the wall while it wears it.
1099 hub.applyList(0, "", true); 1360 hub.applyList(1, "0\n", true);
1100 hub.applyList(0, "", true); 1361 hub.applyList(1, "0\n", true);
1101 hub.applyList(1, "", true); 1362 const graded = try hub.json(alloc);
1102 hub.applyList(1, "", true); 1363 defer alloc.free(graded);
1103 const empty = try hub.json(alloc); 1364 try std.testing.expectEqualStrings(
1365 \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"b","state":"connecting"},{"id":2,"label":"box","session":"w","state":"gone"}]
1366 , graded);
1367
1368 // A layout with no leaf is a shape too, and the only one a hand-rolled
1369 // encoder can emit nothing at all for.
1370 var bare = try Hub.init(alloc, &specs, &[_]Leaf{});
1371 defer bare.deinit();
1372 const empty = try bare.json(alloc);
1104 defer alloc.free(empty); 1373 defer alloc.free(empty);
1105 try std.testing.expectEqualStrings("[]", empty); 1374 try std.testing.expectEqualStrings("[]", empty);
1106 } 1375 }
1107 1376
1108 test "hub: a checkout borrows the host target; the session name is the page's, not the pump's" { 1377 /// A daemon stand-in for the spawn test: accepts one connection, records
1378 /// the attach it is sent, and answers it the way a real daemon does — the
1379 /// pty mode byte first, then the snapshot that IS the birth. The `client`
1380 /// module's own `BirthFake` is private to it, and this file has no import
1381 /// edge that would reach one; what the daemon's half must be is pinned
1382 /// against a real daemon in the server tests.
1383 const SpawnFake = struct {
1384 listener: std.net.Server,
1385 attach_name: [proto.session_name_max]u8 = undefined,
1386 attach_name_len: usize = 0,
1387 /// How many attaches this daemon was asked for. The whole point of a
1388 /// fake that can serve MORE connections than the test expects: a guard
1389 /// that failed to hold shows up as a second birth here, not as a hang.
1390 attaches: usize = 0,
1391 /// Connections to serve before the thread returns. A test that expects
1392 /// fewer than this unblocks the last accept with a dial of its own.
1393 conns: usize = 1,
1394
1395 fn serve(self: *SpawnFake) void {
1396 const alloc = std.testing.allocator;
1397 var served: usize = 0;
1398 while (served < self.conns) : (served += 1) {
1399 const conn = self.listener.accept() catch return;
1400 defer conn.stream.close();
1401 // One birth is an attach and a detach, and nothing else.
1402 var frames: usize = 0;
1403 while (frames < 2) : (frames += 1) {
1404 const f = (proto.readFrame(alloc, conn.stream.handle) catch break) orelse break;
1405 defer f.deinit(alloc);
1406 if (f.type != .attach) continue;
1407 const req = proto.decodeAttach(f.payload) catch break;
1408 self.attaches += 1;
1409 self.attach_name_len = req.name.len;
1410 @memcpy(self.attach_name[0..req.name.len], req.name);
1411 proto.writeFrame(conn.stream.handle, .pty_mode, &.{0}) catch break;
1412 proto.writeFrame(conn.stream.handle, .snapshot, &.{0}) catch break;
1413 }
1414 }
1415 }
1416 };
1417
1418 test "hub: a spawn writes the pane into the layout beside its own, and the tile is on the wall before any poll" {
1109 const alloc = std.testing.allocator; 1419 const alloc = std.testing.allocator;
1110 var hub = try Hub.init(alloc, &.{ 1420 const testtmp = @import("testtmp");
1421 var tmp = try testtmp.TmpDir.make();
1422 defer tmp.cleanup();
1423 var sb: [64]u8 = undefined;
1424 const sock = try std.fmt.bufPrint(&sb, "{s}/d.sock", .{tmp.path()});
1425 var spb: [64]u8 = undefined;
1426 const sock_spelling = try std.fmt.bufPrint(&spb, "--sock {s}", .{sock});
1427 var lb: [64]u8 = undefined;
1428 const path = try std.fmt.bufPrint(&lb, "{s}/layout", .{tmp.path()});
1429
1430 // Two hosts, and the birth happens on the SECOND: a fixture whose only
1431 // daemon is index 0 cannot tell a host index from a zero.
1432 const specs = [_]client.HostSpec{
1433 .{ .spelling = "box", .target = .{ .sock = "/tmp/never" }, .poll_target = .{ .sock = "/tmp/never" } },
1434 .{ .spelling = sock_spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
1435 };
1436 try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
1437 try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 1, .session = "w" });
1438 const leaves = try readLeaves(alloc, path, &specs);
1439 defer freeLeaves(alloc, leaves);
1440
1441 var hub = try Hub.init(alloc, &specs, leaves);
1442 defer hub.deinit();
1443 hub.layout_path = path;
1444
1445 // The daemon's last list, as the poller left it: it already has `0`, so
1446 // the birth takes `1`. Off-origin on purpose — `proto.wireName` sends
1447 // the DEFAULT session nameless, so a fixture that birthed `0` could not
1448 // see the name reach the wire at all.
1449 const listed = "0\n";
1450 @memcpy(hub.hosts[1].poll.list[0..listed.len], listed);
1451 hub.hosts[1].poll.list_len = listed.len;
1452
1453 const addr = try std.net.Address.initUnix(sock);
1454 var fake = SpawnFake{ .listener = try addr.listen(.{}) };
1455 defer fake.listener.deinit();
1456 const th = try std.Thread.spawn(.{}, SpawnFake.serve, .{&fake});
1457 const name = try hub.spawn(1);
1458 th.join();
1459
1460 // The daemon named the session, off its own list — and that session `0`
1461 // is NOT a pane: only the leaf the spawn just wrote is.
1462 try std.testing.expectEqualStrings("1", name.slice());
1463 try std.testing.expectEqualStrings("1", fake.attach_name[0..fake.attach_name_len]);
1464
1465 // The tile is on the wall NOW: the poll is what grades panes, and a
1466 // browser refetching before the next second must see what it just made.
1467 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1468 try std.testing.expectEqual(@as(usize, 1), hub.tiles.items[2].host);
1469 try std.testing.expectEqualStrings("1", hub.tiles.items[2].session);
1470 try std.testing.expectEqual(@as(u32, 2), hub.tiles.items[2].id);
1471
1472 // And the FILE has it, beside the pane it was born from, so the next
1473 // terminal wall opens on the same three panes in the same order.
1474 const after = try readLeaves(alloc, path, &specs);
1475 defer freeLeaves(alloc, after);
1476 try std.testing.expectEqual(@as(usize, 3), after.len);
1477 try std.testing.expectEqual(@as(usize, 0), after[0].host);
1478 try std.testing.expectEqualStrings("w", after[1].session);
1479 try std.testing.expectEqual(@as(usize, 1), after[2].host);
1480 try std.testing.expectEqualStrings("1", after[2].session);
1481 }
1482
1483 test "readLeaves and appendLeaf: the layout round-trips through the hub, and a bad file is refused with its line" {
1484 const alloc = std.testing.allocator;
1485 const testtmp = @import("testtmp");
1486 var tmp = try testtmp.TmpDir.make();
1487 defer tmp.cleanup();
1488 var pb: [64]u8 = undefined;
1489 const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()});
1490 const specs = [_]client.HostSpec{
1111 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, 1491 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1492 };
1493 // No file yet: the first append makes a one-leaf tree.
1494 try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
1495 try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" });
1496 const leaves = try readLeaves(alloc, path, &specs);
1497 defer freeLeaves(alloc, leaves);
1498 try std.testing.expectEqual(@as(usize, 2), leaves.len);
1499 try std.testing.expectEqualStrings("0", leaves[0].session);
1500 try std.testing.expectEqualStrings("b", leaves[1].session);
1501
1502 // A missing file is an empty wall, not a refusal: nothing was authored.
1503 var mb: [64]u8 = undefined;
1504 const missing = try std.fmt.bufPrint(&mb, "{s}/nothing", .{tmp.path()});
1505 const none = try readLeaves(alloc, missing, &specs);
1506 defer freeLeaves(alloc, none);
1507 try std.testing.expectEqual(@as(usize, 0), none.len);
1508
1509 // A host the hosts file does not list refuses the FILE. The layout is a
1510 // match key, never an address: nothing here may be resurrected.
1511 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 nowhere#0\n" });
1512 try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
1513 // A leaf with no session, and text that is not a layout at all.
1514 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 --sock /tmp/a\n" });
1515 try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
1516 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "box\n--sock /tmp/a\n" });
1517 try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
1518 // A repeated leaf, the way `wall_layout.seedLayout` refuses one: a host
1519 // names a session once, so a second pane on the same (host, session)
1520 // could never bind, and a file one front served and the other refused
1521 // would cost whoever opened a terminal wall next their whole wall.
1522 try std.fs.cwd().writeFile(.{
1523 .sub_path = path,
1524 .data = "mux-layout 1\nbeside 0\n leaf 1 --sock /tmp/a#b\n leaf 1 --sock /tmp/a#b\n",
1112 }); 1525 });
1526 try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
1527 }
1528
1529 test "hub: two + inside one poll interval make ONE session and ONE leaf; the second is a Duplicate" {
1530 const alloc = std.testing.allocator;
1531 const testtmp = @import("testtmp");
1532 var tmp = try testtmp.TmpDir.make();
1533 defer tmp.cleanup();
1534 var sb: [64]u8 = undefined;
1535 const sock = try std.fmt.bufPrint(&sb, "{s}/d.sock", .{tmp.path()});
1536 var spb: [64]u8 = undefined;
1537 const sock_spelling = try std.fmt.bufPrint(&spb, "--sock {s}", .{sock});
1538 var lb: [64]u8 = undefined;
1539 const path = try std.fmt.bufPrint(&lb, "{s}/layout", .{tmp.path()});
1540
1541 // Two hosts, the births on the SECOND: a fixture whose only daemon is
1542 // index 0 cannot tell a host index from a zero.
1543 const specs = [_]client.HostSpec{
1544 .{ .spelling = "box", .target = .{ .sock = "/tmp/never" }, .poll_target = .{ .sock = "/tmp/never" } },
1545 .{ .spelling = sock_spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
1546 };
1547 try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
1548 try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 1, .session = "w" });
1549 const leaves = try readLeaves(alloc, path, &specs);
1550 defer freeLeaves(alloc, leaves);
1551
1552 var hub = try Hub.init(alloc, &specs, leaves);
1553 defer hub.deinit();
1554 hub.layout_path = path;
1555
1556 // The poller's last answer, and it does NOT move for the length of this
1557 // test — which is exactly the second a user gets two clicks into. Both
1558 // presses therefore read the same list and pick the same next free name.
1559 const listed = "0\n";
1560 @memcpy(hub.hosts[1].poll.list[0..listed.len], listed);
1561 hub.hosts[1].poll.list_len = listed.len;
1562
1563 const addr = try std.net.Address.initUnix(sock);
1564 // Willing to serve TWO births: a fake that could only serve one would
1565 // pass this test by refusing the second birth itself.
1566 var fake = SpawnFake{ .listener = try addr.listen(.{}), .conns = 2 };
1567 defer fake.listener.deinit();
1568 const th = try std.Thread.spawn(.{}, SpawnFake.serve, .{&fake});
1569
1570 const first = try hub.spawn(1);
1571 try std.testing.expectEqualStrings("1", first.slice());
1572 // The second press, before any poll came back. The daemon would answer
1573 // it by ATTACHING to the session the first press made — a live session
1574 // and a second leaf spelling the same pane, which is a file
1575 // `wall_layout.seedLayout` refuses whole.
1576 try std.testing.expectError(error.Duplicate, hub.spawn(1));
1577
1578 // Release the fake's second accept, which nothing dialled, and join.
1579 const nudge = std.net.connectUnixSocket(sock) catch null;
1580 if (nudge) |n| n.close();
1581 th.join();
1582 try std.testing.expectEqual(@as(usize, 1), fake.attaches);
1583
1584 // One new tile, not two.
1585 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1586 try std.testing.expectEqualStrings("1", hub.tiles.items[2].session);
1587
1588 // And the FILE names the pane once. Read back through the same door the
1589 // next `mux web` start-up uses, which is also the door that refuses a
1590 // repeated leaf outright.
1591 const after = try readLeaves(alloc, path, &specs);
1592 defer freeLeaves(alloc, after);
1593 try std.testing.expectEqual(@as(usize, 3), after.len);
1594 var seen: usize = 0;
1595 for (after) |l| {
1596 if (l.host == 1 and std.mem.eql(u8, l.session, "1")) seen += 1;
1597 }
1598 try std.testing.expectEqual(@as(usize, 1), seen);
1599
1600 // The guard is the file's, not the tile list's: `appendLeaf` refuses a
1601 // pane the file already names whoever asks.
1602 try std.testing.expectError(
1603 error.Duplicate,
1604 appendLeaf(alloc, path, &specs, after[0], .{ .host = 1, .session = "w" }),
1605 );
1606 }
1607
1608 test "hub: a + on a full wall is refused BEFORE the daemon is asked, so no session is born onto a file that cannot hold it" {
1609 const alloc = std.testing.allocator;
1610 const testtmp = @import("testtmp");
1611 var tmp = try testtmp.TmpDir.make();
1612 defer tmp.cleanup();
1613 var sb: [64]u8 = undefined;
1614 const sock = try std.fmt.bufPrint(&sb, "{s}/d.sock", .{tmp.path()});
1615 var spb: [64]u8 = undefined;
1616 const sock_spelling = try std.fmt.bufPrint(&spb, "--sock {s}", .{sock});
1617 var lb: [64]u8 = undefined;
1618 const path = try std.fmt.bufPrint(&lb, "{s}/layout", .{tmp.path()});
1619
1620 const specs = [_]client.HostSpec{
1621 .{ .spelling = "box", .target = .{ .sock = "/tmp/never" }, .poll_target = .{ .sock = "/tmp/never" } },
1622 .{ .spelling = sock_spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
1623 };
1624
1625 // A FULL wall: one pane on the dead host and the rest on the live one,
1626 // `layout.max_leaves` in all.
1627 var buf: std.ArrayListUnmanaged(u8) = .{};
1628 defer buf.deinit(alloc);
1629 try buf.appendSlice(alloc, "mux-layout 1\nbeside 0\n");
1630 try buf.appendSlice(alloc, " leaf 1 box#0\n");
1631 for (1..layout.max_leaves) |i| try buf.print(alloc, " leaf 1 {s}#{d}\n", .{ sock_spelling, i });
1632 try hosts.saveBytes(path, buf.items);
1633
1634 const leaves = try readLeaves(alloc, path, &specs);
1635 defer freeLeaves(alloc, leaves);
1636 try std.testing.expectEqual(layout.max_leaves, leaves.len);
1637 var hub = try Hub.init(alloc, &specs, leaves);
1638 defer hub.deinit();
1639 hub.layout_path = path;
1640
1641 // A real listening socket the birth WOULD reach: the refusal has to be
1642 // the file's, not a dial that failed for want of a daemon.
1643 const addr = try std.net.Address.initUnix(sock);
1644 var listener = try addr.listen(.{});
1645 defer listener.deinit();
1646
1647 // Tile id 1 is the first pane on the live host.
1648 try std.testing.expectEqual(@as(usize, 1), hub.tiles.items[1].host);
1649 try std.testing.expectError(error.WallFull, hub.spawn(1));
1650
1651 // The OS's answer, not the hub's: a listening socket goes readable the
1652 // instant a connect lands on it, and nothing landed. A `+` that dialled
1653 // first would have left a shell running on a wall that can never show it.
1654 var fds = [_]std.posix.pollfd{
1655 .{ .fd = listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 },
1656 };
1657 _ = try std.posix.poll(&fds, 0);
1658 try std.testing.expectEqual(@as(i16, 0), fds[0].revents);
1659
1660 // And the wall is untouched: no tile, and the file still holds exactly
1661 // what it held.
1662 try std.testing.expectEqual(layout.max_leaves, hub.tiles.items.len);
1663 const after = try std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024);
1664 defer alloc.free(after);
1665 try std.testing.expectEqualStrings(buf.items, after);
1666
1667 // The same refusal for a file that is not a layout at all: the daemon is
1668 // never asked, because the pane it would make has nowhere to be written.
1669 try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "not a layout\n" });
1670 try std.testing.expectError(error.BadLayout, hub.spawn(1));
1671 _ = try std.posix.poll(&fds, 0);
1672 try std.testing.expectEqual(@as(i16, 0), fds[0].revents);
1673 }
1674
1675 test "readLeaves and appendLeaf stop at the wall's ceiling, so the hub cannot serve a wall no terminal could open" {
1676 const alloc = std.testing.allocator;
1677 const testtmp = @import("testtmp");
1678 var tmp = try testtmp.TmpDir.make();
1679 defer tmp.cleanup();
1680 var pb: [64]u8 = undefined;
1681 const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()});
1682 const specs = [_]client.HostSpec{
1683 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1684 };
1685
1686 // A flat tree of N panes, written by hand: `appendLeaf` refuses to build
1687 // the over-full one, which is the whole point of pinning the READ too —
1688 // only a hand-edited file can be that long, and a user hand-edits this.
1689 const flat = struct {
1690 fn write(a: std.mem.Allocator, p: []const u8, n: usize) !void {
1691 var buf: std.ArrayListUnmanaged(u8) = .{};
1692 defer buf.deinit(a);
1693 try buf.appendSlice(a, "mux-layout 1\nbeside 0\n");
1694 for (0..n) |i| try buf.print(a, " leaf 1 --sock /tmp/a#{d}\n", .{i});
1695 try hosts.saveBytes(p, buf.items);
1696 }
1697 };
1698
1699 try flat.write(alloc, path, layout.max_leaves);
1700 const full = try readLeaves(alloc, path, &specs);
1701 defer freeLeaves(alloc, full);
1702 try std.testing.expectEqual(layout.max_leaves, full.len);
1703 // The file is full, so nothing may be authored onto it: a `+` that wrote
1704 // a 33rd pane would write a file the terminal wall then refuses whole.
1705 try std.testing.expectError(error.WallFull, appendLeaf(alloc, path, &specs, full[0], .{ .host = 0, .session = "x" }));
1706
1707 try flat.write(alloc, path, layout.max_leaves + 1);
1708 try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
1709 }
1710
1711 test "hub: a checkout borrows the host target; a gone pane is still the browser's to dial" {
1712 const alloc = std.testing.allocator;
1713 const specs = [_]client.HostSpec{
1714 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1715 };
1716 var hub = try Hub.init(alloc, &specs, &[_]Leaf{.{ .host = 0, .session = "wghost" }});
1113 defer hub.deinit(); 1717 defer hub.deinit();
1114 hub.applyList(0, "wghost\n", true);
1115 1718
1116 const t = try hub.checkoutTile(0, 7); 1719 const t = try hub.checkoutTile(0, 7);
1117 // The host's target verbatim — a host outlives every pump, so a copy 1720 // The host's target verbatim — a host outlives every pump, so a copy
@@ -1123,22 +1726,26 @@ test "hub: a checkout borrows the host target; the session name is the page's, n
1123 defer alloc.free(j); 1726 defer alloc.free(j);
1124 try std.testing.expect(std.mem.indexOf(u8, j, "\"session\":\"wghost\"") != null); 1727 try std.testing.expect(std.mem.indexOf(u8, j, "\"session\":\"wghost\"") != null);
1125 1728
1126 // The list drops a tile whenever its session ends, and a browser 1729 // The session ends on its daemon: the pane stays, wearing `gone`, and
1127 // dialling it then is answered in HTTP rather than with a socket that 1730 // the browser's socket stays valid — the tile's pump redials on its own
1128 // closes for reasons it cannot read. 1731 // until the name comes back or the file loses the pane.
1129 hub.applyList(0, "", true); 1732 hub.applyList(0, "", true);
1130 hub.applyList(0, "", true); 1733 hub.applyList(0, "", true);
1131 hub.releaseTile(0, 7); // gone id: a no-op, not a crash 1734 try std.testing.expectEqual(TileState.gone, hub.tiles.items[0].state);
1132 try std.testing.expectError(error.UnknownId, hub.checkoutTile(0, 7)); 1735 _ = try hub.checkoutTile(0, 7);
1736 hub.releaseTile(0, 7);
1737 // An id this hub never handed out is the 404 the page refetches on: a
1738 // browser holds `/ws/<n>` across a hub restart that renumbered the wall.
1739 try std.testing.expectError(error.UnknownId, hub.checkoutTile(99, 7));
1133 } 1740 }
1134 1741
1135 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { 1742 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" {
1136 const alloc = std.testing.allocator; 1743 const alloc = std.testing.allocator;
1137 var hub = try Hub.init(alloc, &.{ 1744 const specs = [_]client.HostSpec{
1138 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } }, 1745 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1139 }); 1746 };
1747 var hub = try Hub.init(alloc, &specs, &[_]Leaf{.{ .host = 0, .session = "0" }});
1140 defer hub.deinit(); 1748 defer hub.deinit();
1141 hub.applyList(0, "0\n", true);
1142 1749
1143 var arena = std.heap.ArenaAllocator.init(alloc); 1750 var arena = std.heap.ArenaAllocator.init(alloc);
1144 defer arena.deinit(); 1751 defer arena.deinit();
@@ -1150,8 +1757,8 @@ test "hub: two pumps on one tile — the first fd stays tracked, the second's re
1150 try std.testing.expectEqualStrings("/tmp/a", second.target.sock); 1757 try std.testing.expectEqualStrings("/tmp/a", second.target.sock);
1151 1758
1152 // The first registration is the tracked one, and the untracked pump's 1759 // The first registration is the tracked one, and the untracked pump's
1153 // release must leave it alone — clearing it would leave a vanishing 1760 // release must leave it alone — two releases on one tile must not make
1154 // with nobody to wake. 1761 // the tracked pump's fd look free while it is still serving.
1155 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd); 1762 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd);
1156 hub.releaseTile(0, 8); 1763 hub.releaseTile(0, 8);
1157 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd); 1764 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd);
@@ -1166,9 +1773,8 @@ test "hub: a HOST tile is never the ask" {
1166 var arena = std.heap.ArenaAllocator.init(alloc); 1773 var arena = std.heap.ArenaAllocator.init(alloc);
1167 defer arena.deinit(); 1774 defer arena.deinit();
1168 const spec = try client.resolveHost(arena.allocator(), "box", null, client.quic_idle_ms_default); 1775 const spec = try client.resolveHost(arena.allocator(), "box", null, client.quic_idle_ms_default);
1169 var hub = try Hub.init(alloc, &.{spec}); 1776 var hub = try Hub.init(alloc, &.{spec}, &[_]Leaf{.{ .host = 0, .session = "0" }});
1170 defer hub.deinit(); 1777 defer hub.deinit();
1171 hub.applyList(0, "0\n", true);
1172 1778
1173 const t = try hub.checkoutTile(0, 7); 1779 const t = try hub.checkoutTile(0, 7);
1174 // Nobody is sitting in front of a browser tile: it may not start a 1780 // Nobody is sitting in front of a browser tile: it may not start a
src/tui/wallview.zig
Old New
@@ -86,7 +86,10 @@ pub const mailbox_max = 4096;
86 86
87 /// The most tiles one wall can hold. A hard bound, not a growable array: 87 /// The most tiles one wall can hold. A hard bound, not a growable array:
88 /// pump threads hold `*Tile` pointers, so growing would move it under them. 88 /// pump threads hold `*Tile` pointers, so growing would move it under them.
89 pub const max_tiles: usize = 32; 89 /// DEFINED FROM the layout's bound rather than beside it: the file is the
90 /// wall, both fronts read it, and two spellings of 32 agreeing by comment
91 /// is a drift waiting for whoever raises one of them.
92 pub const max_tiles: usize = client.layout.max_leaves;
90 93
91 /// Why a tile's pump stopped, for the keyboard that has to decide what that 94 /// Why a tile's pump stopped, for the keyboard that has to decide what that
92 /// means for the WALL. `State` narrates the same events on a label bar; 95 /// means for the WALL. `State` narrates the same events on a label bar;