Skip to content

feat(terminal): trim scrollback of hidden SSH terminals after cold-park delay - #8655

Closed
iridite wants to merge 1 commit into
stablyai:mainfrom
iridite:fix/ssh-hidden-terminal-scrollback-trim
Closed

iridite wants to merge 1 commit into
stablyai:mainfrom
iridite:fix/ssh-hidden-terminal-scrollback-trim

Conversation

@iridite

@iridite iridite commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

SSH PTYs are excluded from hidden-view parking (canParkTerminalTabRenderer() returns false for any ptyId that matches parseAppSshPtyId()) because parking relies on a daemon snapshot for re-hydration and SSH PTYs have no such snapshot in the current phase.

As a result their xterm.js instances — and the full scrollback buffers they hold — remain in the renderer heap for as long as the tab stays hidden. At the default 5 000-row limit each SSH terminal occupies roughly 20–25 MB; a workflow with five or more SSH tabs can push the renderer heap past 100–250 MB without any unhandled rejections or other visible errors.

Log analysis from a production session (issue #8652) shows the renderer heap growing from 23 MB to 257 MB over 3 h in a session with zero unhandled rejections and terminalHiddenViewParking enabled — confirming the parking exclusion as the dominant residual leak.

Solution

Mirror the existing cold-park hysteresis (30 s, TERMINAL_TAB_COLD_PARK_DELAY_MS) with a matching scrollback-trim timer:

  • After a tab has been hidden for ≥ 30 s all SSH panes have their xterm.js scrollback trimmed to SSH_HIDDEN_SCROLLBACK_TRIM_ROWS = 200 rows. xterm.js applies this synchronously by trimming the circular buffer in place, freeing cell data immediately without discarding the process or terminal instance.
  • When the tab is revealed the buffer limit is restored to the user's configured value so agent output can accumulate normally again.
  • A quick tab flip (hide → show in < 30 s) never trims, matching how cold-park avoids parking transient hides.

200 rows is intentionally generous — enough to cover a full viewport plus a short scroll — while releasing > 95 % of the accumulated history that agent runs fill.

Files changed

File Change
terminal-ssh-scrollback-trim.ts New module: trimHiddenSshScrollback, restoreHiddenSshScrollback (pure helpers, testable without React), useHiddenSshScrollbackTrim hook
terminal-ssh-scrollback-trim.test.ts Unit tests: trim/restore helpers + timer behaviour (reveal cancel, re-trim after restore, unmount cleanup)
use-terminal-pane-lifecycle.ts One import + a 6-line hook invocation after the existing scrollback-cap application

Expected outcome

In a session with 5 × SSH terminals each at 1 000-row scrollback the trimmed heap footprint per hidden terminal drops from ~5 MB to ~1 MB, reducing the total contribution of hidden terminals from ~25 MB to ~5 MB. At the previous 5 000-row default the saving is proportionally larger (~100 MB → ~5 MB for 5 terminals).

Related

Test plan

  • Open several SSH terminal tabs in ORCA
  • Switch to a different tab and wait > 30 s
  • Check heap in DevTools Memory → heap should shrink measurably after the 30 s delay
  • Switch back to the SSH tab → scrollback limit should be restored (new output fills the buffer normally)
  • Confirm a quick tab flip (hide → show in < 30 s) does NOT trim the buffer

ELI5

Hidden SSH terminals kept huge scrollback in memory forever. After a delay, Orca trims that scrollback so idle SSH tabs use much less RAM.

…rk delay

SSH PTYs are excluded from hidden-view parking because they have no
daemon snapshot for re-hydration.  Their xterm.js instances therefore
remain in the renderer heap indefinitely while hidden, scrollback
buffers intact.  At the user's configured limit (1k–5k rows) a typical
multi-tab SSH workflow grows the renderer heap to 100–250 MB; logs show
a session reaching 257 MB in under 3 h with zero unhandled rejections —
confirming the parking exclusion as the dominant residual leak (see
issue #8652).

This commit mirrors the cold-park hysteresis (30 s) with a matching
scrollback-trim timer:

* After a tab has been hidden for ≥30 s, all SSH panes have their
  xterm.js scrollback trimmed to SSH_HIDDEN_SCROLLBACK_TRIM_ROWS (200).
  xterm.js applies the trim synchronously, freeing the cell data
  immediately.

* When the tab is revealed the buffer is restored to the user's
  configured limit so agent output can accumulate normally again.

* A quick tab flip (hide→show in <30 s) never trims.

The logic lives in a new `useHiddenSshScrollbackTrim` hook (pure-
function helpers + timer effect) wired into `useTerminalPaneLifecycle`
alongside the existing scrollback-rows application path.  The unit
tests cover trim, restore, timer cancellation on re-reveal, re-trim
after restore, and unmount cleanup.

Closes #8652
@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds delayed scrollback trimming for hidden SSH terminal panes, restoring configured scrollback when panes become visible. The new hook is integrated into the terminal pane lifecycle with visibility, pane manager, transport, and scrollback configuration inputs. Tests cover SSH-only identification, trimming and restoration guards, delayed timers, cancellation, re-trimming, visibility changes, and unmount cleanup.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it misses required template sections like Summary, Screenshots, Testing checklist, AI Review Report, Security Audit, and Notes. Add the missing template sections and include the requested testing, cross-platform review, security audit, and notes details.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement delayed trimming to 200 rows, restore on reveal, quick-hide cancellation, and lifecycle integration as requested in #8652.
Out of Scope Changes check ✅ Passed The PR appears scoped to SSH scrollback trimming, tests, and lifecycle wiring, with no obvious unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is specific and matches the main change: delayed scrollback trimming for hidden SSH terminals.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f12787b7-1362-4aea-9e2c-026587feb40e

📥 Commits

Reviewing files that changed from the base of the PR and between 8662e5a and d3a8587.

📒 Files selected for processing (3)
  • src/renderer/src/components/terminal-pane/terminal-ssh-scrollback-trim.test.ts
  • src/renderer/src/components/terminal-pane/terminal-ssh-scrollback-trim.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts

Comment on lines +21 to +49
export function trimHiddenSshScrollback(
manager: TrimPaneManager,
paneTransports: Map<number, PtyIdProvider>,
trimRows: number
): void {
for (const pane of manager.getPanes()) {
const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
pane.terminal.options.scrollback = trimRows
}
}
}

export function restoreHiddenSshScrollback(
manager: TrimPaneManager,
paneTransports: Map<number, PtyIdProvider>,
configuredRows: number
): void {
for (const pane of manager.getPanes()) {
const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
// Only bump up — never reduce a scrollback that is already at or above
// the configured value (e.g. the pane was never trimmed).
if ((pane.terminal.options.scrollback ?? configuredRows) < configuredRows) {
pane.terminal.options.scrollback = configuredRows
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Trim can raise scrollback above the configured limit and get stuck there.

trimHiddenSshScrollback unconditionally sets scrollback = trimRows with no check against the pane's current value, while restoreHiddenSshScrollback only ever bumps scrollback up to configuredRows, never down. If a user's configured scrollback is below SSH_HIDDEN_SCROLLBACK_TRIM_ROWS (200), hiding the tab actually increases memory usage, and revealing it afterward never restores the smaller configured value — the pane is stuck at 200 for the rest of the session since restore's guard only fires when current < configuredRows.

Give trimHiddenSshScrollback the same directional guard as restore, so trim is a strict no-op when the pane is already at/below the target:

🐛 Proposed fix
 export function trimHiddenSshScrollback(
   manager: TrimPaneManager,
   paneTransports: Map<number, PtyIdProvider>,
   trimRows: number
 ): void {
   for (const pane of manager.getPanes()) {
     const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
     if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
-      pane.terminal.options.scrollback = trimRows
+      // Only reduce — a pane whose configured limit is already below
+      // trimRows must never be pushed up by "trimming".
+      if ((pane.terminal.options.scrollback ?? trimRows) > trimRows) {
+        pane.terminal.options.scrollback = trimRows
+      }
     }
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function trimHiddenSshScrollback(
manager: TrimPaneManager,
paneTransports: Map<number, PtyIdProvider>,
trimRows: number
): void {
for (const pane of manager.getPanes()) {
const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
pane.terminal.options.scrollback = trimRows
}
}
}
export function restoreHiddenSshScrollback(
manager: TrimPaneManager,
paneTransports: Map<number, PtyIdProvider>,
configuredRows: number
): void {
for (const pane of manager.getPanes()) {
const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
// Only bump up — never reduce a scrollback that is already at or above
// the configured value (e.g. the pane was never trimmed).
if ((pane.terminal.options.scrollback ?? configuredRows) < configuredRows) {
pane.terminal.options.scrollback = configuredRows
}
}
}
}
export function trimHiddenSshScrollback(
manager: TrimPaneManager,
paneTransports: Map<number, PtyIdProvider>,
trimRows: number
): void {
for (const pane of manager.getPanes()) {
const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
// Only reduce — a pane whose configured limit is already below
// trimRows must never be pushed up by "trimming".
if ((pane.terminal.options.scrollback ?? trimRows) > trimRows) {
pane.terminal.options.scrollback = trimRows
}
}
}
}
export function restoreHiddenSshScrollback(
manager: TrimPaneManager,
paneTransports: Map<number, PtyIdProvider>,
configuredRows: number
): void {
for (const pane of manager.getPanes()) {
const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null
if (ptyId !== null && parseAppSshPtyId(ptyId) !== null) {
// Only bump up — never reduce a scrollback that is already at or above
// the configured value (e.g. the pane was never trimmed).
if ((pane.terminal.options.scrollback ?? configuredRows) < configuredRows) {
pane.terminal.options.scrollback = configuredRows
}
}
}
}

Comment on lines +574 to +579
useHiddenSshScrollbackTrim({
managerRef,
paneTransportsRef,
isVisible,
configuredScrollbackRows: terminalScrollbackRows
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate applyTerminalScrollbackRowsToMountedPanes to check whether it is
# SSH-aware or unconditionally overwrites every mounted pane's scrollback.
rg -n "applyTerminalScrollbackRowsToMountedPanes" -A 15 src/renderer/src

Repository: stablyai/orca

Length of output: 9378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file structure, then inspect the hidden-scrollback hook and the nearby effects.
ast-grep outline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts --view expanded

printf '\n--- hook implementation ---\n'
sed -n '260,420p' src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts

printf '\n--- effect region ---\n'
sed -n '1920,1975p' src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts

Repository: stablyai/orca

Length of output: 9429


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the tests around hidden SSH scrollback to see whether this interaction is already covered.
sed -n '1,260p' src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts

Repository: stablyai/orca

Length of output: 8098


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for the hidden SSH scrollback hook and any trim/reschedule logic.
rg -n "useHiddenSshScrollbackTrim|scrollback.*trim|cold-park|trim" src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts

Repository: stablyai/orca

Length of output: 445


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/renderer/src/components/terminal-pane/terminal-ssh-scrollback-trim.ts --view expanded

printf '\n--- implementation ---\n'
cat -n src/renderer/src/components/terminal-pane/terminal-ssh-scrollback-trim.ts

Repository: stablyai/orca

Length of output: 6036


Keep hidden SSH panes out of the global scrollback update applyTerminalScrollbackRowsToMountedPanes still overwrites every mounted pane, so a hidden SSH pane trimmed to 200 gets restored to the full configured depth on a scrollback-setting change. useHiddenSshScrollbackTrim only reschedules its timeout in the hidden branch, so the pane stays untrimmed until that delay elapses again.

@iridite

iridite commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Field data update — v1.4.144 through v1.4.161, Jul 23–30

Heap growth continues; Jul 29 hit the highest peak we've recorded.

Daily heap stats (renderer_memory interval samples)

Date Samples Max Avg Growth/hr
Jul 23 285 159 MB 136 MB −1.4 MB/hr
Jul 24 660 133 MB 95 MB −0.9 MB/hr
Jul 25–26 ~230 117 MB 115 MB ~0 MB/hr
Jul 27 500 415 MB 217 MB +30.3 MB/hr
Jul 28 218 348 MB 162 MB +4.7 MB/hr
Jul 29 1191 607 MB 242 MB +25.9 MB/hr
Jul 30 (so far) 527 592 MB 174 MB −42.8 (due to restarts)

Jul 29 607 MB peak is the highest we've seen. Jul 25–26 are low because there were few active sessions.

Session load context

From Jul 29 terminal_safe_fit_retry_exhausted events, there are regularly 7–9 live panes (livePanes: 7, 8, 9) active simultaneously, with a mix of:

  • Local macOS worktrees
  • SSH Windows remote worktrees (350 sidebar activations Jul 23–30)

The SSH remote worktrees drive PTY scrollback buffers that are the primary leak source identified in this PR. With that many concurrent SSH panes, growth rates of 25–30 MB/hr align with the leak model.

Comparison to pre-fix baseline

Our earlier data (before PR #8279 merged): ~62 MB/hr with only macOS local sessions.
Now with SSH remote sessions heavily active: 26–30 MB/hr on high-traffic days.

The fix in this PR should bring that down significantly. Would appreciate a review when there's bandwidth.

@iridite

iridite commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Status update: relationship to PR #10625 (merged in v1.4.162)

PR #10625 shipped the official fix for Issue #8652 and is now in v1.4.162. Its approach is a fleet-level retention budget (slice B): hidden un-parkable worktrees — including remote-runtime and SSH-without-parking — are force-parked once the count exceeds 12 or the TTL hits 45 min. The PR explicitly notes that slice C (scrollback demotion/trim) was intentionally removed in favour of the worktree-count ceiling.

This PR (#8655) is complementary, not redundant:

Layer Mechanism What it controls
#10625 slice B force-park when fleet count > 12 or TTL > 45 min how many hidden SSH/remote worktrees stay mounted
#8655 trim scrollback of mounted hidden SSH panes after a cold-park delay how much memory each still-mounted pane holds in its scrollback buffer

With slice B in place, the number of mounted remote/SSH worktrees is now bounded. But the panes inside those worktrees — including local panes inside a remote worktree — are not scrollback-trimmed. On high-load days (Jul 27, Jul 29) the growth rate on our setup was 25–30 MB/hr with 7–9 live panes and 350 sidebar activations over the week; trimming the scrollback of hidden SSH panes is the next lever available.

If there's appetite for a narrower change than the full eviction approach in #7448, this PR is that change. Happy to rebase on main and address the CodeRabbit comments if it's useful.

@nwparker

nwparker commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closing as superseded — the linked issue(s) for this PR were fixed by another change that has already been merged.

Superseding merge: #10625

Thank you for the contribution! If anything remains unaddressed, please open a new issue or PR against current main.

@nwparker nwparker closed this Aug 3, 2026
@iridite
iridite deleted the fix/ssh-hidden-terminal-scrollback-trim branch August 13, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSH & remote-server PTY tabs excluded from hidden-view parking causes unbounded renderer heap growth

4 participants