019b1c19
feat: the browser hub reads the hosts file and polls like the CLI wall
a73x 2026-08-29 12:14
Commit message
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 | } |