fix(terminal): implement C1 retention budget for hidden parked worktrees

Addresses OOM regressions in hidden parked terminals by force-evicting
worktrees past a retention budget: at most 12 mounted while hidden, none
past 45 minutes (absolute, not exempted by last-active). Eviction is
least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep
their panes to avoid orphaning shells; worktrees are force-parked even
if they contain exempts, and their buffers released elsewhere. SSH/remote
worktrees serialize buffers pre-eviction for reveal; local worktrees keep
daemon snapshots. Command Code's done-settle window is transferred across
park/reveal boundaries so the row cannot strand at 'working'. Model probe
on SSH reattach is scoped to park-reveal only, not ordinary reconnects.
Includes new E2E suite proving the budget actually releases memory.
This commit is contained in:
Jinjing
2026-07-28 13:23:30 -07:00
parent 82bdec1373
commit 4cb259f377
35 changed files with 1524 additions and 186 deletions
+10 -1
View File
@@ -184,7 +184,9 @@ jobs:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
timeout-minutes: 35
steps:
- name: Checkout
@@ -227,6 +229,13 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why both specs in one runner: they share the docker SSH rig and the same
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
// restore, the retention spec proves the C1 budget bounds what they retain.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-terminal-parking.spec.ts',
'tests/e2e/terminal-retention-budget.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
+1
View File
@@ -98,6 +98,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
"test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs",
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
+39
View File
@@ -14461,6 +14461,45 @@ describe('registerPtyHandlers', () => {
)
})
it('seeds the headless emulator from an SSH relay reattach replay', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: 'pty-ssh-reattach',
isReattach: true,
replay: 'relay history\r\n'
})),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedHeadlessTerminal: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'handle-ssh-reattach'),
registerPreAllocatedHandleForPty: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
// Why: without this seed main's model would be a strict suffix of what the renderer painted, and a later park-reveal would restore the fragment.
expect(runtime.seedHeadlessTerminal).toHaveBeenCalledTimes(1)
expect(runtime.seedHeadlessTerminal).toHaveBeenCalledWith(
'pty-ssh-reattach',
'relay history\r\n'
)
})
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
+5
View File
@@ -5069,6 +5069,11 @@ export function registerPtyHandlers(
preferProviderIfExisting: true
}
)
} else if (typeof result.replay === 'string' && result.replay.length > 0) {
// Why: the relay reattach replay is the one restore main never ingests, so without
// this seed its model is a mere suffix of what the renderer painted — and a later
// park-reveal would outrank the fuller relay replay with that fragment.
runtime.seedHeadlessTerminal(result.id, result.replay)
}
}
if (
+27 -25
View File
@@ -93,10 +93,12 @@ import {
} from './terminal-pane/terminal-hidden-view-parking'
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
selectForceParkEvictableTabIds,
selectRetentionForceParkedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
} from './terminal-pane/terminal-hidden-worktree-retention'
import { captureTerminalShutdownBuffersBestEffort } from './terminal-pane/shutdown-buffer-captures'
import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-buffer-capture'
import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation'
import {
@@ -963,19 +965,6 @@ function Terminal(): React.JSX.Element | null {
watcherCoverageByTabId.set(tab.id, covered)
return covered
})
// Why memoized: same shape as coverage above — the retention pass asks this
// for every mounted worktree's tabs, and each call re-reads the store and can
// walk the layout tree.
const evictionExemptByTabId = new Map<string, boolean>()
const tabIsEvictionExempt = (worktreeId: string, tab: TerminalTab): boolean => {
const cached = evictionExemptByTabId.get(tab.id)
if (cached !== undefined) {
return cached
}
const exempt = isEvictionExemptTerminalTab(tab, worktreeId)
evictionExemptByTabId.set(tab.id, exempt)
return exempt
}
// Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt).
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) {
@@ -1013,7 +1002,6 @@ function Terminal(): React.JSX.Element | null {
parkCooldownUntilMs: candidate.parkCooldownUntilMs,
ordinaryParkingCovers:
parkEligible && worktreeTabsAreWatcherCovered(candidate.worktreeId, tabs),
hasEvictionExemptTab: tabs.some((tab) => tabIsEvictionExempt(candidate.worktreeId, tab)),
hasPendingSpawnWork: tabs.some(
(tab) =>
pendingStartupByTabId[tab.id] !== undefined ||
@@ -1036,23 +1024,37 @@ function Terminal(): React.JSX.Element | null {
capturedForceParked.delete(id)
}
}
const repos = useAppStore.getState().repos
for (const worktreeId of forceParkedWorktreeIds) {
if (!capturedForceParked.has(worktreeId)) {
capturedForceParked.add(worktreeId)
// Why: serialize buffers while the panes still exist (same registry sleep
// uses), so classes with no snapshot source still reveal last-known
// content. includeLocalBuffers:false is required here, not optional — a
// heap fix must not plant 512KB/pane of scrollback strings in the store
// for local worktrees that already have the daemon snapshot.
// uses), so SSH classes whose reveal has no local snapshot still show
// last-known content. includeLocalBuffers:false is required here, not
// optional — a heap fix must not plant 512KB/pane of scrollback strings
// in the store for local worktrees that already have the daemon
// snapshot; a remote-runtime pane in a local repo likewise needs none,
// its reveal repaints from the runtime's own subscribe snapshot.
// Eviction-exempt tabs never unmount (per-tab exclusion), so they need
// no pre-unmount capture — skipping spares a serialize+setTabLayout
// walk on live panes per force-park episode.
captureTerminalShutdownBuffersBestEffort(
(tabsByWorktree[worktreeId] ?? [])
.filter((tab) => !tabIsEvictionExempt(worktreeId, tab))
.map((tab) => tab.id),
{ includeLocalBuffers: false }
const worktreeTabs = tabsByWorktree[worktreeId] ?? []
const evictableTabIds = selectForceParkEvictableTabIds(worktreeTabs, (tab) =>
isEvictionExemptTerminalTab(tab, worktreeId)
)
// Why logged: an all-exempt force-park frees no heap at all (daemon
// fail-open makes every local pty exempt), so the budget only holds
// while this stays rare — make the degenerate case observable.
if (evictableTabIds.length === 0 && worktreeTabs.length > 0) {
warnTerminalLifecycleAnomaly('retention force-park freed no panes', {
worktreeId,
reason: `exemptTabs=${worktreeTabs.length}`
})
}
// Why conditional: a tab whose pane was mid-remount registered no
// capture callback, and a later episode is this pass's only retry.
if (captureForceParkedWorktreeBuffers({ worktreeId, tabIds: evictableTabIds, repos })) {
capturedForceParked.add(worktreeId)
}
}
nextParkedTerminalWorktreeIds.add(worktreeId)
}
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
COMMAND_CODE_OUTPUT_DONE_SETTLE_MS,
_resetCommandCodeDoneSettlesForTest,
cancelCommandCodeDoneSettle,
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
const OTHER_PANE_KEY = 'tab-2:22222222-2222-4222-8222-222222222222'
describe('command code done settle window', () => {
beforeEach(() => {
vi.useFakeTimers()
_resetCommandCodeDoneSettlesForTest()
})
afterEach(() => {
_resetCommandCodeDoneSettlesForTest()
vi.useRealTimers()
})
it('settles through the registered executor at the deadline', () => {
const settle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS - 1)
expect(settle).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(settle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
})
// Why: this is the park/reveal contract — the deadline is the turn's, not the owner's.
it('keeps the original deadline when the owner is swapped mid-window', () => {
const parkedWatcherSettle = vi.fn()
const releaseParkedWatcher = setCommandCodeDoneSettleExecutor(PANE_KEY, parkedWatcherSettle)
openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS - 100)
// Reveal: the remounted pane registers before the parked watcher releases.
const revealedPaneSettle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, revealedPaneSettle)
releaseParkedWatcher()
vi.advanceTimersByTime(100)
expect(parkedWatcherSettle).not.toHaveBeenCalled()
expect(revealedPaneSettle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
})
// Why identity-checked: a late release from the previous owner must not unregister the successor.
it('ignores a stale release once a successor has registered', () => {
const releaseFirst = setCommandCodeDoneSettleExecutor(PANE_KEY, vi.fn())
const successorSettle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, successorSettle)
releaseFirst()
openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
expect(successorSettle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
})
it('drops the window when a working repaint cancels it', () => {
const settle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
cancelCommandCodeDoneSettle(PANE_KEY)
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS * 2)
expect(settle).not.toHaveBeenCalled()
})
// Why: a pane torn down with no successor has no row worth completing.
it('fires harmlessly when every owner released before the deadline', () => {
const release = setCommandCodeDoneSettleExecutor(PANE_KEY, vi.fn())
openCommandCodeDoneSettle(PANE_KEY, 'Fix the spinner')
release()
expect(() => vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)).not.toThrow()
})
it('re-opening replaces the pending window rather than stacking a second one', () => {
const settle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
openCommandCodeDoneSettle(PANE_KEY, 'first turn')
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS - 100)
openCommandCodeDoneSettle(PANE_KEY, 'second turn')
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
expect(settle).toHaveBeenCalledExactlyOnceWith('second turn')
})
it('scopes windows per pane key', () => {
const settle = vi.fn()
const otherSettle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, settle)
setCommandCodeDoneSettleExecutor(OTHER_PANE_KEY, otherSettle)
openCommandCodeDoneSettle(PANE_KEY, 'mine')
openCommandCodeDoneSettle(OTHER_PANE_KEY, 'theirs')
cancelCommandCodeDoneSettle(PANE_KEY)
vi.advanceTimersByTime(COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
expect(settle).not.toHaveBeenCalled()
expect(otherSettle).toHaveBeenCalledExactlyOnceWith('theirs')
})
})
@@ -0,0 +1,61 @@
/**
* Cross-mount owner of Command Code's done-settle window.
*
* Why: park unmounts the pane and reveal disposes the parked watcher, both
* possibly mid-settle, and neither successor can re-observe the idle composer
* that opened the window. Owning the deadline here means park cannot strand the
* row at 'working' and reveal need not complete the turn early; only the row
* write (routing + title slot) stays with whoever currently owns the pane.
*/
// Mirrors the mounted and parked policies: the settle window must be identical
// whether the pane is mounted or parked, or park/reveal changes completion timing.
export const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
type CommandCodeDoneSettleExecutor = (normalizedPrompt: string) => void
const executorByPaneKey = new Map<string, CommandCodeDoneSettleExecutor>()
const timerByPaneKey = new Map<string, ReturnType<typeof setTimeout>>()
/** Registers the current writer for this pane; returns its release. */
export function setCommandCodeDoneSettleExecutor(
paneKey: string,
execute: CommandCodeDoneSettleExecutor
): () => void {
executorByPaneKey.set(paneKey, execute)
return () => {
// Why identity-checked: on reveal the remounted pane registers before the parked watcher releases.
if (executorByPaneKey.get(paneKey) === execute) {
executorByPaneKey.delete(paneKey)
}
}
}
export function openCommandCodeDoneSettle(paneKey: string, normalizedPrompt: string): void {
cancelCommandCodeDoneSettle(paneKey)
timerByPaneKey.set(
paneKey,
setTimeout(() => {
timerByPaneKey.delete(paneKey)
// Why optional: a pane torn down with no successor has no row worth completing.
executorByPaneKey.get(paneKey)?.(normalizedPrompt)
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
)
}
export function cancelCommandCodeDoneSettle(paneKey: string): void {
const timer = timerByPaneKey.get(paneKey)
if (timer !== undefined) {
clearTimeout(timer)
timerByPaneKey.delete(paneKey)
}
}
/** Test-only: drops every pending window so suites cannot leak timers across cases. */
export function _resetCommandCodeDoneSettlesForTest(): void {
for (const timer of timerByPaneKey.values()) {
clearTimeout(timer)
}
timerByPaneKey.clear()
executorByPaneKey.clear()
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RepoConnection } from '../../../../shared/workspace-session-terminal-buffers'
import { captureForceParkedWorktreeBuffers } from './force-park-buffer-capture'
import { shutdownBufferCaptures } from './shutdown-buffer-captures'
const LOCAL_REPO: RepoConnection = {
id: 'repo',
connectionId: null,
executionHostId: 'local'
}
const SSH_REPO: RepoConnection = {
id: 'repo',
connectionId: 'conn-1',
executionHostId: null
}
afterEach(() => {
shutdownBufferCaptures.clear()
})
describe('captureForceParkedWorktreeBuffers', () => {
it('skips the capture for a local-repo worktree so a stored buffer survives force-park', () => {
const capture = vi.fn()
shutdownBufferCaptures.set('tab-1', capture)
const captured = captureForceParkedWorktreeBuffers({
worktreeId: 'repo::/repo/worktree',
tabIds: ['tab-1'],
repos: [LOCAL_REPO]
})
expect(captured).toBe(true)
expect(capture).not.toHaveBeenCalled()
})
it('captures remote worktree tabs without local buffers and reports full coverage', () => {
const capture = vi.fn()
shutdownBufferCaptures.set('tab-1', capture)
const captured = captureForceParkedWorktreeBuffers({
worktreeId: 'repo::/repo/worktree',
tabIds: ['tab-1'],
repos: [SSH_REPO]
})
expect(captured).toBe(true)
expect(capture).toHaveBeenCalledWith({ includeLocalBuffers: false })
})
it('reports an incomplete episode when a tab has no registered capture', () => {
shutdownBufferCaptures.set('tab-1', vi.fn())
const captured = captureForceParkedWorktreeBuffers({
worktreeId: 'repo::/repo/worktree',
tabIds: ['tab-1', 'tab-mid-remount'],
repos: [SSH_REPO]
})
expect(captured).toBe(false)
})
})
@@ -0,0 +1,29 @@
import {
shouldPreserveTerminalScrollbackBuffers,
type RepoConnection
} from '../../../../shared/workspace-session-terminal-buffers'
import { captureTerminalShutdownBuffersBestEffort } from './shutdown-buffer-captures'
type ForceParkedWorktreeCaptureArgs = {
worktreeId: string
tabIds: readonly string[]
repos: readonly RepoConnection[]
}
/** Serialize a force-parked worktree's panes before eviction unmounts them.
* Returns whether the episode covered every tab; false leaves it unmarked so a later episode retries. */
export function captureForceParkedWorktreeBuffers({
worktreeId,
tabIds,
repos
}: ForceParkedWorktreeCaptureArgs): boolean {
// Why skip local worktrees: includeLocalBuffers:false serializes nothing for them, so the only
// effect left is setTabLayout replacing away a stored buffer (e.g. an exited setup pane's output).
if (!shouldPreserveTerminalScrollbackBuffers(worktreeId, repos)) {
return true
}
const { requested, captured } = captureTerminalShutdownBuffersBestEffort(tabIds, {
includeLocalBuffers: false
})
return captured === requested
}
@@ -175,28 +175,50 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
policy.dispose()
})
// Why: reveal disposes the watcher mid-settle; cancelling would strand the row at
// 'working' because the remounted detector never re-observes the idle composer.
it('dispose flushes a pending done settle instead of stranding a working row', async () => {
// Why: reveal disposes the watcher mid-settle. Flushing would complete the turn early
// and the same-prompt-done guard then drops the genuine working repaint, so the window
// transfers to the remounted pane instead — the row stays 'working' across the boundary.
it('dispose hands the pending settle to the remounted pane instead of completing it early', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'working',
prompt: 'Fix the spinner',
agentType: 'command-code'
})
const policy = await createPolicy(PTY_ID_LOCAL)
const { setCommandCodeDoneSettleExecutor } = await import('./command-code-done-settle')
policy.onCommandCodeDone('Fix the spinner')
vi.advanceTimersByTime(DONE_SETTLE_MS - 1)
const revealedPaneSettle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, revealedPaneSettle)
policy.dispose()
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
PANE_KEY,
{ state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' },
'✳ Build feature',
undefined,
ROUTING
)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(revealedPaneSettle).toHaveBeenCalledExactlyOnceWith('Fix the spinner')
})
// Why: the transferred window is still a settle window — a working repaint from the
// revealed pane before the original deadline must cancel it, not race it.
it('lets a working repaint after the handoff cancel the transferred settle', async () => {
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({
state: 'working',
prompt: 'Fix the spinner',
agentType: 'command-code'
})
const policy = await createPolicy(PTY_ID_LOCAL)
const { cancelCommandCodeDoneSettle, setCommandCodeDoneSettleExecutor } =
await import('./command-code-done-settle')
policy.onCommandCodeDone('Fix the spinner')
const revealedPaneSettle = vi.fn()
setCommandCodeDoneSettleExecutor(PANE_KEY, revealedPaneSettle)
policy.dispose()
cancelCommandCodeDoneSettle(PANE_KEY)
vi.advanceTimersByTime(DONE_SETTLE_MS * 2)
expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1)
expect(revealedPaneSettle).not.toHaveBeenCalled()
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
})
it('dispose does not flush a settle a working repaint already cancelled', async () => {
@@ -10,10 +10,11 @@ import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-f
import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership'
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
import { useAppStore } from '@/store'
// Mirrors pty-connection.ts COMMAND_CODE_OUTPUT_DONE_SETTLE_MS — the settle window must be
// identical whether the pane is mounted or parked, or park/reveal changes completion timing.
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
import {
cancelCommandCodeDoneSettle,
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
export type ParkedTerminalCommandStatusPolicy = {
onCommandFinished: (bestEffortExitCode: number | null) => void
@@ -44,17 +45,7 @@ export function createParkedTerminalCommandStatusPolicy(options: {
}): ParkedTerminalCommandStatusPolicy {
const { ptyId, worktreeId, tabId, paneId, paneKey } = options
let disposed = false
let commandCodeOutputDoneTimer: ReturnType<typeof setTimeout> | null = null
// Non-null exactly while a done-settle timer pends; lets dispose flush the settle.
let pendingCommandCodeDonePrompt: string | null = null
const clearCommandCodeOutputDoneTimer = (): void => {
if (commandCodeOutputDoneTimer !== null) {
clearTimeout(commandCodeOutputDoneTimer)
commandCodeOutputDoneTimer = null
}
pendingCommandCodeDonePrompt = null
}
const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(paneKey)
const resolveRouting = (): ReturnType<typeof resolveLiveAgentStatusConnectionRouting> => {
if (disposed) {
@@ -96,8 +87,7 @@ export function createParkedTerminalCommandStatusPolicy(options: {
state.dropAgentStatus(paneKey)
}
// Shared by the settle timer and the dispose flush: complete the row only while it is
// still this turn's command-code/working entry.
// Complete the row only while it is still this turn's command-code/working entry.
const settleCommandCodeDone = (normalizedPrompt: string): void => {
const routing = resolveRouting()
if (!routing) {
@@ -126,6 +116,15 @@ export function createParkedTerminalCommandStatusPolicy(options: {
)
}
// Why the shared window: reveal disposes this watcher mid-settle and the remounted pane
// cannot re-observe the already-passed idle composer, so the deadline outlives whichever
// side owns the row — cancelling would strand 'working', flushing would complete the turn
// unrecoverably early (the same-prompt-done guard then drops a genuine working repaint).
const releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor(
paneKey,
settleCommandCodeDone
)
return {
onCommandFinished: (): void => {
if (disposed) {
@@ -179,32 +178,19 @@ export function createParkedTerminalCommandStatusPolicy(options: {
// Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering
// the composer while tools run, so only complete the row if no active repaint arrives.
onCommandCodeDone: (prompt: string): void => {
clearCommandCodeOutputDoneTimer()
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(paneKey)
return
}
pendingCommandCodeDonePrompt = normalizedPrompt
commandCodeOutputDoneTimer = setTimeout(() => {
commandCodeOutputDoneTimer = null
pendingCommandCodeDonePrompt = null
if (disposed) {
return
}
settleCommandCodeDone(normalizedPrompt)
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
openCommandCodeDoneSettle(paneKey, normalizedPrompt)
},
dispose: (): void => {
// Why flush, not cancel: reveal disposes mid-settle and the remounted detector cannot
// re-observe the already-passed idle composer, so cancelling strands the row at
// 'working'. A rare premature 'done' (tool still running behind the composer) only
// shortens the settle window; the turn ends at 'done' anyway.
const pendingPrompt = disposed ? null : pendingCommandCodeDonePrompt
clearCommandCodeOutputDoneTimer()
if (pendingPrompt !== null) {
settleCommandCodeDone(pendingPrompt)
}
// Why release, not flush: the settle window belongs to the turn and outlives this
// watcher, so the remounted pane inherits the remaining deadline and a working
// repaint arriving before it still cancels the completion.
releaseCommandCodeDoneSettleExecutor()
disposed = true
}
}
@@ -53,6 +53,18 @@ async function drainFakeTimerWork(limit = 20): Promise<void> {
vi.clearAllTimers()
}
// Why: a reveal remount reads the still-live park watcher entry at connect time to
// tell itself apart from an in-place reattach; the host disposes it a beat later.
async function parkTabForReveal(tabId: string, ptyId: string): Promise<void> {
const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry')
parkedWatchersByTabId.set(tabId, {
worktreeId: 'wt-1',
tabPtyId: ptyId,
paneIdByPtyId: new Map([[ptyId, 1]]),
disposersByPtyId: new Map([[ptyId, () => {}]])
})
}
async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise<void> {
let iterations = 0
while (pendingTimeouts.length > 0) {
@@ -19866,10 +19878,13 @@ describe('connectPanePty', () => {
const transport = createMockTransport(sshPtyId)
transport.connect.mockImplementation(async () => {
transport.getPtyId.mockReturnValue(sshPtyId)
return { id: sshPtyId, replay: 'relay-fallback-output' }
return { id: sshPtyId, isReattach: true, replay: 'relay-fallback-output' }
})
transportFactoryQueue.push(transport)
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {}))
// Why parked: only a reveal remount probes main's model — the pane reads the
// still-live park watcher entry at connect time to tell the two apart.
await parkTabForReveal('tab-1', sshPtyId)
mockStoreState = {
...mockStoreState,
@@ -19903,6 +19918,42 @@ describe('connectPanePty', () => {
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledTimes(1)
})
// Why: the model probe exists to beat the relay's 100KiB tail on a park-reveal.
// An ordinary reattach (network reconnect, wake, reload) already holds that
// replay, so probing would only delay its paint by the timeout.
it('paints the relay replay without probing main when the reattach did not follow a park', async () => {
const { connectPanePty } = await import('./pty-connection')
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1')
const transport = createMockTransport(sshPtyId)
transport.connect.mockImplementation(async () => {
transport.getPtyId.mockReturnValue(sshPtyId)
return { id: sshPtyId, isReattach: true, replay: 'relay-reconnect-output' }
})
transportFactoryQueue.push(transport)
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {}))
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
deferredSshReconnectTargets: ['conn-1'],
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
}
const pane = createPane(1)
const { writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
expect(writes).toContain('relay-reconnect-output')
})
it('does not auto-reconnect after a user cancels deferred SSH passphrase auth', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -255,6 +255,12 @@ import {
shouldFetchSshReattachModelSnapshot
} from './ssh-reattach-model-restore'
import { readInFlightCommandCodeTurn } from './parked-terminal-command-status'
import {
cancelCommandCodeDoneSettle,
openCommandCodeDoneSettle,
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
import {
getExecutionHostIdForWorktree,
getSettingsForWorktreeRuntimeOwner
@@ -315,7 +321,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000
const REMOTE_PTY_ID_PREFIX = 'remote:'
const PTY_CONNECT_DIAG_LIMIT = 200
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096
const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
@@ -1026,6 +1031,10 @@ export function connectPanePty(
// settles after remount must not remount its already-replaced successor.
const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId)
const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId)
// Why sampled here: the host disposes this tab's park watcher in the effect
// that follows this mount, so connect time is the only moment a pane can tell
// a reveal remount from an in-place reattach.
let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId)
exposeE2eTerminalPtyOutputDebug()
let disposed = false
const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal)
@@ -2774,31 +2783,19 @@ export function connectPanePty(
)
}
let commandCodeOutputDoneTimer: ReturnType<typeof setTimeout> | null = null
const clearCommandCodeOutputDoneTimer = (): void => {
if (commandCodeOutputDoneTimer !== null) {
clearTimeout(commandCodeOutputDoneTimer)
commandCodeOutputDoneTimer = null
}
}
const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
clearCommandCodeOutputDoneTimer()
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
return
}
// Why: Command Code keeps rendering the composer while tools run. Only
// complete the row if no active status repaint arrives during this window.
commandCodeOutputDoneTimer = setTimeout(() => {
commandCodeOutputDoneTimer = null
if (disposed) {
return
}
const currentState = useAppStore.getState()
// Why the settle window lives outside this binding: park unmounts the pane
// mid-settle, so a pane-owned timer would be cancelled with nothing left to
// complete the turn — the row would stick at 'working'. Only the row write
// (routing + title slot) is pane-local; the deadline transfers to whichever
// owner (parked watcher or remounted pane) holds the pane next.
const releaseCommandCodeDoneSettleExecutor = setCommandCodeDoneSettleExecutor(
cacheKey,
(normalizedPrompt) => {
const routing = resolveCurrentAgentStatusRouting()
if (!routing) {
return
}
const currentState = useAppStore.getState()
const currentEntry = currentState.agentStatusByPaneKey[cacheKey]
if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') {
return
@@ -2819,7 +2816,18 @@ export function connectPanePty(
undefined,
routing
)
}, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS)
}
)
const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey)
const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => {
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
cancelCommandCodeDoneSettle(cacheKey)
return
}
// Why: Command Code keeps rendering the composer while tools run. Only
// complete the row if no active status repaint arrives during this window.
openCommandCodeDoneSettle(cacheKey, normalizedPrompt)
}
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
@@ -7649,12 +7657,18 @@ export function connectPanePty(
return snapshot
}
)
// Why consume-once: only the first reattach of a reveal remount may pay
// the probe; a later in-place reconnect on this same mount must not buy a
// second timeout before the relay paint.
const revealFollowsTerminalPark =
mountFollowsTerminalPark && connectResult?.isReattach === true
mountFollowsTerminalPark = false
// Why: a relay restart empties the replay buffer, but main's model may
// still hold the session — an SSH reattach probes it even with no replay
// still hold the session — a park-reveal probes it even with no replay
// so the reveal is never blank when main has content. Prefetched (before
// the payload task) so the coordinator route covers the paint.
let prefetchedSshModelSnapshot: PtyBufferSnapshot | null = null
if (!hasStructuralReplay && connectResult?.isReattach) {
if (revealFollowsTerminalPark && !hasStructuralReplay) {
prefetchedSshModelSnapshot = await fetchSshMainModelReattachSnapshot()
if (!isCurrentReattachPayload()) {
return false
@@ -7702,12 +7716,13 @@ export function connectPanePty(
}
}
} else if (connectResult?.replay || prefetchedSshModelSnapshot) {
// Deliberate: even with a relay replay in hand, ordinary SSH reattach probes
// the model once (bounded by the probe timeout) — the renderer cannot tell a
// park-reveal from a relay restart here, and the 100KiB relay tail loses
// scrollback the model still holds. Memoized, so this is never a second probe.
const modelSnapshot =
prefetchedSshModelSnapshot ?? (await fetchSshMainModelReattachSnapshot())
// Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the
// model still holds, but an in-place reattach (network reconnect, wake,
// reload) already has that replay in hand, so probing would only delay its
// paint by the timeout. Memoized, so this is never a second probe.
const modelSnapshot = revealFollowsTerminalPark
? (prefetchedSshModelSnapshot ?? (await fetchSshMainModelReattachSnapshot()))
: null
if (!isCurrentReattachPayload()) {
return
}
@@ -8705,7 +8720,9 @@ export function connectPanePty(
pendingTerminalInputWrite = null
interruptInference.dispose()
clearTitleOnlyInterruptTimer()
clearCommandCodeOutputDoneTimer()
// Why release, not cancel: the pending settle belongs to the turn, not to
// this pane — a park mid-settle hands it to the parked watcher instead.
releaseCommandCodeDoneSettleExecutor()
if (shiftEnterReconfirmTimer !== null) {
clearTimeout(shiftEnterReconfirmTimer)
shiftEnterReconfirmTimer = null
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import type { RepoConnection } from '../../../../shared/workspace-session-terminal-buffers'
import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
const LOCAL_REPO: RepoConnection = {
id: 'local-repo',
connectionId: null,
executionHostId: 'local'
}
const SSH_REPO: RepoConnection = {
id: 'ssh-repo',
connectionId: 'conn-1',
executionHostId: null
}
const RUNTIME_REPO: RepoConnection = {
id: 'runtime-repo',
connectionId: null,
executionHostId: 'runtime:env-1'
}
const REPOS = [LOCAL_REPO, SSH_REPO, RUNTIME_REPO]
describe('canReleaseReplayedScrollbackFromStore', () => {
it("releases ref-backed and remote-repo replays but keeps a local worktree's only copy", () => {
expect(
canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs: true,
worktreeId: 'local-repo::/local/worktree',
repos: REPOS
})
).toBe(true)
expect(
canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs: false,
worktreeId: 'ssh-repo::/ssh/worktree',
repos: REPOS
})
).toBe(true)
expect(
canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs: false,
worktreeId: 'runtime-repo::/runtime/worktree',
repos: REPOS
})
).toBe(true)
expect(
canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs: false,
worktreeId: 'local-repo::/local/worktree',
repos: REPOS
})
).toBe(false)
})
it('releases for an unhydrated repo catalog, matching the capture guard that re-mints it', () => {
expect(
canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs: false,
worktreeId: 'unknown-repo::/maybe-remote/worktree',
repos: REPOS
})
).toBe(true)
})
})
@@ -0,0 +1,22 @@
import {
shouldPreserveTerminalScrollbackBuffers,
type RepoConnection
} from '../../../../shared/workspace-session-terminal-buffers'
type ReplayedScrollbackReleaseArgs = {
hasScrollbackRefs: boolean
worktreeId: string | undefined
repos: readonly RepoConnection[]
}
/** Whether a replayed pane may drop its store-held scrollback copy now that xterm owns the bytes.
* Inverse of the force-park capture guard: keep the copy only where nothing can re-create it. */
export function canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs,
worktreeId,
repos
}: ReplayedScrollbackReleaseArgs): boolean {
// Refs re-hydrate from disk and remote/SSH worktrees re-serialize at the next park; a local
// worktree never re-mints its copy (includeLocalBuffers:false), so releasing it would lose it.
return hasScrollbackRefs || shouldPreserveTerminalScrollbackBuffers(worktreeId, repos)
}
@@ -25,4 +25,15 @@ describe('captureTerminalShutdownBuffersBestEffort', () => {
expect(failed).toHaveBeenCalledWith({ includeLocalBuffers: false })
expect(succeeded).toHaveBeenCalledWith({ includeLocalBuffers: false })
})
it('reports incomplete coverage when a tab throws or has no registered capture', () => {
shutdownBufferCaptures.set('registered', vi.fn())
shutdownBufferCaptures.set('throwing', () => {
throw new Error('layout capture failed')
})
expect(
captureTerminalShutdownBuffersBestEffort(['registered', 'throwing', 'unregistered'])
).toEqual({ requested: 3, captured: 1 })
})
})
@@ -21,11 +21,13 @@ export const shutdownBufferCaptures = new Map<
(options?: ShutdownBufferCaptureOptions) => void
>()
/** Capture every mounted tab without letting one layout failure abort retention. */
/** Capture every mounted tab without letting one layout failure abort retention.
* Reports coverage so a caller can decide whether the episode is retryable. */
export function captureTerminalShutdownBuffersBestEffort(
tabIds: readonly string[],
options?: ShutdownBufferCaptureOptions
): void {
): { requested: number; captured: number } {
let captured = 0
for (const tabId of tabIds) {
const capture = shutdownBufferCaptures.get(tabId)
if (!capture) {
@@ -33,8 +35,10 @@ export function captureTerminalShutdownBuffersBestEffort(
}
try {
capture(options)
captured += 1
} catch {
// Buffer capture is optional recovery evidence; parking must still commit.
}
}
return { requested: tabIds.length, captured }
}
@@ -114,17 +114,28 @@ describe('decideSshReattachPaintSource', () => {
}
})
it('judges emptiness on the composed payload, not the screen frame alone', () => {
it('judges emptiness on the composed content, not the screen frame alone', () => {
// Why: an alt-screen model snapshot can hold all content in scrollbackAnsi
// with an empty screen; a dangling escape tail alone must also paint.
for (const snapshot of [
{ data: '', scrollbackAnsi: 'history', source: 'headless' as const },
{ data: '', pendingEscapeTailAnsi: '\x1b[', source: 'headless' as const }
]) {
expect(
decideSshReattachPaintSource({ ptyId: SSH_PTY_ID, sshParkingEnabled: true, snapshot })
).toBe('main-model-snapshot')
}
// with an empty screen.
expect(
decideSshReattachPaintSource({
ptyId: SSH_PTY_ID,
sshParkingEnabled: true,
snapshot: { data: '', scrollbackAnsi: 'history', source: 'headless' as const }
})
).toBe('main-model-snapshot')
})
it('treats a dangling escape tail alone as unpaintable content', () => {
// Why: an incomplete escape sequence renders nothing, so painting it would
// blank the pane over a relay replay that still holds the session.
expect(
decideSshReattachPaintSource({
ptyId: SSH_PTY_ID,
sshParkingEnabled: true,
snapshot: { data: '', pendingEscapeTailAnsi: '\x1b[', source: 'headless' as const }
})
).toBe('relay-replay')
})
it('never upgrades when the kill switch is off or the pty is not SSH', () => {
@@ -37,9 +37,9 @@ export async function resolveSshReattachModelSnapshotWithTimeout<T>(
* main's headless model is trusted: a 'renderer'-sourced snapshot serializes a
* mounted xterm, which no longer exists once the pane parked — anything but a
* non-empty headless snapshot degrades to the relay replay, never a blank paint.
* Emptiness is judged on the COMPOSED paint payload (scrollback + screen +
* escape tail): an alt-screen snapshot can carry all content in scrollbackAnsi
* with an empty screen frame.
* Emptiness is judged on the composed CONTENT (scrollback + screen): an
* alt-screen snapshot can carry all content in scrollbackAnsi with an empty
* screen frame.
*/
export function decideSshReattachPaintSource(args: {
ptyId: string
@@ -52,11 +52,10 @@ export function decideSshReattachPaintSource(args: {
if (!args.snapshot || args.snapshot.source !== 'headless') {
return 'relay-replay'
}
const composedLength =
(args.snapshot.scrollbackAnsi?.length ?? 0) +
args.snapshot.data.length +
(args.snapshot.pendingEscapeTailAnsi?.length ?? 0)
return composedLength === 0 ? 'relay-replay' : 'main-model-snapshot'
// Why the escape tail is excluded: a dangling mid-escape is not content — a
// model holding only one paints a blank pane, which this gate forbids.
const contentLength = (args.snapshot.scrollbackAnsi?.length ?? 0) + args.snapshot.data.length
return contentLength === 0 ? 'relay-replay' : 'main-model-snapshot'
}
/** Skip the snapshot fetch entirely when the paint could never use it. */
@@ -3,6 +3,7 @@ import { TERMINAL_WORKTREE_PARK_DELAY_MS } from './terminal-hidden-view-parking'
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
isEvictionExemptTerminalPty,
selectForceParkEvictableTabIds,
selectRetentionForceParkedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
} from './terminal-hidden-worktree-retention'
@@ -38,7 +39,6 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
ordinaryParkingCovers: false,
hasEvictionExemptTab: false,
hasPendingSpawnWork: false,
...partial
}
@@ -79,7 +79,7 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
).toEqual(new Set(['wt-1', 'wt-2']))
})
it('force-parks past the TTL even under the limit, but never the last-active candidate', () => {
it('force-parks past the TTL even under the limit, sparing a last-active candidate inside it', () => {
const worktrees = [
retentionCandidate('wt-old', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS),
retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
@@ -89,6 +89,22 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
)
})
// Why: the last-active exemption keeps the warm cap's "return is always instant"
// promise, but carrying it into the eviction clock made "none past 45 minutes"
// false — a lone hidden un-parkable worktree stayed mounted for the whole session.
it('force-parks the last-active candidate once it passes the TTL (the exemption bounds the cap, not the clock)', () => {
const lone = [retentionCandidate('wt-lone', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS)]
expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees: lone })).toEqual(
new Set(['wt-lone'])
)
const insideTtl = [
retentionCandidate('wt-lone', nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS + 1)
]
expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees: insideTtl })).toEqual(
new Set()
)
})
it('never force-parks visible, measuring, portaled, covered, pending, or fresh candidates', () => {
const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const worktrees = [
@@ -128,19 +144,6 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
).toEqual(new Set(['wt-measured']))
})
it('force-parks worktrees with eviction-exempt tabs (the tabs, not the worktree, are exempt)', () => {
// Why: one exempt tab must not pin co-located un-parkable tabs forever;
// the exempt tabs' panes survive via the per-tab exclusion instead.
const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const worktrees = [
retentionCandidate('wt-exempt', aged, { hasEvictionExemptTab: true }),
retentionCandidate('wt-recent', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS)
]
expect(selectRetentionForceParkedTerminalWorktrees({ ...base, worktrees })).toEqual(
new Set(['wt-exempt'])
)
})
it('is idempotent and only grows as time advances (flip-loop dwell)', () => {
const worktrees = [
retentionCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 3),
@@ -174,3 +177,19 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
}
})
})
describe('selectForceParkEvictableTabIds', () => {
const tabs = [{ id: 'tab-exempt' }, { id: 'tab-evictable' }]
it('drops eviction-exempt tabs from the capture and unmount set', () => {
expect(selectForceParkEvictableTabIds(tabs, (tab) => tab.id === 'tab-exempt')).toEqual([
'tab-evictable'
])
})
// Why: an all-exempt worktree still reports as force-parked while freeing nothing —
// the degenerate case a fleet-wide daemon fail-open produces, which the host logs.
it('yields nothing when every tab is exempt', () => {
expect(selectForceParkEvictableTabIds(tabs, () => true)).toEqual([])
})
})
@@ -8,12 +8,18 @@ import {
type TerminalColdParkPolicyOverrides
} from './terminal-hidden-view-parking'
// Why these sizes (C1, DESIGN.md §2): a retained hidden pane costs a measured
// ~2.5MB of V8 heap at the 5k-row default scrollback and ~19MB at 50k (plus
// per-pane queues), not the ~4-5MB per WORKTREE the warm cap assumed — so
// un-parkable worktrees (pty classes parking can't restore) get a hard
// retention budget: at most 12 stay mounted while hidden, and none past 45
// minutes, evicted least-recently-hidden first via force-park.
// Why these sizes (C1): a retained hidden pane costs a measured ~2.5MB of V8
// heap at the 5k-row default scrollback and ~19MB at 50k (plus per-pane
// queues), not the ~4-5MB per WORKTREE the warm cap assumed — so un-parkable
// worktrees (pty classes parking can't restore) get a retention budget: at most
// 12 stay mounted while hidden and none past 45 minutes, evicted
// least-recently-hidden first via force-park. The TTL is absolute: the
// last-active exemption bounds the cap, never the clock.
// NOT covered by this bound: eviction-exempt TABS (isEvictionExemptTerminalPty
// — live local ptys a remount would respawn, orphaning the shell). Their panes
// stay mounted through a force-park at any age, so a fleet-wide daemon
// fail-open can leave the budget freeing nothing; Terminal.tsx logs that
// degenerate case rather than pretending the bound held.
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12
export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000
@@ -46,8 +52,6 @@ export type TerminalWorktreeRetentionCandidate = {
parkCooldownUntilMs?: number | null
/** Ordinary cold parking can evict this worktree (park-eligible AND watcher-coverable) — the warm cap bounds it already. */
ordinaryParkingCovers: boolean
/** See isEvictionExemptTerminalTab. */
hasEvictionExemptTab: boolean
/** Pending startup or activation spawn — a mount is imminent; never evict. */
hasPendingSpawnWork: boolean
}
@@ -60,9 +64,11 @@ export type TerminalWorktreeRetentionCandidate = {
* do NOT veto the worktree: they keep their mounted panes via the per-tab
* exclusion (Activity-portal pattern) while sibling tabs unmount, so one
* exempt tab can no longer pin co-located remote-runtime tabs forever.
* Ranking reuses the hot-retain machinery, so the last-active exemption and
* deterministic ties hold here too, and the verdict changes only at deadlines
* or on real state transitions (no new flip-loop inputs).
* Ranking reuses the hot-retain machinery, so deterministic ties hold here too,
* and the verdict changes only at deadlines or on real state transitions (no
* new flip-loop inputs). The last-active exemption it carries spares one
* worktree from the CAP only — the TTL below overrides it, else a lone hidden
* un-parkable worktree would stay mounted for the whole session.
*/
export function selectRetentionForceParkedTerminalWorktrees(
args: {
@@ -92,9 +98,28 @@ export function selectRetentionForceParkedTerminalWorktrees(
}
candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
}
return selectIdsBeyondHotRetain(candidates, {
const retentionTtlMs = args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const forceParkedIds = selectIdsBeyondHotRetain(candidates, {
nowMs: args.nowMs,
hotRetainMs: args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
hotRetainMs: retentionTtlMs,
hotRetainLimit: args.retentionLimit ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT
})
// Why re-applied here: selectIdsBeyondHotRetain spares the last-active id from
// its clock too, which is right for the warm cap (instant return after a
// meeting) but makes "none past 45 minutes" false for a lone hidden worktree.
for (const candidate of candidates) {
if (args.nowMs - candidate.hiddenSinceMs >= retentionTtlMs) {
forceParkedIds.add(candidate.id)
}
}
return forceParkedIds
}
// Why exported: an all-exempt force-park frees nothing, and that degenerate
// case is only observable if the empty selection is a value the host can test.
export function selectForceParkEvictableTabIds<T extends { id: string }>(
tabs: readonly T[],
isExempt: (tab: T) => boolean
): string[] {
return tabs.filter((tab) => !isExempt(tab)).map((tab) => tab.id)
}
@@ -796,6 +796,18 @@ describe('terminal-parked-tab-watchers', () => {
expect(isEvictionExemptTerminalTab(tab, WORKTREE_ID)).toBe(true)
})
// Why: locks the documented residual — detection is per pane, retention is
// per tab, so the snapshot-backed first leaf is pinned by its fail-open
// sibling instead of parking on its own.
it('exempts a split tab even when its other leaf is snapshot-backed', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
// Why separator-less: the daemon-fail-open class, restorable by nothing.
{ ptyId: 'pty-local-detached', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(isEvictionExemptTerminalTab({ id: TAB_ID, ptyId: PTY_ID }, WORKTREE_ID)).toBe(true)
})
it('exempts a split tab whose second leaf pty comes from the layout fallback', () => {
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: {
@@ -115,6 +115,14 @@ function parkRestorePolicyFromState(state: {
* so becomes a retention candidate) yet would otherwise look exempt-free and
* unmount, orphaning that live shell. tab.ptyId stays in the union in case pane
* resolution misses it (no layout, no capture).
*
* Accepted residual: detection is per pane but retention is per TAB — the whole
* PaneManager stays mounted, so one exempt leaf also pins its restorable split
* siblings, and the retention budget cannot bound exempt tabs. Capping them is
* not an option (eviction orphans the live shell); the real fix is making those
* pty classes reattachable. Sibling TABS are unaffected — the worktree-level
* veto was removed (selectRetentionForceParkedTerminalWorktrees never consults
* tab exemptions).
*/
export function isEvictionExemptTerminalTab(
tab: ParkableTerminalTabModel,
@@ -51,6 +51,16 @@ export function getParkedTerminalWatcherTabIds(): string[] {
return Array.from(parkedWatchersByTabId.keys())
}
/**
* Whether this tab is parked right now — the reveal remount's own mount effect
* runs before the host effect that disposes the watcher (child effects first),
* so a pane reading this at connect time can tell a park-reveal from an
* in-place reattach. Empty entries are pinned-close tombstones, not live parks.
*/
export function isTerminalTabParked(tabId: string): boolean {
return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0
}
export function disposeParkedTabWatchers(tabId: string): void {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
@@ -60,13 +60,12 @@ describe('getTerminalParkingPolicyOverrides', () => {
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
it('maps the e2e delay to coldParkDelayMs, hotRetainMs, and retentionTtlMs', async () => {
it('maps the e2e delay to coldParkDelayMs and hotRetainMs but never the absolute retention TTL', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: null }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({
coldParkDelayMs: 500,
hotRetainMs: 500,
retentionTtlMs: 500
hotRetainMs: 500
})
})
@@ -86,13 +85,12 @@ describe('getTerminalParkingPolicyOverrides', () => {
expect(getTerminalParkingPolicyOverrides()).toEqual({ retentionLimit: 1 })
})
it('combines the delay trio with the retention limit when both are set', async () => {
it('combines the delay overrides with the retention limit when both are set', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: 1 }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({
coldParkDelayMs: 500,
hotRetainMs: 500,
retentionTtlMs: 500,
retentionLimit: 1
})
})
@@ -16,9 +16,11 @@ export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverr
const delayMs = e2eConfig.terminalParkingDelayMs
const retentionLimit = e2eConfig.terminalRetentionLimit
return {
// Why the retention TTL keeps production timing: it is absolute (the
// last-active exemption does not spare it), so shrinking it here would
// evict the newest hidden worktree the cap specs assert stays mounted.
...(typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
? // Why retentionTtlMs too: the retention-budget e2e needs its 45min TTL shrunk the same way.
{ coldParkDelayMs: delayMs, hotRetainMs: delayMs, retentionTtlMs: delayMs }
? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
: {}),
// Why: limit=1 lets a spec force-park with only two hidden un-parkable worktrees (production floor is 12).
...(typeof retentionLimit === 'number' && Number.isInteger(retentionLimit) && retentionLimit > 0
@@ -219,6 +219,78 @@ describe('captureTerminalShutdownLayout', () => {
expect(buffer).toHaveLength(fittingRows * 1024 + CURSOR_HOME.length)
})
it('bounds serialize probes while capping an oversized buffer by UTF-8 bytes', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const multibyteRow = 'é'.repeat(1024)
const serialize = vi.fn((options?: { scrollback?: number }) =>
multibyteRow.repeat(options?.scrollback ?? 0)
)
const pane = {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: mockTerminal(512),
serializeAddon: { serialize }
}
const manager = {
getPanes: vi.fn(() => [pane]),
getActivePane: vi.fn(() => pane)
}
const layout = captureTerminalShutdownLayout({
manager: manager as never,
container: mockRootForPane(1),
expandedPaneId: null,
paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
paneTitlesByPaneId: { 1: 'ssh shell' },
existingLayout: undefined
})
// Full pass plus at most MAX_SCROLLBACK_FIT_PROBES fit probes; a bisection took 10+.
expect(serialize.mock.calls.length).toBeLessThanOrEqual(5)
expect(getUtf8ByteLength(layout.buffersByLeafId?.[LEAF_ID] ?? '')).toBeLessThanOrEqual(
TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
)
})
it('keeps most of the byte budget when dense recent rows sit above sparse scrollback', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
// Why this shape: the common terminal profile (a long quiet prompt history under a dense
// build/agent run) makes bytes-per-row wildly non-uniform, which a pure secant step creeps on.
const DENSE_ROWS = 1_000
const DENSE_ROW = 'x'.repeat(600)
const serialize = vi.fn((options?: { scrollback?: number }) => {
const rows = options?.scrollback ?? 0
const dense = Math.min(rows, DENSE_ROWS)
return '.'.repeat(Math.max(rows - DENSE_ROWS, 0)) + DENSE_ROW.repeat(dense)
})
const pane = {
id: 1,
leafId: LEAF_ID,
stablePaneId: LEAF_ID,
terminal: mockTerminal(5_000),
serializeAddon: { serialize }
}
const manager = {
getPanes: vi.fn(() => [pane]),
getActivePane: vi.fn(() => pane)
}
const layout = captureTerminalShutdownLayout({
manager: manager as never,
container: mockRootForPane(1),
expandedPaneId: null,
paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
paneTitlesByPaneId: { 1: 'ssh shell' },
existingLayout: undefined
})
const bytes = getUtf8ByteLength(layout.buffersByLeafId?.[LEAF_ID] ?? '')
expect(bytes).toBeLessThanOrEqual(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT)
expect(bytes).toBeGreaterThan(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT * 0.9)
expect(serialize.mock.calls.length).toBeLessThanOrEqual(5)
})
it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const pane = {
@@ -7,7 +7,7 @@ import { mergeCapturedLeafState } from './merge-captured-leaf-state'
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
import { getUtf8ByteLength, measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits'
const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT
@@ -46,6 +46,49 @@ function fitsSessionScrollbackByteLimit(serialized: string): boolean {
return !measureUtf8ByteLength(serialized, { stopAfterBytes: MAX_BUFFER_BYTES }).exceededLimit
}
// Why bounded: a plain row bisection costs ~13 full serializes per over-limit pane (~250ms at the
// 5k scrollback default, ~700ms at 50k), and force-park pays it synchronously per evicted pane.
const MAX_SCROLLBACK_FIT_PROBES = 4
/** Largest tail of `pane` that fits the session byte cap, found in a few interpolation probes. */
function serializeWithinSessionScrollbackByteLimit(
pane: ShutdownPane,
oversized: string,
scrollback: number
): string {
let overRows = scrollback
let overBytes = getUtf8ByteLength(oversized)
let fitRows = 0
let fitBytes = 0
let best: string | null = null
// Why the extra probes before the first fit: returning '' would drop the whole pane, and those
// probes shrink geometrically, so they cost less in total than a bisection's upper-half passes.
for (let probe = 0; probe < MAX_SCROLLBACK_FIT_PROBES || best === null; probe += 1) {
const anchorRows = best === null ? overRows : fitRows
const anchorBytes = best === null ? overBytes : fitBytes
// Bytes grow ~linearly with rows, so a secant step lands on the exact fit for uniform
// scrollback; the midpoint floor stops it creeping when dense recent rows sit above sparse old ones.
const estimate = Math.floor((anchorRows * MAX_BUFFER_BYTES) / Math.max(anchorBytes, 1))
const midpoint = Math.floor((fitRows + overRows) / 2)
const rows = Math.min(Math.max(Math.min(estimate, midpoint), fitRows + 1), overRows - 1)
if (rows <= fitRows || rows >= overRows) {
break
}
const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
scrollback: rows
})
if (fitsSessionScrollbackByteLimit(attempt)) {
best = attempt
fitRows = rows
fitBytes = getUtf8ByteLength(attempt)
} else {
overRows = rows
overBytes = getUtf8ByteLength(attempt)
}
}
return best ?? ''
}
export function captureTerminalShutdownLayout({
manager,
container,
@@ -76,22 +119,7 @@ export function captureTerminalShutdownLayout({
// Why: SSH sleep keeps this string in session JSON; cap by UTF-8
// bytes so non-ASCII scrollback cannot bypass the intended bound.
if (!fitsSessionScrollbackByteLimit(serialized) && scrollback > 1) {
let lo = 1
let hi = scrollback
let best = ''
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, {
scrollback: mid
})
if (fitsSessionScrollbackByteLimit(attempt)) {
best = attempt
lo = mid + 1
} else {
hi = mid - 1
}
}
serialized = best
serialized = serializeWithinSessionScrollbackByteLimit(pane, serialized, scrollback)
}
if (serialized.length > 0) {
buffers[leafId] = serialized
@@ -429,6 +429,136 @@ describe('registerTerminalSideEffectFactConsumer', () => {
expect(events).toEqual([])
})
it('hands off facts emitted between the parked watcher unregistering and the pane registering', () => {
// Why: the reveal window — the watcher unregisters synchronously in the
// effect flush, the pane registers only after its async reattach resolves,
// and replay is title-only, so an unbuffered bell/command-code-done is lost.
const watcher = createCallbackRecorder()
const disposeWatcher = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: watcher.callbacks
})
disposeWatcher()
const events: unknown[][] = []
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'bell' }, { kind: 'command-code-done', prompt: 'Fix the spinner' }], {
seq: 7
})
)
// A PTY that never had a consumer stays dropped — no buffer was opened.
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'never-consumed' }))
expect(events).toEqual([])
expect(watcher.events).toEqual([])
const neverConsumed = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({
ptyId: 'never-consumed',
callbacks: neverConsumed.callbacks
})
expect(neverConsumed.events).toEqual([])
const dispose = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onBell: () => events.push(['bell']),
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
}
})
expect(events).toEqual([['bell'], ['cc-done', 'Fix the spinner']])
expect(watcher.events).toEqual([])
// Drained exactly once: a later registration gets nothing.
dispose()
const later: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onBell: () => later.push(['bell']),
onCommandCodeDone: (prompt) => later.push(['cc-done', prompt])
}
})
expect(later).toEqual([])
})
it('does not buffer replay batches across a handoff', () => {
// Why: the next consumer requests its own snapshot; a held replay would
// regress the title it just restored.
const dispose = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: createCallbackRecorder().callbacks
})
dispose()
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], {
replay: true,
seq: 5
})
)
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
expect(events).toEqual([])
})
it('drains the handoff buffer before the register snapshot, so the replay is stale', async () => {
// Why: the drained live title sets lastLiveTitleSeq, which is what makes
// the async snapshot's older title lose to the fact the pane already got.
const snapshot = batch([{ kind: 'title', normalizedTitle: 'snapshot', rawTitle: 'snapshot' }], {
replay: true,
seq: 20
})
;(globalThis as { window: unknown }).window = {
api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } }
}
const dispose = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: createCallbackRecorder().callbacks
})
dispose()
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 })
)
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks,
restoreTitleOnRegister: true
})
await Promise.resolve()
await Promise.resolve()
expect(events).toEqual([['title', 'live', 'live']])
})
it('drops a handoff-buffered batch once the buffer TTL expires', () => {
vi.useFakeTimers()
try {
const dispose = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: createCallbackRecorder().callbacks
})
dispose()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
vi.advanceTimersByTime(15_001)
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
expect(events).toEqual([])
} finally {
vi.useRealTimers()
}
})
it('subscribes to the channel once and routes IPC batches', () => {
let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null
const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => {
@@ -9,7 +9,8 @@
* callbacks), so every fact has exactly one policy consumer regardless of
* whether the tab is mounted, hidden, or parked. Facts for PTYs without a
* registered consumer are dropped — mirroring today's eager-buffer behavior
* where pre-mount output produces no attention side effects.
* where pre-mount output produces no attention side effects. The one exception
* is a PTY whose consumer just unregistered: see the handoff buffer below.
*/
import type { GlobalSettings } from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
@@ -166,9 +167,78 @@ function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBat
}
}
// Why: a reveal remount unregisters the parked watcher synchronously, but the
// replacement pane registers only after its deferred rAF + async reattach
// resolves — and replay is title-only, so a bell or a command-code 'done'
// dropped in that window is lost for good. Only a PTY that just lost its
// consumer buffers (never-consumed PTYs still drop, per the module contract),
// bounded in time, batches, and PTYs so an abandoned handoff retains nothing.
const HANDOFF_FACT_BUFFER_TTL_MS = 15_000
const MAX_HANDOFF_FACT_BATCHES = 64
const MAX_HANDOFF_FACT_PTYS = 32
type HandoffFactBuffer = { batches: TerminalSideEffectBatch[]; expiresAtMs: number }
const handoffFactBuffersByPtyId = new Map<string, HandoffFactBuffer>()
function openHandoffFactBuffer(ptyId: string): void {
const nowMs = Date.now()
for (const [bufferedPtyId, buffer] of handoffFactBuffersByPtyId) {
if (buffer.expiresAtMs <= nowMs) {
handoffFactBuffersByPtyId.delete(bufferedPtyId)
}
}
if (
!handoffFactBuffersByPtyId.has(ptyId) &&
handoffFactBuffersByPtyId.size >= MAX_HANDOFF_FACT_PTYS
) {
const oldestPtyId = handoffFactBuffersByPtyId.keys().next().value
if (typeof oldestPtyId === 'string') {
handoffFactBuffersByPtyId.delete(oldestPtyId)
}
}
handoffFactBuffersByPtyId.set(ptyId, {
batches: [],
expiresAtMs: nowMs + HANDOFF_FACT_BUFFER_TTL_MS
})
}
function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void {
const buffer = handoffFactBuffersByPtyId.get(batch.ptyId)
if (!buffer) {
return
}
if (buffer.expiresAtMs <= Date.now()) {
handoffFactBuffersByPtyId.delete(batch.ptyId)
return
}
// Why: replay batches are snapshots the next consumer requests for itself.
if (batch.replay) {
return
}
if (buffer.batches.length >= MAX_HANDOFF_FACT_BATCHES) {
buffer.batches.shift()
}
buffer.batches.push(batch)
}
function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void {
const buffer = handoffFactBuffersByPtyId.get(ptyId)
if (!buffer) {
return
}
handoffFactBuffersByPtyId.delete(ptyId)
if (buffer.expiresAtMs <= Date.now()) {
return
}
for (const batch of buffer.batches) {
applyBatchToConsumer(entry, batch)
}
}
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
const entry = consumersByPtyId.get(batch.ptyId)
if (!entry) {
bufferHandoffFactBatch(batch)
return
}
applyBatchToConsumer(entry, batch)
@@ -211,6 +281,8 @@ export function registerTerminalSideEffectFactConsumer(
lastLiveTitleSeq: null
}
consumersByPtyId.set(options.ptyId, entry)
// Why before the snapshot request: draining live facts sets lastLiveTitleSeq, so the async title replay is correctly dropped as stale.
drainHandoffFactBuffer(options.ptyId, entry)
if (options.restoreTitleOnRegister) {
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
@@ -230,6 +302,8 @@ export function registerTerminalSideEffectFactConsumer(
return () => {
if (consumersByPtyId.get(options.ptyId) === entry) {
consumersByPtyId.delete(options.ptyId)
// Why: the pane that replaces this consumer registers only after its async reattach resolves; hold facts across that handoff.
openHandoffFactBuffer(options.ptyId)
}
}
}
@@ -242,6 +316,7 @@ export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffec
/** Test seam: reset module state between tests. */
export function _resetTerminalSideEffectFactConsumersForTest(): void {
consumersByPtyId.clear()
handoffFactBuffersByPtyId.clear()
channelUnsubscribe?.()
channelUnsubscribe = null
persistedAuthorityFlagCache = undefined
@@ -109,6 +109,7 @@ import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-str
import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { canReleaseReplayedScrollbackFromStore } from './replayed-scrollback-store-release'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent'
import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle'
@@ -1382,12 +1383,23 @@ export function useTerminalPaneLifecycle({
replayingPanesRef,
restoredViewportBlankingPanesRef
)
if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) {
const hasScrollbackRefs = Boolean(initialLayoutRef.current.scrollbackRefsByLeafId)
if (
restoredBuffers &&
canReleaseReplayedScrollbackFromStore({
hasScrollbackRefs,
worktreeId,
repos: useAppStore.getState().repos
})
) {
const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current }
delete layoutWithoutRestoredBuffers.buffersByLeafId
initialLayoutRef.current = layoutWithoutRestoredBuffers
if (hasScrollbackRefs) {
// Why refs-only: without a disk ref this mount is the sole replay source, so a re-run (StrictMode) still needs the bytes.
initialLayoutRef.current = layoutWithoutRestoredBuffers
}
if (initialLayoutHadBuffers) {
// Why: raw replay bytes belong to this mount only; drop legacy hydrated copies from Zustand so session writes stay ref-only.
// Why: xterm owns the replayed bytes now; leaving the park-time copy in Zustand retains up to 512KB/leaf for the app's lifetime.
useAppStore.getState().setTabLayout(tabId, layoutWithoutRestoredBuffers)
}
}
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({
storeState: {
pendingStartupByTabId: {} as Record<string, unknown>,
settings: {} as Record<string, unknown>
}
},
exemptTabIds: new Set<string>()
}))
vi.mock('../../store', () => ({
@@ -17,7 +18,7 @@ vi.mock('../../store', () => ({
vi.mock('./terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => true,
disposeParkedTerminalWatchersForWorktree: vi.fn(),
isEvictionExemptTerminalTab: () => false,
isEvictionExemptTerminalTab: (tab: { id: string }) => mocks.exemptTabIds.has(tab.id),
syncParkedTerminalTabWatchers: vi.fn()
}))
@@ -54,6 +55,7 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
afterEach(() => {
vi.useRealTimers()
mocks.exemptTabIds = new Set()
})
// Why: the worktree layer preserves hiddenSince through a background-measure
@@ -96,4 +98,51 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
})
expect(result.current).toEqual(new Set(['tab-2']))
})
// Why: a measure window also ends when the user opens the worktree; the
// hysteresis is then owed to nobody, so still-hidden background tabs must not
// sit out a cool-down (Terminal.tsx clears the worktree clock the same way).
it('clears the post-measure cool-down when the worktree becomes visible', () => {
const { result, rerender } = renderHook(
(args: ReturnType<typeof hookArgs>) => useTerminalTabColdParking(args),
{ initialProps: hookArgs(false) }
)
act(() => {
vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
})
expect(result.current).toEqual(new Set(['tab-2']))
act(() => {
rerender(hookArgs(true))
})
expect(result.current.size).toBe(0)
// Measure ends because the worktree went visible: tab-2 is still hidden
// (no active group assignment) and re-parks on this very pass.
act(() => {
vi.advanceTimersByTime(3_000)
rerender({ ...hookArgs(false), isWorktreeActive: true })
})
expect(result.current).toEqual(new Set(['tab-2']))
})
// Why: force-park is the only park that can contain unrestorable ptys, so its
// exempt tabs keep their panes — a remount would orphan a live shell.
it('keeps eviction-exempt tabs mounted when the worktree is force-parked', () => {
mocks.exemptTabIds = new Set(['tab-1'])
const { result, rerender } = renderHook(
(args: ReturnType<typeof hookArgs> & { isForceParked: boolean }) =>
useTerminalTabColdParking(args),
{ initialProps: { ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: true } }
)
expect(result.current).toEqual(new Set(['tab-2']))
// The carve-out is scoped to force-parks: an ordinary worktree park has no
// exempt tabs to protect, so it evicts both.
act(() => {
rerender({ ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: false })
})
expect(result.current).toEqual(new Set(['tab-1', 'tab-2']))
})
})
@@ -148,6 +148,11 @@ export function useTerminalTabColdParking(args: {
}
wasMeasuringHiddenWorktreeRef.current = false
}
// Why: mirrors Terminal.tsx's worktree clock — a visible worktree ends the
// measure episode outright, so no re-park cool-down is owed.
if (isWorktreeActive) {
measureParkCooldownUntilRef.current = null
}
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
+344 -2
View File
@@ -6,6 +6,9 @@ import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
getAllWorktreeIds,
getWorktreeTabs,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
@@ -49,13 +52,17 @@ const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000
// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes
// each xterm hold realistic narrow+wide buffer rows, so released parked-tab
// memory reflects real agent output rather than uniform filler.
function writeScrollbackFillScript(scriptPath: string, runId: string): void {
function writeScrollbackFillScript(
scriptPath: string,
runId: string,
lineCount: number = SCROLLBACK_LINE_COUNT
): void {
const script = [
`const tabIndex = process.argv[2] ?? '0'`,
`const wide = '統合端末記憶計測'`,
`const emoji = ['🟢', '🟡', '🔵', '🟣']`,
`const lines = []`,
`for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`,
`for (let i = 0; i < ${lineCount}; i += 1) {`,
` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`,
` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`,
` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`,
@@ -369,3 +376,338 @@ test.describe('Terminal parked memory', () => {
}
})
})
// ─────────────────────── C1 retention budget outcome ───────────────────────
// The tests above assert the parking MECHANISM. This one asserts the OUTCOME
// the retention budget exists for: hidden worktrees ordinary parking can never
// evict actually release their buffers once the budget engages, with the
// budget flip as the only change between the two samples.
//
// Honest caveats:
// - The un-parkable class is staged by rewriting tabsByWorktree[*].ptyId to a
// remote-runtime-shaped id. That is a fidelity proxy: park-restorability and
// eviction-exemption are decided from that field alone, but the transports
// underneath stay real LOCAL PTYs — this is not a live remote runtime.
// - The primary gate is retained xterm buffer CELLS (deterministic), not RSS.
// xterm rows are typed arrays outside the V8 heap, so usedJSHeapSize misses
// most of what is released; renderer RSS is recorded and only gated as
// non-growth because it moves with GC timing and compositor allocations.
const RETENTION_TAB_COUNT = 4
const RETENTION_FILL_LINE_COUNT = 12_000
const RETENTION_SCROLLBACK_ROWS = 25_000
// Why 12: xterm packs each cell as 3 uint32s in the BufferLine typed array.
const XTERM_BYTES_PER_CELL = 12
// Why 40: this staging measures ~87 MB of retained buffer, so half of that is a
// floor that fails loudly if the fill silently stops producing scrollback.
const MIN_STAGED_BUFFER_MB = 40
// Why 2s: force-park is a synchronous verdict re-run on the setting flip and
// measured 23-25ms locally; anything near a timer/TTL wait blows this budget.
const MAX_FORCE_PARK_EVICTION_MS = 2_000
// Why 0.05: only the exempt decoy's unfilled pane may survive (~0.1% of the
// baseline). One retained filled pane would be ~25%, so this fails on a partial
// eviction instead of passing it.
const MAX_RETAINED_CELL_FRACTION = 0.05
// Why a band, not zero: RSS is sampled and moved only -0.7 to -3.0 MB on a
// ~453 MB baseline across runs, so "must not grow" would flake on noise. 5 MB
// still fails loudly if an eviction starts planting 512KB/pane capture strings.
const RENDERER_RSS_NOISE_MB = 5
const RETENTION_TEST_TIMEOUT_MS = 420_000
const UNPARKABLE_PTY_PREFIX = 'remote:e2e-retention-'
type RetainedBufferSample = {
cells: number
rows: number
panes: number
}
// Why walk the buffers instead of trusting a heap delta: xterm rows live in
// typed arrays outside the V8 heap, so retained cells are the only
// deterministic measure of what a force-park actually released.
async function readRetainedTerminalBufferCells(page: Page): Promise<RetainedBufferSample> {
return page.evaluate(() => {
let cells = 0
let rows = 0
let panes = 0
for (const manager of window.__paneManagers?.values() ?? []) {
for (const pane of manager.getPanes?.() ?? []) {
const buffer = pane.terminal?.buffer?.active
if (!buffer) {
continue
}
panes += 1
rows += buffer.length
cells += buffer.length * pane.terminal.cols
}
}
return { cells, rows, panes }
})
}
// Why host-side RSS: the renderer process' resident set is the figure the C1
// crash reports are measured in, and performance.memory cannot see it.
async function readRendererResidentMb(page: Page): Promise<number | null> {
return page.evaluate(async () => {
const snapshot = await window.api?.memory?.getSnapshot?.()
const bytes = snapshot?.app?.renderer?.memory
return typeof bytes === 'number' && bytes > 0 ? bytes / (1024 * 1024) : null
})
}
type RetentionMemorySample = {
buffers: RetainedBufferSample
bufferMb: number
heapUsedMb: number
rendererMb: number | null
livePaneManagers: number
}
async function readRetentionMemorySample(page: Page): Promise<RetentionMemorySample> {
const metrics = await sampleParkedMemoryMetrics(page)
const buffers = await readRetainedTerminalBufferCells(page)
return {
buffers,
bufferMb: (buffers.cells * XTERM_BYTES_PER_CELL) / (1024 * 1024),
heapUsedMb: metrics.heapUsedMB,
rendererMb: await readRendererResidentMb(page),
livePaneManagers: metrics.livePaneManagers
}
}
function formatRetentionSample(label: string, sample: RetentionMemorySample): string {
return [
`${label}.cells=${sample.buffers.cells}`,
`${label}.rows=${sample.buffers.rows}`,
`${label}.panes=${sample.buffers.panes}`,
`${label}.bufferMB=${sample.bufferMb.toFixed(1)}`,
`${label}.heapMB=${sample.heapUsedMb.toFixed(1)}`,
`${label}.rendererRssMB=${sample.rendererMb === null ? 'n/a' : sample.rendererMb.toFixed(1)}`,
`${label}.paneManagers=${sample.livePaneManagers}`
].join(' ')
}
// Why rewrite only tab.ptyId: park-restorability and eviction-exemption are
// decided from it alone, so a remote-runtime-shaped id makes the worktree
// un-parkable AND non-exempt while terminalLayoutsByTabId keeps binding the
// panes to their real local transports.
async function stageUnparkableWorktreeTabs(page: Page, worktreeId: string): Promise<number> {
return page.evaluate(
({ worktreeId, prefix }) => {
const store = window.__store
if (!store) {
throw new Error('stageUnparkableWorktreeTabs: window.__store is unavailable')
}
const state = store.getState()
const tabs = state.tabsByWorktree[worktreeId] ?? []
if (tabs.length === 0) {
throw new Error(`stageUnparkableWorktreeTabs: ${worktreeId} has no terminal tabs`)
}
const staged = tabs.map((tab) =>
typeof tab.ptyId === 'string' && tab.ptyId.startsWith(prefix)
? tab
: { ...tab, ptyId: `${prefix}${tab.id}` }
)
;(store as unknown as { setState: (partial: unknown) => void }).setState({
tabsByWorktree: { ...state.tabsByWorktree, [worktreeId]: staged }
})
return staged.length
},
{ worktreeId, prefix: UNPARKABLE_PTY_PREFIX }
)
}
// Why not getTerminalContent: it serializes the whole scrollback, which is
// megabytes of string per poll tick at RETENTION_SCROLLBACK_ROWS. The fill
// marker is the last line written, so scanning the buffer tail is enough.
async function waitForFillMarkerInTab(page: Page, tabId: string, marker: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
({ tabId, marker }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const buffer = pane?.terminal?.buffer?.active
if (!buffer) {
return false
}
const firstRow = Math.max(0, buffer.length - 200)
for (let row = buffer.length - 1; row >= firstRow; row -= 1) {
if (buffer.getLine(row)?.translateToString(true).includes(marker) === true) {
return true
}
}
return false
},
{ tabId, marker }
),
{ timeout: 90_000, message: `scrollback fill marker ${marker} did not render` }
)
.toBe(true)
}
async function updateTerminalSettings(
page: Page,
patch: { terminalHiddenWorktreeRetentionBudget?: boolean; terminalScrollbackRows?: number }
): Promise<void> {
await page.evaluate(async (patch) => {
const store = window.__store
if (!store) {
throw new Error('updateTerminalSettings: window.__store is unavailable')
}
await store.getState().updateSettings(patch)
}, patch)
}
async function waitForRetentionBudgetSetting(page: Page, enabled: boolean): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
() => window.__store?.getState().settings?.terminalHiddenWorktreeRetentionBudget
),
{ timeout: 5_000, message: `terminalHiddenWorktreeRetentionBudget did not become ${enabled}` }
)
.toBe(enabled)
}
test.describe('Terminal hidden worktree retention budget', () => {
test.use({
orcaAppExtraEnv: {
ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS),
// Why limit=1: the retention TTL is absolute production timing (45min) and
// the parking-delay override deliberately no longer shrinks it, so the
// COUNT CAP is the only knob a test can drive. With a budget of 1 the
// newest hidden un-parkable worktree takes the last-active exemption and
// the older one force-parks — cap and exemption proven in one run.
ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1'
},
orcaAppExtraArgs: ['--enable-precise-memory-info']
})
test('releases un-parkable hidden worktree buffers only once the retention budget engages', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(RETENTION_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
const victimWorktreeId = await waitForActiveWorktree(orcaPage)
await skipUnlessParkingWired(orcaPage)
const decoyWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(worktreeId) => worktreeId !== victimWorktreeId
)
if (!decoyWorktreeId) {
throw new Error('retention budget spec: the fixture seeded only one worktree')
}
// Budget OFF for the whole staging phase: that is the control arm proving
// ordinary parking can never evict this class, and it makes the release
// below attributable to the flip alone.
await updateTerminalSettings(orcaPage, {
terminalHiddenWorktreeRetentionBudget: false,
terminalScrollbackRows: RETENTION_SCROLLBACK_ROWS
})
await waitForRetentionBudgetSetting(orcaPage, false)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-retention-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId, RETENTION_FILL_LINE_COUNT)
try {
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const baselineSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
if (!baselinePtyId) {
throw new Error('retention budget spec: baseline terminal tab did not bind a PTY')
}
const victimTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
for (let tabIndex = 0; tabIndex < RETENTION_TAB_COUNT; tabIndex += 1) {
if (tabIndex > 0) {
victimTabs.push(await createActiveTerminalTab(orcaPage, victimWorktreeId))
}
const tab = victimTabs[tabIndex]
await sendToTerminal(
orcaPage,
tab.ptyId,
`node ${JSON.stringify(scriptPath)} ${tabIndex}\r`
)
await waitForFillMarkerInTab(
orcaPage,
tab.tabId,
`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`
)
// Why stage after every fill rather than once at the end: each later
// fill takes seconds, and ordinary TAB-level parking would evict the
// already-hidden earlier tabs inside that window.
await stageUnparkableWorktreeTabs(orcaPage, victimWorktreeId)
}
// Hiding the victim first makes the decoy the more-recently-hidden
// candidate, so the cap's last-active exemption lands on the decoy.
await switchToWorktree(orcaPage, decoyWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await stageUnparkableWorktreeTabs(orcaPage, decoyWorktreeId)
const decoyTabIds = (await getWorktreeTabs(orcaPage, decoyWorktreeId)).map((tab) => tab.id)
// Leaving the terminal view hides BOTH worktrees while keeping them
// mounted (App.tsx hides the workbench, it does not unmount it).
await orcaPage.evaluate(() => {
window.__store?.getState().setActiveView('tasks')
})
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 4)
const victimTabIds = victimTabs.map((tab) => tab.tabId)
expect(victimTabIds).toHaveLength(RETENTION_TAB_COUNT)
// Control arm: hidden well past the parking window, nothing parks —
// ordinary parking cannot restore these ptys, so it never evicts them.
expect(await countMountedPaneManagers(orcaPage, victimTabIds)).toBe(RETENTION_TAB_COUNT)
expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(decoyTabIds.length)
const before = await readRetentionMemorySample(orcaPage)
expect(before.bufferMb).toBeGreaterThan(MIN_STAGED_BUFFER_MB)
const flipStartedAt = Date.now()
await updateTerminalSettings(orcaPage, { terminalHiddenWorktreeRetentionBudget: true })
await waitForRetentionBudgetSetting(orcaPage, true)
await expect
.poll(() => countMountedPaneManagers(orcaPage, victimTabIds), {
timeout: 30_000,
message: 'retention budget did not force-park the older hidden un-parkable worktree'
})
.toBe(0)
const evictionMs = Date.now() - flipStartedAt
const after = await readRetentionMemorySample(orcaPage)
testInfo.annotations.push({
type: 'terminal-retention-budget-memory',
description: [
`stagedTabs=${RETENTION_TAB_COUNT}`,
`scrollbackRows=${RETENTION_SCROLLBACK_ROWS}`,
formatRetentionSample('before', before),
formatRetentionSample('after', after),
`releasedBufferMB=${(before.bufferMb - after.bufferMb).toFixed(1)}`,
`evictionMs=${evictionMs}`
].join(' ')
})
// Primary gate: the staged buffers are gone, not merely hidden.
expect(after.buffers.cells).toBeLessThan(before.buffers.cells * MAX_RETAINED_CELL_FRACTION)
// The decoy holds the cap's last-active exemption, so it stays mounted —
// this is the same run proving the cap did not simply evict everything.
expect(await countMountedPaneManagers(orcaPage, decoyTabIds)).toBe(decoyTabIds.length)
// Secondary only: freed typed arrays return to the allocator's free lists,
// not the OS, so RSS fell just 0.7-3.0 MB locally while 87 MB of buffer was
// released — a strict non-growth assertion would be reading sampling noise.
// What this CAN catch is the inverse regression the capture path risks:
// force-park planting serialized scrollback in the store as it evicts.
if (before.rendererMb !== null && after.rendererMb !== null) {
expect(after.rendererMb).toBeLessThanOrEqual(before.rendererMb + RENDERER_RSS_NOISE_MB)
}
expect(evictionMs).toBeLessThan(MAX_FORCE_PARK_EVICTION_MS)
} finally {
rmSync(scriptPath, { force: true })
}
})
})