0bd41f5a
feat: the hub serves the layout's panes and writes a birth back into it
a73x 2026-09-03 05:20
Commit message
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; |