Move terminal bytes out of panel state to stop the JSON OOM crash - #603
Conversation
Both Pane crashes on 2026-09-10 and 2026-09-11 were V8 heap OOMs inside JSON.stringify / JSON.parse of one tool_panels.state row that had grown to 428 MB: the pty output command-detection heuristic appended every newline-free chunk to lastActiveCommand, and updatePanel re-serialized the whole blob on every write. State now carries no terminal bytes. lastActiveCommand and commandHistory are dropped from TerminalPanelState; the in-memory accumulator is frozen in the alternate screen and reset past 4 KB. scrollbackBuffer, serializedBuffer and alternateScreenBuffer are split out of every state write into a new panel_buffers table capped at 4 MB per panel (oldest scrollback trimmed first, one logged warning). updatePanel merges keys inside SQLite with json_set / json_remove and refuses any state over 256 KB with a logged error naming the largest key. An idempotent startup migration backs up sessions.db, moves the buffers, removes the five keys and vacuums; on a copy of the live database it went from 1,816 MB to 176 MB in 16.5 s. Claude-Session: https://claude.ai/code/session_0138qD9Q32QSDNbJUBi7pRiZ
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. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c772bf0d23
ℹ️ 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".
| this.mergePanelState(panelId, state); | ||
| this.assertPanelStateWithinCeiling(panelId); |
There was a problem hiding this comment.
Keep logs state writable after reaching the new ceiling
When a run-script's last 1,000 output chunks exceed 256 KB, logsManager.handleOutput continues submitting the entire outputBuffer, so this assertion rolls back that write and every later state update, including the process-exit status. Since PanelManager.updatePanel absorbs the false result and the logs manager mutates its cached array before saving, the UI can continue while the database remains frozen with stale output and potentially isRunning: true; either move/bound the logs output by bytes or avoid applying this terminal-oriented ceiling to that state.
Useful? React with 👍 / 👎.
Description
Pane crashed twice (2026-09-10 21:41, 2026-09-11 11:11) opening the archived pane "PR 2164: durable run attention and device alerts". Both crashes are a V8 heap-limit OOM inside one JSON operation:
JSON.stringifyinupdatePanelon the first,JSON.parseof the same row on the second. The row was the Codex terminal panel26044081-3829-4eb1-a6e6-f86b37b4c010, whosetool_panels.statehad grown to 428 MB. Root cause report:~/.pane/reports/pane-oom-crash-2026-09-11.md.The defect: the pty output "command detection" heuristic in
terminalPanelManager.tsappended every output chunk without a newline tocurrentCommand. Full-screen TUIs never emit a newline in the alternate screen, so the accumulator grew for as long as the agent ran, and every state save persisted it aslastActiveCommand(pluscommandHistory).updatePanelthen parsed and re-serialized the whole blob on every write. Nothing ever read either key.This PR fixes the root cause rather than the symptom: panel state carries no terminal bytes, ever.
lastActiveCommandandcommandHistoryare gone fromTerminalPanelStateand both persist sites. The in-memorycurrentCommandis kept for file-operation detection, but no longer accumulates while the alternate screen is active, resets past 4 KB, and history entries are capped at 1 KB / 100 entries.panel_buffers(panel_id, scrollback, serialized, alternate, bytes, updated_at).scrollbackBuffer,serializedBufferandalternateScreenBufferare split out of every state write at the database layer (splitPanelBufferState) and stored there under a hard 4 MB per-panel cap across the three columns; the oldest scrollback is trimmed first, ANSI-safe, with one logged warning per panel per process. Readers of persisted bytes (restoreTerminalState, the lazy scrollback fallback inipc/panels.ts,panels screen/panels outputinipc/runpane.ts) read the table. The retention sweep deletes rows there instead ofjson_removeon state.updatePanelno longer parses or re-serializes the blob in JS. Keys are merged inside SQLite withjson_set/json_remove(same one-levelcustomStatemerge semantics as before: a key set toundefinedis removed). Any write whose resulting state exceeds 256 KB is rolled back, logged with the panel id, the size and the largest key, andupdatePanelreturnsfalse;createPanelthrows. ThepanelManagercache and thepanel:updatedpayload also stop carrying bytes.PRAGMA user_version, runs inDatabaseService.initialize()at module load, before any pane opens. It clonessessions.dbtosessions.db.pre-panel-buffers-<timestamp>.bakfirst (APFS clone, instant), unwraps legacy string-wrapped rows, moves the three buffer keys intopanel_buffersunder the cap,json_removes all five keys, thenVACUUMs. A fresh database is versioned without a backup or rewrite. The bootstrap logger reports before/after sizes.Migration proven on a copy of the live database
Run against an APFS clone of
~/.pane/sessions.db(never the live file), using the compiledmain/distcode:sessions.dbfiletool_panels.statetotalstaterow26044081…)26044081…statelastActiveCommand/commandHistorypanel_buffersrows / total / largestPRAGMA user_versionMigration plus VACUUM 16.5 s wall clock including the stats queries, peak RSS 1.4 GB (SQLite parsing the 428 MB row in its own heap, not V8's),
PRAGMA integrity_checkok, a partialupdatePanelon the repaired big panel accepted, secondinitialize()on the same copy a no-op with no second backup.The migration was not run against the live database. Pane must be restarted for the startup migration to apply; that is the operator's action after merge. Expect roughly 15 s of extra startup time on that first launch plus a 1.8 GB
.baknext to the database.Type of Change
Testing
New Vitest coverage (
pnpm --filter main test):terminalPanelManager.persistence.test.ts— (a) streams 50 MB of newline-free alternate-screen frames through the realpty.onDatahandler via a fake ptyHost handle; persisted state stays under the ceiling andcurrentCommandunder 4 KB; a normal-screen variant proves the 4 KB reset. (d) persists a terminal, restarts the manager, and asserts the replayed bytes equal what the old path replayed from the state JSON, in both normal and alternate screen.panelBuffers.test.ts— (b) a 300 KB state write is refused and logged with the panel, size andcustomState.initialInput (… bytes); a 200 KB write succeeds; nothing from a refused write lands. (c) 5 MB of scrollback persists as 4 MB, oldest trimmed, newest kept, one warning; a second oversize write stays quiet. Plus per-key merge semantics, legacy array scrollback, delete cascades and the retention sweep.panelBufferMigration.test.ts— (e) a fixture with a 30 MBlastActiveCommandrow (plus a string-wrapped legacy row, a logs row and a NULL-state row) migrates through the productioninitialize()entry point: state under 10 KB, buffers intact, backup written once, file shrinks below 1 MB, second run a no-op.database.panel-loading.test.tsand tworunpane.test.tscases updated to the new contract (bytes come from the store, not state).main/src/test/setup.tsnow pointsPANE_DIRat a scratch directory for every test run: importingservices/databaseopens${PANE_DIR}/sessions.dband runs the startup migrations at import time, so a localvitestcould otherwise touch the developer's live~/.pane.Gates:
pnpm lint(oxlint, eslint, advisory, boundary conformance, knip) clean;pnpm typecheckclean;pnpm --filter main test958 passed, 2 skipped; frontendterminalRestore.test.tspasses.Not done: no manual run of the packaged app against the live database, by design.
Critical Areas Modified
panel:updatedno longer carries terminal bytes; renderer restore still goes throughterminal:getState, unchanged)Additional Notes
restoreTerminalStatehas no callers in the repo (unchanged since the initial release); it is updated and covered because the brief names it as a restore path.getPanelsForSession(sessionId, includeScrollback)lost its second parameter: with bytes out of the state row, one query serves summaries and full reads.panel_buffers.panel_idreferencestool_panels(id) ON DELETE CASCADE; this SQLite build enforces foreign keys, so rows go with their panel and no manual cleanup is needed.panels:updatefrom the renderer),updatePanelreturningfalseinstead of throwing (a refused write must not abortpanels:initialize), the synchronous VACUUM (as briefed), and the write-only in-memorycommandHistory(as briefed; it could be deleted in a follow-up).https://claude.ai/code/session_0138qD9Q32QSDNbJUBi7pRiZ