a73x

src/client/webhub.zig

Ref:   Size: 100.8 KiB   History

//! The `mux web` hub's HTTP/WebSocket layer: route table, Origin gate and WS
//! endpoint naming — the decisions std.http does NOT make for us. Assets are
//! `@embedFile`'d by webhub_main.zig and injected, so this tests without wasm.
//!
//! Localhost only, by construction: remote viewing is `ssh -L`, authenticated
//! by ssh like everything else here. No popup, so no prompts — a birth on a
//! host that wants a password fails with ssh's own reason on `/tiles`.

const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
// The layout file is the wall, and the hub reads and writes the same one
// the terminal wall does: `client.layout` is the grammar, `client.hosts`
// the atomic write. Two spellings of either would be two walls.
const layout = client.layout;
const hosts = client.hosts;

pub const default_port: u16 = 7681;

/// Take 127.0.0.1:`port` for this hub, or hand back the kernel's refusal. A
/// port is to a hub what the socket path is to a daemon, so the rule is
/// `sockpath.claim`'s: never share an address a live peer already answers on.
/// Spelled here rather than left to `std.net.Address.listen` because that
/// function's one `reuse_address` bit sets SO_REUSEPORT alongside SO_REUSEADDR
/// for every family but unix, and SO_REUSEPORT is not the permissive-restart
/// flag its name suggests: it lets any number of processes bind one address
/// and port at once and has the kernel spread incoming connections across all
/// of them. Two `mux web` runs on 7681 therefore both bound, `ss` showed two
/// LISTEN rows, and a browser reached whichever the kernel picked — half the
/// tabs got a stale hub's wall, with nothing on screen saying so.
///
/// SO_REUSEADDR alone stays on, for the one job it actually does here: a hub
/// restarted while the connections its predecessor accepted are still in
/// TIME_WAIT gets its port back instead of a spurious refusal. A port a LIVE
/// listener holds is `error.AddressInUse` with or without it — measured, not
/// assumed — and that error is what `mux web` refuses on.
///
/// Port 0 asks the kernel for a free port. `mux web` rejects it at parse,
/// because the announced address would not be the bound one; the tests below
/// use it to take a port nothing else on the box owns.
pub fn listenLocal(port: u16) !std.net.Server {
    const addr = std.net.Address.parseIp("127.0.0.1", port) catch unreachable;
    const fd = try std.posix.socket(
        addr.any.family,
        std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC,
        std.posix.IPPROTO.TCP,
    );
    var server: std.net.Server = .{ .listen_address = undefined, .stream = .{ .handle = fd } };
    errdefer server.stream.close();
    try std.posix.setsockopt(
        fd,
        std.posix.SOL.SOCKET,
        std.posix.SO.REUSEADDR,
        &std.mem.toBytes(@as(c_int, 1)),
    );
    var len = addr.getOsSockLen();
    try std.posix.bind(fd, &addr.any, len);
    try std.posix.listen(fd, 128);
    // The address as BOUND, read back from the kernel: with port 0 it is not
    // the one asked for, and `listen_address` is where a caller reads the
    // port it actually got.
    try std.posix.getsockname(fd, &server.listen_address.any, &len);
    return server;
}

/// ONE number bounds two things, a property of std.http.Server: the connection
/// Reader's buffer is both the max HTTP header size and the max inbound
/// WebSocket message. 64 KiB — the browser chunks pastes at 32 KiB, so nothing
/// approaches it. Hub→browser has no such bound, so snapshots are safe.
pub const ws_buffer_len = 64 * 1024;

/// Any webpage may dial ws://127.0.0.1:PORT — localhost binding does not
/// stop a cross-origin WebSocket, and this socket carries shell input to
/// every device. std's upgradeRequested does not check Origin; this is
/// entirely ours.
pub fn originAllowed(origin: ?[]const u8, port: u16) bool {
    const o = origin orelse return false;
    var buf: [40]u8 = undefined;
    inline for (.{ "127.0.0.1", "localhost" }) |host| {
        const want = std.fmt.bufPrint(&buf, "http://" ++ host ++ ":{d}", .{port}) catch
            unreachable;
        if (std.mem.eql(u8, o, want)) return true;
    }
    return false;
}

/// `/ws/<id>` → the tile id, with no opinion on range: ids go sparse the
/// moment a tile is removed, so only the Hub's map, under its mutex, can
/// answer "is this id live".
pub fn wsTileId(path: []const u8) ?u32 {
    const prefix = "/ws/";
    if (!std.mem.startsWith(u8, path, prefix)) return null;
    return std.fmt.parseInt(u32, path[prefix.len..], 10) catch null;
}

/// What a pump needs to run a tile without holding the tile itself. No
/// session name: the browser names its own session in the attach frame it
/// sends on `up`, off the `/tiles` list — the hub forwards and never
/// attaches on anybody's behalf.
pub const Checkout = struct { target: client.Target };

/// One daemon the hub polls, fixed for the run. A hub never forgets a
/// host — `mux hosts rm` and a restart is how one leaves — so a tile may
/// borrow its target rather than copy it.
pub const HubHost = struct {
    spec: client.HostSpec,
    poll: client.SessionPoll = .{},
    /// Wired by `start`, not by `init`: init returns the Hub BY VALUE, so
    /// a pointer taken there names the temporary it was built in.
    hub: ?*Hub = null,
    idx: usize = 0,

    /// Never stops; `Hub.deinit` says why.
    fn keep(_: *anyopaque) bool {
        return true;
    }

    fn wake(p: *anyopaque) void {
        const self: *HubHost = @ptrCast(@alignCast(p));
        var buf: [proto.sessions_reply_max]u8 = undefined;
        const reachable = self.poll.reachable.load(.acquire);
        self.hub.?.applyList(self.idx, if (reachable) self.poll.snapshot(&buf) else "", reachable);
    }
};

fn pollHubHost(h: *HubHost) void {
    h.poll.run(h.spec.poll_target, .{ .ctx = h, .keep = HubHost.keep, .wake = HubHost.wake });
}

/// One pane of the layout at runtime, and nothing else: the file is the
/// wall, so a session on a listed daemon that no leaf names is not a tile.
/// It owns its session name because a spawn's name arrives in a stack
/// buffer and a seed's in bytes `readLeaves` frees.
const HubTile = struct {
    id: u32,
    host: usize,
    session: []const u8,
    /// The poller's grade, and the only thing a list may change about a
    /// pane. `.gone` is the daemon saying it has no such session; it is a
    /// badge, never a removal, because only the layout file takes a pane
    /// off the wall.
    state: TileState = .connecting,
    /// One list's grace. The poll's connect and its pass through the
    /// daemon take milliseconds a daemon mid-answer can be descheduled
    /// for, and a wall that tore a tile down over one of those would
    /// flicker every time somebody typed `exit` next door.
    missed_once: bool = false,
};

/// One pane of the layout, as the hub holds it: which listed host, and the
/// session on it. `host` indexes the same `specs` the Hub was built from, so
/// a leaf stays a match key and never becomes an address.
pub const Leaf = struct { host: usize, session: []const u8 };

/// The layout's leaves in tree order, each mapped to a spec index. The same
/// strictness as the terminal wall: a leaf the hosts file cannot place, or
/// one with no session, refuses the FILE, because a hub that silently served
/// part of a wall would be a wall the user cannot see is short. A MISSING
/// file is not a refusal — nothing was authored, so there is nothing to fix
/// and nothing to name.
///
/// The refusal prints here rather than reporting a line to the caller: the
/// bytes the line points into are this function's own, and `mux web:` is
/// already this file's voice (`Hub.birth` and `Hub.start` print in it).
pub fn readLeaves(alloc: std.mem.Allocator, path: []const u8, specs: []const client.HostSpec) ![]Leaf {
    const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
        error.FileNotFound => return alloc.alloc(Leaf, 0),
        else => return e,
    };
    defer alloc.free(bytes);
    var bad_line: []const u8 = "";
    var parsed = layout.parseReporting(alloc, bytes, &bad_line) orelse {
        refuseLayout(path, bad_line);
        return error.BadLayout;
    };
    defer parsed.deinit(alloc);
    // The same ceiling the terminal wall refuses past, and for the same
    // reason: a hub that served a 33rd pane would serve a wall no terminal
    // could open, and the two fronts read one file.
    if (parsed.spellings.items.len > layout.max_leaves) {
        refuseLayout(path, parsed.spellings.items[layout.max_leaves]);
        return error.BadLayout;
    }
    var out: std.ArrayListUnmanaged(Leaf) = .{};
    errdefer {
        for (out.items) |l| alloc.free(l.session);
        out.deinit(alloc);
    }
    for (parsed.spellings.items) |sp| {
        const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse {
            refuseLayout(path, sp);
            return error.BadLayout;
        };
        const sess = sp[cut + 1 ..];
        if (!proto.validSessionName(sess)) {
            refuseLayout(path, sp);
            return error.BadLayout;
        }
        // The host part must be ON the hosts file, byte for byte: nothing a
        // layout names may be resurrected from the layout alone.
        const hi = for (specs, 0..) |s, i| {
            if (std.mem.eql(u8, s.spelling, sp[0..cut])) break i;
        } else {
            refuseLayout(path, sp);
            return error.BadLayout;
        };
        // A repeated leaf refuses the FILE, exactly as `wall_layout.seedLayout`
        // refuses it: a host's list names a session once, so a second pane on
        // the same (host, session) could never bind, and the two fronts must
        // agree about which files are walls or one of them loses its wall to
        // a file the other happily served.
        for (out.items) |l| {
            if (l.host == hi and std.mem.eql(u8, l.session, sess)) {
                refuseLayout(path, sp);
                return error.BadLayout;
            }
        }
        try out.append(alloc, .{ .host = hi, .session = try alloc.dupe(u8, sess) });
    }
    return out.toOwnedSlice(alloc);
}

fn refuseLayout(path: []const u8, line: []const u8) void {
    std.debug.print("mux web: layout ignored ({s}): {s}\n", .{ path, line });
}

/// The mirror of `readLeaves` for a caller that did not hand it an arena.
/// `webhub_main` does, and never calls this; a test allocator notices.
pub fn freeLeaves(alloc: std.mem.Allocator, leaves: []const Leaf) void {
    for (leaves) |l| alloc.free(l.session);
    alloc.free(leaves);
}

/// The layout as a tree, for the two callers that need one: a MISSING file
/// is an empty tree, because nothing was authored, and a file that is not a
/// layout names the line it gave up on before refusing. `layout.parse` would
/// drop that line, and a spawn refused by a bad file must name it the same
/// way the start-up read does.
fn readTree(alloc: std.mem.Allocator, path: []const u8) !layout.ParsedLayout {
    const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
        error.FileNotFound => return .{ .tree = layout.Tree.init(alloc) },
        else => return e,
    };
    defer alloc.free(bytes);
    var bad_line: []const u8 = "";
    return layout.parseReporting(alloc, bytes, &bad_line) orelse {
        refuseLayout(path, bad_line);
        return error.BadLayout;
    };
}

/// Where the file already names this pane, if it does. One spelling per
/// (host, session): the hosts file's spelling and the session name are what
/// a leaf IS, so this answers both "where does the new pane go" and "is it
/// already there".
fn leafIndex(spellings: []const []const u8, specs: []const client.HostSpec, leaf: Leaf) ?u8 {
    for (spellings, 0..) |sp, i| {
        const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue;
        if (std.mem.eql(u8, sp[0..cut], specs[leaf.host].spelling) and
            std.mem.eql(u8, sp[cut + 1 ..], leaf.session)) return @intCast(i);
    }
    return null;
}

/// Whether the file could hold this pane, asked BEFORE anything is born.
/// `appendLeaf` refuses the same three ways, but by then the daemon has a
/// live session and the browser has a 502: a session nobody asked for, on no
/// wall, that only `mux a` or a terminal could find again. `Duplicate` is the
/// one that costs most — two `+` inside one poll interval read the same
/// session list, so both take the same next free name, and the daemon
/// answers the second by ATTACHING to the session the first made. The window
/// between this and the append is another writer taking the same file in the
/// same instant; `appendLeaf` refuses there too, and the read-modify-write's
/// accepted cost is a lost update, never a repeated leaf.
pub fn roomForLeaf(
    alloc: std.mem.Allocator,
    path: []const u8,
    specs: []const client.HostSpec,
    new: Leaf,
) !void {
    var parsed = try readTree(alloc, path);
    defer parsed.deinit(alloc);
    if (parsed.spellings.items.len >= layout.max_leaves) return error.WallFull;
    if (leafIndex(parsed.spellings.items, specs, new) != null) return error.Duplicate;
}

/// Read-modify-write over the atomic rename `hosts.saveBytes` does. Two
/// writers in the same instant lose one update; the hosts file accepts the
/// same, and the terminal wall re-reads the file on its next start.
///
/// `beside` places the new pane where `Tree.insert` puts a leaf: the next
/// sibling of the anchor, which is immediately after it in the depth-first
/// walk the file is written in. A null anchor, or one this file does not
/// name, falls back to the first leaf rather than refusing: by the time this
/// runs the session EXISTS, so the pane has to land somewhere, and an anchor
/// the file no longer names means a terminal wall rewrote the file between
/// the `+` and here. The terminal wall's own insert always has its focused
/// pane in hand and needs no such fallback.
pub fn appendLeaf(
    alloc: std.mem.Allocator,
    path: []const u8,
    specs: []const client.HostSpec,
    beside: ?Leaf,
    new: Leaf,
) !void {
    var parsed = try readTree(alloc, path);
    defer parsed.deinit(alloc);

    // Every caller's host index came off a `Leaf` the hosts file placed;
    // asserted rather than checked because a stray one is a caller bug, not
    // a file the user can fix.
    std.debug.assert(new.host < specs.len);
    if (beside) |b| std.debug.assert(b.host < specs.len);
    if (parsed.spellings.items.len >= layout.max_leaves) return error.WallFull;
    // Before the tree is touched: a file naming one pane twice is a file
    // `wall_layout.seedLayout` refuses WHOLE, so writing the second leaf here
    // would cost the next terminal `mux` on this machine its entire wall.
    if (leafIndex(parsed.spellings.items, specs, new) != null) return error.Duplicate;
    const new_id: u8 = @intCast(parsed.spellings.items.len);
    const anchor: ?u8 = if (beside) |b| leafIndex(parsed.spellings.items, specs, b) else null;
    const new_sp = try std.fmt.allocPrint(alloc, "{s}#{s}", .{ specs[new.host].spelling, new.session });
    // Owned by `parsed` from here on, so no errdefer: a second free is what
    // a scope-local one would become the moment the append succeeded.
    parsed.spellings.append(alloc, new_sp) catch |e| {
        alloc.free(new_sp);
        return e;
    };
    if (parsed.tree.root == null)
        try parsed.tree.addFirst(new_id)
    else
        try parsed.tree.insert(anchor orelse 0, new_id);

    var buf: std.ArrayListUnmanaged(u8) = .{};
    defer buf.deinit(alloc);
    try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc));
    try hosts.saveBytes(path, buf.items);
}

/// The wall at runtime: the LAYOUT's panes as tiles, in the file's tree
/// order, and the ids the browser names them by. Ids are handed out once and
/// never reused — the browser holds them across changes it did not make, and
/// a recycled one would re-point a `/ws/<n>` at a different shell.
pub const Hub = struct {
    alloc: std.mem.Allocator,
    /// One lock for the tile list. Every writer is a poller's wake or a
    /// browser's checkout, both rare, and every one of them touches the
    /// list as a whole.
    mutex: std.Thread.Mutex = .{},
    hosts: []HubHost,
    tiles: std.ArrayList(HubTile) = .empty,
    next_id: u32 = 0,
    /// Whether `start` has handed `hosts` to threads that never stop. Read
    /// by `deinit` alone, and both run on the thread that built the Hub.
    polling: bool = false,
    /// Borrowed beside `hosts`, because `appendLeaf` spells a pane out of a
    /// spec's own `spelling` and a leaf's `host` indexes this slice.
    specs: []const client.HostSpec,
    /// Where a birth writes the new pane. `webhub_main` sets it after
    /// `init`, which cannot: the path comes from the environment and the Hub
    /// is built by value. Empty means nobody handed this hub a layout, and a
    /// spawn refuses rather than birthing a session no wall would show.
    layout_path: []const u8 = "",

    /// `specs` and `leaves` are borrowed: webhub_main resolves the hosts file
    /// and reads the layout into an arena that outlives the accept loop.
    /// One tile per leaf, ids in leaf order — which is the file's tree order,
    /// so `/tiles` and the terminal wall lay the same panes out the same way.
    pub fn init(alloc: std.mem.Allocator, specs: []const client.HostSpec, leaves: []const Leaf) !Hub {
        const rows = try alloc.alloc(HubHost, specs.len);
        errdefer alloc.free(rows);
        for (rows, specs) |*r, spec| r.* = .{ .spec = spec };
        var self: Hub = .{ .alloc = alloc, .hosts = rows, .specs = specs };
        errdefer {
            for (self.tiles.items) |t| alloc.free(t.session);
            self.tiles.deinit(alloc);
        }
        for (leaves) |l| {
            // A leaf naming no listed host is `readLeaves`' refusal, not a
            // tile: `birth` and every checkout index `hosts` by this number.
            if (l.host >= rows.len) return error.BadLayout;
            try self.birth(l.host, l.session, self.tiles.items.len);
        }
        return self;
    }

    pub fn deinit(self: *Hub) void {
        // A started hub cannot be torn down: its pollers are detached, hold
        // `*HubHost` into the array freed below, and have no stop to ask for.
        // An assert rather than a comment, because the day someone adds a
        // shutdown path this free is a use-after-free per host.
        std.debug.assert(!self.polling);
        for (self.tiles.items) |t| self.alloc.free(t.session);
        self.tiles.deinit(self.alloc);
        self.alloc.free(self.hosts);
    }

    /// One poller thread per host. A host whose thread will not start
    /// shows what an unreachable one shows — nothing — rather than taking
    /// the hub down with it.
    pub fn start(self: *Hub) void {
        self.polling = true;
        for (self.hosts, 0..) |*h, i| {
            h.hub = self;
            h.idx = i;
        }
        for (self.hosts) |*h| {
            const th = std.Thread.spawn(.{}, pollHubHost, .{h}) catch {
                std.debug.print("mux web: no poller for {s}\n", .{h.spec.spelling});
                continue;
            };
            th.detach();
        }
    }

    /// One host's answer, turned into GRADES on the panes that host already
    /// owns. It adds nothing: the layout file is the wall, and a session
    /// somebody started elsewhere on a listed daemon is not a pane until
    /// someone writes it into the file. Callable without a thread, which is
    /// what lets the rule be tested without a daemon: the poller's wake is
    /// the only other caller.
    pub fn applyList(self: *Hub, host_idx: usize, list: []const u8, reachable: bool) void {
        self.mutex.lock();
        defer self.mutex.unlock();
        // Only a LIST grades. A host that has gone quiet keeps its panes as
        // they were, and they reconnect on their own; grading them on a
        // failed poll would mark a whole wall gone over one dropped packet,
        // and spending the grace on silence would do it one poll later.
        if (!reachable) return;
        for (self.tiles.items) |*t| {
            // Only a host's own sessions are its list's to grade: two
            // daemons may have a session of the same name.
            if (t.host != host_idx) continue;
            if (proto.sessionsHas(list, t.session)) {
                t.missed_once = false;
                // Clearing the badge changes what `/tiles` says and nothing
                // else. The pump is not parked: after an `exit_status` it
                // reconnects the transport and idles on it, and the ATTACH is
                // the browser's to send — the page latches `exited` and
                // re-attaches on its own `up`. Nothing here is a doorbell.
                if (t.state == .gone) t.state = .connecting;
                continue;
            }
            if (!t.missed_once) {
                t.missed_once = true;
                continue;
            }
            t.state = .gone;
        }
    }

    /// Caller holds the mutex.
    fn indexOf(self: *Hub, id: u32) ?usize {
        for (self.tiles.items, 0..) |t, i| if (t.id == id) return i;
        return null;
    }

    /// Caller holds the mutex. `at` is where the pane goes in `tiles`, which
    /// is the FILE's tree order and nothing else: the seed appends in leaf
    /// order, and a spawn puts its new pane immediately after the one it was
    /// born beside — exactly where `Tree.insert` puts that leaf in the file.
    fn birth(self: *Hub, host_idx: usize, name: []const u8, at: usize) !void {
        const own = try self.alloc.dupe(u8, name);
        errdefer self.alloc.free(own);
        const id = self.next_id;
        try self.tiles.insert(self.alloc, at, .{ .id = id, .host = host_idx, .session = own });
        self.next_id += 1;
        // A user reads this to learn what `/ws/<id>` names, and a script
        // waits on it to know the wall has a tile at all.
        std.debug.print("mux web: tile {d}: {s}#{s}\n", .{ id, self.hosts[host_idx].spec.spelling, name });
    }

    /// UnknownId: an ended session. 404, and the page refetches. Nothing is
    /// registered here: a pane is never torn down under its pump any more —
    /// a session the daemon stops leaves the pane wearing `gone` and the
    /// pump redials — so there is no wakeup that would need the socket, and
    /// two browsers on one tile are simply two pumps, each ended by its own
    /// WS read.
    pub fn checkoutTile(self: *Hub, id: u32) error{UnknownId}!Checkout {
        self.mutex.lock();
        defer self.mutex.unlock();

        const idx = self.indexOf(id) orelse return error.UnknownId;
        // BORROWED: a host is fixed for the run and outlives every pump,
        // so there is nothing here to copy field by field — the three
        // comptime field-count asserts a copy needed are gone with it.
        return .{ .target = self.hosts[self.tiles.items[idx].host].spec.target };
    }

    /// The `+` on a tile: a new session on THAT tile's daemon, AND a new
    /// pane beside that tile in the layout file. The name is the daemon's
    /// own next free one, so the browser and `Ctrl-\ c` count in the same
    /// series; the file write is what makes the birth a pane rather than a
    /// stranger the poll would only ever grade.
    pub fn spawn(self: *Hub, id: u32) !client.SessionName {
        // A hub with no layout has nowhere to author: refuse before the
        // dial rather than leave a session running on a wall that will
        // never show it.
        if (self.layout_path.len == 0) return error.NoLayout;
        var hi: usize = undefined;
        // Copied, not borrowed: the anchor is read under the lock and used
        // after it, and `SessionName.of` is the copy this file already has.
        // Every tile name reached `validSessionName` on its way in, so the
        // unbounded memcpy inside `of` has a bound here.
        var beside: client.SessionName = undefined;
        var name: client.SessionName = undefined;
        {
            self.mutex.lock();
            // Unlocked before the dial: a birth is a whole round trip to a
            // daemon, and every poller's wake would queue behind it.
            defer self.mutex.unlock();
            const idx = self.indexOf(id) orelse return error.UnknownId;
            hi = self.tiles.items[idx].host;
            beside = client.SessionName.of(self.tiles.items[idx].session);
            // The name is picked HERE, not after the probe, because the file
            // has to be asked about the pane this `+` would actually make.
            // `snapshot` takes the poller's own lock and releases it; the
            // poller takes that lock and releases it before it ever reaches
            // this one, so the two orders never nest.
            var list_buf: [proto.sessions_reply_max]u8 = undefined;
            var name_buf: [proto.session_name_max]u8 = undefined;
            name = client.SessionName.of(client.nextFreeName(
                &name_buf,
                self.hosts[hi].poll.snapshot(&list_buf),
            ));
            // The file is asked whether it can hold THAT pane BEFORE the
            // daemon is asked to make it. A full wall, an unparseable file
            // and a pane the file already names are all certain now, and each
            // would otherwise leave a live session behind a 502 with no wall
            // to show it.
            try roomForLeaf(self.alloc, self.layout_path, self.specs, .{
                .host = hi,
                .session = name.slice(),
            });
        }

        const h = &self.hosts[hi];
        try client.birthSession(self.alloc, h.spec.target, name.slice(), client.birth_cols, client.birth_rows);
        // The file write and the tile share ONE hold of the lock: a `/tiles`
        // between them would answer a wall the file already has a pane the
        // hub does not, and two browsers pressing `+` at once would each
        // read the file before the other wrote it and lose a pane. The hold
        // spans no dial — the round trip above is over — so the pollers
        // queue behind a read and a rename, not behind a network.
        self.mutex.lock();
        defer self.mutex.unlock();
        try appendLeaf(self.alloc, self.layout_path, self.specs, .{
            .host = hi,
            .session = beside.slice(),
        }, .{ .host = hi, .session = name.slice() });
        // Re-found under the lock: `idx` was read before the dial, and the
        // pane it named may have moved if another spawn landed meanwhile.
        const at = if (self.indexOf(id)) |now| now + 1 else self.tiles.items.len;
        try self.birth(hi, name.slice(), at);
        // The pane is on the wall already; the poke is for the GRADE, which
        // is the poll's alone and would otherwise be a second stale.
        h.poll.poke.store(true, .release);
        return name;
    }

    /// The tile list the page renders and attaches by, in the layout's tree
    /// order. `state` is the poller's grade, so a page can say why a pane it
    /// is showing has nothing behind it.
    pub fn json(self: *Hub, alloc: std.mem.Allocator) ![]u8 {
        self.mutex.lock();
        defer self.mutex.unlock();

        var out: std.ArrayList(u8) = .empty;
        errdefer out.deinit(alloc);
        try out.append(alloc, '[');
        for (self.tiles.items, 0..) |t, i| {
            if (i > 0) try out.append(alloc, ',');
            try out.print(alloc, "{{\"id\":{d},\"label\":", .{t.id});
            // Both strings take the same escape road: a label is the host
            // line the user wrote, which is theirs to make unparseable, and
            // the session rides beside it rather than inside it so the page
            // never has to dig one back out of the other.
            try appendJsonString(alloc, &out, self.hosts[t.host].spec.spelling);
            try out.appendSlice(alloc, ",\"session\":");
            try appendJsonString(alloc, &out, t.session);
            try out.print(alloc, ",\"state\":\"{s}\"", .{@tagName(t.state)});
            try out.append(alloc, '}');
        }
        try out.append(alloc, ']');
        return out.toOwnedSlice(alloc);
    }
};

/// The embedded page: webhub_main @embedFiles them, tests inject fakes.
pub const Assets = struct {
    index_html: []const u8,
    mux_js: []const u8,
    core_wasm: []const u8,
};

pub const Asset = struct {
    body: []const u8,
    content_type: []const u8,
};

/// The whole static route table. Anything else is a 404 — there are no
/// other files, and inventing a directory to traverse would be the only
/// way to get one.
pub fn route(assets: Assets, path: []const u8) ?Asset {
    if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html"))
        return .{ .body = assets.index_html, .content_type = "text/html; charset=utf-8" };
    if (std.mem.eql(u8, path, "/mux.js"))
        return .{ .body = assets.mux_js, .content_type = "application/javascript" };
    if (std.mem.eql(u8, path, "/mux_core.wasm"))
        return .{ .body = assets.core_wasm, .content_type = "application/wasm" };
    return null;
}

// ---------------------------------------------------------------------------
// The wire between hub and browser: WebSocket binary messages, one envelope
// byte. 0x00 + a mux frame verbatim both ways; 0x01 + JSON control, hub→browser
// only. The whole vocabulary — anything the protocol learns later transits.

pub const env_frame: u8 = 0x00;
pub const env_control: u8 = 0x01;

pub const TileState = enum { connecting, up, reconnecting, gone };

/// The full control MESSAGE — envelope byte included — ready for one
/// writeMessage. Comptime because the vocabulary is closed.
pub fn controlMessage(comptime s: TileState) []const u8 {
    return &[1]u8{env_control} ++ "{\"state\":\"" ++ @tagName(s) ++ "\"}";
}

pub const ParsedFrame = struct { t: proto.MsgType, payload: []const u8 };

pub const FrameMsgError = error{
    BadEnvelope,
    ShortFrame,
    LengthMismatch,
    Oversize,
    /// An agent frame from the browser. Those are the hub's own to author
    /// (it offers no agent today), and a tab has no key to speak for — so
    /// the frame is a bug or a spoof, and either way the silent offer
    /// that wedges ssh.
    HubOwned,
};

/// One browser→hub WebSocket message → one mux frame. The hub parses the 5-byte
/// header — it must, since the WS leg is message-delimited and the daemon leg is
/// a byte stream — and NEVER the payload. The length must match exactly:
/// WebSocket already delimits, so a trailing byte is a bug upstream.
pub fn parseFrameMessage(msg: []const u8) FrameMsgError!ParsedFrame {
    if (msg.len < 1 or msg[0] != env_frame) return error.BadEnvelope;
    const f = msg[1..];
    if (f.len < proto.frame_header_len) return error.ShortFrame;
    const len = std.mem.readInt(u32, f[1..5], .little);
    if (len > proto.max_payload) return error.Oversize;
    if (f.len - proto.frame_header_len != len) return error.LengthMismatch;
    // MsgType is non-exhaustive BY DESIGN, so an unknown type byte is a
    // valid value that transits untouched — the proxy thesis holding for
    // vocabulary the protocol has not learned yet. The one exception is
    // named, and narrow: three KNOWN types the hub keeps for itself.
    const t: proto.MsgType = @enumFromInt(f[0]);
    switch (t) {
        .agent_offer, .agent_data, .agent_close => return error.HubOwned,
        else => {},
    }
    return .{ .t = t, .payload = f[proto.frame_header_len..] };
}

/// What the WS reader's ALREADY-BUFFERED bytes will do to the next
/// `readSmallMessage`, decided without touching the socket. It exists because
/// that call BLOCKS on a partial frame, parking the tile thread with the daemon
/// leg unattended — and poll cannot rescue it, since the bytes already in
/// userspace are what made poll fire in the first place.
pub const HeadFrame = union(enum) {
    /// No complete frame at the head: leave POLLIN armed and re-check after the
    /// next readable event, since a short frame's rest is still in flight. A
    /// peer that then goes quiet is what the dead-leg ping below ends.
    incomplete,
    /// A complete pong at the head, `bytes` long. readSmallMessage
    /// SWALLOWS pongs and loops to the frame behind them, so handing it
    /// one is the blocking hazard all over again; the pump tosses the
    /// pong itself and counts it as the liveness proof it is.
    pong: usize,
    /// A whole frame is buffered: readSmallMessage returns it (or names
    /// it a close) without touching the socket.
    ready,
    /// The frame does not FIT the reader's buffer, so no waiting makes it
    /// readable and a pump that waited would fill the buffer with a frame it can
    /// never finish. Said here so `readSmallMessage` is never handed one it
    /// would block on. Our page's largest message is half the buffer.
    too_big,
};

const ws_opcode_pong: u4 = 10;

/// `buffered` is the reader's unread bytes; `capacity` its whole buffer
/// (readSmallMessage's own MessageTooBig bound).
pub fn headFrame(buffered: []const u8, capacity: usize) HeadFrame {
    if (buffered.len < 2) return .incomplete;
    const opcode: u4 = @truncate(buffered[0]);
    const masked = buffered[1] & 0x80 != 0;
    const len7: u7 = @truncate(buffered[1]);

    var off: usize = 2;
    var payload_len: u64 = len7;
    switch (len7) {
        126 => {
            if (buffered.len < off + 2) return .incomplete;
            payload_len = std.mem.readInt(u16, buffered[off..][0..2], .big);
            off += 2;
        },
        127 => {
            if (buffered.len < off + 8) return .incomplete;
            payload_len = std.mem.readInt(u64, buffered[off..][0..8], .big);
            off += 8;
        },
        else => {},
    }
    // Browser→server frames are always masked; the bit is checked by
    // readSmallMessage, but the four key bytes count toward the length
    // either way.
    if (masked) {
        if (buffered.len < off + 4) return .incomplete;
        off += 4;
    }
    // HEADER AND PAYLOAD against the capacity: the reader holds both, so a
    // payload that only just fits still leaves a frame that never completes —
    // and `fillMore`'s rebase asserts on a FULL buffer, so saying `.too_big`
    // first is what keeps the pump off that panic.
    //
    // A subtraction on the RIGHT, because `payload_len` is a wire number:
    // `off + payload_len` overflows near u64 max, which is a Debug panic and a
    // wrap to a permanent `.incomplete` in ReleaseFast.
    if (capacity < off or payload_len > capacity - off) return .too_big;
    if (buffered.len - off < payload_len) return .incomplete;
    if (opcode == ws_opcode_pong) return .{ .pong = off + @as(usize, @intCast(payload_len)) };
    return .ready;
}

/// Dead browser leg. The pump's poll is capped at 100 ms for the QUIC timer, so
/// silence is measured on the wall clock: the hub pings after `ping_idle_ms` and
/// gives up after `dead_intervals`. What this releases is the daemon CLIENT SLOT
/// a half-open socket would otherwise hold until the daemon exits.
pub const ping_idle_ms: i64 = 30_000;
pub const dead_intervals: i64 = 3;

/// The browser leg's liveness, carried BY POINTER across `dialLoop` — which is
/// the whole point of the struct. A reconnect is when a dead browser is most
/// likely and nothing else is watching: leaving the timer to the pump blinds the
/// check for the outage, and resetting it on the way out hides a browser that
/// died mid-way. One ping in both loops against one clock.
const Liveness = struct {
    last_inbound_ms: i64,
    pings_sent: i64 = 0,

    fn init() Liveness {
        return .{ .last_inbound_ms = std.time.milliTimestamp() };
    }

    fn sawInbound(self: *Liveness) void {
        self.last_inbound_ms = std.time.milliTimestamp();
        self.pings_sent = 0;
    }

    /// Ping on each elapsed interval; false once the browser has been
    /// silent through all of them, and the caller ends the tile.
    fn tick(self: *Liveness, ws: *std.http.Server.WebSocket) bool {
        const silence = std.time.milliTimestamp() - self.last_inbound_ms;
        if (silence >= ping_idle_ms * dead_intervals) return false;
        if (silence >= ping_idle_ms * (self.pings_sent + 1)) {
            ws.writeMessage("", .ping) catch return false;
            self.pings_sent += 1;
        }
        return true;
    }
};

/// What one drain pass leaves behind. `transport_dead` is only reachable
/// when a transport was handed in — the dial loop drains with none.
const Drained = enum { ok, browser_dead, transport_dead };

/// Both loops drain the browser leg through this copy; the optional
/// transport is their only difference.
fn drainBrowser(
    ws: *std.http.Server.WebSocket,
    live: *Liveness,
    fill: bool,
    transport: ?*client.Transport,
) Drained {
    // poll reports what the KERNEL holds, and one fill turns that into buffered
    // bytes without blocking. Readiness gates only this step: bytes already in
    // userspace are invisible to poll, so a drain that ran only on readiness
    // would strand what a mid-drain reconnect left — a close frame among them.
    if (fill) ws.input.fillMore() catch return .browser_dead;
    while (true) {
        // Every read is gated on headFrame: readSmallMessage may not be
        // called speculatively, here or anywhere, because it blocks.
        switch (headFrame(ws.input.buffered(), ws.input.buffer.len)) {
            .incomplete => return .ok,
            .too_big => return .browser_dead,
            .pong => |n| {
                ws.input.toss(n);
                live.sawInbound();
                continue;
            },
            .ready => {},
        }
        const msg = ws.readSmallMessage() catch return .browser_dead;
        live.sawInbound();
        switch (msg.opcode) {
            // RFC 6455: a pong carrying the ping's payload back. Ignoring
            // pings meant a browser heartbeat could not tell a wedged hub
            // from a busy one — and while dialing, "still here" is a live
            // answer worth giving.
            .ping => ws.writeMessage(msg.data, .pong) catch return .browser_dead,
            .binary, .text => {
                // No transport is the dial loop's case: nothing to carry
                // it yet, and the browser re-attaches on `up` anyway.
                const t = transport orelse continue;
                if (parseFrameMessage(msg.data)) |parsed| {
                    t.writeFrame(parsed.t, parsed.payload) catch return .transport_dead;
                } else |_| {
                    // Not a frame message: dropped, deliberately — the
                    // browser side is ours, so this is a bug's signature,
                    // and killing every tile for it would make the page
                    // unusable exactly when debugging.
                }
            },
            else => {},
        }
    }
}

/// False means the tile is over. Close is idempotent, so a failed
/// re-dial leaves the caller's deferred close with nothing to do.
fn redial(
    alloc: std.mem.Allocator,
    transport: *client.Transport,
    target: client.Target,
    ws: *std.http.Server.WebSocket,
    ws_fd: std.posix.fd_t,
    live: *Liveness,
    dial: *Dial,
) bool {
    // A dial that never saw a grid was REFUSED, and a refusal is a state: the
    // next dial opens on its first try and the page walks back into the same no.
    // A dial that DID see a grid was torn, and a tear still heals at once.
    if (!dial.saw_grid) dial.spin_ms = client.nextBackoffMs(dial.spin_ms);
    // The per-dial reset belongs here, not at the three call sites: one of
    // them forgot, and a forgotten reset stays silent until a torn
    // transport turns the next refusal into an ending.
    dial.onRedial();
    ws.writeMessage(controlMessage(.reconnecting), .binary) catch return false;
    transport.close();
    transport.* = dialLoop(alloc, target, ws, ws_fd, live, dial.spin_ms) orelse return false;
    ws.writeMessage(controlMessage(.up), .binary) catch return false;
    return true;
}

/// The dial's own state, one value rather than two locals so that
/// `redial` can own the per-dial reset.
const Dial = struct {
    // The only per-DIAL flag: whether this dial ever reached a grid. A
    // dial that did not was REFUSED, which is a state rather than an
    // event, and the backoff below is what turns re-dialling into a poll
    // instead of a spin.
    saw_grid: bool = false,
    // How fast the refusal loop may re-dial. Carried across dials and
    // cleared by a grid, which is the only evidence the refusal is over.
    spin_ms: u64 = 0,

    // Only `saw_grid` resets: `spin_ms` outlives the connection ON
    // PURPOSE, because every refusal closes it — `serviceObserver` answers
    // an unseated attach with exit_status and then `dropObserver`, so a
    // counter cleared on the close bounds nothing at all.
    fn onRedial(self: *Dial) void {
        self.saw_grid = false;
    }

    fn onFrame(self: *Dial, t: proto.MsgType) void {
        if (t == .snapshot or t == .delta) {
            self.saw_grid = true;
            self.spin_ms = 0;
        }
    }
};

/// One thread per tile: Transport.readFrame's blocking read is
/// correct here. The hub reconnects; the browser re-attaches.
pub fn pumpTile(
    alloc: std.mem.Allocator,
    ws: *std.http.Server.WebSocket,
    ws_fd: std.posix.fd_t,
    target_in: client.Target,
) void {
    var target = target_in;
    // No terminal to spam and a control channel that already narrates, so the
    // CLI's one fallback line is quieted. It also keeps a browser from starting
    // daemons: a hub tile redials for as long as the page is open.
    if (target == .hand) target.hand.asked = false;

    var dial: Dial = .{};

    var live = Liveness.init();
    ws.writeMessage(controlMessage(.connecting), .binary) catch return;
    var transport = dialLoop(alloc, target, ws, ws_fd, &live, 0) orelse return;
    defer transport.close();
    // `up` is not decoration: the browser re-attaches when it reads this, and
    // the hub never re-attaches on its behalf. mux.js's ENV_CONTROL handler is
    // the other half of that contract.
    ws.writeMessage(controlMessage(.up), .binary) catch return;

    outer: while (true) {
        var fds = [_]std.posix.pollfd{
            .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
            .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 },
            // ssh's stderr when this tile stayed on the pipe. Nothing here
            // shows it, but an unread pipe fills at 64k and takes ssh's
            // whole session down with it — the pump's reason, verbatim.
            .{
                .fd = transport.errFd() orelse -1,
                .events = std.posix.POLL.IN,
                .revents = 0,
            },
        };
        _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return;
        transport.service();
        if (fds[2].revents != 0) transport.drainErr();

        // Daemon → browser. The `.quic` disjunct is the CLI's lesson
        // verbatim: there frames can arrive from the stream layer with
        // the socket never going readable.
        if (fds[0].revents != 0 or transport.link == .quic) frames: {
            while (true) {
                const incoming = transport.readFrame(alloc) catch return;
                const frame = switch (incoming) {
                    .frame => |f| f,
                    .incomplete => break :frames,
                    .closed => {
                        if (!redial(alloc, &transport, target, ws, ws_fd, &live, &dial)) return;
                        // `fds[1].revents` describes a socket state from BEFORE
                        // the re-dial, and `dialLoop` may have eaten the message
                        // it described. Nothing is stranded by re-polling: the
                        // drain below runs on buffered bytes regardless.
                        continue :outer;
                    },
                };
                defer frame.deinit(alloc);
                dial.onFrame(frame.type);
                const hdr = proto.encodeHeader(frame.type, frame.payload.len);
                var vecs = [_][]const u8{ &.{env_frame}, &hdr, frame.payload };
                ws.writeMessageVec(&vecs, .binary) catch return;
                // Only the socket link carries the guarantee that one
                // readable event is one frame; QUIC may have buffered
                // more, so drain until .incomplete.
                //
                // The socket arm breaks after one frame where wall_pump.zig
                // re-polls at a zero timeout to drain the whole burst. That
                // divergence is correct: the CLI judges KEYS between frames
                // and misreads a wheel notch it sees before the `term_modes`
                // trailing it, so it must not stop mid-burst. The hub judges
                // nothing — it re-frames onto the WebSocket in order — and a
                // burst still pending makes the next poll return immediately.
                if (transport.link != .quic) break :frames;
            }
        }

        // Browser → daemon. One WS message is one frame, and the drain is
        // unconditional: readiness decides only whether to fill.
        switch (drainBrowser(ws, &live, fds[1].revents != 0, &transport)) {
            .ok => {},
            .browser_dead => return,
            .transport_dead => {
                if (!redial(alloc, &transport, target, ws, ws_fd, &live, &dial)) return;
                continue :outer; // same stale-revents reason as above
            },
        }

        // Is anyone still there? A closed tab arrives as a close frame or EOF,
        // but a slept laptop or a dropped `ssh -L` leaves the socket half-open
        // and silent forever. Best-effort: it is measured between passes.
        if (!live.tick(ws)) return;
    }
}

/// Null when the browser hangs up. A message arriving mid-backoff is
/// dropped; the browser re-attaches on `up` anyway.
fn dialLoop(
    alloc: std.mem.Allocator,
    target: client.Target,
    ws: *std.http.Server.WebSocket,
    ws_fd: std.posix.fd_t,
    live: *Liveness,
    /// Where the wait resumes. Zero is the ordinary dial, so the wait below is
    /// skipped on the first pass. Nonzero is `redial` saying the last dial was
    /// REFUSED: the target is reachable, so retrying at once buys the same no.
    backoff_start_ms: u64,
) ?client.Transport {
    var backoff_ms: u64 = backoff_start_ms;
    while (true) {
        if (backoff_ms != 0) {
            // The backoff doubles as the WS liveness window. It caps at 2s
            // against a 30s ping interval, so the tick below is never more
            // than one backoff late.
            var fds = [_]std.posix.pollfd{
                .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 },
            };
            // revents is zero-initialised above and poll clears it on a
            // timeout, so the count it returns says nothing the flags do not.
            _ = std.posix.poll(&fds, @intCast(backoff_ms)) catch return null;
            // The pump's drain exactly, minus a transport to forward to: a
            // partial frame would block this loop too, and this one has no
            // second leg to notice. A close frame still ends the tile, which
            // is the whole point of watching the socket during a backoff.
            switch (drainBrowser(ws, live, fds[0].revents != 0, null)) {
                .ok => {},
                .browser_dead => return null,
                .transport_dead => unreachable, // no transport was handed in
            }
            // Same check, same clock as the pump's: a browser that died
            // during the outage is reaped here rather than after it, and one
            // that is merely waiting answers the ping and lives.
            if (!live.tick(ws)) return null;
        }
        // No reason kept: the page has a control channel that already says
        // `connecting`, and nothing in the browser paints an ssh sentence.
        if (client.Transport.open(alloc, target, null, -1, null)) |t| {
            return t;
        } else |_| {}
        backoff_ms = client.nextBackoffMs(backoff_ms);
    }
}

/// Labels are argv, not hostile input, but a path with a quote in it must not
/// break the page. Deliberately NOT `mux a`'s `jsonEscape`: this sends every
/// control byte to `\u00XX`, one rule with no table to get wrong.
fn appendJsonString(alloc: std.mem.Allocator, out: *std.ArrayList(u8), s: []const u8) !void {
    try out.append(alloc, '"');
    for (s) |c| switch (c) {
        '"' => try out.appendSlice(alloc, "\\\""),
        '\\' => try out.appendSlice(alloc, "\\\\"),
        0x00...0x1f => try out.print(alloc, "\\u{x:0>4}", .{c}),
        else => try out.append(alloc, c),
    };
    try out.append(alloc, '"');
}

/// Static requests loop for keep-alive; a WS upgrade takes the connection
/// and never returns to HTTP.
pub fn serveConn(
    alloc: std.mem.Allocator,
    stream: std.net.Stream,
    port: u16,
    hub: *Hub,
    assets: Assets,
) void {
    defer stream.close();
    // The ONE buffer: max HTTP header and max inbound WS message alike.
    var in_buf: [ws_buffer_len]u8 = undefined;
    var out_buf: [8 * 1024]u8 = undefined;
    var conn_reader = stream.reader(&in_buf);
    var conn_writer = stream.writer(&out_buf);
    var server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);

    while (true) {
        var req = server.receiveHead() catch return;
        // `path` BORROWS the head buffer — it is a slice, not a copy — so
        // every use of it below must happen before a body reader touches
        // that buffer (readerExpectContinue → readerExpectNone reuses it).
        // The mutating verbs read a body; they must read `path` first.
        const path = req.head.target;
        const method = req.head.method;

        var origin: ?[]const u8 = null;
        var it = req.iterateHeaders();
        while (it.next()) |h| {
            if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value;
        }

        // std's `discardBody` ASSERTS that a kept-alive request whose method may
        // carry a body declared a length, and `curl -X POST` sends neither —
        // which aborted the whole hub. Answering and closing covers every route
        // below; the page's own POST carries `content-length: 0` and keeps alive.
        const keep = !(method.requestHasBody() and
            req.head.content_length == null and
            req.head.transfer_encoding == .none);

        if (wsTileId(path)) |id| {
            // Origin BEFORE upgrade, always: the refusal must happen while
            // this is still HTTP, so a hostile page gets a 403 and never a
            // socket. std's upgradeRequested does not look at Origin.
            if (!originAllowed(origin, port)) {
                req.respond("forbidden\n", .{ .status = .forbidden, .keep_alive = keep }) catch {};
                return;
            }
            const key = switch (req.upgradeRequested()) {
                .websocket => |k| k orelse {
                    req.respond("bad upgrade\n", .{ .status = .bad_request, .keep_alive = keep }) catch {};
                    return;
                },
                else => {
                    req.respond("websocket only\n", .{ .status = .bad_request, .keep_alive = keep }) catch {};
                    return;
                },
            };
            // The pump owns a COPY of the target: the tile (and its arena)
            // may be removed mid-pump, and the shutdown() that kicks us out
            // must never race a free of our own strings.
            var pump_arena = std.heap.ArenaAllocator.init(alloc);
            defer pump_arena.deinit();
            // Checkout BEFORE the upgrade, so a missing id is answered in HTTP
            // — a 404 the page can read, not a 101 and a silent close.
            const ws_fd = stream.handle;
            const checked = hub.checkoutTile(id) catch |err| switch (err) {
                // Removed between the page's GET and this dial: the browser
                // refetches /tiles and stops asking for it.
                error.UnknownId => {
                    req.respond("no such tile\n", .{ .status = .not_found, .keep_alive = keep }) catch {};
                    return;
                },
            };
            var ws = req.respondWebSocket(.{ .key = key }) catch return;
            ws.flush() catch return;
            pumpTile(alloc, &ws, ws_fd, checked.target);
            return;
        }

        if (std.mem.eql(u8, path, "/tiles") and method == .GET) {
            const json = hub.json(alloc) catch return;
            defer alloc.free(json);
            req.respond(json, .{
                .keep_alive = keep,
                .extra_headers = &.{
                    .{ .name = "content-type", .value = "application/json" },
                    .{ .name = "cache-control", .value = "no-cache" },
                },
            }) catch return;
            continue;
        }

        // The one mutation left, Origin-gated: a text/plain POST is a CSRF
        // "simple request" any page can fire at localhost with no preflight.
        // GET /tiles stays ungated — no CORS headers, so a hostile page can make
        // the request and never read the answer. EXACT match only, or
        // `/tilesgarbage` routes into a mutation.
        const tiles_root = std.mem.eql(u8, path, "/tiles");
        const tile_id_suffix = if (std.mem.startsWith(u8, path, "/tiles/") and path.len > "/tiles/".len)
            path["/tiles/".len..]
        else
            null;
        if (tiles_root or tile_id_suffix != null) {
            // Every answer here ends the connection and SAYS so: `keep` would be
            // true for the page's own `content-length: 0` POST, promising a
            // connection this route then closes anyway.
            if (!originAllowed(origin, port)) {
                req.respond("forbidden\n", .{ .status = .forbidden, .keep_alive = false }) catch {};
                return;
            }
            // The page authors nothing about the wall as a whole: panes come
            // from the layout file, and the one door onto it is `+` on a
            // tile, which is POST /tiles/<id>. POST /tiles, PUT and DELETE
            // name nothing this hub can do — one answer for all of them.
            const id_str = tile_id_suffix orelse {
                req.respond("bad method\n", .{ .status = .method_not_allowed, .keep_alive = false }) catch {};
                return;
            };
            if (method != .POST) {
                req.respond("bad method\n", .{ .status = .method_not_allowed, .keep_alive = false }) catch {};
                return;
            }
            const id = std.fmt.parseInt(u32, id_str, 10) catch {
                req.respond("not found\n", .{ .status = .not_found, .keep_alive = false }) catch {};
                return;
            };
            const name = hub.spawn(id) catch |err| {
                const status: std.http.Status, const msg: []const u8 = switch (err) {
                    error.UnknownId => .{ .not_found, "not found" },
                    // The wall already has this pane: a second `+` inside one
                    // poll interval reads the same session list and asks for
                    // the same name. Nothing was born and nothing was
                    // written, so the page refetches and finds the tile the
                    // first press made.
                    error.Duplicate => .{ .conflict, "duplicate" },
                    // The daemon's no, or a box that did not answer: the hub
                    // is up and the birth is not, which is a gateway failure
                    // and not this server's own.
                    else => .{ .bad_gateway, @errorName(err) },
                };
                var buf: [64]u8 = undefined;
                const body = std.fmt.bufPrint(&buf, "{s}\n", .{msg}) catch msg;
                req.respond(body, .{ .status = status, .keep_alive = false }) catch {};
                return;
            };
            var buf: [64]u8 = undefined;
            const resp = std.fmt.bufPrint(&buf, "{{\"session\":\"{s}\"}}", .{name.slice()}) catch unreachable;
            req.respond(resp, .{ .status = .created, .keep_alive = false, .extra_headers = &.{
                .{ .name = "content-type", .value = "application/json" },
            } }) catch {};
            return;
        }

        if (route(assets, path)) |asset| {
            req.respond(asset.body, .{
                .keep_alive = keep,
                .extra_headers = &.{
                    .{ .name = "content-type", .value = asset.content_type },
                    // `no-cache` is revalidate-every-time, not don't-store:
                    // a reload during development must pick up an edited
                    // page, and the assets are local and tiny, so the
                    // round trip costs nothing worth saving.
                    .{ .name = "cache-control", .value = "no-cache" },
                },
            }) catch return;
        } else {
            req.respond("not found\n", .{ .status = .not_found, .keep_alive = keep }) catch return;
        }
    }
}

test "dial: a refused dial charges the backoff and a grid clears it" {
    // The spin this bounds: every refusal closes the connection, so the
    // re-dial opens on its first try and the page attaches into the same
    // no. Carried across dials, or the tile connect/attach/close-loops at
    // round-trip speed for as long as the daemon keeps refusing.
    var d: Dial = .{};
    var charged: u64 = 0;
    for (0..6) |_| {
        if (!d.saw_grid) d.spin_ms = client.nextBackoffMs(d.spin_ms);
        charged = d.spin_ms;
        d.onRedial();
        d.onFrame(.exit_status);
    }
    try std.testing.expect(charged >= 2_000);

    // A grid is the only evidence the refusal is over, and a tear after
    // one heals at full speed again.
    d.onFrame(.snapshot);
    try std.testing.expectEqual(@as(u64, 0), d.spin_ms);
    // A grid seen on the PREVIOUS dial says nothing about this one: the
    // flag is per-dial, and a dial that kept it would read the refusal
    // after a tear as a healthy connection and never back off.
    d.onRedial();
    try std.testing.expect(!d.saw_grid);
}

test "origin: exactly our two spellings pass, everything else refuses" {
    const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{
        .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true },
        .{ .origin = "http://localhost:7681", .port = 7681, .want = true },
        .{ .origin = "http://127.0.0.1:41234", .port = 41234, .want = true },
        // The port is part of the identity.
        .{ .origin = "http://127.0.0.1:7682", .port = 7681, .want = false },
        // https is a different origin even on the right host+port.
        .{ .origin = "https://127.0.0.1:7681", .port = 7681, .want = false },
        // Any other page, including one that merely CONTAINS ours.
        .{ .origin = "http://evil.example", .port = 7681, .want = false },
        .{ .origin = "http://127.0.0.1:7681.evil.example", .port = 7681, .want = false },
        .{ .origin = "http://[::1]:7681", .port = 7681, .want = false },
        // A missing Origin header is a refusal, not a shrug: browsers always
        // send it on cross-origin dials, and "absent means yes" is how localhost
        // servers get owned.
        .{ .origin = null, .port = 7681, .want = false },
        .{ .origin = "", .port = 7681, .want = false },
    };
    for (cases) |c| {
        try std.testing.expectEqual(c.want, originAllowed(c.origin, c.port));
    }
}

test "listenLocal: a port a live hub holds is refused, and its flags are REUSEADDR without REUSEPORT" {
    // Port 0 for the first bind, so the port under test is one the kernel
    // just said was free rather than a number this file hopes nothing on the
    // box is using. The second bind then asks for that exact port: two hubs,
    // one `--port`, which is the bug this refusal exists for.
    const port = port: {
        var first = try listenLocal(0);
        defer first.deinit();
        const p = first.listen_address.getPort();
        try std.testing.expect(p != 0);
        try std.testing.expectError(error.AddressInUse, listenLocal(p));

        // Asked of the DESCRIPTOR, not read off the call that made it — the
        // two flags are the whole of this fix, and the call that used to set
        // them set both from one `reuse_address = true` (serve.zig's cloexec
        // test is the same shape, for the same reason: a default going
        // quietly wrong is invisible at the call site).
        var v: c_int = undefined;
        try std.posix.getsockopt(
            first.stream.handle,
            std.posix.SOL.SOCKET,
            std.posix.SO.REUSEADDR,
            std.mem.asBytes(&v),
        );
        try std.testing.expect(v != 0);
        try std.posix.getsockopt(
            first.stream.handle,
            std.posix.SOL.SOCKET,
            std.posix.SO.REUSEPORT,
            std.mem.asBytes(&v),
        );
        try std.testing.expectEqual(@as(c_int, 0), v);
        break :port p;
    };

    // The hub that held it is gone, so the port is takeable again: the
    // refusal is about a LIVE listener, not about the number having been
    // used once. Without SO_REUSEADDR this is where a restart would start
    // failing as soon as a browser had ever connected.
    var second = try listenLocal(port);
    defer second.deinit();
    try std.testing.expectEqual(port, second.listen_address.getPort());
}

test "ws path by id: parses, no range opinion" {
    try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0"));
    try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41"));
    try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/"));
    try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/x"));
    try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/-1"));
    try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0"));
}

test "hub: tiles are the layout's leaves in order; a list names born elsewhere add nothing; a missing session reads gone and comes back" {
    const alloc = std.testing.allocator;
    // TWO hosts, off-origin sessions, and a leaf order that is NOT host
    // order: a fixture holding either constant is blind to the dimension it
    // holds, and grouping by daemon is exactly the old wall this replaces.
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
        .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
    };
    const leaves = [_]Leaf{ .{ .host = 1, .session = "0" }, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" } };
    var hub = try Hub.init(alloc, &specs, &leaves);
    defer hub.deinit();
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    try std.testing.expectEqual(@as(u32, 0), hub.tiles.items[0].id);
    try std.testing.expectEqualStrings("box", specs[hub.tiles.items[0].host].spelling);

    // A session somebody else started on a listed daemon is not a pane:
    // the file is the wall, and only a write to it adds one.
    hub.applyList(0, "0\nb\nstranger\n", true);
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);

    hub.applyList(0, "0\n", true);
    hub.applyList(0, "0\n", true);
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state);
    hub.applyList(0, "0\nb\n", true);
    try std.testing.expect(hub.tiles.items[2].state != .gone);

    const json = try hub.json(alloc);
    defer alloc.free(json);
    try std.testing.expectEqualStrings(
        \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"0","state":"connecting"},{"id":2,"label":"--sock /tmp/a","session":"b","state":"connecting"}]
    , json);
}

test "hub: a pane missing from one list keeps its grade, missing from two reads gone, and an unreachable answer grades nothing" {
    const alloc = std.testing.allocator;
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
        .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
    };
    const leaves = [_]Leaf{
        .{ .host = 0, .session = "0" },
        .{ .host = 0, .session = "b" },
        .{ .host = 1, .session = "c" },
    };
    var hub = try Hub.init(alloc, &specs, &leaves);
    defer hub.deinit();

    // One miss is the poll's own race with a daemon mid-answer, not an exit.
    hub.applyList(0, "0\n", true);
    try std.testing.expect(hub.tiles.items[1].state != .gone);
    // A blip grades nothing AND clears nothing: an unreachable answer is no
    // evidence either way, so the second miss still has to be a LIST.
    hub.applyList(0, "", false);
    try std.testing.expect(hub.tiles.items[1].state != .gone);
    hub.applyList(0, "0\n", true);
    try std.testing.expectEqual(TileState.gone, hub.tiles.items[1].state);
    // Nothing left the wall. A gone pane is still a pane the user authored:
    // only a write to the layout file takes one off.
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    // Three of host 0's lists have now missed host 1's pane, and none of
    // them may grade it: two daemons may have a session of the same name.
    try std.testing.expect(hub.tiles.items[2].state != .gone);

    // A name that comes back clears the grace, so the next miss gets its own.
    hub.applyList(1, "c\n", true);
    hub.applyList(1, "0\n", true);
    hub.applyList(1, "c\n", true);
    hub.applyList(1, "0\n", true);
    try std.testing.expect(hub.tiles.items[2].state != .gone);
    hub.applyList(1, "0\n", true);
    try std.testing.expectEqual(TileState.gone, hub.tiles.items[2].state);
    // And host 1's lists never touched host 0's panes.
    try std.testing.expect(hub.tiles.items[0].state != .gone);
}

test "hub: json is the layout's leaf order, not host order; label is the host spelling and the grade rides beside it" {
    const alloc = std.testing.allocator;
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
        .{ .spelling = "box", .target = .{ .sock = "/tmp/b" }, .poll_target = .{ .sock = "/tmp/b" } },
    };
    // A wall that interleaves its daemons is the ordinary one, and the old
    // hub regrouped every list by host — the one order this must not be.
    const leaves = [_]Leaf{
        .{ .host = 1, .session = "0" },
        .{ .host = 0, .session = "b" },
        .{ .host = 1, .session = "w" },
    };
    var hub = try Hub.init(alloc, &specs, &leaves);
    defer hub.deinit();

    const j = try hub.json(alloc);
    defer alloc.free(j);
    try std.testing.expectEqualStrings(
        \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"b","state":"connecting"},{"id":2,"label":"box","session":"w","state":"connecting"}]
    , j);

    // A grade reaches the page on the pane it belongs to, and the pane
    // keeps its place in the wall while it wears it.
    hub.applyList(1, "0\n", true);
    hub.applyList(1, "0\n", true);
    const graded = try hub.json(alloc);
    defer alloc.free(graded);
    try std.testing.expectEqualStrings(
        \\[{"id":0,"label":"box","session":"0","state":"connecting"},{"id":1,"label":"--sock /tmp/a","session":"b","state":"connecting"},{"id":2,"label":"box","session":"w","state":"gone"}]
    , graded);

    // A layout with no leaf is a shape too, and the only one a hand-rolled
    // encoder can emit nothing at all for.
    var bare = try Hub.init(alloc, &specs, &[_]Leaf{});
    defer bare.deinit();
    const empty = try bare.json(alloc);
    defer alloc.free(empty);
    try std.testing.expectEqualStrings("[]", empty);
}

/// A daemon stand-in for the spawn test: accepts one connection, records
/// the attach it is sent, and answers it the way a real daemon does — the
/// pty mode byte first, then the snapshot that IS the birth. The `client`
/// module's own `BirthFake` is private to it, and this file has no import
/// edge that would reach one; what the daemon's half must be is pinned
/// against a real daemon in the server tests.
const SpawnFake = struct {
    listener: std.net.Server,
    attach_name: [proto.session_name_max]u8 = undefined,
    attach_name_len: usize = 0,
    /// How many attaches this daemon was asked for. The whole point of a
    /// fake that can serve MORE connections than the test expects: a guard
    /// that failed to hold shows up as a second birth here, not as a hang.
    attaches: usize = 0,
    /// Connections to serve before the thread returns. A test that expects
    /// fewer than this unblocks the last accept with a dial of its own.
    conns: usize = 1,

    fn serve(self: *SpawnFake) void {
        const alloc = std.testing.allocator;
        var served: usize = 0;
        while (served < self.conns) : (served += 1) {
            const conn = self.listener.accept() catch return;
            defer conn.stream.close();
            // One birth is an attach and a detach, and nothing else.
            var frames: usize = 0;
            while (frames < 2) : (frames += 1) {
                const f = (proto.readFrame(alloc, conn.stream.handle) catch break) orelse break;
                defer f.deinit(alloc);
                if (f.type != .attach) continue;
                const req = proto.decodeAttach(f.payload) catch break;
                self.attaches += 1;
                self.attach_name_len = req.name.len;
                @memcpy(self.attach_name[0..req.name.len], req.name);
                proto.writeFrame(conn.stream.handle, .pty_mode, &.{0}) catch break;
                proto.writeFrame(conn.stream.handle, .snapshot, &.{0}) catch break;
            }
        }
    }
};

test "hub: a spawn writes the pane into the layout beside its own, and the tile is on the wall before any poll" {
    const alloc = std.testing.allocator;
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var sb: [64]u8 = undefined;
    const sock = try std.fmt.bufPrint(&sb, "{s}/d.sock", .{tmp.path()});
    var spb: [64]u8 = undefined;
    const sock_spelling = try std.fmt.bufPrint(&spb, "--sock {s}", .{sock});
    var lb: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&lb, "{s}/layout", .{tmp.path()});

    // Two hosts, and the birth happens on the SECOND: a fixture whose only
    // daemon is index 0 cannot tell a host index from a zero.
    const specs = [_]client.HostSpec{
        .{ .spelling = "box", .target = .{ .sock = "/tmp/never" }, .poll_target = .{ .sock = "/tmp/never" } },
        .{ .spelling = sock_spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
    };
    try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
    try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 1, .session = "w" });
    const leaves = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, leaves);

    var hub = try Hub.init(alloc, &specs, leaves);
    defer hub.deinit();
    hub.layout_path = path;

    // The daemon's last list, as the poller left it: it already has `0`, so
    // the birth takes `1`. Off-origin on purpose — `proto.wireName` sends
    // the DEFAULT session nameless, so a fixture that birthed `0` could not
    // see the name reach the wire at all.
    const listed = "0\n";
    @memcpy(hub.hosts[1].poll.list[0..listed.len], listed);
    hub.hosts[1].poll.list_len = listed.len;

    const addr = try std.net.Address.initUnix(sock);
    var fake = SpawnFake{ .listener = try addr.listen(.{}) };
    defer fake.listener.deinit();
    const th = try std.Thread.spawn(.{}, SpawnFake.serve, .{&fake});
    const name = try hub.spawn(1);
    th.join();

    // The daemon named the session, off its own list — and that session `0`
    // is NOT a pane: only the leaf the spawn just wrote is.
    try std.testing.expectEqualStrings("1", name.slice());
    try std.testing.expectEqualStrings("1", fake.attach_name[0..fake.attach_name_len]);

    // The tile is on the wall NOW: the poll is what grades panes, and a
    // browser refetching before the next second must see what it just made.
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    try std.testing.expectEqual(@as(usize, 1), hub.tiles.items[2].host);
    try std.testing.expectEqualStrings("1", hub.tiles.items[2].session);
    try std.testing.expectEqual(@as(u32, 2), hub.tiles.items[2].id);

    // And the FILE has it, beside the pane it was born from, so the next
    // terminal wall opens on the same three panes in the same order.
    const after = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, after);
    try std.testing.expectEqual(@as(usize, 3), after.len);
    try std.testing.expectEqual(@as(usize, 0), after[0].host);
    try std.testing.expectEqualStrings("w", after[1].session);
    try std.testing.expectEqual(@as(usize, 1), after[2].host);
    try std.testing.expectEqualStrings("1", after[2].session);
}

test "readLeaves and appendLeaf: the layout round-trips through the hub, and a bad file is refused with its line" {
    const alloc = std.testing.allocator;
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var pb: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()});
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
    };
    // No file yet: the first append makes a one-leaf tree.
    try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
    try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 0, .session = "b" });
    const leaves = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, leaves);
    try std.testing.expectEqual(@as(usize, 2), leaves.len);
    try std.testing.expectEqualStrings("0", leaves[0].session);
    try std.testing.expectEqualStrings("b", leaves[1].session);

    // A missing file is an empty wall, not a refusal: nothing was authored.
    var mb: [64]u8 = undefined;
    const missing = try std.fmt.bufPrint(&mb, "{s}/nothing", .{tmp.path()});
    const none = try readLeaves(alloc, missing, &specs);
    defer freeLeaves(alloc, none);
    try std.testing.expectEqual(@as(usize, 0), none.len);

    // A host the hosts file does not list refuses the FILE. The layout is a
    // match key, never an address: nothing here may be resurrected.
    try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 nowhere#0\n" });
    try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
    // A leaf with no session, and text that is not a layout at all.
    try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "mux-layout 1\nleaf 0 --sock /tmp/a\n" });
    try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
    try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "box\n--sock /tmp/a\n" });
    try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
    // A repeated leaf, the way `wall_layout.seedLayout` refuses one: a host
    // names a session once, so a second pane on the same (host, session)
    // could never bind, and a file one front served and the other refused
    // would cost whoever opened a terminal wall next their whole wall.
    try std.fs.cwd().writeFile(.{
        .sub_path = path,
        .data = "mux-layout 1\nbeside 0\n leaf 1 --sock /tmp/a#b\n leaf 1 --sock /tmp/a#b\n",
    });
    try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
}

test "hub: two + inside one poll interval make ONE session and ONE leaf; the second is a Duplicate" {
    const alloc = std.testing.allocator;
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var sb: [64]u8 = undefined;
    const sock = try std.fmt.bufPrint(&sb, "{s}/d.sock", .{tmp.path()});
    var spb: [64]u8 = undefined;
    const sock_spelling = try std.fmt.bufPrint(&spb, "--sock {s}", .{sock});
    var lb: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&lb, "{s}/layout", .{tmp.path()});

    // Two hosts, the births on the SECOND: a fixture whose only daemon is
    // index 0 cannot tell a host index from a zero.
    const specs = [_]client.HostSpec{
        .{ .spelling = "box", .target = .{ .sock = "/tmp/never" }, .poll_target = .{ .sock = "/tmp/never" } },
        .{ .spelling = sock_spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
    };
    try appendLeaf(alloc, path, &specs, null, .{ .host = 0, .session = "0" });
    try appendLeaf(alloc, path, &specs, .{ .host = 0, .session = "0" }, .{ .host = 1, .session = "w" });
    const leaves = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, leaves);

    var hub = try Hub.init(alloc, &specs, leaves);
    defer hub.deinit();
    hub.layout_path = path;

    // The poller's last answer, and it does NOT move for the length of this
    // test — which is exactly the second a user gets two clicks into. Both
    // presses therefore read the same list and pick the same next free name.
    const listed = "0\n";
    @memcpy(hub.hosts[1].poll.list[0..listed.len], listed);
    hub.hosts[1].poll.list_len = listed.len;

    const addr = try std.net.Address.initUnix(sock);
    // Willing to serve TWO births: a fake that could only serve one would
    // pass this test by refusing the second birth itself.
    var fake = SpawnFake{ .listener = try addr.listen(.{}), .conns = 2 };
    defer fake.listener.deinit();
    const th = try std.Thread.spawn(.{}, SpawnFake.serve, .{&fake});

    const first = try hub.spawn(1);
    try std.testing.expectEqualStrings("1", first.slice());
    // The second press, before any poll came back. The daemon would answer
    // it by ATTACHING to the session the first press made — a live session
    // and a second leaf spelling the same pane, which is a file
    // `wall_layout.seedLayout` refuses whole.
    try std.testing.expectError(error.Duplicate, hub.spawn(1));

    // Release the fake's second accept, which nothing dialled, and join.
    const nudge = std.net.connectUnixSocket(sock) catch null;
    if (nudge) |n| n.close();
    th.join();
    try std.testing.expectEqual(@as(usize, 1), fake.attaches);

    // One new tile, not two.
    try std.testing.expectEqual(@as(usize, 3), hub.tiles.items.len);
    try std.testing.expectEqualStrings("1", hub.tiles.items[2].session);

    // And the FILE names the pane once. Read back through the same door the
    // next `mux web` start-up uses, which is also the door that refuses a
    // repeated leaf outright.
    const after = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, after);
    try std.testing.expectEqual(@as(usize, 3), after.len);
    var seen: usize = 0;
    for (after) |l| {
        if (l.host == 1 and std.mem.eql(u8, l.session, "1")) seen += 1;
    }
    try std.testing.expectEqual(@as(usize, 1), seen);

    // The guard is the file's, not the tile list's: `appendLeaf` refuses a
    // pane the file already names whoever asks.
    try std.testing.expectError(
        error.Duplicate,
        appendLeaf(alloc, path, &specs, after[0], .{ .host = 1, .session = "w" }),
    );
}

test "hub: a + on a full wall is refused BEFORE the daemon is asked, so no session is born onto a file that cannot hold it" {
    const alloc = std.testing.allocator;
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var sb: [64]u8 = undefined;
    const sock = try std.fmt.bufPrint(&sb, "{s}/d.sock", .{tmp.path()});
    var spb: [64]u8 = undefined;
    const sock_spelling = try std.fmt.bufPrint(&spb, "--sock {s}", .{sock});
    var lb: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&lb, "{s}/layout", .{tmp.path()});

    const specs = [_]client.HostSpec{
        .{ .spelling = "box", .target = .{ .sock = "/tmp/never" }, .poll_target = .{ .sock = "/tmp/never" } },
        .{ .spelling = sock_spelling, .target = .{ .sock = sock }, .poll_target = .{ .sock = sock } },
    };

    // A FULL wall: one pane on the dead host and the rest on the live one,
    // `layout.max_leaves` in all.
    var buf: std.ArrayListUnmanaged(u8) = .{};
    defer buf.deinit(alloc);
    try buf.appendSlice(alloc, "mux-layout 1\nbeside 0\n");
    try buf.appendSlice(alloc, " leaf 1 box#0\n");
    for (1..layout.max_leaves) |i| try buf.print(alloc, " leaf 1 {s}#{d}\n", .{ sock_spelling, i });
    try hosts.saveBytes(path, buf.items);

    const leaves = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, leaves);
    try std.testing.expectEqual(layout.max_leaves, leaves.len);
    var hub = try Hub.init(alloc, &specs, leaves);
    defer hub.deinit();
    hub.layout_path = path;

    // A real listening socket the birth WOULD reach: the refusal has to be
    // the file's, not a dial that failed for want of a daemon.
    const addr = try std.net.Address.initUnix(sock);
    var listener = try addr.listen(.{});
    defer listener.deinit();

    // Tile id 1 is the first pane on the live host.
    try std.testing.expectEqual(@as(usize, 1), hub.tiles.items[1].host);
    try std.testing.expectError(error.WallFull, hub.spawn(1));

    // The OS's answer, not the hub's: a listening socket goes readable the
    // instant a connect lands on it, and nothing landed. A `+` that dialled
    // first would have left a shell running on a wall that can never show it.
    var fds = [_]std.posix.pollfd{
        .{ .fd = listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 },
    };
    _ = try std.posix.poll(&fds, 0);
    try std.testing.expectEqual(@as(i16, 0), fds[0].revents);

    // And the wall is untouched: no tile, and the file still holds exactly
    // what it held.
    try std.testing.expectEqual(layout.max_leaves, hub.tiles.items.len);
    const after = try std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024);
    defer alloc.free(after);
    try std.testing.expectEqualStrings(buf.items, after);

    // The same refusal for a file that is not a layout at all: the daemon is
    // never asked, because the pane it would make has nowhere to be written.
    try std.fs.cwd().writeFile(.{ .sub_path = path, .data = "not a layout\n" });
    try std.testing.expectError(error.BadLayout, hub.spawn(1));
    _ = try std.posix.poll(&fds, 0);
    try std.testing.expectEqual(@as(i16, 0), fds[0].revents);
}

test "readLeaves and appendLeaf stop at the wall's ceiling, so the hub cannot serve a wall no terminal could open" {
    const alloc = std.testing.allocator;
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    var pb: [64]u8 = undefined;
    const path = try std.fmt.bufPrint(&pb, "{s}/layout", .{tmp.path()});
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
    };

    // A flat tree of N panes, written by hand: `appendLeaf` refuses to build
    // the over-full one, which is the whole point of pinning the READ too —
    // only a hand-edited file can be that long, and a user hand-edits this.
    const flat = struct {
        fn write(a: std.mem.Allocator, p: []const u8, n: usize) !void {
            var buf: std.ArrayListUnmanaged(u8) = .{};
            defer buf.deinit(a);
            try buf.appendSlice(a, "mux-layout 1\nbeside 0\n");
            for (0..n) |i| try buf.print(a, " leaf 1 --sock /tmp/a#{d}\n", .{i});
            try hosts.saveBytes(p, buf.items);
        }
    };

    try flat.write(alloc, path, layout.max_leaves);
    const full = try readLeaves(alloc, path, &specs);
    defer freeLeaves(alloc, full);
    try std.testing.expectEqual(layout.max_leaves, full.len);
    // The file is full, so nothing may be authored onto it: a `+` that wrote
    // a 33rd pane would write a file the terminal wall then refuses whole.
    try std.testing.expectError(error.WallFull, appendLeaf(alloc, path, &specs, full[0], .{ .host = 0, .session = "x" }));

    try flat.write(alloc, path, layout.max_leaves + 1);
    try std.testing.expectError(error.BadLayout, readLeaves(alloc, path, &specs));
}

test "hub: a checkout borrows the host target; a gone pane is still the browser's to dial" {
    const alloc = std.testing.allocator;
    const specs = [_]client.HostSpec{
        .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
    };
    var hub = try Hub.init(alloc, &specs, &[_]Leaf{.{ .host = 0, .session = "wghost" }});
    defer hub.deinit();

    const t = try hub.checkoutTile(0);
    // The host's target verbatim — a host outlives every pump, so a copy
    // would be a lifetime nobody needed to track.
    try std.testing.expectEqualStrings("/tmp/a", t.target.sock);
    // The name the pump would once have needed reaches the BROWSER
    // instead, which is what sends the attach frame naming it.
    const j = try hub.json(alloc);
    defer alloc.free(j);
    try std.testing.expect(std.mem.indexOf(u8, j, "\"session\":\"wghost\"") != null);

    // The session ends on its daemon: the pane stays, wearing `gone`, and
    // the browser's socket stays valid — the tile's pump redials on its own
    // until the name comes back or the file loses the pane.
    hub.applyList(0, "", true);
    hub.applyList(0, "", true);
    try std.testing.expectEqual(TileState.gone, hub.tiles.items[0].state);
    _ = try hub.checkoutTile(0);
    // An id this hub never handed out is the 404 the page refetches on: a
    // browser holds `/ws/<n>` across a hub restart that renumbered the wall.
    try std.testing.expectError(error.UnknownId, hub.checkoutTile(99));
}

test "hub: a HOST tile is never the ask" {
    const alloc = std.testing.allocator;
    // Resolved the way webhub_main resolves the file, because that is the one
    // road onto this wall and the permission is set on it.
    var arena = std.heap.ArenaAllocator.init(alloc);
    defer arena.deinit();
    const spec = try client.resolveHost(arena.allocator(), "box", null, client.quic_idle_ms_default);
    var hub = try Hub.init(alloc, &.{spec}, &[_]Leaf{.{ .host = 0, .session = "0" }});
    defer hub.deinit();

    const t = try hub.checkoutTile(0);
    // Nobody is sitting in front of a browser tile: it may not start a
    // daemon on a box whose owner just stopped one, and it may not print
    // an ssh-fallback line into a page that has no stderr.
    try std.testing.expect(!t.target.hand.asked);
    // The POLL is nobody's ask either, and it is the dial that runs once a
    // second forever.
    try std.testing.expect(!hub.hosts[0].spec.poll_target.hand.asked);
}

test "routes: the three assets with their content types, 404 for the rest" {
    const assets = Assets{
        .index_html = "<html>",
        .mux_js = "js",
        .core_wasm = "\x00asm",
    };
    const idx = route(assets, "/").?;
    try std.testing.expectEqualStrings("<html>", idx.body);
    try std.testing.expectEqualStrings("text/html; charset=utf-8", idx.content_type);
    try std.testing.expectEqualStrings("<html>", route(assets, "/index.html").?.body);
    const js = route(assets, "/mux.js").?;
    try std.testing.expectEqualStrings("application/javascript", js.content_type);
    const wasm = route(assets, "/mux_core.wasm").?;
    try std.testing.expectEqualStrings("application/wasm", wasm.content_type);
    try std.testing.expectEqualStrings("\x00asm", wasm.body);
    try std.testing.expectEqual(@as(?Asset, null), route(assets, "/etc/passwd"));
    try std.testing.expectEqual(@as(?Asset, null), route(assets, "/ws/0"));
    try std.testing.expectEqual(@as(?Asset, null), route(assets, "/../src/main.zig"));
}

test "control messages: the closed vocabulary, envelope included" {
    try std.testing.expectEqualStrings("\x01{\"state\":\"connecting\"}", controlMessage(.connecting));
    try std.testing.expectEqualStrings("\x01{\"state\":\"up\"}", controlMessage(.up));
    try std.testing.expectEqualStrings("\x01{\"state\":\"reconnecting\"}", controlMessage(.reconnecting));
    try std.testing.expectEqualStrings("\x01{\"state\":\"gone\"}", controlMessage(.gone));
}

test "head frame: every split boundary is INCOMPLETE, the whole frame is READY" {
    const cap = ws_buffer_len;
    // A masked binary frame, 3-byte payload: the shape the browser sends.
    const small = [_]u8{ 0x82, 0x83, 1, 2, 3, 4, 'a' ^ 1, 'b' ^ 2, 'c' ^ 3 };
    // Every proper prefix is incomplete — 1-byte header included.
    for (0..small.len) |n| {
        try std.testing.expectEqual(HeadFrame.incomplete, headFrame(small[0..n], cap));
    }
    try std.testing.expectEqual(HeadFrame.ready, headFrame(&small, cap));
    // Trailing bytes of the NEXT frame do not make this one incomplete.
    try std.testing.expectEqual(HeadFrame.ready, headFrame(&(small ++ [_]u8{0x82}), cap));

    // 16-bit extended length: the split lands mid-length and mid-mask.
    var ext16: [4 + 4 + 200]u8 = undefined;
    ext16[0] = 0x82;
    ext16[1] = 0x80 | 126;
    std.mem.writeInt(u16, ext16[2..4], 200, .big);
    @memset(ext16[4..8], 0); // mask
    @memset(ext16[8..], 'x');
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(ext16[0..3], cap)); // mid-length
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(ext16[0..6], cap)); // mid-mask
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(ext16[0..8], cap)); // header only
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(ext16[0 .. ext16.len - 1], cap)); // mid-payload
    try std.testing.expectEqual(HeadFrame.ready, headFrame(&ext16, cap));

    // 64-bit extended length: same, one byte at a time through the length.
    var ext64: [2 + 8 + 4 + 70000]u8 = undefined;
    ext64[0] = 0x82;
    ext64[1] = 0x80 | 127;
    std.mem.writeInt(u64, ext64[2..10], 70000, .big);
    @memset(ext64[10..14], 0);
    @memset(ext64[14..], 'y');
    for (2..10) |n| {
        try std.testing.expectEqual(HeadFrame.incomplete, headFrame(ext64[0..n], cap));
    }
    // 70000 > the reader's buffer: named from the header alone, because
    // waiting for a frame that can never be buffered whole would fill the
    // buffer with bytes the pump can never use.
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(ext64[0..14], cap));
    // ...and that verdict does NOT come before the header is buffered.
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(ext64[0..11], cap));

    // The boundary the payload-only check got wrong: a payload that fits
    // the buffer EXACTLY still needs 14 bytes of header alongside it, so
    // the frame as a whole never fits and the pump must not wait for it.
    var edge: [14]u8 = undefined;
    edge[0] = 0x82;
    edge[1] = 0x80 | 127;
    std.mem.writeInt(u64, edge[2..10], cap, .big);
    @memset(edge[10..14], 0);
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));
    // One byte under the whole-frame budget is an ordinary wait.
    std.mem.writeInt(u64, edge[2..10], cap - 14, .big);
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(&edge, cap));
    // One byte OVER it is not.
    std.mem.writeInt(u64, edge[2..10], cap - 14 + 1, .big);
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));

    // The declared length is a WIRE number and gets no benefit of the doubt:
    // u64 max in 14 bytes overflows `off + payload_len` — a Debug panic, or a
    // wrap to a permanent `.incomplete`. One hostile message per tile.
    std.mem.writeInt(u64, edge[2..10], std.math.maxInt(u64), .big);
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));
    std.mem.writeInt(u64, edge[2..10], std.math.maxInt(u64) - 13, .big);
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));
    // A capacity smaller than the header itself must not underflow the
    // subtraction that replaced it. Nothing in the hub does this, but
    // headFrame is pub and the arithmetic has to stand on its own.
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, 4));
    try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, 0));

    // A pong is named separately: readSmallMessage swallows it and blocks
    // on whatever is behind it, so the pump must toss it itself.
    const pong = [_]u8{ 0x8a, 0x84, 0, 0, 0, 0, 'p', 'i', 'n', 'g' };
    try std.testing.expectEqual(@as(usize, 10), headFrame(&pong, cap).pong);
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(pong[0..9], cap));
    // An UNMASKED pong (what a server sends) is 4 bytes shorter.
    const server_pong = [_]u8{ 0x8a, 0x00 };
    try std.testing.expectEqual(@as(usize, 2), headFrame(&server_pong, cap).pong);

    // Ping and close are ordinary reads: readSmallMessage returns the
    // ping and errors ConnectionClose on the close, both without a socket
    // read, and both end up handled rather than waited on.
    try std.testing.expectEqual(HeadFrame.ready, headFrame(&[_]u8{ 0x89, 0x80, 0, 0, 0, 0 }, cap));
    try std.testing.expectEqual(HeadFrame.ready, headFrame(&[_]u8{ 0x88, 0x80, 0, 0, 0, 0 }, cap));
    // Empty buffer: nothing to do, and above all no speculative read.
    try std.testing.expectEqual(HeadFrame.incomplete, headFrame(&.{}, cap));
}

test "drain browser: the reader's OWN bytes decide, with or without a readable event" {
    // std's WebSocket is {key, input, output} and nothing else, so the
    // drain runs over a fixed reader with no socket under it — which is
    // exactly the state this pin is about: bytes in userspace, kernel
    // empty, poll silent forever.
    var out_buf: [256]u8 = undefined;

    // A close frame that is ALREADY buffered must end the tile on a pass where
    // poll said nothing — it gets there via a drain a mid-drain reconnect cut
    // short, and holds this tile's daemon client slot until the reaper.
    {
        var bytes = [_]u8{ 0x88, 0x80, 0, 0, 0, 0 };
        var r: std.Io.Reader = .fixed(&bytes);
        var w: std.Io.Writer = .fixed(&out_buf);
        var ws: std.http.Server.WebSocket = .{ .key = "", .input = &r, .output = &w };
        var live = Liveness.init();
        try std.testing.expectEqual(Drained.browser_dead, drainBrowser(&ws, &live, false, null));
    }

    // A data frame with no transport is dropped — and CONSUMED. Leaving
    // it buffered is the strand: the pass that ignores it is the pass
    // that never comes back for it.
    {
        const input_byte: u8 = @intFromEnum(proto.MsgType.input);
        var bytes = [_]u8{ 0x82, 0x80 | 8, 0, 0, 0, 0, env_frame, input_byte, 2, 0, 0, 0, 'h', 'i' };
        var r: std.Io.Reader = .fixed(&bytes);
        var w: std.Io.Writer = .fixed(&out_buf);
        var ws: std.http.Server.WebSocket = .{ .key = "", .input = &r, .output = &w };
        var live = Liveness.init();
        try std.testing.expectEqual(Drained.ok, drainBrowser(&ws, &live, false, null));
        try std.testing.expectEqual(@as(usize, 0), r.bufferedLen());
    }

    // Drained to the LAST byte, not to the first message: a pong the pump
    // must toss itself, then a close behind it. Both are seen in one
    // pass, and the pong counts as the liveness proof it is.
    {
        var bytes = [_]u8{ 0x8a, 0x84, 0, 0, 0, 0, 'p', 'i', 'n', 'g' } ++
            [_]u8{ 0x88, 0x80, 0, 0, 0, 0 };
        var r: std.Io.Reader = .fixed(&bytes);
        var w: std.Io.Writer = .fixed(&out_buf);
        var ws: std.http.Server.WebSocket = .{ .key = "", .input = &r, .output = &w };
        var live = Liveness.init();
        live.pings_sent = 2;
        try std.testing.expectEqual(Drained.browser_dead, drainBrowser(&ws, &live, false, null));
        try std.testing.expectEqual(@as(i64, 0), live.pings_sent);
    }

    // A ping is answered from the drain, transport or no transport:
    // "dialing" is a live answer, and a browser heartbeat that went
    // unanswered could not tell a wedged hub from a busy one.
    {
        var bytes = [_]u8{ 0x89, 0x80, 0, 0, 0, 0 };
        var r: std.Io.Reader = .fixed(&bytes);
        var w: std.Io.Writer = .fixed(&out_buf);
        var ws: std.http.Server.WebSocket = .{ .key = "", .input = &r, .output = &w };
        var live = Liveness.init();
        try std.testing.expectEqual(Drained.ok, drainBrowser(&ws, &live, false, null));
        // Server→client, so unmasked: opcode 0xa, length 0.
        try std.testing.expectEqualSlices(u8, &[_]u8{ 0x8a, 0x00 }, out_buf[0..w.end]);
    }

    // The idle pass: nothing buffered, nothing readable, no read
    // attempted. This is the whole cost of running the drain every time.
    {
        var r: std.Io.Reader = .fixed(&.{});
        var w: std.Io.Writer = .fixed(&out_buf);
        var ws: std.http.Server.WebSocket = .{ .key = "", .input = &r, .output = &w };
        var live = Liveness.init();
        try std.testing.expectEqual(Drained.ok, drainBrowser(&ws, &live, false, null));
    }

    // ...and `fill` is the one step that can block or hit EOF. A fixed
    // reader has no stream behind it, which is what a hung-up browser
    // looks like: the tile ends there rather than spinning.
    {
        var r: std.Io.Reader = .fixed(&.{});
        var w: std.Io.Writer = .fixed(&out_buf);
        var ws: std.http.Server.WebSocket = .{ .key = "", .input = &r, .output = &w };
        var live = Liveness.init();
        try std.testing.expectEqual(Drained.browser_dead, drainBrowser(&ws, &live, true, null));
    }
}

test "tiles json: every byte a label or session can carry, escaped" {
    // Pinned on the helper rather than on `Hub.json`, because the helper is
    // what both strings go through and building a Hub would test the
    // brackets instead of the escaping. Label and session share one road on
    // purpose: two copies of this loop would be two things to keep in step.
    const alloc = std.testing.allocator;
    const cases = [_]struct { in: []const u8, want: []const u8 }{
        .{ .in = "", .want = "\"\"" },
        .{ .in = "box2#b", .want = "\"box2#b\"" },
        // A path with a quote in it must not break the page.
        .{ .in = "/tmp/we\"ird\\path", .want = "\"/tmp/we\\\"ird\\\\path\"" },
        // Control bytes go to \u00XX, including the ones JSON has short
        // spellings for — one rule, no table to get wrong.
        .{ .in = "a\nb\tc\x00d\x1fe", .want = "\"a\\u000ab\\u0009c\\u0000d\\u001fe\"" },
        // Bytes above 0x7f pass through: labels are argv, and a UTF-8
        // hostname stays itself.
        .{ .in = "héllo", .want = "\"héllo\"" },
        // `validSessionName` refuses every one of these, which is exactly
        // why the escaping is pinned here: nothing downstream would catch it
        // going wrong.
        .{ .in = "we\"ird\\\x01", .want = "\"we\\\"ird\\\\\\u0001\"" },
    };
    for (cases) |c| {
        var out: std.ArrayList(u8) = .empty;
        defer out.deinit(alloc);
        try appendJsonString(alloc, &out, c.in);
        try std.testing.expectEqualStrings(c.want, out.items);
    }
}

test "frame messages: exact framing in, everything else named" {
    // Types spelled via the enum so the test cannot drift from the wire.
    const input_byte: u8 = @intFromEnum(proto.MsgType.input);
    const good = [_]u8{ 0x00, input_byte, 2, 0, 0, 0, 'h', 'i' };
    const parsed = try parseFrameMessage(&good);
    try std.testing.expectEqual(proto.MsgType.input, parsed.t);
    try std.testing.expectEqualStrings("hi", parsed.payload);

    // Empty payload is legal (detach sends one).
    const detach_byte: u8 = @intFromEnum(proto.MsgType.detach);
    const empty = [_]u8{ 0x00, detach_byte, 0, 0, 0, 0 };
    try std.testing.expectEqual(proto.MsgType.detach, (try parseFrameMessage(&empty)).t);

    // Each refusal by name.
    try std.testing.expectError(error.BadEnvelope, parseFrameMessage(&[_]u8{}));
    try std.testing.expectError(error.BadEnvelope, parseFrameMessage(&[_]u8{ 0x01, 'x' }));
    try std.testing.expectError(error.ShortFrame, parseFrameMessage(&[_]u8{ 0x00, input_byte, 1, 0 }));
    // Length says 3, message carries 2.
    try std.testing.expectError(error.LengthMismatch, parseFrameMessage(&[_]u8{ 0x00, input_byte, 3, 0, 0, 0, 'h', 'i' }));
    // A trailing byte is upstream's bug, not framing to resync.
    try std.testing.expectError(error.LengthMismatch, parseFrameMessage(&[_]u8{ 0x00, input_byte, 1, 0, 0, 0, 'h', 'i' }));
    // Length field claims more than max_payload.
    var oversize = [_]u8{ 0x00, input_byte, 0, 0, 0, 0 };
    std.mem.writeInt(u32, oversize[2..6], proto.max_payload + 1, .little);
    try std.testing.expectError(error.Oversize, parseFrameMessage(&oversize));
    // MsgType is non-exhaustive by design: a type byte from a future
    // protocol PARSES and transits untouched rather than dying here.
    const future = try parseFrameMessage(&[_]u8{ 0x00, 0x40, 0, 0, 0, 0 });
    try std.testing.expectEqual(@as(u8, 0x40), @intFromEnum(future.t));
}

test "parseFrameMessage: the three agent frames are the hub's to send, never the browser's" {
    // Why: `FrameMsgError.HubOwned`. Pinned here so the "unknown types
    // transit" property above stays exactly as wide as it was.
    for ([_]proto.MsgType{ .agent_offer, .agent_data, .agent_close }) |t| {
        const msg = [_]u8{ 0x00, @intFromEnum(t), 0, 0, 0, 0 };
        try std.testing.expectError(error.HubOwned, parseFrameMessage(&msg));
    }
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}