Fix the main-process crash when streaming kitty graphics: bound the PTY command scrape - #489
Open
parsakhaz wants to merge 3 commits into
Open
Fix the main-process crash when streaming kitty graphics: bound the PTY command scrape#489parsakhaz wants to merge 3 commits into
parsakhaz wants to merge 3 commits into
Conversation
`terminal.currentCommand` reconstructs the typed command line by scraping echoed PTY output, so it sees every byte a program prints. It only reset on CR or LF, and nothing capped it. Kitty graphics frames carry neither: the payload is base64, an alphabet with no CR or LF, wrapped in APC sequences, and a full-screen TUI positions its cursor with CSI rather than newlines. A game streamed through terminal-browser therefore appended every frame to the accumulator and never once hit the reset branch, until the string crossed V8's ~512M-character ceiling and `+=` threw `RangeError: Invalid string length` from inside the onData listener. That is unrecoverable in the main process, so Electron showed the error dialog and the app was dead. Cap the accumulator at 8KB, keeping the tail so the characters the user actually typed - always the newest bytes before Enter - survive the trim. Also bound `commandHistory` in memory to the 100 entries already applied when panel state is saved; `getTerminalState` returns that array in full. The cap sits on the scrape heuristic only. Image data still reaches xterm through `outputBuffer` untouched, so inline graphics render exactly as before. The defect predates the inline-image work, but #486 is what made it reachable in practice by enabling the kitty protocol that image-streaming tools need. Claude-Session: https://claude.ai/code/session_01GfBLdfsxeXVv3AWNnRBt1z
parsakhaz
commented
Aug 23, 2026
parsakhaz
left a comment
Member
Author
There was a problem hiding this comment.
Verdict: Approve - the bounded scrape fulfills the PR intent without changing the renderer output path.
Counts: Must Fix: 0 (security: 0) · Should Fix: 0 · pass 1/3
Must Fix
None.
Should Fix
None.
Praise
- The production bound is applied directly at the only unbounded
currentCommandappend site, preserving the newest bytes while preventing cross-chunk growth (main/src/services/terminalPanelManager.ts:1328). - Command history is bounded immediately after insertion and matches the existing persisted 100-entry window (
main/src/services/terminalPanelManager.ts:1298). - The regression suite drives the real private
setupTerminalHandlerscallback rather than duplicating its logic, and separately proves the cap, renderer byte preservation, tail preservation, and bounded history (main/src/services/terminalPanelManager.test.ts:1090). - Security review found no new authorization, parsing, process-spawn, secret, or untrusted-deserialization surface.
⚠️ Cannot verify
- Interactive kitty graphics rendering in Electron still needs the manual
terminal-browserstreaming check described in the PR.
Checks: git diff --check origin/main...HEAD; pnpm --filter main exec vitest run src/services/terminalPanelManager.test.ts (36 passed).
Member
Author
Review, simplify, and refactor completeReview
SimplifyCommit:
RefactorCommit:
Tests
Follow-ups
Left for Parsa
Pushed head: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Bounds a string on the PTY data path that could grow until V8 refused to extend it, killing the main process. One-line production fix; the rest is the evidence for it.
Pane died with an unrecoverable main-process crash while a game was streaming through
terminal-browser:Building
main/with the repo's owntscconfig puts an exact line and column match on that frame:Root cause
terminal.currentCommandreconstructs the command line the user typed by scraping echoed PTY output, so it sees every byte a program prints. It resets only when a chunk contains CR or LF, and nothing capped it:Kitty graphics frames contain neither character. The payload is base64 — an alphabet with no CR or LF — wrapped in APC sequences, and a full-screen TUI positions its cursor with CSI rather than newlines. So a game streaming frames appended every one of them to the accumulator and never once reached the reset branch, until the string crossed V8's ~512M-character ceiling and
+=threw. A game is the worst case here: it re-renders continuously, so it gets there in minutes where a static page might never.Worth being precise about two things:
RangeError: Invalid string lengthis V8 refusing to build a string past a hard per-string ceiling. It fires with gigabytes of RAM still free, so "use less memory" would not have fixed it.datacan never be 512MB. The defect is unbounded accumulation across chunks, which is why the fix is a cap rather than chunking.Every other accumulator on this path was already bounded —
outputBufferflushes each chunk, andscrollbackBuffer,alternateScreenBufferandagentSessionScrapeBufferall run throughtrimAnsiSafeevery call.currentCommandwas the only one without a ceiling.The fix
Cap the accumulator at 8KB, keeping the tail, because what the user typed is always the newest bytes before Enter:
8KB is chosen to be far past any real command line while far below anything that
threatens a string limit.
ARG_MAXon macOS is 1MB, but that bounds an executedargv, not a line someone types at a prompt.
If it looks like this truncates short commands, it does not:
String.slice(-N)clamps to 0 and returns the whole string when it is shorter than
N, so thecommon path is a plain append. V8 returns the receiver for a full-range slice, so
there is no extra copy either.
Also bound
commandHistoryin memory to the 100 entries already applied when panel state is saved —getTerminalStatereturns that array in full, so a long-lived panel was growing it without limit.The production change is 12 lines.
Inline images still render
The cap sits on the scrape heuristic, which nothing draws. Image data reaches xterm through
outputBufferon a path this PR does not touch, so kitty, sixel and iTerm2 frames render exactly as they did before. There is a test asserting the streamed bytes arrive at the renderer intact.Relationship to #486
terminalPanelManager.tswas last modified in #438, so the inline-image work did not introduce this. What #486 changed is reachability: it enabled the kitty protocol that image-streaming tools need, which is what put a newline-free multi-hundred-megabyte stream through this handler in the first place. The bug is older than the PR that exposed it.Type of Change
Checklist
pnpm typecheckandpnpm lintlocallypnpm electron-dev— see Testing notesCritical Areas Modified
pty.onDatapath. Deliberate and necessary: the crash is on this path. The only behavior change is that a string that previously grew without limit now stops at 8KB; no bytes are added, dropped or reordered on the render path.currentCommandfeedslastActiveCommandin persisted panel state andgetTerminalSnapshot, so both are now bounded too. That was a real secondary problem: before this, a long graphics session was writing a multi-hundred-megabyte string into panel state and across IPC.Testing notes
Four regression tests in
main/src/services/terminalPanelManager.test.tsdrive the realpty.onDatalistener throughsetupTerminalHandlers:Verified as genuine regression tests: reverting the fix and re-running fails tests 1 and 4. Tests 2 and 3 pass either way by design — they are guarantees that the fix did not break streaming, not detectors of the original bug.
I did not add the "write a >256MB string to a PTY" test that would reproduce the crash end to end. It needs ~512MB to actually fire, which makes it minutes-long, memory-hungry and flaky, and it would be by far the slowest thing in the suite. The invariant that matters — the accumulator is bounded regardless of input volume — is provable in milliseconds and does fail on the unfixed code. Happy to add the heavyweight version behind an opt-in env guard if reviewers want it.
Manual test: run
terminal-browserin a pane, load something that repaints continuously, and confirm images still render and the app survives. I have not run this — it needs a real interactive Electron session, and the crash it exercises takes minutes of streaming to reproduce.Additional Notes
Known limitation, deliberately not fixed here. The design underneath is weak:
currentCommandinfers user input from program output, so anything that prints without newlines pollutes it. After this PR a graphics-streaming session will still eventually push ~8KB of base64 intocommandHistoryand fire a bogusterminal:command_executedevent when a newline finally arrives. That behavior exists today at 500MB scale — this PR bounds it rather than redesigning it. The real fix is to derive commands from the PTY write path, i.e. what the user actually types, instead of scraping the read path. That belongs in its own change, not smuggled into a crash fix.