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.
}