Skip to content

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

Merged
ThomasK33 merged 11 commits into
mainfrom
perf/cheaper-painted-glyph-scan
Nov 10, 2026
Merged

ThomasK33 merged 11 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 64 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": codepoint !== 0. That is the rule of ghostty-web 0.4.0's getChars(), which returns '' only for codepoint 0 and maps any other value to a character (U+FFFD for a value that is not a Unicode scalar value). The snapshot used getChars() === '' before this branch, and the scan used it before the first commit. So the scan and the snapshot keep main's rule.
  • 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 three per-row getLine loops of the decodes, and no scan loop. I left it.
  • One README line in src/renderer/ghosttyWeb/README.md says what each decode takes from the one read.

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.

The first checks ran on d252ac32. There, cellHasChars also returned false for a value above U+10FFFF or a surrogate, which main's rule does not. The logs hold only valid Unicode text, and the pin test below plants the other values. I ran the library check and the page check again on the final head (rows 3 and 4), against main 776ee5c9.

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
Library, seeds 1-40, final head against main 776ee5c9 The same check 480 states, 1,843,760 cells, 43,231 scrollback lines, 10,560 wait results, 236 states on the alternate screen 0, no seed dropped
Page, after every event of 24 seeds, on d252ac32 and again on the final head (same totals) 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, on d252ac32 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, on d252ac32 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 has the rule of main and of that head (codepoint !== 0), 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.

Pin test, same file: says a cell with any codepoint but 0 has a character, as the per-row read does. ghostty-web's getChars() gives '' for codepoint 0 and a character for every other value (U+FFFD for a value above U+10FFFF or a surrogate). The terminal does not write such values, so the page plants them: it wraps wasmTerm.getViewport so that the first cell holds 0, 0x41, 0xd7ff, 0xd800, 0xdfff, 0xe000, 0x10ffff, 0x110000 or 0x1fffff, then reads the snapshot cell, the scan cell and the per-row reference. All three must agree: '' for 0 and the grapheme of the cell for every other value.

I changed a copy of the committed code (a0d04603), one change at a time, and ran the three 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, the scan test and the pin test
character test is always true the new test, the scan test and the pin test
the old range checks back in cellHasChars (above U+10FFFF, surrogates) the pin test only
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

Two changes survive. I think neither 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 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 (head 7.6 ms, main 5.8 ms) and in one pair of the snapshot at 80x24 (head 16.5 ms, main 7.1 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

Commit 23fb5986 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 in the two decodes, also for blank cells. The scan skips cells that hold no character. 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

Final head a0d04603, Node 26.8.1, pnpm 10.34.5, host load 100-160 from other agents. Every command passed at the first run. No test needed a rerun.

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, 2,401 tests pass
npx vitest run --maxWorkers=1 --retry=0 on 8 files that reach the harness through snapshot, wait, screenshot and export renderer-backend 36 of 36 (it holds the three per-row tests), wait-render 31, screen-hash 6, cross-backend-screen-hash 6, screenshot-out 9, empty-session-capture 11, record-export 17, e2e export-fixtures 5

Earlier gate on d252ac32 (the same harness code except the character rule, before the merge of main). It ran on a busier host (load up to 270), so some tests timed out. I list them because they can show again:

  • 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). They passed when their files ran again.
  • wait-render > rejects unsafe regexes through direct waitForRender RPC: "Hook timed out in 30000ms" once. The file passed 31 of 31 alone.
  • 10 more files that reach the ghostty-web renderer passed: 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.
  • screenshot-missing-glyphs > says the glyph check is incomplete when a screen has too many glyphs to probe failed on that 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 timed out at 30 s on that 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 timed out at 30 s in 3 of 5 runs on that head (the first 3, at load up to 265) and in 1 of 3 runs on main (load 242). It passed 3 of 3 on that head at load 90 and 231, and on main at load 271 and 178.
  • cli (77 tests): 12 tests timed out at 5 s because each starts several CLI processes that need 5-7 s at that load. All 77 passed 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-10T09:48:58.023402Z a0d0460 Manual request
🔒 Security Review ✅ Completed 2026-11-10T09:50:24.853478Z a0d0460 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.

getChars() gives '' only for codepoint 0. For a value above U+10FFFF or a surrogate it gives U+FFFD, so main's rule equals codepoint === 0. The range checks in cellHasChars changed that rule. The README line now says that the text of a cell still comes from getGraphemeString(row, col).
…ot write

The page plants 0 and invalid codepoints in one cell of the viewport read. The snapshot cell and the scan must give the answer of the per-row read.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: a0d046036c

ℹ️ 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: a0d046036c

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.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit 74c22d7 Nov 10, 2026
29 checks passed
@ThomasK33
ThomasK33 deleted the perf/cheaper-painted-glyph-scan branch November 10, 2026 11:53
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