Repository navigation
fix: hold one video frame before a resize in a max-speed export - #364
Conversation
The harness awaited terminal.write(data, callback) for every output chunk. ghostty-web runs that callback one animation frame later, so a replay cost about 17-24 ms per event even though Node already sent up to 1000 chunks per page call. Add a harness writeBatch(chunks, waitForFrame) that writes all chunks at once and waits for one frame only when asked. The backend asks for the frame on the last page call of a flush. Screenshots and snapshots are byte-identical to before. In a WebM replay, the chunks of one flush now share one video frame.
… flush once Max-speed flushed once per run of output events, and a flush is one frame since the batched write. A WebM of an output-only recording then showed only its final state. Max-speed now flushes every frame hold (16 ms) of recorded time and holds the frame only after the last flush of a run, so it is never slower than one frame per output event. A flush of more than 1000 chunks takes several page calls. ghostty-web paints on every animation frame, so a state between two calls reached the video. The page now holds the chunks of a flush until the call that ends it and writes them in one task. Tests pin flushes of exactly 1000 and 2000 chunks and read the screencast frames.
…peed frames, drop held chunks after a failed flush
…et cap, and fix the max-speed docs
…last max-speed flush
…hold-and-docs # Conflicts: # docs/USAGE.md # src/renderer/ghosttyWeb/README.md # src/renderer/ghosttyWeb/backend.ts # test/unit/renderer/ghosttyWebBackend.test.ts
|
@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. Bravo. 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. |
The 16 ms frame hold before a resize is shorter than one video frame (40 ms at 25 frames per second), so the recorder missed the screen before a resize in 20 of 720 resizes in an interleaved test (719 of 720 seen with a hold of one video frame). A max-speed replay now holds the larger of the frame hold and one video frame before a resize. Recorded and accelerated replays keep their frame hold. The context sets the 25 frames per second that the hold is built on. A new test pins one hold when a timed flush, pending output and a resize follow each other. The tests for the sticky flag and for a hold before the resize's own flush turn red.
The USAGE sentence that said max-speed makes a frame at most once per 16 ms ignored resizes, and a resize adds at most one frame, not one each. The renderer and export READMEs now describe the one-video-frame hold before a resize. The budget comment lists the measured flush costs.
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Breezy! 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. |
The max-speed wait before a resize passes VIDEO_FRAME_MS to waitForDelay, which needs a whole number. A VIDEO_FPS that does not divide 1000 now fails when the module loads, with a message that names the cause.
An event that changes no pixel ends a flush only through the time rule in max-speed mode. In recorded and accelerated mode it ends a flush directly.
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. 👍 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. |
# Conflicts: # src/export/README.md # src/renderer/ghosttyWeb/README.md # src/renderer/ghosttyWeb/backend.ts
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Swish! 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
PR #358 merged at 934760d. Its last Codex round (round 4) left one finding open: "Preserve the pre-resize hold after a timed flush". This PR fixes that finding. An independent verifier then checked the first head of this PR (a9b8083) and found four more problems. This PR fixes them too.
Terms in this text:
max-speedtiming.The finding in plain words. In
max-speedtiming, the replay waits before a resize so that the screen before the resize can reach the video. When the timestamp check had already flushed that output, the resize found nothing waiting and did not wait. The screen before the resize can be missing from the video.The verifier found four more problems with a9b8083:
docs/USAGE.mdstill said thatmax-speedmakes a frame at most once per 16 ms of recorded time. That ignores resizes. "Each resize adds one frame" was only an upper bound.evaluatepage calls in 368.5 s. The text also said that no one reproduced a missing frame. The verifier did.What
The original fix stays.
lastFlushUnheldremembers a flush that no wait followed. The resize case pays that wait once. A second resize right after the first does not wait again.The wait before a resize now lasts one video frame in
max-speedtiming (problem 4, 26 source lines insrc/renderer/ghosttyWeb/backend.ts):VIDEO_FPS = 25andVIDEO_FRAME_MS = 1000 / VIDEO_FPS. The recorder is Playwright's ffmpeg recorder. Its default is 25 frames per second (kDefaultFpsin playwright-core 1.64.1, and it starts ffmpeg with-r 25). The browser context now setsrecordVideo.fpstoVIDEO_FPS, so the number does not depend on that default. The exported video still hasr_frame_rate=25/1.preResizeHoldMsis the larger of the frame hold and one video frame inmax-speedtiming. Inrecordedandacceleratedtiming it is the frame hold, as before.preResizeHoldMs. The wait after an earlier timed flush uses it too. A resize waits once, not twice.Tests (
test/unit/renderer/ghosttyWebBackend.test.ts, 67 to 75 tests, with the 5 tests that fix: stop a webm replay only when the renderer stalls #356 added). The 3 cases of a9b8083 and the old caseholds one frame hold between the last flush and a resizenow expect 40 ms. New cases:holds one video frame, not two, when a timed flush is followed by pending output and a resize(problem 1). The log is output at 0 ms, output at 100 ms, resize at 101 ms. The resize runs one wait after the second flush.holds a max-speed frame hold that is longer than a video frame before a resize(a frame hold of 100 ms stays 100 ms).holds the frame hold of %s mode before a resize, forrecorded(16 ms) andaccelerated(100 ms). Both stay as they were.records the video at 25 frames per second.VIDEO_FRAME_MSgoes towaitForDelay, which needs a whole number of milliseconds. The constant now has aninvariantthat stops the module at load with the messageVIDEO_FPS must divide 1000 so that a video frame is a whole number of ms. Every test file that imports the backend runs that check. WithVIDEO_FPS = 30the unit test file fails at load with that message and runs no test. With the check removed andVIDEO_FPS = 30, 7 tests fail with a message about a delay, and the fps test fails too.Docs and comments (problem 2). I re-read every sentence that this PR changes against the code and the numbers below:
docs/USAGE.md: "It makes a frame at most once per 16 ms" now says that recorded time ends a frame at most once per 16 ms. A resize that finds output waiting ends one more frame, outside the 2,400 limit. A resize adds at most one frame and none when no output is waiting. 100,000 outputs that each have a resize 1 ms after them make 100,000 frames. A new sentence names the wait of one video frame before a resize.src/renderer/ghosttyWeb/README.md: the same resize sentence. "An event that changes no pixel does not end it" became a sentence that names its mode. Inmax-speedmode, an input, marker, run-complete, signal or exit event ends a flush only through the time rule. Inrecordedandacceleratedmode, such an event ends a flush directly (flushBeforeNonVisualEvent). A resize that finds output waiting and the end of the replay end a flush in every mode. The other sentences of that paragraph say which mode they are for. The sentence about waits now lists the three waits ofmax-speed: after the last flush, after a resize, and one video frame before a resize.src/export/README.md: the same wait list.backend.tscomments: the resize comment on the budget, and the timing comments. The comment onMAX_SPEED_FRAME_BUDGETlisted numbers from the first PR text. It now lists measured numbers: 18 to 20 ms per flush at CPU pressure 4 to 22 % (PR perf: write each ghostty-web replay flush in one frame #358), 27 to 54 ms per flush at pressure 38 to 70 % (14 runs of this PR), and 287 s to 405 s for the 2,400-flush log at 200x60. The sentence "this many flushes fill 96 s of video" was wrong because the video is as long as the replay. It now says "of 40 ms or more make a video of 96 s or more". The sentence that said a WebM export allows its replay 5 minutes is gone, because fix: stop a webm replay only when the renderer stalls #356 removed that limit. The comment now says that the export has no limit on the length of its replay and fails only when the renderer makes no progress for 5 minutes (REPLAY_STALL_WINDOW_MS).Measurements
The screen before a resize reaches the video slightly more often with one video frame than with 16 ms (problem 4). The gain over a 16 ms wait is small. In one verifier run the head missed 3 cycles and the 16 ms wait missed 2.
Method. I made 3 offline sessions. Each has 8 cycles. A cycle fills the screen with one palette colour (SGR
48;5;N), then resizes the terminal to a smaller size (80x24 down to 16x8). The logs differ in the gap before the resize:close(resize 5 ms after the output),input(an input at 100 ms, a resize at 101 ms) andresize(resize 100 ms after the output). I ranrecord export --format webm --timing max-speedwithnode --import tsx src/cli/main.tsin two trees ofgit archive, with the samenode_modules. The two trees ran in alternating order, 30 rounds of 3 logs each. I decoded pixel row 6 of every video frame (ffmpeg -vf format=rgb24,crop=760:1:0:6 -fps_mode passthrough -f rawvideo) and counted the frames in which the row has the colour of the cycle over the full width of that screen. A cycle counts as seen when one frame shows it. The host was busy (CPU pressureavg1041 to 88 %, median 64 %).The verifier's pooled difference is 10 against 4 missed cycles (p = 0.18, not significant). My difference is 20 against 1 (p = 1.9e-5). So the gain over a 16 ms wait is 0.8 to 2.6 points. It depends on the host and the run. The capture rate depends on host load: a busier host leaves more time between frames. By the host CPU pressure (
avg10) at the start of each export, main without any wait missed the pre-resize screen in 28 % of cycles below 45, 25 % at 45 to 55, 16 % at 55 to 65 and 2 to 3 % above 65. The 16 ms wait (a9b8083) missed 6 %, 2 %, 1 % and 0 % in the same bands. So main's 96.9 % in run A holds only at pressure near 70, and the earlier 76 % (188 of 248) came from lower load. The head with one video frame was not measured below pressure 45, because the host stayed busier than that.In my 30 rounds, by log:
close230 of 240 against 239 of 240,input235 against 240,resize235 against 240. 18 of 90 exports of a9b8083 lost at least one screen. 1 of 90 exports with the longer wait did. The Fisher exact test is two-sided. The one missed cycle with the longer wait was in thecloselog at pressure 68 %.I repeated the run with the code of 426998c (it has the explicit
fps): 10 rounds of 3 logs, 240 cycles against a9b8083. It showed all 240 (100 %). a9b8083 showed 232 (96.7 %).The longer wait adds 24 ms of waiting for each resize that follows output. Real replays cost more than that sum (see the real-browser pairs below). Logs without a resize are not touched.
No resize. The wait code only runs at a resize. On a fake clock (the real replay loop with stubbed page calls) a log without a resize has the same number of flushes and the same time in main, in a9b8083 and in this head. That covers the 2,400-flush limit:
Real runs agree: a log of 400 outputs 40 ms apart made 400 flushes and 405 page calls in all 14 runs of both trees.
Resizes (fake clock, flushes the same in all three):
The 100,000-flush row is why the USAGE text now says that resizes are outside the 2,400 limit. The
a9b80836column differs from main only when the timestamp check flushed first (row 2).Real browser, resize-heavy log, 7 alternating pairs of mine, replay time only (no video), medians:
The control shows the noise floor of this busy host: about ±20 %. My second row (1.00, range 0.44 to 1.55) is too noisy to show the cost. Page calls were the same in every pair (805, 605 and 405).
The verifier measured the 200-resize log in 5 real-browser pairs and found this head slower in all 5. The median ratio was 1.42 (1.12 to 1.54), and the difference was +2.9 s to +11.2 s. The extra wait predicts 200 x 24 ms = 4.8 s. So the cost is 15 to 56 ms per resize, and the verifier's pairs are the better measurement of it.
What stays open:
evaluatecalls, CPU pressure 27 %) and 405.4 s on a9b8083 (45 %). I did not repeat it. The difference is load, because the code path is the same.Mutation checks of the new code, on the merge result. Each run is the two unit files
ghosttyWebBackendandwebm(122 tests). The control with an identical text failed 3 tests of #356 by their 5 s test timeout at CPU pressure 87 %. Those 3 tests (exports a dense 'accelerated' recording whose replay runs far past its plan, the same for'max-speed', andexports a max-speed recording whose output goes to the renderer in one flush) fail the same way on main (47a8157): 2 of 6 runs on main and 2 of 6 on the merge result failed at pressure 86 to 88 %, and all passed on reruns. I leave them out of the red counts below:||=)holds one video frame, not two, ...(and only it)holds one video frame, not two, ...(and only it)holds a max-speed frame hold that is longer than a video frame ...holds the frame hold of recorded mode before a resizeVIDEO_FPS = 30VIDEO_FPS must divide 1000message and no test runsfpsinrecordVideorecords the video at 25 frames per secondreports progress between the resize call and the note that follow a resize eventandexports a recording that resizes often while no page call outlasts the windowreports progress after each bridge batch of a timed replay flushand thenames event ... hangstestsThe first two rows also go red on a9b8083 with the same new test (with 16 ms in place of 40 ms), and they were green before it.
Merge with main
The branch merges
origin/mainat 47a8157 (a normal merge, no rebase, no force). Main now has #356 (the stall watchdog) and #363. The merge conflicted in 3 files, in comments and docs only:src/renderer/ghosttyWeb/backend.ts: the comment onMAX_SPEED_FRAME_BUDGET. I kept this PR's measured numbers and rewrote the sentence about a replay limit of 5 minutes. After fix: stop a webm replay only when the renderer stalls #356 the export has no total limit and fails only when the renderer makes no progress for 5 minutes (REPLAY_STALL_WINDOW_MS).src/export/README.md: I kept this PR's timing-preset line and main's stall-window line. The oldREPLAY_TIMEOUT_MSline is gone.src/renderer/ghosttyWeb/README.md: I kept main'sonProgressline and this PR'sreplayWithTimingparagraph. Main says thatreplayWithTimingreports in four places. This PR adds no report, so that count stays true.Both changes survive in the merged code. A resize event waits once (
preResizeHoldMs), then calls the resize, then reports progress for that event (seqof the resize,scheduledWaitMs0), then notes the painted graphemes.waitForDelayis now a chain of timers and still takes whole milliseconds, andVIDEO_FRAME_MSkeeps its assertion. NoREPLAY_TIMEOUT_MSor "5 minutes in total" text is left in the tree.git diff origin/main HEADshows only this PR: 5 files, 250 added lines and 20 removed lines. The branch history also lists the commits of the old #358 branch, because main has the squash of #358. The diff against main is not affected.Validation
All commands ran on the merge result bd31626 with Node 26.8.1. The integration and e2e files ran one at a time with
--maxWorkers=1 --retry=0.npm run format:checknpm run lintnpm run typechecknpx vitest run test/unitbash .agents/skills/verify-session-lifecycle/scripts/check.sh --drift-onlytest/integration/renderer-backend.test.tstest/integration/record-export.test.tstest/integration/ghostty-web-crash.test.tstest/integration/empty-session-capture.test.ts(it exports WebM too)test/e2e/export-fixtures.test.tsTests that passed only on rerun. The host was very busy during the first pass (CPU pressure
avg1078 to 88 %). Tests failed by timeout or by a CLI that exited withnull. No failing assertion pointed at this PR. The same tests passed on the rerun at pressure 25 to 50 %.doctor(returns unique passing checks across environment and renderer groups),lifecycle(times out without recording destroyed while the group survives its kill),artifactManifestLock(lets one writer at a time in when many find a dead owner's lock),lineDiff(handles a huge one-sided middle without allocating the DP table) and 2 tests ofbuiltins(registers agent-tty-smoke ...and the one after it). Unit, second run:gc(removes the session folder when a manifest write lands during its rm). The third run passed all 2,401 tests.renderer-backend:records a glyph that a resize brings back from the scrollback. It gotmissingGlyphsTruncated: true, so the glyph probe ran out of its time under load.record-export: 6 tests (exports deterministic asciicast artifacts for running sessions,creates missing parent folders of --out,fails with EXPORT_ERROR when the --out parent folder cannot be searched,keeps one manifest entry per file when it exports the same seq again,lists a glyph that a webm frame drew as a box ...,names the size limit when the event log is too large to read).ghostty-web-crash: all 4 tests.empty-session-capture: 9 of 11 tests.export-fixtures:captures alt-screen content live ...andsupports post-mortem snapshot, screenshot, and asciicast export after a crash.test/unit/export/webm.test.ts(exports a dense 'accelerated' recording whose replay runs far past its plan, the same for'max-speed', andexports a max-speed recording whose output goes to the renderer in one flush) hit their 5 s test timeout at pressure 86 to 88 %. They do the same on main: 2 of 6 runs on main and 2 of 6 runs on the merge result failed, and all passed in the other runs.I also ran the real CLI (
record export --format webm --timing max-speed) on 3 crafted sessions about 240 times for the measurements above.Generated with
xum• Model:anthropic:claude-sonnet-5-5• Thinking:xhigh