a73x

019b1c19

feat: the browser hub reads the hosts file and polls like the CLI wall

a73x   2026-08-29 12:14

Commit message
feat: the browser hub reads the hosts file and polls like the CLI wall

The hub kept a private `wall` file of TARGET#SESSION spellings the page
authored: an add box, an x, drag-to-reorder, POST/PUT/DELETE /tiles, and
a pump that could birth a session the file remembered. That was a second
answer to "what is on the wall" — and the CLI already had the first.

`mux web [HOST ...]` now records its argv into the hosts file and serves
that file: one poller per daemon, tiles are those daemons live sessions,
and a session born or ended anywhere reaches the page within two polls.
The `+` stays and births on that tile host; POST /tiles, PUT and DELETE
answer 405. Two consecutive missing lists drop a tile; an unreachable
answer drops nothing. Ids are birth order and are never reused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

build.zig
Old New
@@ -280,7 +280,7 @@ const mod_table = [_]ModSpec{
280 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route 280 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
281 // table, WS endpoint naming. Assets are injected (the exe root 281 // table, WS endpoint naming. Assets are injected (the exe root
282 // @embedFiles them), so its tests build no artifacts. 282 // @embedFiles them), so its tests build no artifacts.
283 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client", "wall", "handoff" }, .quic_tests = true }, 283 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client" }, .quic_tests = true },
284 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of 284 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
285 // which can be ZOOMED — promoted to the terminal's size and typed 285 // which can be ZOOMED — promoted to the terminal's size and typed
286 // through. Same layer as webhub for the same reason — both sit on 286 // through. Same layer as webhub for the same reason — both sit on
@@ -292,12 +292,12 @@ const mod_table = [_]ModSpec{
292 // second copy of it. 292 // second copy of it.
293 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 293 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
294 // ---- layer 5 ---- 294 // ---- layer 5 ----
295 // wall owns the spelling grammar and the state file, so argv is parsed 295 // hosts owns the host grammar and the state file, so argv is parsed by
296 // by the SAME rules the page's POST /tiles and the restored file are — 296 // the SAME rules the CLI's own `mux hosts add` is — one grammar, not
297 // one grammar, not three. Resolution itself now lives in the Hub, so 297 // two. Resolution is `client.resolveHost`, shared with the CLI wall;
298 // handoff/protocol left with it; sockpath stays for the one startup 298 // sockpath stays for the one startup message that names the sun_path
299 // message that names the sun_path bound. 299 // bound.
300 .{ .name = "hub_main", .path = "src/cli/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath", "cliflags" }, .quic_tests = true }, 300 .{ .name = "hub_main", .path = "src/cli/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "hosts", "xdg", "sockpath", "cliflags" }, .quic_tests = true },
301 // sockpath is the sun_path bound only; the client binds no socket itself. 301 // sockpath is the sun_path bound only; the client binds no socket itself.
302 // protocol is the session-name validator alone (validSessionName): a bad 302 // protocol is the session-name validator alone (validSessionName): a bad
303 // --session has to be a usage error here, at parse, not bytes some 303 // --session has to be a usage error here, at parse, not bytes some
src/cli/webhub_main.zig
Old New
@@ -1,39 +1,34 @@
1 //! `mux web` — the hub mode. `mux web [TARGET ...] 1 //! `mux web` — the hub mode. `mux web [HOST ...] [--port N]`: serves the
2 //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one 2 //! wall page on 127.0.0.1 and pumps one WebSocket per tile.
3 //! WebSocket per tile, dialing each TARGET the way the mux CLI does.
4 //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff),
5 //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in
6 //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile
7 //! attaches to, which is how one host becomes two tiles. The TARGET string
8 //! is the tile's label, suffix and all.
9 //! 3 //!
10 //! The wall is now a persisted list the page edits at runtime, so argv is 4 //! The wall is the HOSTS FILE, exactly as it is for the CLI: each HOST is
11 //! an override of the VIEW only: with targets, argv is what this run shows 5 //! a daemon (`HOST` | `--sock PATH` | `quic://HOST[:PORT]`, with --key /
12 //! and is ADDED to the wall (deduped, nothing removed); without, the last 6 //! MUX_KEY_FILE as in mux), and the tiles are whatever those daemons have
13 //! run's whole wall is restored and served. The standing 7 //! live. A HOST on argv is RECORDED into the file, the same thing `mux
14 //! non-goal (no config file) survives — the state file is written by the 8 //! HOST` does, and then the FILE is the wall. `#SESSION` is refused:
15 //! program, never by hand. 9 //! nothing here may name a session, because nothing here may resurrect
10 //! one. The standing non-goal (no config file) survives — the state file
11 //! is written by the program, never by hand.
16 12
17 const std = @import("std"); 13 const std = @import("std");
18 const client = @import("client"); 14 const client = @import("client");
19 const webhub = @import("webhub"); 15 const webhub = @import("webhub");
20 const wall = @import("wall"); 16 const hosts = @import("hosts");
21 const build_options = @import("build_options"); 17 const build_options = @import("build_options");
22 const xdg = @import("xdg"); 18 const xdg = @import("xdg");
23 const sockpath = @import("sockpath"); 19 const sockpath = @import("sockpath");
24 const cliflags = @import("cliflags"); 20 const cliflags = @import("cliflags");
25 21
26 const usage = 22 const usage =
27 \\usage: mux web [TARGET[#SESSION] ...] [--port N] 23 \\usage: mux web [HOST ...] [--port N]
28 \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] 24 \\ each HOST is a daemon: HOST | --sock PATH | quic://HOST[:PORT]
29 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the 25 \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the
30 \\ spelling the wall file holds; `mux wall` takes both too 26 \\ spelling the hosts file holds; `mux hosts add` takes both too
31 \\ with no TARGET the wall from the last run is restored; with TARGETs 27 \\ a HOST on the line is added to the hosts file (deduped); the FILE is
32 \\ argv is added to the saved wall (deduped) and shown; nothing already 28 \\ the wall either way, and its tiles are those daemons' live sessions
33 \\ there is removed 29 \\ no #SESSION: the wall lists daemons and shows every session they
34 \\ #SESSION names the daemon session the tile attaches to (default: the 30 \\ have — `mux hosts rm` is how a daemon leaves it
35 \\ default session) — the same host twice, two sessions, two tiles 31 \\ quic:// hosts use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
36 \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key
37 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 32 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
38 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, 33 \\ --port N serves on 127.0.0.1:N (default 7681); localhost only,
39 \\ remote viewing is `ssh -L` 34 \\ remote viewing is `ssh -L`
@@ -50,7 +45,7 @@ const Parsed = struct {
50 port: u16 = webhub.default_port, 45 port: u16 = webhub.default_port,
51 key: ?[]const u8 = null, 46 key: ?[]const u8 = null,
52 quic_idle_ms: client.IdleMs = .{}, 47 quic_idle_ms: client.IdleMs = .{},
53 _argv: wall.Argv, 48 _argv: hosts.Argv,
54 49
55 pub fn positional(self: *Parsed, w: []const u8) bool { 50 pub fn positional(self: *Parsed, w: []const u8) bool {
56 return self._argv.positional(w); 51 return self._argv.positional(w);
@@ -89,7 +84,7 @@ fn parseArgs(
89 // targets on the line, `usage` alone would not say which. 84 // targets on the line, `usage` alone would not say which.
90 if (p._argv.err) |e| { 85 if (p._argv.err) |e| {
91 if (e.err == error.OutOfMemory) return error.OutOfMemory; 86 if (e.err == error.OutOfMemory) return error.OutOfMemory;
92 std.debug.print("mux web: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) }); 87 std.debug.print("mux web: host {s}: {s}\n", .{ e.word, hosts.reason(e.err) });
93 return error.Usage; 88 return error.Usage;
94 } 89 }
95 try outcome; 90 try outcome;
@@ -99,9 +94,9 @@ fn parseArgs(
99 // what typing it means. 94 // what typing it means.
100 if (p.port == 0) return error.Usage; 95 if (p.port == 0) return error.Usage;
101 96
102 // No targets is not a usage error any more: it asks for the wall the 97 // No hosts is not a usage error: it asks for whatever the file holds.
103 // last run persisted. main decides what an empty argv means; the parse 98 // main decides what an empty argv means; the parse only reports what
104 // only reports what was on the line. 99 // was on the line.
105 p.key = xdg.pickKey(p.key, env_key); 100 p.key = xdg.pickKey(p.key, env_key);
106 return p; 101 return p;
107 } 102 }
@@ -129,63 +124,50 @@ pub fn main(args: []const [:0]const u8) !u8 {
129 defer arena_state.deinit(); 124 defer arena_state.deinit();
130 const arena = arena_state.allocator(); 125 const arena = arena_state.allocator();
131 126
132 const state_path = try wall.statePath(arena); 127 const state_path = try hosts.statePath(arena);
133 var w: wall.Wall = undefined; 128 // Argv is RECORDED, never a view of its own: `mux web box` is `mux
134 if (parsed._argv.tiles.items.len == 0) { 129 // hosts add box` followed by a hub, exactly as `mux box` is. Written
135 // No argv: the wall is whatever the last run persisted. 130 // before the load, so the load below is the one road onto the wall.
136 w = wall.load(arena, state_path) catch |err| { 131 for (parsed._argv.list.items) |spelling| {
137 // The file may have been hand-edited into a line that no longer 132 _ = hosts.record(alloc, state_path, spelling) catch |err|
138 // parses. Naming it beats a stack trace: the fix is in the file. 133 return refuseFile(arena, state_path, err);
139 std.debug.print("mux web: cannot read wall {s}: {s}\n", .{ state_path, @errorName(err) }); 134 }
140 return 2; 135 var h = hosts.load(arena, state_path) catch |err| return refuseFile(arena, state_path, err);
136 defer h.deinit(arena);
137 if (h.lines.items.len == 0) {
138 // NOT the local socket: nothing asked for a daemon, and a read
139 // never starts one. Said once, so an empty wall is a wall the user
140 // knows how to fill rather than a page that looks broken.
141 std.debug.print("mux web: no hosts (mux hosts add HOST, or mux HOST)\n", .{});
142 }
143
144 // The SAME resolver the CLI wall runs on — handoff.recipeFor and
145 // xdg.resolveKeyPath through `client.resolveHost` — so the two fronts
146 // cannot drift on what a bare HOST or a `quic://` means, and the poll
147 // recipe is the one nobody is sitting in front of.
148 const specs = try arena.alloc(client.HostSpec, h.lines.items.len);
149 for (specs, h.lines.items) |*spec, line| {
150 spec.* = client.resolveHost(arena, line, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) {
151 error.MissingKey => {
152 std.debug.print(
153 "mux web: no key for a quic:// host: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n",
154 .{},
155 );
156 return 2;
157 },
158 error.SockPathTooLong => {
159 std.debug.print(
160 "mux web: socket path too long (max {d} bytes)\n",
161 .{sockpath.max_sun_path},
162 );
163 return 2;
164 },
165 else => return err,
141 }; 166 };
142 } else {
143 // Argv present: the explicit override. It becomes the persisted wall.
144 w = wall.Wall{};
145 for (parsed._argv.tiles.items) |s| _ = try w.add(arena, s);
146 } 167 }
147 168
148 // The Hub resolves every spelling into a dialable Target through the 169 var hub = try webhub.Hub.init(arena, specs);
149 // SAME owners mux_main uses — handoff.recipeFor and xdg.resolveKeyPath —
150 // so the two binaries cannot drift on what a bare HOST or a `quic://`
151 // means, and a tile POSTed by the page means what one typed on the
152 // command line.
153 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) {
154 error.MissingKey => {
155 std.debug.print(
156 "mux web: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n",
157 .{},
158 );
159 return 2;
160 },
161 error.SockPathTooLong => {
162 std.debug.print(
163 "mux web: socket path too long (max {d} bytes)\n",
164 .{sockpath.max_sun_path},
165 );
166 return 2;
167 },
168 else => return err,
169 };
170 defer hub.deinit(); 170 defer hub.deinit();
171 // Argv overrides the VIEW — this run shows the tiles it named, and only
172 // those — but it no longer overwrites the FILE. That file stopped being
173 // "the last wall the hub was told to show" when attaches started writing
174 // to it: it is the user's attach history now, and one
175 // `mux web HOST` would have silently erased every tile every `mux` had
176 // recorded. So each argv tile is ADDED (deduped by spelling, wall.zig)
177 // and nothing is removed. Forgetting stays explicit, which is the whole
178 // "remove is detach" doctrine: the page's `×`, the wall's `x`,
179 // `mux wall rm`.
180 //
181 // Written only when there WAS argv, still: a restore that rewrote what
182 // it just read would turn a read failure into a lost wall.
183 if (parsed._argv.tiles.items.len != 0) for (hub.wall_state.targets.items) |spelling| {
184 _ = wall.record(alloc, state_path, spelling) catch |err| {
185 std.debug.print("mux web: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) });
186 return 2;
187 };
188 };
189 171
190 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; 172 const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable;
191 var listener = addr.listen(.{ .reuse_address = true }) catch |err| { 173 var listener = addr.listen(.{ .reuse_address = true }) catch |err| {
@@ -194,17 +176,14 @@ pub fn main(args: []const [:0]const u8) !u8 {
194 }; 176 };
195 defer listener.deinit(); 177 defer listener.deinit();
196 178
197 // The tile list, one line each, then the door: everything a script 179 // The door first, then the tiles as their hosts answer: a tile is a
198 // (or a user) needs to know the hub is up and what it serves. The id 180 // live session on a listed daemon, so the hub does not know one until
199 // is the hub's, not a position — it is what `/ws/<id>` names, and the 181 // a poll comes back. `Hub.birth` prints each `tile N:` line.
200 // page can add and remove tiles under it while this line stays true.
201 for (hub.tiles.items) |t| {
202 std.debug.print("mux web: tile {d}: {s}\n", .{ t.id, t.label });
203 }
204 std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{ 182 std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{
205 parsed.port, 183 parsed.port,
206 std.os.linux.getpid(), 184 std.os.linux.getpid(),
207 }); 185 });
186 hub.start();
208 187
209 const assets = webhub.Assets{ 188 const assets = webhub.Assets{
210 .index_html = @embedFile("index.html"), 189 .index_html = @embedFile("index.html"),
@@ -224,58 +203,54 @@ pub fn main(args: []const [:0]const u8) !u8 {
224 } 203 }
225 } 204 }
226 205
227 test "parse: three spellings become three tiles in argv order, port and key bind" { 206 test "parse: three spellings become three hosts in argv order, port and key bind" {
228 const alloc = std.testing.allocator; 207 const alloc = std.testing.allocator;
229 const args = [_][:0]const u8{ 208 const args = [_][:0]const u8{
230 "web", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000", 209 "web", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000",
231 }; 210 };
232 var r = try parseArgs(alloc, &args, null); 211 var r = try parseArgs(alloc, &args, null);
233 defer r.deinit(); 212 defer r.deinit();
234 try std.testing.expectEqual(@as(usize, 3), r._argv.tiles.items.len); 213 try std.testing.expectEqual(@as(usize, 3), r._argv.list.items.len);
235 try std.testing.expectEqualStrings("box1", r._argv.tiles.items[0]); 214 try std.testing.expectEqualStrings("box1", r._argv.list.items[0]);
236 // `--sock PATH` is ONE spelling from here on, prefix included — that 215 // `--sock PATH` is ONE spelling from here on, prefix included — that
237 // string is the label, the wall line, and the resolver's input alike. 216 // string is the hosts-file line, the page's label, and the resolver's
238 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.tiles.items[1]); 217 // input alike.
239 try std.testing.expectEqualStrings("quic://h:4433", r._argv.tiles.items[2]); 218 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.list.items[1]);
219 try std.testing.expectEqualStrings("quic://h:4433", r._argv.list.items[2]);
240 try std.testing.expectEqual(@as(u16, 8000), r.port); 220 try std.testing.expectEqual(@as(u16, 8000), r.port);
241 try std.testing.expectEqualStrings("/k", r.key.?); 221 try std.testing.expectEqualStrings("/k", r.key.?);
242 } 222 }
243 223
244 test "parse: a quoted '--sock PATH#SESSION' is the same tile as the two-argument form" { 224 test "parse: a quoted '--sock PATH' is the same host as the two-argument form" {
245 const alloc = std.testing.allocator; 225 const alloc = std.testing.allocator;
246 // The wall file's own spelling, pasted straight onto the command line: 226 // The hosts file's own spelling, pasted straight onto the command line.
247 // The hub used to refuse it while `mux wall` required it. 227 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--sock /tmp/a.sock" }, null);
248 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--sock /tmp/a.sock#b" }, null);
249 defer r.deinit(); 228 defer r.deinit();
250 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len); 229 try std.testing.expectEqual(@as(usize, 1), r._argv.list.items.len);
251 try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r._argv.tiles.items[0]); 230 try std.testing.expectEqualStrings("--sock /tmp/a.sock", r._argv.list.items[0]);
252 } 231 }
253 232
254 test "parse: zero targets, bad flags, and flag-beats-env" { 233 test "parse: zero hosts, bad flags, and flag-beats-env" {
255 const alloc = std.testing.allocator; 234 const alloc = std.testing.allocator;
256 // No targets is an empty argv wall, not a refusal: restore-from-file 235 // No hosts is an empty argv, not a refusal: what the FILE holds is
257 // semantics live in main, which is the only place that can read a file. 236 // main's business, which is the only place that can read one.
258 { 237 {
259 var r = try parseArgs(alloc, &[_][:0]const u8{"web"}, null); 238 var r = try parseArgs(alloc, &[_][:0]const u8{"web"}, null);
260 defer r.deinit(); 239 defer r.deinit();
261 try std.testing.expectEqual(@as(usize, 0), r._argv.tiles.items.len); 240 try std.testing.expectEqual(@as(usize, 0), r._argv.list.items.len);
262 } 241 }
263 // Every other refusal arrives as error.Usage — and the testing 242 // Every other refusal arrives as error.Usage — and the testing
264 // allocator is the other half of this pin: a refusal that leaked the 243 // allocator is the other half of this pin: a refusal that leaked the
265 // tile list (whose strings are now owned) would fail the test that 244 // host list (whose strings are now owned) would fail the test that
266 // provoked it, which is what the single errdefer guarantees. 245 // provoked it, which is what the single errdefer guarantees.
267 // 246 //
268 // A flag with no value is a usage mistake, not a transport. 247 // A flag with no value is a usage mistake, not a host.
269 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null)); 248 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null));
270 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null)); 249 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null));
271 // The refusals that had a tile on the list already, so the cleanup is 250 // The refusals that had a host on the list already, so the cleanup is
272 // load-bearing rather than theoretical. 251 // load-bearing rather than theoretical.
273 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--wat" }, null)); 252 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--wat" }, null));
274 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "quic://" }, null)); 253 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "quic://" }, null));
275 // A `#NAME` that ate the whole target: all three transport spellings
276 // refuse an empty spec, so `--sock '#b'` fails at usage altitude rather
277 // than at a connect to the empty path, far from the typo.
278 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--sock", "#b" }, null));
279 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--quic-idle-ms", "0" }, null)); 254 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--quic-idle-ms", "0" }, null));
280 // Port 0 means "kernel, you pick" — but the hub announces the port it 255 // Port 0 means "kernel, you pick" — but the hub announces the port it
281 // was asked for, so the door it prints is not the door it opened. 256 // was asked for, so the door it prints is not the door it opened.
@@ -306,67 +281,61 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
306 } 281 }
307 } 282 }
308 283
309 test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" { 284 test "hosts: the spelling reaches the file verbatim" {
310 const alloc = std.testing.allocator; 285 const alloc = std.testing.allocator;
311 const args = [_][:0]const u8{ 286 const args = [_][:0]const u8{ "web", "user@box.example.com", "quic://h:1", "--sock", "/tmp/x", "plainhost" };
312 "web", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c",
313 };
314 var r = try parseArgs(alloc, &args, null); 287 var r = try parseArgs(alloc, &args, null);
315 defer r.deinit(); 288 defer r.deinit();
316 try std.testing.expectEqual(@as(usize, 5), r._argv.tiles.items.len); 289 try std.testing.expectEqual(@as(usize, 4), r._argv.list.items.len);
317 290
318 // The session SPLIT is wall.parseSpelling's, tested there. What is 291 // The user asked for `user@box.example.com`, so that is the file's
319 // this parse's own is that the argv string arrives intact: the user 292 // line and therefore the tile's label — nobody decorates it on the way.
320 // asked for `host#b`, so that is the wall line, and therefore the 293 try std.testing.expectEqualStrings("user@box.example.com", r._argv.list.items[0]);
321 // tile's name on screen — nobody decorates it on the way. 294 try std.testing.expectEqualStrings("quic://h:1", r._argv.list.items[1]);
322 try std.testing.expectEqualStrings("host#b", r._argv.tiles.items[0]); 295 // The flag and its value become one spelling.
323 try std.testing.expectEqualStrings("quic://h:1#b", r._argv.tiles.items[1]); 296 try std.testing.expectEqualStrings("--sock /tmp/x", r._argv.list.items[2]);
324 // The flag and its value become one spelling; the `#NAME` rides on the 297 try std.testing.expectEqualStrings("plainhost", r._argv.list.items[3]);
325 // VALUE, where the user put it.
326 try std.testing.expectEqualStrings("--sock /tmp/x#b", r._argv.tiles.items[2]);
327 try std.testing.expectEqualStrings("plainhost", r._argv.tiles.items[3]);
328 try std.testing.expectEqualStrings("a#b#c", r._argv.tiles.items[4]);
329 } 298 }
330 299
331 test "tiles: a bad session name after # is still a usage error at parse" { 300 test "hosts: a '#SESSION' is refused at parse, in every spelling" {
332 const alloc = std.testing.allocator; 301 const alloc = std.testing.allocator;
333 // The refusal stays HERE, at argv altitude, rather than surfacing later 302 // The wall lists DAEMONS. A `#NAME` here would name a session the hub
334 // as one tile that will not attach. These print a line naming the tile 303 // must never resurrect, so it is refused at argv altitude rather than
335 // before returning, so the `mux web:` lines in this test's output are the 304 // surfacing later as a line the strict loader will not take. These
336 // point, not noise: with several tiles on the line, `usage` alone would 305 // print a line naming the host before returning, so the `mux web:`
337 // not say which one. 306 // lines in this test's output are the point, not noise.
338 try std.testing.expectError( 307 for ([_][]const u8{ "host#b", "host#has space", "host#", "a#b#c", "quic://h:1#b" }) |bad| {
339 error.Usage, 308 var argv = [_][:0]const u8{ "web", undefined };
340 parseArgs(alloc, &[_][:0]const u8{ "web", "host#has space" }, null), 309 var buf: [64]u8 = undefined;
341 ); 310 @memcpy(buf[0..bad.len], bad);
342 // A bare trailing '#' asks for the empty name. It is the default ON THE 311 buf[bad.len] = 0;
343 // WIRE but not a name a user may spell, so typing it is a mistake. 312 argv[1] = buf[0..bad.len :0];
344 try std.testing.expectError( 313 try std.testing.expectError(error.Usage, parseArgs(alloc, &argv, null));
345 error.Usage, 314 }
346 parseArgs(alloc, &[_][:0]const u8{ "web", "host#" }, null), 315 // Same rule through --sock's value.
347 );
348 // Same rule through --sock's value and through quic://.
349 try std.testing.expectError( 316 try std.testing.expectError(
350 error.Usage, 317 error.Usage,
351 parseArgs(alloc, &[_][:0]const u8{ "web", "--sock", "/tmp/x#bad name" }, null), 318 parseArgs(alloc, &[_][:0]const u8{ "web", "--sock", "/tmp/x#b" }, null),
352 ); 319 );
320 // Punctuation in a HOST is the other refusal this grammar owns: the
321 // word becomes one argv element of an ssh line.
353 try std.testing.expectError( 322 try std.testing.expectError(
354 error.Usage, 323 error.Usage,
355 parseArgs(alloc, &[_][:0]const u8{ "web", "quic://h:1#a/b" }, null), 324 parseArgs(alloc, &[_][:0]const u8{ "web", "box; touch /tmp/pwned" }, null),
356 ); 325 );
357 } 326 }
358 327
359 test "help is an answer, not a refusal, and -- fences the tiles from the flags" { 328 test "help is an answer, not a refusal, and -- fences the hosts from the flags" {
360 const alloc = std.testing.allocator; 329 const alloc = std.testing.allocator;
361 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "--help" }, null)); 330 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "--help" }, null));
362 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "-h" }, null)); 331 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "-h" }, null));
363 332
364 // Past `--` a word is a tile whatever it is spelled like: the escape a 333 // Past `--` a word is a host whatever it is spelled like: the escape a
365 // host whose name reads as a flag would otherwise have none of. 334 // machine whose name reads as a flag would otherwise have none of.
366 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--", "host" }, null); 335 var r = try parseArgs(alloc, &[_][:0]const u8{ "web", "--", "host" }, null);
367 defer r.deinit(); 336 defer r.deinit();
368 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len); 337 try std.testing.expectEqual(@as(usize, 1), r._argv.list.items.len);
369 try std.testing.expectEqualStrings("host", r._argv.tiles.items[0]); 338 try std.testing.expectEqualStrings("host", r._argv.list.items[0]);
370 } 339 }
371 340
372 test "version short-circuits everything else on the line" { 341 test "version short-circuits everything else on the line" {
@@ -380,3 +349,15 @@ test "version short-circuits everything else on the line" {
380 test { 349 test {
381 std.testing.refAllDeclsRecursive(@This()); 350 std.testing.refAllDeclsRecursive(@This());
382 } 351 }
352
353 /// `mux_main.refuseFile`'s shape, in the words this binary answers in: the
354 /// file may have been hand-edited into a line the strict loader will not
355 /// take, and naming the line beats a stack trace because the fix is in the
356 /// file.
357 fn refuseFile(arena: std.mem.Allocator, path: []const u8, err: anyerror) u8 {
358 std.debug.print("mux web: {s}: {s}\n", .{ path, hosts.reason(err) });
359 if (!hosts.isParse(err)) return 1;
360 const lines = hosts.loadLines(arena, path) catch return 2;
361 for (lines.items) |l| _ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l});
362 return 2;
363 }
src/client/client.zig
Old New
@@ -1097,22 +1097,6 @@ pub fn spellingCap(target: Target) usize {
1097 return "--sock ".len + operand + 1 + proto.session_name_max; 1097 return "--sock ".len + operand + 1 + proto.session_name_max;
1098 } 1098 }
1099 1099
1100 // Whether a line RESTORED from the wall file may create the session it
1101 // names. The browser hub's per-tile pump is the door that asks; the CLI
1102 // wall no longer restores sessions at all. It sits on `Target` rather than on `hosts.Spec` because
1103 // that is what both doors still hold at the moment they decide — the
1104 // spelling is resolved away long before.
1105 //
1106 // A saved local line is the user's own workspace, and the daemon that
1107 // held it dies on every reboot: joining only left the whole wall
1108 // refused with nothing to do but forget the lines by hand. Every other
1109 // spelling is the other way round — creating would spawn a shell on a
1110 // box the user is not looking at, out of a file they last edited by
1111 // attaching once.
1112 pub fn hydratedCreates(target: Target) bool {
1113 return target == .sock;
1114 }
1115
1116 // The grid a birth asks for. It is `main.Opts`'s own default — the size 1100 // The grid a birth asks for. It is `main.Opts`'s own default — the size
1117 // `mux d run` gives session 0 — because a session created for a client 1101 // `mux d run` gives session 0 — because a session created for a client
1118 // that claims no size has to be born at SOMETHING, and the daemon's own 1102 // that claims no size has to be born at SOMETHING, and the daemon's own
@@ -2589,26 +2573,6 @@ test {
2589 std.testing.refAllDeclsRecursive(@This()); 2573 std.testing.refAllDeclsRecursive(@This());
2590 } 2574 }
2591 2575
2592 test "hydratedCreates: a saved LOCAL line may create the session; every remote spelling joins only" {
2593 // The rule the CLI wall and the browser hub both ask for, asserted at
2594 // the one altitude both hold: a Target, after the spelling is gone.
2595 try std.testing.expect(hydratedCreates(.{ .sock = "/tmp/a" }));
2596 try std.testing.expect(!hydratedCreates(.{ .quic = .{
2597 .host_port = "h:4433",
2598 .key_path = "/tmp/k",
2599 .idle_ms = 30_000,
2600 } }));
2601 try std.testing.expect(!hydratedCreates(.{ .hand = .{
2602 .host = "box",
2603 .ssh_argv = &.{ "ssh", "box", "mux d endpoint" },
2604 .cache_path = null,
2605 } }));
2606 // `.via` has no wall spelling at all, so it can never come off the
2607 // saved file — but a caller holding one must still not create through
2608 // an arbitrary command it cannot even name.
2609 try std.testing.expect(!hydratedCreates(.{ .via = "ssh box mux d run" }));
2610 }
2611
2612 /// A daemon stand-in for the birth tests: accepts once, records every frame 2576 /// A daemon stand-in for the birth tests: accepts once, records every frame
2613 /// type it is sent, and answers the first one with `reply`. 2577 /// type it is sent, and answers the first one with `reply`.
2614 const BirthFake = struct { 2578 const BirthFake = struct {
@@ -2768,7 +2732,7 @@ test "listSessions: a poll that failed still reports the login it paid for" {
2768 // An ssh that dies without an announce: the login is spent, the poll has 2732 // An ssh that dies without an announce: the login is spent, the poll has
2769 // nothing. Left at `.fd` the wall would ask again in a second, forever — 2733 // nothing. Left at `.fd` the wall would ask again in a second, forever —
2770 // one sshd auth line per second per dead host, which is the whole reason 2734 // one sshd auth line per second per dead host, which is the whole reason
2771 // `wall_host.pollDelayMs` stretches a `.pipe` answer tenfold. 2735 // `pollDelayMs` stretches a `.pipe` answer tenfold.
2772 var link: std.meta.Tag(Link) = .quic; 2736 var link: std.meta.Tag(Link) = .quic;
2773 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .hand = .{ 2737 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .hand = .{
2774 .host = "nowhere", 2738 .host = "nowhere",
src/client/handoff.zig
Old New
@@ -180,10 +180,10 @@ pub const Recipe = struct {
180 180
181 pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void { 181 pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void {
182 // Exactly once, by the arena that built it: the wall hands one 182 // Exactly once, by the arena that built it: the wall hands one
183 // recipe to every Tile of a host and a Tile ALIASES both argvs 183 // recipe to every Tile of a host, and both the CLI tile and the
184 // rather than copying them (`webhub.copyTarget` is the one caller 184 // hub's checkout ALIAS its argvs rather than copying them. Two
185 // that dupes). Two levels deep, so a future non-arena owner has two 185 // levels deep, so a future non-arena owner has two levels to free
186 // levels to free and a per-Tile free would be a double one. 186 // and a per-Tile free would be a double one.
187 freeArgv(alloc, self.ssh_argv); 187 freeArgv(alloc, self.ssh_argv);
188 freeArgv(alloc, self.start_argv); 188 freeArgv(alloc, self.start_argv);
189 if (self.cache_path) |c| alloc.free(c); 189 if (self.cache_path) |c| alloc.free(c);
src/client/webhub.zig
Old New
@@ -12,8 +12,6 @@
12 const std = @import("std"); 12 const std = @import("std");
13 const proto = @import("protocol"); 13 const proto = @import("protocol");
14 const client = @import("client"); 14 const client = @import("client");
15 const wall = @import("wall");
16 const handoff = @import("handoff");
17 15
18 pub const default_port: u16 = 7681; 16 pub const default_port: u16 = 7681;
19 17
@@ -50,289 +48,219 @@ pub fn wsTileId(path: []const u8) ?u32 {
50 return std.fmt.parseInt(u32, path[prefix.len..], 10) catch null; 48 return std.fmt.parseInt(u32, path[prefix.len..], 10) catch null;
51 } 49 }
52 50
53 /// What a pump needs to run a tile without holding the tile itself. The 51 /// What a pump needs to run a tile without holding the tile itself. No
54 /// session name rides along because the pump may have to CREATE it. 52 /// session name: the browser names its own session in the attach frame it
55 pub const Checkout = struct { target: client.Target, session: []const u8 }; 53 /// sends on `up`, off the `/tiles` list — the hub forwards and never
54 /// attaches on anybody's behalf.
55 pub const Checkout = struct { target: client.Target };
56
57 /// One daemon the hub polls, fixed for the run. A hub never forgets a
58 /// host — `mux hosts rm` and a restart is how one leaves — so a tile may
59 /// borrow its target rather than copy it.
60 pub const HubHost = struct {
61 spec: client.HostSpec,
62 poll: client.SessionPoll = .{},
63 /// Wired by `start`, not by `init`: init returns the Hub BY VALUE, so
64 /// a pointer taken there names the temporary it was built in.
65 hub: ?*Hub = null,
66 idx: usize = 0,
67
68 fn keep(p: *anyopaque) bool {
69 const self: *HubHost = @ptrCast(@alignCast(p));
70 return self.hub.?.serving.load(.acquire);
71 }
56 72
57 /// One runtime tile. Owns an arena holding its resolved target and label 73 fn wake(p: *anyopaque) void {
58 /// strings; the arena dies with the tile, which is why pumps must 74 const self: *HubHost = @ptrCast(@alignCast(p));
59 /// checkout a COPY rather than borrow. 75 var buf: [proto.sessions_text_max]u8 = undefined;
76 const reachable = self.poll.reachable.load(.acquire);
77 self.hub.?.applyList(self.idx, if (reachable) self.poll.snapshot(&buf) else "", reachable);
78 }
79 };
80
81 fn pollHubHost(h: *HubHost) void {
82 h.poll.run(h.spec.poll_target, .{ .ctx = h, .keep = HubHost.keep, .wake = HubHost.wake });
83 }
84
85 /// One runtime tile: a live session on a listed daemon, and nothing the
86 /// browser authored. It owns its session name because the name arrived in
87 /// a poll buffer the next poll overwrites.
60 const HubTile = struct { 88 const HubTile = struct {
61 id: u32, 89 id: u32,
62 arena: std.heap.ArenaAllocator, 90 host: usize,
63 target: client.Target,
64 label: []const u8,
65 session: []const u8, 91 session: []const u8,
66 /// Registered while a pump owns a WS for this tile. removeTile uses 92 /// Registered while a pump owns a WS for this tile. A vanishing uses
67 /// it to shutdown(2) the socket, which unblocks the pump's reads; 93 /// it to shutdown(2) the socket, which unblocks the pump's reads; the
68 /// the pump unregisters (under the hub mutex) BEFORE serveConn 94 /// pump unregisters (under the hub mutex) BEFORE serveConn closes the
69 /// closes the fd, so a shutdown can never hit a recycled fd. 95 /// fd, so a shutdown can never hit a recycled fd.
70 ws_fd: ?std.posix.fd_t = null, 96 ws_fd: ?std.posix.fd_t = null,
97 /// One list's grace. The poll's connect and its pass through the
98 /// daemon take milliseconds a daemon mid-answer can be descheduled
99 /// for, and a wall that tore a tile down over one of those would
100 /// flicker every time somebody typed `exit` next door.
101 missed_once: bool = false,
71 }; 102 };
72 103
73 /// What a spelling can fail to become. `MissingKey` is a `quic://` entry 104 /// The wall at runtime: the hosts file's daemons, their live sessions as
74 /// with no key to prove itself with; `SockPathTooLong` is a path sun_path 105 /// tiles, and the ids the browser names them by.
75 /// cannot hold; `PersistFailed` is a mutation the wall file did not accept.
76 pub const AddError = wall.ParseError || ResolveError || error{PersistFailed};
77
78 /// The half of AddError a spelling can fail at resolution time — shared
79 /// with `Hub.init`, which resolves the whole wall before serving. The
80 /// resolver's own set: one door, one list of ways through it to fail.
81 pub const ResolveError = client.SpecError;
82
83 /// Resolves as argv does at startup, so a runtime tile means what the
84 /// command line means.
85 fn resolveTile(
86 arena: std.mem.Allocator,
87 spelling: []const u8,
88 key: ?[]const u8,
89 idle_ms: u32,
90 ) (wall.ParseError || ResolveError)!struct {
91 target: client.Target,
92 label: []const u8,
93 session: []const u8,
94 } {
95 const p = try wall.parseSpelling(spelling);
96 // The label is the user's spelling verbatim, `#NAME` included — the
97 // page shows what was typed, not a rebuilt approximation of it.
98 const label = try arena.dupe(u8, spelling);
99 const session = try arena.dupe(u8, p.session);
100 // The hub is never the ask: a tile redials for as long as the page is
101 // open and nobody is sitting in front of it. Said HERE and not only
102 // where the pump clears it — a permission cleared downstream is one a
103 // new road can miss.
104 const target = try client.Target.fromSpec(arena, p.spec, key, idle_ms, false);
105 return .{ .target = target, .label = label, .session = session };
106 }
107
108 /// Every string field copied into `arena`, every scalar by value: what a
109 /// pump holds must outlive the tile it came from, and a field left out
110 /// would arrive as its default — a handshake budget quietly shortened by
111 /// a copy nobody suspected.
112 ///
113 /// Field by field, with the shapes pinned below, because most of these
114 /// fields HAVE defaults: a new one added to client.zig would compile here
115 /// in silence and be wrong at runtime. The counts make that a build error.
116 fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target {
117 comptime {
118 std.debug.assert(@typeInfo(client.Target).@"union".fields.len == 4);
119 std.debug.assert(@typeInfo(client.QuicTarget).@"struct".fields.len == 4);
120 std.debug.assert(@typeInfo(client.HandoffTarget).@"struct".fields.len == 8);
121 }
122 return switch (t) {
123 .sock => |s| .{ .sock = try arena.dupe(u8, s) },
124 .via => |s| .{ .via = try arena.dupe(u8, s) },
125 .quic => |q| .{ .quic = .{
126 .host_port = try arena.dupe(u8, q.host_port),
127 .key_path = try arena.dupe(u8, q.key_path),
128 .idle_ms = q.idle_ms,
129 .deadline_ms = q.deadline_ms,
130 } },
131 .hand => |h| .{ .hand = .{
132 .host = try arena.dupe(u8, h.host),
133 .ssh_argv = try handoff.dupeArgv(arena, h.ssh_argv),
134 .start_argv = try handoff.dupeArgv(arena, h.start_argv),
135 .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null,
136 .deadline_ms = h.deadline_ms,
137 .idle_ms = h.idle_ms,
138 .asked = h.asked,
139 .quiet = h.quiet,
140 } },
141 };
142 }
143
144 /// The wall at runtime: the persisted spellings, their resolved targets,
145 /// and the ids the browser names them by.
146 /// 106 ///
147 /// Ids are handed out once and never reused, because the browser holds 107 /// Ids are handed out once and never reused, because the browser holds
148 /// them across a mutation it did not make: an index would silently 108 /// them across a change it did not make: an index — or a recycled id —
149 /// re-point a `/ws/<n>` at a different machine the moment another device 109 /// would silently re-point a `/ws/<n>` at a different shell the moment a
150 /// removed a tile. The wall file stays index-ordered — order is the whole 110 /// session ended somewhere else.
151 /// content of a wall — so `tiles` is kept parallel to `wall_state.targets`
152 /// and the id lives only here.
153 pub const Hub = struct { 111 pub const Hub = struct {
154 alloc: std.mem.Allocator, 112 alloc: std.mem.Allocator,
155 /// One lock for the whole hub. Mutations are rare (a human clicking) 113 /// One lock for the tile list. Every writer is a poller's wake or a
156 /// and every one of them touches wall, tiles and the file together; 114 /// browser's checkout, both rare, and every one of them touches the
157 /// finer locking would buy nothing and cost an invariant. 115 /// list as a whole.
158 mutex: std.Thread.Mutex = .{}, 116 mutex: std.Thread.Mutex = .{},
117 hosts: []HubHost,
159 tiles: std.ArrayList(HubTile) = .empty, 118 tiles: std.ArrayList(HubTile) = .empty,
160 wall_state: wall.Wall,
161 next_id: u32 = 0, 119 next_id: u32 = 0,
162 /// null = don't persist (tests). Persist failures on a mutation are 120 serving: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
163 /// PersistFailed, not silence: a wall the user thinks is saved and 121
164 /// isn't would be the worst kind of quiet. 122 /// `specs` is borrowed: hub_main resolves the hosts file into an arena
165 state_path: ?[]const u8, 123 /// that outlives the process's accept loop.
166 key: ?[]const u8, 124 pub fn init(alloc: std.mem.Allocator, specs: []const client.HostSpec) !Hub {
167 idle_ms: u32, 125 const rows = try alloc.alloc(HubHost, specs.len);
168 126 for (rows, specs) |*r, spec| r.* = .{ .spec = spec };
169 /// Takes ownership of `w` — resolved or not, `deinit` frees it. 127 return .{ .alloc = alloc, .hosts = rows };
170 pub fn init(
171 alloc: std.mem.Allocator,
172 w: wall.Wall,
173 state_path: ?[]const u8,
174 key: ?[]const u8,
175 idle_ms: u32,
176 ) !Hub {
177 var self = Hub{
178 .alloc = alloc,
179 .wall_state = w,
180 .state_path = state_path,
181 .key = key,
182 .idle_ms = idle_ms,
183 };
184 errdefer self.deinit();
185 for (self.wall_state.targets.items) |spelling| {
186 const tile = try self.makeTile(spelling);
187 errdefer {
188 var a = tile.arena;
189 a.deinit();
190 }
191 try self.tiles.append(alloc, tile);
192 }
193 return self;
194 } 128 }
195 129
196 pub fn deinit(self: *Hub) void { 130 pub fn deinit(self: *Hub) void {
197 for (self.tiles.items) |*t| t.arena.deinit(); 131 for (self.tiles.items) |t| self.alloc.free(t.session);
198 self.tiles.deinit(self.alloc); 132 self.tiles.deinit(self.alloc);
199 self.wall_state.deinit(self.alloc); 133 self.alloc.free(self.hosts);
200 } 134 }
201 135
202 /// Resolve one spelling into a tile with its own arena. Takes no lock 136 /// One poller thread per host. A host whose thread will not start
203 /// itself: mutating callers hold the mutex, and init runs before the 137 /// shows what an unreachable one shows — nothing — rather than taking
204 /// Hub is reachable from any other thread. 138 /// the hub down with it.
205 fn makeTile(self: *Hub, spelling: []const u8) !HubTile { 139 pub fn start(self: *Hub) void {
206 var arena = std.heap.ArenaAllocator.init(self.alloc); 140 for (self.hosts, 0..) |*h, i| {
207 errdefer arena.deinit(); 141 h.hub = self;
208 const r = try resolveTile(arena.allocator(), spelling, self.key, self.idle_ms); 142 h.idx = i;
209 const id = self.next_id; 143 }
210 self.next_id += 1; 144 for (self.hosts) |*h| {
211 return .{ 145 const th = std.Thread.spawn(.{}, pollHubHost, .{h}) catch {
212 .id = id, 146 std.debug.print("mux web: no poller for {s}\n", .{h.spec.spelling});
213 .arena = arena, 147 continue;
214 .target = r.target, 148 };
215 .label = r.label, 149 th.detach();
216 .session = r.session, 150 }
217 }; 151 }
152
153 /// One host's answer, turned into births and vanishings. Callable
154 /// without a thread, which is what lets the rule be tested without a
155 /// daemon: the poller's wake is the only other caller.
156 pub fn applyList(self: *Hub, host_idx: usize, list: []const u8, reachable: bool) void {
157 self.mutex.lock();
158 defer self.mutex.unlock();
159 // Only a LIST drives the diff. A host that has gone quiet keeps
160 // its tiles, which reconnect on their own; vanishing them on a
161 // failed poll would tear a wall down over one dropped packet, and
162 // spending the grace on silence would do it one poll later.
163 if (!reachable) return;
164 var it = std.mem.splitScalar(u8, list, '\n');
165 while (it.next()) |name| {
166 if (name.len == 0) continue;
167 // The trust boundary: a peer's reply is bounded only in TOTAL,
168 // so one "name" in it can be 1056 bytes of anything.
169 // `encodeAttachNamed` memcpys a name into a 32-byte tail behind
170 // an assert, which states a bug rather than filtering input.
171 if (!proto.validSessionName(name)) continue;
172 if (self.find(host_idx, name) != null) continue;
173 self.birth(host_idx, name) catch continue;
174 }
175 var i: usize = 0;
176 while (i < self.tiles.items.len) {
177 const t = &self.tiles.items[i];
178 // Only a host's own sessions are its list's to keep or drop:
179 // two daemons may have a session of the same name.
180 if (t.host != host_idx) {
181 i += 1;
182 continue;
183 }
184 if (listHas(list, t.session)) {
185 t.missed_once = false;
186 i += 1;
187 continue;
188 }
189 if (!t.missed_once) {
190 t.missed_once = true;
191 i += 1;
192 continue;
193 }
194 self.vanish(i);
195 }
218 } 196 }
219 197
220 /// Caller holds the mutex. 198 /// Caller holds the mutex.
221 fn indexOf(self: *Hub, id: u32) ?usize { 199 fn find(self: *Hub, host_idx: usize, name: []const u8) ?usize {
222 for (self.tiles.items, 0..) |t, i| if (t.id == id) return i; 200 for (self.tiles.items, 0..) |t, i| {
201 if (t.host == host_idx and std.mem.eql(u8, t.session, name)) return i;
202 }
223 return null; 203 return null;
224 } 204 }
225 205
226 /// Caller holds the mutex. A wall we cannot write is a mutation the 206 /// Caller holds the mutex.
227 /// user must be told did not stick. 207 fn indexOf(self: *Hub, id: u32) ?usize {
228 fn persist(self: *Hub) error{PersistFailed}!void { 208 for (self.tiles.items, 0..) |t, i| if (t.id == id) return i;
229 const path = self.state_path orelse return; 209 return null;
230 wall.save(&self.wall_state, path) catch return error.PersistFailed;
231 } 210 }
232 211
233 pub fn addTile(self: *Hub, spelling: []const u8) AddError!u32 { 212 /// Caller holds the mutex. Inserted rather than appended, so `tiles`
234 self.mutex.lock(); 213 /// reads in HOST order however the pollers happen to be scheduled;
235 defer self.mutex.unlock(); 214 /// within a host it is the daemon's own list order, because that is
236 215 /// the order `applyList` walks the list in.
237 const idx = try self.wall_state.add(self.alloc, spelling); 216 fn birth(self: *Hub, host_idx: usize, name: []const u8) !void {
238 errdefer self.wall_state.remove(self.alloc, idx); 217 const own = try self.alloc.dupe(u8, name);
239 var tile = try self.makeTile(spelling); 218 errdefer self.alloc.free(own);
240 errdefer tile.arena.deinit(); 219 var at = self.tiles.items.len;
241 try self.tiles.append(self.alloc, tile); 220 for (self.tiles.items, 0..) |t, i| if (t.host > host_idx) {
242 errdefer _ = self.tiles.pop(); 221 at = i;
243 try self.persist(); 222 break;
244 return tile.id; 223 };
224 const id = self.next_id;
225 try self.tiles.insert(self.alloc, at, .{ .id = id, .host = host_idx, .session = own });
226 self.next_id += 1;
227 // A user reads this to learn what `/ws/<id>` names, and a script
228 // waits on it to know the wall has a tile at all.
229 std.debug.print("mux web: tile {d}: {s}#{s}\n", .{ id, self.hosts[host_idx].spec.spelling, name });
245 } 230 }
246 231
247 /// `UnknownId` for an id that is already gone — a second device 232 /// Caller holds the mutex.
248 /// removing the same tile is a race the caller may ignore (404), not a 233 fn vanish(self: *Hub, i: usize) void {
249 /// failure. `PersistFailed` says the tile IS gone from this hub and the 234 const t = self.tiles.orderedRemove(i);
250 /// wall file disagrees, which the user must be told: silence there 235 // Wake the pump before freeing what it might still be reading for:
251 /// means the tile reappears at the next start with no explanation. 236 // shutdown unblocks its recv, and it unregisters the fd itself.
252 pub fn removeTile(self: *Hub, id: u32) error{ UnknownId, PersistFailed }!void {
253 self.mutex.lock();
254 defer self.mutex.unlock();
255
256 const idx = self.indexOf(id) orelse return error.UnknownId;
257 var tile = self.tiles.orderedRemove(idx);
258 // Wake the pump before freeing anything it might still be reading
259 // for: shutdown unblocks its recv, and it unregisters the fd
260 // itself. Its target is a copy in its own arena, so the arena
261 // dying here cannot pull the ground from under it.
262 // The raw syscall, ignoring errno: std.posix.shutdown calls BADF 237 // The raw syscall, ignoring errno: std.posix.shutdown calls BADF
263 // and NOTSOCK unreachable, and neither is a reason to abort a 238 // and NOTSOCK unreachable, and neither is a reason to abort a
264 // removal — a registered fd whose pump is already unwinding is 239 // removal — a registered fd whose pump is already unwinding is
265 // exactly the race this wakes up, and it has nothing left to say. 240 // exactly the race this wakes up.
266 if (tile.ws_fd) |fd| _ = std.os.linux.shutdown(fd, std.os.linux.SHUT.RDWR); 241 if (t.ws_fd) |fd| _ = std.os.linux.shutdown(fd, std.os.linux.SHUT.RDWR);
267 tile.arena.deinit(); 242 std.debug.print("mux web: tile {d}: gone\n", .{t.id});
268 // Same index, by construction: tiles is parallel to the wall. 243 self.alloc.free(t.session);
269 self.wall_state.remove(self.alloc, idx);
270 // No rollback, unlike addTile: the tile's arena is already freed and
271 // its pump already woken, so there is nothing left to put back. The
272 // removal stands and the caller is told the file did not take it.
273 return self.persist();
274 }
275
276 /// `ids` must name every live tile exactly once — a browser working
277 /// from a view that has since changed gets Stale and refetches rather
278 /// than imposing an order built around a tile that no longer exists.
279 pub fn reorderTiles(self: *Hub, ids: []const u32) error{ Stale, OutOfMemory, PersistFailed }!void {
280 self.mutex.lock();
281 defer self.mutex.unlock();
282
283 const n = self.tiles.items.len;
284 if (ids.len != n) return error.Stale;
285 const order = try self.alloc.alloc(usize, n);
286 defer self.alloc.free(order);
287 var seen = try self.alloc.alloc(bool, n);
288 defer self.alloc.free(seen);
289 @memset(seen, false);
290 for (ids, 0..) |id, dst| {
291 const src = self.indexOf(id) orelse return error.Stale;
292 if (seen[src]) return error.Stale;
293 seen[src] = true;
294 order[dst] = src;
295 }
296 const old = try self.alloc.dupe(HubTile, self.tiles.items);
297 defer self.alloc.free(old);
298 // The wall goes first: it is the one that can refuse, and it
299 // refuses only on a permutation we have already proven good.
300 self.wall_state.reorder(self.alloc, order) catch |err| switch (err) {
301 error.BadOrder => return error.Stale,
302 error.OutOfMemory => return error.OutOfMemory,
303 };
304 for (order, 0..) |src, dst| self.tiles.items[dst] = old[src];
305 // A failed save leaves the new order live and the file behind it:
306 // the hub is the running truth, and the next successful mutation
307 // writes the whole wall anyway. The caller is told, which is the
308 // part that matters. (addTile differs — there the rollback costs
309 // nothing and a POST that errors is cleaner having changed
310 // nothing at all.)
311 try self.persist();
312 } 244 }
313 245
314 /// UnknownId vs OutOfMemory: the HTTP layer answers 404 for one, 246 /// UnknownId: an ended session. 404, and the page refetches.
315 /// 500 for the other.
316 pub fn checkoutTile( 247 pub fn checkoutTile(
317 self: *Hub, 248 self: *Hub,
318 id: u32, 249 id: u32,
319 ws_fd: std.posix.fd_t, 250 ws_fd: std.posix.fd_t,
320 arena: std.mem.Allocator, 251 ) error{UnknownId}!Checkout {
321 ) error{ UnknownId, OutOfMemory }!Checkout {
322 self.mutex.lock(); 252 self.mutex.lock();
323 defer self.mutex.unlock(); 253 defer self.mutex.unlock();
324 254
325 const idx = self.indexOf(id) orelse return error.UnknownId; 255 const idx = self.indexOf(id) orelse return error.UnknownId;
326 // Copy first: a failed copy must not leave an fd registered for a 256 // BORROWED: a host is fixed for the run and outlives every pump,
327 // pump that never starts. 257 // so there is nothing here to copy field by field — the three
328 const copy = Checkout{ 258 // comptime field-count asserts a copy needed are gone with it.
329 .target = try copyTarget(arena, self.tiles.items[idx].target), 259 const copy = Checkout{ .target = self.hosts[self.tiles.items[idx].host].spec.target };
330 .session = try arena.dupe(u8, self.tiles.items[idx].session),
331 };
332 // FIRST registration wins: two browsers on one wall run two pumps 260 // FIRST registration wins: two browsers on one wall run two pumps
333 // per tile, and overwriting would leave removeTile able to wake 261 // per tile, and overwriting would leave a vanishing able to wake
334 // only the last one — worse, the untracked pump's release would 262 // only the last one — worse, the untracked pump's release would
335 // then clear the tracked one's fd and removeTile would wake 263 // then clear the tracked one's fd and the vanishing would wake
336 // nobody. The second pump still runs and still serves its browser; 264 // nobody. The second pump still runs and still serves its browser;
337 // it is simply not the shutdown-tracked one, and its own WS read 265 // it is simply not the shutdown-tracked one, and its own WS read
338 // ends it when the browser goes away. Best-effort single-fd 266 // ends it when the browser goes away. Best-effort single-fd
@@ -344,7 +272,7 @@ pub const Hub = struct {
344 272
345 /// Unregister only the fd this caller registered: a second pump 273 /// Unregister only the fd this caller registered: a second pump
346 /// releasing must not clear the tracked pump's. Must run BEFORE the 274 /// releasing must not clear the tracked pump's. Must run BEFORE the
347 /// caller closes the fd, or a concurrent removeTile could shutdown a 275 /// caller closes the fd, or a concurrent vanishing could shutdown a
348 /// number the kernel has already recycled. 276 /// number the kernel has already recycled.
349 pub fn releaseTile(self: *Hub, id: u32, ws_fd: std.posix.fd_t) void { 277 pub fn releaseTile(self: *Hub, id: u32, ws_fd: std.posix.fd_t) void {
350 self.mutex.lock(); 278 self.mutex.lock();
@@ -355,7 +283,33 @@ pub const Hub = struct {
355 } 283 }
356 } 284 }
357 285
358 /// The tile list the page renders and attaches by, in wall order. 286 /// The `+` on a tile: a new session on THAT tile's daemon. The name is
287 /// the daemon's own next free one, so the browser and `Ctrl-\ c` count
288 /// in the same series.
289 pub fn spawn(self: *Hub, id: u32) !client.SessionName {
290 self.mutex.lock();
291 const idx = self.indexOf(id) orelse {
292 self.mutex.unlock();
293 return error.UnknownId;
294 };
295 const hi = self.tiles.items[idx].host;
296 // Unlocked before the dial: a birth is a whole round trip to a
297 // daemon, and every poller's wake would queue behind it.
298 self.mutex.unlock();
299
300 const h = &self.hosts[hi];
301 var list_buf: [proto.sessions_text_max]u8 = undefined;
302 var name_buf: [proto.session_name_max]u8 = undefined;
303 const name = client.SessionName.of(client.nextFreeName(&name_buf, h.poll.snapshot(&list_buf)));
304 try client.birthSession(self.alloc, h.spec.target, name.slice(), client.birth_cols, client.birth_rows);
305 // The tile appears on the next LIST, never on this reply — one
306 // road onto the wall — so the poll is asked for now rather than in
307 // a second.
308 h.poll.poke.store(true, .release);
309 return name;
310 }
311
312 /// The tile list the page renders and attaches by, in host order.
359 pub fn json(self: *Hub, alloc: std.mem.Allocator) ![]u8 { 313 pub fn json(self: *Hub, alloc: std.mem.Allocator) ![]u8 {
360 self.mutex.lock(); 314 self.mutex.lock();
361 defer self.mutex.unlock(); 315 defer self.mutex.unlock();
@@ -366,11 +320,11 @@ pub const Hub = struct {
366 for (self.tiles.items, 0..) |t, i| { 320 for (self.tiles.items, 0..) |t, i| {
367 if (i > 0) try out.append(alloc, ','); 321 if (i > 0) try out.append(alloc, ',');
368 try out.print(alloc, "{{\"id\":{d},\"label\":", .{t.id}); 322 try out.print(alloc, "{{\"id\":{d},\"label\":", .{t.id});
369 // Both strings take the same escape road: a label is the user's 323 // Both strings take the same escape road: a label is the host
370 // spelling, which is theirs to make unparseable, and the session 324 // line the user wrote, which is theirs to make unparseable, and
371 // rides beside it rather than inside it so the page never has to 325 // the session rides beside it rather than inside it so the page
372 // dig one back out of the other. 326 // never has to dig one back out of the other.
373 try appendJsonString(alloc, &out, t.label); 327 try appendJsonString(alloc, &out, self.hosts[t.host].spec.spelling);
374 try out.appendSlice(alloc, ",\"session\":"); 328 try out.appendSlice(alloc, ",\"session\":");
375 try appendJsonString(alloc, &out, t.session); 329 try appendJsonString(alloc, &out, t.session);
376 try out.append(alloc, '}'); 330 try out.append(alloc, '}');
@@ -380,6 +334,14 @@ pub const Hub = struct {
380 } 334 }
381 }; 335 };
382 336
337 /// A '\n'-separated `sessions_reply` payload, asked whether it holds one
338 /// name. Never `indexOf`: `w` is in `work` and neither is the other.
339 fn listHas(list: []const u8, name: []const u8) bool {
340 var it = std.mem.splitScalar(u8, list, '\n');
341 while (it.next()) |n| if (std.mem.eql(u8, n, name)) return true;
342 return false;
343 }
344
383 /// The embedded page: webhub_main @embedFiles them, tests inject fakes. 345 /// The embedded page: webhub_main @embedFiles them, tests inject fakes.
384 pub const Assets = struct { 346 pub const Assets = struct {
385 index_html: []const u8, 347 index_html: []const u8,
@@ -670,7 +632,7 @@ fn redial(
670 ws: *std.http.Server.WebSocket, 632 ws: *std.http.Server.WebSocket,
671 ws_fd: std.posix.fd_t, 633 ws_fd: std.posix.fd_t,
672 live: *Liveness, 634 live: *Liveness,
673 restore: *Restore, 635 dial: *Dial,
674 ) bool { 636 ) bool {
675 // A dial that never saw a grid was REFUSED, and a refusal is a state, 637 // A dial that never saw a grid was REFUSED, and a refusal is a state,
676 // not an event: the daemon closes on it, so the next dial opens on its 638 // not an event: the daemon closes on it, so the next dial opens on its
@@ -678,80 +640,46 @@ fn redial(
678 // the same no. Charging the backoff here is what turns that loop into 640 // the same no. Charging the backoff here is what turns that loop into
679 // a poll. A dial that DID see a grid was torn, and a tear still heals 641 // a poll. A dial that DID see a grid was torn, and a tear still heals
680 // at once. 642 // at once.
681 if (!restore.saw_grid) restore.spin_ms = client.nextBackoffMs(restore.spin_ms); 643 if (!dial.saw_grid) dial.spin_ms = client.nextBackoffMs(dial.spin_ms);
682 // The per-dial reset belongs here, not at the three call sites: one of 644 // The per-dial reset belongs here, not at the three call sites: one of
683 // them forgot, and a forgotten reset stays silent until a torn 645 // them forgot, and a forgotten reset stays silent until a torn
684 // transport turns the next refusal into an ending. 646 // transport turns the next refusal into an ending.
685 restore.onRedial(); 647 dial.onRedial();
686 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return false; 648 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return false;
687 transport.close(); 649 transport.close();
688 transport.* = dialLoop(alloc, target, ws, ws_fd, live, restore.spin_ms) orelse return false; 650 transport.* = dialLoop(alloc, target, ws, ws_fd, live, dial.spin_ms) orelse return false;
689 ws.writeMessage(controlMessage(.up), .binary) catch return false; 651 ws.writeMessage(controlMessage(.up), .binary) catch return false;
690 return true; 652 return true;
691 } 653 }
692 654
693 /// The restore rule's state, one value rather than three locals so that 655 /// The dial's own state, one value rather than two locals so that
694 /// `redial` can own the per-dial reset. 656 /// `redial` can own the per-dial reset.
695 const Restore = struct { 657 const Dial = struct {
696 // The only per-DIAL flag, and the browser's own discriminator (mux.js, 658 // The only per-DIAL flag: whether this dial ever reached a grid. A
697 // the exit_status case): before any grid an exit_status is the daemon 659 // dial that did not was REFUSED, which is a state rather than an
698 // refusing the attach, after one it is the shell exiting. A dial that 660 // event, and the backoff below is what turns re-dialling into a poll
699 // keeps it inverts the next exit_status — the refusal after a tear 661 // instead of a spin.
700 // reads as the shell exiting, latches `ended`, and disables the heal
701 // for the rest of the pump's life, silently, because a tear is the
702 // only way to reach it.
703 saw_grid: bool = false, 662 saw_grid: bool = false,
704 // Bounds a daemon that refuses the birth too (a full table, a name it
705 // will not make) to one attempt per `birth_retry_ms`, so a refuse/
706 // redial spin cannot fork a process per turn. A plain latch was
707 // cleared only by a grid — the very thing a refused birth prevents —
708 // so a table that emptied an hour later healed no tile at all.
709 birth_at_ms: ?i64 = null,
710 // How fast the refusal loop may re-dial. Carried across dials and 663 // How fast the refusal loop may re-dial. Carried across dials and
711 // cleared by a grid, which is the only evidence the refusal is over. 664 // cleared by a grid, which is the only evidence the refusal is over.
712 spin_ms: u64 = 0, 665 spin_ms: u64 = 0,
713 // wallview's "exited stays exited" (its pump ENDS on an exit_status 666
714 // rather than redialing). Without it a user typing `exit` gets a new 667 // Only `saw_grid` resets: `spin_ms` outlives the connection ON
715 // shell: the daemon reaps the session and closes, the hub redials, 668 // PURPOSE, because every refusal closes it — `serviceObserver` answers
716 // mux.js re-attaches on `up`, the attach is refused because the
717 // session is gone — indistinguishable from the restore case unless
718 // the pump remembers it already watched this session die.
719 ended: bool = false,
720
721 // Only `saw_grid` resets. The other two outlive the connection ON
722 // PURPOSE, because every refusal closes it: `serviceObserver` answers
723 // an unseated attach with exit_status and then `dropObserver`, so a 669 // an unseated attach with exit_status and then `dropObserver`, so a
724 // flag cleared on the close bounds nothing at all. 670 // counter cleared on the close bounds nothing at all.
725 fn onRedial(self: *Restore) void { 671 fn onRedial(self: *Dial) void {
726 self.saw_grid = false; 672 self.saw_grid = false;
727 } 673 }
728 674
729 fn onFrame(self: *Restore, t: proto.MsgType) void { 675 fn onFrame(self: *Dial, t: proto.MsgType) void {
730 if (t == .snapshot or t == .delta) { 676 if (t == .snapshot or t == .delta) {
731 self.saw_grid = true; 677 self.saw_grid = true;
732 self.birth_at_ms = null;
733 self.spin_ms = 0; 678 self.spin_ms = 0;
734 } 679 }
735 if (t == .exit_status and self.saw_grid) self.ended = true;
736 }
737
738 // Read AFTER onFrame: an exit_status this pump has not answered and
739 // cannot read as an ending. Whether the target may be recreated at all
740 // is the caller's half (`client.hydratedCreates`).
741 fn wantsBirth(self: Restore, t: proto.MsgType, now_ms: i64) bool {
742 if (t != .exit_status or self.saw_grid or self.ended) return false;
743 const tried = self.birth_at_ms orelse return true;
744 return now_ms - tried >= birth_retry_ms;
745 } 680 }
746 }; 681 };
747 682
748 /// The floor between two births into the same refusal. Long against the
749 /// dial backoff's 2s ceiling, so a daemon that refuses both is polled by
750 /// the cheap half; short against `ping_idle_ms`, so a table that empties
751 /// heals the tile long before the browser would be reaped. Wrong, and
752 /// either a spin forks a shell per turn or a restored wall stays dead.
753 const birth_retry_ms: i64 = 5_000;
754
755 /// One thread per tile: Transport.readFrame's blocking read is 683 /// One thread per tile: Transport.readFrame's blocking read is
756 /// correct here. The hub reconnects; the browser re-attaches. 684 /// correct here. The hub reconnects; the browser re-attaches.
757 pub fn pumpTile( 685 pub fn pumpTile(
@@ -759,7 +687,6 @@ pub fn pumpTile(
759 ws: *std.http.Server.WebSocket, 687 ws: *std.http.Server.WebSocket,
760 ws_fd: std.posix.fd_t, 688 ws_fd: std.posix.fd_t,
761 target_in: client.Target, 689 target_in: client.Target,
762 session: []const u8,
763 ) void { 690 ) void {
764 var target = target_in; 691 var target = target_in;
765 // No terminal to spam and a control channel that already narrates: 692 // No terminal to spam and a control channel that already narrates:
@@ -769,7 +696,7 @@ pub fn pumpTile(
769 // open, and nobody is watching it do so. 696 // open, and nobody is watching it do so.
770 if (target == .hand) target.hand.asked = false; 697 if (target == .hand) target.hand.asked = false;
771 698
772 var restore: Restore = .{}; 699 var dial: Dial = .{};
773 700
774 var live = Liveness.init(); 701 var live = Liveness.init();
775 ws.writeMessage(controlMessage(.connecting), .binary) catch return; 702 ws.writeMessage(controlMessage(.connecting), .binary) catch return;
@@ -800,7 +727,7 @@ pub fn pumpTile(
800 .frame => |f| f, 727 .frame => |f| f,
801 .incomplete => break :frames, 728 .incomplete => break :frames,
802 .closed => { 729 .closed => {
803 if (!redial(alloc, &transport, target, ws, ws_fd, &live, &restore)) return; 730 if (!redial(alloc, &transport, target, ws, ws_fd, &live, &dial)) return;
804 // fds[1].revents describes a socket state from 731 // fds[1].revents describes a socket state from
805 // BEFORE the re-dial, and dialLoop may have eaten 732 // BEFORE the re-dial, and dialLoop may have eaten
806 // the very message it described. Re-poll instead 733 // the very message it described. Re-poll instead
@@ -812,35 +739,7 @@ pub fn pumpTile(
812 }, 739 },
813 }; 740 };
814 defer frame.deinit(alloc); 741 defer frame.deinit(alloc);
815 restore.onFrame(frame.type); 742 dial.onFrame(frame.type);
816 // A saved LOCAL line whose session the daemon no longer
817 // has: the CLI wall recreates it, and a browser wall that
818 // did not would be a grid of dead tiles after every
819 // reboot. The tile itself cannot ask — it attaches at 0x0
820 // by the passivity contract, which is join-only — so the
821 // hub births the session on a connection of its own and
822 // re-dials. The browser re-attaches on `up` and never
823 // learns a refusal happened, which is why nothing in
824 // mux.js decides any of this.
825 if (restore.wantsBirth(frame.type, std.time.milliTimestamp()) and
826 client.hydratedCreates(target))
827 {
828 restore.birth_at_ms = std.time.milliTimestamp();
829 if (client.birthSession(
830 alloc,
831 target,
832 session,
833 client.birth_cols,
834 client.birth_rows,
835 )) |_| {
836 if (!redial(alloc, &transport, target, ws, ws_fd, &live, &restore)) return;
837 continue :outer;
838 } else |_| {
839 // A birth the daemon refused too (a full table, a
840 // name it will not make). The refusal below is
841 // forwarded exactly as it was before any of this.
842 }
843 }
844 var hdr: [proto.frame_header_len]u8 = undefined; 743 var hdr: [proto.frame_header_len]u8 = undefined;
845 hdr[0] = @intFromEnum(frame.type); 744 hdr[0] = @intFromEnum(frame.type);
846 std.mem.writeInt(u32, hdr[1..5], @intCast(frame.payload.len), .little); 745 std.mem.writeInt(u32, hdr[1..5], @intCast(frame.payload.len), .little);
@@ -859,7 +758,7 @@ pub fn pumpTile(
859 .ok => {}, 758 .ok => {},
860 .browser_dead => return, 759 .browser_dead => return,
861 .transport_dead => { 760 .transport_dead => {
862 if (!redial(alloc, &transport, target, ws, ws_fd, &live, &restore)) return; 761 if (!redial(alloc, &transport, target, ws, ws_fd, &live, &dial)) return;
863 continue :outer; // same stale-revents reason as above 762 continue :outer; // same stale-revents reason as above
864 }, 763 },
865 } 764 }
@@ -938,21 +837,6 @@ fn appendJsonString(alloc: std.mem.Allocator, out: *std.ArrayList(u8), s: []cons
938 try out.append(alloc, '"'); 837 try out.append(alloc, '"');
939 } 838 }
940 839
941 /// `3,0,2` → ids. Empty, junk, or trailing garbage refuse: the body is
942 /// machine-written by our own page, so anything malformed is a bug
943 /// worth surfacing, not input to repair.
944 pub fn parseIdList(alloc: std.mem.Allocator, body: []const u8) error{ Bad, OutOfMemory }![]u32 {
945 var out: std.ArrayList(u32) = .empty;
946 errdefer out.deinit(alloc);
947 var it = std.mem.splitScalar(u8, std.mem.trim(u8, body, " \t\r\n"), ',');
948 while (it.next()) |part| {
949 const id = std.fmt.parseInt(u32, part, 10) catch return error.Bad;
950 try out.append(alloc, id);
951 }
952 if (out.items.len == 0) return error.Bad;
953 return out.toOwnedSlice(alloc);
954 }
955
956 /// Static requests loop for keep-alive; a WS upgrade takes the connection 840 /// Static requests loop for keep-alive; a WS upgrade takes the connection
957 /// and never returns to HTTP. 841 /// and never returns to HTTP.
958 pub fn serveConn( 842 pub fn serveConn(
@@ -1015,17 +899,13 @@ pub fn serveConn(
1015 // One name for the fd we register with, so the release below 899 // One name for the fd we register with, so the release below
1016 // provably names the same number it checked out under. 900 // provably names the same number it checked out under.
1017 const ws_fd = stream.handle; 901 const ws_fd = stream.handle;
1018 const checked = hub.checkoutTile(id, ws_fd, pump_arena.allocator()) catch |err| switch (err) { 902 const checked = hub.checkoutTile(id, ws_fd) catch |err| switch (err) {
1019 // Removed between the page's GET and this dial: the browser 903 // Removed between the page's GET and this dial: the browser
1020 // refetches /tiles and stops asking for it. 904 // refetches /tiles and stops asking for it.
1021 error.UnknownId => { 905 error.UnknownId => {
1022 req.respond("no such tile\n", .{ .status = .not_found }) catch {}; 906 req.respond("no such tile\n", .{ .status = .not_found }) catch {};
1023 return; 907 return;
1024 }, 908 },
1025 error.OutOfMemory => {
1026 req.respond("out of memory\n", .{ .status = .internal_server_error }) catch {};
1027 return;
1028 },
1029 }; 909 };
1030 // Deferred, not called after pumpTile: an upgrade that fails 910 // Deferred, not called after pumpTile: an upgrade that fails
1031 // must unregister too. Registered AFTER `defer stream.close()`, 911 // must unregister too. Registered AFTER `defer stream.close()`,
@@ -1035,7 +915,7 @@ pub fn serveConn(
1035 defer hub.releaseTile(id, ws_fd); 915 defer hub.releaseTile(id, ws_fd);
1036 var ws = req.respondWebSocket(.{ .key = key }) catch return; 916 var ws = req.respondWebSocket(.{ .key = key }) catch return;
1037 ws.flush() catch return; 917 ws.flush() catch return;
1038 pumpTile(alloc, &ws, ws_fd, checked.target, checked.session); 918 pumpTile(alloc, &ws, ws_fd, checked.target);
1039 return; 919 return;
1040 } 920 }
1041 921
@@ -1051,17 +931,17 @@ pub fn serveConn(
1051 continue; 931 continue;
1052 } 932 }
1053 933
1054 // Mutations: Origin-gated, always — a text/plain POST is a CSRF 934 // The one mutation left, Origin-gated: a text/plain POST is a CSRF
1055 // "simple request" any web page can fire at localhost without a 935 // "simple request" any web page can fire at localhost without a
1056 // preflight; the gate is what keeps this page's power this page's. 936 // preflight; the gate is what keeps this page's power this page's.
1057 // GET above stays ungated: we send no CORS headers, so a hostile 937 // GET /tiles above stays ungated: we send no CORS headers, so a
1058 // page can make the request but never read the answer. 938 // hostile page can make the request but never read the answer.
1059 // 939 //
1060 // Exact match only: a prefix match would route `/tilesgarbage` 940 // Exact match only: a prefix match would route `/tilesgarbage`
1061 // into a mutation and `/tiles/` or `/tiles?x=1` into a confusing 941 // into a mutation and `/tiles?x=1` into a confusing 403 here
1062 // 403/405 here instead of the asset router's plain 404. The API 942 // instead of the asset router's plain 404. The API defines exactly
1063 // defines exactly "/tiles" (POST/PUT) and "/tiles/<id>" (DELETE); 943 // "/tiles" (GET) and "/tiles/<id>" (POST); reject everything else
1064 // reject everything else by not matching it at all. 944 // by not matching it at all.
1065 const tiles_root = std.mem.eql(u8, path, "/tiles"); 945 const tiles_root = std.mem.eql(u8, path, "/tiles");
1066 const tile_id_suffix = if (std.mem.startsWith(u8, path, "/tiles/") and path.len > "/tiles/".len) 946 const tile_id_suffix = if (std.mem.startsWith(u8, path, "/tiles/") and path.len > "/tiles/".len)
1067 path["/tiles/".len..] 947 path["/tiles/".len..]
@@ -1072,99 +952,38 @@ pub fn serveConn(
1072 req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; 952 req.respond("forbidden\n", .{ .status = .forbidden }) catch {};
1073 return; 953 return;
1074 } 954 }
1075 switch (method) { 955 // Authoring a tile is gone with the wall file: the page shows
1076 .POST => { // body = one spelling 956 // what the listed daemons have, so POST /tiles, PUT and DELETE
1077 if (!tiles_root) { 957 // name nothing this hub can do. One answer for all of them.
1078 req.respond("not found\n", .{ .status = .not_found }) catch return; 958 const id_str = tile_id_suffix orelse {
1079 continue; 959 req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return;
1080 } 960 continue;
1081 var body_buf: [512]u8 = undefined; 961 };
1082 const rdr = req.readerExpectContinue(&body_buf) catch return; 962 if (method != .POST) {
1083 const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return; 963 req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return;
1084 defer alloc.free(body_raw); 964 continue;
1085 const spelling = std.mem.trim(u8, body_raw, " \t\r\n");
1086 const id = hub.addTile(spelling) catch |err| {
1087 const status: std.http.Status, const msg: []const u8 = switch (err) {
1088 error.BadSession => .{ .bad_request, "bad session name after '#'\n" },
1089 error.EmptySpec => .{ .bad_request, "empty target\n" },
1090 error.BadByte => .{ .bad_request, "control byte in target\n" },
1091 error.BadSpelling => .{ .bad_request, "punctuation in host: a tile spelling names a machine, not a command\n" },
1092 error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" },
1093 error.SockPathTooLong => .{ .bad_request, "socket path too long\n" },
1094 // The tile is NOT live: addTile rolls back on a
1095 // failed save, so 500 is the whole truth here.
1096 error.PersistFailed => .{ .internal_server_error, "wall not saved\n" },
1097 error.OutOfMemory => return,
1098 };
1099 req.respond(msg, .{ .status = status }) catch {};
1100 continue;
1101 };
1102 var buf: [32]u8 = undefined;
1103 const resp = std.fmt.bufPrint(&buf, "{{\"id\":{d}}}", .{id}) catch unreachable;
1104 req.respond(resp, .{ .extra_headers = &.{
1105 .{ .name = "content-type", .value = "application/json" },
1106 } }) catch return;
1107 },
1108 .DELETE => { // path = /tiles/<id>
1109 const id_str = tile_id_suffix orelse {
1110 req.respond("not found\n", .{ .status = .not_found }) catch return;
1111 continue;
1112 };
1113 const id = std.fmt.parseInt(u32, id_str, 10) catch {
1114 req.respond("not found\n", .{ .status = .not_found }) catch return;
1115 continue;
1116 };
1117 hub.removeTile(id) catch |err| {
1118 const status: std.http.Status, const msg: []const u8 = switch (err) {
1119 error.UnknownId => .{ .not_found, "not found\n" },
1120 // The tile IS gone from the running hub; only the
1121 // file disagrees. 500 so the user learns the wall
1122 // will come back on the next start.
1123 error.PersistFailed => .{ .internal_server_error, "wall not saved\n" },
1124 };
1125 req.respond(msg, .{ .status = status }) catch return;
1126 continue;
1127 };
1128 req.respond("", .{ .status = .no_content }) catch return;
1129 },
1130 .PUT => { // body = CSV of ids, the FULL new order
1131 if (!tiles_root) {
1132 req.respond("not found\n", .{ .status = .not_found }) catch return;
1133 continue;
1134 }
1135 var body_buf: [512]u8 = undefined;
1136 const rdr = req.readerExpectContinue(&body_buf) catch return;
1137 const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return;
1138 defer alloc.free(body_raw);
1139 const ids = parseIdList(alloc, body_raw) catch {
1140 req.respond("bad id list\n", .{ .status = .bad_request }) catch return;
1141 continue;
1142 };
1143 defer alloc.free(ids);
1144 hub.reorderTiles(ids) catch |err| switch (err) {
1145 error.Stale => {
1146 // A stale view: hand back the truth so the page
1147 // can reconcile and retry.
1148 const json = hub.json(alloc) catch return;
1149 defer alloc.free(json);
1150 req.respond(json, .{ .status = .conflict, .extra_headers = &.{
1151 .{ .name = "content-type", .value = "application/json" },
1152 } }) catch return;
1153 continue;
1154 },
1155 // The new order IS live (reorderTiles does not roll
1156 // back) — only the file lags, which the next
1157 // successful mutation rewrites whole.
1158 error.PersistFailed => {
1159 req.respond("wall not saved\n", .{ .status = .internal_server_error }) catch return;
1160 continue;
1161 },
1162 error.OutOfMemory => return,
1163 };
1164 req.respond("", .{ .status = .no_content }) catch return;
1165 },
1166 else => req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return,
1167 } 965 }
966 const id = std.fmt.parseInt(u32, id_str, 10) catch {
967 req.respond("not found\n", .{ .status = .not_found }) catch return;
968 continue;
969 };
970 const name = hub.spawn(id) catch |err| {
971 const status: std.http.Status, const msg: []const u8 = switch (err) {
972 error.UnknownId => .{ .not_found, "not found\n" },
973 // The daemon's no, or a box that did not answer: the
974 // hub is up and the birth is not, which is a gateway
975 // failure and not this server's own.
976 else => .{ .bad_gateway, @errorName(err) },
977 };
978 var buf: [64]u8 = undefined;
979 req.respond(std.fmt.bufPrint(&buf, "{s}\n", .{msg}) catch msg, .{ .status = status }) catch return;
980 continue;
981 };
982 var buf: [64]u8 = undefined;
983 const resp = std.fmt.bufPrint(&buf, "{{\"session\":\"{s}\"}}", .{name.slice()}) catch unreachable;
984 req.respond(resp, .{ .status = .created, .extra_headers = &.{
985 .{ .name = "content-type", .value = "application/json" },
986 } }) catch return;
1168 continue; 987 continue;
1169 } 988 }
1170 989
@@ -1185,106 +1004,30 @@ pub fn serveConn(
1185 } 1004 }
1186 } 1005 }
1187 1006
1188 test "restore: a refusal before any grid asks for a birth, once" { 1007 test "dial: a refused dial charges the backoff and a grid clears it" {
1189 var r: Restore = .{};
1190 r.onFrame(.exit_status);
1191 try std.testing.expect(r.wantsBirth(.exit_status, 1_000));
1192 try std.testing.expect(!r.ended);
1193
1194 // The pump's own bookkeeping when it acts, then the re-dial.
1195 r.birth_at_ms = 1_000;
1196 r.onRedial();
1197 r.onFrame(.exit_status);
1198 // A daemon that refuses the birth too must not be asked again on
1199 // every turn of the refuse/redial spin.
1200 try std.testing.expect(!r.wantsBirth(.exit_status, 1_000 + birth_retry_ms - 1));
1201 }
1202
1203 test "restore: a birth the daemon refused is retried once the retry floor passes" {
1204 // The latch that only a grid could clear was a deadlock in one
1205 // direction: the grid it waited for is the thing the refused birth
1206 // prevents, so a table that emptied later healed nothing until the
1207 // browser reconnected.
1208 var r: Restore = .{};
1209 r.birth_at_ms = 1_000;
1210 r.onRedial();
1211 r.onFrame(.exit_status);
1212 try std.testing.expect(r.wantsBirth(.exit_status, 1_000 + birth_retry_ms));
1213 }
1214
1215 test "restore: a torn transport is not an ending, and the heal survives it" {
1216 var r: Restore = .{};
1217 r.onFrame(.snapshot);
1218 // A tear mid-session. `saw_grid` belongs to the DIAL, and a redial
1219 // site that forgot the reset read the refusal below as the shell
1220 // exiting: `ended` latched and the heal never fired again.
1221 r.onRedial();
1222 r.onFrame(.exit_status);
1223 try std.testing.expect(!r.ended);
1224 try std.testing.expect(r.wantsBirth(.exit_status, 0));
1225 }
1226
1227 test "restore: a session watched dying is never reborn" {
1228 var r: Restore = .{};
1229 r.onFrame(.delta);
1230 r.onFrame(.exit_status);
1231 try std.testing.expect(r.ended);
1232
1233 // The redial the hub does anyway, and the refusal that follows it
1234 // because the daemon reaped the session: indistinguishable from the
1235 // restore case except through `ended`.
1236 r.onRedial();
1237 r.onFrame(.exit_status);
1238 try std.testing.expect(r.ended);
1239 // Even past the retry floor: a session watched dying is not a refusal
1240 // waiting on a daemon to recover.
1241 try std.testing.expect(!r.wantsBirth(.exit_status, birth_retry_ms * 10));
1242 }
1243
1244 test "restore: a grid re-arms the birth, so a second daemon restart heals too" {
1245 var r: Restore = .{};
1246 r.birth_at_ms = 1_000;
1247 r.onFrame(.snapshot);
1248 try std.testing.expect(r.birth_at_ms == null);
1249 r.onRedial();
1250 r.onFrame(.exit_status);
1251 // At the same instant the last birth was tried: the grid, not the
1252 // clock, is what re-armed it.
1253 try std.testing.expect(r.wantsBirth(.exit_status, 1_000));
1254 }
1255
1256 test "restore: a refused dial charges the backoff and a grid clears it" {
1257 // The spin this bounds: every refusal closes the connection, so the 1008 // The spin this bounds: every refusal closes the connection, so the
1258 // re-dial opens on its first try and the page attaches into the same 1009 // re-dial opens on its first try and the page attaches into the same
1259 // no. Carried across dials, or the tile connect/attach/close-loops at 1010 // no. Carried across dials, or the tile connect/attach/close-loops at
1260 // round-trip speed for as long as the daemon keeps refusing. 1011 // round-trip speed for as long as the daemon keeps refusing.
1261 var r: Restore = .{}; 1012 var d: Dial = .{};
1262 var charged: u64 = 0; 1013 var charged: u64 = 0;
1263 for (0..6) |_| { 1014 for (0..6) |_| {
1264 if (!r.saw_grid) r.spin_ms = client.nextBackoffMs(r.spin_ms); 1015 if (!d.saw_grid) d.spin_ms = client.nextBackoffMs(d.spin_ms);
1265 charged = r.spin_ms; 1016 charged = d.spin_ms;
1266 r.onRedial(); 1017 d.onRedial();
1267 r.onFrame(.exit_status); 1018 d.onFrame(.exit_status);
1268 } 1019 }
1269 try std.testing.expect(charged >= 2_000); 1020 try std.testing.expect(charged >= 2_000);
1270 1021
1271 // A grid is the only evidence the refusal is over, and a tear after 1022 // A grid is the only evidence the refusal is over, and a tear after
1272 // one heals at full speed again. 1023 // one heals at full speed again.
1273 r.onFrame(.snapshot); 1024 d.onFrame(.snapshot);
1274 try std.testing.expectEqual(@as(u64, 0), r.spin_ms); 1025 try std.testing.expectEqual(@as(u64, 0), d.spin_ms);
1275 } 1026 // A grid seen on the PREVIOUS dial says nothing about this one: the
1276 1027 // flag is per-dial, and a dial that kept it would read the refusal
1277 test "restore: only an exit_status asks for a birth" { 1028 // after a tear as a healthy connection and never back off.
1278 // A pump in the state a birth needs — nothing seen, nothing tried — 1029 d.onRedial();
1279 // fed the other frames a refused attach can be followed by. Fresh 1030 try std.testing.expect(!d.saw_grid);
1280 // each time on purpose: a shared Restore would answer no because a
1281 // snapshot earlier in the list set `saw_grid`, and would pass with
1282 // the frame-type test deleted.
1283 for ([_]proto.MsgType{ .snapshot, .delta, .pty_mode, .term_event, .term_modes }) |t| {
1284 var r: Restore = .{};
1285 r.onFrame(t);
1286 try std.testing.expect(!r.wantsBirth(t, 0));
1287 }
1288 } 1031 }
1289 1032
1290 test "origin: exactly our two spellings pass, everything else refuses" { 1033 test "origin: exactly our two spellings pass, everything else refuses" {
@@ -1322,120 +1065,150 @@ test "ws path by id: parses, no range opinion" {
1322 try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0")); 1065 try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0"));
1323 } 1066 }
1324 1067
1325 test "hub: ids are stable across remove and reorder; json is wall order" { 1068 test "hub: a listed name births a tile once; ids are birth order and never reused" {
1326 const alloc = std.testing.allocator; 1069 const alloc = std.testing.allocator;
1327 var w = wall.Wall{}; 1070 // TWO hosts, off-origin sessions: a fixture holding either constant is
1328 _ = try w.add(alloc, "--sock /tmp/a"); 1071 // blind to the dimension it holds.
1329 _ = try w.add(alloc, "--sock /tmp/b#s"); 1072 var hub = try Hub.init(alloc, &.{
1330 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); 1073 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1074 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1075 });
1331 defer hub.deinit(); 1076 defer hub.deinit();
1332 1077
1333 const j0 = try hub.json(alloc); 1078 hub.applyList(0, "0\nb\n", true);
1334 defer alloc.free(j0); 1079 hub.applyList(1, "work\n", true);
1335 try std.testing.expectEqualStrings( 1080 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1336 \\[{"id":0,"label":"--sock /tmp/a","session":""},{"id":1,"label":"--sock /tmp/b#s","session":"s"}] 1081 // The same list again is no news: a tile is born once per name per host.
1337 , j0); 1082 hub.applyList(0, "0\nb\n", true);
1338 1083 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1339 const id2 = try hub.addTile("--sock /tmp/c"); 1084 try std.testing.expectEqualSlices(u32, &.{ 0, 1, 2 }, &.{
1340 try std.testing.expectEqual(@as(u32, 2), id2); 1085 hub.tiles.items[0].id, hub.tiles.items[1].id, hub.tiles.items[2].id,
1341 try hub.removeTile(1); 1086 });
1342 try std.testing.expectError(error.UnknownId, hub.removeTile(1)); // already gone 1087
1343 try hub.reorderTiles(&.{ 2, 0 }); 1088 // A name a peer's reply invented is skipped, not tiled: `encodeAttachNamed`
1344 try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view 1089 // memcpys into a 32-byte tail behind an assert.
1345 1090 hub.applyList(1, "work\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\n", true);
1346 const j1 = try hub.json(alloc); 1091 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1347 defer alloc.free(j1); 1092
1348 try std.testing.expectEqualStrings( 1093 // Gone twice, then back: the browser holds `/ws/<id>` across a
1349 \\[{"id":2,"label":"--sock /tmp/c","session":""},{"id":0,"label":"--sock /tmp/a","session":""}] 1094 // vanishing it did not make, so a reused id would silently re-point
1350 , j1); 1095 // that socket at a different shell.
1351 1096 hub.applyList(0, "0\n", true);
1352 // An empty wall is a shape too, and the only one where a hand-rolled 1097 hub.applyList(0, "0\n", true);
1353 // encoder can emit nothing at all: the page's JSON.parse gets `[]`. 1098 try std.testing.expectEqual(@as(usize, 2), hub.tiles.items.len);
1354 try hub.removeTile(2); 1099 hub.applyList(0, "0\nb\n", true);
1355 try hub.removeTile(0); 1100 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1356 const j2 = try hub.json(alloc); 1101 try std.testing.expectEqual(@as(u32, 3), hub.tiles.items[1].id);
1357 defer alloc.free(j2);
1358 try std.testing.expectEqualStrings("[]", j2);
1359 } 1102 }
1360 1103
1361 test "hub: checkout copies the target into the caller's arena; release unregisters" { 1104 test "hub: a name missing from one list stays, missing from two leaves, and an unreachable answer removes nothing" {
1362 const alloc = std.testing.allocator; 1105 const alloc = std.testing.allocator;
1363 var w = wall.Wall{}; 1106 var hub = try Hub.init(alloc, &.{
1364 _ = try w.add(alloc, "--sock /tmp/a"); 1107 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1365 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); 1108 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1109 });
1366 defer hub.deinit(); 1110 defer hub.deinit();
1367 1111 hub.applyList(0, "0\nb\n", true);
1368 var arena = std.heap.ArenaAllocator.init(alloc); 1112 hub.applyList(1, "0\n", true);
1369 defer arena.deinit(); 1113
1370 const t = try hub.checkoutTile(0, 7, arena.allocator()); 1114 // One miss is the poll's own race with a daemon mid-answer, not an exit.
1371 // The copy must survive the tile's death: remove frees the tile's own 1115 hub.applyList(0, "0\n", true);
1372 // arena, and the pump's strings must not be in it. 1116 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1373 try hub.removeTile(0); 1117 // A blip removes nothing AND clears nothing: an unreachable answer is
1374 try std.testing.expectEqualStrings("/tmp/a", t.target.sock); 1118 // no evidence either way, so the second miss still has to be a LIST.
1375 hub.releaseTile(0, 7); // gone id: a no-op, not a crash 1119 hub.applyList(0, "", false);
1376 try std.testing.expectError(error.UnknownId, hub.checkoutTile(0, 7, arena.allocator())); 1120 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1121 hub.applyList(0, "0\n", true);
1122 try std.testing.expectEqual(@as(usize, 2), hub.tiles.items.len);
1123
1124 // A name that comes back clears the grace, so the next miss gets its own.
1125 hub.applyList(1, "0\nc\n", true);
1126 hub.applyList(1, "0\n", true);
1127 hub.applyList(1, "0\nc\n", true);
1128 hub.applyList(1, "0\n", true);
1129 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1130
1131 // Only its OWN host's list may drop a tile: two daemons may share a name.
1132 hub.applyList(0, "0\n", true);
1133 hub.applyList(0, "0\n", true);
1134 try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
1377 } 1135 }
1378 1136
1379 test "hub: a HOST tile is never the ask, whether it came from the wall file or a POST" { 1137 test "hub: json is host order then daemon order; label is the host spelling" {
1380 const alloc = std.testing.allocator; 1138 const alloc = std.testing.allocator;
1381 var w = wall.Wall{}; 1139 var hub = try Hub.init(alloc, &.{
1382 _ = try w.add(alloc, "box"); 1140 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1383 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); 1141 .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
1142 });
1384 defer hub.deinit(); 1143 defer hub.deinit();
1385 // Both mouths, because the startup wall and the runtime POST are two 1144 // Host 1 answers FIRST — the pollers are threads and nothing orders
1386 // roads to `resolveTile` and only one of them was ever walked here. 1145 // them — and the page must still read the file's order.
1387 _ = try hub.addTile("gate"); 1146 hub.applyList(1, "0\n", true);
1147 hub.applyList(0, "0\nb\n", true);
1388 1148
1389 var arena = std.heap.ArenaAllocator.init(alloc); 1149 const j = try hub.json(alloc);
1390 defer arena.deinit(); 1150 defer alloc.free(j);
1391 for ([_]u32{ 0, 1 }) |id| { 1151 try std.testing.expectEqualStrings(
1392 const t = try hub.checkoutTile(id, 7, arena.allocator()); 1152 \\[{"id":1,"label":"--sock /tmp/a","session":"0"},{"id":2,"label":"--sock /tmp/a","session":"b"},{"id":0,"label":"box","session":"0"}]
1393 // Nobody is sitting in front of a browser tile: it may not start a 1153 , j);
1394 // daemon on a box whose owner just stopped one, and it may not 1154
1395 // print an ssh-fallback line into a page that has no stderr. 1155 // A wall with no live session anywhere is a shape too, and the only
1396 try std.testing.expect(!t.target.hand.asked); 1156 // one a hand-rolled encoder can emit nothing at all for.
1397 } 1157 hub.applyList(0, "", true);
1158 hub.applyList(0, "", true);
1159 hub.applyList(1, "", true);
1160 hub.applyList(1, "", true);
1161 const empty = try hub.json(alloc);
1162 defer alloc.free(empty);
1163 try std.testing.expectEqualStrings("[]", empty);
1398 } 1164 }
1399 1165
1400 test "hub: a checkout carries the tile's session name, which the pump may have to create" { 1166 test "hub: a checkout borrows the host target; the session name is the page's, not the pump's" {
1401 const alloc = std.testing.allocator; 1167 const alloc = std.testing.allocator;
1402 var w = wall.Wall{}; 1168 var hub = try Hub.init(alloc, &.{
1403 _ = try w.add(alloc, "--sock /tmp/a#wghost"); 1169 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1404 _ = try w.add(alloc, "--sock /tmp/a"); 1170 });
1405 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default);
1406 defer hub.deinit(); 1171 defer hub.deinit();
1172 hub.applyList(0, "wghost\n", true);
1407 1173
1408 var arena = std.heap.ArenaAllocator.init(alloc); 1174 const t = try hub.checkoutTile(0, 7);
1409 defer arena.deinit(); 1175 // The host's target verbatim — a host outlives every pump, so a copy
1410 // Without the name the pump could only ever create the default 1176 // would be a lifetime nobody needed to track.
1411 // session, which is not the one the browser tile is asking for. 1177 try std.testing.expectEqualStrings("/tmp/a", t.target.sock);
1412 const named = try hub.checkoutTile(0, 7, arena.allocator()); 1178 // The name the pump would once have needed reaches the BROWSER
1413 try std.testing.expectEqualStrings("wghost", named.session); 1179 // instead, which is what sends the attach frame naming it.
1414 // A spelling with no `#NAME` rides the wire as the empty tail, which 1180 const j = try hub.json(alloc);
1415 // is the default session — `proto.wireName`'s own convention, kept 1181 defer alloc.free(j);
1416 // rather than rebuilt here. 1182 try std.testing.expect(std.mem.indexOf(u8, j, "\"session\":\"wghost\"") != null);
1417 const bare = try hub.checkoutTile(1, 8, arena.allocator()); 1183
1418 try std.testing.expectEqualStrings("", bare.session); 1184 // The list drops a tile whenever its session ends, and a browser
1185 // dialling it then is answered in HTTP rather than with a socket that
1186 // closes for reasons it cannot read.
1187 hub.applyList(0, "", true);
1188 hub.applyList(0, "", true);
1189 hub.releaseTile(0, 7); // gone id: a no-op, not a crash
1190 try std.testing.expectError(error.UnknownId, hub.checkoutTile(0, 7));
1419 } 1191 }
1420 1192
1421 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { 1193 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" {
1422 const alloc = std.testing.allocator; 1194 const alloc = std.testing.allocator;
1423 var w = wall.Wall{}; 1195 var hub = try Hub.init(alloc, &.{
1424 _ = try w.add(alloc, "--sock /tmp/a"); 1196 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1425 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); 1197 });
1426 defer hub.deinit(); 1198 defer hub.deinit();
1199 hub.applyList(0, "0\n", true);
1427 1200
1428 var arena = std.heap.ArenaAllocator.init(alloc); 1201 var arena = std.heap.ArenaAllocator.init(alloc);
1429 defer arena.deinit(); 1202 defer arena.deinit();
1430 1203
1431 // Two browsers on one wall: both pumps get a target and both serve. 1204 // Two browsers on one wall: both pumps get a target and both serve.
1432 const first = try hub.checkoutTile(0, 7, arena.allocator()); 1205 const first = try hub.checkoutTile(0, 7);
1433 const second = try hub.checkoutTile(0, 8, arena.allocator()); 1206 const second = try hub.checkoutTile(0, 8);
1434 try std.testing.expectEqualStrings("/tmp/a", first.target.sock); 1207 try std.testing.expectEqualStrings("/tmp/a", first.target.sock);
1435 try std.testing.expectEqualStrings("/tmp/a", second.target.sock); 1208 try std.testing.expectEqualStrings("/tmp/a", second.target.sock);
1436 1209
1437 // The first registration is the tracked one, and the untracked pump's 1210 // The first registration is the tracked one, and the untracked pump's
1438 // release must leave it alone — clearing it would leave removeTile 1211 // release must leave it alone — clearing it would leave a vanishing
1439 // with nobody to wake. 1212 // with nobody to wake.
1440 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd); 1213 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd);
1441 hub.releaseTile(0, 8); 1214 hub.releaseTile(0, 8);
@@ -1444,29 +1217,25 @@ test "hub: two pumps on one tile — the first fd stays tracked, the second's re
1444 try std.testing.expectEqual(@as(?std.posix.fd_t, null), hub.tiles.items[0].ws_fd); 1217 try std.testing.expectEqual(@as(?std.posix.fd_t, null), hub.tiles.items[0].ws_fd);
1445 } 1218 }
1446 1219
1447 test "hub: addTile persists; a reloaded wall matches" { 1220 test "hub: a HOST tile is never the ask" {
1448 const alloc = std.testing.allocator; 1221 const alloc = std.testing.allocator;
1449 var tmp = std.testing.tmpDir(.{}); 1222 // Resolved the way hub_main resolves the file, because that is the one
1450 defer tmp.cleanup(); 1223 // road onto this wall and the permission is set on it.
1451 const dir_path = try tmp.dir.realpathAlloc(alloc, "."); 1224 var arena = std.heap.ArenaAllocator.init(alloc);
1452 defer alloc.free(dir_path); 1225 defer arena.deinit();
1453 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{dir_path}); 1226 const spec = try client.resolveHost(arena.allocator(), "box", null, client.quic_idle_ms_default);
1454 defer alloc.free(path); 1227 var hub = try Hub.init(alloc, &.{spec});
1455 1228 defer hub.deinit();
1456 { 1229 hub.applyList(0, "0\n", true);
1457 var hub = try Hub.init(alloc, wall.Wall{}, path, null, client.quic_idle_ms_default); 1230
1458 defer hub.deinit(); 1231 const t = try hub.checkoutTile(0, 7);
1459 _ = try hub.addTile("--sock /tmp/a"); 1232 // Nobody is sitting in front of a browser tile: it may not start a
1460 _ = try hub.addTile("--sock /tmp/b"); 1233 // daemon on a box whose owner just stopped one, and it may not print
1461 _ = try hub.addTile("--sock /tmp/c"); 1234 // an ssh-fallback line into a page that has no stderr.
1462 try hub.removeTile(1); 1235 try std.testing.expect(!t.target.hand.asked);
1463 try hub.reorderTiles(&.{ 2, 0 }); 1236 // The POLL is nobody's ask either, and it is the dial that runs once a
1464 } 1237 // second forever.
1465 var r = try wall.load(alloc, path); 1238 try std.testing.expect(!hub.hosts[0].spec.poll_target.hand.asked);
1466 defer r.deinit(alloc);
1467 try std.testing.expectEqual(@as(usize, 2), r.targets.items.len);
1468 try std.testing.expectEqualStrings("--sock /tmp/c", r.targets.items[0]);
1469 try std.testing.expectEqualStrings("--sock /tmp/a", r.targets.items[1]);
1470 } 1239 }
1471 1240
1472 test "routes: the three assets with their content types, 404 for the rest" { 1241 test "routes: the three assets with their content types, 404 for the rest" {
@@ -1673,17 +1442,6 @@ test "drain browser: the reader's OWN bytes decide, with or without a readable e
1673 } 1442 }
1674 } 1443 }
1675 1444
1676 test "parseIdList: happy path and refusals" {
1677 const alloc = std.testing.allocator;
1678 const ids = try parseIdList(alloc, "3,0,2\n");
1679 defer alloc.free(ids);
1680 try std.testing.expectEqualSlices(u32, &.{ 3, 0, 2 }, ids);
1681 try std.testing.expectError(error.Bad, parseIdList(alloc, ""));
1682 try std.testing.expectError(error.Bad, parseIdList(alloc, "1,,2"));
1683 try std.testing.expectError(error.Bad, parseIdList(alloc, "1,x"));
1684 try std.testing.expectError(error.Bad, parseIdList(alloc, "-1"));
1685 }
1686
1687 test "tiles json: every byte a label or session can carry, escaped" { 1445 test "tiles json: every byte a label or session can carry, escaped" {
1688 // Pinned on the helper rather than on `Hub.json`, because the helper is 1446 // Pinned on the helper rather than on `Hub.json`, because the helper is
1689 // what both strings go through and building a Hub would test the 1447 // what both strings go through and building a Hub would test the
web/index.html
Old New
@@ -63,58 +63,46 @@
63 display: none; position: fixed; inset: 0; z-index: 10; background: #000a; 63 display: none; position: fixed; inset: 0; z-index: 10; background: #000a;
64 } 64 }
65 #shade.on { display: block; } 65 #shade.on { display: block; }
66 /* The add tile: the wall's one standing control. A tile-shaped door, 66 /* The settings tile: the wall's one standing control, and the only
67 not a toolbar — the wall stays a wall. */ 67 element on it the hub did not put there. A tile-shaped panel, not a
68 .tile.add { 68 toolbar — the wall stays a wall. Nothing here authors the wall: the
69 wall is the hosts file. It costs nothing to make the panel
70 unreachable while zoomed, too — the shade covers the wall — so an
71 open panel can never be holding the keyboard the terminal owns. */
72 .tile.settings-tile {
69 min-height: 80px; align-items: center; justify-content: center; 73 min-height: 80px; align-items: center; justify-content: center;
70 cursor: text; border-style: dashed; 74 border-style: dashed;
71 } 75 }
72 .tile.add input { 76 .tile.settings-tile .settings { width: 90%; font-size: 11px; }
73 width: 90%; background: transparent; border: 0; outline: none; 77 .tile.settings-tile .settings summary { color: var(--dim); cursor: pointer; list-style: none; }
74 color: var(--fg); font: inherit; text-align: center; 78 .tile.settings-tile .settings summary::-webkit-details-marker { display: none; }
75 } 79 .tile.settings-tile .settings summary:hover { color: var(--fg); }
76 .tile.add .err { color: #e07a7a; font-size: 11px; } 80 .tile.settings-tile .settings .row {
77 /* Settings live INSIDE the add tile rather than in a toolbar of their
78 own: the wall keeps its one standing control. It costs nothing to
79 make them unreachable while zoomed, too — the shade covers the wall —
80 so an open panel can never be holding the keyboard the terminal owns. */
81 .tile.add .settings { width: 90%; font-size: 11px; }
82 .tile.add .settings summary { color: var(--dim); cursor: pointer; list-style: none; }
83 .tile.add .settings summary::-webkit-details-marker { display: none; }
84 .tile.add .settings summary:hover { color: var(--fg); }
85 .tile.add .settings .row {
86 display: flex; align-items: center; gap: 6px; margin-top: 6px; 81 display: flex; align-items: center; gap: 6px; margin-top: 6px;
87 } 82 }
88 .tile.add .settings .row > span { color: var(--dim); } 83 .tile.settings-tile .settings .row > span { color: var(--dim); }
89 .tile.add .settings input[type="text"], 84 .tile.settings-tile .settings input[type="text"],
90 .tile.add .settings input[type="number"] { 85 .tile.settings-tile .settings input[type="number"] {
91 flex: 1; min-width: 0; padding: 2px 4px; border: 1px solid #4a5261; 86 flex: 1; min-width: 0; padding: 2px 4px; border: 1px solid #4a5261;
92 border-radius: 3px; background: transparent; color: var(--fg); 87 border-radius: 3px; background: transparent; color: var(--fg);
93 font: inherit; font-size: 11px; text-align: left; 88 font: inherit; font-size: 11px; text-align: left;
94 } 89 }
95 .tile.add .settings input[type="number"] { flex: 0 0 4.5em; } 90 .tile.settings-tile .settings input[type="number"] { flex: 0 0 4.5em; }
96 .tile.add .settings button { 91 .tile.settings-tile .settings button {
97 padding: 2px 6px; border: 1px solid #4a5261; border-radius: 3px; 92 padding: 2px 6px; border: 1px solid #4a5261; border-radius: 3px;
98 background: transparent; color: var(--fg); font: inherit; 93 background: transparent; color: var(--fg); font: inherit;
99 font-size: 11px; cursor: pointer; 94 font-size: 11px; cursor: pointer;
100 } 95 }
101 .tile.add .settings .note, .tile.add .settings .caveat { color: var(--dim); font-size: 11px; } 96 .tile.settings-tile .settings .note, .tile.settings-tile .settings .caveat { color: var(--dim); font-size: 11px; }
102 .tile.add .settings .note.bad { color: #e07a7a; } 97 .tile.settings-tile .settings .note.bad { color: #e07a7a; }
103 .tile header .close, .tile header .spawn { 98 .tile header .spawn {
104 padding: 0 6px; border: 0; background: transparent; color: var(--dim); 99 padding: 0 6px; border: 0; background: transparent; color: var(--dim);
105 font: inherit; cursor: pointer; 100 font: inherit; cursor: pointer;
106 } 101 }
107 .tile header .close:hover { color: #e07a7a; }
108 /* `+` = another session on this tile's host: an add, so it greens. */ 102 /* `+` = another session on this tile's host: an add, so it greens. */
109 .tile header .spawn:hover { color: #6fce8a; } 103 .tile header .spawn:hover { color: #6fce8a; }
110 /* Zoomed = the terminal owns the header; wall management waits. */ 104 /* Zoomed = the terminal owns the header; wall management waits. */
111 .tile.zoomed header .close, .tile.zoomed header .spawn { display: none; } 105 .tile.zoomed header .spawn { display: none; }
112 /* Drag to reorder: the dragged tile fades, and the tile under the cursor
113 shows the edge the drop lands on. Both marks are transient — every way
114 out of a drag (dragleave, drop, dragend) clears them. */
115 .tile.dragging { opacity: 0.5; }
116 .tile.drop-before { border-left: 2px solid #6ab0e0; }
117 .tile.drop-after { border-right: 2px solid #6ab0e0; }
118 /* IME target: focusable, invisible, never display:none (that kills IME). */ 106 /* IME target: focusable, invisible, never display:none (that kills IME). */
119 #ime { 107 #ime {
120 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; 108 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px;
web/mux.js
Old New
@@ -335,32 +335,18 @@ class Tile {
335 // of the DOM, and the node is what a drag moves. 335 // of the DOM, and the node is what a drag moves.
336 this.el.dataset.tileId = String(id); 336 this.el.dataset.tileId = String(id);
337 this.el.innerHTML = 337 this.el.innerHTML =
338 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="spawn" type="button" title="new session here">+</button><button class="close" type="button">×</button><span class="badge connecting">connecting</span></header>`; 338 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="spawn" type="button" title="new session here">+</button><span class="badge connecting">connecting</span></header>`;
339 // The label only: the wall's order is mutable, so a baked-in number 339 // The label only: tiles come and go as sessions do, so a baked-in
340 // would lie the moment anything moved. 340 // number would lie the moment anything ended.
341 this.el.querySelector('.label').textContent = label; 341 this.el.querySelector('.label').textContent = label;
342 this.el.querySelector('.close').addEventListener('click', (ev) => { 342 // "New session here": the wall's one door. There is nothing to type —
343 ev.stopPropagation(); // a close is not a zoom 343 // the tile already names the daemon, and the daemon names the session.
344 removeTile(this.id);
345 // Same reason the copy control does it: clicking a button takes focus,
346 // and the hidden IME is what a zoomed tile's keys come from.
347 ime.focus();
348 });
349 // "New session here": the wall's second door, opened from a tile that
350 // already names the host. It spells a target into the add tile's input
351 // and hands over — one add path, not two.
352 this.el.querySelector('.spawn').addEventListener('click', (ev) => { 344 this.el.querySelector('.spawn').addEventListener('click', (ev) => {
353 ev.stopPropagation(); // a spawn is not a zoom 345 ev.stopPropagation(); // a spawn is not a zoom
354 // The label is the spelling the user typed, so the host part is that 346 spawnHere(this.id);
355 // label minus its trailing `#session` — and only when this tile HAS 347 // Same reason the copy control does it: clicking a button takes
356 // a session, since otherwise a '#' can only belong to the host part. 348 // focus, and the hidden IME is what a zoomed tile's keys come from.
357 const cut = this.session ? this.label.lastIndexOf('#') : -1; 349 ime.focus();
358 const host = cut > 0 ? this.label.slice(0, cut) : this.label;
359 addInput.value = host + '#';
360 // No ime.focus() here, unlike close and copy: putting focus IN the
361 // add input is the entire point of this button, and it is only ever
362 // visible unzoomed (CSS), so no terminal is waiting on the IME.
363 addInput.focus();
364 }); 350 });
365 this.copyButton = this.el.querySelector('.copy-request'); 351 this.copyButton = this.el.querySelector('.copy-request');
366 this.copyButton.addEventListener('click', (ev) => { 352 this.copyButton.addEventListener('click', (ev) => {
@@ -383,43 +369,6 @@ class Tile {
383 }, { passive: false }); 369 }, { passive: false });
384 // Drag to reorder. The whole tile is the handle: the header is a thin 370 // Drag to reorder. The whole tile is the handle: the header is a thin
385 // strip and a wall tile has no other inert surface to grab. 371 // strip and a wall tile has no other inert surface to grab.
386 this.el.draggable = true;
387 this.el.addEventListener('dragstart', (ev) => {
388 // A zoomed tile is a terminal — a drag there would fight the pointer
389 // selection. The rest of the wall is under the shade while anything
390 // is zoomed, so nothing on this page is reorderable then.
391 if (zoomedTile) { ev.preventDefault(); return; }
392 draggingTile = this;
393 ev.dataTransfer.setData('text/plain', String(this.id));
394 ev.dataTransfer.effectAllowed = 'move';
395 this.el.classList.add('dragging');
396 });
397 this.el.addEventListener('dragend', () => {
398 draggingTile = null;
399 this.el.classList.remove('dragging');
400 clearDropMarks(); // a drag that ended ANYWHERE leaves no borders
401 });
402 this.el.addEventListener('dragover', (ev) => {
403 if (!draggingTile || draggingTile === this) return; // no drop on self
404 ev.preventDefault(); // preventDefault on dragover IS "yes, drop here"
405 ev.dataTransfer.dropEffect = 'move';
406 // Not offsetX: that is relative to the event's target, which here is
407 // usually the canvas or the header. Measure the tile's own box.
408 this.markDropSide(ev);
409 });
410 this.el.addEventListener('dragleave', () => {
411 this.el.classList.remove('drop-before', 'drop-after');
412 });
413 this.el.addEventListener('drop', (ev) => {
414 ev.preventDefault();
415 this.el.classList.remove('drop-before', 'drop-after');
416 const src = dragSource(ev);
417 if (!src || src === this.el) return;
418 const r = this.el.getBoundingClientRect();
419 if (ev.clientX < r.left + r.width / 2) this.el.before(src);
420 else this.el.after(src);
421 putOrder(); // the DOM moved; the hub has not heard yet
422 });
423 this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev)); 372 this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev));
424 this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev)); 373 this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev));
425 this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev)); 374 this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev));
@@ -431,15 +380,6 @@ class Tile {
431 }); 380 });
432 } 381 }
433 382
434 // Which half of this tile the cursor is over, as a class the CSS draws
435 // an edge for. Exactly one of the two is ever set: toggle, not add.
436 markDropSide(ev) {
437 const r = this.el.getBoundingClientRect();
438 const before = ev.clientX < r.left + r.width / 2;
439 this.el.classList.toggle('drop-before', before);
440 this.el.classList.toggle('drop-after', !before);
441 }
442
443 async start() { 383 async start() {
444 // NOT `const { instance }`: WebAssembly.instantiate has two return 384 // NOT `const { instance }`: WebAssembly.instantiate has two return
445 // shapes, and which one you get depends on what you passed. Given 385 // shapes, and which one you get depends on what you passed. Given
@@ -1558,7 +1498,8 @@ function zoom(tile) {
1558 if (zoomedTile) unzoom(); 1498 if (zoomedTile) unzoom();
1559 // Settings belong to the wall, and the wall is what zoom covers. Closing 1499 // Settings belong to the wall, and the wall is what zoom covers. Closing
1560 // the panel keeps it from sitting open and focusable behind the shade. 1500 // the panel keeps it from sitting open and focusable behind the shade.
1561 addTileEl?.querySelector('.settings')?.removeAttribute('open'); 1501 // It re-opens on the next unzoom (the tile is built with `open`).
1502 settingsTileEl?.querySelector('.settings')?.removeAttribute('open');
1562 zoomedTile = tile; 1503 zoomedTile = tile;
1563 tile.zoomed = true; 1504 tile.zoomed = true;
1564 tile.el.classList.add('zoomed'); 1505 tile.el.classList.add('zoomed');
@@ -1596,12 +1537,12 @@ shade.addEventListener('click', unzoom);
1596 1537
1597 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). 1538 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
1598 document.addEventListener('keydown', (ev) => { 1539 document.addEventListener('keydown', (ev) => {
1599 // The add tile's fields are real form controls: while one has focus its 1540 // The settings tile's fields are real form controls: while one has focus
1600 // keys are a target being spelled or a setting being edited, not input 1541 // its keys are a setting being edited, not input for any terminal. The
1601 // for any terminal. The whole tile, not just the add input — zoom hides 1542 // whole tile — zoom hides the panel behind the shade, but Tab still
1602 // the settings panel behind the shade, but Tab still reaches it, and a 1543 // reaches it, and a key answered by both would type the user's font size
1603 // key answered by both would type the user's font size into their shell. 1544 // into their shell.
1604 if (addTileEl?.contains(document.activeElement)) return; 1545 if (settingsTileEl?.contains(document.activeElement)) return;
1605 const t = zoomedTile; 1546 const t = zoomedTile;
1606 if (!t) return; 1547 if (!t) return;
1607 if (ev.isComposing) return; // IME owns it; compositionend delivers 1548 if (ev.isComposing) return; // IME owns it; compositionend delivers
@@ -1681,53 +1622,11 @@ ime.addEventListener('compositionend', (ev) => {
1681 ime.value = ''; 1622 ime.value = '';
1682 }); 1623 });
1683 // --- the wall --- 1624 // --- the wall ---
1684 // Keyed by the hub's tile id, never by position: /tiles is reorderable and 1625 // Keyed by the hub's tile id, never by position: two entries share a label
1685 // two entries can share a label. The DOM under #wall carries the order. 1626 // whenever one daemon has two sessions, and an id is never reused. The DOM
1627 // under #wall carries the hub's order.
1686 const tilesById = new Map(); 1628 const tilesById = new Map();
1687 let addTileEl = null; // the add tile, built once in boot, always last 1629 let settingsTileEl = null; // built once in boot, always last
1688 let addInput = null; // its one <input> — a real focus target (see keydown)
1689 // The tile whose drag is in flight. dataTransfer is write-only until the
1690 // drop (the spec hides the data from dragover to stop pages snooping what
1691 // is being dragged over them), so the side indicator needs its own handle
1692 // on the source to know it is not pointing at the dragged tile itself.
1693 let draggingTile = null;
1694
1695 function clearDropMarks() {
1696 for (const t of tilesById.values()) t.el.classList.remove('drop-before', 'drop-after');
1697 addTileEl?.classList.remove('drop-before');
1698 }
1699
1700 // The element a drop is moving, named by the id the drag carries. Resolved
1701 // through tilesById rather than from draggingTile so a drop whose source
1702 // the wall lost mid-drag (another browser deleted it) moves nothing at all
1703 // instead of moving a detached node.
1704 function dragSource(ev) {
1705 const tile = tilesById.get(Number(ev.dataTransfer.getData('text/plain')));
1706 return tile ? tile.el : null;
1707 }
1708
1709 // The DOM under #wall IS the order: a drag moves a node, and this tells the
1710 // hub what the nodes now say. Reading the order back out of the page rather
1711 // than keeping an array means there is no second copy to drift from it.
1712 async function putOrder() {
1713 const wallEl = document.getElementById('wall');
1714 const ids = [...wallEl.children]
1715 .filter((el) => el !== addTileEl)
1716 .map((el) => el.dataset.tileId)
1717 .filter((id) => id !== undefined);
1718 try {
1719 const res = await fetch('/tiles', { method: 'PUT', body: ids.join(',') });
1720 // 409 = we dragged against a stale wall (another browser added or
1721 // removed a tile since this page's last refetch). The hub kept its own
1722 // order and answered with it; refetching is what agrees us with the
1723 // truth. 400 and 500 take the same road for the same reason: the page
1724 // has already moved a node the hub may not have accepted.
1725 if (res.status !== 204) await refetchWall();
1726 } catch (err) {
1727 console.error('mux wall: reorder failed', err);
1728 await refetchWall().catch((e) => console.error('mux wall: refetch failed', e));
1729 }
1730 }
1731 1630
1732 window.addEventListener('resize', () => { 1631 window.addEventListener('resize', () => {
1733 // The zoomed tile may claim a new grid; every wall tile just re-fits to 1632 // The zoomed tile may claim a new grid; every wall tile just re-fits to
@@ -1738,9 +1637,10 @@ window.addEventListener('resize', () => {
1738 }); 1637 });
1739 1638
1740 // `GET /tiles` is the truth about the wall; this makes the page match it. 1639 // `GET /tiles` is the truth about the wall; this makes the page match it.
1741 // Every mutation (add, remove, reorder) goes to the hub and then comes back 1640 // The hub polls its daemons once a second and the page asks it just as
1742 // through here, so there is one shape of "what the wall is" and no local 1641 // often, so a session born or ended ANYWHERE — another browser, a
1743 // guess to drift from it. 1642 // terminal, a `mux a` — reaches this wall without the page being told.
1643 // There is one shape of "what the wall is" and no local guess to drift.
1744 // 1644 //
1745 // Two refetches can be in flight at once (Enter twice quickly, a reorder 1645 // Two refetches can be in flight at once (Enter twice quickly, a reorder
1746 // overlapping an add) and nothing orders their responses. The last one 1646 // overlapping an add) and nothing orders their responses. The last one
@@ -1785,27 +1685,75 @@ async function refetchWall() {
1785 // wall out in wall order, existing sockets undisturbed. 1685 // wall out in wall order, existing sockets undisturbed.
1786 wallEl.appendChild(tile.el); 1686 wallEl.appendChild(tile.el);
1787 } 1687 }
1788 if (addTileEl) wallEl.appendChild(addTileEl); // the door stays last 1688 if (settingsTileEl) wallEl.appendChild(settingsTileEl); // stays last
1789 } 1689 }
1790 1690
1791 async function removeTile(id) { 1691 // How many refetches a birth may wait for its tile, and how long between
1692 // them. The tile cannot exist when the POST returns: `Hub.spawn` stores a
1693 // poke and answers, and the host's poller feels that poke only at its next
1694 // 50 ms slice, then still has to dial the daemon and diff the list. Six
1695 // unspaced fetches on a kept-alive localhost socket all finish inside
1696 // ~10 ms, so the loop lost that race and the user got a session born and
1697 // unzoomed — the "sits on a badge" outcome the zoom exists to prevent.
1698 // Measured against a live hub: the tile is on `/tiles` 59 ms after the
1699 // POST answers (Debug, so a ceiling — the dial is the only part release
1700 // speeds up). 250 ms clears that with room for a slow daemon; 6 of them
1701 // bound the wait at 1.5 s, after which the 1 s interval picks it up.
1702 const SPAWN_POLLS = 6;
1703 const SPAWN_POLL_MS = 250;
1704
1705 // The wait between two of those refetches. Its own function so the delay
1706 // is a thing a test can reach: verify.js cannot run `spawnHere`'s network
1707 // path, and a bare inline `setTimeout` would be invisible to it.
1708 function spawnPollDelay() {
1709 return new Promise((resolve) => setTimeout(resolve, SPAWN_POLL_MS));
1710 }
1711
1712 // The `+`: a new session on THAT tile's daemon, named by the daemon. The
1713 // tile appears through refetchWall like every other tile — one road onto
1714 // the wall — and the zoom is what lands the user in the shell, because an
1715 // unzoomed wall tile attaches passive at 0x0 (see sendResize).
1716 async function spawnHere(id) {
1717 const tile = tilesById.get(id);
1792 try { 1718 try {
1793 const res = await fetch(`/tiles/${id}`, { method: 'DELETE' }); 1719 const res = await fetch(`/tiles/${id}`, { method: 'POST' });
1794 // 404 is not worth shouting about — the wall already lost it, and the 1720 if (!res.ok) {
1795 // refetch below is what agrees the page with that. 1721 // The hub's own words for why, on the badge of the tile that asked —
1796 if (!res.ok && res.status !== 404) 1722 // there is no add box left to put them in.
1797 console.error(`mux remove tile ${id}: hub said ${res.status}`); 1723 const why = (await res.text()).trim() || `hub said ${res.status}`;
1724 console.error(`mux spawn on tile ${id}: ${why}`);
1725 tile?.setStatus('gone', why);
1726 return;
1727 }
1728 // The hub answers {"session":"NAME"}. Parsed defensively: the session
1729 // IS made, so a body we cannot read must not read as a failed birth.
1730 const body = await res.json().catch(() => null);
1731 const label = tile?.label ?? null;
1732 for (let i = 0; i < SPAWN_POLLS && body !== null; i++) {
1733 // BEFORE the fetch, first pass included: the hub answered this POST
1734 // before its poller had heard about the birth, so asking now can
1735 // only ever miss.
1736 await spawnPollDelay();
1737 await refetchWall();
1738 const fresh = [...tilesById.values()].find(
1739 (t) => t.session === body.session && t.label === label,
1740 );
1741 if (!fresh) continue;
1742 await fresh.startup; // refetchWall fires start(), it does not await it
1743 // Re-checked after the await: a poll may have retired this tile, and
1744 // a zoom the user did meanwhile is theirs to keep.
1745 if (tilesById.get(fresh.id) === fresh && !zoomedTile) zoom(fresh);
1746 return;
1747 }
1798 } catch (err) { 1748 } catch (err) {
1799 console.error(`mux remove tile ${id}: request failed`, err); 1749 console.error(`mux spawn on tile ${id}: request failed`, err);
1800 } 1750 }
1801 // The close button calls this and drops the promise: a rejection here
1802 // would be an unhandled one, reported as a page error with no owner.
1803 await refetchWall().catch((err) => { 1751 await refetchWall().catch((err) => {
1804 console.error('mux wall: refetch failed', err); 1752 console.error('mux wall: refetch failed', err);
1805 }); 1753 });
1806 } 1754 }
1807 1755
1808 // The settings panel, wired inside the add tile. Every change goes through 1756 // The settings panel, wired inside the settings tile. Every change goes through
1809 // updateSettings — no caller can apply one without saving it — and every 1757 // updateSettings — no caller can apply one without saving it — and every
1810 // answer lands in .note, including the count of lines a file did NOT give 1758 // answer lands in .note, including the count of lines a file did NOT give
1811 // us, which is how "I uploaded my whole ghostty config" reads as partial 1759 // us, which is how "I uploaded my whole ghostty config" reads as partial
@@ -1867,17 +1815,15 @@ function wireSettingsPanel(el) {
1867 size.addEventListener('change', applyFont); 1815 size.addEventListener('change', applyFont);
1868 } 1816 }
1869 1817
1870 // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile 1818 // The settings tile: a tile-shaped panel at the end of the wall. It is NOT
1871 // — no core, no socket, never zooms — so the zoom click path (bound per 1819 // a Tile — no core, no socket, never zooms — so the zoom click path (bound
1872 // Tile) cannot reach it. 1820 // per Tile) cannot reach it. Nothing here authors the wall: the wall is the
1873 function buildAddTile() { 1821 // hosts file, and `mux hosts add|rm` is how it changes.
1822 function buildSettingsTile() {
1874 const el = document.createElement('div'); 1823 const el = document.createElement('div');
1875 el.className = 'tile add'; 1824 el.className = 'tile settings-tile';
1876 el.innerHTML = 1825 el.innerHTML =
1877 `<input type="text" name="target" aria-label="add a tile"` + 1826 `<details class="settings" open><summary>settings</summary>` +
1878 ` autocomplete="off" autocapitalize="off" spellcheck="false"` +
1879 ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>` +
1880 `<details class="settings"><summary>settings</summary>` +
1881 `<div class="row"><span>theme</span>` + 1827 `<div class="row"><span>theme</span>` +
1882 `<input type="file" class="theme-file" aria-label="ghostty theme file">` + 1828 `<input type="file" class="theme-file" aria-label="ghostty theme file">` +
1883 `<button type="button" class="theme-clear">clear</button></div>` + 1829 `<button type="button" class="theme-clear">clear</button></div>` +
@@ -1886,75 +1832,15 @@ function buildAddTile() {
1886 ` autocomplete="off" spellcheck="false" placeholder="${DEFAULT_FONT_FAMILY}">` + 1832 ` autocomplete="off" spellcheck="false" placeholder="${DEFAULT_FONT_FAMILY}">` +
1887 `<input type="number" class="font-size" aria-label="font size"` + 1833 `<input type="number" class="font-size" aria-label="font size"` +
1888 ` min="${FONT_SIZE_MIN}" max="${FONT_SIZE_MAX}"></div>` + 1834 ` min="${FONT_SIZE_MIN}" max="${FONT_SIZE_MAX}"></div>` +
1889 `<div class="caveat">theme is this browser's alone; font size claims a` + 1835 `<div class="caveat">tiles are the live sessions of the daemons in your` +
1890 ` new grid, so every client on the zoomed session reflows</div>` + 1836 ` hosts file — <code>mux hosts add HOST</code> to list one, + on a tile` +
1837 ` to start a session there; theme is this browser's alone, and font size` +
1838 ` claims a new grid, so every client on the zoomed session reflows</div>` +
1891 `<div class="note"></div></details>`; 1839 `<div class="note"></div></details>`;
1892 const input = el.querySelector('input');
1893 const err = el.querySelector('.err');
1894 const settingsEl = el.querySelector('.settings'); 1840 const settingsEl = el.querySelector('.settings');
1895 wireSettingsPanel(settingsEl); 1841 wireSettingsPanel(settingsEl);
1896 // The whole tile is the affordance; a click anywhere in it lands in the 1842 // Nothing above this tile should read a click meant for its own controls.
1897 // input and goes no further (nothing above it should read this click). 1843 el.addEventListener('click', (ev) => ev.stopPropagation());
1898 // The panel is the exception: its own controls are the click target, so
1899 // it stops the click before the tile can treat it as "type a target".
1900 settingsEl.addEventListener('click', (ev) => ev.stopPropagation());
1901 el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); });
1902 // The add tile never moves and is never dragged, but it is the wall's
1903 // last child — so dropping on it is the only way to say "put this at the
1904 // end", and it means exactly "insert before me". One side only: there is
1905 // nothing after it to land on.
1906 el.addEventListener('dragover', (ev) => {
1907 if (!draggingTile) return;
1908 ev.preventDefault();
1909 ev.dataTransfer.dropEffect = 'move';
1910 el.classList.add('drop-before');
1911 });
1912 el.addEventListener('dragleave', () => el.classList.remove('drop-before'));
1913 el.addEventListener('drop', (ev) => {
1914 ev.preventDefault();
1915 el.classList.remove('drop-before');
1916 const src = dragSource(ev);
1917 if (src) { el.before(src); putOrder(); }
1918 });
1919 input.addEventListener('keydown', async (ev) => {
1920 if (ev.key !== 'Enter') return;
1921 ev.preventDefault();
1922 const target = input.value.trim();
1923 if (!target) return;
1924 err.textContent = '';
1925 try {
1926 const res = await fetch('/tiles', { method: 'POST', body: target });
1927 if (!res.ok) {
1928 // The hub's own words for why, kept next to the spelling that
1929 // earned them — the value stays so it can be edited, not retyped.
1930 err.textContent = (await res.text()).trim() || `hub said ${res.status}`;
1931 return;
1932 }
1933 // The hub answers {"id":N}. Parsed defensively: the tile IS added, so
1934 // a body we cannot read must not be reported back as a failed add.
1935 const body = await res.json().catch(() => null);
1936 input.value = '';
1937 await refetchWall();
1938 // A wall tile attaches passive at 0x0 and the daemon refuses to
1939 // CREATE a session for a client claiming no size, so a tile added for
1940 // a session that does not exist yet would sit on a badge that never
1941 // resolves. Zoom attaches at the real size, and that is what creates
1942 // it — "new session here" has to land the user in the shell they
1943 // asked for.
1944 const fresh = body === null ? null : tilesById.get(body.id);
1945 if (fresh) {
1946 await fresh.startup; // refetchWall fires start(), it does not await it
1947 // Re-checked after the await: a concurrent refetch may have retired
1948 // this tile, and a zoom the user did meanwhile is theirs to keep.
1949 if (tilesById.get(body.id) === fresh && !zoomedTile) zoom(fresh);
1950 }
1951 } catch (e) {
1952 // An over-long body is dropped without any reply (the hub's cap), so
1953 // fetch rejects rather than resolving. Silence would read as success.
1954 err.textContent = 'request failed';
1955 console.error('mux add tile: POST /tiles failed', e);
1956 }
1957 });
1958 return el; 1844 return el;
1959 } 1845 }
1960 1846
@@ -1973,7 +1859,12 @@ function buildAddTile() {
1973 applySettings(settings); 1859 applySettings(settings);
1974 }); 1860 });
1975 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); 1861 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
1976 addTileEl = buildAddTile(); 1862 settingsTileEl = buildSettingsTile();
1977 addInput = addTileEl.querySelector('input');
1978 await refetchWall(); 1863 await refetchWall();
1864 // The hub's own poll is a second, so asking it any faster only costs
1865 // requests. This interval is the whole of how a session born or ended
1866 // elsewhere reaches this page: nothing pushes.
1867 setInterval(() => {
1868 refetchWall().catch((err) => console.error('mux wall: refetch failed', err));
1869 }, 1000);
1979 })(); 1870 })();
web/verify.js
Old New
@@ -489,7 +489,9 @@ function browserShell(source, opts = {}) {
489 new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` + 489 new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` +
490 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + 490 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' +
491 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }, ' + 491 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }, ' +
492 'setAddTile(el, input) { addTileEl = el; addInput = input; }, ' + 492 'setSettingsTile(el) { settingsTileEl = el; }, ' +
493 'spawnPollDelay: typeof spawnPollDelay === "function" ? spawnPollDelay : undefined, ' +
494 'SPAWN_POLL_MS: typeof SPAWN_POLL_MS === "number" ? SPAWN_POLL_MS : undefined, ' +
493 'parseGhosttyTheme: typeof parseGhosttyTheme === "function" ? parseGhosttyTheme : undefined, ' + 495 'parseGhosttyTheme: typeof parseGhosttyTheme === "function" ? parseGhosttyTheme : undefined, ' +
494 'applySettings: typeof applySettings === "function" ? applySettings : undefined, ' + 496 'applySettings: typeof applySettings === "function" ? applySettings : undefined, ' +
495 'loadSettings: typeof loadSettings === "function" ? loadSettings : undefined, ' + 497 'loadSettings: typeof loadSettings === "function" ? loadSettings : undefined, ' +
@@ -818,7 +820,7 @@ async function verifyClipboardShell(shell, html) {
818 check('hidden fallback preserves terminal Tab encoding', terminalKeys.join('|'), '2,0,0'); 820 check('hidden fallback preserves terminal Tab encoding', terminalKeys.join('|'), '2,0,0');
819 821
820 const header = click.tile.el.querySelector('header'); 822 const header = click.tile.el.querySelector('header');
821 check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|spawn|close|badge connecting'); 823 check('tile header places copy button between label and badge', header.children.map((el) => el.className).join('|'), 'label|copy-request on|spawn|badge connecting');
822 check('tile copy control is a real button', click.tile.copyButton.tagName, 'BUTTON'); 824 check('tile copy control is a real button', click.tile.copyButton.tagName, 'BUTTON');
823 check('tile copy control has button type', click.tile.copyButton.type, 'button'); 825 check('tile copy control has button type', click.tile.copyButton.type, 'button');
824 check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false); 826 check('tile header does not interpolate label into innerHTML', click.tile.el.innerHTML.includes('<unsafe-label>'), false);
@@ -2841,19 +2843,19 @@ async function verifySettingsShell(shell, html) {
2841 check('an unzoomed tile sends nothing when the font changes', passiveSent.length, 0); 2843 check('an unzoomed tile sends nothing when the font changes', passiveSent.length, 0);
2842 2844
2843 // --- the panel must not hold the keyboard the terminal owns --- 2845 // --- the panel must not hold the keyboard the terminal owns ---
2844 // Zoom hides the add tile behind the shade, so the panel is unreachable 2846 // Zoom hides the settings tile behind the shade, so the panel is
2845 // by pointer — but Tab still reaches it, and a key answered by both the 2847 // unreachable by pointer — but Tab still reaches it, and a key answered
2846 // focused field and the session types the user's font size into their 2848 // by both the focused field and the session types the user's font size
2847 // shell. Caught in a real browser, not here, which is why it is pinned. 2849 // into their shell. Caught in a real browser, not here, so it is pinned.
2848 const keyed = h.makeSettingsTile(); 2850 const keyed = h.makeSettingsTile();
2849 const typed = []; 2851 const typed = [];
2850 keyed.sendKey = () => typed.push('key'); 2852 keyed.sendKey = () => typed.push('key');
2851 keyed.zoomed = true; 2853 keyed.zoomed = true;
2852 h.setZoomedTile(keyed); 2854 h.setZoomedTile(keyed);
2853 const fakeAddTile = h.document.createElement('div'); 2855 const fakeSettingsTile = h.document.createElement('div');
2854 const fakeAddInput = fakeAddTile.appendChild(h.document.createElement('input')); 2856 const fakePanelField = fakeSettingsTile.appendChild(h.document.createElement('input'));
2855 const fakePanelField = fakeAddTile.appendChild(h.document.createElement('input')); 2857 const fakeSizeField = fakeSettingsTile.appendChild(h.document.createElement('input'));
2856 h.setAddTile(fakeAddTile, fakeAddInput); 2858 h.setSettingsTile(fakeSettingsTile);
2857 const key = (target) => { 2859 const key = (target) => {
2858 target.focus(); 2860 target.focus();
2859 h.document.dispatchEvent('keydown', { 2861 h.document.dispatchEvent('keydown', {
@@ -2861,17 +2863,17 @@ async function verifySettingsShell(shell, html) {
2861 preventDefault() {}, isComposing: false, 2863 preventDefault() {}, isComposing: false,
2862 }); 2864 });
2863 }; 2865 };
2864 key(fakeAddInput);
2865 check('the add input still keeps its keys out of the session', typed.length, 0);
2866 key(fakePanelField); 2866 key(fakePanelField);
2867 check('a focused settings field keeps its keys out of the session', typed.length, 0); 2867 check('a focused settings field keeps its keys out of the session', typed.length, 0);
2868 // The guard is the add tile, not "any input": the terminal still has to 2868 key(fakeSizeField);
2869 // receive keys when focus is anywhere else. 2869 check('a second settings field keeps its keys out too', typed.length, 0);
2870 // The guard is the settings tile, not "any input": the terminal still
2871 // has to receive keys when focus is anywhere else.
2870 const elsewhere = h.document.createElement('input'); 2872 const elsewhere = h.document.createElement('input');
2871 key(elsewhere); 2873 key(elsewhere);
2872 check('a key with focus outside the add tile still reaches the session', typed.length > 0, true); 2874 check('a key with focus outside the settings tile still reaches the session', typed.length > 0, true);
2873 h.setZoomedTile(null); 2875 h.setZoomedTile(null);
2874 h.setAddTile(null, null); 2876 h.setSettingsTile(null);
2875 2877
2876 // --- persistence, where the browser is allowed to say no --- 2878 // --- persistence, where the browser is allowed to say no ---
2877 check('settings load to defaults with no stored value', h.loadSettings().fontSize, 14); 2879 check('settings load to defaults with no stored value', h.loadSettings().fontSize, 14);
@@ -2908,8 +2910,54 @@ async function verifySettingsShell(shell, html) {
2908 true, 2910 true,
2909 ); 2911 );
2910 check( 2912 check(
2911 'the settings panel lives inside the add tile', 2913 'the settings panel lives inside the settings tile',
2912 /\.tile\.add\s+\.settings/.test(executableHtmlCss), 2914 /\.tile\.settings-tile\s+\.settings/.test(executableHtmlCss),
2915 true,
2916 );
2917 // The browser authors nothing: the wall is the hosts file, so a page
2918 // that grew an add box or an `x` back would be showing a control whose
2919 // route the hub answers 405 to.
2920 check(
2921 'the page has no add box and no per-tile close',
2922 /addTileEl|removeTile|putOrder|draggable/.test(shell),
2923 false,
2924 );
2925
2926 // --- the `+` must not out-race the hub it just asked ---
2927 // `Hub.spawn` stores a poke and answers; the poller feels it at its next
2928 // 50 ms slice and only then dials. Six unspaced fetches finish in ~10 ms,
2929 // so an unspaced loop finds nothing, falls through, and the session the
2930 // user asked for arrives unzoomed a second later. The delay is a named
2931 // function precisely so this check can reach it — the network path around
2932 // it is not executable here.
2933 check('the spawn poll delay is a function', typeof h.spawnPollDelay, 'function');
2934 check('the spawn poll delay clears the hub 50ms poke slice', h.SPAWN_POLL_MS >= 100, true);
2935 const timersBefore = h.timers.length;
2936 h.spawnPollDelay();
2937 const spawnTimer = h.timers.at(-1);
2938 check('the spawn poll delay schedules exactly one timer', h.timers.length - timersBefore, 1);
2939 check('the spawn poll delay sleeps SPAWN_POLL_MS', spawnTimer?.ms, h.SPAWN_POLL_MS);
2940 // ...and `spawnHere` actually awaits it. Read off the EXECUTABLE tokens,
2941 // so a delay that survives only in a comment does not pass.
2942 const spawnBodies = balancedBodiesAfter(
2943 executableJsTokens(shell),
2944 /\basync function spawnHere\s*\([^)]*\)\s*\{/g,
2945 );
2946 check('shell has exactly one spawnHere', spawnBodies.length, 1);
2947 const spawnBody = spawnBodies.length === 1 ? (spawnBodies[0].body ?? '') : '';
2948 check(
2949 'spawnHere waits between its polls',
2950 /await\s+spawnPollDelay\s*\(\s*\)/.test(spawnBody),
2951 true,
2952 );
2953 // The wait is INSIDE the retry loop, not once before it: a single sleep
2954 // would cover the poke slice and then burn the remaining five polls in
2955 // 10 ms against a daemon that answered slowly.
2956 const spawnLoops = balancedBodiesAfter(spawnBody, /\bfor\s*\([^)]*SPAWN_POLLS[^)]*\)\s*\{/g);
2957 check('spawnHere has one bounded retry loop', spawnLoops.length, 1);
2958 check(
2959 'the wait is inside the retry loop',
2960 /await\s+spawnPollDelay\s*\(\s*\)/.test(spawnLoops.length === 1 ? (spawnLoops[0].body ?? '') : ''),
2913 true, 2961 true,
2914 ); 2962 );
2915 } 2963 }