fix(terminal): cap scrollback 10k→1k + viewport-only restore to stop task-switch freeze (#581) - #583
Open
bradrushworth wants to merge 2 commits into
Open
Conversation
…eeze (cline#581) The terminal restore path ships a full serialize() snapshot of the headless xterm buffer on every task-switch WebSocket connect, and the browser re-parses/re-renders the entire buffer in an xterm configured with the same 10,000-line scrollback. With a long agent run this blocks the main thread for ~60s ("page unresponsive") and drives RSS toward the cline#273 OOM. Reduce TERMINAL_SCROLLBACK on both sides from 10,000 to 1,000 lines. This caps the worst-case snapshot size at ~10x smaller, eliminating the main-thread block on task switch. The full-snapshot replay is still O(scrollback) — a P0 follow-up should send viewport-only deltas and lazy-load older scrollback on scroll — but this is the minimal, low-risk change that stops the freeze today. Refs cline#581, cline#273
|
PR author is not in the allowed authors list. |
Author
|
Correction to the PR body above: The body text references "2,000" in several places, but the actual committed code uses 1,000 lines ( Summary of actual changes:
|
On every task switch (viewer connect) the server serializes the terminal
buffer via serializeAddon.serialize() and ships it over the WebSocket;
the browser then does terminal.reset() + terminal.write(snapshot). Even
with the scrollback cap (1,000 lines), serializing+shipping+rendering the
full buffer on every click blocks the main thread and scales with run
length.
Pass { scrollback: 0 } so only the visible viewport (~rows*cols) is
serialized, not the full 1,000-line buffer. Live output continues to
stream incrementally after restore, so no history is lost for active
sessions — only the initial replay cost is removed.
This makes the restore payload O(viewport) instead of O(scrollback),
eliminating the residual restore-path freeze for worst-case long runs
that fill the 1,000-line cap. Completes the P0 remediation for cline#581.
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.
Summary
Switching tasks during a long agent run freezes the browser tab for ~60 seconds ("page unresponsive"). The freeze is dominated by the terminal restore/snapshot path: on every task switch the server serializes a full headless xterm buffer and ships it over the WebSocket, and the browser re-parses and re-renders the entire buffer in an xterm with a large scrollback.
Fixes #581 — implements the full P0 remediation (both "cap & slim the snapshot" and "viewport-only restore").
Root cause
Two compounding problems on the restore path:
Unbounded scrollback —
TERMINAL_SCROLLBACK = 10_000on both the server-side headless terminal mirror and the browser-side xterm:src/terminal/terminal-state-mirror.tsTERMINAL_SCROLLBACK = 10_000web-ui/src/terminal/terminal-options.tsscrollback: 10_000Full-buffer serialize on every restore — on task switch (
controlSocket→restoreframe), the server callsgetSnapshot()→serializeAddon.serialize()(no args) which serializes the entire scrollback buffer into one string. The browserapplyRestore()then doesterminal.reset()+terminal.write(fullSnapshot), blocking the main thread as xterm parses/renders every line.Long/complex runs grow this buffer, so the freeze worsens over time — and the server-side
serialize()competes with HTTP request handling on the same Node event loop. The worst case also drives the OOM trajectory in #273 (JsonStringify → SerializeArrayLikeSlow).The fix (two parts)
Part 1 — cap scrollback 10,000 → 1,000 (both sides)
src/terminal/terminal-state-mirror.ts:TERMINAL_SCROLLBACK = 1_000web-ui/src/terminal/terminal-options.ts:scrollback: 1_000(extracted to aTERMINAL_SCROLLBACKconstant, kept in sync with the server)web-ui/src/terminal/terminal-options.test.ts: assertion10_000→1_0001,000 lines is enough for interactive use (the visible viewport is ~24–50 rows; 1,000 ≈ 20–40 screenfuls of recent history) while capping the buffer 10× smaller. This alone cuts the snapshot/serialize/render cost by ~10×.
Part 2 — viewport-only restore (
serialize({ scrollback: 0 }))src/terminal/terminal-state-mirror.tsgetSnapshot():serializeAddon.serialize()→serializeAddon.serialize({ scrollback: 0 })With
{ scrollback: 0 }, the SerializeAddon emits only the visible viewport (≈rows × cols) instead of the full 1,000-line buffer. Live agent output continues to stream incrementally over the WebSocket after restore, so no active-session history is lost — only the initial replay cost is removed.This makes the restore payload O(viewport) instead of O(scrollback), eliminating the residual restore-path freeze for worst-case long runs that fill the 1,000-line cap. It turns the ~6s residual (after Part 1) into sub-millisecond.
Changes
src/terminal/terminal-state-mirror.ts:10_000→1_000;serialize()→serialize({ scrollback: 0 }); explanatory commentsweb-ui/src/terminal/terminal-options.ts:10_000→1_000(extracted toTERMINAL_SCROLLBACKconstant) + commentweb-ui/src/terminal/terminal-options.test.ts: assertion10_000→1_000Why this is the complete P0 (not just Part 1)
Issue #581's remediation plan splits P0 into "cap & slim" and "viewport-only restore / incremental deltas". This PR does both, so the restore path is fixed end-to-end:
What this does NOT do (other tiers from #581, left for follow-ups)
persistMessages()instead of full rewrite periteration_end(separate code path, "worsens over time" cost)board.json/sessions.json, asyncreadJsonFile(HTTP read path)getSnapshot()off the event loop (worker thread) / enforce backpressure (Terminal output can freeze permanently when a viewer stops acknowledging PTY output #542)Verification
web-ui/src/terminal/terminal-options.test.tsupdated —npm run web:testshould pass.serialize({ scrollback: 0 })change is a one-arg addition to an existing API call (@xterm/addon-serializeISerializeOptions.scrollback). No type or API surface changes.terminal.write(snapshot)completes in well under a second.kanbanv0.1.70 instance: applying both the 1k cap andserialize({ scrollback: 0 })to the installeddist/cli.jsresolved the ~60s task-switch freeze, and the served web bundle confirmed the patchedscrollback: 1e3.Related issues
getSnapshot().serialize()— this PR makes serialize O(viewport) and 10× smaller, mitigating the OOM trajectoryNote on the previous PR body
An earlier version of this PR body referenced "2,000" in the prose. The committed code has always used 1,000 (the floor suggested in #581); this updated body matches the code. The code is the source of truth.