a73x

d1fc858d

fix: a snapshot that blanked the grid before failing ends the tile

a73x   2026-09-04 18:04

Commit message
fix: a snapshot that blanked the grid before failing ends the tile

`Replica.apply(.snapshot)` adopts the epoch and seq, resizes and CLEARS the
grid on the strength of the prefix, then decodes the rows. A row that would
not decode returned error.BadPayload from mid-loop — the same error a payload
too short for the prefix returns, which really does leave the grid untouched.
interact's `frame` mapped BadPayload to `.skip`, so the tile carried on over a
blank grid whose `last_seq` claimed to be current: stale text on screen until
the next full repaint, an empty selection copy, and a redial quoting a seq for
a screen the client never held.

The two shapes are different errors now. error.BadPayload keeps its meaning —
nothing was written, `.skip` is right — and error.SnapshotAborted names the
destructive one, so no caller's switch can treat it as harmless. interact
returns it to the driver, wasm_core answers -3 (mux.js already resets the core
on any negative return) and the wsclient stand-in stops reporting state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

src/client/wasm_core.zig
Old New
@@ -205,6 +205,10 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
205 const applied = c.rep.apply(t, payload) catch |err| switch (err) { 205 const applied = c.rep.apply(t, payload) catch |err| switch (err) {
206 // A snapshot that will not decode proves nothing and paints nothing. 206 // A snapshot that will not decode proves nothing and paints nothing.
207 error.BadPayload => return -3, 207 error.BadPayload => return -3,
208 // The destructive one: the grid has been cleared and the seq adopted
209 // before the bad row. Same -3, and mux.js resets the core on any
210 // negative return, so the host never reads from the blanked replica.
211 error.SnapshotAborted => return -3,
208 // Allocation failure, or a grid the daemon named that is beyond us. 212 // Allocation failure, or a grid the daemon named that is beyond us.
209 else => return -3, 213 else => return -3,
210 }; 214 };
src/engine/replica.zig
Old New
@@ -54,12 +54,22 @@ pub const Replica = struct {
54 /// Consume one replay frame; only `.snapshot` and `.delta` are replay 54 /// Consume one replay frame; only `.snapshot` and `.delta` are replay
55 /// frames. 55 /// frames.
56 /// 56 ///
57 /// `.snapshot`: a payload too short for the prefix or the cursor is 57 /// `.snapshot` has two failure shapes, and they are DIFFERENT errors
58 /// `error.BadPayload` with nothing consumed and `state_since_attach` 58 /// because the grid is in a different state after each.
59 /// untouched, because a short snapshot proves nothing. A row that will 59 ///
60 /// not decode is `error.BadPayload` as well rather than `.resync`: a 60 /// `error.BadPayload` is the untouched one: a payload too short for the
61 /// snapshot IS the resync, so asking for another cannot fix it, and the 61 /// prefix or the cursor, or a `cols` beyond `max_cols`. All three are
62 /// caller ends the tile with the error instead of looping. 62 /// caught before anything is written, `state_since_attach` is untouched,
63 /// and a caller that skips the frame is left holding exactly what it held
64 /// before — a short snapshot proves nothing.
65 ///
66 /// `error.SnapshotAborted` is the destructive one: the prefix was good,
67 /// so the grid has already been resized and cleared and the resume
68 /// coordinates already adopted, and then a row would not decode. The grid
69 /// is now blank while `last_seq` claims to be current, so this replica
70 /// must not be used again — the caller ENDS the tile with the error. It
71 /// is not `.resync` either: a snapshot IS the resync, so asking for
72 /// another cannot fix it.
63 /// 73 ///
64 /// `.delta`: arrival alone sets `state_since_attach`; a rejected payload 74 /// `.delta`: arrival alone sets `state_since_attach`; a rejected payload
65 /// is `.resync`, and the rows that did decode before the bad one stay — 75 /// is `.resync`, and the rows that did decode before the bad one stay —
@@ -87,7 +97,12 @@ pub const Replica = struct {
87 var rest = payload[proto.snapshot_prefix_len + proto.snapshot_cursor_len ..]; 97 var rest = payload[proto.snapshot_prefix_len + proto.snapshot_cursor_len ..];
88 var y: u16 = 0; 98 var y: u16 = 0;
89 while (y < prefix.rows) : (y += 1) { 99 while (y < prefix.rows) : (y += 1) {
90 rest = try grid_mod.decodeRow(self.alloc, &self.grid.lines[y], rest, self.grid.cols); 100 // Past the clear, so there is no untouched grid to hand
101 // back: any decode failure here is the aborted kind.
102 rest = grid_mod.decodeRow(self.alloc, &self.grid.lines[y], rest, self.grid.cols) catch |err| switch (err) {
103 error.OutOfMemory => return err,
104 else => return error.SnapshotAborted,
105 };
91 } 106 }
92 self.grid.cursor = .{ .x = cur.x, .y = cur.y }; 107 self.grid.cursor = .{ .x = cur.x, .y = cur.y };
93 return .painted; 108 return .painted;
@@ -303,9 +318,11 @@ test "short snapshot proves nothing: BadPayload, state_since_attach untouched" {
303 try std.testing.expectEqual(@as(u64, 0), r.last_seq); 318 try std.testing.expectEqual(@as(u64, 0), r.last_seq);
304 } 319 }
305 320
306 test "replica: a snapshot whose rows do not decode is BadPayload, not resync" { 321 test "replica: a snapshot whose rows do not decode is SnapshotAborted, not resync" {
307 // A snapshot IS the resync, so answering one with "please resync" is a 322 // A snapshot IS the resync, so answering one with "please resync" is a
308 // loop. The caller ends the tile on the error instead. 323 // loop. The caller ends the tile on the error instead — and it is a
324 // DISTINCT error from the short-payload BadPayload, because by the time a
325 // row fails the grid has been cleared and the seq adopted.
309 const alloc = std.testing.allocator; 326 const alloc = std.testing.allocator;
310 const g = try Grid.init(alloc, 80, 24); 327 const g = try Grid.init(alloc, 80, 24);
311 defer g.deinit(); 328 defer g.deinit();
@@ -329,7 +346,53 @@ test "replica: a snapshot whose rows do not decode is BadPayload, not resync" {
329 try payload.appendSlice(alloc, &cbuf); 346 try payload.appendSlice(alloc, &cbuf);
330 try payload.appendSlice(alloc, "\x1b[1mVT"); 347 try payload.appendSlice(alloc, "\x1b[1mVT");
331 348
332 try std.testing.expectError(error.BadPayload, r.apply(.snapshot, payload.items)); 349 try std.testing.expectError(error.SnapshotAborted, r.apply(.snapshot, payload.items));
350 }
351
352 test "replica: a good prefix and a bad row partway through aborts, and says so" {
353 // The I1 shape: rows 0 and 1 decode, row 2 does not. Everything before it
354 // has already been written into a grid that was cleared for this snapshot,
355 // so the replica cannot answer "nothing happened" — and a caller that
356 // treated this like a short payload would carry on over a blanked grid
357 // whose last_seq claims to be current.
358 const alloc = std.testing.allocator;
359 const g = try Grid.init(alloc, 8, 4);
360 defer g.deinit();
361 var r = Replica.init(alloc, g);
362
363 const seed = try testSnapshot(alloc, .{
364 .seq = 1,
365 .history_rows = 0,
366 .cols = 8,
367 .rows = 4,
368 .epoch = 1,
369 }, .{ .x = 0, .y = 0 }, &.{ "aaa", "bbb", "ccc", "ddd" });
370 defer alloc.free(seed);
371 _ = try r.apply(.snapshot, seed);
372
373 var payload: std.ArrayList(u8) = .empty;
374 defer payload.deinit(alloc);
375 var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
376 proto.writeSnapshotPrefix(&pbuf, .{
377 .seq = 2,
378 .history_rows = 0,
379 .cols = 8,
380 .rows = 4,
381 .epoch = 1,
382 });
383 try payload.appendSlice(alloc, &pbuf);
384 var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
385 proto.writeSnapshotCursor(&cbuf, 0, 0);
386 try payload.appendSlice(alloc, &cbuf);
387 try appendTextRow(&payload, alloc, "one");
388 try appendTextRow(&payload, alloc, "two");
389 // Row 2: a CellRow header claiming more cells than the grid has columns,
390 // which `decodeRow` refuses before it writes.
391 const wide = try testRow(alloc, "aaaaaaaaaaaa");
392 defer alloc.free(wide);
393 try payload.appendSlice(alloc, wide);
394
395 try std.testing.expectError(error.SnapshotAborted, r.apply(.snapshot, payload.items));
333 } 396 }
334 397
335 test "replica: a snapshot claiming more columns than a row can hold is refused at the prefix" { 398 test "replica: a snapshot claiming more columns than a row can hold is refused at the prefix" {
src/tui/interact.zig
Old New
@@ -1608,13 +1608,18 @@ pub const Core = struct {
1608 /// driver applying on its own is a second switch over the same enum. 1608 /// driver applying on its own is a second switch over the same enum.
1609 /// 1609 ///
1610 /// What comes back is only what is LEFT (see `Routed`). Errors are the 1610 /// What comes back is only what is LEFT (see `Routed`). Errors are the
1611 /// replica's, unchanged: a snapshot too short to read is `.skip`, since 1611 /// replica's, unchanged, and the two snapshot failures are told apart the
1612 /// `readSnapshotPrefix` left everything untouched. 1612 /// way `Replica.apply` documents them: `error.BadPayload` left the grid
1613 /// untouched, so it is `.skip`; `error.SnapshotAborted` did not — the grid
1614 /// is blank and `last_seq` claims to be current — so it goes out to the
1615 /// driver, which ends the tile rather than paint from a replica that
1616 /// holds nothing.
1613 pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed { 1617 pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed {
1614 switch (frame_type) { 1618 switch (frame_type) {
1615 .snapshot => { 1619 .snapshot => {
1616 _ = self.rep.apply(.snapshot, payload) catch |err| switch (err) { 1620 _ = self.rep.apply(.snapshot, payload) catch |err| switch (err) {
1617 error.BadPayload => return .skip, 1621 error.BadPayload => return .skip,
1622 error.SnapshotAborted => return err,
1618 else => |e| return e, 1623 else => |e| return e,
1619 }; 1624 };
1620 try self.snapshotTaken(); 1625 try self.snapshotTaken();
@@ -4804,6 +4809,35 @@ test "interact: a frame the Core answers itself never reaches the driver" {
4804 try std.testing.expect(core.rep.state_since_attach); 4809 try std.testing.expect(core.rep.state_since_attach);
4805 } 4810 }
4806 4811
4812 test "interact: a snapshot that blanked the grid before failing is not .skip" {
4813 // The other snapshot failure. `.skip` would leave the tile painting from
4814 // a grid this frame cleared, over a last_seq it also adopted; the error
4815 // goes out to the driver, which ends the tile.
4816 const alloc = std.testing.allocator;
4817 var core = try Core.initSized(alloc, -1, -1, .{ .cols = 8, .rows = 2 });
4818 defer core.deinit();
4819
4820 var payload: std.ArrayList(u8) = .empty;
4821 defer payload.deinit(alloc);
4822 var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
4823 proto.writeSnapshotPrefix(&pbuf, .{
4824 .seq = 5,
4825 .history_rows = 0,
4826 .cols = 8,
4827 .rows = 2,
4828 .epoch = 1,
4829 });
4830 try payload.appendSlice(alloc, &pbuf);
4831 var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
4832 proto.writeSnapshotCursor(&cbuf, 0, 0);
4833 try payload.appendSlice(alloc, &cbuf);
4834 // A prefix good enough to resize and clear on, then a body that is not a
4835 // CellRow at all.
4836 try payload.appendSlice(alloc, "\x1b[1mVT");
4837
4838 try std.testing.expectError(error.SnapshotAborted, core.frame(.snapshot, payload.items));
4839 }
4840
4807 test "interact: an unvalidated clipboard effect writes nothing" { 4841 test "interact: an unvalidated clipboard effect writes nothing" {
4808 const alloc = std.testing.allocator; 4842 const alloc = std.testing.allocator;
4809 var out: std.ArrayList(u8) = .empty; 4843 var out: std.ArrayList(u8) = .empty;
test/wsclient.zig
Old New
@@ -286,7 +286,16 @@ const Client = struct {
286 // DELTA's arrival alone proves the attach was admitted, 286 // DELTA's arrival alone proves the attach was admitted,
287 // decodable or not. 287 // decodable or not.
288 if (t == delta) self.got_state = true; 288 if (t == delta) self.got_state = true;
289 const applied = self.rep.apply(@enumFromInt(t), payload) catch return; 289 const applied = self.rep.apply(@enumFromInt(t), payload) catch |err| switch (err) {
290 // A snapshot that blanked the grid before it failed: this
291 // replica holds nothing worth painting, so the stand-in stops
292 // reporting state, exactly as the browser resets its core.
293 error.SnapshotAborted => {
294 self.got_state = false;
295 return;
296 },
297 else => return,
298 };
290 if (applied == .painted) self.got_state = true; 299 if (applied == .painted) self.got_state = true;
291 if (applied == .resync) { 300 if (applied == .resync) {
292 // Mirror the browser: a garbled delta re-attaches fresh, 301 // Mirror the browser: a garbled delta re-attaches fresh,