Skip to content

fix: skip empty output chunks in the ghostty-web renderer - #366

Open
ThomasK33 wants to merge 5 commits into
mainfrom
fix/ghostty-web-empty-output-write
Open

ThomasK33 wants to merge 5 commits into
mainfrom
fix/ghostty-web-empty-output-write

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Why

An output event with data "" passes the event schema (OutputEventPayloadSchema is data: z.string()), the event log codec and EventLog.append. Two things go wrong when a replay meets one.

  1. ghostty-web throws. The page calls terminal.write(''), and ghostty-web 0.4.0 throws RangeError: offset is out of bounds. On origin/main these commands fail on a log with such an event:

    Command Where it replays Result on origin/main
    screenshot host (running session) or CLI (stopped session) exit 9 RPC_ERROR (running), exit 10 REPLAY_ERROR (stopped)
    --renderer ghostty-web snapshot host (running) or CLI (stopped) exit 9 RPC_ERROR (running), exit 10 REPLAY_ERROR (stopped)
    record export --format webm always in the CLI process, for a running and a stopped session exit 7 EXPORT_ERROR

    The default snapshot and wait use libghostty-vt, which already takes an empty chunk. They use ghostty-web only when libghostty-vt is unavailable. record export --format asciicast uses no renderer, and --format svg replays through libghostty-vt. Both exited 0 on origin/main in my run.

  2. The timed replay gives the event a place on the schedule. After the page stops throwing, replayWithTiming (the WebM export) still treats the empty event like any output. Its timestamp opens a flush window. In recorded and accelerated mode it forces a flush and a frame hold before the next input, marker, exit or resize. In max-speed mode it moves the split points. The video of a log with an empty event then differs from the video of the same log without it (more states, longer video).

A session that agent-tty records itself never holds such an event today:

  • The host appends PTY output only through RunCompletionCoordinator, and its three appendOutput calls all check length > 0. RunCompletionSentinelScanner.feed('') returns no pieces.
  • node-pty reads the PTY through a net.Socket with setEncoding, and Node drops a decoded chunk that is empty. I wrote the bytes E2, 82, AC of one character in three PTY writes, an empty write, and a lone lead byte (python3 script on a real session). The log held one € event and no empty event.
  • Before feat: keep run completion markers out of rendered terminal artifacts (#21) #55 the host appended pty.onData data without that check, so an older log can only hold an empty event if node-pty emitted one. I did not find a way to make it do that. I did not test the Windows ConPTY reader.

So the logs that hit this are hand-made, from another tool, or from an older host. They are valid under the schema, so the renderer has to take them. I kept the log format and the validation as they are. Rejecting these logs breaks every existing log of this shape, and the renderer is the only part that cannot take the value.

What

  • src/renderer/ghosttyWeb/embeddedHarnessHtml.ts: the page drops an empty chunk before it reaches terminal.write. This is the only place that calls terminal.write, so every caller of the page is covered, including replayTo and the host's live render.
    • writeBatch does not hold an empty chunk. The write callback (which resolves the call one frame later) goes to the last chunk that is written, so a flush that ends with an empty chunk still resolves. A call that ends a flush of only empty chunks writes nothing and waits for no frame, and its painted-grapheme note still runs.
    • write returns for an empty string. Nothing in the backend calls write any more (GhosttyWebBackend.writeBridge has no caller), but it has the same defect in the same file.
  • src/renderer/ghosttyWeb/backend.ts: replayWithTiming takes an output event with empty data out of the recording. The loop checks seq order, then skips the event before it reads the timestamp, so the event has no place on the schedule, opens and ends no flush, and adds nothing to the recorded span that sets the max-speed split distance. The replay is the replay of the log without the event.
    • The event still counts as applied: it advances highestProcessedSeq, so lastSeq and every later progress report reach its seq. Nothing else reports for it, because it does no work.
    • The 1000-chunk rule is unchanged, because empty chunks never join a flush.
    • Behavior change: the timestamp of an empty output event is no longer parsed or compared with the one before it. The seq check still applies. A malformed or backward timestamp on such an event used to fail the replay.
    • replayTo has no schedule, so it is unchanged. It sends the empty chunks to the page, which drops them.
    • I did not use the smaller form of skipping only the chunk. It keeps the event's timestamp on the schedule, so the event still adds a wait, can move a flush to the time of the empty event, and widens the span. Eight of the new unit tests fail on that form.
  • src/renderer/ghosttyWeb/README.md: the page sentences (after the discardHeldChunks() sentence), the timed replay sentence and the timestamp assertion line.
  • src/renderer/ghosttyWeb/harness.html is not changed. No code reads it (it is not in copy-renderer-assets.mjs, and the backend serves EMBEDDED_HARNESS_HTML at the /harness.html URL). It has no writeBatch, so it was already out of step.

Merge with main

Commit 074b3a31 merges main 9b26e448 (it holds #365 and #367) into this branch. Only src/renderer/ghosttyWeb/README.md conflicted: #365 added a line about the one viewport read next to the lines this branch changes, and the merge keeps both. On the merge commit, format check, lint, typecheck and the unit tests pass (149 files, 2,432 tests), and renderer-backend.test.ts passes 47 of 47 with --maxWorkers=1 --retry=0. Three unit tests (doctor > returns unique passing checks across environment and renderer groups, lineDiff > handles a huge one-sided middle without allocating the DP table, registerBuiltinPresets > registers agent-tty-smoke and makes repeated registration a no-op) hit the 5 s test timeout at host load 218 and passed alone at load 51.

Validation

  • Unit tests (ghosttyWebBackend.test.ts, 31 new): for each of 9 placements of an empty output event and each of the 3 timing modes (27 tests), the steps and times of the replay (flushes, resizes, notes, final read, in a fake-timer replay with a fake page) equal those of the same log without the event. The placements are before an input, before a resize, before an exit, between two outputs of one flush, between two outputs of two flushes, before the first output, after the last output, far after the last output (it spreads the frame budget over its time) and before each of 20 inputs. Four more tests pin the accelerated and max-speed repros, lastSeq with the progress reports, and a log of only empty events.

  • Integration tests (renderer-backend.test.ts, describe empty output chunks, 11 tests, all through the real renderer):

    • replayTo with an empty chunk in the middle, at the start, as the last chunk, as every chunk, as the only chunk of the call that ends a flush of 1001 chunks, and inside the first call of such a flush.
    • A direct page write('').
    • A call that ends a flush of only empty chunks notes the painted graphemes.
    • Such a call waits for no frame. The test stops requestAnimationFrame in the page, expects the empty flush to settle, and expects a flush that wrote a chunk to stay pending.
    • replayWithTiming with empty events around two resizes.
    • A seeded 300-chunk log (a 40x10 screen, so it scrolls: more than 20 scrollback lines and more than 6 filled rows) with and without empty chunks has the same visible lines, scrollback, cursor and screenshot SHA-256.
  • Mutation of backend.ts (vitest -t "output events that have empty data", 31 tests):

    Mutant Red tests
    control (identical text) 0
    no skip and no span change (the head before this change) 31
    skip only the chunk and keep the event on the schedule 8 (before an exit and before the first output in 2 modes each, after the last output in 1 mode, far after the last output in 3 modes)
    keep the empty event in the recorded span 1 (far after the last output, max-speed)
    skip without advancing highestProcessedSeq 1 (lastSeq and progress)
  • Mutation of embeddedHarnessHtml.ts (vitest -t "empty output chunks", 11 tests, archive copy):

    Mutant Red tests
    control (identical text) 0
    revert the page fix (origin/main file) 10 (the timed replay test stays green, because the backend no longer sends it empty chunks)
    hold every chunk again 9
    write when the flush is empty (drop the flush.length > 0 check) 3 (as every chunk, note, no frame)
    drop the write check 1 (direct page write)
    drop one-character chunks (length > 1) 4
    hold empty chunks in a call that does not end the flush (chunk.length > 0 || !endsFlush) 1 (inside the first call of a flush of 1001 chunks)
    skip the painted-grapheme note on an empty flush 1 (notes the painted graphemes)
    wait for a frame on an empty flush 1 (without waiting for a frame)
    drop the chunk after an empty chunk 4 (middle, start, first call, seeded twin)
  • Real CLI on isolated AGENT_TTY_HOME folders, on a log that a real host wrote. A host-only preload (NODE_OPTIONS=--import) wraps EventLog.prototype.append, so the host appends one "" output event after each output event and two before exit, through the validated writer. The log holds 9 records, 5 of them "". The same Home and session served both trees. Commands on a stopped session unless noted:

    Command origin/main this branch
    snapshot --json (libghostty-vt) exit 0 exit 0
    --renderer ghostty-web snapshot --format text exit 10 REPLAY_ERROR exit 0
    screenshot --json exit 10 REPLAY_ERROR exit 0
    record export --format webm exit 7 EXPORT_ERROR exit 0 (21,411 bytes)
    running session, --renderer ghostty-web snapshot exit 9 RPC_ERROR RangeError exit 0
    running session, screenshot exit 9 RPC_ERROR RangeError exit 0

    On this branch the --renderer ghostty-web snapshot text (without the Session ID and Captured At Seq lines) and the screenshot SHA-256 cbd557008eea0f327920bea5809e4145cef0e46e9877295a07e2de2e6f19d905 equal those of a control session with the same program and no empty events. The running session gave the same SHA-256.

  • Real CLI WebM export of a crafted stopped session (a copy of an exited real session with a new id and a log of controlled timestamps, read by the CLI through the log codec), each cell 5 alternating runs at host load 50 to 80, before this change (54ec1153) and after it. Frames are the distinct frames that ffmpeg mpdecimate keeps, and the video length is the median:

    Log Mode Before, without / with the empty event After, without / with the empty event
    full-screen red at 60 ms, green at 110 ms, blue at 2000 ms, empty event at 0 ms accelerated frames 4,4,4,5,5 / 5,6,6,6,6 frames 4,4,5,5,5 / 4,4,5,5,5
    50 times an output, an empty output 2 ms later, an input 4 ms later recorded video 5.80 s / 6.40 s video 5.44 s / 5.40 s

    All exports exited 0. The numbers are noisy at this load. The point is that the with-event runs match the without-event runs only after this change.

  • Gates: npm run format:check, npm run lint, npm run typecheck, npx vitest run test/unit (149 files, 2432 tests, all passed), check.sh --drift-only passes. Integration and e2e files that reach the page (--maxWorkers=1 --retry=0, one file per run): renderer-backend (44 of 44, with the 11 empty-chunk tests), wait-render (31), record-export (17), ghostty-web-crash (4), empty-session-capture (11), host-renderer-rpc (9), e2e export-fixtures (5), screenshot-out (9), screenshot-missing-glyphs (1), screen-hash (6), cross-backend-screen-hash (6), slow-replay (5), backend-selection (7), record-diff (10), artifact-manifest-race (3), batch (11), mouse (15), run (15), event-log (5), cli (77), lifecycle (73), and the other 9 e2e files (batch-fixture, hello-prompt, large-snapshot, libghostty-vt-renderer, renderer-errors, renderer-slice, resize-demo, scrollback-demo, unicode-grid). All passed.

  • Two files passed only on a second run of the file: backend-selection (3 tests: defaults semantic snapshot rendering to libghostty-vt when available, does not leak automatic renderer defaults into PTY environments, threads --renderer ghostty-web through live snapshot RPC paths) and cli (5 tests: the two doctor tests, uses --home instead of AGENT_TTY_HOME and the two config.json tests). The first run was at host load 40 to 85, and each failure was a test timeout (STACK_TRACE_ERROR, 5.3 to 13 s against the 5 s limit), never an assertion about output. Both files passed alone (7 of 7 and 77 of 77).


Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

An output event with data "" passes the event schema, but ghostty-web's
terminal.write('') throws RangeError: offset is out of bounds. A replay
that met one failed with it. The harness page now drops empty chunks in
write() and writeBatch(): the write callback goes to the last chunk that
is written, and a flush of empty chunks writes nothing.
@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 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T13:28:53.508040Z 074b3a3 Manual request
🔒 Security Review ✅ Completed 2026-11-10T13:29:59.810221Z 074b3a3 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. Breezy!

Reviewed commit: 54ec11532a

ℹ️ 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: 54ec11532a

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.

An output event with empty data changes no pixel, but a timed replay still
gave it a place on the schedule and put its empty chunk in a flush. Its
timestamp opened a flush window, ended a flush before an input, a marker, an
exit or a resize, and moved the max-speed split points, so a WebM export of
a log with such an event had other frames than the export of the log
without it. The replay now skips the event: it counts as applied for lastSeq
and the progress reports, and it has no other effect.

The page tests now cover an empty chunk in the first call of a flush above
1000 chunks, the painted-grapheme note of a flush of empty chunks, and the
missing frame wait. The seeded twin test scrolls.
# Conflicts:
#	src/renderer/ghosttyWeb/README.md
@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. Hooray!

Reviewed commit: 074b3a3108

ℹ️ 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: 074b3a3108

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.

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