Repository navigation
Evict scrollback by pin row arithmetic, not page identity - #635
Merged
Merged
Conversation
dakra
force-pushed
the
fix/scrollback-eviction
branch
3 times, most recently
from
August 14, 2026 20:09
b43c276 to
d47c278
Compare
dakra
force-pushed
the
fix/scrollback-eviction
branch
from
August 14, 2026 20:31
d47c278 to
9bfbe88
Compare
emil-e
force-pushed
the
fix/scrollback-eviction
branch
from
August 17, 2026 07:20
9bfbe88 to
99f1a34
Compare
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
force-pushed
the
fix/scrollback-eviction
branch
from
August 17, 2026 20:00
99f1a34 to
b4b82f4
Compare
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-allstill 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
MaterializedPagelist, andevictScrollbackdeleted 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'sinvalidateNodeLayoutbumps a live page's serial in place whenever its row coordinates change, whichcursorScrollAbovedoes on every scroll of a top-anchored partial scroll region (CSI 1;N rwith N below the last row) — exactly how TUIs scroll a transcript above a pinned input box. Plain bottom-row scrolling takes thecursorDownScrollfast 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_bufferwas then reset tototal_rows, so the renderer believed the history was still materialized and only ever rewrote the viewport fromactive_pin.Minimal repro (against the old module):
Fix
Page bookkeeping is unnecessary for eviction. Rows above
active_pinare 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. SincegotoActiveStartaligns the rewrite region from the buffer end, the stale line count is simplydeleted 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 beforecommitResizeso 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
renderrewrites 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 toinvalidate, 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
MaterializedPagemachinery outright, includingrender()'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
ghostel--copy-all-textrather 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 allpasses (build, lint, elisp + native + zig + evil tests).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-redrawheals an already-desynced buffer.