Repository navigation
fix: keep a live render waiting while its replay moves forward - #359
Conversation
A live screenshot or ghostty-web snapshot of a long log replays the whole log before it answers, and the client gives up after 5 s without a byte. The host now replays a long backlog in slices, tells every request that waits for the renderer when a slice ends, and writes one space ahead of the response line for each. A replay that moves keeps its clients waiting, and a replay that stops still ends in HOST_TIMEOUT.
|
@codex review |
|
@codex security review |
Codex Review SummaryThis 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. Keep them coming! 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. |
…nt waiting The libghostty-vt screenshot replays the whole log in a second ghostty-web renderer inside backend.screenshot(). That replay was cold, unsliced and silent, so a live screenshot of a long log still ended in HOST_TIMEOUT. ScreenshotOptions gets onReplayProgress. The libghostty-vt backend reports the boot of the second renderer and each slice of its replay, and the host passes MethodContext.keepAlive for it. replaySlices.ts moves to src/renderer, because the renderer layer uses it now.
Slices were sized by event count only, and the last call replayed everything that was left. With fast replay, 9,000 tiny events grow the slice to thousands of events, and the 500 events of 64 KB that follow then run for seconds in one silent call. A slice now also holds at most 1 MiB of output (the last call too), and only slices that the event count ended change the count for the next slice.
|
@codex review |
|
@codex security review |
🛡️ 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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33b4dd4aa6
ℹ️ 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".
…d a slice by its flushes A libghostty-vt screenshot reports the progress of its second renderer to every call that waits (the operation of withBackend gets reportProgress), sendRpc leaves keep-alive spaces out of the response cap, a slice holds at most 16 events that follow output, and the fallback replay test pins event 0.
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. Another round soon, 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. |
|
@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
A live
screenshot, and a livesnapshot --renderer ghostty-web, replay the whole in-memory event log into a renderer of the host before they answer. The CLI client (sendRpc) gives up after 5 s without a byte from the host. A long log takes longer than that, so the command fails withHOST_TIMEOUT(exit 5).The host keeps replaying after the client has gone. The next command waits in the renderer's queue and times out too.
On
origin/main, a session with about 21,000 events failed 6 of 6 live calls (3screenshot, 3snapshot --renderer ghostty-web) at about 5.5 s each. A session with 249,990 events failed 4 of 4. Asnapshotthat waits behindscreenshot --renderer libghostty-vtfailed the same way: on a live log of about 3,650 events, it started 20 s after the screenshot and ended withHOST_TIMEOUTafter 7.0 s on25083d92(the previous head of this PR, merged with main).#358 makes the replay itself much faster. This PR does not change the replay speed. It makes the client wait for as long as the replay moves, so a replay that is faster but still longer than 5 s under load no longer fails. In my runs on a cold 249,990-event log, #358 alone failed 3 of 4 times. #358 with this change failed 0 of 4.
What
MethodContext.keepAlive()insrc/host/rpcServer.tswrites one space ahead of the response line. JSON allows whitespace before a value, so a CLI from before this change still parses the response. I checked it: theorigin/mainCLI took ascreenshotfrom a host of this branch with a cold 2,500-event log. It waited 50.9 s and got the PNG.sendRpcalready waits with a socket inactivity timer, so each byte starts the wait over. The request and response schemas do not change.keepAlivewrites nothing once the socket can no longer be written to. Without that check, a late call after the response has started cuts a large response: the client fails with "RPC connection closed before a complete response was received".PROTOCOL_ERRORafter keep-alives.sendRpcnow leaves the spaces ahead of the line out of the count. Only the CLI changes, so a host of any version works, and a host without keep-alives behaves as before.replayInSlices(src/renderer/replaySlices.ts) replays a backlog in slices and reports each finished slice.HostRendererManager.withBackenduses it and callsonProgressafter the renderer has booted and after each slice. It also tells every call that waits for the renderer, so asnapshotbehind a slow firstscreenshotstays alive.MAX_SLICE_BYTES). One event that is larger makes a slice of its own. A slice also holds at most 16 events that follow output (MAX_SLICE_FLUSHES). A renderer writes its output at such an event and waits for a frame, which takes about 34 ms with perf: write each ghostty-web replay flush in one frame #358. Tiny outputs cost almost nothing, so the count grows over them, and thousands of input and output pairs after them made one slice that stayed silent for minutes. The last call is bounded the same way, because it replays only the events that fit in one slice. Only a slice that the event count ended changes the count for the next slice. A count that grew over slices of big events says nothing about the events that follow.backend.screenshot()of libghostty-vt boots aghostty-webrenderer and replays the whole log into it. That replay runs in slices too.ScreenshotOptions.onReplayProgresscarries the report to the caller,captureScreenshotResultpasses it on, and thescreenshothandler gives it thereportProgressfunction thatwithBackendhands to its operation.reportProgresscalls theonProgressof every call that waits, the screenshot's ownkeepAliveincluded. So a command that waits behind the screenshot in the renderer queue hears of each slice. Before, only the screenshot itself heard of it.keepAlive. Thesnapshot,screenshotandrunhandlers pass it towithBackend(runreplays after a waited command ends).keepAliveruns only when a slice ends or a renderer has booted, never on a timer. A renderer that stops finishes no slice, so the client ends withHOST_TIMEOUT5 s after the last report.replaySlices.tslives insrc/renderer/, because the renderer layer uses it. Nothing undersrc/renderer/ghosttyWeb/changes. A local merge of this branch with the perf: write each ghostty-web replay flush in one frame #358 branch (060eed1d) has no conflict.docs/USAGE.md,src/host/README.md,src/renderer/README.md,src/renderer/libghosttyVt/README.md,src/screenshot/README.mdandsrc/protocol/README.mddescribe the wait, the limits of a slice, the cap and theHOST_TIMEOUTrule.Other designs, and why not:
origin/main, so the time grows with the log. A timeout big enough for the largest log also makes a hung host wait that long before it fails.Not covered:
HOST_TIMEOUT.waitForRenderanswers at its own timeout, andmousereplays libghostty-vt only. Both pass nothing.Validation
All commands ran on this branch at
6a72fa82. It hasorigin/main(7334f032, with #357) merged in. The only conflict wastest/unit/host/rpcServer.test.ts, where both sides added adescribeblock. I kept both.npm run format:check,npm run lint,npm run typecheckandnpm run buildpass.npx vitest run test/unit: 148 files, 2293 tests pass..agents/skills/verify-session-lifecycle/scripts/check.sh --drift-onlypasses.--maxWorkers=1 --retry=0on every file oftest/integration(4 shards, 39 files, 427 tests) andtest/e2e(3 shards, 10 files, 35 tests), at host load 35 to 135. In the integration shards, 420 tests pass, 3 are skipped, and 4 fail. In the e2e shards, 32 pass and 3 fail. All 7 failures pass when I rerun their file alone, with the default timeout and--retry=0, exceptbackend-selection(see below). The 7 tests:backend-selection: "does not leak automatic renderer defaults into PTY environments" and "threads --renderer ghostty-web through live snapshot RPC paths" fail with the 5 s test timeout. A rerun at load 35 to 60 failed 6 of 7 with that timeout. With--testTimeout=60000, all 7 pass. Two more reruns at load 55 to 90 passed 6 of 7 and 7 of 7.origin/main(7334f032) passed 7 of 7 at load 56, with tests that took 1.6 to 5.5 s.artifact-manifest-race"lists the file with entries that describe it when exports write one path at once": a CLI call exited 6 (createdid not reach its host in time). Passes alone.lifecycle"destroy --force records a running session as destroyed without waiting for its socket": 4,690 ms against a bound of 4,500 ms. Passes alone.e2e/export-fixtures"captures a color-grid screenshot with a non-trivial PNG artifact":wait --idle-msexited 4. Passes alone.e2e/hello-prompt"full interaction flow" ande2e/resize-demo"initial size and resize": the 30 s test timeout. Both pass alone.060eed1d) has no conflict.npm run typecheckpasses on it.test/unit/host,test/unit/rendererandtest/unit/screenshot(30 files, 727 tests) pass on it.test/unit/renderer/replaySlices.test.ts(9 new, 31 in all),test/unit/host/renderer.test.ts(1 new),test/unit/host/rpcClient.test.ts(3 new),test/integration/slow-replay.test.ts(1 new, 5 in all), and one more assertion intest/unit/renderer/libghosttyVtBackend.test.ts: the replay of the fallback renderer starts at event 0, and every event reaches it once.Each new test fails when I break the code it covers. I made these changes one at a time:
withBackendhands its operation a function that reports nothingscreenshothandler passescontext.keepAliveasonReplayProgressagainHOST_TIMEOUTafter 5,000 mssendRpccounts the keep-alive spacessliceEndhas no limit on flushesmarkerandresize)sliceEndcounts every event that is not output as a flushsliceEndkeeps output pending after a flush>=, not>sliceEndtest of the flush limit, once forinput_text, once formarkerand once forresize-1becomes0)sliceEndignores the byte limit>=.lengthsliceEndhas no limit on the event countThe last five rows are from the previous round. I ran them again, because
sliceEndchanged. The other mutations of the previous round touch code that this round leaves as it was.Longest silent gap. A script runs
HostRendererManager.withBackendon a realghostty-webrenderer and records the time between two reports of progress. The log holds 100,000 tiny output events, then 4,000 pairs of an output event and an input event:25083d92)input_textresizeinput_textresizeinput_textpairsHost load was 15 to 100 during these runs. Without #358, a tiny output already costs about 20 ms, so the count does not grow over tiny events and the limit changes nothing.
A queued command behind a libghostty-vt screenshot, with the real CLI. A live session printed about 3,650 events (a python program, one
os.writeevery 4 ms, load 40 to 200).screenshot --renderer libghostty-vtstarted, andsnapshotstarted 20 s later:25083d92(before this round): the snapshot ended withHOST_TIMEOUT(exit 5) after 7.0 s. The screenshot took 94.2 s and passed.Sliced and unsliced replay give the same result. A script replays random logs with
replayInSlicesand with onereplayTo, in both renderers, and compares the results. A fake clock with random slice times changes the slice boundaries from run to run.The logs hold 258 to 531 events, of which 105 to 238 follow output. They hold resizes (40x12 up to 120x40), wide glyphs, box drawing, an unassigned code point with no glyph, true color and 256-color text, scroll regions, line insert and delete, the alt screen, mouse and paste modes, markers and input events. Autowrap is off from the first event, and the last event prints a line that is longer than the screen, so a replay that loses the first event shows on the final screen. Some logs have bursts of 18 to 45 output events that each follow an input event or a marker (slices that the flush limit ends), and bursts of 2 to 4 events of 121 to 242 KB (slices that the byte limit ends). The generator leaves out
\x1bcand empty output events.060eed1d): 15 logs (seeds 2 to 16) give identical results.Generated with
xum• Model:anthropic:claude-opus-5-5• Thinking:high