Pin.leftWrap and rightWrap calculated the destination using the
remainder after consuming the current row. When that remainder was an
exact multiple of the column count, rightWrap subtracted one from zero
and leftWrap produced a column equal to the width. Dereferencing either
pin could panic. A maximum usize offset on a one-column page also
overflowed the row count.
Base the row and column calculations on the remainder minus one. This
maps exact multiples to the final cell of the correct row and keeps the
maximum offset calculation in range so traversal reports overflow
normally.
Low-level search state accepted an empty needle even though the search
thread normally filters it out. SlidingWindow treated the empty string
as a zero-length match and underflowed while calculating its inclusive
end offset. Active and viewport overlap calculations could also
underflow while loading adjacent pages.
Treat an empty needle as an inactive search with no matches or history,
and saturate the viewport overlap length.
This adds transparent compression for non-active/non-viewport scrollback
pages, reducing physical memory for compressed pages by anywhere from
70% to 90%. Compression is obviously highly dependent on the shape of
the data, but these are the numbers I got for normal scrollback.
Due to compression being available, I bumped the default scrollback
limit from 10MB to 50MB. On average, a full scrollback still uses less
memory than the prior limit due to the compression ratios.
## Demo
Here is a demo video showing me filling my scrollback and using the
inspector so you can see the live compression/decompression activity and
results:
https://github.com/user-attachments/assets/7b9d0383-42f7-47bf-8b3f-853e3f89549c
## Resident vs. Virtual Memory
This PR works by lowering _resident/physicalmemory, but doesn't touch
_virtual_ memory.
Practically what this means is that users need to make sure they're
looking at resident memory to see the change.
We use OS primitives like `MADV_DONTNEED` on Linux or
`MADV_FREE_REUSABLE` on Darwin to discard our physical memory, but
retain our virtual memory allocations. This is awesome because it means
our decompression is infallible: the OS has already given us the memory,
but it just remaps it at that point.
This is baked into the core implementation, so compression only works on
systems that support an OS primitive to retain virtual mappings while
discarding physical. Today, that is macOS and 64-bit Linux. Other
operating systems have support we just haven't coded it up yet.
## Implementation
A major refactor had to happen to PageList to represent nodes as either
resident or compressed. Pins typically accessed node content directly so
we had to add a bunch of helpers to read metadata without decompression
(but some access requires it).
Compression is relatively slow and its important we don't impact IO
performance. So we support incremental compression passes and they only
run when the terminal is idle (250ms timer on the render thread that
resets on any activity). Benchmarks show zero regression in IO
throughput on this change.
In order to maintain the no-libc invariant for libghostty-vt, we use a
hand-written (an AI assisted optimization) LZ4 compression
implementation. The performance and compression ratio is _okay_. Its a
good first step for this. I think in the future I want to look at other
implementations we can bring in based on build configuration.
## Performance
Measured with a saved 7.3 MB corpus made by repeating `gh --help` output
into a 120x80 terminal with a 50 MB scrollback limit on my machine:
| compression measurement | result |
|-------------------------|--------|
| pages compressed | 121 |
| raw page backing | 49.56 MB |
| encoded storage | 3.03 MB (6.11% of raw) |
| estimated physical memory savings | 46.53 MB (93.89%) |
| full compression | 30.3 ms total, 12.2 ms over the 18.1 ms no-op
baseline (~101 µs/page) |
| incremental drain | 29.7 ms total, 11.6 ms over baseline (~96 µs/page)
|
| compress and restore | 33.5 ms total, 3.2 ms over full compression
(~26 µs/page to restore) |
The workload above is especially repetitive, so its 6.11% encoded ratio
is better than the 10% to 30% expected for text-heavy terminal history
in general. Steady-state throughput is unchanged within noise
(`terminal-stream` benchmarks and manual `cat` timings).
## libghostty-vt
The same caller-driven compression controls are exposed to Zig and C.
Note that compression _is not automatic_ for libghostty users. Callers
must determine their own definition of "idle" and when to compress and
call our incremental callback APIs to perform the compression.
Decompression is automatic and as-needed (and will trigger
recompression-required flags so callers are aware).
## LLM Notes
This work was done in concert with Codex. I reviewed and
rewrote/reshaped pretty much every change extensively, particularly
PageList/Terminal. This PR message is written by hand, commit messages
are LLM written but reviewed.
The mutex is unfair. From my understanding (after a brief convo with
@rockorager), it's a race between the parse thread and the renderer
thread.
The parse thread is unlocking it then locking it faster than the
renderer thread can get a lock/unlock. Under sustained pty output the
parse thread never sleeps between batches, so the renderer's frame
snapshot can starve for as long as the output lasts.
The fix here makes the parse thread voluntarily stay off the lock until
the renderer has had one turn by introducing two atomics:
- `demand`: a waiter count. The renderer increments it before locking
and decrements it after acquiring, so demand > 0 means "the renderer is
queued on the lock or about to be."
- `handoff_gen`: a generation counter. The renderer bumps it (with a
futex wake) after unlocking, meaning "a waiter completed one full turn."
At each batch boundary the parse thread checks demand (a single relaxed
load in the common case, so this **should** cost nothing when the
renderer isn't waiting). If a waiter exists, it futex-waits on
handoff_gen until the renderer has taken and released the lock, bounded
by a 1ms timeout, trying to ensure a lost wake can't stall IO.
This is inspired from `parking_lot` form rust land, which has eventual
fairness, but applied only at the one site that misbehaves.
## LLM Note
I used Fable 5 in Amp to write the code, but only after I identified the
problem myself from previous experience with mutex fairness (and the
convo above with rockorager).
The diagnosis — the parse thread barging the unfair mutex and starving
the renderer — came first.Fable then traced the exact loop in
`Exec.zig/generic.zig` and implemented the handoff protocol.
I've reviewed the changes.
The block decoder previously copied literals through variable-length
memcpy calls and expanded every match with word loops that carried an
overcopy fallback in each branch. Real page blocks decode as millions
of tiny sequences, so per-sequence overhead dominated restore time.
Decode short literal runs and in-token matches with blind fixed-width
copies whose margin checks subsume the exact bounds checks they
replace. Expand small repeating periods into pattern-word stores,
copy distant long matches with one exact memcpy, and propagate the
rare non-power-of-two short offsets bytewise. Page corpora restore
13% to 19% faster and text around twice as fast, while compressor
output stays byte-for-byte unchanged.
Replace the fuzz test with a differential property suite which
round-trips generated inputs, validates blocks with an independent
format walker, rejects wrong-size outputs, and decodes corrupted and
truncated blocks. A light version runs as a normal unit test; the
exhaustive version runs when GHOSTTY_LZ4_SLOW is set. An AGENTS.md
records the benchmarking, testing, and verification workflow for
this directory.
The renderer state mutex is unfair on all platforms (os_unfair_lock
on macOS, a futex based lock elsewhere). A thread that unlocks and
right away locks again wins over a sleeping waiter, because the
waiter first has to be woken up and scheduled. The termio parse
thread does exactly this under heavy pty output: it relocks the
mutex for every batch and never sleeps in between, so the renderer
can starve in updateFrame for as long as the output lasts.
Fix this by letting the parse thread stay off the lock until the
renderer had its turn. `renderer.State` gets two atomics: a waiter
count (`demand`) and a generation counter (`handoff_gen`). The renderer
takes the mutex through lockDemand/unlockDemand which update these,
and the parse thread calls yieldToDemand between batches. If a
waiter exists it sleeps on a futex until the renderer took and
released the lock, with a 1ms timeout so a lost wake can not stall
IO forever.
All the atomics are monotonic on purpose: they are only a hint for
scheduling, the mutex still protects the terminal state itself.
When the renderer is not waiting the cost for the parse loop is a
single relaxed atomic load per batch.
Inspector rendering can hold the terminal mutex while waking the
renderer. When the compression scheduler failed to acquire that mutex,
it treated every wake as possible terminal activity and restarted the
idle timer. Frequent inspector frames could therefore postpone
compression indefinitely until another interaction changed the timing.
Keep an existing compression deadline when a wake encounters lock
contention. The timer callback already rechecks both terminal activity
and lock availability, while the first contended wake still arms a
timer when none is active.
Scrollback compression scheduling was only available to Zig callers that
used Terminal directly, leaving C embedders unable to drive the same idle
compression policy.
Define ABI-aware mode and result enums on Terminal and export activity
and compression operations through the C API. Keep scheduling
caller-owned, validate C inputs, and document the incremental contract
with a complete example.
Report unsupported reclamation consistently for full passes so callers
can disable compression on targets that cannot retain decommitted
mappings.
The PageList overview mixed structural state with a long list of exact
byte counters, making the compression result difficult to interpret.
Keep the overview focused on grid and scrollback structure, and add a
dedicated compression section before scrollbar details. Present page
states, uncompressed size, encoded ratio, resident estimate, and savings
using readable units and scoped help text.
Idle compression was always enabled on supported renderer-backed
surfaces and the default logical scrollback limit remained sized for
fully resident history.
Add a default-on scrollback-compression option and make renderer
scheduling honor it across config reloads. Existing compressed pages
remain encoded when the option is disabled, while reenabling it starts a
fresh idle pass.
Raise the default logical scrollback limit from 10 MB to 50 MB and
document typical physical-memory savings, content-dependent behavior,
and retained virtual address usage.
Compression previously stopped whenever the viewport left the active
area, leaving all scrollback resident while a user viewed history.
Traverse complete historical pages through a metadata-only iterator
which skips the contiguous visible range. Restart incremental traversal
after every viewport movement so pages become eligible once they leave
view, while visible pages remain resident for immediate redraw.
Add a PageList-only drain mode for tests and benchmarks, and update
scrollback documentation to describe the offscreen eligibility rule.
Compression scheduling previously postponed its idle timer after every
renderer wake. The inspector redraw loop wakes the renderer
continuously, so opening it could prevent pending scrollback compression
from ever starting.
Track compression-relevant PageList activity with a wrapping 48-bit
token and restart the timer only when the composite Terminal token
changes. This removes the separate dirty bit while reserving 16 bits for
future Terminal-owned compression triggers.
Expose target availability through the terminal package and leave
renderer compression state undefined on unsupported targets so its timer
is never initialized.
The raw LZ4 codec previously kept one match candidate and extended and
copied matches byte by byte. This limited compression quality and made
page restoration substantially more expensive than the reference
decoder.
Pack two candidates into the existing 16 KiB table, accelerate long
literal searches, and compare matching runs a word at a time. Decode
short literals and common repeated patterns with fixed-width copies
while retaining exact bounds checks for malformed blocks.
This keeps the public API, workspace size, block format, and
allocation-free dependency model unchanged.
Scrollback compression was available only through explicit PageList
calls, so normal renderer-backed surfaces never reclaimed cold history.
Debounce renderer wakeups with a one-shot timer and run bounded
compression steps only when the terminal lock is immediately available.
Timer state is isolated in the renderer and becomes idle once PageList
reports that the pass is complete.
Keep inspector reads representation-preserving by decoding compressed
pages into temporary copies. Update the scrollback configuration and
benchmark documentation to describe the production behavior and its
memory accounting.
PageList previously kept only traversal position, so callers had no
central signal for deciding when an incremental compression pass should
run. Scheduling policy had to infer work from output and UI activity.
Track compression dirtiness alongside the PageList continuation state.
Growth preserves valid progress while marking work pending, and resize
or viewport transitions restart from the oldest page. A no-work
verification pass clears the state.
Expose Terminal helpers which report whether compression is required and
run compression against primary scrollback even while the alternate
screen is active. Unsupported retained-memory targets report no work.
Search previously used the normal page access boundary while formatting
history and checking soft-wrap boundaries. Inspecting compressed history
therefore restored its retained mapping and undid the memory
reclamation.
Format through preserved page snapshots and copy row counts and wrap
state into the sliding window's owned metadata. Overlap decisions reuse
the same snapshot, so compressed pages are decoded at most once per
append and remain compressed after matching.
Add a cross-page regression which searches compressed history and
verifies both source nodes retain their compressed representation.
Incremental compression previously exposed its traversal state to
callers, requiring them to coordinate the cursor with PageList lifetime
and topology changes. Read-only consumers also had to restore compressed
nodes to inspect their contents.
Move the continuation state into PageList and expose a single mode-based
compression entry point. Incremental passes restart safely across
mutations and verify a no-work pass before becoming idle, while full
passes leave incremental state fresh.
Add preserved page reads which decode compressed nodes into caller-owned
storage without changing their representation, and migrate the
scrollback compression benchmark to the new API.
Cold-history compression previously required scanning every eligible
page in one call, which makes it unsuitable for an idle-time scheduler.
The inspector also restored compressed pages while traversing collapsed
entries, hiding the representation and undoing reclamation.
Add caller-owned serial state that resumes compression without retaining
node pointers. Each invocation inspects at most eight candidates and
attempts one resident page. List mutations restart safely, while
unsupported or historical viewports stop work. Keep a stateless
whole-history operation for measurement.
Expose metadata-only storage and memory accounting for diagnostics,
update the inspector to restore only expanded pages, and add an
incremental live scrollback benchmark. This remains disconnected from
production scheduling.
PageList could compress individual nodes but had no policy-level
operation for selecting pages that are safe to reclaim. The compressed
state therefore remained reachable only from tests.
Add a stateless pass that considers only complete history pages while
the viewport follows the active area. It gates work on supported
retained-mapping reclamation, reports attempts and retained bytes, and
leaves restoration lazy when a resize pulls compressed history back into
the active area.
Add a live scrollback-compression benchmark for measuring complete
PageList compression and restoration against saved VT corpora. The pass
still has no production callers, and ReleaseFast terminal-stream
comparisons remain within the existing throughput guardrail.
PageList nodes previously exposed Page directly, so introducing a
compressed representation would require every consumer to understand its
state and ownership.
Add resident and compressed node states behind a page access boundary.
Content access transparently recommits and restores retained mappings,
while metadata traversal stays compressed and lifecycle paths can
discard encoded contents without decoding. Compression borrows pool
memory for standard scratch and uses temporary aligned storage for
oversized pages.
Migrate terminal, rendering, search, formatting, and C API consumers to
the new boundary. Hot grow, scroll, and print paths reuse resolved
pages, with an explicit resident-only accessor where live cursor
pointers prove that the page cannot be compressed.
The compression entry point remains private with no production callers,
so normal terminal behavior and scrollback accounting are unchanged.
ReleaseFast terminal-stream comparisons across bulk output, scrolling,
redraw, and erase workloads remain within 2% of the parent revision.
PageList's virtual-memory helpers were tied to pool items even though
the underlying decommit and recommit operations also apply to retained
page mappings.
Move the helpers into terminal/mem.zig and express their different
failure contracts as generic modes. Zero mode preserves the existing
pool invariant and fallbacks, while strict mode only succeeds when the
operating system accepts reclamation and avoids touching memory that
will be restored.
PageList now uses the shared zero mode for its page pool. The strict
path is tested in isolation and remains unused, so this does not enable
page compression yet.
The standalone LZ4 codec had no representation for terminal page
ownership or metadata, so PageList integration would otherwise need to
reconstruct every Page field independently.
Add compress.Page, which embeds the complete terminal Page while
retaining its original virtual mapping and owns only an exact-sized
encoded block. Compression is kept only when the encoded state is
strictly smaller, and scratch output is capped at that profitability
boundary so a future PageList caller can borrow a standard pool item.
Extend the page-compression benchmark with a store mode that measures
the encoded copy, allocation, bounded retention, and eviction path.
Nothing uses compression from PageList yet; this remains isolated
groundwork.
Scrollback compression needs a codec that can be used from libghostty-vt
without pulling in libc, and we need to measure it before integrating it
with terminal page ownership.
This adds an allocation-free raw LZ4 block codec in scalar Zig. Callers
provide the input, output, and fixed-size scratch table. The decoder
uses an exact-size output contract so page metadata mismatches fail
cleanly. Compatibility vectors, boundary cases, random round trips, and
fuzz coverage exercise the block format.
Also adds a page-compression benchmark that operates on reusable raw
page corpora. Compression and decompression have separate modes with
setup outside the timed region, plus a ratio report and no-op baseline.
Nothing uses compression in the terminal yet; this is the isolated codec
and measurement groundwork.
Determine if any links are active before building the string and
byte-to-cell map. Those buffers scale with viewport size, and this
function runs during frame updates, so avoid allocating them when the
current mouse/modifier state can't highlight any regex links.
This adds an additional `self.links` iteration, but that list is usually
small, the "active" check is cheap, and it breaks on the first hit.
---
GPT 5.5 spotted this and wrote the test case. I came up with the
tradeoffs myself and wrote the runtime code.
Determine if any links are active before building the string and
byte-to-cell map. Those buffers scale with viewport size, and this
function runs during frame updates, so avoid allocating them when the
current mouse/modifier state can't highlight any regex links.
This adds an additional `self.links` iteration, but that list is usually
small, the "active" check is cheap, and it breaks on the first hit.
This reverts commit bed47168ca.
This commit reduced the buffer size and count on non-Darwin. This
introduced a ~20% perf loss on linux for high throughput loads on a
questionable claim. Producers of bytes should not be pacing frames based
on how fast they can write to the terminal.
"Why is my quick terminal not taking up the entire top of my docked Mac
screen after I reconnect?" Boy howdy are you in the right PR.
It turns out that the quick terminal caches its last-closed window frame
per display so it can restore the user's size when reopened. The cache
entry was considered valid whenever the current screen was the same size
*or larger* than when the frame was saved ("persist when screens grow").
This has led to a pattern that was simply maddening. To wit: that rule
breaks across display changes.
When an external display is disconnected and later reconnected at a
different resolution (common after traveling with a laptop, do not even
get me started on projectors) the same display can come back larger than
when the frame was cached. The stale frame is still treated as valid and
restored, so the quick terminal no longer fills the screen (it appears
at a partial width/height). Because the cache is persisted, restarting
Ghostty does not clear it, and the user is slowly driven mad. Welcome to
madness; we have snacks.
This PR addresses this by treating a cached frame as valid when the
screen geometry matches exactly (both backing scale factor and frame
size). On any mismatch we drop the entry and fall back to the configured
quick-terminal-size. Manual resizes are still remembered across toggles
within a stable display configuration.
Fixes the regression reported in #12348.
AI disclaimer: I used AI for this. Of course I used AI for this, my code
is terrible on a good day. Specifically, Claude Code, as well as a
custom harness that has the curious tendency to write commit messages
containing conspiracy theories about the code because I am history's
greatest monster.
Fight me!
This uses `madvise` flags on both macOS and Linux to mark our unused
pool items as free memory and not show up in RSS (until they're actually
used). We can do this since we do page-aligned/page-multiple
allocations, heyo!
Background: The PageList page pool never returns memory to the OS:
destroyed pages are zeroed and free-listed until the surface exits. Any
operation that shrinks the page count (clearing scrollback, pruning
churn, resets, reflow) therefore retains its high-water RSS forever.
Clearing a full scrollback keeps all of it resident, which at the
default 10MB scrollback-limit is 10MB per terminal of memory that can
never be used again for anything else.
Lots of memes on the internet about this, and it turns out operating
systems give us an answer for this (both Linux and macOS at least), so
let's do it kids.
On Linux, madvise(MADV_DONTNEED) reclaims the pages immediately and
guarantees zero-fill on the next touch, which also lets us skip the
zeroing memset entirely, making destroy cheaper.
On macOS, we zero in place and mark the item MADV_FREE_REUSABLE, which
removes it from the process footprint immediately. Reuse is paired with
MADV_FREE_REUSE when the pool hands a buffer back out so that footprint
accounting stays correct. The zero invariant required by page reuse
holds either way: reusable page contents are either preserved (our
zeroes) or reclaimed and zero-filled by the kernel.
Other platforms and test builds keep the existing memset behavior.
## LLM Notes
Fable 5 found the retention behavior while re-analyzing scrollback
memory, wrote the change and tests, and verified the madvise semantics
empirically with memory probes on macOS and a real Linux kernel, plus
before/after throughput benchmarks on both. I reviewed the analysis, the
diff, rewrote the code to be more idiomatic Zig, and wrote this commit
message you're reading.
The PageList page pool never returns memory to the OS: destroyed pages
are zeroed and free-listed until the surface exits. Any operation that
shrinks the page count (clearing scrollback, pruning churn, resets,
reflow) therefore retains its high-water RSS forever.
Clearing a full scrollback keeps all of it resident, which at the default 10MB
scrollback-limit is 10MB per terminal of memory that can never be
used again for anything else.
Lots of memes on the internet about this, and it turns out operating
systems give us an answer for this (both Linux and macOS at least),
so let's do it kids.
Pool items can't be individually freed since they live inside arena
chunks, but they are page-aligned and page-multiple sized, so we can
decommit them while they sit in the free list. Our page-aligned
allocation pays off, again!
On Linux, madvise(MADV_DONTNEED) reclaims the pages immediately and
guarantees zero-fill on the next touch, which also lets us skip the
zeroing memset entirely, making destroy cheaper.
On macOS, we zero in place and mark the item MADV_FREE_REUSABLE, which
removes it from the process footprint immediately. Reuse is paired
with MADV_FREE_REUSE when the pool hands a buffer back out so that
footprint accounting stays correct. The zero invariant required by
page reuse holds either way: reusable page contents are either
preserved (our zeroes) or reclaimed and zero-filled by the kernel.
Other platforms and test builds keep the existing memset behavior.
## LLM Notes
Fable 5 found the retention behavior while re-analyzing scrollback
memory, wrote the change and tests, and verified the madvise semantics
empirically with memory probes on macOS and a real Linux kernel, plus
before/after throughput benchmarks on both. I reviewed the analysis,
the diff, rewrote the code to be more idiomatic Zig, and wrote this
commit message you're reading.
Each commit is standalone. This contains various unoffensive hardening
of edge cases in PageList layout stuff. None of this is reachable today,
but I'm just buttoning this up to experiment with compaction/compression
of pages in various scenarios.
PageList skips zeroing pooled page buffers in release builds, relying
on the OS page allocator handing out zeroed pages and destroyNodeExt
zeroing buffers before returning them to the pool. There is a hidden
exception: std.heap.MemoryPool writes its free list node into the
first pointer-size bytes of a free-listed buffer, so a reused buffer
is not fully zero. This is only safe because the page rows array is
laid out at offset 0, a page always has at least one row, and initBuf
fully rewrites every row, overwriting the stale free list pointer.
None of that was written down or checked anywhere, so a future layout
reorder (or a zero-row page) would corrupt pages in release builds
only, in a way that depends on pool reuse patterns. This adds a
comptime assert that a Row covers at least a pointer, a runtime assert
that pages always have at least one row, and comments tying the
invariant together at the layout, initBuf, and pool reuse sites.
Also fixes stale doc comments: deinit referenced a clonePool function
that no longer exists, and Screen tests referenced increaseCapacity by
its old adjustCapacity name.
Layout.init(0) is an explicitly supported special case that produces a
valid zero-capacity set with a zero-size table. But lookupContext had
no guard for it: probing computes `table[hash & 0]` and reads whatever
memory follows the set in its backing buffer, treating those bytes as
an item ID which is then used to index the (also zero-size) items
array, an out-of-bounds read reaching arbitrarily far past the set.
This has never fired in practice because no production page carries a
zero-capacity set today, and where one could occur the adjacent bytes
happen to be zero (which reads as an empty bucket and returns null).
Page.exactRowCapacity legitimately produces zero capacities for pages
without styled or hyperlinked cells though, so any page compaction
work makes this reachable with nonzero adjacent memory: in a Page
layout the styles set can be followed by the grapheme bitmap, which
is initialized to all ones.
Lookups on a zero-capacity set now return null without touching the
table. This also covers add, which looks up before inserting and
already handles the zero capacity correctly after that point by
returning OutOfMemory. All other entry points assert on valid IDs,
which a zero-capacity set cannot have.
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).
PageList decided whether a page's backing memory belongs to the memory
pool or the heap by comparing its length against std_size. This was
super error-prone and the source of many bugs historically. We locked it
down but its bothered me and has gotten in the way of another feature
I've wanted to do: memory compaction.
First, this commit records the ownership explicitly on each node and
uses it everywhere ownership was previously inferred from size.
Second, createPage gains an exact_size option that forces an exact-size
heap allocation even when the layout would fit a pool item.
Third, compact() now uses it to shrink any page to its minimum size,
including standard pages. Nothing calls compact [YET!] but this is going
to be the key to compressing scrollback history.
This is groundwork for a ton of memory savings. Coming soon.
PageList decided whether a page's backing memory belongs to the memory
pool or the heap by comparing its length against std_size. This was super
error-prone and the source of many bugs historically. We locked it down
but its bothered me and has gotten in the way of another feature I've
wanted to do: memory compaction.
First, this commit records the ownership explicitly on each node and uses it
everywhere ownership was previously inferred from size.
Second, createPage gains an exact_size option that forces an exact-size
heap allocation even when the layout would fit a pool item.
Third, compact() now uses it to shrink any page to its minimum size,
including standard pages. Nothing calls compact [YET!] but this is going
to be the key to compressing scrollback history.
This is groundwork for a ton of memory savings. Coming soon.
BitmapAllocator.layout takes a capacity in bytes, but sized its chunk
region as `aligned_cap * chunk_size`, reserving chunk_size times more
memory than the bitmaps can ever address. As a result, the grapheme
region of every standard page reserved 128 KiB with only 8 KiB
reachable, and the string region 64 KiB with only 2 KiB reachable. About
~180 KiB of waste in every 576 KiB page.
Results for a standard page:
| region | before | after |
|---|---|---|
| grapheme allocator | 131,136 B | 8,256 B |
| string allocator | 65,544 B | 2,056 B |
| page total | 589,824 B (576 KiB) | 409,600 B (400 KiB) |
30% less memory for every standard page in every terminal, including the
preheated pages in the PageList pool.
BitmapAllocator.layout takes a capacity in bytes, but sized its chunk
region as `aligned_cap * chunk_size`, reserving chunk_size times more
memory than the bitmaps can ever address. As a result, the grapheme
region of every standard page reserved 128 KiB with only 8 KiB
reachable, and the string region 64 KiB with only 2 KiB reachable.
About ~180 KiB of waste in every 576 KiB page.
Results for a standard page:
| region | before | after |
|---|---|---|
| grapheme allocator | 131,136 B | 8,256 B |
| string allocator | 65,544 B | 2,056 B |
| page total | 589,824 B (576 KiB) | 409,600 B (400 KiB) |
30% less memory for every standard page in every terminal,
including the preheated pages in the PageList pool.
Hooks up responses to OSC and Kitty color queries if `write_pty` is set
for libghostty.
Also found a memory leak: the OSC parser now releases color operation
request lists during reset.
Fixes C ABI compatibility issue on arm64 macOS by passing color by
reference instead of by value. I ran into an
[issue](https://github.com/elias8/libghostty/actions/runs/28889437162/job/85698207979?pr=93)
while working on the Dart bindings, where tests passed on Linux and
x86_64 macOS locally, but failed on arm64 macOS. The Dart FFI appears to
lower the argument (3 byte struct) differently on arm64 macOS. So the
change addresses that by changing the arguments to by reference and
aligns with the preexisting APIs.
_AI disclosure: I used codex 5.5 to assist with debugging and making the
changes. However, I have reviewed all changes manually and verified by
running the tests both locally and on CI with the fix applied._
libghostty-vt already tracked OSC color state but ignored color queries in the standalone stream handler. This meant embedders that installed write_pty still received no response for OSC 4/10/11/12 or Kitty OSC 21 queries.
Resolve the current terminal colors through shared Terminal helpers and encode replies through the write_pty effect. Xterm queries use the fixed 16-bit rgb form, preserve the request terminator, and fall back from cursor to foreground when no cursor color is set. Kitty color queries now report supported terminal-backed keys and return empty values for unset dynamic colors.
Add RGB wire encoders and tests covering the stream handler and C API. The OSC parser now releases color operation request lists during reset, fixing an allocation leak exposed by multi-query OSC color tests.
Fixes a frame time regression reported with fortio's `fps -fire`
benchmark (fortio.org/terminal/fps): frame times nearly doubled at
typical grid sizes after #13209 (the pipelined pty reader), and were
more than 5x worse at small grids.
## The problem
The `fps -fire` program follows a request/response pattern: write a
frame, end it with a cursor position query (CSI 6n), and block until the
reply arrives before starting the next frame.
The gather stage treats any burst of 1 KiB or more as a saturated
stream. When its spin retries come up dry, it sleeps in a 1ms poll
expecting the writer's next refill so it can publish fewer, larger
batches. But a frame-synced writer will never refill here: it is blocked
waiting for a reply to a query that is sitting inside the very batch the
gather stage is holding back. The poll always sleeps its full timeout,
adding ~1.2ms to every round trip. Ouch!
## The fix
Sleeping for a refill gap is only free while the parse stage is busy,
since the wait hides behind parse time. Once the parser is idle, every
additional microsecond spent bridging is added straight to output
latency. So:
1. When the spin retries exhaust and the parse stage is idle, deliver
the batch immediately instead of polling.
2. When the parse stage is busy, use a `pipe2` pipe to allow the parser
to notify the gather thread it is idle. In the middle of the `poll` loop
and sleep, we can get interrupted immediately and deliver the batch.
The pipe is only written while the gather stage is actively polling, so
an interactive terminal never pays the syscalls, and a saturated stream
never idles the parser, preserving full batching and throughput for bulk
output. Win, win, win!
## Benchmarks
| workload | pre-#13209 | main | this PR |
|---|---|---|---|
| fps -fire 80x24 | 0.262 ms / 3674 fps | 1.435 ms / 694 fps | 0.234 ms
/ 4106 fps |
| fps -fire 160x45 | 1.012 ms / 975 fps | 1.823 ms / 545 fps | 0.701 ms
/ 1405 fps |
| fps -fire 284x68 | 2.443 ms / 407 fps | 2.391 ms / 414 fps | 1.351 ms
/ 732 fps |
| cat 19.3 MB | 0.204 s (95 MB/s) | 0.086 s (224 MB/s) | 0.082 s (236
MB/s) |
## LLM Notes
Bisect script written by hand and found the offending commit. Fable 5
found the likely cause. I manually came up with the proposed solution
and wrote it out. Fable helped benchmark for me to verify my assumptions
conceptually and in the real world. Commit message written by hand
except for the benchmark results.
Fixes a frame time regression reported with fortio's `fps -fire`
benchmark (fortio.org/terminal/fps): frame times nearly doubled at
typical grid sizes after #13209 (the pipelined pty reader), and were
more than 5x worse at small grids.
## The problem
The `fps -fire` program follows a request/response pattern: write a
frame, end it with a cursor position query (CSI 6n), and block until
the reply arrives before starting the next frame.
The gather stage treats any burst of 1 KiB or more as a saturated
stream. When its spin retries come up dry, it sleeps in a 1ms poll
expecting the writer's next refill so it can publish fewer, larger
batches. But a frame-synced writer will never refill here: it is
blocked waiting for a reply to a query that is sitting inside the
very batch the gather stage is holding back. The poll always sleeps
its full timeout, adding ~1.2ms to every round trip. Ouch!
## The fix
Sleeping for a refill gap is only free while the parse stage is busy,
since the wait hides behind parse time. Once the parser is idle,
every additional microsecond spent bridging is added straight to
output latency. So:
1. When the spin retries exhaust and the parse stage is idle,
deliver the batch immediately instead of polling.
2. When the parse stage is busy, use a `pipe2` pipe to allow the parser
to notify the gather thread it is idle. In the middle of the `poll`
loop and sleep, we can get interrupted immediately and deliver
the batch.
The pipe is only written while the gather stage is actively polling, so an
interactive terminal never pays the syscalls, and a saturated stream
never idles the parser, preserving full batching and throughput for
bulk output. Win, win, win!
## Benchmarks
| workload | pre-#13209 | before | after |
|---|---|---|---|
| fps -fire 80x24 | 0.262 ms / 3674 fps | 1.435 ms / 694 fps | 0.234 ms / 4106 fps |
| fps -fire 160x45 | 1.012 ms / 975 fps | 1.823 ms / 545 fps | 0.701 ms / 1405 fps |
| fps -fire 284x68 | 2.443 ms / 407 fps | 2.391 ms / 414 fps | 1.351 ms / 732 fps |
| cat 19.3 MB | 0.204 s (95 MB/s) | 0.086 s (224 MB/s) | 0.082 s (236 MB/s) |
## LLM Notes
Bisect script written by hand and found the offending commit. Fable 5
found the likely cause. I manually came up with the proposed solution and
wrote it out. Fable helped benchmark for me to verify my assumptions
conceptually and in the real world.
The pipelined POSIX pty reader uses multiple large gather buffers to
avoid stalling on Darwin, where pty reads are capped around 1KiB. On
Linux this can let bulk terminal producers run too far ahead of terminal
parsing/rendering.
Frame-style terminal apps such as DOOM-fire can then report very high
producer FPS while Ghostty displays stale frames from the buffered
stream.
Keep the Darwin-tuned 4 x 64KiB pipeline, but reduce non-Darwin
read-ahead to 2 x 8KiB so the pty can apply backpressure before multiple
frames are queued.
AI disclosure: Codex gpt-5.5 xhigh helped.
The pipelined POSIX pty reader uses multiple large gather buffers to avoid
stalling on Darwin, where pty reads are capped around 1KiB. On Linux this can
let bulk terminal producers run too far ahead of terminal parsing/rendering.
Frame-style terminal apps such as DOOM-fire can then report very high producer
FPS while Ghostty displays stale frames from the buffered stream.
Keep the Darwin-tuned 4 x 64KiB pipeline, but reduce non-Darwin read-ahead to
2 x 8KiB so the pty can apply backpressure before multiple frames are queued.
This optimizes scrolling inside a scroll region (DECSTBM).
We're squeezing blood out of a rock here. There just isn't much
improvement to be had given our architecture for this case, but there
were a couple things found that result in modest gains.
## Vtebench
You can see the modest gains here. Still behind Alacritty (very very
close).
<img width="3558" height="2410" alt="CleanShot 2026-07-06 at 21 18
39@2x"
src="https://github.com/user-attachments/assets/3c800089-3510-4887-9872-e9926d686955"
/>
## The changes
1. **Stop creating scrollback for top-anchored regions on screens that
don't retain scrollback.** `index()` routed any full-width region with
`top == 0` through `cursorScrollAbove()`, which pushes the scrolled-out
row into scrollback. Every scroll paid `PageList.grow()` plus amortized
page pruning, which includes a 512 KB `memset` each time a page is
recycled. These now use the in-place region scroll instead. CSI S gets
the same routing fix. **Result: 1.05x-1.49x on the bottom-anchored
region workloads, 1.25x on alt-screen full-screen scrolling.**
2. **Add a specialized `Screen.cursorScrollRegionUp()` for the region
scroll hot path.** The previous fast path (`PageList.eraseRowBounded`)
paid per-scroll bookkeeping that exceeded the actual row work. The new
function is built around the invariant that the cursor sits on the
bottom row of a full-width region. **Result: 1.23x-1.24x on the
top-anchored region workloads.**
## Benchmarks
| workload | region (80 rows) | before | after | change |
|---|---|---|---|---|
| scrolling (control) | primary screen, no region | 237 ms | 235 ms |
1.0x |
| scrolling_bottom_region | alt, rows 1-79 | 243 ms | 231 ms | 1.05x |
| scrolling_bottom_small_region | alt, rows 1-40 | 311 ms | 208 ms |
1.49x |
| scrolling_top_region | alt, rows 2-80 | 283 ms | 229 ms | 1.23x |
| scrolling_top_small_region | alt, rows 40-80 | 258 ms | 208 ms | 1.24x
|
| alt screen full-screen scrolling | alt, no region | 288 ms | 230 ms |
1.25x |
## LLM Notes
Assisted by Fable 5: it diagnosed the vtebench gap, wrote the benchmark
harness payloads, profiled, and proposed hot paths. I manually wrote the
hot path replacements and had it judge my work.