terminal: fix increaseCapacity growth from zero-capacity dimensions

PageList.increaseCapacity grows a capacity dimension by doubling it.
If the dimension is zero, doubling "succeeds" without growing: the
page is reallocated and recloned with an identical capacity, violating
the documented guarantee that we always increase by at least one unit.
Every unbounded retry site (startHyperlink, cursorSetHyperlink, the
reflow probes, insertLines/deleteLines) then loops forever reallocating
a page per iteration, and the single-retry sites (styles, graphemes)
fail their retry and silently drop data.

No production page has a zero dimension today, which is why this has
never fired: standard capacities are nonzero and doubling keeps them
nonzero. But exactRowCapacity legitimately returns zero for dimensions
with no content (a compacted plain text page has zero styles, grapheme,
string, and hyperlink capacity), so any compaction work makes this
reachable.

Growth from zero now jumps straight to the standard default for the
dimension rather than doubling. The default is what every standard
page starts with, so single-retry callers are guaranteed enough room
for their pending allocation, whereas doubling from a minimum unit
could still come up short (a single grapheme can need multiple chunks,
and a style set below capacity 3 cannot store anything).
This commit is contained in:
Mitchell Hashimoto
2026-07-07 19:52:39 -07:00
parent 39b3c47717
commit 8307349ec5
2 changed files with 112 additions and 15 deletions

View File

@@ -3775,6 +3775,54 @@ test "Screen cursorCopy hyperlink deref" {
try testing.expect(s2.cursor.hyperlink_id == 0);
}
test "Screen write regrows compacted page capacity" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, .{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
});
defer s.deinit();
// Compact the active page so every managed capacity dimension is
// zero, then reload the cursor since its cached row/cell pointers
// point into the replaced page.
{
const node = (try s.pages.compact(s.cursor.page_pin.node)).?;
try testing.expectEqual(0, node.data.capacity.styles);
try testing.expectEqual(0, node.data.capacity.grapheme_bytes);
try testing.expectEqual(0, node.data.capacity.string_bytes);
try testing.expectEqual(0, node.data.capacity.hyperlink_bytes);
s.cursorReload();
}
// Styled write: exercises the manualStyleUpdate single-retry
// path. Prior to increaseCapacity handling zero dimensions, the
// retry would fail and the style would be dropped.
try s.setAttribute(.{ .bold = {} });
try s.testWriteString("A");
// Grapheme write: exercises the appendGrapheme single-retry path.
// We can't use testWriteString here because it appends graphemes
// directly on the page without the capacity retry.
try s.testWriteString("a");
try s.appendGrapheme(s.cursorCellLeft(1), 0x0301);
// Hyperlink: exercises the startHyperlink retry loop, which used
// to loop forever when capacity growth from zero didn't grow.
try s.startHyperlink("https://example.com/", null);
try s.testWriteString("B");
s.endHyperlink();
// Verify the content landed on the page.
const page = &s.cursor.page_pin.node.data;
try testing.expect(page.styles.count() >= 1);
try testing.expect(page.hyperlink_set.count() >= 1);
try testing.expect(page.graphemeCount() >= 1);
}
test "Screen cursorCopy hyperlink deref new page" {
const testing = std.testing;
const alloc = testing.allocator;