Skip to content

Evict scrollback by pin row arithmetic, not page identity - #635

Merged
dakra merged 1 commit into
mainfrom
fix/scrollback-eviction
Aug 17, 2026
Merged

dakra merged 1 commit into
mainfrom
fix/scrollback-eviction

Conversation

@dakra

@dakra dakra commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Bug

Entering copy mode in a session where a TUI (e.g. Claude Code) had scrolled its transcript showed only the current viewport — the materialized scrollback was gone from the Emacs buffer, while ghostel-copy-all still returned the full history. The desync was stable: once it happened, incremental redraws never restored the missing lines.

Root cause

The renderer mirrored libghostty's page list as a serial-keyed MaterializedPage list, and evictScrollback deleted leading buffer text whenever the terminal's first page serial no longer matched a recorded one. But a page serial is a layout epoch, not an identity: ghostty's invalidateNodeLayout bumps a live page's serial in place whenever its row coordinates change, which cursorScrollAbove does on every scroll of a top-anchored partial scroll region (CSI 1;N r with N below the last row) — exactly how TUIs scroll a transcript above a pinned input box. Plain bottom-row scrolling takes the cursorDownScroll fast path and never bumps serials, which is why ordinary floods were unaffected.

Early in a session the whole terminal fits in one page, so the first region scroll after a redraw left eviction with no matching serial: it popped every materialized page and deleted the entire scrollback from the buffer. rows_in_buffer was then reset to total_rows, so the renderer believed the history was still materialized and only ever rewrote the viewport from active_pin.

Minimal repro (against the old module):

redraw → flood 100 lines → redraw → ESC[1;20r, one LF scroll in the region, ESC[r → redraw
⇒ buffer 25 lines, native history 101 lines

Fix

Page bookkeeping is unnecessary for eviction. Rows above active_pin are frozen: libghostty removes them only by dropping whole leading pages, and doing so shifts tracked pins down in screen coordinates by exactly the dropped row count. Since gotoActiveStart aligns the rewrite region from the buffer end, the stale line count is simply

stale = rows_in_buffer − (pin's screen row + term.rows)

deleted from the buffer front. The same arithmetic covers byte- and line-limit pruning, erased history (CSI 3J), and pins orphaned by a between-redraws flood. Eviction runs before commitResize so the pin, page list, and rows still match the buffer's last render, and skips when the active screen is not the rendered one.

A prune that drops the pin's own page is the exception: libghostty lands the pin on the screen's top-left, where its row measures nothing. Trimming on it would strand pruned rows at the front and, because render rewrites only dirty rows, drop as many live rows after the seam — with the line counts still matching, so nothing downstream notices. Eviction returns early on that pin and leaves the state to invalidate, which reads exactly it and repaints from scratch. (Found by fuzzing the rows-per-redraw count; it needs a prune of roughly 400–900 rows in one redraw, a band paced PTY floods rarely land on.)

This deletes the MaterializedPage machinery outright, including render()'s per-page char accounting and its page-boundary span break, which existed only to feed that accounting — and which also appended a duplicate list entry whenever a walked page's serial had been bumped, double-counting chars that a later real eviction would have deleted from unrelated buffer text.

Testing

  • New native regression tests: the region-scroll repro, a repeated region-scroll steady-state case, a real-pruning content-parity test (1600 rows past the ~900-row single-page threshold; the pre-existing eviction test only checked window positions), and a partial-prune test that compares the entire buffer text against ghostel--copy-all-text rather than line counts. The two region-scroll tests fail on the old module and pass on the new one; the pruning test passes on both.
  • make -j8 all passes (build, lint, elisp + native + zig + evil tests).
  • A 14-scenario VT matrix (floods, mode 2026, DECSTBM full/partial regions, 2J/0J repaints, resizes, alt-screen roundtrip) stays buffer/native in sync.
  • Live-verified in a sandboxed TTY Emacs with a real shell: after the flood + region-scroll sequence the buffer holds the full history, copy mode's M-< reaches the first prompt line, and rendering continues normally afterwards.

Note for existing sessions: a running Emacs keeps the old module until restart; M-x ghostel-force-redraw heals an already-desynced buffer.

@dakra
dakra force-pushed the fix/scrollback-eviction branch 3 times, most recently from b43c276 to d47c278 Compare August 14, 2026 20:09
@dakra
dakra force-pushed the fix/scrollback-eviction branch from d47c278 to 9bfbe88 Compare August 14, 2026 20:31
@emil-e
emil-e force-pushed the fix/scrollback-eviction branch from 9bfbe88 to 99f1a34 Compare August 17, 2026 07:20
Libghostty page serials identify layout generations, so scrolling a
partial region can change a serial without pruning history. The renderer
then discarded materialized scrollback and continued from the active
pin, leaving the buffer permanently incomplete.

Derive the stale buffer prefix from the tracked active pin and
`rows_in_buffer` instead. Clear projections that cannot be reconciled
before eviction, and evict before a pending resize changes terminal
geometry. This removes per-page materialization accounting in exchange
for walking the stale buffer lines when pruning.
@dakra
dakra force-pushed the fix/scrollback-eviction branch from 99f1a34 to b4b82f4 Compare August 17, 2026 20:00
@dakra
dakra merged commit b4b82f4 into main Aug 17, 2026
29 checks passed
@dakra
dakra deleted the fix/scrollback-eviction branch August 17, 2026 20:09

This branch was previously deployed

1 inactive deployment
github-pages — b4b82f4e Deployed Aug 17, 2026 by dakra via deploy #167
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