d1fc858d
fix: a snapshot that blanked the grid before failing ends the tile
a73x 2026-09-04 18:04
Commit message
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, |