terminal/search: include generations in viewport fingerprints

ViewportSearch compared only cached node addresses when deciding
whether to reuse its owned search window. An in-place layout change or
pool address reuse could therefore make stale text and coordinates
appear current.

Snapshot each node generation with its pointer and compare only those
captured values. This detects a renewed generation at the same address
without ever dereferencing cached node pointers.
This commit is contained in:
Mitchell Hashimoto
2026-07-10 07:21:28 -07:00
parent 83aada2056
commit c243d89bdf

View File

@@ -103,9 +103,9 @@ pub const ViewportSearch = struct {
// If our viewport contains the start or end of the active area,
// we are in the active area. We purposely do this first
// because our viewport is always larger than the active area.
for (old.nodes) |node| {
if (node == active_tl.node) break :match;
if (node == active_br.node) break :match;
for (old.entries) |entry| {
if (entry.node == active_tl.node) break :match;
if (entry.node == active_br.node) break :match;
}
}
@@ -135,7 +135,7 @@ pub const ViewportSearch = struct {
// exists) so we can cover the overlap. We only do this for the
// soft-wrapped prior pages.
const overlap_len = self.window.needle.len -| 1;
var node_ = fingerprint.nodes[0].prev;
var node_ = fingerprint.entries[0].node.prev;
var added: usize = 0;
while (node_) |node| : (node_ = node.prev) {
// We could be more accurate here and count bytes since the
@@ -149,13 +149,13 @@ pub const ViewportSearch = struct {
// We can use our fingerprint nodes to initialize our sliding
// window, since we already traversed the viewport once.
var end_append: SlidingWindow.AppendResult = undefined;
for (fingerprint.nodes) |node| {
end_append = try self.window.append(node);
for (fingerprint.entries) |entry| {
end_append = try self.window.append(entry.node);
}
// Add any trailing overlap as well.
trailing: {
const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1];
const end: *PageList.List.Node = fingerprint.entries[fingerprint.entries.len - 1].node;
if (!end_append.last_row_wrapped) break :trailing;
node_ = end.next;
@@ -181,14 +181,18 @@ pub const ViewportSearch = struct {
/// Viewport fingerprint so we can detect when the viewport moves.
const Fingerprint = struct {
/// The nodes that make up the viewport. We need to flatten this
/// to a single list because we can't safely traverse the cached values
/// because the page nodes may be invalid. All that is safe is comparing
/// the actual pointer values.
nodes: []const *PageList.List.Node,
/// The node addresses and generations that make up the viewport. We
/// can't safely dereference cached node pointers because the nodes may
/// be invalid, so equality only compares these captured values.
entries: []const Entry,
const Entry = struct {
node: *PageList.List.Node,
serial: u64,
};
pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint {
var list: std.ArrayList(*PageList.List.Node) = .empty;
var list: std.ArrayList(Entry) = .empty;
defer list.deinit(alloc);
// Get our viewport area. Bottom right of a viewport can never
@@ -197,18 +201,22 @@ pub const ViewportSearch = struct {
const br = pages.getBottomRight(.viewport).?;
var it = tl.pageIterator(.right_down, br);
while (it.next()) |chunk| try list.append(alloc, chunk.node);
return .{ .nodes = try list.toOwnedSlice(alloc) };
while (it.next()) |chunk| try list.append(alloc, .{
.node = chunk.node,
.serial = chunk.node.serial,
});
return .{ .entries = try list.toOwnedSlice(alloc) };
}
pub fn deinit(self: *Fingerprint, alloc: Allocator) void {
alloc.free(self.nodes);
alloc.free(self.entries);
}
pub fn eql(self: Fingerprint, other: Fingerprint) bool {
if (self.nodes.len != other.nodes.len) return false;
for (self.nodes, other.nodes) |a, b| {
if (a != b) return false;
if (self.entries.len != other.entries.len) return false;
for (self.entries, other.entries) |a, b| {
if (a.node != b.node) return false;
if (a.serial != b.serial) return false;
}
return true;
}
@@ -258,6 +266,33 @@ test "simple search" {
try testing.expect(search.next() == null);
}
test "fingerprint detects node generation changes" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var search: ViewportSearch = try .init(alloc, "needle");
defer search.deinit();
search.active_dirty = false;
try testing.expect(try search.update(&t.screens.active.pages));
try testing.expect(!try search.update(&t.screens.active.pages));
const old_entry = search.fingerprint.?.entries[0];
const node = t.screens.active.pages.pages.first.?;
try testing.expectEqual(node, old_entry.node);
const original_serial = node.serial;
defer node.serial = original_serial;
node.serial += 1;
// The viewport still contains the same pointer, but its generation has
// changed, so the cached window must be rebuilt.
try testing.expect(try search.update(&t.screens.active.pages));
const new_entry = search.fingerprint.?.entries[0];
try testing.expectEqual(old_entry.node, new_entry.node);
try testing.expect(old_entry.serial != new_entry.serial);
}
test "clear screen and search" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });