diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c2b597fafbf..2ab1ed44421 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs new file mode 100644 index 00000000000..8d4183b4515 --- /dev/null +++ b/config/scripts/run-ssh-docker-terminal-parking-e2e.mjs @@ -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) diff --git a/package.json b/package.json index 5190a892ebd..2e70251702a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 58b841508bd..190a1288be4 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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' diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index feae29a0096..5a82ff3dfa0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 ( diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 89d11ff5073..11e172d82c2 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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() - 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) } diff --git a/src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts b/src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts new file mode 100644 index 00000000000..c71be186617 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/command-code-done-settle.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/command-code-done-settle.ts b/src/renderer/src/components/terminal-pane/command-code-done-settle.ts new file mode 100644 index 00000000000..f7c207fb7e7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/command-code-done-settle.ts @@ -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() +const timerByPaneKey = new Map>() + +/** 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() +} diff --git a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts new file mode 100644 index 00000000000..4083fcface8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts new file mode 100644 index 00000000000..14f73071169 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/force-park-buffer-capture.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index c8d1d2810e8..c2479267e24 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 39fec0a12a2..ff422e55de5 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -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 | 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 => { 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 } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index e87b1e057f9..bdeca30e7f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -53,6 +53,18 @@ async function drainFakeTimerWork(limit = 20): Promise { 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 { + 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 { 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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 94949cf24dc..27c52889e0d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 | 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 diff --git a/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts new file mode 100644 index 00000000000..06a0d610f24 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts new file mode 100644 index 00000000000..4d7b360f812 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/replayed-scrollback-store-release.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts index 8743d2e111f..af003ff1258 100644 --- a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts +++ b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.test.ts @@ -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 }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts index 5cbf038812e..8e349586860 100644 --- a/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts +++ b/src/renderer/src/components/terminal-pane/shutdown-buffer-captures.ts @@ -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 } } diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts index 1d8b57033af..faedd69c971 100644 --- a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts +++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts index 2a9887088b2..1bcaa1ebf6a 100644 --- a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts +++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts @@ -37,9 +37,9 @@ export async function resolveSshReattachModelSnapshotWithTimeout( * 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. */ diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts index 867dd33dd67..3b1621b3fec 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts @@ -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([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 840c79791b2..f836c8000a0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -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( + tabs: readonly T[], + isExempt: (tab: T) => boolean +): string[] { + return tabs.filter((tab) => !isExempt(tab)).map((tab) => tab.id) } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 7aeb20e2cc7..617a7eb2edb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -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: { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 2ec82a0ee52..d0b37b26e39 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index c3ec86a0888..fd34398824a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts index 102c4503577..b15b8712b45 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -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 }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts index d82e1dfcf50..cbef3cebf5e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 7acbd6a5ff9..dcbf3782f4b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -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 = { diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index 4e6d1698787..006a76b913f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index 84a91c2e7c4..c5f2b696f53 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -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) => { diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 892e8977d72..95374ae9f8a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -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() + +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 diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 66e91ee7fdb..d2472c01686 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 286996fce46..404c32da5e9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({ storeState: { pendingStartupByTabId: {} as Record, settings: {} as Record - } + }, + exemptTabIds: new Set() })) 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) => 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 & { 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'])) + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index a0b90ff2942..e3fbec28c25 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -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) diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts index 52ce3693297..1eea0e16398 100644 --- a/tests/e2e/terminal-parked-memory.spec.ts +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }) + } + }) +})