terminal: invalidate partially erased page refs

Partial history erasure shifted retained rows while preserving the
node generation. Cached flattened matches then passed pointer-plus-
generation validation and could be tracked with coordinates beyond the
shortened page.

Renew the generation before reinitializing or shifting a page layout,
and mark compression activity so an incremental pass restarts and
revisits a restored cold page. The conservative serial floor permits
the fresh generation to remain before older live successors without
rejecting them.
This commit is contained in:
Mitchell Hashimoto
2026-07-10 07:18:43 -07:00
parent f1a5fab452
commit a804e3ac78
2 changed files with 109 additions and 2 deletions

View File

@@ -365,8 +365,9 @@ pool: MemoryPool,
/// The list of pages in the screen.
pages: List,
/// The next globally unique page generation for this PageList. A generation
/// is assigned whenever a page is allocated or reused as new.
/// The next globally unique reference generation for this PageList. A
/// generation is assigned whenever a page is allocated, reused as new, or
/// changed in place such that existing page coordinates are no longer stable.
///
/// The serial number can be used to detect whether the page is identical
/// to the page that was originally referenced by a pointer. Since we reuse
@@ -3099,6 +3100,14 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void {
for (0..non_empty) |_| _ = try self.grow();
}
/// Renew a live node's generation before changing its coordinate layout in
/// place. This invalidates external pointer-plus-generation references without
/// changing the whole-list invalidation floor.
fn invalidateNodeLayout(self: *PageList, node: *List.Node) void {
node.serial = self.page_serial;
self.page_serial += 1;
}
/// Compact a page to use the minimum required memory for the contents
/// it stores. Returns the new node pointer if compaction occurred, or null
/// if the page was already compact or compaction would not provide any
@@ -4589,6 +4598,7 @@ fn eraseRows(
bl_pt: ?point.Point,
) void {
defer self.assertIntegrity();
self.page_compression.markActivity();
// The count of rows that was erased.
var erased: usize = 0;
@@ -4606,6 +4616,7 @@ fn eraseRows(
// page so to handle this we reinit this page, set it to zero
// size which will let us grow our active area back.
if (chunk.node.next == null and chunk.node.prev == null) {
self.invalidateNodeLayout(chunk.node);
const page = chunk.node.page();
erased += page.size.rows;
page.reinit();
@@ -4618,6 +4629,10 @@ fn eraseRows(
continue;
}
// Moving the retained suffix changes the meaning and valid range of
// every captured row coordinate in this node.
self.invalidateNodeLayout(chunk.node);
// We are modifying our chunk so make sure it is in a good state.
const page = chunk.node.page();
defer page.assertIntegrity();
@@ -6979,6 +6994,65 @@ test "PageList incremental compression restarts after prune reuse" {
try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages);
}
test "PageList bounded pruning after partial erase preserves live serials" {
const testing = std.testing;
var s = try init(
testing.allocator,
80,
24,
2 * PagePool.item_size,
);
defer s.deinit();
while (s.totalPages() < 2) _ = try s.grow();
const first = s.pages.first.?;
const old_serial = first.serial;
const old_rows = first.rows();
s.eraseHistory(.{ .history = .{ .y = 0 } });
try testing.expectEqual(first, s.pages.first.?);
try testing.expectEqual(old_rows - 1, first.rows());
try testing.expect(!s.nodeIsValid(first, old_serial));
try s.fillLastPageForTest();
_ = try s.grow();
try s.expectLivePageSerialsValidForTest();
}
test "PageList partial erase restarts compression before continuation" {
const testing = std.testing;
var s = try init(testing.allocator, 80, 24, null);
defer s.deinit();
try s.growColdPagesForTest(incremental_compression_max_inspected + 1);
_ = s.compress(.full);
const first = s.pages.first.?;
try testing.expect(first.isCompressed());
var marker = first;
for (1..incremental_compression_max_inspected) |_| marker = marker.next.?;
s.page_compression = .{
.flags = .{ .verifying = true },
.last_serial = marker.serial,
.next_serial = s.page_serial,
};
const activity = s.page_compression.activity_serial;
s.eraseHistory(.{ .history = .{ .y = 0 } });
try testing.expect(!first.isCompressed());
try testing.expect(activity != s.page_compression.activity_serial);
// The changed generation is before the saved marker, so continuation must
// restart and recompress it instead of reporting verification complete.
try testing.expectEqual(
IncrementalCompressionResult.pending,
s.compress(.incremental),
);
try testing.expect(first.isCompressed());
}
test "PageList repeated bounded pruning after split preserves live serials" {
const testing = std.testing;

View File

@@ -1616,6 +1616,39 @@ test "select after history compaction ignores replaced results" {
try testing.expect(search.selected == null);
}
test "select after partial history page erase ignores shifted results" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var stream = t.vtStream();
defer stream.deinit();
const first = list.pages.first.?;
while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
stream.nextSlice("error");
for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
try testing.expect(first != list.pages.last.?);
var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
defer search.deinit();
try search.searchAll();
try testing.expectEqual(0, search.active_results.items.len);
try testing.expectEqual(1, search.history_results.items.len);
const old_rows = first.rows();
list.eraseHistory(.{ .history = .{ .y = 0 } });
try testing.expectEqual(old_rows - 1, first.rows());
try testing.expect(!try search.select(.next));
try testing.expect(search.selected == null);
}
test "screen search no scrollback has no history" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{