a73x

8c4695ae

test: the delta fixture replays through the one core, and a zero-row delta is pinned

a73x   2026-09-04 18:04

Commit message
test: the delta fixture replays through the one core, and a zero-row delta is pinned

Three items the Task 5 review left for this task.

delta.zig's `deltaText` walked the row iterator and called `Grid.applyRow`
itself, which is a second delta applier in a test fixture and against
CLAUDE.md's one-replay-core rule. It now goes through `Replica.apply`, so a
rule the replica grows — a row index the header cannot justify, a
row_count mismatch — grades these tracker payloads too instead of passing
under them.

The zero-row delta had no pin left after the rewrite: the deleted "empty
delta composes to a bare cursor home" was the only test applying a delta
with row_count 0, and the tracker mints exactly that frame when only the
cursor moved. replica.zig now pins it: `.painted`, last_seq advanced, the
cursor at the header's position, the grid text untouched. Watched to fail
with `row_count == 0` forced to `.resync`.

Two comments described the mechanism Task 5 removed. `hitTest`'s refusal
while scrolled said `renderScrollback` blits VT bytes with no engine behind
them; it paints decoded history rows the Core does not hold. wasm_core's
scrollback section called itself a scratch terminal; it is the last fetched
chunk, decoded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

src/client/wasm_core.zig
Old New
@@ -567,7 +567,8 @@ fn paintRowFrom(c: *Core, r: *const grid_mod.Row, y: u16) void {
567 } 567 }
568 568
569 // --------------------------------------------------------------------- 569 // ---------------------------------------------------------------------
570 // Scrollback view (a scratch terminal; the live replica is never touched) 570 // Scrollback view (the last fetched chunk, decoded; the live replica is
571 // never touched)
571 // --------------------------------------------------------------------- 572 // ---------------------------------------------------------------------
572 573
573 /// Decode `len` staged bytes: a WHOLE scrollback_chunk payload, echoed 574 /// Decode `len` staged bytes: a WHOLE scrollback_chunk payload, echoed
src/engine/delta.zig
Old New
@@ -231,12 +231,19 @@ pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.Snaps
231 // way a client does — through grid.decodeRow — rather than searching bytes. 231 // way a client does — through grid.decodeRow — rather than searching bytes.
232 232
233 const grid = @import("term").grid; 233 const grid = @import("term").grid;
234 234 const Replica = @import("term").replica.Replica;
235 /// Apply every row of a delta payload into `g` and return the plain text, so 235
236 /// a test can say what the far side would be SHOWING after the frame. 236 /// Apply a delta payload into `g` and return the plain text, so a test can
237 /// say what the far side would be SHOWING after the frame.
238 ///
239 /// Through `Replica.apply` rather than a loop over the rows: that is the one
240 /// replay core, and a fixture with its own applier grades payloads by rules
241 /// the product does not use. A payload the product would resync on is an
242 /// error here, since every caller builds one it expects to land.
237 fn deltaText(alloc: std.mem.Allocator, g: *grid.Grid, payload: []const u8) ![]const u8 { 243 fn deltaText(alloc: std.mem.Allocator, g: *grid.Grid, payload: []const u8) ![]const u8 {
238 var it = proto.deltaRowIterator(payload); 244 var r = Replica.init(alloc, g);
239 while (try it.next()) |row| try g.applyRow(row.row, row.bytes); 245 const applied = try r.apply(.delta, payload);
246 if (applied != .painted) return error.DeltaRefused;
240 return g.dumpPlain(alloc); 247 return g.dumpPlain(alloc);
241 } 248 }
242 249
src/engine/replica.zig
Old New
@@ -409,6 +409,47 @@ test "delta replay: rows land, last_seq advances, history and cursor follow" {
409 try std.testing.expectEqualStrings("hi\n\nyo\nok", dump); 409 try std.testing.expectEqualStrings("hi\n\nyo\nok", dump);
410 } 410 }
411 411
412 test "delta replay: a delta carrying no rows moves the cursor and nothing else" {
413 const alloc = std.testing.allocator;
414 const g = try Grid.init(alloc, 80, 24);
415 defer g.deinit();
416 var r = Replica.init(alloc, g);
417
418 var rows: [24][]const u8 = undefined;
419 for (&rows) |*row| row.* = "";
420 rows[1] = "keep me";
421 const snap = try testSnapshot(alloc, .{
422 .seq = 4,
423 .history_rows = 0,
424 .cols = 80,
425 .rows = 24,
426 .epoch = 1,
427 }, .{ .x = 0, .y = 0 }, &rows);
428 defer alloc.free(snap);
429 _ = try r.apply(.snapshot, snap);
430
431 // The tracker mints exactly this frame when only the cursor moved, so a
432 // replica that refused it would resync a client on every arrow key.
433 var delta: std.ArrayList(u8) = .empty;
434 defer delta.deinit(alloc);
435 try proto.appendDeltaHeader(&delta, alloc, .{
436 .seq = 5,
437 .history_rows = 0,
438 .cursor_x = 9,
439 .cursor_y = 4,
440 .row_count = 0,
441 });
442
443 try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.delta, delta.items));
444 try std.testing.expectEqual(@as(u64, 5), r.last_seq);
445 try std.testing.expectEqual(@as(u16, 9), g.cursor.x);
446 try std.testing.expectEqual(@as(u16, 4), g.cursor.y);
447
448 const dump = try g.dumpPlain(alloc);
449 defer alloc.free(dump);
450 try std.testing.expectEqualStrings("\nkeep me", dump);
451 }
452
412 test "delta decode failure reports .resync — and its arrival still proves admission" { 453 test "delta decode failure reports .resync — and its arrival still proves admission" {
413 const alloc = std.testing.allocator; 454 const alloc = std.testing.allocator;
414 const g = try Grid.init(alloc, 80, 24); 455 const g = try Grid.init(alloc, 80, 24);
src/tui/interact.zig
Old New
@@ -1933,9 +1933,9 @@ pub const Core = struct {
1933 /// trivial, which is why selection lives in the Core: a terminal cell is a 1933 /// trivial, which is why selection lives in the Core: a terminal cell is a
1934 /// grid cell less the tile's origin, plus the history under it. 1934 /// grid cell less the tile's origin, plus the history under it.
1935 fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit { 1935 fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit {
1936 // Scrolled back, nothing here is addressable: `renderScrollback` blits 1936 // Scrolled back, nothing here is addressable: `renderScrollback`
1937 // VT bytes with no engine behind them. Refused rather than 1937 // paints decoded history rows this Core does not hold. Refused rather
1938 // half-answered, or the coordinates name the live view instead. 1938 // than half-answered, or the coordinates name the live view instead.
1939 if (self.scroll_rows > 0) return null; 1939 if (self.scroll_rows > 0) return null;
1940 // A tile paints at `row_off`/`col_off`, so a terminal cell is a grid 1940 // A tile paints at `row_off`/`col_off`, so a terminal cell is a grid
1941 // cell only after the origin comes off — on BOTH axes. Above or left 1941 // cell only after the origin comes off — on BOTH axes. Above or left
@@ -2987,7 +2987,7 @@ const full_vp: paint_mod.Viewport = .{ .top = 0, .left = 0, .rows = 24, .cols =
2987 2987
2988 /// Only the tests reach for an engine now: one authors the screens a client 2988 /// Only the tests reach for an engine now: one authors the screens a client
2989 /// would be sent, and one plays the terminal the client paints onto. 2989 /// would be sent, and one plays the terminal the client paints onto.
2990 const Engine = @import("term").engine.Engine; 2990 const Engine = @import("engine").Engine;
2991 2991
2992 /// An engine and the grid it mirrors into. A test describes a screen in VT, 2992 /// An engine and the grid it mirrors into. A test describes a screen in VT,
2993 /// and what the code under test reads is the cells — through the daemon's 2993 /// and what the code under test reads is the cells — through the daemon's
@@ -4060,10 +4060,10 @@ test "interact: a drag while scrolled back selects nothing" {
4060 var buf: [8192]u8 = undefined; 4060 var buf: [8192]u8 = undefined;
4061 _ = drainPipe(p[0], &buf); 4061 _ = drainPipe(p[0], &buf);
4062 4062
4063 // Viewing history: `renderScrollback` blits daemon VT bytes with no 4063 // Viewing history: `renderScrollback` paints decoded history rows this
4064 // engine behind them, so nothing on this screen is a row this Core can 4064 // Core does not hold, so nothing on this screen is a row it can name.
4065 // name. Refused outright rather than answered against the live view 4065 // Refused outright rather than answered against the live view the user
4066 // the user is not looking at. 4066 // is not looking at.
4067 core.scroll_rows = 4; 4067 core.scroll_rows = 4;
4068 try mouse(&core, &tr, 0, 3, 2, 'M'); 4068 try mouse(&core, &tr, 0, 3, 2, 'M');
4069 try mouse(&core, &tr, 32, 7, 2, 'M'); 4069 try mouse(&core, &tr, 32, 7, 2, 'M');