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