mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 04:39:01 +00:00
Misc improvements for future binary snapshot API (#13525)
Extracted out the raw `src/terminal` changes needed for the future snapshot work, 4 separate changes. These are uncontroversial and relatively simple, summarized below. Tests AI assisted but the rest including commit messages, this PR message, etc. all organic. * **Add iterator to ref counted set.** Iterate over live entries and their IDs. Const, doesn't mutate the set. * **lib.Enum produces stable enums for Zig.** Basically the same as C except it uses the smallest fitting integer including the holes. * **PageList: a couple helpers for manually creating pages.** There is `PageList.Builder` for creating a new pagelist and `PageList.allocatePage` for modifying an existing one. This allows PageList construction from raw pages.
This commit is contained in:
@@ -5,18 +5,16 @@ const Target = @import("target.zig").Target;
|
||||
/// if we're targeting C, otherwise a Zig enum with smallest possible
|
||||
/// backing type.
|
||||
///
|
||||
/// In all cases, the enum keys will be created in the order given.
|
||||
/// For C ABI, this means that the order MUST NOT be changed in order
|
||||
/// to preserve ABI compatibility. You can set a key to null to
|
||||
/// remove it from the Zig enum while keeping the "hole" in the C enum
|
||||
/// to preserve ABI compatibility.
|
||||
/// In all cases, each enum value is its index in `keys`. The order MUST NOT
|
||||
/// be changed when the integer values are part of an ABI or serialized format.
|
||||
/// A null key removes that field while preserving its integer hole.
|
||||
///
|
||||
/// C detection is up to the caller, since there are multiple ways
|
||||
/// to do that. We rely on the `target` parameter to determine whether we
|
||||
/// should create a C compatible enum or a Zig enum.
|
||||
///
|
||||
/// For the Zig enum, the enum value is not guaranteed to be stable, so
|
||||
/// it shouldn't be relied for things like serialization.
|
||||
/// C enums use `c_int`. Zig enums use the smallest unsigned integer that can
|
||||
/// represent every key index, including holes.
|
||||
pub fn Enum(
|
||||
target: Target,
|
||||
keys: []const ?[:0]const u8,
|
||||
@@ -24,14 +22,12 @@ pub fn Enum(
|
||||
var names_raw: [keys.len][]const u8 = undefined;
|
||||
var values_raw: [keys.len]comptime_int = undefined;
|
||||
const names_actual, const values_actual = kv: {
|
||||
// Remove any holes in the input (null values). As mentioned in the
|
||||
// function docs, in the event of the C ABI, we need to preserve the
|
||||
// original value, so we account for that.
|
||||
// Remove null fields while preserving their positions as integer holes.
|
||||
var to: comptime_int = 0;
|
||||
for (0..keys.len) |from| {
|
||||
if (keys[from]) |key| {
|
||||
names_raw[to] = key;
|
||||
values_raw[to] = if (target == .c) from else to;
|
||||
values_raw[to] = from;
|
||||
to += 1;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +37,7 @@ pub fn Enum(
|
||||
|
||||
const TagInt = switch (target) {
|
||||
.c => c_int,
|
||||
.zig => std.math.IntFittingRange(0, names_actual.len - 1),
|
||||
.zig => std.math.IntFittingRange(0, keys.len - 1),
|
||||
};
|
||||
|
||||
return @Enum(TagInt, .exhaustive, names_actual, &(values: {
|
||||
@@ -69,7 +65,7 @@ test "c" {
|
||||
try testing.expectEqual(c_int, info.tag_type);
|
||||
}
|
||||
|
||||
test "abi by removing a key" {
|
||||
test "stable values when removing a key" {
|
||||
const testing = std.testing;
|
||||
// C
|
||||
{
|
||||
@@ -84,10 +80,28 @@ test "abi by removing a key" {
|
||||
const T = Enum(.zig, &.{ "a", "b", null, "d" });
|
||||
const info = @typeInfo(T).@"enum";
|
||||
try testing.expectEqual(u2, info.tag_type);
|
||||
try testing.expectEqual(2, @intFromEnum(T.d));
|
||||
try testing.expectEqual(3, @intFromEnum(T.d));
|
||||
}
|
||||
}
|
||||
|
||||
test "zig backing integer includes trailing holes" {
|
||||
const testing = std.testing;
|
||||
const T = Enum(.zig, &.{ "a", null, null, null, null });
|
||||
const info = @typeInfo(T).@"enum";
|
||||
try testing.expectEqual(u3, info.tag_type);
|
||||
try testing.expectEqual(0, @intFromEnum(T.a));
|
||||
}
|
||||
|
||||
test "zig values remain stable across multiple holes" {
|
||||
const testing = std.testing;
|
||||
const T = Enum(.zig, &.{ null, "b", null, "d", null, "f" });
|
||||
const info = @typeInfo(T).@"enum";
|
||||
try testing.expectEqual(u3, info.tag_type);
|
||||
try testing.expectEqual(1, @intFromEnum(T.b));
|
||||
try testing.expectEqual(3, @intFromEnum(T.d));
|
||||
try testing.expectEqual(5, @intFromEnum(T.f));
|
||||
}
|
||||
|
||||
/// Verify that for every key in enum T, there is a matching declaration in
|
||||
/// `ghostty.h` with the correct value. This should only ever be called inside a `test`
|
||||
/// because the `ghostty.h` module is only available then.
|
||||
|
||||
@@ -3866,6 +3866,125 @@ pub fn increaseCapacity(
|
||||
return new_node;
|
||||
}
|
||||
|
||||
/// Allocate a new page using the PageList's memory pools.
|
||||
///
|
||||
/// The page is detached: it doesn't contribute to the memory limits or
|
||||
/// row counts or anything in the PageList. The caller must call `finalize`
|
||||
/// to add it to the PageList at the appropriate place, or `deinit` to
|
||||
/// throw it away.
|
||||
pub fn allocatePage(
|
||||
self: *PageList,
|
||||
capacity: Capacity,
|
||||
) Allocator.Error!PageAllocation {
|
||||
return .{
|
||||
.destination = self,
|
||||
.node = try createPageExt(
|
||||
&self.pool,
|
||||
.{ .cap = capacity },
|
||||
&self.page_serial,
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// One PageList-pooled page which has not yet joined the live page sequence.
|
||||
pub const PageAllocation = struct {
|
||||
destination: *PageList,
|
||||
node: ?*List.Node,
|
||||
|
||||
/// Return the fresh page storage for the caller to populate.
|
||||
pub fn page(self: *PageAllocation) *Page {
|
||||
return self.node.?.pageAssumeResident();
|
||||
}
|
||||
|
||||
/// Release an uncommitted page back to its PageList's pools.
|
||||
///
|
||||
/// This is safe to call after `finalize` succeeds, so callers can defer
|
||||
/// it unconditionally.
|
||||
pub fn deinit(self: *PageAllocation) void {
|
||||
const node = self.node orelse return;
|
||||
destroyNodeExt(
|
||||
&self.destination.pool,
|
||||
node,
|
||||
null,
|
||||
);
|
||||
self.node = null;
|
||||
}
|
||||
|
||||
pub const Location = union(enum) {
|
||||
/// Prepend the page to the start of the list (oldest history).
|
||||
prepend,
|
||||
};
|
||||
|
||||
/// Finalize this complete page and transfer its ownership to the PageList.
|
||||
/// The parameter determines where it goes into the PageList.
|
||||
///
|
||||
/// Existing pages and tracked pins keep their identity. A pinned viewport
|
||||
/// keeps showing the same content while its cached absolute row offset
|
||||
/// moves down by the number of newly inserted rows.
|
||||
pub fn finalize(self: *PageAllocation, location: Location) FinalizeError!void {
|
||||
switch (location) {
|
||||
.prepend => return try self.prepend(),
|
||||
}
|
||||
}
|
||||
|
||||
pub const FinalizeError = error{
|
||||
InvalidPageDimensions,
|
||||
RowCountOverflow,
|
||||
PageSizeOverflow,
|
||||
MaxSizeExceeded,
|
||||
MaxLinesExceeded,
|
||||
};
|
||||
|
||||
fn prepend(self: *PageAllocation) FinalizeError!void {
|
||||
const destination = self.destination;
|
||||
const node = self.node.?;
|
||||
|
||||
// Validate the populated page and all resulting accounting before
|
||||
// publishing the detached node into the live list.
|
||||
if (node.cols() == 0 or node.rows() == 0) return error.InvalidPageDimensions;
|
||||
const total_rows = std.math.add(
|
||||
usize,
|
||||
destination.total_rows,
|
||||
node.rows(),
|
||||
) catch return error.RowCountOverflow;
|
||||
const node_size: usize = switch (node.owned) {
|
||||
.pool => PagePool.item_size,
|
||||
.heap => node.pageAssumeResident().memory.len,
|
||||
};
|
||||
const page_size = std.math.add(
|
||||
usize,
|
||||
destination.page_size,
|
||||
node_size,
|
||||
) catch return error.PageSizeOverflow;
|
||||
|
||||
// Restored history is exact data, so reject a page which cannot
|
||||
// coexist with the receiving PageList's configured limits.
|
||||
if (page_size > destination.limits.max(.bytes)) {
|
||||
return error.MaxSizeExceeded;
|
||||
}
|
||||
if (total_rows - destination.rows > destination.limits.max(.lines)) {
|
||||
return error.MaxLinesExceeded;
|
||||
}
|
||||
|
||||
// No fallible work remains. Publish the page and update every cached
|
||||
// quantity affected by inserting rows above the existing first page.
|
||||
errdefer comptime unreachable;
|
||||
destination.pages.prepend(node);
|
||||
destination.page_size = page_size;
|
||||
destination.total_rows = total_rows;
|
||||
if (destination.viewport == .pin) {
|
||||
if (destination.viewport_pin_row_offset) |*offset| {
|
||||
offset.* += node.rows();
|
||||
}
|
||||
}
|
||||
destination.page_compression.markActivity();
|
||||
|
||||
destination.assertIntegrity();
|
||||
self.node = null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Options for createPage and createPageExt.
|
||||
const CreatePage = struct {
|
||||
/// The capacity to allocate the page with.
|
||||
@@ -6829,6 +6948,513 @@ pub const Pin = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Build up a PageList manually from a set of Pages.
|
||||
///
|
||||
/// This data structure is transactional: `deinit` releases every page
|
||||
/// until `finish` is called. This keeps the ownership clear: a complete
|
||||
/// PageList either owns all its pages or doesn't.
|
||||
///
|
||||
/// This was specifically built to help facilitate snapshot decoding
|
||||
/// which transfers pages directly, but could be generally useful
|
||||
/// for other purposes as well.
|
||||
pub const Builder = struct {
|
||||
pool: MemoryPool,
|
||||
pages: List = .{},
|
||||
page_serial: u64 = 0,
|
||||
page_size: usize = 0,
|
||||
options: Options,
|
||||
finished: bool = false,
|
||||
|
||||
/// Initialize an empty builder. The options are the final state
|
||||
/// of the PageList and some validation is done on the finish call
|
||||
/// to ensure you built up a proper PageList according to those options.
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
options: Options,
|
||||
) Allocator.Error!Builder {
|
||||
return .{
|
||||
.pool = try MemoryPool.init(
|
||||
alloc,
|
||||
pageAllocator(),
|
||||
page_preheat,
|
||||
),
|
||||
.options = options,
|
||||
};
|
||||
}
|
||||
|
||||
/// Release all pages when restoration does not finish.
|
||||
///
|
||||
/// This is safe to call after `finish` succeeds, so callers can defer it
|
||||
/// unconditionally.
|
||||
pub fn deinit(self: *Builder) void {
|
||||
if (self.finished) return;
|
||||
|
||||
// Free all our in-progress pages
|
||||
while (self.pages.popFirst()) |node| destroyNodeExt(
|
||||
&self.pool,
|
||||
node,
|
||||
&self.page_size,
|
||||
);
|
||||
// Free memory pool
|
||||
self.pool.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Allocate a new page into the PageList with the given capacity.
|
||||
///
|
||||
/// The caller can then take this page and populate it. When `finish`
|
||||
/// is called, ownership is transferred to the resulting PageList.
|
||||
/// Until then, this Builder owns the page.
|
||||
pub fn allocatePage(
|
||||
self: *Builder,
|
||||
capacity: Capacity,
|
||||
) Allocator.Error!*Page {
|
||||
const node = try createPageExt(
|
||||
&self.pool,
|
||||
.{ .cap = capacity },
|
||||
&self.page_serial,
|
||||
&self.page_size,
|
||||
);
|
||||
self.pages.append(node);
|
||||
return node.pageAssumeResident();
|
||||
}
|
||||
|
||||
pub const FinishError = Allocator.Error || error{
|
||||
InvalidDimensions,
|
||||
InvalidPageDimensions,
|
||||
NoPages,
|
||||
InsufficientRows,
|
||||
};
|
||||
|
||||
/// Validate the decoded pages and transfer them into a live PageList.
|
||||
/// After this succeeds, `deinit` is a no-op because all resources have
|
||||
/// transferred to the PageList.
|
||||
pub fn finish(self: *Builder) FinishError!PageList {
|
||||
// These are basic validations but they're cheap to do and
|
||||
// we want to be careful we don't let corruption from untrusted
|
||||
// sources into our PageList which asserts this.
|
||||
if (self.options.cols == 0 or self.options.rows == 0) {
|
||||
return error.InvalidDimensions;
|
||||
}
|
||||
if (self.pages.first == null) return error.NoPages;
|
||||
|
||||
// Manually count our total rows at this point which we'll
|
||||
// need for our PageList cache as well as a safety check.
|
||||
const total_rows: usize = total_rows: {
|
||||
var total_rows: usize = 0;
|
||||
var node = self.pages.first;
|
||||
while (node) |current| : (node = current.next) {
|
||||
if (current.cols() == 0 or current.rows() == 0) {
|
||||
return error.InvalidPageDimensions;
|
||||
}
|
||||
total_rows += current.rows();
|
||||
}
|
||||
if (total_rows < self.options.rows) return error.InsufficientRows;
|
||||
break :total_rows total_rows;
|
||||
};
|
||||
|
||||
// Get our active pin
|
||||
const active_top: Pin = active_top: {
|
||||
var rem = self.options.rows;
|
||||
var node = self.pages.last;
|
||||
while (node) |current| : (node = current.prev) {
|
||||
if (rem <= current.rows()) break :active_top .{
|
||||
.node = current,
|
||||
.y = current.rows() - rem,
|
||||
};
|
||||
rem -= current.rows();
|
||||
} else unreachable;
|
||||
};
|
||||
|
||||
// Set our viewport up to the active
|
||||
const viewport_pin = try self.pool.pins.create();
|
||||
errdefer self.pool.pins.destroy(viewport_pin);
|
||||
viewport_pin.* = active_top;
|
||||
|
||||
// Setup our one viewport tracked pin
|
||||
var tracked_pins: PinSet = .{};
|
||||
errdefer tracked_pins.deinit(self.pool.alloc);
|
||||
try tracked_pins.putNoClobber(self.pool.alloc, viewport_pin, {});
|
||||
|
||||
// Initialize limits
|
||||
var limits: Limits = .init(self.options.cols, self.options.rows);
|
||||
limits.set(.bytes, self.options.max_size);
|
||||
limits.set(.lines, self.options.max_lines);
|
||||
|
||||
const result: PageList = .{
|
||||
.cols = self.options.cols,
|
||||
.rows = self.options.rows,
|
||||
.pool = self.pool,
|
||||
.pages = self.pages,
|
||||
.page_serial = self.page_serial,
|
||||
.page_serial_epoch = 0,
|
||||
.page_size = self.page_size,
|
||||
.limits = limits,
|
||||
.total_rows = total_rows,
|
||||
.tracked_pins = tracked_pins,
|
||||
.viewport = .{ .active = {} },
|
||||
.viewport_pin = viewport_pin,
|
||||
.viewport_pin_row_offset = null,
|
||||
};
|
||||
result.assertIntegrity();
|
||||
self.finished = true;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
test "PageList Builder transfers mixed-width pages" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Build two populated pages whose widths differ from each other and from
|
||||
// the final active-area width. The first page also includes one row of
|
||||
// incidental history above the three-row active area.
|
||||
var result: PageList = result: {
|
||||
var builder = try Builder.init(testing.allocator, .{
|
||||
.cols = 4,
|
||||
.rows = 3,
|
||||
.max_size = null,
|
||||
.max_lines = null,
|
||||
});
|
||||
defer builder.deinit();
|
||||
|
||||
const first = try builder.allocatePage(.{
|
||||
.cols = 2,
|
||||
.rows = 2,
|
||||
});
|
||||
first.size.rows = 2;
|
||||
first.getRowAndCell(0, 0).cell.* = .init('A');
|
||||
|
||||
const second = try builder.allocatePage(.{
|
||||
.cols = 4,
|
||||
.rows = 2,
|
||||
});
|
||||
second.size.rows = 2;
|
||||
second.getRowAndCell(0, 0).cell.* = .init('B');
|
||||
|
||||
break :result try builder.finish();
|
||||
};
|
||||
defer result.deinit();
|
||||
|
||||
// Successful finish transfers ownership and initializes the PageList's
|
||||
// desired geometry, viewport, and required tracked viewport pin.
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), result.cols);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 3), result.rows);
|
||||
try testing.expectEqual(@as(usize, 2), result.totalPages());
|
||||
try testing.expectEqual(@as(usize, 1), result.countTrackedPins());
|
||||
try testing.expectEqual(Viewport.active, result.viewport);
|
||||
|
||||
// Complete pages and their contents are preserved in insertion order,
|
||||
// including widths which have not yet been reflowed.
|
||||
const screen_top = result.getTopLeft(.screen);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 2), screen_top.node.cols());
|
||||
try testing.expectEqual(@as(u21, 'A'), screen_top
|
||||
.node.page().getRowAndCell(0, 0).cell.codepoint());
|
||||
|
||||
// The active area is calculated backward from the newest page, so it
|
||||
// begins at row one of the oldest page and leaves row zero as history.
|
||||
const active_top = result.getTopLeft(.active);
|
||||
try testing.expectEqual(screen_top.node, active_top.node);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 1), active_top.y);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 4), active_top
|
||||
.node.next.?.cols());
|
||||
try testing.expectEqual(@as(u21, 'B'), active_top
|
||||
.node.next.?.page().getRowAndCell(0, 0).cell.codepoint());
|
||||
|
||||
result.assertIntegrity();
|
||||
}
|
||||
|
||||
test "PageList Builder validates the finished list" {
|
||||
const testing = std.testing;
|
||||
|
||||
// The desired PageList geometry must describe a non-empty screen.
|
||||
{
|
||||
var builder = try Builder.init(testing.allocator, .{
|
||||
.cols = 0,
|
||||
.rows = 1,
|
||||
});
|
||||
defer builder.deinit();
|
||||
try testing.expectError(error.InvalidDimensions, builder.finish());
|
||||
}
|
||||
|
||||
// A PageList cannot be finished without any backing pages.
|
||||
{
|
||||
var builder = try Builder.init(testing.allocator, .{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
});
|
||||
defer builder.deinit();
|
||||
try testing.expectError(error.NoPages, builder.finish());
|
||||
}
|
||||
|
||||
// Allocated capacity alone is insufficient: callers must populate a
|
||||
// nonzero logical page size before transferring ownership.
|
||||
{
|
||||
var builder = try Builder.init(testing.allocator, .{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
});
|
||||
defer builder.deinit();
|
||||
const page = try builder.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
page.size.rows = 0;
|
||||
try testing.expectError(
|
||||
error.InvalidPageDimensions,
|
||||
builder.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
// The populated pages must contain enough rows to cover the active area.
|
||||
{
|
||||
var builder = try Builder.init(testing.allocator, .{
|
||||
.cols = 1,
|
||||
.rows = 2,
|
||||
});
|
||||
defer builder.deinit();
|
||||
const page = try builder.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
page.size.rows = 1;
|
||||
try testing.expectError(error.InsufficientRows, builder.finish());
|
||||
}
|
||||
}
|
||||
|
||||
test "PageList Builder finish is transactional on allocation failure" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Construct a valid builder so finish reaches its fallible bookkeeping
|
||||
// allocations after all page and geometry validation succeeds.
|
||||
var failing = testing.FailingAllocator.init(testing.allocator, .{});
|
||||
var builder = try Builder.init(failing.allocator(), .{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
});
|
||||
defer builder.deinit();
|
||||
const page = try builder.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
page.size.rows = 1;
|
||||
|
||||
// The pools are preheated, so the next general allocation is the tracked
|
||||
// viewport pin map created by finish.
|
||||
failing.fail_index = failing.alloc_index;
|
||||
try testing.expectError(error.OutOfMemory, builder.finish());
|
||||
try testing.expect(failing.has_induced_failure);
|
||||
|
||||
// Failed finish leaves page ownership with the builder so its normal
|
||||
// deinit path can release the still-linked page.
|
||||
try testing.expect(builder.pages.first != null);
|
||||
try testing.expectEqual(builder.pages.first, builder.pages.last);
|
||||
}
|
||||
|
||||
test "PageList PageAllocation finalizes pages and preserves live state" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Build an existing list with history, a two-row active area, an external
|
||||
// active pin, and a pinned viewport whose absolute offset is cached.
|
||||
var result: PageList = result: {
|
||||
var builder = try Builder.init(testing.allocator, .{
|
||||
.cols = 4,
|
||||
.rows = 2,
|
||||
.max_size = null,
|
||||
.max_lines = null,
|
||||
});
|
||||
defer builder.deinit();
|
||||
|
||||
const first = try builder.allocatePage(.{ .cols = 3, .rows = 2 });
|
||||
first.size.rows = 2;
|
||||
first.getRowAndCell(0, 0).cell.* = .init('C');
|
||||
|
||||
const second = try builder.allocatePage(.{ .cols = 4, .rows = 2 });
|
||||
second.size.rows = 2;
|
||||
second.getRowAndCell(0, 0).cell.* = .init('D');
|
||||
|
||||
break :result try builder.finish();
|
||||
};
|
||||
defer result.deinit();
|
||||
|
||||
const old_first = result.pages.first.?;
|
||||
const old_last = result.pages.last.?;
|
||||
const active_top = result.getTopLeft(.active);
|
||||
const tracked_active = try result.trackPin(active_top);
|
||||
result.scroll(.{ .row = 1 });
|
||||
try testing.expectEqual(Viewport.pin, result.viewport);
|
||||
try testing.expectEqual(@as(usize, 1), result.scrollbar().offset);
|
||||
|
||||
// Prepend differently sized historical pages newest-first. Each page is
|
||||
// populated while detached and joins the live list only on success.
|
||||
{
|
||||
var allocation = try result.allocatePage(.{ .cols = 4, .rows = 1 });
|
||||
defer allocation.deinit();
|
||||
const page = allocation.page();
|
||||
page.size.rows = 1;
|
||||
page.getRowAndCell(0, 0).cell.* = .init('B');
|
||||
try allocation.finalize(.prepend);
|
||||
}
|
||||
{
|
||||
var allocation = try result.allocatePage(.{ .cols = 2, .rows = 2 });
|
||||
defer allocation.deinit();
|
||||
const page = allocation.page();
|
||||
page.size.rows = 2;
|
||||
page.getRowAndCell(0, 0).cell.* = .init('A');
|
||||
try allocation.finalize(.prepend);
|
||||
}
|
||||
|
||||
// Repeated prepends reconstruct oldest-to-newest order without replacing
|
||||
// any existing nodes or tracked pins.
|
||||
try testing.expectEqual(@as(usize, 4), result.totalPages());
|
||||
try testing.expectEqual(@as(usize, 7), result.total_rows);
|
||||
try testing.expectEqual(old_last, result.pages.last.?);
|
||||
try testing.expectEqual(old_first, result.pages.first.?.next.?.next.?);
|
||||
try testing.expectEqual(
|
||||
@as(u21, 'A'),
|
||||
result.pages.first.?.page().getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(u21, 'B'),
|
||||
result.pages.first.?.next.?
|
||||
.page().getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
try testing.expect(active_top.eql(result.getTopLeft(.active)));
|
||||
try testing.expect(active_top.eql(tracked_active.*));
|
||||
|
||||
// The viewport remains pinned to the same content, while its cached row
|
||||
// offset and the scrollbar total include the three new historical rows.
|
||||
try testing.expectEqual(Viewport.pin, result.viewport);
|
||||
try testing.expectEqual(old_first, result.viewport_pin.node);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 1), result.viewport_pin.y);
|
||||
const scrollbar_state = result.scrollbar();
|
||||
try testing.expectEqual(@as(usize, 7), scrollbar_state.total);
|
||||
try testing.expectEqual(@as(usize, 4), scrollbar_state.offset);
|
||||
try testing.expectEqual(@as(usize, 2), scrollbar_state.len);
|
||||
|
||||
result.assertIntegrity();
|
||||
}
|
||||
|
||||
test "PageList PageAllocation stays detached until finalize" {
|
||||
const testing = std.testing;
|
||||
|
||||
var result = try init(testing.allocator, .{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
.max_size = null,
|
||||
.max_lines = null,
|
||||
});
|
||||
defer result.deinit();
|
||||
|
||||
const initial_first = result.pages.first.?;
|
||||
const initial_last = result.pages.last.?;
|
||||
const initial_total_rows = result.total_rows;
|
||||
const initial_page_size = result.page_size;
|
||||
|
||||
// Allocating and populating a detached page does not alter any live list
|
||||
// links or accounting. Deinit returns it to the same PageList pools.
|
||||
var detached = try result.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
detached.page().size.rows = 1;
|
||||
try testing.expectEqual(initial_first, result.pages.first.?);
|
||||
try testing.expectEqual(initial_last, result.pages.last.?);
|
||||
try testing.expectEqual(initial_total_rows, result.total_rows);
|
||||
try testing.expectEqual(initial_page_size, result.page_size);
|
||||
result.assertIntegrity();
|
||||
detached.deinit();
|
||||
result.assertIntegrity();
|
||||
|
||||
// Invalid populated dimensions leave ownership with the allocation so it
|
||||
// can still be released normally.
|
||||
var invalid = try result.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
defer invalid.deinit();
|
||||
try testing.expectError(
|
||||
error.InvalidPageDimensions,
|
||||
invalid.finalize(.prepend),
|
||||
);
|
||||
|
||||
try testing.expectEqual(initial_first, result.pages.first.?);
|
||||
try testing.expectEqual(initial_last, result.pages.last.?);
|
||||
try testing.expectEqual(initial_total_rows, result.total_rows);
|
||||
try testing.expectEqual(initial_page_size, result.page_size);
|
||||
result.assertIntegrity();
|
||||
}
|
||||
|
||||
test "PageList PageAllocation rejects limits before modifying the destination" {
|
||||
const testing = std.testing;
|
||||
|
||||
var result = try init(testing.allocator, .{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
.max_size = 0,
|
||||
.max_lines = null,
|
||||
});
|
||||
defer result.deinit();
|
||||
|
||||
// The effective minimum permits one complete page beyond the active page.
|
||||
// Fill that allowance so the following allocation exceeds the byte limit.
|
||||
{
|
||||
var allocation = try result.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
defer allocation.deinit();
|
||||
allocation.page().size.rows = 1;
|
||||
try allocation.finalize(.prepend);
|
||||
}
|
||||
|
||||
const before_first = result.pages.first.?;
|
||||
const before_total_rows = result.total_rows;
|
||||
const before_page_size = result.page_size;
|
||||
|
||||
var allocation = try result.allocatePage(.{ .cols = 1, .rows = 1 });
|
||||
defer allocation.deinit();
|
||||
allocation.page().size.rows = 1;
|
||||
try testing.expectError(
|
||||
error.MaxSizeExceeded,
|
||||
allocation.finalize(.prepend),
|
||||
);
|
||||
|
||||
try testing.expectEqual(before_first, result.pages.first.?);
|
||||
try testing.expectEqual(before_total_rows, result.total_rows);
|
||||
try testing.expectEqual(before_page_size, result.page_size);
|
||||
result.assertIntegrity();
|
||||
}
|
||||
|
||||
test "PageList PageAllocation allocation failure leaves list unchanged" {
|
||||
const testing = std.testing;
|
||||
|
||||
var failing = testing.FailingAllocator.init(testing.allocator, .{});
|
||||
var result = try init(failing.allocator(), .{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
.max_size = null,
|
||||
.max_lines = null,
|
||||
});
|
||||
defer result.deinit();
|
||||
|
||||
const initial_first = result.pages.first.?;
|
||||
const initial_total_rows = result.total_rows;
|
||||
const initial_page_size = result.page_size;
|
||||
|
||||
// Existing pool capacity is deliberately an implementation detail. Allow
|
||||
// preheated slots to succeed until allocation reaches node-pool growth.
|
||||
failing.fail_index = failing.alloc_index;
|
||||
var allocations: [64]PageAllocation = undefined;
|
||||
var allocation_count: usize = 0;
|
||||
defer for (allocations[0..allocation_count]) |*allocation| {
|
||||
allocation.deinit();
|
||||
};
|
||||
|
||||
var failed = false;
|
||||
for (0..64) |_| {
|
||||
allocations[allocation_count] = result.allocatePage(.{
|
||||
.cols = 1,
|
||||
.rows = 1,
|
||||
}) catch |err| {
|
||||
try testing.expectEqual(error.OutOfMemory, err);
|
||||
failed = true;
|
||||
break;
|
||||
};
|
||||
allocation_count += 1;
|
||||
}
|
||||
try testing.expect(failed);
|
||||
try testing.expect(failing.has_induced_failure);
|
||||
|
||||
// Detached allocations and failed pool growth never publish into the live
|
||||
// list; the deferred cleanup returns every successful allocation.
|
||||
try testing.expectEqual(initial_first, result.pages.first.?);
|
||||
try testing.expectEqual(initial_total_rows, result.total_rows);
|
||||
try testing.expectEqual(initial_page_size, result.page_size);
|
||||
result.assertIntegrity();
|
||||
}
|
||||
|
||||
fn mixedWidthPinListForTest(alloc: Allocator) !PageList {
|
||||
var result = try init(alloc, .{ .cols = 2, .rows = 1 });
|
||||
errdefer result.deinit();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const std = @import("std");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const testing = std.testing;
|
||||
|
||||
const size = @import("size.zig");
|
||||
const Offset = size.Offset;
|
||||
@@ -447,6 +448,47 @@ pub fn RefCountedSet(
|
||||
return self.living;
|
||||
}
|
||||
|
||||
/// A live entry returned by `Iterator`.
|
||||
pub const Entry = struct {
|
||||
id: Id,
|
||||
value_ptr: *T,
|
||||
};
|
||||
|
||||
/// Iterates live entries in ascending ID order.
|
||||
///
|
||||
/// Released entries whose reference count reached zero are skipped.
|
||||
/// Any mutation of the set invalidates the iterator.
|
||||
pub const Iterator = struct {
|
||||
items: [*]Item,
|
||||
id: Id = 1,
|
||||
end: Id,
|
||||
|
||||
pub fn next(self: *Iterator) ?Entry {
|
||||
while (self.id < self.end) {
|
||||
const id = self.id;
|
||||
self.id += 1;
|
||||
|
||||
const item = &self.items[id];
|
||||
if (item.meta.ref == 0) continue;
|
||||
|
||||
return .{
|
||||
.id = id,
|
||||
.value_ptr = &item.value,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Return an iterator over the live entries in this set.
|
||||
pub fn iterator(self: *const Self, base: anytype) Iterator {
|
||||
return .{
|
||||
.items = self.items.ptr(base),
|
||||
.end = self.next_id,
|
||||
};
|
||||
}
|
||||
|
||||
/// Delete an item, removing any references from
|
||||
/// the table, and freeing its ID to be reused.
|
||||
fn deleteItem(self: *Self, base: anytype, id: Id, ctx: Context) void {
|
||||
@@ -721,3 +763,48 @@ pub fn RefCountedSet(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test "iterator visits live entries in ID order" {
|
||||
const alloc = testing.allocator;
|
||||
const TestSet = RefCountedSet(
|
||||
u32,
|
||||
u16,
|
||||
u16,
|
||||
struct {
|
||||
pub fn hash(_: *const @This(), value: u32) u64 {
|
||||
return std.hash.int(value);
|
||||
}
|
||||
|
||||
pub fn eql(_: *const @This(), a: u32, b: u32) bool {
|
||||
return a == b;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const layout: TestSet.Layout = .init(8);
|
||||
const buf = try alloc.alignedAlloc(
|
||||
u8,
|
||||
TestSet.base_align,
|
||||
layout.total_size,
|
||||
);
|
||||
defer alloc.free(buf);
|
||||
|
||||
var set: TestSet = .init(.init(buf), layout, .{});
|
||||
const first = try set.add(buf, 11);
|
||||
const released = try set.add(buf, 22);
|
||||
const last = try set.add(buf, 33);
|
||||
_ = try set.add(buf, 11);
|
||||
set.release(buf, released);
|
||||
|
||||
var it = set.iterator(buf);
|
||||
|
||||
const first_entry = it.next().?;
|
||||
try testing.expectEqual(first, first_entry.id);
|
||||
try testing.expectEqual(@as(u32, 11), first_entry.value_ptr.*);
|
||||
|
||||
const last_entry = it.next().?;
|
||||
try testing.expectEqual(last, last_entry.id);
|
||||
try testing.expectEqual(@as(u32, 33), last_entry.value_ptr.*);
|
||||
|
||||
try testing.expect(it.next() == null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user