a73x

src/server/server_test_clipboard.zig

Ref:   Size: 35.3 KiB   History

const std = @import("std");
const Engine = @import("engine").Engine;
const proto = @import("term").protocol;
const TmpDir = @import("testtmp").TmpDir;
const h = @import("server_test_harness.zig");
const dial = h.dial;
const srv_mod = @import("server.zig");
const Server = srv_mod.Server;
const Session = srv_mod.Session;
const selectionReplyStatus = srv_mod.selectionReplyStatus;
const awaitFrame = h.awaitFrame;
const awaitGridText = h.awaitGridText;
const connectedPair = h.connectedPair;
const writeDyingGapShell = h.writeDyingGapShell;
const SelectionAction = @FieldType(proto.SelectionReq, "action");

/// Returns the parts of the reply that outlive the frame's payload — which
/// is the whole reason this is not `awaitFrame` spelled at each call site:
/// `text_len` has to be read while the payload is still alive.
fn awaitSelectionReply(
    alloc: std.mem.Allocator,
    srv: *Server,
    peer: std.posix.fd_t,
) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } {
    // Every grid update also carries an id-zero source/position state. A
    // direct extraction is correlated by its nonzero id, as ClientCore is.
    for (0..16) |_| {
        const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null;
        defer frame.deinit(alloc);
        const reply = try proto.decodeSelectionReply(frame.payload);
        if (reply.id == 0) continue;
        return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len };
    }
    return null;
}

const TrackedReply = struct {
    id: u32,
    gesture: u32,
    seq: u64,
    source: u64,
    status: proto.SelectionStatus,
    anchor: proto.SelectionPoint,
    active: proto.SelectionPoint,
    text: [128]u8 = undefined,
    text_len: usize = 0,

    fn textValue(self: *const TrackedReply) []const u8 {
        return self.text[0..self.text_len];
    }
};

/// Position updates have id zero; a copy/start reply carries its request id.
/// Skip the other client-local updates so tests never depend on send order.
fn awaitTracked(
    alloc: std.mem.Allocator,
    srv: *Server,
    peer: std.posix.fd_t,
    gesture: u32,
    id: u32,
) !?TrackedReply {
    for (0..16) |_| {
        const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null;
        defer frame.deinit(alloc);
        const reply = try proto.decodeSelectionReply(frame.payload);
        if (reply.gesture != gesture or reply.id != id) continue;
        if (reply.text.len > 128) return error.TrackedTextTooLong;
        var result: TrackedReply = .{
            .id = reply.id,
            .gesture = reply.gesture,
            .seq = reply.seq,
            .source = reply.source,
            .status = reply.status,
            .anchor = reply.anchor,
            .active = reply.active,
        };
        @memcpy(result.text[0..reply.text.len], reply.text);
        result.text_len = reply.text.len;
        return result;
    }
    return null;
}

fn writeTracked(
    peer: std.posix.fd_t,
    action: SelectionAction,
    id: u32,
    gesture: u32,
    epoch: u64,
    source: u64,
    anchor: proto.SelectionPoint,
    active: proto.SelectionPoint,
) !void {
    const bytes = proto.encodeSelectionReq(.{
        .action = action,
        .id = id,
        .gesture = gesture,
        .epoch = epoch,
        .source = source,
        .anchor = anchor,
        .active = active,
    });
    try proto.writeFrame(peer, .selection_req, &bytes);
}

fn attachTracked(alloc: std.mem.Allocator, td: *h.TestDaemon) !std.net.Stream {
    const c = try dial.dialAttach(td.sock_path, 16, 9);
    errdefer c.close();
    (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoTrackedSnapshot).deinit(alloc);
    const state_frame = (try awaitFrame(alloc, &td.srv, c.handle, .selection_reply, 400)) orelse return error.NoTrackedSource;
    defer state_frame.deinit(alloc);
    const state = try proto.decodeSelectionReply(state_frame.payload);
    if (state.id != 0 or state.gesture != 0) return error.BadTrackedSource;
    return c;
}

test "Server: tracked selection rejects a stale exact source even when the grid did not change" {
    const alloc = std.testing.allocator;
    var td = try h.TestDaemon.init(alloc, "follow-stale", .{ .shell = "/bin/cat" });
    defer td.deinit();
    const c = try attachTracked(alloc, &td);
    defer c.close();

    const session = td.srv.sessions.table[0].?;
    const source = session.eng.selectionSource();
    // Scroll a blank screen: row identities move but the rendered grid is
    // unchanged. The source guard must still reject a delayed coordinate.
    session.eng.feed("\x1b[1S");
    td.srv.sendUpdate(0);
    try writeTracked(c.handle, .start, 1, 41, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
    const reply = (try awaitTracked(alloc, &td.srv, c.handle, 41, 1)) orelse return error.NoStaleTrackedReply;
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, reply.status);
    try std.testing.expectEqual(@as(usize, 0), reply.text_len);
}

test "Server: a stale start does not retire the client's existing tracked gesture" {
    const alloc = std.testing.allocator;
    var td = try h.TestDaemon.init(alloc, "tracked-stale-replace", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 });
    defer td.deinit();
    const c = try attachTracked(alloc, &td);
    defer c.close();
    const session = td.srv.sessions.table[0].?;

    session.eng.feed("KEEP");
    td.srv.sendUpdate(0);
    const source = session.eng.selectionSource();
    try writeTracked(c.handle, .start, 1, 71, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 });
    _ = (try awaitTracked(alloc, &td.srv, c.handle, 71, 1)) orelse return error.NoInitialTrackedStart;

    // This changes the coordinate source after gesture 71 is owned. A late
    // gesture 72 must be refused without clearing the still-live pins.
    session.eng.feed("\x1b[1S");
    td.srv.sendUpdate(0);
    try writeTracked(c.handle, .start, 2, 72, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
    const stale = (try awaitTracked(alloc, &td.srv, c.handle, 72, 2)) orelse return error.NoStaleReplacementReply;
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, stale.status);

    try writeTracked(c.handle, .copy, 3, 71, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
    const kept = (try awaitTracked(alloc, &td.srv, c.handle, 71, 3)) orelse return error.NoKeptTrackedCopy;
    try std.testing.expectEqual(proto.SelectionStatus.ok, kept.status);
    try std.testing.expectEqualStrings("KEEP", kept.textValue());
}

test "Server: tracked selection tracks duplicate occurrences per client and clear ids cannot cross" {
    const alloc = std.testing.allocator;
    var td = try h.TestDaemon.init(alloc, "follow-clients", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 });
    defer td.deinit();
    const a = try attachTracked(alloc, &td);
    defer a.close();
    const b = try attachTracked(alloc, &td);
    defer b.close();
    const session = td.srv.sessions.table[0].?;

    session.eng.feed("DUPLICATE\r\nBEFORE\r\nDUPLICATE\r\nAFTER");
    td.srv.sendUpdate(0);
    const epoch = session.epoch;
    const source = session.eng.selectionSource();
    try writeTracked(a.handle, .start, 1, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 });
    try writeTracked(b.handle, .start, 1, 202, epoch, source, .{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 });
    const started_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 1)) orelse return error.NoTrackedA;
    const started_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 1)) orelse return error.NoTrackedB;
    try std.testing.expectEqualStrings("DUPLICATE", started_a.textValue());
    try std.testing.expectEqualStrings("DUPLICATE", started_b.textValue());
    try std.testing.expect(started_a.anchor.row != started_b.anchor.row);

    // Start at the bottom and append enough to shift both matching strings;
    // no text comparison can identify which one each client intended.
    session.eng.feed("\r\nTAIL-1\r\nTAIL-2\r\nTAIL-3\r\nTAIL-4\r\nTAIL-5\r\nTAIL-6");
    td.srv.sendUpdate(0);
    const moved_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 0)) orelse return error.NoMovedTrackedA;
    const moved_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 0)) orelse return error.NoMovedTrackedB;
    try std.testing.expect(moved_a.anchor.row != moved_b.anchor.row);

    // A foreign clear cannot destroy A's pins. Its following copy must still
    // name A's original duplicate, not B's or a current coordinate.
    try writeTracked(a.handle, .clear, 0, 999, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
    try writeTracked(a.handle, .copy, 2, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
    const copied_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 2)) orelse return error.NoTrackedCopy;
    try std.testing.expectEqual(proto.SelectionStatus.ok, copied_a.status);
    try std.testing.expectEqualStrings("DUPLICATE", copied_a.textValue());
}

test "Server: tracked selection is retired by resync, resize and detach" {
    const alloc = std.testing.allocator;
    var td = try h.TestDaemon.init(alloc, "follow-retire", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 });
    defer td.deinit();
    var c = try attachTracked(alloc, &td);
    var c_open = true;
    defer if (c_open) c.close();
    const session = td.srv.sessions.table[0].?;
    session.eng.feed("tracked");
    td.srv.sendUpdate(0);
    try writeTracked(c.handle, .start, 1, 77, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 });
    _ = (try awaitTracked(alloc, &td.srv, c.handle, 77, 1)) orelse return error.NoTrackedFollow;

    td.srv.resyncSnapshot(0);
    const resynced = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResyncFollow;
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, resynced.status);

    // Re-arm before a real client resize; its snapshot and following state
    // must likewise retire the pins rather than projecting them through reflow.
    try writeTracked(c.handle, .start, 2, 78, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 });
    _ = (try awaitTracked(alloc, &td.srv, c.handle, 78, 2)) orelse return error.NoResizeFollowStart;
    const size = proto.encodeSize(17, 9);
    try proto.writeFrame(c.handle, .resize, &size);
    const resized = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResizeFollow;
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, resized.status);

    // Socket closure takes the sole teardown path. The testing allocator at
    // TestDaemon.deinit then proves its tracked pins were unregistered.
    c.close();
    c_open = false;
    var detached = false;
    for (0..80) |_| {
        try td.srv.pumpOnce(5);
        if (!td.srv.hasClientsIn(0)) {
            detached = true;
            break;
        }
    }
    try std.testing.expect(detached);
}

test "Server: a session-less slot still answers a well-formed selection request" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "selless", .{ .shell = "/bin/cat" });
    defer td.deinit();

    // The QUIC shape again: promoted by a completed handshake, never
    // attached. It has no grid to read, but its request carried an id, so
    // the lane owes it exactly one correlated answer.
    const c = try connectedPair();
    defer std.posix.close(c.peer);
    td.srv.clients[0] = .{ .sink = .{ .socket = c.daemon } };

    const req = proto.encodeSelectionReq(.{
        .id = 4242,
        .anchor = .{ .row = 0, .col = 0 },
        .active = .{ .row = 0, .col = 3 },
    });
    try proto.writeFrame(c.peer, .selection_req, &req);

    const reply = (try awaitSelectionReply(alloc, &td.srv, c.peer)) orelse
        return error.NoSelectionReply;
    try std.testing.expectEqual(@as(u32, 4242), reply.id);
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, reply.status);
    try std.testing.expectEqual(@as(usize, 0), reply.text_len);
}

test "Server: an unencodable selection result still answers with unavailable" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "seloom", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try connectedPair();
    defer std.posix.close(c.peer);
    td.srv.clients[0] = .{ .sink = .{ .socket = c.daemon }, .session = 0 };
    // The per-client queue must not be what fails: this test is about the
    // scratch encode, and appendFrame draws on the same allocator.
    try td.srv.clients[0].?.pending.ensureTotalCapacity(alloc, 4096);

    const text = try alloc.alloc(u8, 64 * 1024);
    defer alloc.free(text);
    @memset(text, 'x');

    // The reservation for the fallback is allocation zero; growing for the
    // text is allocation one, and that is the one denied here.
    var failing = std.testing.FailingAllocator.init(alloc, .{
        .fail_index = 1,
        .resize_fail_index = 0,
    });
    const real = td.srv.alloc;
    td.srv.alloc = failing.allocator();
    td.srv.queueSelectionReply(0, .{ .id = 99, .status = .ok, .history_rows = 7, .text = text });
    td.srv.alloc = real;
    try std.testing.expect(failing.has_induced_failure);

    const reply = (try awaitSelectionReply(alloc, &td.srv, c.peer)) orelse
        return error.NoSelectionReply;
    try std.testing.expectEqual(@as(u32, 99), reply.id);
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, reply.status);
    try std.testing.expectEqual(@as(usize, 0), reply.text_len);
}

test "Server: selection extraction statuses map to wire replies" {
    try std.testing.expectEqual(proto.SelectionStatus.ok, selectionReplyStatus(.ok));
    try std.testing.expectEqual(proto.SelectionStatus.invalid, selectionReplyStatus(.invalid));
    try std.testing.expectEqual(proto.SelectionStatus.too_large, selectionReplyStatus(.too_large));
    try std.testing.expectEqual(proto.SelectionStatus.unavailable, selectionReplyStatus(null));
}

// ---------------------------------------------------------------------------
// Side-channel events: the engine's OSC 52 and bell, out to the session's own
// clients. The drift pin comes FIRST, because a wedged test below prints
// nothing at all.
// ---------------------------------------------------------------------------

// `engine` does not import `protocol` — the VT engine holds no opinion about
// the wire format, and that convention is worth keeping — so the cap is written
// in two places and this is what stops them drifting in silence. server.zig is
// one of the few modules that legitimately imports both.
test "the engine's default clipboard cap is the wire's" {
    try std.testing.expectEqual(
        proto.clipboard_base64_max,
        (Engine.Options{ .cols = 80, .rows = 24 }).clipboard_max,
    );
}

test "Server: a clipboard event reaches this session's clients and no others" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "clip");
    defer td.deinit();

    // The OSC 52 arrives as INPUT rather than from a script: one `SpawnPlan` is
    // shared by every session, so a shell emitting the escape itself emits it in
    // BOTH and the "no others" half could never be observed.
    try td.start(.{ .shell = "/bin/cat" });

    const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
    defer ca.close();
    (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400) orelse
        return error.NoSnapshotA).deinit(alloc);

    const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
    defer cb.close();
    (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400) orelse
        return error.NoSnapshotB).deinit(alloc);

    // The newline is what flushes the line to cat in canonical mode; the
    // echo the tty may add alongside is harmless either way — mangled by
    // ECHOCTL it is not an OSC at all, and unmangled it is the same event.
    try proto.writeFrame(ca.handle, .input, "\x1b]52;c;aGk=\x07\n");

    const ev = (try awaitFrame(alloc, &td.srv, ca.handle, .term_event, 400)) orelse
        return error.NoTermEventForEmittingSession;
    defer ev.deinit(alloc);
    switch (try proto.decodeTermEvent(ev.payload)) {
        .clipboard => |clip| {
            try std.testing.expectEqual(@as(u8, 'c'), clip.target);
            try std.testing.expectEqualStrings("aGk=", clip.base64);
        },
        .bell => return error.ExpectedClipboardGotBell,
    }

    // The boundary, with the positive already in hand so this cannot pass
    // vacuously: the budget keeps pumping, so a misdirected push would have
    // arrived by now. A clipboard write belongs to the shell that produced it.
    if (try awaitFrame(alloc, &td.srv, cb.handle, .term_event, 60)) |leaked| {
        leaked.deinit(alloc);
        return error.TermEventCrossedSessions;
    }
}

// ---------------------------------------------------------------------------
// The gap: a side-channel event produced while nobody was attached. The side
// channel follows the grid's own resync verdict — a client that earns a DELTA
// was watching continuously and is owed what it missed, while one that gets a
// SNAPSHOT is starting fresh and would have its clipboard hijacked.
//
// The first two tests are OPPOSITES and neither is meaningful alone: one is
// passed by replaying to everyone, the other by replaying to no one. The third
// pins the boundary, the fourth the expiry no client can observe.
// ---------------------------------------------------------------------------

/// The escapes are emitted ON DEMAND because they must land in the gap: after
/// one client has gone, before the next arrives. `gap-open` keeps these off the
/// pty's echo, TWO clipboard sets make "last one wins" observable, the BEL fills
/// the second slot, and `after-osc` is the gate.
fn writeGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
    try tmp.dir.writeFile(.{
        .sub_path = "gap.sh",
        .data =
        \\#!/bin/sh
        \\read -r go
        \\printf 'gap-open\n'
        \\printf '\033]52;c;Zmlyc3Q=\007'
        \\printf '\033]52;c;c2Vjb25k\007\007'
        \\printf 'after-osc'
        \\exec sleep 30
        \\
        ,
        .flags = .{ .mode = 0o755 },
    });
    return std.fmt.allocPrintSentinel(alloc, "{s}/gap.sh", .{tmp.path()}, 0);
}

/// `first` and `second`, base64 as OSC 52 carries them. Spelled out so a test
/// asserting "the LAST set is the one replayed" names what it expects rather
/// than an opaque literal.
const gap_clip_first = "Zmlyc3Q=";

const gap_clip_second = "c2Vjb25k";

/// What the departed client held: exactly the two values `sendResync` reads
/// to choose its branch.
const GapWatermark = struct { seq: u64, epoch: u64 };

/// An attach is the only thing that builds the tracker, and so earns a
/// servable seq.
fn clipboardIntoGap(
    alloc: std.mem.Allocator,
    srv: *Server,
    sock_path: []const u8,
) !GapWatermark {
    const a = try dial.dialAttach(sock_path, 80, 24);
    (try awaitFrame(alloc, srv, a.handle, .snapshot, 400) orelse
        return error.NoSnapshotBeforeGap).deinit(alloc);

    // Read off the session rather than parsed back out of the snapshot: these
    // two values are what pick the branch, so handing them back verbatim is
    // what makes the branch certain.
    const wm: GapWatermark = .{
        .seq = srv.sessions.table[0].?.tracker.seq,
        .epoch = srv.sessions.table[0].?.epoch,
    };

    // Gone, and OBSERVED gone. Closing the socket only makes the daemon
    // eligible to notice; until it has, the drain below would find a client
    // and deliver live, which is the one thing these tests must not measure.
    a.close();
    var gone = false;
    for (0..400) |_| {
        try srv.pumpOnce(5);
        if (!srv.hasClientsIn(0)) {
            gone = true;
            break;
        }
    }
    if (!gone) return error.ClientStillAttached;

    try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "go\n");
    if (!try awaitGridText(alloc, srv, "after-osc", 3000)) return error.EscapeNeverDrained;
    return wm;
}

/// What a reattaching client received: the frame that answered its attach,
/// and every side-channel event that arrived with it.
const GapReplay = struct {
    /// `.delta` or `.snapshot` — which arm of `sendResync` answered, kept as
    /// the FIRST content frame seen. Null means the attach was never
    /// serviced, which would make every absence below vacuous, so each test
    /// asserts on it.
    content: ?proto.MsgType = null,
    clips: usize = 0,
    bells: usize = 0,
    /// A bell arrived when a clipboard event had already been seen.
    bell_after_clip: bool = false,
    /// A side-channel event arrived BEFORE the frame that repaints the grid —
    /// the one ordering `replayPending` calls load-bearing, since a terminal ACTS
    /// on a bell and acting ahead of the repaint dings about a screen nobody can
    /// see. Here because this loop is the only thing that can see it.
    event_before_content: bool = false,
    clip_target: u8 = 0,
    /// Sized to the fixture, not to the wire cap: `clipboard_base64_max` is
    /// tens of KiB and this lives on a stack frame. An over-long payload is
    /// refused loudly rather than truncated into a comparison that passes.
    clip_buf: [32]u8 = undefined,
    clip_len: usize = 0,

    fn lastClip(self: *const GapReplay) []const u8 {
        return self.clip_buf[0..self.clip_len];
    }
};

/// Pump until the attach has been answered, then keep pumping a settling window
/// past it, collecting every `term_event` on the way.
///
/// EVERYTHING is inspected, because `awaitFrame` DROPS frames it is not looking
/// for — so "wait for the content frame, then watch for a term_event" is blind
/// to an event queued AHEAD of it, which is exactly where a replay hoisted out
/// of the delta branch lands.
///
/// The 400 below is `awaitFrame`'s own budget for an attach reply, ~6ms an
/// iteration; the 60 is the settling window that keeps running past the content
/// frame, so an event queued after it is caught too.
///
/// So this stays a pump-and-poll loop rather than becoming a `Link.awaitFrame`
/// wait: both numbers are ROUND counts on purpose. The wait ends a fixed
/// number of pumps after the content frame, not when a particular frame
/// arrives, and a sink that ended the wait early would be the blindness the
/// paragraph above describes.
fn collectGapReplay(alloc: std.mem.Allocator, srv: *Server, fd: std.posix.fd_t) !GapReplay {
    var out: GapReplay = .{};
    var i: usize = 0;
    var after: usize = 0;
    while (i < 400 and after < 60) : (i += 1) {
        if (out.content != null) after += 1;
        try srv.pumpOnce(5);
        var pfd = [_]std.posix.pollfd{
            .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
        };
        if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
        const f = (try proto.readFrame(alloc, fd)) orelse break;
        defer f.deinit(alloc);
        switch (f.type) {
            .delta, .snapshot => out.content = out.content orelse f.type,
            .term_event => switch (try proto.decodeTermEvent(f.payload)) {
                .clipboard => |clip| {
                    if (out.content == null) out.event_before_content = true;
                    if (clip.base64.len > out.clip_buf.len) return error.ClipboardLongerThanFixtureEmits;
                    out.clips += 1;
                    out.clip_target = clip.target;
                    out.clip_len = clip.base64.len;
                    @memcpy(out.clip_buf[0..clip.base64.len], clip.base64);
                },
                .bell => {
                    if (out.content == null) out.event_before_content = true;
                    out.bells += 1;
                    if (out.clips > 0) out.bell_after_clip = true;
                },
            },
            else => {},
        }
    }
    return out;
}

test "Server: a delta reattach is replayed the gap's last clipboard set, then its bell" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gapdelta");
    defer td.deinit();

    const script = try writeGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    const wm = try clipboardIntoGap(alloc, &td.srv, td.sock_path);

    // The reattach that earns a delta: same size, and the watermark the
    // departed client really held. Yank in vim, the link blinks, you are back
    // two seconds later — losing the yank to that is how a copy feature
    // becomes one you stop believing.
    const b = try dial.dial(td.sock_path);
    defer b.close();
    try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, wm.seq, wm.epoch));

    // Collected rather than asserted frame by frame, because four claims ride on
    // the SEQUENCE: the delta arm answered, exactly one clipboard event came back,
    // it carries the second set, and the bell follows it.
    const got = try collectGapReplay(alloc, &td.srv, b.handle);

    // The grid first, and by branch rather than merely "something arrived":
    // the events are worthless if the repaint never landed, and a bell about
    // a screen the user cannot see yet is what the ordering rule prevents.
    try std.testing.expectEqual(proto.MsgType.delta, got.content orelse
        return error.AttachNeverAnswered);
    // The delta BEFORE the events, the one ordering `replayPending` claims
    // matters. Nothing else here can see it — `content` reads `.delta` however
    // early an event arrives.
    try std.testing.expect(!got.event_before_content);
    try std.testing.expectEqual(@as(usize, 1), got.clips);
    try std.testing.expectEqual(@as(u8, 'c'), got.clip_target);
    try std.testing.expectEqualStrings(gap_clip_second, got.lastClip());
    try std.testing.expectEqual(@as(usize, 1), got.bells);
    try std.testing.expect(got.bell_after_clip);
}

test "Server: a reattach quoting the seq an event was stamped at is not replayed it" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gapsameseq");
    defer td.deinit();

    const script = try writeGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    const wm = try clipboardIntoGap(alloc, &td.srv, td.sock_path);

    // The `>` in `replayPending`, at its exact boundary: a client quoting the seq
    // an event was stamped at was THERE for it, and replaying overwrites whatever
    // the user has copied since. The stamps are read off the session and the
    // HIGHEST is quoted, since the kinds need not share a seq. Walked via
    // `pendingSlots`, so a third kind cannot sit silently outside the `@max`.
    const s = &td.srv.sessions.table[0].?;
    var at: u64 = 0;
    var lowest: u64 = std.math.maxInt(u64);
    var recorded: usize = 0;
    for (s.pendingSlots()) |slot| {
        const p = slot.* orelse continue;
        recorded += 1;
        at = @max(at, p.seq);
        lowest = @min(lowest, p.seq);
    }
    // Every kind that EXISTS must be recorded — `kinds.len`, not what the fixture
    // happens to emit — so a kind added later SHOULD fail here. And every stamp
    // must be above the departed client's watermark, or the absence below passes
    // for the wrong reason.
    try std.testing.expectEqual(@as(usize, Session.kinds.len), recorded);
    try std.testing.expect(lowest > wm.seq);

    const b = try dial.dial(td.sock_path);
    defer b.close();
    try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, at, wm.epoch));

    // A delta is what proves the branch: quoting a servable seq at the
    // current size takes the same arm the replay lives on, so an absence here
    // is the comparison refusing rather than the branch never being reached.
    const got = try collectGapReplay(alloc, &td.srv, b.handle);
    try std.testing.expectEqual(proto.MsgType.delta, got.content orelse
        return error.AttachNeverAnswered);
    try std.testing.expectEqual(@as(usize, 0), got.clips);
    try std.testing.expectEqual(@as(usize, 0), got.bells);
}

test "Server: a clipboard event in the gap is not replayed to a snapshot attach" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gapsnap");
    defer td.deinit();

    const script = try writeGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    _ = try clipboardIntoGap(alloc, &td.srv, td.sock_path);

    // seq 0, epoch 0: what a fresh client sends, and what `canServe` refuses
    // by construction. This client has never seen this session, so the
    // clipboard write it slept through is not addressed to it.
    const b = try dial.dialAttach(td.sock_path, 80, 24);
    defer b.close();

    // `collectGapReplay` inspects EVERY frame: `awaitFrame` drops what it is not
    // looking for, so a replay queued ahead of the snapshot would be swallowed —
    // and that is exactly where a hoisted replay lands. The snapshot is still
    // required, so the absence cannot pass vacuously.
    const got = try collectGapReplay(alloc, &td.srv, b.handle);
    try std.testing.expectEqual(proto.MsgType.snapshot, got.content orelse
        return error.AttachNeverAnswered);
    try std.testing.expectEqual(@as(usize, 0), got.clips);
    try std.testing.expectEqual(@as(usize, 0), got.bells);
}

test "Server: a rebuild drops the pending clipboard it just put out of reach" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gapexpire");
    defer td.deinit();

    const script = try writeGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    _ = try clipboardIntoGap(alloc, &td.srv, td.sock_path);

    // On daemon STATE rather than the wire, because an expired event and a
    // retained one are indistinguishable to every client. A rebuild moves
    // `reset_seq` past the recorded seq, so retaining leaves the user's copied
    // text in a daemon that can no longer give it to anyone. Every slot, so a
    // kind added later cannot go unchecked.
    for (td.srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* != null);

    // A joiner at a different size is the shortest route to a rebuild: it
    // takes sendResync's size_changed arm straight into resyncSnapshot.
    const b = try dial.dialAttach(td.sock_path, 100, 30);
    defer b.close();
    (try awaitFrame(alloc, &td.srv, b.handle, .snapshot, 400) orelse
        return error.NoSnapshotAfterResize).deinit(alloc);

    for (td.srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* == null);
}

test "Server: the expiry keeps a servable event and drops one a stranded tracker cannot serve" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gappred");
    defer td.deinit();

    const script = try writeGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    _ = try clipboardIntoGap(alloc, &td.srv, td.sock_path);
    const s = &td.srv.sessions.table[0].?;
    for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null);

    // RETAIN: nothing has moved, so every stamp is still servable. This half has
    // no other witness — a successful rebuild puts every stamp out of reach, so a
    // version that dropped unconditionally would behave identically. The
    // predicate only RETAINS where rebuild backs out, under allocation failure.
    s.dropUnservablePending(td.srv.alloc);
    for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null);

    // DROP. `rows == 0` is what a failure inside rebuild's dump loop leaves
    // behind, and it makes `canServe` false for every seq. Set directly because
    // there is no failing-allocator seam into a live Server, so what stays
    // unfalsifiable is the WIRING and no longer the rule.
    s.tracker.rows = 0;
    s.dropUnservablePending(td.srv.alloc);
    for (s.pendingSlots()) |slot| try std.testing.expect(slot.* == null);
}

test "Server: recordPending stores nothing against a tracker that can serve no seq" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gapunbuilt");
    defer td.deinit();

    const script = try writeGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    // No client connects at all; the session exists because `Server.init` creates
    // the default one. This cannot reach the guard the natural way: `sendUpdate`
    // runs BEFORE the drain on every pty chunk, and a tracker with `rows == 0`
    // resyncs — which rebuilds even with nobody attached. So the FIRST chunk of
    // output builds the tracker whether or not anyone is watching.
    try td.start(.{ .shell = script });

    try proto.writeAllFd(td.srv.sessions.table[0].?.pty.master, "go\n");
    try std.testing.expect(try awaitGridText(alloc, &td.srv, "after-osc", 3000));

    // The control, and it is what the paragraph above earns: with no client
    // ever attached the tracker IS servable, and the events ARE recorded. A
    // refusal asserted without this would look like the guard working when it
    // was really the drain never reaching it.
    const s = &td.srv.sessions.table[0].?;
    try std.testing.expect(s.tracker.canServe(s.tracker.seq));
    for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null);
    s.freePending(td.srv.alloc);

    // The guard itself, by direct call against a stranded tracker. Reachable in a
    // live daemon only under allocation failure: the rule is testable, the path
    // to it is not.
    s.tracker.rows = 0;
    s.recordPending(td.srv.alloc, .clipboard, gap_clip_first);
    s.recordPending(td.srv.alloc, .bell, "");
    for (s.pendingSlots()) |slot| try std.testing.expect(slot.* == null);
}

test "Server: a session that dies holding a pending event frees it" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "gapdeath");
    defer td.deinit();

    const script = try writeDyingGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    // A client attaches, and NOT because the recording needs one. It is here
    // because `reap` does more for a session that HAS clients — queue
    // `exit_status`, drain, drop, then free — so attaching puts the free after
    // that sequence rather than after two no-op loops.
    const c = try dial.dialAttach(td.sock_path, 80, 24);
    defer c.close();
    (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse
        return error.NoSnapshot).deinit(alloc);

    try proto.writeAllFd(td.srv.sessions.table[0].?.pty.master, "go\n");
    try std.testing.expect(try awaitGridText(alloc, &td.srv, "after-osc", 3000));
    // Non-vacuity, and it is the whole test: with an empty slot the teardown
    // below frees nothing and the leak this pins could not occur.
    for (td.srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* != null);

    // Now let the shell exit: the `reap` teardown, a session dying while the
    // daemon lives on. The other gap tests never reach it — they hold their
    // shells open and so exercise only `Server.deinit`.
    try proto.writeAllFd(td.srv.sessions.table[0].?.pty.master, "die\n");
    var reaped = false;
    for (0..600) |_| {
        td.srv.pumpOnce(5) catch break;
        if (td.srv.sessions.table[0] == null) {
            reaped = true;
            break;
        }
    }
    try std.testing.expect(reaped);
    // testing.allocator is the assertion from here: delete freePending from
    // reapSessions and this test reports the payloads as leaked.
}