Repository navigation
perf: read the ghostty-web viewport once per decode - #365
Conversation
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
|
@codex review |
|
@codex security review |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
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.
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
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:getCellGraphemes, the scan of every flush of a recorded replay.decodeVisibleLines, the text of everysnapshot, everywaitpoll and every screenshot or export that reads the screen.decodeVisibleCells, the style and colour data ofsnapshot --include-cells.In the page, the loop of
getLinecalls 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. OnegetViewport()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.tsis not touched.getCellGraphemes(first commit of this branch) reads the viewport once withwasmTerm.getViewport()and indexes it withrow * cols + col.decodeVisibleLinesanddecodeVisibleCellsdo the same. The helperreadViewportCellsreads 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 fromgetLineis ingetViewport():codepoint,fg_r/g/b,bg_r/g/b,flagsandwidth. The cursor comes fromgetCursor(), as before, so I kept nothing on a per-row read for these two decodes.cellHasCharsis the one rule for "this cell holds a character":codepoint !== 0. That is the rule of ghostty-web 0.4.0'sgetChars(), 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 usedgetChars() === ''before this branch, and the scan used it before the first commit. So the scan and the snapshot keep main's rule.CellFlags, which the harness now imports.decodeScrollbackLinesstill callsgetLine(row)for each row. For a scrollback row,getLinereads 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.htmlis a standalone copy that no code serves (see the README of the directory). It has the same three per-rowgetLineloops of the decodes, and no scan loop. I left it.src/renderer/ghosttyWeb/README.mdsays 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,cellHasCharsalso 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 main776ee5c9.snapshotwith scrollback and cells (JSON hash),snapshotwithout 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-linescope for text and for regex, cursor position,screenStableMs, text withscreenStableMs), each judged with and without a stable window776ee5c9d252ac32and again on the final head (same totals)d252ac32snapshot --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 comparedd252ac32wait --screen-stable-ms 800,wait --text,wait --regex,snapshot --include-scrollback --include-cells,snapshot --format text,screenshot(PNG SHA-256 andmissingGlyphs)Controls: I planted bugs in copies of this head and ran the same checks. A row stride of
rowsinstead ofcolsindecodeVisibleCellsgave 473 differing fields in 4 library seeds and 408 of 434 page states. A character test ofcodepoint !== 32gave 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
missingGlyphsper target, timed replays) was not repeated. The scan has the rule of main and of that head (codepoint !== 0), and the livescreenshotruns 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.tsholds 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'sgetChars()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 wrapswasmTerm.getViewportso 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:rowsinstead ofcolsgetViewport()rowscodepoint !== 32cellHasChars(above U+10FFFF, surrogates)Two changes survive. I think neither is a gap:
decodeVisibleLinesalways runs first in a snapshot and refreshes the pool in the same task, and the page has no other entry todecodeVisibleCells.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).getSnapshot())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 thatwaitandsnapshotmake), same pairs: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.evaluatereturns about 2,000 cell objects), 197 ms on main and 194 ms on head. The same call with a result of1fell 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
23fb5986merges main776ee5c9(it holds #356 and #364) into this branch. The merge had no conflicts. #356 and #364 changebackend.ts, other tests and docs, and neither changes the harness orrenderer-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
MAX_SPEED_FRAME_BUDGETinbackend.tsstates per-flush costs from before these changes. I did not touch it.decodeScrollbackLinesstill reads each scrollback row withgetLine(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 thegrapheme_lenfield 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.npm run format:checknpm run lintnpm run typecheckbash .agents/skills/verify-session-lifecycle/scripts/check.sh --drift-onlynpx vitest run test/unitnpx vitest run --maxWorkers=1 --retry=0on 8 files that reach the harness through snapshot, wait, screenshot and exportrenderer-backend36 of 36 (it holds the three per-row tests),wait-render31,screen-hash6,cross-backend-screen-hash6,screenshot-out9,empty-session-capture11,record-export17, e2eexport-fixtures5Earlier 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: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), andartifactManifestLock>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.ghostty-web-crash4,host-renderer-rpc9,slow-replay5,batch11, e2eunicode-grid1,scrollback-demo2,renderer-slice8,large-snapshot1,libghostty-vt-renderer1,batch-fixture2.screenshot-missing-glyphs>says the glyph check is incomplete when a screen has too many glyphs to probefailed on that head in 3 of 3 runs and on main (934760d) in 2 of 2 runs, withexpected 5 to be +0(the live screenshot exits with HOST_TIMEOUT at this load) orexpected null to be +0. At backend level, the same screen (200x50, 4,596 CJK glyphs) gives the same PNG SHA-256 on both trees, andmissingGlyphsTruncatedistrueon both. The length of themissingGlyphslist differs from run to run on both trees (0 to 1,024 entries), because the check hits its probe cap.hello-prompt>full interaction flowtimed out at 30 s on that head in 5 of 5 runs and on main in 2 of 2 runs. The test pickslibghostty-vtwhen it is available, so it is not on the path of this change.resize-demotimed 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