a73x

src/engine/replica.zig

Ref:   Size: 26.6 KiB   History

//! The replay core: what a mux client does to mirror a daemon's session.
//! Applies snapshot and delta frames into a local grid, tracks the resume
//! coordinates, and follows the authoritative grid — ONE implementation for the
//! CLI client, the wasm core and the server's test fixtures.
//!
//! No VT parser on this side: the frames carry cells, and grid.zig copies them
//! in. Deliberately platform-free too — no posix, no fds, no clocks, since this
//! must compile for wasm32-freestanding. The Replica BORROWS its grid; the
//! caller owns that lifetime.

const std = @import("std");
const grid_mod = @import("grid.zig");
const Grid = grid_mod.Grid;
const proto = @import("protocol.zig");

pub const Replica = struct {
    alloc: std.mem.Allocator,
    /// Borrowed. The grid the frames are copied into, and the authoritative
    /// size: under latest-wins another client's attach or resize can make it
    /// differ from any local tty, and the replica follows the grid. Its own
    /// dimensions are what a snapshot's prefix is compared against, so a grid
    /// resized by anyone else is still one this can be indexed with.
    grid: *Grid,
    /// The daemon instance we are talking to, learned from its snapshots,
    /// and quoted back on reconnect so the daemon can tell whether the seq
    /// we hold is one of its own (a restarted daemon counts from zero over
    /// different content). 0 until the first snapshot: "I hold nothing".
    session_epoch: u64 = 0,
    /// The newest seq we hold. Quoted on reconnect; the daemon answers
    /// with a delta when it can still reach us from there.
    last_seq: u64 = 0,
    history_rows: u32 = 0,
    /// Whether state has arrived ON THE CURRENT ATTACH. A refusal arrives as
    /// `exit_status` before anything else, which without this is
    /// indistinguishable from the shell exiting 1. Any replay frame's arrival
    /// sets it — even a delta that fails to decode proves we were admitted —
    /// and the CALLER clears it, since only the caller knows about a re-attach.
    state_since_attach: bool = false,

    pub const Applied = enum {
        /// The frame landed; the grid reflects it.
        painted,
        /// A delta that could not be trusted; the grid was not touched. The
        /// caller re-attaches with `have_seq=0`, since quoting a seq invites the
        /// delta that cannot fix us. NOT the reconnect path: the transport is
        /// alive and the replica is what is suspect.
        resync,
    };

    pub fn init(alloc: std.mem.Allocator, g: *Grid) Replica {
        return .{ .alloc = alloc, .grid = g };
    }

    /// Consume one replay frame; only `.snapshot` and `.delta` are replay
    /// frames.
    ///
    /// `.snapshot` has two failure shapes, and they are DIFFERENT errors
    /// because the grid is in a different state after each.
    ///
    /// `error.BadPayload` is the untouched one: a payload too short for the
    /// prefix or the cursor, or a `cols` beyond `max_cols`. All three are
    /// caught before anything is written, `state_since_attach` is untouched,
    /// and a caller that skips the frame is left holding exactly what it held
    /// before — a short snapshot proves nothing.
    ///
    /// `error.SnapshotAborted` is the destructive one: the prefix was good,
    /// so the grid has already been resized and cleared and the resume
    /// coordinates already adopted, and then a row would not decode. The grid
    /// is now blank while `last_seq` claims to be current, so this replica
    /// must not be used again — the caller ENDS the tile with the error. It
    /// is not `.resync` either: a snapshot IS the resync, so asking for
    /// another cannot fix it.
    ///
    /// `.delta`: arrival alone sets `state_since_attach`; a rejected payload
    /// is `.resync`, and the rows that did decode before the bad one stay —
    /// the resync's snapshot rewrites every row anyway.
    pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied {
        switch (t) {
            .snapshot => {
                const prefix = try proto.readSnapshotPrefix(payload);
                const cur = try proto.readSnapshotCursor(payload);
                // Refused BEFORE the resize, so a width no row could ever
                // decode is reported against the prefix that named it rather
                // than against the first row, and so the grid is never left
                // at a size nothing can be decoded into.
                if (prefix.cols > proto.max_cols) return error.BadPayload;
                self.state_since_attach = true;
                self.session_epoch = prefix.epoch;
                self.last_seq = prefix.seq;
                self.history_rows = prefix.history_rows;
                if (prefix.cols != self.grid.cols or prefix.rows != self.grid.rows)
                    try self.grid.resize(prefix.cols, prefix.rows);
                // Cleared, then every row written: a snapshot is the whole
                // screen, and a row the payload happens not to reach must not
                // keep what the previous grid had there.
                self.grid.clear();
                var rest = payload[proto.snapshot_prefix_len + proto.snapshot_cursor_len ..];
                var y: u16 = 0;
                while (y < prefix.rows) : (y += 1) {
                    // Past the clear, so there is no untouched grid to hand
                    // back: any decode failure here is the aborted kind.
                    rest = grid_mod.decodeRow(self.alloc, &self.grid.lines[y], rest, self.grid.cols) catch |err| switch (err) {
                        error.OutOfMemory => return err,
                        else => return error.SnapshotAborted,
                    };
                }
                self.grid.cursor = .{ .x = cur.x, .y = cur.y };
                return .painted;
            },
            .delta => {
                self.state_since_attach = true;
                const hdr = proto.readDeltaHeader(payload) catch return .resync;
                var it = proto.deltaRowIterator(payload);
                var seen: usize = 0;
                while (it.next() catch return .resync) |row| {
                    self.grid.applyRow(row.row, row.bytes) catch return .resync;
                    seen += 1;
                }
                // The header's row_count is authoritative: a payload carrying
                // fewer rows than it claims is a truncation, not a short frame.
                if (seen != hdr.row_count) return .resync;
                self.history_rows = hdr.history_rows;
                self.last_seq = hdr.seq;
                self.grid.cursor = .{ .x = hdr.cursor_x, .y = hdr.cursor_y };
                return .painted;
            },
            else => unreachable, // not a replay frame; callers dispatch
        }
    }

    pub const AttachArgs = struct { have_seq: u64, have_epoch: u64 };

    /// (0,0) until the first snapshot. The delta-resync re-attach quotes
    /// (0,0) at its call site instead: what we hold is untrusted.
    pub fn attachArgs(self: *const Replica) AttachArgs {
        return .{ .have_seq = self.last_seq, .have_epoch = self.session_epoch };
    }

    /// Rows, not pages: the keys scroll a screenful, the wheel a few
    /// lines; only one can be the wire's.
    pub fn scrollStart(self: *const Replica, rows_up: u32) u32 {
        return self.history_rows -| rows_up;
    }
};

// ---------------------------------------------------------------------------
// Tests. The wire layouts these build are golden-pinned in protocol.zig, so
// constructing real payloads here is mechanical, not speculative.

/// A snapshot payload: prefix, cursor, then one CellRow per row of `rows`,
/// each row being the plain text to put at column 0.
fn testSnapshot(
    alloc: std.mem.Allocator,
    p: proto.SnapshotPrefix,
    cursor: proto.SnapshotCursor,
    rows: []const []const u8,
) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&pbuf, p);
    try out.appendSlice(alloc, &pbuf);
    var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
    proto.writeSnapshotCursor(&cbuf, cursor.x, cursor.y);
    try out.appendSlice(alloc, &cbuf);
    for (rows) |text| try appendTextRow(&out, alloc, text);
    return out.toOwnedSlice(alloc);
}

/// One CellRow of default-styled narrow ASCII cells.
fn appendTextRow(list: *std.ArrayList(u8), alloc: std.mem.Allocator, text: []const u8) !void {
    var w = try proto.CellRowWriter.begin(list, alloc);
    errdefer w.deinit();
    for (text) |ch| try w.cell(.{}, .narrow, &[_]u8{ch});
    w.finish();
}

/// The CellRow bytes alone, for a delta row.
fn testRow(alloc: std.mem.Allocator, text: []const u8) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    try appendTextRow(&out, alloc, text);
    return out.toOwnedSlice(alloc);
}

test "snapshot replay: prefix consumed, rows copied in, epoch and seq adopted" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    // Two rows authored, not one: a decoder that stopped after the first row
    // would still make the assertion about "hi" pass.
    var rows: [24][]const u8 = undefined;
    for (&rows) |*row| row.* = "";
    rows[0] = "hi";
    rows[1] = "there";
    const payload = try testSnapshot(alloc, .{
        .seq = 7,
        .history_rows = 3,
        .cols = 80,
        .rows = 24,
        .epoch = 0xABCD,
    }, .{ .x = 2, .y = 1 }, &rows);
    defer alloc.free(payload);

    try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload));
    try std.testing.expectEqual(@as(u64, 7), r.last_seq);
    try std.testing.expectEqual(@as(u64, 0xABCD), r.session_epoch);
    try std.testing.expectEqual(@as(u32, 3), r.history_rows);
    try std.testing.expect(r.state_since_attach);
    // The cursor rides in the body and is adopted with the rows.
    try std.testing.expectEqual(@as(u16, 2), g.cursor.x);
    try std.testing.expectEqual(@as(u16, 1), g.cursor.y);

    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("hi\nthere", dump);
}

test "snapshot replay: a row the previous grid held is cleared, not kept" {
    // A snapshot is the whole screen. Without the clear, a shorter payload's
    // unwritten rows would keep the old session's text under the new one.
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 8, 3);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    const first = try testSnapshot(alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cols = 8,
        .rows = 3,
        .epoch = 1,
    }, .{ .x = 0, .y = 0 }, &.{ "aaa", "bbb", "ccc" });
    defer alloc.free(first);
    _ = try r.apply(.snapshot, first);

    const second = try testSnapshot(alloc, .{
        .seq = 2,
        .history_rows = 0,
        .cols = 8,
        .rows = 3,
        .epoch = 1,
    }, .{ .x = 0, .y = 0 }, &.{ "z", "", "" });
    defer alloc.free(second);
    _ = try r.apply(.snapshot, second);

    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("z", dump);
}

test "snapshot at a new grid size resizes the replica grid first" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    var rows: [30][]const u8 = undefined;
    for (&rows) |*row| row.* = "";
    rows[0] = "wide";
    const payload = try testSnapshot(alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cols = 100,
        .rows = 30,
        .epoch = 1,
    }, .{ .x = 4, .y = 0 }, &rows);
    defer alloc.free(payload);

    try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload));
    try std.testing.expectEqual(@as(u16, 100), g.cols);
    try std.testing.expectEqual(@as(u16, 30), g.rows);
}

test "snapshot: a grid resized by somebody else is measured as it is, not as it was" {
    // The size a prefix is compared against is the GRID's own, not a copy the
    // replica took at init: a client that resized its grid for its own reasons
    // and then took a snapshot of the size the replica remembered would skip
    // the resize and write rows off the end of the shorter grid.
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 8, 4);
    defer g.deinit();
    var r = Replica.init(alloc, g);
    try g.resize(4, 2);

    const payload = try testSnapshot(alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cols = 8,
        .rows = 4,
        .epoch = 1,
    }, .{ .x = 0, .y = 0 }, &.{ "aaaa", "bbbb", "cccc", "dddd" });
    defer alloc.free(payload);

    try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload));
    try std.testing.expectEqual(@as(u16, 8), g.cols);
    try std.testing.expectEqual(@as(u16, 4), g.rows);
    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("aaaa\nbbbb\ncccc\ndddd", dump);
}

test "short snapshot proves nothing: BadPayload, state_since_attach untouched" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    const short = [_]u8{0} ** (proto.snapshot_prefix_len - 1);
    try std.testing.expectError(error.BadPayload, r.apply(.snapshot, &short));
    // A whole prefix and no cursor is short too: the four bytes behind it
    // would otherwise be read out of the first row.
    const no_cursor = [_]u8{0} ** proto.snapshot_prefix_len;
    try std.testing.expectError(error.BadPayload, r.apply(.snapshot, &no_cursor));
    try std.testing.expect(!r.state_since_attach);
    try std.testing.expectEqual(@as(u64, 0), r.last_seq);
}

test "replica: a snapshot whose rows do not decode is SnapshotAborted, not resync" {
    // A snapshot IS the resync, so answering one with "please resync" is a
    // loop. The caller ends the tile on the error instead — and it is a
    // DISTINCT error from the short-payload BadPayload, because by the time a
    // row fails the grid has been cleared and the seq adopted.
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    // A body of VT bytes: what a daemon on the far side of the renumbering
    // would have sent, and what this decoder must refuse rather than paint.
    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&pbuf, .{
        .seq = 1,
        .history_rows = 0,
        .cols = 80,
        .rows = 24,
        .epoch = 1,
    });
    try payload.appendSlice(alloc, &pbuf);
    var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
    proto.writeSnapshotCursor(&cbuf, 0, 0);
    try payload.appendSlice(alloc, &cbuf);
    try payload.appendSlice(alloc, "\x1b[1mVT");

    try std.testing.expectError(error.SnapshotAborted, r.apply(.snapshot, payload.items));
}

test "replica: a good prefix and a bad row partway through aborts, and says so" {
    // The I1 shape: rows 0 and 1 decode, row 2 does not. Everything before it
    // has already been written into a grid that was cleared for this snapshot,
    // so the replica cannot answer "nothing happened" — and a caller that
    // treated this like a short payload would carry on over a blanked grid
    // whose last_seq claims to be current.
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 8, 4);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    const seed = try testSnapshot(alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cols = 8,
        .rows = 4,
        .epoch = 1,
    }, .{ .x = 0, .y = 0 }, &.{ "aaa", "bbb", "ccc", "ddd" });
    defer alloc.free(seed);
    _ = try r.apply(.snapshot, seed);

    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&pbuf, .{
        .seq = 2,
        .history_rows = 0,
        .cols = 8,
        .rows = 4,
        .epoch = 1,
    });
    try payload.appendSlice(alloc, &pbuf);
    var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
    proto.writeSnapshotCursor(&cbuf, 0, 0);
    try payload.appendSlice(alloc, &cbuf);
    try appendTextRow(&payload, alloc, "one");
    try appendTextRow(&payload, alloc, "two");
    // Row 2: a CellRow header claiming more cells than the grid has columns,
    // which `decodeRow` refuses before it writes.
    const wide = try testRow(alloc, "aaaaaaaaaaaa");
    defer alloc.free(wide);
    try payload.appendSlice(alloc, wide);

    try std.testing.expectError(error.SnapshotAborted, r.apply(.snapshot, payload.items));
}

test "replica: a snapshot claiming more columns than a row can hold is refused at the prefix" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    var payload: std.ArrayList(u8) = .empty;
    defer payload.deinit(alloc);
    var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&pbuf, .{
        .seq = 1,
        .history_rows = 0,
        .cols = proto.max_cols + 1,
        .rows = 1,
        .epoch = 1,
    });
    try payload.appendSlice(alloc, &pbuf);
    var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
    proto.writeSnapshotCursor(&cbuf, 0, 0);
    try payload.appendSlice(alloc, &cbuf);
    try appendTextRow(&payload, alloc, "a");

    try std.testing.expectError(error.BadPayload, r.apply(.snapshot, payload.items));
    // The grid is still the one it was, at the size it was: nothing resized
    // to a width no row could ever be decoded into.
    try std.testing.expectEqual(@as(u16, 80), g.cols);
    try std.testing.expectEqual(@as(u16, 24), g.rows);
}

test "delta replay: rows land, last_seq advances, history and cursor follow" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    var rows: [24][]const u8 = undefined;
    for (&rows) |*row| row.* = "";
    rows[0] = "hi";
    const snap = try testSnapshot(alloc, .{
        .seq = 7,
        .history_rows = 0,
        .cols = 80,
        .rows = 24,
        .epoch = 0xABCD,
    }, .{ .x = 0, .y = 0 }, &rows);
    defer alloc.free(snap);
    _ = try r.apply(.snapshot, snap);

    // Two rows, and neither of them row 0: a delta that applied its rows at
    // the wrong index would still show the text somewhere.
    var delta: std.ArrayList(u8) = .empty;
    defer delta.deinit(alloc);
    try proto.appendDeltaHeader(&delta, alloc, .{
        .seq = 8,
        .history_rows = 2,
        .cursor_x = 2,
        .cursor_y = 3,
        .row_count = 2,
    });
    const row_two = try testRow(alloc, "yo");
    defer alloc.free(row_two);
    const row_three = try testRow(alloc, "ok");
    defer alloc.free(row_three);
    try proto.appendDeltaRow(&delta, alloc, 2, row_two);
    try proto.appendDeltaRow(&delta, alloc, 3, row_three);

    try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.delta, delta.items));
    try std.testing.expectEqual(@as(u64, 8), r.last_seq);
    try std.testing.expectEqual(@as(u32, 2), r.history_rows);
    try std.testing.expectEqual(@as(u16, 2), g.cursor.x);
    try std.testing.expectEqual(@as(u16, 3), g.cursor.y);

    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("hi\n\nyo\nok", dump);
}

test "delta replay: a delta carrying no rows moves the cursor and nothing else" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    var rows: [24][]const u8 = undefined;
    for (&rows) |*row| row.* = "";
    rows[1] = "keep me";
    const snap = try testSnapshot(alloc, .{
        .seq = 4,
        .history_rows = 0,
        .cols = 80,
        .rows = 24,
        .epoch = 1,
    }, .{ .x = 0, .y = 0 }, &rows);
    defer alloc.free(snap);
    _ = try r.apply(.snapshot, snap);

    // The tracker mints exactly this frame when only the cursor moved, so a
    // replica that refused it would resync a client on every arrow key.
    var delta: std.ArrayList(u8) = .empty;
    defer delta.deinit(alloc);
    try proto.appendDeltaHeader(&delta, alloc, .{
        .seq = 5,
        .history_rows = 0,
        .cursor_x = 9,
        .cursor_y = 4,
        .row_count = 0,
    });

    try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.delta, delta.items));
    try std.testing.expectEqual(@as(u64, 5), r.last_seq);
    try std.testing.expectEqual(@as(u16, 9), g.cursor.x);
    try std.testing.expectEqual(@as(u16, 4), g.cursor.y);

    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("\nkeep me", dump);
}

test "delta decode failure reports .resync — and its arrival still proves admission" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    // Header claims two rows, payload carries one: the row_count check.
    var delta: std.ArrayList(u8) = .empty;
    defer delta.deinit(alloc);
    try proto.appendDeltaHeader(&delta, alloc, .{
        .seq = 9,
        .history_rows = 0,
        .cursor_x = 0,
        .cursor_y = 0,
        .row_count = 2,
    });
    const row = try testRow(alloc, "x");
    defer alloc.free(row);
    try proto.appendDeltaRow(&delta, alloc, 0, row);

    try std.testing.expectEqual(Replica.Applied.resync, try r.apply(.delta, delta.items));
    // The subtlety the CLI relies on: a delta's ARRIVAL alone proves the
    // attach was admitted, decodable or not...
    try std.testing.expect(r.state_since_attach);
    // ...while the resume coordinates stay exactly where they were.
    try std.testing.expectEqual(@as(u64, 0), r.last_seq);
}

test "replica: a delta whose row is wider than the grid is resync and the grid is untouched" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 4, 2);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    const seed = try testSnapshot(alloc, .{
        .seq = 1,
        .history_rows = 0,
        .cols = 4,
        .rows = 2,
        .epoch = 1,
    }, .{ .x = 0, .y = 0 }, &.{ "keep", "me" });
    defer alloc.free(seed);
    _ = try r.apply(.snapshot, seed);

    var delta: std.ArrayList(u8) = .empty;
    defer delta.deinit(alloc);
    try proto.appendDeltaHeader(&delta, alloc, .{
        .seq = 2,
        .history_rows = 5,
        .cursor_x = 1,
        .cursor_y = 1,
        .row_count = 1,
    });
    // Five cells into a four-column grid.
    const too_wide = try testRow(alloc, "abcde");
    defer alloc.free(too_wide);
    try proto.appendDeltaRow(&delta, alloc, 0, too_wide);

    try std.testing.expectEqual(Replica.Applied.resync, try r.apply(.delta, delta.items));
    // Neither the row nor the resume coordinates moved: `decodeRow` validates
    // before it writes, so a refused row leaves what was there.
    const dump = try g.dumpPlain(alloc);
    defer alloc.free(dump);
    try std.testing.expectEqualStrings("keep\nme", dump);
    try std.testing.expectEqual(@as(u64, 1), r.last_seq);
    try std.testing.expectEqual(@as(u32, 0), r.history_rows);
}

test "attach args: first attach quotes (0,0); after a snapshot, (last_seq, epoch)" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);

    const fresh = r.attachArgs();
    try std.testing.expectEqual(@as(u64, 0), fresh.have_seq);
    try std.testing.expectEqual(@as(u64, 0), fresh.have_epoch);

    var rows: [24][]const u8 = undefined;
    for (&rows) |*row| row.* = "";
    const snap = try testSnapshot(alloc, .{
        .seq = 42,
        .history_rows = 0,
        .cols = 80,
        .rows = 24,
        .epoch = 0xFEED,
    }, .{ .x = 0, .y = 0 }, &rows);
    defer alloc.free(snap);
    _ = try r.apply(.snapshot, snap);

    const held = r.attachArgs();
    try std.testing.expectEqual(@as(u64, 42), held.have_seq);
    try std.testing.expectEqual(@as(u64, 0xFEED), held.have_epoch);
}

test "scrollStart: rows count up from the live viewport top, saturating at row 0" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 80, 24);
    defer g.deinit();
    var r = Replica.init(alloc, g);
    r.history_rows = 100;

    // A screenful at a time, as the scroll keys ask...
    try std.testing.expectEqual(@as(u32, 76), r.scrollStart(24));
    try std.testing.expectEqual(@as(u32, 52), r.scrollStart(48));
    // ...and three at a time, as a wheel notch does.
    try std.testing.expectEqual(@as(u32, 97), r.scrollStart(3));
    try std.testing.expectEqual(@as(u32, 0), r.scrollStart(120));
}

// 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());
}

test "replica: old daemon snapshot containing DEL recovers without changing its wire or session" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 1, 1);
    defer g.deinit();
    var replica = Replica.init(alloc, g);
    var snapshot: std.ArrayList(u8) = .empty;
    defer snapshot.deinit(alloc);
    var prefix: [proto.snapshot_prefix_len]u8 = undefined;
    proto.writeSnapshotPrefix(&prefix, .{ .seq = 37, .history_rows = 0, .cols = 8, .rows = 2, .epoch = 93 });
    try snapshot.appendSlice(alloc, &prefix);
    var cursor: [proto.snapshot_cursor_len]u8 = undefined;
    proto.writeSnapshotCursor(&cursor, 4, 1);
    try snapshot.appendSlice(alloc, &cursor);
    // Literal old encoder output, deliberately bypassing today's writer:
    // first row "a<DEL>b", second row "next". A retained daemon grid sends
    // this same unsafe cell on every new attach until it is overwritten.
    try snapshot.appendSlice(alloc, &.{ 3, 0, 3, 0, 0, 1, 'a', 1, 0x7f, 1, 'b', 4, 0, 4, 0, proto.mask_ascii, 'n', 'e', 'x', 't' });
    for (0..2) |_| {
        replica.state_since_attach = false;
        try std.testing.expectEqual(Replica.Applied.painted, try replica.apply(.snapshot, snapshot.items));
        try std.testing.expect(replica.state_since_attach);
        try std.testing.expectEqual(@as(u64, 37), replica.last_seq);
        try std.testing.expectEqual(@as(u64, 93), replica.session_epoch);
        const dump = try g.dumpPlain(alloc);
        defer alloc.free(dump);
        try std.testing.expectEqualStrings("a\xef\xbf\xbdb\nnext", dump);
        try std.testing.expectEqual(grid_mod.CursorPos{ .x = 4, .y = 1 }, g.cursor);
    }
}