Skip to content

fix(terminal): cap scrollback and cache/chunk restore (fixes #581) - #596

Open
TyroneNel wants to merge 5 commits into
cline:mainfrom
TyroneNel:fix/terminal-restore-perf-581
Open

fix(terminal): cap scrollback and cache/chunk restore (fixes #581)#596
TyroneNel wants to merge 5 commits into
cline:mainfrom
TyroneNel:fix/terminal-restore-perf-581

Conversation

@TyroneNel

@TyroneNel TyroneNel commented Aug 18, 2026

Copy link
Copy Markdown

Context

When a user switches tasks, the server sends a restore snapshot of the headless terminal. The browser writes that snapshot in one step. A 10,000-line buffer blocked the UI for about 60 seconds. The same path also grew RSS toward the OOM in #273.

#583 caps the buffer at 1,000 lines. A later commit on that PR uses serialize({ scrollback: 0 }). That call keeps only the current screen. It removes lines above the viewport.

Decision

Keep the 1,000-line cap from #583.

Do not ship viewport-only serialize({ scrollback: 0 }). Restore still sends the last 1,000 lines.

Add three controls:

  1. Dirty cache. The server serializes the 1,000-line buffer only after output or resize. Later connections reuse the cached snapshot.
  2. Chunked apply. The browser writes the snapshot in slices of about 16 KiB.
  3. Restore generation. Each new PTY start increases restoreGeneration. If this viewer already applied that generation at the same cols and rows, it does not reset and it does not write again. It still sends restore_complete. If the server omits restoreGeneration, the viewer always applies the snapshot.

Parked terminals keep their sockets. A card switch on an already-open terminal does not restore. Restore runs on first open, on refresh, and after the socket dies.

Domain words

  • Restore snapshot — ANSI string of the 1,000-line buffer.
  • Restore generation — integer that increases when that task PTY starts.
  • Dirty — output or resize occurred after the last cached snapshot.
  • Warm skip — this viewer already applied this generation at this cols and rows. It must not reset. It must not write the snapshot. It must still send restore_complete.

Consequences

The last 1,000 lines stay scrollable. The UI does not block on a 10,000-line write. A warm client that already has the current PTY does not reset.

This PR does not add two-phase restore, lazy-load on scroll, a worker-thread serialize, or a raw PTY ring.

Test plan

Automated:

  • test/runtime/terminal/terminal-state-mirror.test.ts — snapshot includes lines above the viewport. A second call with no output reuses the cache. Output or resize rebuilds the snapshot.
  • web-ui restore-snapshot-chunks — empty input returns no parts. One byte over 16 KiB returns two parts. Join of parts equals the input.
  • web-ui restore-generation — skip only when generation, cols, and rows match.
  • ws-server and session-manager — restore includes generation. The same session keeps that generation. A new PTY increases it.
  • npm run check — biome, typecheck, and tests. Two integration files timed out when they ran in parallel with the web-ui suite. They passed when they ran alone.
  • npm --prefix web-ui typecheck and tests
  • npm run build

Manual (after merge or local run):

  1. Start a long task. Open it. Scroll the last 1,000 lines.
  2. Switch cards. There is no flash if that terminal was already open.
  3. Kill the WebSocket. Reconnect. There is no freeze. The screen is not stale if the PTY has the same generation.
  4. Refresh. The last 1,000 lines replay in chunks. There is no hang of about 60 seconds.
  5. Start a new PTY. The viewer applies a full restore because the generation changed.
  6. Open a second browser tab on the same task. That tab does a cold restore from the server cache.

Fixes #581
Related: #583, #273

bradrushworth and others added 5 commits August 18, 2026 18:10
…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
Serialize the full 1k headless buffer on demand, then reuse that
snapshot until applyOutput or resize marks it dirty. Connect no
longer pays serialize on every viewer if the PTY has not changed.

The snapshot still includes lines above the viewport. Do not use
serialize({ scrollback: 0 }).
Split the full 1k snapshot into ~16 KiB chunks and enqueue each write
so xterm parses restore on a budget instead of one huge write.
restore_complete still waits until the write queue drains.
Start of a PTY bumps restoreGeneration. Control reconnects send that
id with the cached snapshot. A viewer that already applied the same
generation at the same cols x rows sends restore_complete without
reset or rewrite. Old servers that omit the field still apply.
The client buffer is room for the last 1k restore lines. Restore
payload size comes from the cached server serialize, not from keeping
two constants in lockstep.
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

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.

Web UI freezes ~60s (page unresponsive) when switching tasks during a long agent run (terminal snapshot + 10k scrollback on main thread)

2 participants