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