PageList can initialize with more than ~46,000 columns (#10297)

I've been poking around our internals around overflow cases while
diagnosing systemic ways to fix #10258.

One thing I found is that our terminal internals advertise that they can
accept up to u16 (~65K) rows and columns for the active area, but at
around 46K columns on x86_64 and 47K on aarch64, our terminal initialize
would error out. Worse, we'd actually trigger integer overflow and crash
in safe builds (and have silent corruption in release builds).

In fixing this, I realized we could comptime-verify a bunch of things
and avoid overflows completely. So this diff contains a bunch of places
that previously had error returns and now do not! 😄 This is a bit silly,
but with libghostty it's more relevant to have correctness around the
edges.

We now accept full u16 rows/cols. More than that isn't allowed by the
type system and our upstream callers must handle that appropriately.

cc @qwerasd205 since its relevant to layout fallibility but not on the
same path
This commit is contained in:
Mitchell Hashimoto
2026-01-12 10:16:18 -08:00
committed by GitHub
2 changed files with 209 additions and 47 deletions

View File

@@ -49,7 +49,12 @@ const Node = struct {
/// The memory pool we get page nodes from.
const NodePool = std.heap.MemoryPool(List.Node);
/// The standard page capacity that we use as a starting point for
/// all pages. This is chosen as a sane default that fits most terminal
/// usage to support using our pool.
const std_capacity = pagepkg.std_capacity;
/// The byte size required for a standard page.
const std_size = Page.layout(std_capacity).total_size;
/// The memory pool we use for page memory buffers. We use a separate pool
@@ -223,19 +228,30 @@ pub const Viewport = union(enum) {
/// But this gives us a nice fast heuristic for determining min/max size.
/// Therefore, if the page size is violated you should always also verify
/// that we have enough space for the active area.
fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize {
fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) usize {
// Invariant required to ensure our divCeil below cannot overflow.
comptime {
const max_rows = std.math.maxInt(size.CellCountInt);
_ = std.math.divCeil(usize, max_rows, 1) catch unreachable;
}
// Get our capacity to fit our rows. If the cols are too big, it may
// force less rows than we want meaning we need more than one page to
// represent a viewport.
const cap = try std_capacity.adjust(.{ .cols = cols });
const cap = initialCapacity(cols);
// Calculate the number of standard sized pages we need to represent
// an active area.
const pages_exact = if (cap.rows >= rows) 1 else try std.math.divCeil(
const pages_exact = if (cap.rows >= rows) 1 else std.math.divCeil(
usize,
rows,
cap.rows,
);
) catch {
// Not possible:
// - initialCapacity guarantees at least 1 row
// - numerator/denominator can't overflow because of comptime check above
unreachable;
};
// We always need at least one page extra so that we
// can fit partial pages to spread our active area across two pages.
@@ -255,6 +271,49 @@ fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize {
return PagePool.item_size * pages;
}
/// Calculates the initial capacity for a new page for a given column
/// count. This will attempt to fit within std_size at all times so we
/// can use our memory pool, but if cols is too big, this will return a
/// larger capacity.
///
/// The returned capacity is always guaranteed to layout properly (not
/// overflow). We are able to support capacities up to the maximum int
/// value of cols, so this will never overflow.
fn initialCapacity(cols: size.CellCountInt) Capacity {
// This is an important invariant that ensures that this function
// can never return an error. We verify here that our standard capacity
// when increased to maximum possible columns can always support at
// least one row in memory.
//
// IF THIS EVER FAILS: We probably need to modify our logic below
// to reduce other elements of the capacity (styles, graphemes, etc.).
// But, instead, I recommend taking a step back and re-evaluating
// life choices.
comptime {
var cap = std_capacity;
cap.cols = std.math.maxInt(size.CellCountInt);
_ = Page.layout(cap);
}
if (std_capacity.adjust(
.{ .cols = cols },
)) |cap| {
// If we can adjust our standard capacity, we fit within the
// standard size and we're good!
return cap;
} else |err| {
// Ensure our error set doesn't change.
comptime assert(@TypeOf(err) == error{OutOfMemory});
}
// This code path means that our standard capacity can't even
// accommodate our column count! The only solution is to increase
// our capacity and go non-standard.
var cap: Capacity = std_capacity;
cap.cols = cols;
return cap;
}
/// This is the page allocator we'll use for all our underlying
/// VM page allocations.
inline fn pageAllocator() Allocator {
@@ -310,7 +369,7 @@ pub fn init(
);
// Get our minimum max size, see doc comments for more details.
const min_max_size = try minMaxSize(cols, rows);
const min_max_size = minMaxSize(cols, rows);
// We always track our viewport pin to ensure this is never an allocation
const viewport_pin = try pool.pins.create();
@@ -344,17 +403,31 @@ fn initPages(
serial: *u64,
cols: size.CellCountInt,
rows: size.CellCountInt,
) !struct { List, usize } {
) Allocator.Error!struct { List, usize } {
var page_list: List = .{};
var page_size: usize = 0;
// Add pages as needed to create our initial viewport.
const cap = try std_capacity.adjust(.{ .cols = cols });
const cap = initialCapacity(cols);
const layout = Page.layout(cap);
const pooled = layout.total_size <= std_size;
const page_alloc = pool.pages.arena.child_allocator;
var rem = rows;
while (rem > 0) {
const node = try pool.nodes.create();
const page_buf = try pool.pages.create();
// no errdefer because the pool deinit will clean these up
const page_buf = if (pooled)
try pool.pages.create()
else
try page_alloc.alignedAlloc(
u8,
.fromByteUnits(std.heap.page_size_min),
layout.total_size,
);
errdefer if (pooled)
pool.pages.destroy(page_buf)
else
page_alloc.free(page_buf);
// In runtime safety modes we have to memset because the Zig allocator
// interface will always memset to 0xAA for undefined. In non-safe modes
@@ -364,10 +437,7 @@ fn initPages(
// Initialize the first set of pages to contain our viewport so that
// the top of the first page is always the active area.
node.* = .{
.data = .initBuf(
.init(page_buf),
Page.layout(cap),
),
.data = .initBuf(.init(page_buf), layout),
.serial = serial.*,
};
node.data.size.rows = @min(rem, node.data.capacity.rows);
@@ -533,10 +603,8 @@ pub fn reset(self: *PageList) void {
// We need enough pages/nodes to keep our active area. This should
// never fail since we by definition have allocated a page already
// that fits our size but I'm not confident to make that assertion.
const cap = std_capacity.adjust(
.{ .cols = self.cols },
) catch @panic("reset: std_capacity.adjust failed");
assert(cap.rows > 0); // adjust should never return 0 rows
const cap = initialCapacity(self.cols);
assert(cap.rows > 0);
// The number of pages we need is the number of rows in the active
// area divided by the row capacity of a page.
@@ -828,7 +896,7 @@ pub fn resize(self: *PageList, opts: Resize) !void {
// when increasing beyond our initial minimum max size or explicit max
// size to fit the active area.
const old_min_max_size = self.min_max_size;
self.min_max_size = try minMaxSize(
self.min_max_size = minMaxSize(
opts.cols orelse self.cols,
opts.rows orelse self.rows,
);
@@ -1592,7 +1660,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void {
// We only set the new min_max_size if we're not reflowing. If we are
// reflowing, then resize handles this for us.
const old_min_max_size = self.min_max_size;
self.min_max_size = if (!opts.reflow) try minMaxSize(
self.min_max_size = if (!opts.reflow) minMaxSize(
opts.cols orelse self.cols,
opts.rows orelse self.rows,
) else old_min_max_size;
@@ -4551,6 +4619,38 @@ test "PageList init rows across two pages" {
}, s.scrollbar());
}
test "PageList init more than max cols" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize with more columns than we can fit in our standard
// capacity. This is going to force us to go to a non-standard page
// immediately.
var s = try init(
alloc,
std_capacity.maxCols().? + 1,
80,
null,
);
defer s.deinit();
try testing.expect(s.viewport == .active);
try testing.expectEqual(@as(usize, s.rows), s.totalRows());
// We expect a single, non-standard page
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first.?.data.memory.len > std_size);
// Initial total rows should be our row count
try testing.expectEqual(s.rows, s.total_rows);
// Scrollbar should be where we expect it
try testing.expectEqual(Scrollbar{
.total = s.rows,
.offset = 0,
.len = s.rows,
}, s.scrollbar());
}
test "PageList pointFromPin active no history" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -196,7 +196,8 @@ pub const Page = struct {
// We need to go through and initialize all the rows so that
// they point to a valid offset into the cells, since the rows
// zero-initialized aren't valid.
const cells_ptr = cells.ptr(buf)[0 .. cap.cols * cap.rows];
const cells_len = @as(usize, cap.cols) * @as(usize, cap.rows);
const cells_ptr = cells.ptr(buf)[0..cells_len];
for (rows.ptr(buf)[0..cap.rows], 0..) |*row, y| {
const start = y * cap.cols;
row.* = .{
@@ -1556,7 +1557,7 @@ pub const Page = struct {
const rows_start = 0;
const rows_end: usize = rows_start + (rows_count * @sizeOf(Row));
const cells_count: usize = @intCast(cap.cols * cap.rows);
const cells_count: usize = @as(usize, cap.cols) * @as(usize, cap.rows);
const cells_start = alignForward(usize, rows_end, @alignOf(Cell));
const cells_end = cells_start + (cells_count * @sizeOf(Cell));
@@ -1662,43 +1663,42 @@ pub const Capacity = struct {
cols: ?size.CellCountInt = null,
};
/// Returns the maximum number of columns that can be used with this
/// capacity while still fitting at least one row. Returns null if even
/// a single column cannot fit (which would indicate an unusable capacity).
///
/// Note that this is the maximum number of columns that never increases
/// the amount of memory the original capacity will take. If you modify
/// the original capacity to add rows, then you can fit more columns.
pub fn maxCols(self: Capacity) ?size.CellCountInt {
const available_bits = self.availableBitsForGrid();
// If we can't even fit the row metadata, return null
if (available_bits <= @bitSizeOf(Row)) return null;
// We do the math of how many columns we can fit in the remaining
// bits ignoring the metadata of a row.
const remaining_bits = available_bits - @bitSizeOf(Row);
const max_cols = remaining_bits / @bitSizeOf(Cell);
// Clamp to CellCountInt max
return @min(std.math.maxInt(size.CellCountInt), max_cols);
}
/// Adjust the capacity parameters while retaining the same total size.
///
/// Adjustments always happen by limiting the rows in the page. Everything
/// else can grow. If it is impossible to achieve the desired adjustment,
/// OutOfMemory is returned.
pub fn adjust(self: Capacity, req: Adjustment) Allocator.Error!Capacity {
var adjusted = self;
if (req.cols) |cols| {
// The math below only works if there is no alignment gap between
// the end of the rows array and the start of the cells array.
//
// To guarantee this, we assert that Row's size is a multiple of
// Cell's alignment, so that any length array of Rows will end on
// a valid alignment for the start of the Cell array.
assert(@sizeOf(Row) % @alignOf(Cell) == 0);
const layout = Page.layout(self);
// In order to determine the amount of space in the page available
// for rows & cells (which will allow us to calculate the number of
// rows we can fit at a certain column width) we need to layout the
// "meta" members of the page (i.e. everything else) from the end.
const hyperlink_map_start = alignBackward(usize, layout.total_size - layout.hyperlink_map_layout.total_size, hyperlink.Map.base_align.toByteUnits());
const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - layout.hyperlink_set_layout.total_size, hyperlink.Set.base_align.toByteUnits());
const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits());
const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits());
const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits());
const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, StyleSet.base_align.toByteUnits());
const available_bits = self.availableBitsForGrid();
// The size per row is:
// - The row metadata itself
// - The cells per row (n=cols)
const bits_per_row: usize = size: {
var bits: usize = @bitSizeOf(Row); // Row metadata
bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols)
break :size bits;
};
const available_bits: usize = styles_start * 8;
const bits_per_row: usize = @bitSizeOf(Row) + @bitSizeOf(Cell) * @as(usize, @intCast(cols));
const new_rows: usize = @divFloor(available_bits, bits_per_row);
// If our rows go to zero then we can't fit any row metadata
@@ -1711,6 +1711,34 @@ pub const Capacity = struct {
return adjusted;
}
/// Computes the number of bits available for rows and cells in the page.
///
/// This is done by laying out the "meta" members (styles, graphemes,
/// hyperlinks, strings) from the end of the page and finding where they
/// start, which gives us the space available for rows and cells.
fn availableBitsForGrid(self: Capacity) usize {
// The math below only works if there is no alignment gap between
// the end of the rows array and the start of the cells array.
//
// To guarantee this, we assert that Row's size is a multiple of
// Cell's alignment, so that any length array of Rows will end on
// a valid alignment for the start of the Cell array.
assert(@sizeOf(Row) % @alignOf(Cell) == 0);
const l = Page.layout(self);
// Layout meta members from the end to find styles_start
const hyperlink_map_start = alignBackward(usize, l.total_size - l.hyperlink_map_layout.total_size, hyperlink.Map.base_align.toByteUnits());
const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - l.hyperlink_set_layout.total_size, hyperlink.Set.base_align.toByteUnits());
const string_alloc_start = alignBackward(usize, hyperlink_set_start - l.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits());
const grapheme_map_start = alignBackward(usize, string_alloc_start - l.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits());
const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - l.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits());
const styles_start = alignBackward(usize, grapheme_alloc_start - l.styles_layout.total_size, StyleSet.base_align.toByteUnits());
// Multiply by 8 to convert bytes to bits
return styles_start * 8;
}
};
pub const Row = packed struct(u64) {
@@ -2070,6 +2098,40 @@ test "Page capacity adjust cols too high" {
);
}
test "Capacity maxCols basic" {
const cap = std_capacity;
const max = cap.maxCols().?;
// maxCols should be >= current cols (since current capacity is valid)
try testing.expect(max >= cap.cols);
// Adjusting to maxCols should succeed with at least 1 row
const adjusted = try cap.adjust(.{ .cols = max });
try testing.expect(adjusted.rows >= 1);
// Adjusting to maxCols + 1 should fail
try testing.expectError(
error.OutOfMemory,
cap.adjust(.{ .cols = max + 1 }),
);
}
test "Capacity maxCols preserves total size" {
const cap = std_capacity;
const original_size = Page.layout(cap).total_size;
const max = cap.maxCols().?;
const adjusted = try cap.adjust(.{ .cols = max });
const adjusted_size = Page.layout(adjusted).total_size;
try testing.expectEqual(original_size, adjusted_size);
}
test "Capacity maxCols with 1 row exactly" {
const cap = std_capacity;
const max = cap.maxCols().?;
const adjusted = try cap.adjust(.{ .cols = max });
try testing.expectEqual(@as(size.CellCountInt, 1), adjusted.rows);
}
test "Page init" {
var page = try Page.init(.{
.cols = 120,