From 91a63e5287c06c5a9545752028a3afe5ef7b4933 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jul 2026 07:20:29 -0700 Subject: [PATCH] terminal: invalidate split source page refs PageList.split moved the source suffix into a fresh target but left the shortened source on its old generation. Cached matches in that suffix could therefore pass validation against the live source pointer and reach pin tracking with invalid coordinates. Renew the source generation only after target cloning succeeds, and mark compression activity so restored history is reconsidered. The conservative floor keeps bounded pruning safe even when the renewed source and fresh target precede older successors. --- src/terminal/PageList.zig | 35 ++++++++++++++++++++++++++++++++++ src/terminal/search/screen.zig | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ea7f00d18..9b0f5838e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3252,6 +3252,12 @@ pub fn split( // error handling. It is possible but we haven't written it. errdefer comptime unreachable; + // The source is about to describe a shorter row range. Renew its + // generation only after every failable step has succeeded so a failed + // split leaves existing references valid. + self.invalidateNodeLayout(original_node); + self.page_compression.markActivity(); + // Move any tracked pins from the copied rows for (self.tracked_pins.keys()) |tracked| { if (tracked.node.page() != page or @@ -7053,6 +7059,35 @@ test "PageList partial erase restarts compression before continuation" { try testing.expect(first.isCompressed()); } +test "PageList bounded pruning after split invalidation 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 activity = s.page_compression.activity_serial; + + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + try testing.expect(!s.nodeIsValid(first, old_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + test "PageList repeated bounded pruning after split preserves live serials" { const testing = std.testing; diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 71c6fe184..5bd977240 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1649,6 +1649,39 @@ test "select after partial history page erase ignores shifted results" { try testing.expect(search.selected == null); } +test "select after history page split ignores moved 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"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + try list.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + 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, .{