libghostty: detach tracked grid refs on free

Tracked grid references previously held a raw terminal wrapper pointer and
were required to be freed before the terminal. If callers kept one past
terminal destruction, later tracked-ref calls could dereference freed
terminal or page-list memory before detecting that the reference was no
longer meaningful.

Track live C tracked-grid-ref handles from the terminal wrapper and detach
them before tearing down terminal storage. Detached refs now report no
value through the tracked-ref APIs and can still be freed by the caller.
Update the C API docs to describe this lifetime behavior and add a
regression test for using a tracked ref after terminal free.

This introduces some overhead but tracked pins shouldn't be numerous
and this dramatically improves safety.
This commit is contained in:
Mitchell Hashimoto
2026-05-24 14:09:28 -07:00
parent d5d8cef4d3
commit 03df613e39
6 changed files with 75 additions and 13 deletions

View File

@@ -33,6 +33,9 @@ pub const TrackedGridRef = struct {
pub fn tracked_grid_ref_free(ref_: CTrackedGridRef) callconv(lib.calling_conv) void {
const ref = ref_ orelse return;
if (ref.terminal) |wrapper| {
_ = wrapper.tracked_grid_refs.swapRemove(ref);
}
if (ref.pageList()) |list| list.untrackPin(ref.pin);
ref.alloc.destroy(ref);
}
@@ -185,3 +188,38 @@ test "tracked_grid_ref reports no value after alternate screen reset" {
try testing.expect(tracked_grid_ref_has_value(ref));
try testing.expectEqual(Result.success, tracked_grid_ref_snapshot(ref, &snapshot));
}
test "tracked_grid_ref reports no value after terminal free" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
.{ .cols = 5, .rows = 2, .max_scrollback = 10_000 },
));
terminal_c.vt_write(terminal, "A", 1);
var ref: CTrackedGridRef = null;
try testing.expectEqual(Result.success, terminal_c.grid_ref_track(
terminal,
point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
&ref,
));
terminal_c.free(terminal);
try testing.expect(!tracked_grid_ref_has_value(ref));
var snapshot: grid_ref_c.CGridRef = undefined;
try testing.expectEqual(Result.no_value, tracked_grid_ref_snapshot(ref, &snapshot));
var coord: point.Coordinate = undefined;
try testing.expectEqual(Result.no_value, tracked_grid_ref_point(ref, .active, &coord));
try testing.expectEqual(Result.invalid_value, tracked_grid_ref_set(
ref,
terminal,
point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
));
tracked_grid_ref_free(ref);
}

View File

@@ -35,6 +35,7 @@ const TerminalWrapper = struct {
terminal: *ZigTerminal,
stream: Stream,
effects: Effects = .{},
tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
};
/// C callback state for terminal effects. Trampolines are always
@@ -758,6 +759,18 @@ pub fn grid_ref_track(
.pin = tracked_pin,
};
// Store the tracked ref in the terminal so that when we free
// the terminal the tracked ref can be detached safely.
wrapper.tracked_grid_refs.putNoClobber(
alloc,
ref,
{},
) catch {
list.untrackPin(tracked_pin);
alloc.destroy(ref);
return .out_of_memory;
};
out.* = ref;
return .success;
}
@@ -779,9 +792,11 @@ pub fn point_from_grid_ref(
pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void {
const wrapper = terminal_ orelse return;
const t = wrapper.terminal;
wrapper.stream.deinit();
const alloc = t.gpa();
for (wrapper.tracked_grid_refs.keys()) |ref| ref.terminal = null;
wrapper.tracked_grid_refs.deinit(alloc);
wrapper.stream.deinit();
t.deinit(alloc);
alloc.destroy(t);
alloc.destroy(wrapper);