PageList overflow detection and protection (#10337)

Fixes #10258  
Replaces #10284

1. `Page.Capacity` now uses smaller bit-width integers that represent a
true maximum capacity for various fields.
2. On 64-bit systems, a maxed out `Page.Capacity` (every field `maxInt`)
can be represented in an addressable allocation (total required memory
less than 64 bits). This means `Page.layout` can't overflow.
3. All `adjustCapacity` functions replaced with `increaseCapacity` which
doesn't allow specifying the resulting value, which makes it so overflow
is only possible in significantly fewer places, making it easier to
handle in general.
4. `increaseCapacity` can return a new error `OutOfSpace` which happens
when overflow is detected. This means that no valid page can accommodate
the desired capacity increase because we're already at the max. The
caller is expected to handle this.
5. Updated our resize so that the only possible error is system OOM, we
handle the new `OutOfSpace` by copying the recent reflowed row into a
new page and continuing.

A very, very high-level overview is below. The "overflow" here papers
over a bunch of details where the prior usize capacities flowed through
to Page.layout and ultimately RefCountedSet and other managed types
which then caused incorrect calculations on total memory size required.

```mermaid
flowchart TB
    subgraph Before["Before: adjustCapacity"]
        A1[capacity: usize] --> A2["capacity *= 2"]
        A2 --> A3{Overflow?}
        A3 -->|"Not detected"| A4["Massive allocation or crash"]
    end
    
    subgraph After["After: increaseCapacity"]
        B1["capacity: bounded int<br/>(u16/u32)"] --> B2["capacity *= 2"]
        B2 --> B3{Overflow?}
        B3 -->|"OutOfSpace error"| B4["Graceful handling:<br/>move row to new page"]
        B3 -->|"Success"| B5["Normal allocation"]
    end
    
    Before --> After
    
    classDef beforeStyle fill:#3d1a1a,stroke:#ff6b6b,color:#ff6b6b
    classDef afterStyle fill:#1a3d3a,stroke:#4ecdc4,color:#4ecdc4
    
    class A1,A2,A3,A4 beforeStyle
    class B1,B2,B3,B4,B5 afterStyle
```
This commit is contained in:
Mitchell Hashimoto
2026-01-16 14:52:28 -08:00
committed by GitHub
7 changed files with 1207 additions and 563 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -517,39 +517,38 @@ pub fn clone(
result.assertIntegrity();
return result;
}
/// Adjust the capacity of a page within the pagelist of this screen.
/// This handles some accounting if the page being modified is the
/// cursor page.
pub fn adjustCapacity(
pub fn increaseCapacity(
self: *Screen,
node: *PageList.List.Node,
adjustment: PageList.AdjustCapacity,
) PageList.AdjustCapacityError!*PageList.List.Node {
adjustment: ?PageList.IncreaseCapacity,
) PageList.IncreaseCapacityError!*PageList.List.Node {
// If the page being modified isn't our cursor page then
// this is a quick operation because we have no additional
// accounting.
if (node != self.cursor.page_pin.node) {
return try self.pages.adjustCapacity(node, adjustment);
}
// accounting. We have to do this check here BEFORE calling
// increaseCapacity because increaseCapacity will update all
// our tracked pins (including our cursor).
if (node != self.cursor.page_pin.node) return try self.pages.increaseCapacity(
node,
adjustment,
);
// We're modifying the cursor page. When we adjust the
// We're modifying the cursor page. When we increase the
// capacity below it will be short the ref count on our
// current style and hyperlink, so we need to init those.
const new_node = try self.pages.adjustCapacity(node, adjustment);
const new_node = try self.pages.increaseCapacity(node, adjustment);
const new_page: *Page = &new_node.data;
// Re-add the style, if the page somehow doesn't have enough
// memory to add it, we emit a warning and gracefully degrade
// to the default style for the cursor.
if (self.cursor.style_id != 0) {
if (self.cursor.style_id != style.default_id) {
self.cursor.style_id = new_page.styles.add(
new_page.memory,
self.cursor.style,
) catch |err| id: {
// TODO: Should we increase the capacity further in this case?
log.warn(
"(Screen.adjustCapacity) Failed to add cursor style back to page, err={}",
"(Screen.increaseCapacity) Failed to add cursor style back to page, err={}",
.{err},
);
@@ -571,7 +570,7 @@ pub fn adjustCapacity(
self.startHyperlinkOnce(link.*) catch |err| {
// TODO: Should we increase the capacity further in this case?
log.warn(
"(Screen.adjustCapacity) Failed to add cursor hyperlink back to page, err={}",
"(Screen.increaseCapacity) Failed to add cursor hyperlink back to page, err={}",
.{err},
);
};
@@ -1106,14 +1105,19 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void {
return;
}
// If we have a old style then we need to release it from the old page.
// If we have an old style then we need to release it from the old page.
const old_style_: ?style.Style = if (self.cursor.style_id == style.default_id)
null
else
self.cursor.style;
if (old_style_ != null) {
// Release the style directly from the old page instead of going through
// manualStyleUpdate, because the cursor position may have already been
// updated but the pin has not, which would fail integrity checks.
const old_page: *Page = &self.cursor.page_pin.node.data;
old_page.styles.release(old_page.memory, self.cursor.style_id);
self.cursor.style = .{};
self.manualStyleUpdate() catch unreachable; // Removing a style should never fail
self.cursor.style_id = style.default_id;
}
// If we have a hyperlink then we need to release it from the old page.
@@ -1930,7 +1934,17 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void {
}
/// Call this whenever you manually change the cursor style.
pub fn manualStyleUpdate(self: *Screen) !void {
///
/// Note that this can return any PageList capacity error, because it
/// is possible for the internal pagelist to not accommodate the new style
/// at all. This WILL attempt to resize our internal pages to fit the style
/// but it is possible that it cannot be done, in which case upstream callers
/// need to split the page or do something else.
///
/// NOTE(mitchellh): I think in the future we'll do page splitting
/// automatically here and remove this failure scenario.
pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void {
defer self.assertIntegrity();
var page: *Page = &self.cursor.page_pin.node.data;
// std.log.warn("active styles={}", .{page.styles.count()});
@@ -1949,6 +1963,9 @@ pub fn manualStyleUpdate(self: *Screen) !void {
// Clear the cursor style ID to prevent weird things from happening
// if the page capacity has to be adjusted which would end up calling
// manualStyleUpdate again.
//
// This also ensures that if anything fails below, we fall back to
// clearing our style.
self.cursor.style_id = style.default_id;
// After setting the style, we need to update our style map.
@@ -1960,30 +1977,50 @@ pub fn manualStyleUpdate(self: *Screen) !void {
page.memory,
self.cursor.style,
) catch |err| id: {
// Our style map is full or needs to be rehashed,
// so we allocate a new page, which will rehash,
// and double the style capacity for it if it was
// full.
const node = try self.adjustCapacity(
// Our style map is full or needs to be rehashed, so we need to
// increase style capacity (or rehash).
const node = try self.increaseCapacity(
self.cursor.page_pin.node,
switch (err) {
error.OutOfMemory => .{ .styles = page.capacity.styles * 2 },
error.NeedsRehash => .{},
error.OutOfMemory => .styles,
error.NeedsRehash => null,
},
);
page = &node.data;
break :id try page.styles.add(
break :id page.styles.add(
page.memory,
self.cursor.style,
);
) catch |err2| switch (err2) {
error.OutOfMemory => {
// This shouldn't happen because increaseCapacity is
// guaranteed to increase our capacity by at least one and
// we only need one space, but again, I don't want to crash
// here so let's log loudly and reset.
log.err("style addition failed after capacity increase", .{});
return error.OutOfMemory;
},
error.NeedsRehash => {
// This should be impossible because we rehash above
// and rehashing should never result in a duplicate. But
// we don't want to simply hard crash so log it and
// clear our style.
log.err("style rehash resulted in needs rehash", .{});
return;
},
};
};
errdefer page.styles.release(page.memory, id);
self.cursor.style_id = id;
self.assertIntegrity();
}
/// Append a grapheme to the given cell within the current cursor row.
pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void {
pub fn appendGrapheme(
self: *Screen,
cell: *Cell,
cp: u21,
) PageList.IncreaseCapacityError!void {
defer self.cursor.page_pin.node.data.assertIntegrity();
self.cursor.page_pin.node.data.appendGrapheme(
self.cursor.page_row,
@@ -2003,11 +2040,9 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void {
// Adjust our capacity. This will update our cursor page pin and
// force us to reload.
const original_node = self.cursor.page_pin.node;
const new_bytes = original_node.data.capacity.grapheme_bytes * 2;
_ = try self.adjustCapacity(
original_node,
.{ .grapheme_bytes = new_bytes },
_ = try self.increaseCapacity(
self.cursor.page_pin.node,
.grapheme_bytes,
);
// The cell pointer is now invalid, so we need to get it from
@@ -2018,17 +2053,22 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void {
.gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)),
};
try self.cursor.page_pin.node.data.appendGrapheme(
self.cursor.page_pin.node.data.appendGrapheme(
self.cursor.page_row,
reloaded_cell,
cp,
);
) catch |err2| {
comptime assert(@TypeOf(err2) == error{OutOfMemory});
// This should never happen because we just increased capacity.
// Log loudly but still return an error so we don't just
// crash.
log.err("grapheme append failed after capacity increase", .{});
return err2;
};
},
};
}
pub const StartHyperlinkError = Allocator.Error || PageList.AdjustCapacityError;
/// Start the hyperlink state. Future cells will be marked as hyperlinks with
/// this state. Note that various terminal operations may clear the hyperlink
/// state, such as switching screens (alt screen).
@@ -2036,7 +2076,7 @@ pub fn startHyperlink(
self: *Screen,
uri: []const u8,
id_: ?[]const u8,
) StartHyperlinkError!void {
) PageList.IncreaseCapacityError!void {
// Create our pending entry.
const link: hyperlink.Hyperlink = .{
.uri = uri,
@@ -2061,21 +2101,21 @@ pub fn startHyperlink(
error.OutOfMemory => return error.OutOfMemory,
// strings table is out of memory, adjust it up
error.StringsOutOfMemory => _ = try self.adjustCapacity(
error.StringsOutOfMemory => _ = try self.increaseCapacity(
self.cursor.page_pin.node,
.{ .string_bytes = self.cursor.page_pin.node.data.capacity.string_bytes * 2 },
.string_bytes,
),
// hyperlink set is out of memory, adjust it up
error.SetOutOfMemory => _ = try self.adjustCapacity(
error.SetOutOfMemory => _ = try self.increaseCapacity(
self.cursor.page_pin.node,
.{ .hyperlink_bytes = self.cursor.page_pin.node.data.capacity.hyperlink_bytes * 2 },
.hyperlink_bytes,
),
// hyperlink set is too full, rehash it
error.SetNeedsRehash => _ = try self.adjustCapacity(
error.SetNeedsRehash => _ = try self.increaseCapacity(
self.cursor.page_pin.node,
.{},
null,
),
}
@@ -2137,7 +2177,7 @@ pub fn endHyperlink(self: *Screen) void {
}
/// Set the current hyperlink state on the current cell.
pub fn cursorSetHyperlink(self: *Screen) !void {
pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void {
assert(self.cursor.hyperlink_id != 0);
var page = &self.cursor.page_pin.node.data;
@@ -2152,40 +2192,38 @@ pub fn cursorSetHyperlink(self: *Screen) !void {
} else |err| switch (err) {
// hyperlink_map is out of space, realloc the page to be larger
error.HyperlinkMapOutOfMemory => {
const uri_size = if (self.cursor.hyperlink) |link| link.uri.len else 0;
var string_bytes = page.capacity.string_bytes;
// Attempt to allocate the space that would be required to
// insert a new copy of the cursor hyperlink uri in to the
// string alloc, since right now adjustCapacity always just
// string alloc, since right now increaseCapacity always just
// adds an extra copy even if one already exists in the page.
// If this alloc fails then we know we also need to grow our
// string bytes.
//
// FIXME: This SUCKS
if (page.string_alloc.alloc(
u8,
page.memory,
uri_size,
)) |slice| {
// We don't bother freeing because we're
// about to free the entire page anyway.
_ = &slice;
} else |_| {
// We didn't have enough room, let's just double our
// string bytes until there's definitely enough room
// for our uri.
const before = string_bytes;
while (string_bytes - before < uri_size) string_bytes *= 2;
// FIXME: increaseCapacity should not do this.
while (self.cursor.hyperlink) |link| {
if (page.string_alloc.alloc(
u8,
page.memory,
link.uri.len,
)) |slice| {
// We don't bother freeing because we're
// about to free the entire page anyway.
_ = slice;
break;
} else |_| {}
// We didn't have enough room, let's increase string bytes
const new_node = try self.increaseCapacity(
self.cursor.page_pin.node,
.string_bytes,
);
assert(new_node == self.cursor.page_pin.node);
page = &new_node.data;
}
_ = try self.adjustCapacity(
_ = try self.increaseCapacity(
self.cursor.page_pin.node,
.{
.hyperlink_bytes = page.capacity.hyperlink_bytes * 2,
.string_bytes = string_bytes,
},
.hyperlink_bytes,
);
// Retry
@@ -3007,15 +3045,15 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
.protected = self.cursor.protected,
};
// If we have a hyperlink, add it to the cell.
if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
// If we have a ref-counted style, increase.
if (self.cursor.style_id != style.default_id) {
const page = self.cursor.page_pin.node.data;
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
}
// If we have a hyperlink, add it to the cell.
if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
},
2 => {
@@ -8896,134 +8934,240 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" {
try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes);
}
test "Screen: adjustCapacity cursor style ref count" {
test "Screen: increaseCapacity cursor style ref count preserved" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
var s = try init(alloc, .{
.cols = 5,
.rows = 5,
.max_scrollback = 0,
});
defer s.deinit();
try s.setAttribute(.{ .bold = {} });
try s.setAttribute(.bold);
try s.testWriteString("1ABCD");
// We should have one page and it should be our cursor page
try testing.expect(s.pages.pages.first == s.pages.pages.last);
try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
const old_style = s.cursor.style;
{
const page = &s.pages.pages.last.?.data;
// 5 chars + cursor = 6 refs
try testing.expectEqual(
6, // All chars + cursor
6,
page.styles.refCount(page.memory, s.cursor.style_id),
);
}
// This forces the page to change.
_ = try s.adjustCapacity(
// This forces the page to change via increaseCapacity.
const new_node = try s.increaseCapacity(
s.cursor.page_pin.node,
.{ .grapheme_bytes = s.cursor.page_pin.node.data.capacity.grapheme_bytes * 2 },
.grapheme_bytes,
);
// Our ref counts should still be the same
// Cursor's page_pin should now point to the new node
try testing.expect(s.cursor.page_pin.node == new_node);
// Verify cursor's page_cell and page_row are correctly reloaded from the pin
const page_rac = s.cursor.page_pin.rowAndCell();
try testing.expect(s.cursor.page_row == page_rac.row);
try testing.expect(s.cursor.page_cell == page_rac.cell);
// Style should be preserved
try testing.expectEqual(old_style, s.cursor.style);
try testing.expect(s.cursor.style_id != style.default_id);
// After increaseCapacity, the 5 chars are cloned (5 refs) and
// the cursor's style is re-added (1 ref) = 6 total.
{
const page = &s.pages.pages.last.?.data;
const ref_count = page.styles.refCount(page.memory, s.cursor.style_id);
try testing.expectEqual(6, ref_count);
}
}
test "Screen: increaseCapacity cursor hyperlink ref count preserved" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{
.cols = 5,
.rows = 5,
.max_scrollback = 0,
});
defer s.deinit();
try s.startHyperlink("https://example.com/", null);
try s.testWriteString("1ABCD");
// We should have one page and it should be our cursor page
try testing.expect(s.pages.pages.first == s.pages.pages.last);
try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
{
const page = &s.pages.pages.last.?.data;
// Cursor has the hyperlink active = 1 count in hyperlink_set
try testing.expectEqual(1, page.hyperlink_set.count());
try testing.expect(s.cursor.hyperlink_id != 0);
try testing.expect(s.cursor.hyperlink != null);
}
// This forces the page to change via increaseCapacity.
_ = try s.increaseCapacity(
s.cursor.page_pin.node,
.grapheme_bytes,
);
// Hyperlink should be preserved with correct URI
try testing.expect(s.cursor.hyperlink != null);
try testing.expect(s.cursor.hyperlink_id != 0);
try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri);
// After increaseCapacity, the hyperlink is re-added to the new page.
{
const page = &s.pages.pages.last.?.data;
try testing.expectEqual(1, page.hyperlink_set.count());
}
}
test "Screen: increaseCapacity cursor with both style and hyperlink preserved" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{
.cols = 5,
.rows = 5,
.max_scrollback = 0,
});
defer s.deinit();
// Set both a non-default style AND an active hyperlink.
// Write one character first with bold to mark the row as styled,
// then start the hyperlink and write more characters.
try s.setAttribute(.bold);
try s.startHyperlink("https://example.com/", null);
try s.testWriteString("1ABCD");
// We should have one page and it should be our cursor page
try testing.expect(s.pages.pages.first == s.pages.pages.last);
try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
const old_style = s.cursor.style;
{
const page = &s.pages.pages.last.?.data;
// 5 chars + cursor = 6 refs for bold style
try testing.expectEqual(
6, // All chars + cursor
6,
page.styles.refCount(page.memory, s.cursor.style_id),
);
// Cursor has the hyperlink active = 1 count in hyperlink_set
try testing.expectEqual(1, page.hyperlink_set.count());
try testing.expect(s.cursor.style_id != style.default_id);
try testing.expect(s.cursor.hyperlink_id != 0);
try testing.expect(s.cursor.hyperlink != null);
}
// This forces the page to change via increaseCapacity.
_ = try s.increaseCapacity(
s.cursor.page_pin.node,
.grapheme_bytes,
);
// Style should be preserved
try testing.expectEqual(old_style, s.cursor.style);
try testing.expect(s.cursor.style_id != style.default_id);
// Hyperlink should be preserved with correct URI
try testing.expect(s.cursor.hyperlink != null);
try testing.expect(s.cursor.hyperlink_id != 0);
try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri);
// After increaseCapacity, both style and hyperlink are re-added to the new page.
{
const page = &s.pages.pages.last.?.data;
const ref_count = page.styles.refCount(page.memory, s.cursor.style_id);
try testing.expectEqual(6, ref_count);
try testing.expectEqual(1, page.hyperlink_set.count());
}
}
test "Screen: adjustCapacity cursor hyperlink exceeds string alloc size" {
test "Screen: increaseCapacity non-cursor page returns early" {
// Test that calling increaseCapacity on a page that is NOT the cursor's
// page properly delegates to pages.increaseCapacity without doing the
// extra cursor accounting (style/hyperlink re-adding).
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 });
var s = try init(alloc, .{
.cols = 80,
.rows = 24,
.max_scrollback = 10000,
});
defer s.deinit();
// Start a hyperlink with a URI that just barely fits in the string alloc.
// This will ensure that the redundant copy added in `adjustCapacity` won't
// fit in the available string alloc space.
const uri = "a" ** (pagepkg.std_capacity.string_bytes - 8);
try s.startHyperlink(uri, null);
// Write some characters with this so that the URI
// is copied to the new page when adjusting capacity.
// Set up a custom style and hyperlink on the cursor
try s.setAttribute(.bold);
try s.startHyperlink("https://example.com/", null);
try s.testWriteString("Hello");
// Adjust the capacity, right now this will cause a redundant copy of
// the URI to be added to the string alloc, but since there isn't room
// for this this will clear the cursor hyperlink.
_ = try s.adjustCapacity(s.cursor.page_pin.node, .{});
// Store cursor state before growing pages
const old_style = s.cursor.style;
const old_style_id = s.cursor.style_id;
const old_hyperlink = s.cursor.hyperlink;
const old_hyperlink_id = s.cursor.hyperlink_id;
// The cursor hyperlink should have been cleared by the `adjustCapacity`
// call, because there isn't enough room to add the redundant URI string.
//
// This behavior will change, causing this test to fail, if any of these
// changes are made:
//
// - The string alloc is changed to intern strings.
//
// - The adjustCapacity function is changed to ensure the new
// capacity will fit the redundant copy of the hyperlink uri.
//
// - The cursor managed memory handling is reworked so that it
// doesn't reside in the pages anymore and doesn't need this
// accounting.
//
// In such a case, adjust this test accordingly.
try testing.expectEqual(null, s.cursor.hyperlink);
try testing.expectEqual(0, s.cursor.hyperlink_id);
}
// The cursor is on the first (and only) page
try testing.expect(s.pages.pages.first == s.pages.pages.last);
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
test "Screen: adjustCapacity cursor style exceeds style set capacity" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 });
defer s.deinit();
const page = &s.cursor.page_pin.node.data;
// We add unique styles to the page until no more will fit.
fill: for (0..255) |bg| {
for (0..255) |fg| {
const st: style.Style = .{
.bg_color = .{ .palette = @intCast(bg) },
.fg_color = .{ .palette = @intCast(fg) },
};
s.cursor.style = st;
// Try to insert the new style, if it doesn't fit then
// we succeeded in filling the style set, so we break.
s.cursor.style_id = page.styles.add(
page.memory,
s.cursor.style,
) catch break :fill;
try s.testWriteString("a");
}
// Grow pages until we have multiple pages. The cursor's pin stays on
// the first page since we're just adding rows.
const first_page_node = s.pages.pages.first.?;
first_page_node.data.pauseIntegrityChecks(true);
for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| {
_ = try s.pages.grow();
}
first_page_node.data.pauseIntegrityChecks(false);
_ = try s.pages.grow();
// Adjust the capacity, this should cause the style set to reach the
// same state it was in to begin with, since it will clone the page
// in the same order as the styles were added to begin with, meaning
// the cursor style will not be able to be added to the set, which
// should, right now, result in the cursor style being cleared.
_ = try s.adjustCapacity(s.cursor.page_pin.node, .{});
// Now we have two pages
try testing.expect(s.pages.pages.first != s.pages.pages.last);
const second_page = s.pages.pages.last.?;
// The cursor style should have been cleared by the `adjustCapacity`.
//
// This behavior will change, causing this test to fail, if either
// of these changes are made:
//
// - The adjustCapacity function is changed to ensure the
// new capacity will definitely fit the cursor style.
//
// - The cursor managed memory handling is reworked so that it
// doesn't reside in the pages anymore and doesn't need this
// accounting.
//
// In such a case, adjust this test accordingly.
try testing.expect(s.cursor.style.default());
try testing.expectEqual(style.default_id, s.cursor.style_id);
// Cursor should still be on the first page (where it was created)
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
try testing.expect(s.cursor.page_pin.node != second_page);
const second_page_styles_cap = second_page.data.capacity.styles;
const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles;
// Call increaseCapacity on the second page (NOT the cursor's page)
const new_second_page = try s.increaseCapacity(second_page, .styles);
// The second page should have increased capacity
try testing.expectEqual(
second_page_styles_cap * 2,
new_second_page.data.capacity.styles,
);
// The cursor's page (first page) should be unchanged
try testing.expectEqual(
cursor_page_styles_cap,
s.cursor.page_pin.node.data.capacity.styles,
);
// Cursor state should be completely unchanged since we didn't touch its page
try testing.expectEqual(old_style, s.cursor.style);
try testing.expectEqual(old_style_id, s.cursor.style_id);
try testing.expectEqual(old_hyperlink, s.cursor.hyperlink);
try testing.expectEqual(old_hyperlink_id, s.cursor.hyperlink_id);
// Verify hyperlink is still valid
try testing.expect(s.cursor.hyperlink != null);
try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri);
}
test "Screen: cursorDown to page with insufficient capacity" {

View File

@@ -1634,54 +1634,48 @@ pub fn insertLines(self: *Terminal, count: usize) void {
self.scrolling_region.left,
self.scrolling_region.right + 1,
) catch |err| {
const cap = dst_p.node.data.capacity;
// Adjust our page capacity to make
// room for we didn't have space for
_ = self.screens.active.adjustCapacity(
_ = self.screens.active.increaseCapacity(
dst_p.node,
switch (err) {
// Rehash the sets
error.StyleSetNeedsRehash,
error.HyperlinkSetNeedsRehash,
=> .{},
=> null,
// Increase style memory
error.StyleSetOutOfMemory,
=> .{ .styles = cap.styles * 2 },
=> .styles,
// Increase string memory
error.StringAllocOutOfMemory,
=> .{ .string_bytes = cap.string_bytes * 2 },
=> .string_bytes,
// Increase hyperlink memory
error.HyperlinkSetOutOfMemory,
error.HyperlinkMapOutOfMemory,
=> .{ .hyperlink_bytes = cap.hyperlink_bytes * 2 },
=> .hyperlink_bytes,
// Increase grapheme memory
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> .{ .grapheme_bytes = cap.grapheme_bytes * 2 },
=> .grapheme_bytes,
},
) catch |e| switch (e) {
// This shouldn't be possible because above we're only
// adjusting capacity _upwards_. So it should have all
// the existing capacity it had to fit the adjusted
// data. Panic since we don't expect this.
error.StyleSetOutOfMemory,
error.StyleSetNeedsRehash,
error.StringAllocOutOfMemory,
error.HyperlinkSetOutOfMemory,
error.HyperlinkSetNeedsRehash,
error.HyperlinkMapOutOfMemory,
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> @panic("adjustCapacity resulted in capacity errors"),
// The system allocator is OOM. We can't currently do
// anything graceful here. We panic.
// System OOM. We have no way to recover from this
// currently. We should probably change insertLines
// to raise an error here.
error.OutOfMemory,
=> @panic("adjustCapacity system allocator OOM"),
=> @panic("increaseCapacity system allocator OOM"),
// The page can't accommodate the managed memory required
// for this operation. We previously just corrupted
// memory here so a crash is better. The right long
// term solution is to allocate a new page here
// move this row to the new page, and start over.
error.OutOfSpace,
=> @panic("increaseCapacity OutOfSpace"),
};
// Continue the loop to try handling this row again.
@@ -1834,49 +1828,41 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
self.scrolling_region.left,
self.scrolling_region.right + 1,
) catch |err| {
const cap = dst_p.node.data.capacity;
// Adjust our page capacity to make
// room for we didn't have space for
_ = self.screens.active.adjustCapacity(
_ = self.screens.active.increaseCapacity(
dst_p.node,
switch (err) {
// Rehash the sets
error.StyleSetNeedsRehash,
error.HyperlinkSetNeedsRehash,
=> .{},
=> null,
// Increase style memory
error.StyleSetOutOfMemory,
=> .{ .styles = cap.styles * 2 },
=> .styles,
// Increase string memory
error.StringAllocOutOfMemory,
=> .{ .string_bytes = cap.string_bytes * 2 },
=> .string_bytes,
// Increase hyperlink memory
error.HyperlinkSetOutOfMemory,
error.HyperlinkMapOutOfMemory,
=> .{ .hyperlink_bytes = cap.hyperlink_bytes * 2 },
=> .hyperlink_bytes,
// Increase grapheme memory
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> .{ .grapheme_bytes = cap.grapheme_bytes * 2 },
=> .grapheme_bytes,
},
) catch |e| switch (e) {
// See insertLines which has the same error capture.
error.StyleSetOutOfMemory,
error.StyleSetNeedsRehash,
error.StringAllocOutOfMemory,
error.HyperlinkSetOutOfMemory,
error.HyperlinkSetNeedsRehash,
error.HyperlinkMapOutOfMemory,
error.GraphemeMapOutOfMemory,
error.GraphemeAllocOutOfMemory,
=> @panic("adjustCapacity resulted in capacity errors"),
// See insertLines
error.OutOfMemory,
=> @panic("adjustCapacity system allocator OOM"),
=> @panic("increaseCapacity system allocator OOM"),
error.OutOfSpace,
=> @panic("increaseCapacity OutOfSpace"),
};
// Continue the loop to try handling this row again.

View File

@@ -13,8 +13,9 @@ const autoHash = std.hash.autoHash;
const autoHashStrat = std.hash.autoHashStrat;
/// The unique identifier for a hyperlink. This is at most the number of cells
/// that can fit in a single terminal page.
pub const Id = size.CellCountInt;
/// that can fit in a single terminal page, since each cell can only contain
/// at most one hyperlink.
pub const Id = size.HyperlinkCountInt;
// The mapping of cell to hyperlink. We use an offset hash map to save space
// since its very unlikely a cell is a hyperlink, so its a waste to store

View File

@@ -1569,7 +1569,10 @@ pub const Page = struct {
const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align.toByteUnits());
const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size;
const grapheme_count = @divFloor(cap.grapheme_bytes, grapheme_chunk);
const grapheme_count = std.math.ceilPowerOfTwo(
usize,
@divFloor(cap.grapheme_bytes, grapheme_chunk),
) catch unreachable;
const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count));
const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits());
const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size;
@@ -1639,25 +1642,33 @@ pub const Size = struct {
};
/// Capacity of this page.
///
/// This capacity can be maxed out (every field max) and still fit
/// within a 64-bit memory space. If you need more than this, you will
/// need to split data across separate pages.
///
/// For 32-bit systems, it is possible to overflow the addressable
/// space and this is something we still need to address in the future
/// likely by limiting the maximum capacity on 32-bit systems further.
pub const Capacity = struct {
/// Number of columns and rows we can know about.
cols: size.CellCountInt,
rows: size.CellCountInt,
/// Number of unique styles that can be used on this page.
styles: usize = 16,
styles: size.StyleCountInt = 16,
/// Number of bytes to allocate for hyperlink data. Note that the
/// amount of data used for hyperlinks in total is more than this because
/// hyperlinks use string data as well as a small amount of lookup metadata.
/// This number is a rough approximation.
hyperlink_bytes: usize = hyperlink_bytes_default,
hyperlink_bytes: size.HyperlinkCountInt = hyperlink_bytes_default,
/// Number of bytes to allocate for grapheme data.
grapheme_bytes: usize = grapheme_bytes_default,
grapheme_bytes: size.GraphemeBytesInt = grapheme_bytes_default,
/// Number of bytes to allocate for strings.
string_bytes: usize = string_bytes_default,
string_bytes: size.StringBytesInt = string_bytes_default,
pub const Adjustment = struct {
cols: ?size.CellCountInt = null,
@@ -2025,6 +2036,21 @@ pub const Cell = packed struct(u64) {
// //const pages = total_size / std.heap.page_size_min;
// }
test "Page.layout can take a maxed capacity" {
// Our intention is for a maxed-out capacity to always fit
// within a page layout without triggering runtime safety on any
// overflow. This simplifies some of our handling downstream of the
// call (relevant to: https://github.com/ghostty-org/ghostty/issues/10258)
var cap: Capacity = undefined;
inline for (@typeInfo(Capacity).@"struct".fields) |field| {
@field(cap, field.name) = std.math.maxInt(field.type);
}
// Note that a max capacity will exceed our max_page_size so we
// can't init a page with it, but it should layout.
_ = Page.layout(cap);
}
test "Cell is zero by default" {
const cell = Cell.init(0);
const cell_int: u64 = @bitCast(cell);

View File

@@ -11,9 +11,32 @@ pub const max_page_size = std.math.maxInt(u32);
/// derived from the maximum terminal page size.
pub const OffsetInt = std.math.IntFittingRange(0, max_page_size - 1);
/// The int type that can contain the maximum number of cells in a page.
pub const CellCountInt = u16; // TODO: derive
/// Int types for maximum values of things. A lot of these sizes are
/// based on "X is enough for any reasonable use case" principles.
// The goal is that a user can have the maxInt amount of all of these
// present at one time and be able to address them in a single Page.zig.
// Total number of cells that are possible in each dimension (row/col).
// Based on 2^16 being enough for any reasonable terminal size and allowing
// IDs to remain 16-bit.
pub const CellCountInt = u16;
// Total number of styles and hyperlinks that are possible in a page.
// We match CellCountInt here because each cell in a single row can have at
// most one style, making it simple to split a page by splitting rows.
//
// Note due to the way RefCountedSet works, we are short one value, but
// this is a theoretical limit we accept. A page with a single row max
// columns wide would be one short of having every cell have a unique style.
pub const StyleCountInt = CellCountInt;
pub const HyperlinkCountInt = CellCountInt;
// Total number of bytes that can be taken up by grapheme data and string
// data. Both of these technically unlimited with malicious input, but
// we choose a reasonable limit of 2^32 (4GB) per.
pub const GraphemeBytesInt = u32;
pub const StringBytesInt = u32;
/// The offset from the base address of the page to the start of some data.
/// This is typed for ease of use.
///

View File

@@ -11,7 +11,7 @@ const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet;
/// The unique identifier for a style. This is at most the number of cells
/// that can fit into a terminal page.
pub const Id = size.CellCountInt;
pub const Id = size.StyleCountInt;
/// The Id to use for default styling.
pub const default_id: Id = 0;