94b89d3d
refactor: the replica measures the grid it holds, and a delta paint holds no row list
a73x 2026-09-04 18:04
Commit message
src/engine/replica.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,12 +15,12 @@ const proto = @import("protocol.zig"); | |||
| 15 | 15 | ||
| 16 | pub const Replica = struct { | 16 | pub const Replica = struct { |
| 17 | alloc: std.mem.Allocator, | 17 | alloc: std.mem.Allocator, |
| 18 | /// Borrowed. The grid the frames are copied into. | 18 | /// Borrowed. The grid the frames are copied into, and the authoritative |
| 19 | /// size: under latest-wins another client's attach or resize can make it | ||
| 20 | /// differ from any local tty, and the replica follows the grid. Its own | ||
| 21 | /// dimensions are what a snapshot's prefix is compared against, so a grid | ||
| 22 | /// resized by anyone else is still one this can be indexed with. | ||
| 19 | grid: *Grid, | 23 | grid: *Grid, |
| 20 | /// The authoritative grid size, learned from snapshot prefixes. Under | ||
| 21 | /// latest-wins another client's attach or resize can make it differ | ||
| 22 | /// from any local tty; the replica follows the grid, not the tty. | ||
| 23 | grid_size: proto.Size, | ||
| 24 | /// The daemon instance we are talking to, learned from its snapshots, | 24 | /// The daemon instance we are talking to, learned from its snapshots, |
| 25 | /// and quoted back on reconnect so the daemon can tell whether the seq | 25 | /// and quoted back on reconnect so the daemon can tell whether the seq |
| 26 | /// we hold is one of its own (a restarted daemon counts from zero over | 26 | /// we hold is one of its own (a restarted daemon counts from zero over |
| @@ -48,11 +48,7 @@ pub const Replica = struct { | |||
| 48 | }; | 48 | }; |
| 49 | 49 | ||
| 50 | pub fn init(alloc: std.mem.Allocator, g: *Grid) Replica { | 50 | pub fn init(alloc: std.mem.Allocator, g: *Grid) Replica { |
| 51 | return .{ | 51 | return .{ .alloc = alloc, .grid = g }; |
| 52 | .alloc = alloc, | ||
| 53 | .grid = g, | ||
| 54 | .grid_size = .{ .cols = g.cols, .rows = g.rows }, | ||
| 55 | }; | ||
| 56 | } | 52 | } |
| 57 | 53 | ||
| 58 | /// Consume one replay frame; only `.snapshot` and `.delta` are replay | 54 | /// Consume one replay frame; only `.snapshot` and `.delta` are replay |
| @@ -82,10 +78,8 @@ pub const Replica = struct { | |||
| 82 | self.session_epoch = prefix.epoch; | 78 | self.session_epoch = prefix.epoch; |
| 83 | self.last_seq = prefix.seq; | 79 | self.last_seq = prefix.seq; |
| 84 | self.history_rows = prefix.history_rows; | 80 | self.history_rows = prefix.history_rows; |
| 85 | if (prefix.cols != self.grid_size.cols or prefix.rows != self.grid_size.rows) { | 81 | if (prefix.cols != self.grid.cols or prefix.rows != self.grid.rows) |
| 86 | try self.grid.resize(prefix.cols, prefix.rows); | 82 | try self.grid.resize(prefix.cols, prefix.rows); |
| 87 | self.grid_size = .{ .cols = prefix.cols, .rows = prefix.rows }; | ||
| 88 | } | ||
| 89 | // Cleared, then every row written: a snapshot is the whole | 83 | // Cleared, then every row written: a snapshot is the whole |
| 90 | // screen, and a row the payload happens not to reach must not | 84 | // screen, and a row the payload happens not to reach must not |
| 91 | // keep what the previous grid had there. | 85 | // keep what the previous grid had there. |
| @@ -261,12 +255,38 @@ test "snapshot at a new grid size resizes the replica grid first" { | |||
| 261 | defer alloc.free(payload); | 255 | defer alloc.free(payload); |
| 262 | 256 | ||
| 263 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload)); | 257 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload)); |
| 264 | try std.testing.expectEqual(@as(u16, 100), r.grid_size.cols); | ||
| 265 | try std.testing.expectEqual(@as(u16, 30), r.grid_size.rows); | ||
| 266 | try std.testing.expectEqual(@as(u16, 100), g.cols); | 258 | try std.testing.expectEqual(@as(u16, 100), g.cols); |
| 267 | try std.testing.expectEqual(@as(u16, 30), g.rows); | 259 | try std.testing.expectEqual(@as(u16, 30), g.rows); |
| 268 | } | 260 | } |
| 269 | 261 | ||
| 262 | test "snapshot: a grid resized by somebody else is measured as it is, not as it was" { | ||
| 263 | // The size a prefix is compared against is the GRID's own, not a copy the | ||
| 264 | // replica took at init: a client that resized its grid for its own reasons | ||
| 265 | // and then took a snapshot of the size the replica remembered would skip | ||
| 266 | // the resize and write rows off the end of the shorter grid. | ||
| 267 | const alloc = std.testing.allocator; | ||
| 268 | const g = try Grid.init(alloc, 8, 4); | ||
| 269 | defer g.deinit(); | ||
| 270 | var r = Replica.init(alloc, g); | ||
| 271 | try g.resize(4, 2); | ||
| 272 | |||
| 273 | const payload = try testSnapshot(alloc, .{ | ||
| 274 | .seq = 1, | ||
| 275 | .history_rows = 0, | ||
| 276 | .cols = 8, | ||
| 277 | .rows = 4, | ||
| 278 | .epoch = 1, | ||
| 279 | }, .{ .x = 0, .y = 0 }, &.{ "aaaa", "bbbb", "cccc", "dddd" }); | ||
| 280 | defer alloc.free(payload); | ||
| 281 | |||
| 282 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.snapshot, payload)); | ||
| 283 | try std.testing.expectEqual(@as(u16, 8), g.cols); | ||
| 284 | try std.testing.expectEqual(@as(u16, 4), g.rows); | ||
| 285 | const dump = try g.dumpPlain(alloc); | ||
| 286 | defer alloc.free(dump); | ||
| 287 | try std.testing.expectEqualStrings("aaaa\nbbbb\ncccc\ndddd", dump); | ||
| 288 | } | ||
| 289 | |||
| 270 | test "short snapshot proves nothing: BadPayload, state_since_attach untouched" { | 290 | test "short snapshot proves nothing: BadPayload, state_since_attach untouched" { |
| 271 | const alloc = std.testing.allocator; | 291 | const alloc = std.testing.allocator; |
| 272 | const g = try Grid.init(alloc, 80, 24); | 292 | const g = try Grid.init(alloc, 80, 24); |
src/tui/paint.zig
| Old | New | ||
|---|---|---|---|
| @@ -270,14 +270,6 @@ pub fn paintDeltaClipped( | |||
| 270 | out_fd: std.posix.fd_t, | 270 | out_fd: std.posix.fd_t, |
| 271 | ) !void { | 271 | ) !void { |
| 272 | const hdr = try proto.readDeltaHeader(payload); | 272 | const hdr = try proto.readDeltaHeader(payload); |
| 273 | var rows_buf: [max_delta_rows]u16 = undefined; | ||
| 274 | var n: usize = 0; | ||
| 275 | var it = proto.deltaRowIterator(payload); | ||
| 276 | while (try it.next()) |row| { | ||
| 277 | if (n == rows_buf.len) break; | ||
| 278 | rows_buf[n] = row.row; | ||
| 279 | n += 1; | ||
| 280 | } | ||
| 281 | var paint: std.ArrayList(u8) = .empty; | 273 | var paint: std.ArrayList(u8) = .empty; |
| 282 | defer paint.deinit(alloc); | 274 | defer paint.deinit(alloc); |
| 283 | try paint.appendSlice(alloc, sync_begin); | 275 | try paint.appendSlice(alloc, sync_begin); |
| @@ -286,23 +278,20 @@ pub fn paintDeltaClipped( | |||
| 286 | const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols }; | 278 | const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols }; |
| 287 | var ech_buf: [16]u8 = undefined; | 279 | var ech_buf: [16]u8 = undefined; |
| 288 | const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch ""; | 280 | const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch ""; |
| 289 | for (rows_buf[0..n]) |y| { | 281 | // The frame is walked for the row INDICES it names and nothing else: the |
| 290 | if (y >= limit) continue; | 282 | // cells it carried are already in the grid, put there by the replica. |
| 291 | try appendRowAt(&paint, alloc, y, vp, ech); | 283 | var it = proto.deltaRowIterator(payload); |
| 292 | const row = try dumpRow(alloc, g, y, hl, view); | 284 | while (try it.next()) |row| { |
| 293 | defer alloc.free(row); | 285 | if (row.row >= limit) continue; |
| 294 | try paint.appendSlice(alloc, row); | 286 | try appendRowAt(&paint, alloc, row.row, vp, ech); |
| 287 | const seg = try dumpRow(alloc, g, row.row, hl, view); | ||
| 288 | defer alloc.free(seg); | ||
| 289 | try paint.appendSlice(alloc, seg); | ||
| 295 | } | 290 | } |
| 296 | 291 | ||
| 297 | try finishPaint(&paint, alloc, .{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp, out_fd); | 292 | try finishPaint(&paint, alloc, .{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp, out_fd); |
| 298 | } | 293 | } |
| 299 | 294 | ||
| 300 | /// The row indices one delta paint will hold on the stack. A frame naming | ||
| 301 | /// more rows than this is a frame naming more rows than any grid mux serves | ||
| 302 | /// has, so the surplus is dropped rather than allocated for; the replica has | ||
| 303 | /// already taken every row, and the next full repaint carries them. | ||
| 304 | const max_delta_rows = 1024; | ||
| 305 | |||
| 306 | /// An inverse status marker parked in the top-right corner: `[scroll]` when | 295 | /// An inverse status marker parked in the top-right corner: `[scroll]` when |
| 307 | /// viewing history, `[reconnecting]` when the transport is being rebuilt. | 296 | /// viewing history, `[reconnecting]` when the transport is being rebuilt. |
| 308 | /// Text only, so a caller mid-repaint can append it into its own paint | 297 | /// Text only, so a caller mid-repaint can append it into its own paint |