Repository navigation
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. |
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 68 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": the rule of ghostty-web'sgetChars()(no character for codepoint 0, for a value above U+10FFFF, or for a surrogate). The scan usedgetChars() === ''before the first commit of this branch andcodepoint === 0after 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.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 loops. I left it.src/renderer/ghosttyWeb/README.mdstates 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.
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 windowsnapshot --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 comparedwait --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 differs from that head only in the character rule above, 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.
I changed a copy of the committed code, one change at a time, and ran both tests on each copy:
rowsinstead ofcolsgetViewport()rowscodepoint !== 32Four changes survive. I think none 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.codepoint === 0, and a character test without the surrogate check: no effect on any screen in these checks.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 (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 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
The last commit merges main
776ee5c9(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, also for blank cells. 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
All commands ran on
d252ac32with 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.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-backend35 of 35 (the new test takes 14 s),wait-render30 of 31 then 31 of 31 alone,screen-hash6,cross-backend-screen-hash6,screenshot-out9,empty-session-capture11,record-export17, e2eexport-fixtures5ghostty-web-crash4,host-renderer-rpc9,slow-replay5,batch11, e2eunicode-grid1,scrollback-demo2,renderer-slice8,large-snapshot1,libghostty-vt-renderer1,batch-fixture2. Load failures:resize-demo,hello-prompt,cli,screenshot-missing-glyphs(below)Tests that did not pass at the first run:
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). 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, 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 flow: times out at 30 s on this 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-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