Skip to content

fix: keep a live render waiting while its replay moves forward - #359

Merged
ThomasK33 merged 12 commits into
mainfrom
fix/live-render-survives-slow-replay
Nov 6, 2026
Merged

ThomasK33 merged 12 commits into
mainfrom
fix/live-render-survives-slow-replay

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Why

A live screenshot, and a live snapshot --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 with HOST_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 (3 screenshot, 3 snapshot --renderer ghostty-web) at about 5.5 s each. A session with 249,990 events failed 4 of 4. A snapshot that waits behind screenshot --renderer libghostty-vt failed the same way: on a live log of about 3,650 events, it started 20 s after the screenshot and ended with HOST_TIMEOUT after 7.0 s on 25083d92 (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

  • The host says that it is alive. MethodContext.keepAlive() in src/host/rpcServer.ts writes 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: the origin/main CLI took a screenshot from a host of this branch with a cold 2,500-event log. It waited 50.9 s and got the PNG. sendRpc already waits with a socket inactivity timer, so each byte starts the wait over. The request and response schemas do not change. keepAlive writes 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".
  • The response cap counts the response line alone. The server checks the size of the response line, and the client checked every byte that it read. A response near the 64 MiB limit then failed with PROTOCOL_ERROR after keep-alives. sendRpc now 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.
  • A replay reports progress, in slices. replayInSlices (src/renderer/replaySlices.ts) replays a backlog in slices and reports each finished slice. HostRendererManager.withBackend uses it and calls onProgress after the renderer has booted and after each slice. It also tells every call that waits for the renderer, so a snapshot behind a slow first screenshot stays alive.
  • A slice is bounded by time, bytes and flushes. The number of events in a slice follows the time of the last slice and aims at 500 ms, which leaves 10 times the client's 5 s. A slice also holds at most 1 MiB of output (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.
  • A libghostty-vt screenshot reports its second renderer to every call. backend.screenshot() of libghostty-vt boots a ghostty-web renderer and replays the whole log into it. That replay runs in slices too. ScreenshotOptions.onReplayProgress carries the report to the caller, captureScreenshotResult passes it on, and the screenshot handler gives it the reportProgress function that withBackend hands to its operation. reportProgress calls the onProgress of every call that waits, the screenshot's own keepAlive included. So a command that waits behind the screenshot in the renderer queue hears of each slice. Before, only the screenshot itself heard of it.
  • Requests pass keepAlive. The snapshot, screenshot and run handlers pass it to withBackend (run replays after a waited command ends).
  • A hung host still fails. keepAlive runs 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 with HOST_TIMEOUT 5 s after the last report.
  • Replay stays the same. Any split of the events gives the same backend state, because each live render already replays only the events since the one before. A slice keeps the target of the whole replay, and the last call gets the whole input, because a backend keeps its last input.
  • replaySlices.ts lives in src/renderer/, because the renderer layer uses it. Nothing under src/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. docs/USAGE.md, src/host/README.md, src/renderer/README.md, src/renderer/libghosttyVt/README.md, src/screenshot/README.md and src/protocol/README.md describe the wait, the limits of a slice, the cap and the HOST_TIMEOUT rule.

Other designs, and why not:

  • A longer timeout has no good bound. The replay takes 17 to 21 ms per event on 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.
  • A replay in the background from the start of each session boots Chromium for every session, also for sessions that never take a screenshot.
  • A new frame type breaks CLIs from before this change, because the request and response schemas are strict.

Not covered:

  • The renderer boot and the time of one slice stay silent. A boot or a slice that takes more than 5 s still ends in HOST_TIMEOUT.
  • waitForRender answers at its own timeout, and mouse replays libghostty-vt only. Both pass nothing.
  • A host that keeps reporting progress keeps its client waiting. The wait ends when the host stops, not at a total limit.

Validation

All commands ran on this branch at 6a72fa82. It has origin/main (7334f032, with #357) merged in. The only conflict was test/unit/host/rpcServer.test.ts, where both sides added a describe block. I kept both.

  • npm run format:check, npm run lint, npm run typecheck and npm run build pass.
  • npx vitest run test/unit: 148 files, 2293 tests pass.
  • .agents/skills/verify-session-lifecycle/scripts/check.sh --drift-only passes.
  • --maxWorkers=1 --retry=0 on every file of test/integration (4 shards, 39 files, 427 tests) and test/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, except backend-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 (create did 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-ms exited 4. Passes alone.
    • e2e/hello-prompt "full interaction flow" and e2e/resize-demo "initial size and resize": the 30 s test timeout. Both pass alone.
  • A trial merge of this branch with the perf: write each ghostty-web replay flush in one frame #358 branch (060eed1d) has no conflict. npm run typecheck passes on it. test/unit/host, test/unit/renderer and test/unit/screenshot (30 files, 727 tests) pass on it.
  • New tests of this round: 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 in test/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:

Change Red test
withBackend hands its operation a function that reports nothing "reports the progress that an operation reports to every call that waits, its own too"
the screenshot handler passes context.keepAlive as onReplayProgress again integration "answers a snapshot that waits behind a libghostty-vt screenshot, which replays the log in a second renderer, for longer than the client waits": the snapshot ends with HOST_TIMEOUT after 5,000 ms
sendRpc counts the keep-alive spaces "takes a response of exactly 64 MiB after keep-alives that come in chunks of their own" and "... in the same write as the response"
sliceEnd has no limit on flushes 7 tests, among them "never makes a call that takes long, after thousands of tiny outputs and then pairs of output and input_text" (and the same for marker and resize)
sliceEnd counts every event that is not output as a flush 4 tests, among them "does not count an event that follows no output, or one that follows another such event"
sliceEnd keeps output pending after a flush "does not count an event that follows no output, or one that follows another such event"
the limit of flushes uses >=, not > 3 tests: the sliceEnd test of the flush limit, once for input_text, once for marker and once for resize
libghostty-vt replays its fallback renderer from event 1 (-1 becomes 0) "replays the fallback of a screenshot in slices, and reports its boot and each slice". Before this round, this change passed every test.
sliceEnd ignores the byte limit 8 tests
the byte limit uses >= 2 tests
the first event of a slice is not exempt 2 tests
bytes counted as .length "counts bytes of UTF-8, not characters"
sliceEnd has no limit on the event count 9 tests

The last five rows are from the previous round. I ran them again, because sliceEnd changed. The other mutations of the previous round touch code that this round leaves as it was.

Longest silent gap. A script runs HostRendererManager.withBackend on a real ghostty-web renderer 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:

Tree Pair Longest silent gap
#358 and this PR before this round (25083d92) output + input_text 127.6 s (one slice, 9 reports)
#358 and this PR before this round output + resize 87.6 s (one slice, 10 reports)
#358 and this PR now output + input_text 0.80 s and 1.99 s (two runs, 245 reports each, 121 s and 215 s in all)
#358 and this PR now output + resize 1.40 s (246 reports, 179 s in all)
this PR without #358, before and now 1,000 tiny outputs, then 300 output + input_text pairs 0.77 s and 0.80 s

Host 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.write every 4 ms, load 40 to 200). screenshot --renderer libghostty-vt started, and snapshot started 20 s later:

  • 25083d92 (before this round): the snapshot ended with HOST_TIMEOUT (exit 5) after 7.0 s. The screenshot took 94.2 s and passed.
  • This branch: the snapshot waited 183.5 s and passed (exit 0). The screenshot took 203.3 s and passed. It ran at a much higher load.

Sliced and unsliced replay give the same result. A script replays random logs with replayInSlices and with one replayTo, in both renderers, and compares the results. A fake clock with random slice times changes the slice boundaries from run to run.

  • libghostty-vt: the snapshot with scrollback and cells.
  • ghostty-web: the snapshot with scrollback and cells, the SHA-256 of the PNG with and without the cursor, and the screenshot result without the artifact path.
  • Each of these also after a replay in two phases (to the middle seq, then to the end).
  • A libghostty-vt screenshot, which replays its second renderer in slices, against a direct unsliced ghostty-web screenshot (PNG and result), and at least 2 reports of progress.

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 \x1bc and empty output events.

  • This branch: 31 logs (seeds 2 to 32) give identical results.
  • A trial merge with perf: write each ghostty-web replay flush in one frame #358 (060eed1d): 15 logs (seeds 2 to 16) give identical results.
  • Three planted bugs each turn 3 of 3 logs red: a slicer that drops every 7th event inside a slice, a slicer that skips the first event of each slice, and a fallback that replays from event 1. The first version of the script missed the last one, so it got the autowrap event and the long last line.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high

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.
@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 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T19:42:13.056935Z f3eb309 Manual request
🔒 Security Review ✅ Completed 2026-11-06T19:45:32.967404Z f3eb309 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. Keep them coming!

Reviewed commit: c592f7e92f

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

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.

…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.
@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 Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 33b4dd4aa6

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/host/rpcServer.ts Resolved
Comment thread src/host/hostMain.ts Resolved
…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.
@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. Another round soon, please!

Reviewed commit: 6a72fa8227

ℹ️ 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: 6a72fa8227

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

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. Swish!

Reviewed commit: f3eb309c6c

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

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 6, 2026
Merged via the queue into main with commit c9f7152 Nov 6, 2026
26 checks passed
@ThomasK33
ThomasK33 deleted the fix/live-render-survives-slow-replay branch November 6, 2026 21:20
@github-actions github-actions Bot mentioned this pull request Nov 6, 2026
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