Skip to content

Bound Buffer.resize to populated lines (don't materialize the full scrollback ring) - #556

Open
evertjr wants to merge 1 commit into
migueldeicaza:mainfrom
evertjr:fix/buffer-resize-scrollback-materialization
Open

Bound Buffer.resize to populated lines (don't materialize the full scrollback ring)#556
evertjr wants to merge 1 commit into
migueldeicaza:mainfrom
evertjr:fix/buffer-resize-scrollback-materialization

Conversation

@evertjr

@evertjr evertjr commented May 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Buffer.resize() iterates for i in 0..<lines.maxLength in three places: a col-grow loop that adjusts each line's column count before reflow, a col-shrink loop that trims lines after reflow, and a DEBUG:-labelled post-condition that asserts every row matches newCols.

The lines collection is a CircularBufferLineList whose subscript materializes a BufferLine on read when the slot is nil:

subscript (index: Int) -> BufferLine {
    _read {
        let idx = getCyclicIndex(index)
        if array[idx] == nil {
            array[idx] = makeEmpty!(idx)
        }
        yield array[idx]!
    }
}

So every resize reads every slot up to the scrollback cap, allocating a BufferLine for each nil entry. A terminal that has only pushed 200 lines but is configured with scrollback = 50_000 materializes ~50,000 BufferLines on the first resize. Lines that never carried content end up holding a full-width data array sized to current cols.

Discovered in Maestri (12 terminals × scrollback = 50_000): heap showed 600,591 BufferLine instances totaling 1.83 GB in BufferLine.data after any window/sidebar resize, even for alt-screen terminals (vim/agents) with minimal real scrollback in their normal buffer. After the patch the same workload sits at thousands of BufferLines instead of hundreds of thousands, and process RSS dropped from ~3 GB at workspace startup to ~1 GB.

Most consumers won't notice because the default scrollback is small, but the cost is real for anyone increasing it or running many terminals: it's alloc-per-nil-slot × resize-count × terminal-count.

The DEBUG: label on the post-condition is a leftover comment, not an #if DEBUG — the assertion ships in release and abort()s the process if it triggers, while also being the loop that materializes the most slots.

Fix

  • Bound the two non-debug resize loops by lines.count instead of lines.maxLength. Slots beyond count haven't been pushed and aren't logical buffer rows — reflow, rendering, selection extraction, image accounting, and getBufferAsData already bound their reads by count.
  • Wrap the post-condition loop in #if DEBUG so the materialization side effect doesn't ship in release. The assertion still runs in development builds with the same count-bound iteration.

Behavior is identical for populated rows — they still get resized exactly as before. Resize is also incidentally faster on terminals with low utilization since the work now scales with content instead of cap.

Tests

Manually verified in Maestri across the regression matrix: resize during idle and during heavy seq output, resize while in vim/nano/less (alt screen), shrink + grow with wrapped content, brand-new-terminal resize (lines.count == 0, loops no-op), cross-floor resize. All identical to upstream behavior. Memory check: heap | grep BufferLine shows counts proportional to actual pushed content (a few hundred per terminal) instead of one full ring per terminal.

The col-grow, col-shrink, and DEBUG post-condition loops iterated up to
lines.maxLength (the full scrollback ring). The lazy subscript would
materialize a fresh BufferLine for every nil slot, allocating the entire
ring on each resize. Use lines.count instead so we only touch populated
lines, and gate the post-condition loop behind #if DEBUG.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant