Commit Graph

16508 Commits

Author SHA1 Message Date
Mitchell Hashimoto
172f15da3b terminal: expose compression through libghostty-vt
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.
2026-07-09 10:11:07 -07:00
Mitchell Hashimoto
6d5dda40db inspector: clarify page compression memory
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.
2026-07-09 09:47:13 -07:00
Mitchell Hashimoto
0fb89f4ffe terminal: configure scrollback compression
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.
2026-07-09 09:47:13 -07:00
Mitchell Hashimoto
95685afd26 terminal: compress offscreen scrollback history
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.
2026-07-09 09:47:13 -07:00
Mitchell Hashimoto
181254d36a terminal: debounce compression by page activity
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.
2026-07-09 09:47:13 -07:00
Mitchell Hashimoto
b9bb50c832 terminal: optimize page compression codec
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.
2026-07-09 09:47:13 -07:00
Mitchell Hashimoto
461562ca4f terminal: enable idle scrollback compression
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.
2026-07-09 09:47:13 -07:00
Mitchell Hashimoto
f8c217e557 terminal: track pending scrollback compression
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.
2026-07-09 09:47:12 -07:00
Mitchell Hashimoto
0d015b2fce terminal: preserve compressed pages during search
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.
2026-07-09 09:47:12 -07:00
Mitchell Hashimoto
e7969ed436 terminal: make PageList compression self-contained
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.
2026-07-09 09:47:12 -07:00
Mitchell Hashimoto
70e788f066 terminal: add incremental scrollback compression
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.
2026-07-09 09:47:12 -07:00
Mitchell Hashimoto
9156ada169 terminal: add cold-history compression pass
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.
2026-07-09 09:47:12 -07:00
Mitchell Hashimoto
421fe8dabe terminal: integrate compressed pages into PageList
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.
2026-07-09 09:47:11 -07:00
Mitchell Hashimoto
f6fd4cb087 terminal: generalize page memory reclamation
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.
2026-07-09 09:47:11 -07:00
Mitchell Hashimoto
ebc3ffd222 terminal: add compressed page representation
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.
2026-07-09 09:47:11 -07:00
Mitchell Hashimoto
c62c159841 terminal: add standalone LZ4 block codec
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.
2026-07-09 09:47:11 -07:00
Mitchell Hashimoto
ad692f1e85 terminal: return free-listed page memory to the OS (#13245)
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.
2026-07-07 20:59:56 -07:00
Mitchell Hashimoto
896aca4990 terminal: return free-listed page memory to the OS
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.
2026-07-07 20:52:44 -07:00
Mitchell Hashimoto
d3f4476dbf terminal: various PageList hardening for capacity edge cases (#13244)
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.
2026-07-07 20:43:26 -07:00
Mitchell Hashimoto
c442eb4b30 terminal: document the pool reuse zeroing invariant
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.
2026-07-07 20:05:09 -07:00
Mitchell Hashimoto
e44f5cb0fa terminal: guard RefCountedSet lookups against zero-capacity sets
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.
2026-07-07 20:05:09 -07:00
Mitchell Hashimoto
8307349ec5 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).
2026-07-07 20:05:09 -07:00
Mitchell Hashimoto
39b3c47717 terminal: track page ownership explicitly on pagelist nodes (#13241)
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.
2026-07-07 15:46:07 -07:00
Mitchell Hashimoto
16e4b5e98f terminal: track page ownership explicitly on pagelist nodes
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.
2026-07-07 15:33:47 -07:00
Mitchell Hashimoto
bc6ca3ff53 terminal: fix bitmap allocator chunk region sizing (#13240)
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.
2026-07-07 13:48:51 -07:00
Mitchell Hashimoto
b953bb3463 terminal: fix bitmap allocator chunk region sizing
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.
2026-07-07 13:42:48 -07:00
Mitchell Hashimoto
3ff6d08fad lib-vt: report OSC and Kitty color queries (#13239)
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.
2026-07-07 13:10:16 -07:00
Mitchell Hashimoto
5a4f8106a3 fix(lib-vt): pass RGB color inputs by pointer (#13238)
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._
2026-07-07 13:05:51 -07:00
Mitchell Hashimoto
14c8298830 terminal: report OSC color queries in lib-vt
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.
2026-07-07 13:03:19 -07:00
Elias Andualem
20a1bfa5fd fix: pass RGB color inputs by pointer 2026-07-08 02:57:17 +08:00
Mitchell Hashimoto
e7a3a1fc8c termio: don't bridge pty reads while the parser is idle (#13237)
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.
2026-07-07 11:41:42 -07:00
Mitchell Hashimoto
bb0ac4c723 termio: don't bridge pty reads while the parser is idle
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.
2026-07-07 11:35:50 -07:00
Mitchell Hashimoto
15a2edcbb9 termio: bound POSIX read-ahead on non-Darwin (#13236)
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.
2026-07-07 10:33:55 -07:00
Ēriks Remess
bed47168ca termio: bound POSIX read-ahead on non-Darwin
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.
2026-07-07 20:24:06 +03:00
Mitchell Hashimoto
160c3c69ea terminal: handful of scroll region optimizations (minor) (#13231)
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.
2026-07-07 06:36:07 -07:00
Mitchell Hashimoto
77190bd023 terminal: handful of scroll region optimizations
This optimizes scrolling inside a scroll region (DECSTBM). 

## 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.
2026-07-06 21:13:42 -07:00
Mitchell Hashimoto
c41c6b81a4 macOS: use thegetOpinionatedStringContents same as paste for dragging (#13212)
The only difference of getting the string contents between dragging
pasteboard and general pasteboard is the URL now. It was first
introduced in #4962, I don't why it was added, since `public.url` often
comes with `public.utf8-plain-text` when dragging.

I also check with iTerm's
[PTYTextView](f267f243e5/sources/TerminalView/PTYTextView.m (L307)),
it doesn't register URL either, so I think it's safe for us to remove
it.

> I checked the iTerm's source after my changes, I hope that doesn't
violates any licensing
2026-07-06 21:10:58 -07:00
ghostty-vouch[bot]
d8464a5f5b Update VOUCHED list (#13229)
Triggered by [discussion
comment](https://github.com/ghostty-org/ghostty/discussions/13129#discussioncomment-17555238)
from @tristan957.

Vouch: @escalonc

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 03:49:26 +00:00
Mitchell Hashimoto
cabbdee32b Fix adjust-cursor-height regression (#13225)
The `cursor-height` metric (and corresponding `adjust-`) was introduced
by #3062 but the sprite face rework in #7732 accidentally removed the
logic that it relied on. I've moved the logic to live inside of the
sprite face itself (which, I think, was my plan while writing the
rework, I just forgot to actually do it lol), and added a test for the
height metric being respected and the re-centering being performed
correctly.

This problem came to my attention because of #13221, which didn't go
about doing the fix the right way, but did make me realize that it was a
problem in the first place (since I had thought that I had already
implemented this logic when doing the rework!)

### Verification

https://github.com/user-attachments/assets/4074690b-846e-442d-8ec0-91a34042f6eb
2026-07-06 20:18:11 -07:00
Mitchell Hashimoto
98a7c0f6f9 terminal: render state update optimizations (~2.7x to ~11x lock time reduction) (#13227)
This optimizes `RenderState.update`, the per-frame call that snapshots
terminal state for the renderer and is the main reason the renderer
thread holds the terminal lock.

Lock hold time is reduced ~2.7x to ~11x depending on the frame. This was
already a super fast part of Ghostty, so I don't expect any noticeable
changes for end users or library users. But, why not be fast?

## The changes

1. iterate page chunks instead of rows in `update`
2. classify cells with masked vector compares. 
3. split the update into `beginUpdate`/`endUpdate` phases. There's a lot
to be gained by accumulating data with the lock held and then processing
it out of the lock.
4. generalize the masked-compare scans into `page.Mask`. This is just a
really common pattern we're doing now and it yields a ton of great
value. Its error prone so lets make it a tested helper.

## Benchmarks

Measured with the new `ghostty-bench +screen-clone` modes (`render`,
`render-locked`, `render-clean`, `render-partial`), 120x80 terminal, M4
Max, macOS 26, ReleaseFast, hyperfine means of 10+ runs, per-update
times derived from fixed-count update loops with process startup
subtracted. "Lock held" is the time the terminal lock must be held per
update; "before" held the lock for the entire update.

| scenario | before (lock held) | after (lock held) | after (total) |
lock change |

|----------|--------------------|-------------------|---------------|-------------|
| clean frame (nothing dirty) | 202 ns | 19 ns | 19 ns | 10.9x | 
| partial frame (1 dirty row) | 290 ns | 54 ns | 54 ns | 5.4x | 
| full rebuild, lightly styled | 6.9 µs | 2.5 µs | 3.0 µs | 2.7x | 
| full rebuild, fully styled | 9.3 µs | 2.4 µs | 8.0 µs | 3.8x | 
| full rebuild, fully styled, 250x150 | 49.9 µs | 9.4 µs | 31.6 µs |
5.3x |
| full rebuild, plain text | 1.9 µs | 1.9 µs | 1.9 µs | 1.0x (memcpy
floor) |

The clean and partial cases are the steady-state frame costs (cursor
blink, mouse movement, typing). The full-rebuild cases are the contended
ones: colored scrolling output (build logs, htop, vim) moves the
viewport pin every frame, forcing a full rebuild exactly when the IO
thread is busiest, so that row of the table is where lock contention
actually hurts. Plain text was already at the memcpy floor and is
unchanged.

## LLM Notes

This work was driven by Fable 5: benchmarks, optimizations, the property
test, and the measurements above. I reviewed every line, simplified the
design in a few places (API naming, the Mask helper shape), and re-ran
the verifications myself.
2026-07-06 20:07:16 -07:00
Mitchell Hashimoto
446f80f4ed terminal: render state update optimizations (~2.7x to ~11x less lock hold)
This optimizes `RenderState.update`, the per-frame call that snapshots
terminal state for the renderer and is the main reason the renderer
thread holds the terminal lock. 

Lock hold time is reduced ~2.7x to ~11x depending on the frame.

## The changes

1. iterate page chunks instead of rows in `update`
2. classify cells with masked vector compares. 
3. split the update into `beginUpdate`/`endUpdate` phases. There's a 
   lot to be gained by accumulating data with the lock held and then
   processing it out of the lock.
4. generalize the masked-compare scans into `page.Mask`. This is just
   a really common pattern we're doing now and it yields a ton of great
   value. Its error prone so lets make it a tested helper.

## Benchmarks

Measured with the new `ghostty-bench +screen-clone` modes (`render`,
`render-locked`, `render-clean`, `render-partial`), 120x80 terminal, M4
Max, macOS 26, ReleaseFast, hyperfine means of 10+ runs, per-update
times derived from fixed-count update loops with process startup
subtracted. "Lock held" is the time the terminal lock must be held per
update; "before" held the lock for the entire update.

| scenario | before (lock held) | after (lock held) | after (total) | lock change |
|----------|--------------------|-------------------|---------------|-------------|
| clean frame (nothing dirty) | 202 ns | 19 ns | 19 ns | 10.9x |
| partial frame (1 dirty row) | 290 ns | 54 ns | 54 ns | 5.4x |
| full rebuild, lightly styled | 6.9 µs | 2.5 µs | 3.0 µs | 2.7x |
| full rebuild, fully styled | 9.3 µs | 2.4 µs | 8.0 µs | 3.8x |
| full rebuild, fully styled, 250x150 | 49.9 µs | 9.4 µs | 31.6 µs | 5.3x |
| full rebuild, plain text | 1.9 µs | 1.9 µs | 1.9 µs | 1.0x (memcpy floor) |

The clean and partial cases are the steady-state frame costs (cursor
blink, mouse movement, typing). The full-rebuild cases are the contended
ones: colored scrolling output (build logs, htop, vim) moves the
viewport pin every frame, forcing a full rebuild exactly when the IO
thread is busiest, so that row of the table is where lock contention
actually hurts. Plain text was already at the memcpy floor and is
unchanged.

## LLM Notes

This work was driven by Fable 5: benchmarks, optimizations, the property
test, and the measurements above. I reviewed every line, simplified the
design in a few places (API naming, the Mask helper shape), and re-ran
the verifications myself.
2026-07-06 19:57:04 -07:00
Mitchell Hashimoto
634957c8e6 terminal: VT throughput optimizations from real-world dataset (~1.2x to ~3.4x) (#13226)
This is a series of seven commits that optimizes VT processing
throughput. Each commit is isolated, individually benchmarked, and
carries a detailed commit message so please read each for details about
each change.

Whereas #13220 was driven by synthetic data, this series was driven by
profiling a 2.6 GB recording of real terminal sessions from an asciinema
data dump. Through this, I've been able to improve throughput processing
the full dump from 276 to 342 MB/s on my system.

> [!NOTE]
>
> **LLM usage:** This series of work was largely driven by Fable 5 and
the summaries below started as LLM-written. I've proofread (and mostly
modified) every line of work and rewritten everything to be shorter and
more in line with how I'd describe a change. Nothing here was
unreviewed. I also threw away 3 sets of changes I didn't agree with the
maintenance of, but did speed up things a bit.

## The changes

1. **decode ASCII inline in the SIMD scan for ESC**. Real streams call
`utf8DecodeUntilControlSeq` on short runs (an escape every ~18 bytes),
so ~9% of total time was simdutf setup plus its scalar tail paid per
tiny run. The ESC scan and the UTF-8 to UTF-32 "decode" (a widening for
ASCII) are now one pass. **Result: +5.4% on the real corpus.**
2. **handle CSI entry bytes inline in consumeUntilGround**. The `[`
after ESC and the single `csi_entry` byte each paid a `nextNonUtf8`
call, two to three calls for every one of the tens of millions of CSI
sequences in the recording. Both transitions are now handled in the
consumeUntilGround loop, so a typical CSI parses with no per-byte calls
at all. **Result: +2.7% real corpus, +3.1% CSI-heavy stream.**
3. **fill style-only cell runs in bulk in printSliceFill**. The largest
single item in the profile (~25%). The print fast path's two scans (run
eligibility, simple-cell check) are early-exit search loops LLVM won't
vectorize, and real traffic constantly lands in the general path because
styled text overwrites cells styled differently (TUI redraws), paying a
per-cell release/use pair. The scans are now vectorized and
uniformly-styled runs are consumed wholesale: one vector scan, one
releaseMultiple/useMultiple pair, one branch-free fill. **Result: +11%
real corpus, +21% TUI redraw.**
4. **release style refs per run instead of per cell in clearCells**.
Erasing styled rows released each cell's style reference one at a time
even though styled cells overwhelmingly share one style per run (status
bars, highlighted regions, solid rows). Runs now release with a single
releaseMultiple. **Result: +1.1% real corpus, 2.1x on full-screen styled
erase.**
5. **log unsupported-input messages once per distinct value**. The
recording triggers ~120k warnings, dominated by a few messages some
program re-emitted every frame ("unimplemented mode: 34"), each paying
formatting plus a blocking writev while adding nothing beyond the first
occurrence. A logUnsupportedOnce helper suppresses repeats per (call
site, value) using a 64-byte lock-free table per site. **Result: +3.2%
on the real corpus, system time halved.**

## Benchmarks

Measured with `ghostty-bench +terminal-stream` (full terminal handler,
120x80 terminal, M4 Max, macOS 26, ReleaseFast, hyperfine means of 6
runs, 64KiB read chunks). These are parser-stage numbers, not end-to-end
app numbers.

| stream | before | after | throughput | change |

|-------------------------------|---------|---------|------------------|--------|
| real 2.6 GB session recording | 9.441 s | 7.609 s | 276 → 342 MB/s |
1.24x |
| ascii (no escapes) | 119 ms | 84 ms | 838 → 1186 MB/s | 1.41x |
| TUI redraw (rotating styles) | 417 ms | 293 ms | 240 → 342 MB/s |
1.42x |
| styled paint + ED 2 erase | 418 ms | 124 ms | 239 → 808 MB/s | 3.38x |
| csi mix (random-color SGR/CUP)| 695 ms | 696 ms | (adversarial) |
~1.0x |

Note the "csi mix" benchmark above was a generated adversarial input
e.g. a worst-case input for the changes we made. It wasn't based in
real-world data or expectations. But I asked for it to be done so we can
verify we don't see regressions too much (and were able to verify we see
basically none).
2026-07-06 15:04:00 -07:00
Mitchell Hashimoto
b5053153f4 terminal: log unsupported-input messages once per distinct value
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~5% of total time under writev, all of it log
output: the recording triggers ~120k warnings, dominated by a few
repeated messages ("unimplemented mode: 34", "invalid device
attributes command", "invalid C0 character") that some program in
the recorded session re-emitted on every frame or every prompt.
Each occurrence pays formatting plus a blocking write syscall,
and repeats add no diagnostic value beyond the first: the message
already includes the offending value.

These messages are emitted in response to input that the terminal
application controls, so a misbehaving or merely chatty program
can flood the log indefinitely. This adds a logUnsupportedOnce
helper that suppresses repeats per (call site, value): each site
tracks the distinct keys it has logged (the mode number, final
byte, or first parameter, depending on the site) in a small fixed
table of 16 u32 slots, 64 bytes per site. Real streams only ever
produce a handful of distinct unsupported values per site, so if a
table fills, new values are suppressed too; by then the log
already shows the problem class and unbounded distinct values
would flood it anyway. Slots are claimed with 32-bit atomics
(native on wasm32) and never change afterwards, so lookups are a
lock-free scan and the worst case race is a duplicate message.

The OSC 1 change-icon message moves from info to warn to match the
other unsupported-input messages the helper covers.

Measured with ghostty-bench terminal-stream (2.6 GB real-session
corpus, 120x80, M4 Max, ReleaseFast, hyperfine means of 5 runs,
stderr to /dev/null which undersells the cost of a real log sink):

| stream                     | before  | after   | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 7.916 s | 7.674 s | +3.2%  |

System time drops from 0.49 s to 0.22 s from the eliminated
writev calls.
2026-07-06 13:51:35 -07:00
Mitchell Hashimoto
8d663a76e9 terminal: release style refs per run instead of per cell in clearCells
clearCells released the style reference of every styled cell
individually: an array index, a ref decrement, and a liveness
check per cell. Styled cells overwhelmingly come in runs sharing
the same style id (a colored status bar, a highlighted region, a
full row painted in one color), so most of that work is repeated
bookkeeping on the same style entry.

This groups consecutive cells with the same style id and releases
each run with a single releaseMultiple call. Rows with alternating
styles degrade to the same per-cell cost as before; uniform rows,
the common case, do one ref-count update per run. The
releaseMultiple assertion that the ref count is at least the run
length holds by construction since every cell in the run held a
reference.

Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). The erase corpus paints a
full screen of styled rows and erases it with ED 2 in a loop,
which is the pattern full-screen TUIs produce on clear/redraw:

| stream                     | before  | after   | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 8.055 s | 7.965 s | +1.1%  |
| styled paint + ED 2 (100 MB) | 260 ms | 123 ms | 2.1x   |
2026-07-06 13:51:35 -07:00
Mitchell Hashimoto
cb2d785871 terminal: fill style-only cell runs in bulk in printSliceFill
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed printSliceFill as the single largest item (~25% of
total time), and disassembly showed the time split across three
scalar loops: the run-eligibility scan over codepoints, the
simple-cell check that guards the branch-free fill, and the general
path that fixes up style ref counts one cell at a time. The store
loop itself was already auto-vectorized by LLVM, but the two scans
are early-exit search loops that LLVM does not vectorize, and the
general path turns out to be the common case in real traffic:
styled text constantly overwrites cells holding a different style
(TUI redraws, scrolling colored output), so every such cell failed
the simple check and paid a release/use pair.

Three changes, which only pay off together (vectorizing the scans
without the bulk path makes mismatch-heavy rows slower because the
wider check re-runs for every cell the general path consumes):

The run-eligibility scan handles the narrow class, codepoints in
[0x10, 0xFF], eight lanes at a time. The simple-cell check compares
four masked cells per iteration. And a new bulk path handles runs
of cells that differ from the expected simple cell only by style
id: one vector scan finds the extent of the uniformly-styled run,
the ref counts are fixed with a single releaseMultiple/useMultiple
pair, and the cells are filled with the same branch-free store
loop as the simple case. Cells with graphemes, hyperlinks, or wide
content still fall back to print().

Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). The redraw corpus is a
full-screen 80-row styled repaint whose span color rotates every
frame, so every cell is overwritten with a different style:

| stream                     | before  | after   | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 8.826 s | 7.955 s | +11%   |
| TUI redraw (100 MB)        | 348 ms  | 287 ms  | +21%   |
2026-07-06 13:51:35 -07:00
Mitchell Hashimoto
300f42c7a9 terminal: handle CSI entry bytes inline in consumeUntilGround
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~7% of time in nextNonUtf8 self, and most calls
were for the structural bytes of CSI sequences: the '[' after ESC
and the single byte spent in the csi_entry state (a digit, private
marker, or final byte). Real streams contain tens of millions of
CSI sequences, and each paid two to three function calls just to
advance the parser through those states before the bulk parameter
loop could take over.

This lifts both transitions into the consumeUntilGround loop: the
"ESC [" prefix is matched directly, and the csi_entry byte is
handled by a shared csiEntryByte helper that both the loop and
nextNonUtf8 use (the logic previously lived only in nextNonUtf8).
A typical CSI sequence now parses entirely within
consumeUntilGround/consumeCsiParams without any per-byte calls.
Handlers with a vtRaw hook keep the general path since csiEntryByte
dispatches finals directly.

Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). nextNonUtf8 self time
drops from ~7% to ~3% of the profile:

| stream                     | before  | after   | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 9.097 s | 8.854 s | +2.7%  |
| csi mix (SGR/CUP, 100 MB)  | 695 ms  | 674 ms  | +3.1%  |
2026-07-06 13:51:35 -07:00
Mitchell Hashimoto
083d9709be terminal: decode ASCII inline in the SIMD scan for ESC
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~9% of total time inside the UTF-8 decode stage,
and most of it was not the decode itself: real streams contain an
escape sequence every ~18 bytes, so utf8DecodeUntilControlSeq is
called on short printable runs, and each call paid simdutf setup
plus its scalar rewind_and_convert_with_errors tail (which handles
the last partial SIMD block of every conversion) for only a
handful of bytes. The scalar tail alone accounted for ~3.4% of
total time.

Terminal input is also overwhelmingly ASCII, for which UTF-8 to
UTF-32 "decoding" is just widening each byte to 32 bits. This
fuses the two passes: while scanning each chunk for ESC we also
check for bytes >= 0x80 and widen pure-ASCII chunks straight into
the output vector via PromoteTo, never touching simdutf. The first
non-ASCII byte hands the remainder of the run (up to the next ESC)
to the existing simdutf-based path, so non-ASCII text takes
exactly the same code as before. Inputs shorter than one vector
are handled by a scalar byte loop that likewise skips simdutf for
ASCII.

The widening store needs a dedicated path for the HWY_SCALAR
fallback target (compiled on targets without guaranteed SIMD, e.g.
arm-linux-androideabi): its single-lane vectors cannot be halved
so the one lane is widened directly.

The new differential fuzz test verifies the SIMD implementation
still matches the scalar reference exactly. Measured with
ghostty-bench terminal-stream (2.6 GB real-session corpus, 87%
printable ASCII / 5.5% ESC / 5.6% UTF-8, 120x80, M4 Max,
ReleaseFast, hyperfine means):

| stream            | before          | after           | change |
|-------------------|-----------------|-----------------|--------|
| real 2.6 GB corpus | 9.582 s (272 MB/s) | 9.090 s (287 MB/s) | +5.4% |
2026-07-06 13:51:35 -07:00
Qwerasd
e8f3f6c438 font/sprite: add regression test for cursor-height metric 2026-07-06 15:48:23 -04:00
Qwerasd
dac341cad5 font/sprite: make cursor height respect adjust-cursor-height
This was a regression caused by the sprite face rework (#7732), I'm
surprised it went unnoticed for so long.
2026-07-06 15:48:23 -04:00
Mitchell Hashimoto
cb4c49fbf2 terminal: scalar UTF-8 decode consumes partial sequences cut off by ESC
The scalar fallback of utf8DecodeUntilControlSeq (used when SIMD is
disabled, e.g. wasm builds) treated a valid-so-far but incomplete
UTF-8 sequence at the end of its decode region as pending input in
all cases: it stopped without consuming the bytes so a future chunk
could complete the sequence. That is correct when the region ends
at the end of the input, but the region can also be bounded by an
ESC byte. In that case the sequence can never be completed (the
next byte is already known to be ESC), and the SIMD implementation,
via simdutf, replaces the ill-formed prefix with U+FFFD and
consumes up to the ESC. The two implementations disagreed on both
the consumed count and the decoded output for inputs like
"\xC2\x1B[0m".

The divergence is invisible at the stream level (the pending bytes
take the scalar nextUtf8 path which also emits a replacement
character once it sees the ESC) but it means the scalar decoder is
not a faithful reference for the SIMD one.

This makes the scalar decoder treat a partial sequence bounded by
an ESC as a maximal subpart per Unicode 3-7: one U+FFFD, consumed
through the end of the region. Truncation at the true end of input
still leaves the bytes pending. Also adds a differential fuzz test
that runs 10k random mixtures of ASCII, escapes, controls, and
valid/invalid UTF-8 through both implementations and requires
identical results, which is what caught this.
2026-07-06 12:25:58 -07:00