Skip to content

perf: read the ghostty-web viewport once per decode - #365

Open
ThomasK33 wants to merge 9 commits into
mainfrom
perf/cheaper-painted-glyph-scan
Open

ThomasK33 wants to merge 9 commits into
mainfrom
perf/cheaper-painted-glyph-scan

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Why

Codex round 4 on #358 left a P2 finding open: "Scale the frame budget to the terminal dimensions". Each timed flush scans every cell of the screen (notePaintedGraphemes), so a large terminal makes each flush slower.

The size of the screen is not the whole cause. In ghostty-web 0.4.0, activeBuffer.getLine(row) re-reads the whole viewport out of the wasm module and copies one row of it. A loop that calls it once for each row reads each cell once for each row (rows x rows x cols cell reads). The harness had three such loops over the visible screen:

  1. getCellGraphemes, the scan of every flush of a recorded replay.
  2. decodeVisibleLines, the text of every snapshot, every wait poll and every screenshot or export that reads the screen.
  3. decodeVisibleCells, the style and colour data of snapshot --include-cells.

In the page, the loop of getLine calls took 4.1 of 5.1 ms at 80x24, 10.8 of 15.7 ms at 120x40 and 18.2 of 21.5 ms at 200x60 for the scan alone. One getViewport() call takes 0.05, 0.15 and 1.1 ms (measured in the first commit of this branch).

What

All code changes are in src/renderer/ghosttyWeb/embeddedHarnessHtml.ts. Against main, that file gains 68 lines and loses 32. backend.ts is not touched.

  • getCellGraphemes (first commit of this branch) reads the viewport once with wasmTerm.getViewport() and indexes it with row * cols + col.
  • New in this change: decodeVisibleLines and decodeVisibleCells do the same. The helper readViewportCells reads the viewport once per decode and checks that the pool covers the grid. The width of a column, whether it holds a character, its two colours and its styles come from that read. Every field that the old code took from getLine is in getViewport(): codepoint, fg_r/g/b, bg_r/g/b, flags and width. The cursor comes from getCursor(), as before, so I kept nothing on a per-row read for these two decodes.
  • cellHasChars is the one rule for "this cell holds a character": the rule of ghostty-web's getChars() (no character for codepoint 0, for a value above U+10FFFF, or for a surrogate). The scan used getChars() === '' before the first commit of this branch and codepoint === 0 after it. Now the scan and the snapshot share the old rule. I did not check whether the terminal can store a value for which the two rules differ.
  • The style flags come from ghostty-web's exported CellFlags, which the harness now imports.
  • decodeScrollbackLines still calls getLine(row) for each row. For a scrollback row, getLine reads that one row (getScrollbackLine) and does not re-read the viewport, so its cost grows with the number of rows and not with rows x rows. I did not change it.
  • harness.html is a standalone copy that no code serves (see the README of the directory). It has the same loops. I left it.
  • One README line in src/renderer/ghosttyWeb/README.md states the rule.

No change to the CLI JSON, the schemas or the event log.

Identity: the result does not change

Method: the same seeded logs ran on main (934760d) and on this head. The logs hold wide glyphs (CJK, fullwidth), emoji (ZWJ sequences, flags, skin tones, keycaps), combining marks, Indic, Thai, Arabic and Hebrew text, unassigned and private-use code points, every style (bold, italic, underline, strikethrough, faint, inverse, blink, hidden, and 16, 256 and true colour), a wide glyph that is half overwritten, a wide glyph at the last column, scrolling, scroll regions, insert and delete of lines, the alternate screen, and resizes between nine sizes from 40x10 to 200x60. No RIS: it crashes ghostty-web 0.4.0 on both trees.

Check What is compared Size Differences
Library, seeds 1-100 At 12 targets per log: snapshot with scrollback and cells (JSON hash), snapshot without them (twice), getVisibleText, the screen hash of both snapshots, and 11 wait conditions (text, absent text, wide text, regex, anchored regex, non-ASCII regex, cursor-line scope for text and for regex, cursor position, screenStableMs, text with screenStableMs), each judged with and without a stable window 1,200 states, 4,639,208 cells, 98,464 scrollback lines, 26,400 wait results, 2,400 visible texts, 2,400 plain snapshots, 2,400 screen hashes, 586 states on the alternate screen 0, no seed dropped
Glyph classes in seeds 61-100 The same check, with counters of what the cells hold 4,718 multi-codepoint cells, 764 ZWJ, 341 flags, 1,640 emoji, 3,397 with a combining mark, 1,016 Indic, 4,731 wide-glyph spacers, 15,054 non-ASCII cells 0
Page, after every event of 24 seeds The new decode against the per-row reference decode (the old code, on the same terminal), and the scan against the reference cells. Half of the states are read in the task of the write or the resize 3,210 states (1,533 in the task of a write or resize), 12,525,652 cells, 717,146 non-empty cells, 255,373 scrollback lines, 1,530 states on the alternate screen 0
Real CLI, offline 12 crafted exited sessions (4 seeds, 3 cut points each): snapshot --include-scrollback --include-cells, snapshot --format text, wait --text (found and absent), wait --regex (two), wait --scope cursor-line, wait --screen-stable-ms, all with --renderer ghostty-web. Exit code and JSON compared 96 comparisons, 7.57 MB. 27 results are errors and are identical on both trees 0
Real CLI, live One live session per tree and seed (3 seeds), same program: wait --screen-stable-ms 800, wait --text, wait --regex, snapshot --include-scrollback --include-cells, snapshot --format text, screenshot (PNG SHA-256 and missingGlyphs) 18 comparisons, 1.2 MB 0

Controls: I planted bugs in copies of this head and ran the same checks. A row stride of rows instead of cols in decodeVisibleCells gave 473 differing fields in 4 library seeds and 408 of 434 page states. A character test of codepoint !== 32 gave 203 differing fields in the library check, and the page check flagged both the cells and the scan in all 142 states of seed 1. A swap of bold and italic gave 30. A width of 1 for every column gave 420 and 367 of 434 page states. The checks see these bugs.

The 26-seed export-level check of the earlier change (snapshot, PNG and missingGlyphs per target, timed replays) was not repeated. The scan differs from that head only in the character rule above, and the live screenshot runs and the page-level scan comparison cover it.

Tests

New test in test/integration/renderer-backend.test.ts: decodes the visible lines and cells as the per-row read of the same screen does. test/helpers/perRowDecode.ts holds the reference: the old per-row code (activeBuffer.getLine(row) for every row), as a string that runs in the page on the same terminal. The harness decodes first, then the reference, and the page returns only the differences, because sending the cells of a 200x60 screen to the test cost more than decoding them. The screens: blank (in the task of a resize), every style with narrow and wide glyphs, combining marks, ZWJ, flags, Indic and unassigned glyphs and a half-overwritten wide glyph, 60 lines of scrollback, the alternate screen and back, a write whose decode runs in the task of the write, and five resizes (120x40, 40x10, 100x30, 200x60, 80x24) with a full screen after each. It also checks the content (a bold cell, an inverse cell, a spacer after a wide glyph).

The test that the scan change added now compares the scan with the reference cells and no longer with a snapshot, because a snapshot no longer reads row by row.

I changed a copy of the committed code, one change at a time, and ran both tests on each copy:

Change Red test
none (control) none
text decode: row stride rows instead of cols the new test
text decode: column shifted by one the new test
text decode: width 1 for every column the new test
text decode: the stale cell pool instead of getViewport() the new test (the new row has wide glyphs where the old screen has narrow cells)
cell decode: row stride rows the new test
cell decode: row stride from the pool length the new test (after the shrink to 40x10)
character test is codepoint !== 32 the new test and the scan test
character test is always true the new test and the scan test
red and blue swapped in the colour the new test
inverse ignored the new test
inverse swaps only the foreground the new test
bold and italic swapped the new test
underline read from the strikethrough flag the new test
strikethrough read from the underline flag the new test
foreground and background swapped the new test

Four changes survive. I think none is a gap:

  • The cell decode reads the stale pool: no effect. decodeVisibleLines always runs first in a snapshot and refreshes the pool in the same task, and the page has no other entry to decodeVisibleCells.
  • The scan back to codepoint === 0, and a character test without the surrogate check: no effect on any screen in these checks.
  • The pool-size check removed: it is an assertion that no input reaches.

The speed has no test, because a timing assertion is flaky on a shared host.

Cost

In the page, one call of getSnapshot, median of 20 calls per run. Main and head ran interleaved in pairs on a full screen (text in every row). The ratio is head / main per pair: median, range in brackets, and the count of pairs in which head was faster. Round 1 ran while the host was busy (load 17-272), round 2 on a quieter host (load 15-85).

Call Screen Round (pairs) main head ratio head faster
wait poll (getSnapshot()) 80x24 1 (7) 7.5 ms 3.9 ms 0.46 (0.02-1.31) 6 of 7
wait poll 80x24 2 (5) 2.4 ms 1.1 ms 0.44 (0.42-0.48) 5 of 5
wait poll 200x60 1 (7) 113 ms 16.4 ms 0.11 (0.06-0.17) 7 of 7
wait poll 200x60 2 (5) 75.2 ms 5.5 ms 0.08 (0.06-0.31) 5 of 5
snapshot with scrollback and cells 80x24 1 (7) 18.5 ms 5.8 ms 0.31 (0.03-2.32) 6 of 7
snapshot with scrollback and cells 80x24 2 (5) 5.5 ms 2.2 ms 0.38 (0.29-0.48) 5 of 5
snapshot with scrollback and cells 200x60 1 (7) 297 ms 104 ms 0.30 (0.09-0.42) 7 of 7
snapshot with scrollback and cells 200x60 2 (5) 86.1 ms 14.6 ms 0.17 (0.08-0.40) 5 of 5

In round 1, head was slower in one pair of the poll at 80x24 (5.8 against 7.6 ms) and in one pair of the snapshot at 80x24 (7.1 against 16.5 ms). Both are at the noise of this host.

From the Node side, backend.snapshot() (the call that wait and snapshot make), same pairs:

Call Screen Round main head ratio head faster
wait poll 80x24 1 85.7 ms 16.2 ms 0.20 (0.06-0.72) 7 of 7
wait poll 80x24 2 5.9 ms 2.9 ms 0.55 (0.31-0.70) 5 of 5
wait poll 200x60 1 204 ms 97.1 ms 0.47 (0.11-0.58) 7 of 7
wait poll 200x60 2 71.9 ms 11.9 ms 0.16 (0.14-0.45) 5 of 5
snapshot with cells 80x24 1 809 ms 789 ms 1.17 (0.72-1.81) 3 of 7
snapshot with cells 80x24 2 190 ms 222 ms 1.10 (0.95-1.44) 1 of 5
snapshot with cells 200x60 1 4,507 ms 5,682 ms 0.97 (0.51-1.86) 4 of 7
snapshot with cells 200x60 2 868 ms 1,186 ms 0.89 (0.67-2.54) 4 of 5

A snapshot with cells does not get faster from the Node side. I looked at why with 8 more pairs at 80x24: the call takes 200 ms (main) and 207 ms (head), ratio 1.00 (0.89-3.26), head faster in 4 of 8. About 195 ms of that is the transfer of the result (page.evaluate returns about 2,000 cell objects), 197 ms on main and 194 ms on head. The same call with a result of 1 fell from 6.7 to 3.2 ms (ratio 0.44, 8 of 8 pairs). Node-side validation takes 6-7 ms. I report this and do not tune it.

What a user feels is the wait poll at large sizes: in the page it fell from 75-113 ms to 5.5-16 ms at 200x60.

Scrollback decode: in the page with 477 to 1,032 scrollback lines of 80 columns, the time grew about in step with the line count (0.1-0.5 ms per line, noisy). I did not change it.

Merge with main

The last commit merges main 776ee5c9 (it holds #356 and #364) into this branch. The merge had no conflicts. #356 and #364 change backend.ts, other tests and docs, and neither changes the harness or renderer-backend.test.ts. On the merge commit, format check, lint, typecheck and the unit tests pass (149 files, 2,401 tests). One unit test, gc.test.ts > removes the session folder when a manifest write lands during its rm, timed out at 5 s at host load 211 and passed alone (17 of 17).

Not in this PR

  • The comment on MAX_SPEED_FRAME_BUDGET in backend.ts states per-flush costs from before these changes. I did not touch it.
  • decodeScrollbackLines still reads each scrollback row with getLine(row) (see What).
  • getGraphemeString(row, col) is still one wasm call for each cell, also for blank cells. A cheaper way needs the grapheme_len field of the wasm cell. I measured that for the scan in the first commit and did not adopt it.

Validation

All commands ran on d252ac32 with Node 26.8.1 and pnpm 10.34.5. The host had a load of 20-265 from other agents, so many failures below are load timeouts. I judged each red test by its assertion and ran it again.

Command Result
npm run format:check pass
npm run lint 0 warnings, 0 errors
npm run typecheck pass
bash .agents/skills/verify-session-lifecycle/scripts/check.sh --drift-only covered code matches the sync record
npx vitest run test/unit 149 files, 2347 tests. First run: 2342 pass, 5 time out under load (below)
npx vitest run --maxWorkers=1 --retry=0 on 8 files that reach the harness through snapshot, wait, screenshot and export renderer-backend 35 of 35 (the new test takes 14 s), wait-render 30 of 31 then 31 of 31 alone, screen-hash 6, cross-backend-screen-hash 6, screenshot-out 9, empty-session-capture 11, record-export 17, e2e export-fixtures 5
The same command on 14 more files that name ghostty-web pass: ghostty-web-crash 4, host-renderer-rpc 9, slow-replay 5, batch 11, e2e unicode-grid 1, scrollback-demo 2, renderer-slice 8, large-snapshot 1, libghostty-vt-renderer 1, batch-fixture 2. Load failures: resize-demo, hello-prompt, cli, screenshot-missing-glyphs (below)

Tests that did not pass at the first run:

  • Unit, 5 tests: doctor > returns unique passing checks across environment and renderer groups, lifecycle > times out without recording destroyed while the group survives its kill, lineDiff > handles a huge one-sided middle without allocating the DP table, evals/workspaces/builtins > registers agent-tty-smoke and makes repeated registration a no-op (5 s timeouts), and artifactManifestLock > lets one writer at a time in when many find a dead owner's lock (30 s). The first four pass when their files run again with --testTimeout=120000. The last passes alone in 23.7 s.
  • wait-render > rejects unsafe regexes through direct waitForRender RPC: "Hook timed out in 30000ms" in the run of the 8 files. The file passes 31 of 31 alone.
  • screenshot-missing-glyphs > says the glyph check is incomplete when a screen has too many glyphs to probe: fails on this head in 3 of 3 runs and on main (934760d) in 2 of 2 runs, with expected 5 to be +0 (the live screenshot exits with HOST_TIMEOUT at this load) or expected null to be +0. At backend level, the same screen (200x50, 4,596 CJK glyphs) gives the same PNG SHA-256 on both trees, and missingGlyphsTruncated is true on both. The length of the missingGlyphs list differs from run to run on both trees (0 to 1,024 entries), because the check hits its probe cap.
  • e2e hello-prompt > full interaction flow: times out at 30 s on this head in 5 of 5 runs and on main in 2 of 2 runs. The test picks libghostty-vt when it is available, so it is not on the path of this change.
  • e2e resize-demo: 30 s timeouts in 3 of 5 runs on this head (the first 3, at load up to 265) and in 1 of 3 runs on main (load 242). It passes 3 of 3 on this head at load 90 and 231, and on main at load 271 and 178.
  • cli (77 tests): 12 tests time out at 5 s because each starts several CLI processes that need 5-7 s at this load. All 77 pass with --testTimeout=90000.

Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

activeBuffer.getLine(row) re-reads the whole viewport out of the wasm module on every call. decodeVisibleLines and decodeVisibleCells called it once per row, so a snapshot or a wait poll read each cell once per row. They now read the viewport once with wasmTerm.getViewport() and take the text, width, colour and style of a cell from it.
A snapshot and a wait poll read the viewport once. The new test decodes the same screens with the per-row read that the harness used before and compares the lines, the scrollback, the cells and the text. The scan test now compares with the same per-row read.
Sending the cells of a large screen to the test took far longer than decoding them. The page compares the two decodes and returns the first difference.
The row that the write puts on the screen has the spacers of two wide glyphs where the old screen has narrow cells, so a decode that reads the pool of the old screen adds spaces to the line.

docs: note in the ghostty-web README that a decode reads the viewport once
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T20:15:12.045990Z 23fb598 Manual request
🔒 Security Review ✅ Completed 2026-11-09T20:15:54.773061Z 23fb598 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 23fb59869c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 23fb59869c

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant