Add bounded terminal renderer retention (#4523)

This commit is contained in:
Neil 2026-06-02 18:40:45 -07:00 committed by GitHub
parent b7348e6c6b
commit 1ab6b87c7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 976 additions and 2 deletions

View File

@ -57,6 +57,11 @@ import {
getEffectiveLayoutForWorktree as getEffectiveLayout,
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
} from './terminal/split-group-mount'
import {
getTerminalWorktreeColdParkRecheckDelayMs,
selectColdParkedTerminalWorktrees,
type TerminalWorktreeColdParkCandidate
} from './terminal/terminal-worktree-parking'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
import CodexRestartChip from './CodexRestartChip'
@ -103,6 +108,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'con
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
function haveSameWorktreeIds(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
function findUnifiedTabByVisibleId(
state: TerminalStoreSnapshot,
worktreeId: string,
@ -181,11 +198,15 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
function Terminal(): React.JSX.Element | null {
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const parkedTerminalWorktreeIdsRef = useRef(new Set<string>())
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
const allWorktrees = useAllWorktrees()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const renderedActiveWorktreeId = activeWorktreeId
const activeView = useAppStore((s) => s.activeView)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
@ -651,7 +672,8 @@ function Terminal(): React.JSX.Element | null {
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
// spawning when restoring a session with many saved worktree tabs.
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
const [, setBackgroundMountRevision] = useState(0)
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
useEffect(() => {
const timers = measurableBackgroundWorktreeTimersRef.current
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
@ -701,6 +723,108 @@ function Terminal(): React.JSX.Element | null {
closeDialogDebounceTimers.clear()
}
}, [])
useEffect(() => {
const timers = terminalWorktreeParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
useEffect(() => {
const parkingTimers = terminalWorktreeParkingTimersRef.current
for (const timer of parkingTimers.values()) {
window.clearTimeout(timer)
}
parkingTimers.clear()
const nowMs = Date.now()
let changed = false
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
const currentWorktreeIds = new Set(allWorktrees.map((worktree) => worktree.id))
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
}
}
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
for (const worktree of allWorktrees) {
const worktreeId = worktree.id
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
continue
}
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
}
retentionCandidates.push({
worktreeId,
terminalTabs: tabsByWorktree[worktreeId] ?? [],
isVisible,
shouldMeasureHiddenWorktree,
hasActivityTerminalPortal,
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
})
}
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
worktrees: retentionCandidates,
pendingStartupByTabId,
nowMs
})
if (!haveSameWorktreeIds(parkedTerminalWorktreeIdsRef.current, nextParkedTerminalWorktreeIds)) {
parkedTerminalWorktreeIdsRef.current = nextParkedTerminalWorktreeIds
changed = true
}
for (const candidate of retentionCandidates) {
if (
candidate.isVisible ||
candidate.shouldMeasureHiddenWorktree ||
candidate.hasActivityTerminalPortal ||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
) {
continue
}
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
hiddenSinceMs: candidate.hiddenSinceMs,
nowMs
})
if (delayMs !== null && delayMs > 0) {
const worktreeId = candidate.worktreeId
const timer = window.setTimeout(() => {
parkingTimers.delete(worktreeId)
setTerminalParkingRevision((revision) => revision + 1)
}, delayMs)
parkingTimers.set(worktreeId, timer)
}
}
if (changed) {
setTerminalParkingRevision((revision) => revision + 1)
}
}, [
activeView,
activityTerminalPortals,
allWorktrees,
backgroundMountRevision,
pendingStartupByTabId,
renderedActiveWorktreeId,
tabsByWorktree,
terminalParkingRevision
])
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
@ -1665,6 +1789,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIdsRef.current.has(worktree.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${worktree.id}`}
@ -1674,6 +1802,7 @@ function Terminal(): React.JSX.Element | null {
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
activityTerminalPortals={activityTerminalPortals}
/>
)
@ -1724,6 +1853,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIdsRef.current.has(worktree.id)
return (
<div
key={worktree.id}
@ -1745,6 +1878,9 @@ function Terminal(): React.JSX.Element | null {
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
return null
}
const terminalPane = (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
@ -1933,6 +2069,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
focusedGroupId,
isVisible,
shouldMeasureHiddenWorktree,
shouldColdParkTerminalPanes,
activityTerminalPortals
}: {
worktreeId: string
@ -1941,6 +2078,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
focusedGroupId?: string
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
shouldColdParkTerminalPanes: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
}): React.JSX.Element {
const browserPageIds = useAppStore(
@ -1978,6 +2116,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
activityTerminalPortals={activityTerminalPortals}
/>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
@ -9,6 +9,11 @@ import {
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import TerminalPane from './TerminalPane'
import {
getTerminalTabColdParkRecheckDelayMs,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from '../terminal/terminal-worktree-parking'
type TerminalOverlayAssignment = {
groupId: string
@ -35,6 +40,18 @@ type TerminalOverlaySlotProps = {
leaveWorktreeIfEmpty: () => void
}
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
terminalTabId,
terminalGeneration,
@ -134,11 +151,13 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
isWorktreeActive,
coldParkTerminalPanes = false,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
}: {
worktreeId: string
worktreePath: string
isWorktreeActive: boolean
coldParkTerminalPanes?: boolean
activityTerminalPortals?: ActivityTerminalPortalTarget[]
}): React.JSX.Element | null {
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
@ -154,6 +173,13 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
const closeTab = useAppStore((state) => state.closeTab)
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel)
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
() => new Set()
)
// Why: legacy TabGroupPanel routed terminal closes through
// commands.closeItem → leaveWorktreeIfEmpty, which deselected the worktree
@ -199,6 +225,98 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
return entries
}, [groupActiveTabById, unifiedTabs])
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
const nowMs = Date.now()
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
const portalTabIds = new Set(
activityTerminalPortals
.filter((portal) => portal.worktreeId === worktreeId)
.map((portal) => portal.tabId)
)
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
if (!currentTerminalTabIds.has(tabId)) {
terminalTabHiddenSinceRef.current.delete(tabId)
}
}
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
if (isVisible || hasActivityTerminalPortal) {
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
} else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
}
return {
id: terminalTab.id,
ptyId: terminalTab.ptyId,
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
isVisible,
hasActivityTerminalPortal,
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
}
})
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
worktreeId,
terminalTabs: candidates,
pendingStartupByTabId,
nowMs
})
setColdParkedTerminalTabIds((current) =>
haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds)
? current
: nextColdParkedTerminalTabIds
)
for (const candidate of candidates) {
if (
candidate.isVisible ||
candidate.hasActivityTerminalPortal ||
nextColdParkedTerminalTabIds.has(candidate.id)
) {
continue
}
const delayMs = getTerminalTabColdParkRecheckDelayMs({
hiddenSinceMs: candidate.hiddenSinceMs,
nowMs
})
if (delayMs !== null && delayMs > 0) {
const tabId = candidate.id
const timer = window.setTimeout(() => {
timers.delete(tabId)
setTerminalTabParkingRevision((revision) => revision + 1)
}, delayMs)
timers.set(tabId, timer)
}
}
}, [
activityTerminalPortals,
assignments,
isWorktreeActive,
pendingStartupByTabId,
terminalTabParkingRevision,
terminalTabs,
worktreeId
])
if (!worktreePath) {
return null
}
@ -213,6 +331,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
tabId: terminalTab.id
})
if (
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
!activityTerminalPortal
) {
return null
}
return (
<TerminalOverlaySlot
key={terminalTab.id}

View File

@ -3654,6 +3654,43 @@ describe('connectPanePty', () => {
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached')
})
it('restores a detached local PTY from the reattach snapshot when remounted', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return { id: sessionId, snapshot: 'snapshot-after-parking' }
}
return null
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }]
},
settings: {
...mockStoreState.settings
}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'wt-1@@detached-local' })
)
expect(transport.attach).not.toHaveBeenCalled()
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function))
expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-after-parking', expect.any(Function))
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'wt-1@@detached-local')
})
it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()

View File

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_TAB_HOT_RETAIN_MS,
TERMINAL_WORKTREE_PARK_DELAY_MS,
getTerminalTabColdParkRecheckDelayMs,
selectColdParkedTerminalTabs
} from './terminal-worktree-parking'
describe('selectColdParkedTerminalTabs', () => {
const nowMs = 500_000
function localTab(id: string, hiddenSinceMs: number) {
return {
id,
ptyId: `wt-1@@session-${id}`,
pendingActivationSpawn: false,
isVisible: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
}
}
it('keeps visible and recent inactive terminal tabs mounted', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
{ ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true },
localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set())
})
it('cold-parks the oldest inactive local tabs beyond the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set(['tab-3']))
})
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 12
})
expect(selected).toEqual(new Set(['tab-1']))
})
it('does not cold-park inactive terminal tabs without local snapshot recovery', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
{
...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
ptyId: 'ssh:ssh-1@@pty-1'
},
{
...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
ptyId: 'remote:env-1@@terminal-1'
}
],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set(['tab-local']))
})
it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
{
...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
hasActivityTerminalPortal: true
},
localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
{
...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
pendingActivationSpawn: true
}
],
pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } },
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
})
describe('getTerminalTabColdParkRecheckDelayMs', () => {
it('returns the next terminal-tab cold-park policy deadline', () => {
expect(
getTerminalTabColdParkRecheckDelayMs({
hiddenSinceMs: null,
nowMs: 1_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
expect(
getTerminalTabColdParkRecheckDelayMs({
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(50)
expect(
getTerminalTabColdParkRecheckDelayMs({
hiddenSinceMs: 1_000,
nowMs: 1_100,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(900)
expect(
getTerminalTabColdParkRecheckDelayMs({
hiddenSinceMs: 1_000,
nowMs: 2_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
})

View File

@ -0,0 +1,283 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_WORKTREE_HOT_RETAIN_MS,
TERMINAL_WORKTREE_PARK_DELAY_MS,
canParkTerminalWorktreeRenderers,
getTerminalWorktreeColdParkRecheckDelayMs,
selectColdParkedTerminalWorktrees,
isSnapshotBackedTerminalPty
} from './terminal-worktree-parking'
describe('isSnapshotBackedTerminalPty', () => {
it('allows local daemon sessions owned by the worktree', () => {
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
})
it('allows legacy local PTY IDs that reattach through the local session path', () => {
expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(true)
})
it('rejects tabs that do not have a PTY yet', () => {
expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false)
})
it('rejects daemon sessions owned by another worktree', () => {
expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false)
})
it('rejects SSH and remote runtime PTY handles', () => {
expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false)
})
})
describe('canParkTerminalWorktreeRenderers', () => {
const hiddenSinceMs = 1_000
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
it('parks hidden local terminal renderers after the idle delay', () => {
expect(
canParkTerminalWorktreeRenderers({
worktreeId: 'repo::/worktree',
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
pendingStartupByTabId: {},
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs,
nowMs
})
).toBe(true)
})
it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => {
const base = {
worktreeId: 'repo::/worktree',
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
pendingStartupByTabId: {},
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs,
nowMs
}
expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false)
expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe(
false
)
expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe(
false
)
expect(
canParkTerminalWorktreeRenderers({
...base,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1
})
).toBe(false)
})
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
expect(
canParkTerminalWorktreeRenderers({
worktreeId: 'repo::/worktree',
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' },
{ id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' }
],
pendingStartupByTabId: {},
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs,
nowMs
})
).toBe(false)
})
it('keeps renderers mounted while a tab has startup or activation work pending', () => {
const base = {
worktreeId: 'repo::/worktree',
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
pendingStartupByTabId: {},
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs,
nowMs
}
expect(
canParkTerminalWorktreeRenderers({
...base,
pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } }
})
).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true }
]
})
).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 }
]
})
).toBe(false)
})
})
describe('selectColdParkedTerminalWorktrees', () => {
const nowMs = 500_000
function localCandidate(worktreeId: string, hiddenSinceMs: number) {
return {
worktreeId,
terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }],
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
}
}
it('keeps recent hidden local worktrees hot up to the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set())
})
it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set(['wt-3']))
})
it('cold-parks aged local worktrees even when under the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 4
})
expect(selected).toEqual(new Set(['wt-1']))
})
it('does not cold-park terminals without local snapshot recovery', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
{
...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }]
},
{
...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }]
}
],
pendingStartupByTabId: {},
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set(['wt-local']))
})
it('keeps visible, measuring, portaled, and pending terminals mounted', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
{
...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
isVisible: true
},
{
...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
shouldMeasureHiddenWorktree: true
},
{
...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
hasActivityTerminalPortal: true
},
{
...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [
{
id: 'tab-activation',
ptyId: 'wt-activation@@session-1',
pendingActivationSpawn: true
}
]
},
localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)
],
pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } },
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
})
describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
it('returns the next cold-park policy deadline', () => {
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
hiddenSinceMs: null,
nowMs: 1_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(50)
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
hiddenSinceMs: 1_000,
nowMs: 1_100,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(900)
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
hiddenSinceMs: 1_000,
nowMs: 2_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
})

View File

@ -0,0 +1,244 @@
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { TerminalTab } from '../../../../shared/types'
import { isRemoteRuntimePtyId } from '../../runtime/runtime-terminal-inspection'
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4
export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000
export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
export type TerminalWorktreeColdParkCandidate = {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
}
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
isVisible: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
}
function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
if (value === true) {
return 1
}
return typeof value === 'number' && value > 0 ? value : 0
}
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
if (!ptyId) {
return false
}
if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
return false
}
const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
return separatorIdx === -1 || ptyId.slice(0, separatorIdx) === worktreeId
}
export function canParkTerminalWorktreeRenderers(args: {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
}): boolean {
if (
args.isVisible ||
args.shouldMeasureHiddenWorktree ||
args.hasActivityTerminalPortal ||
args.hiddenSinceMs === null
) {
return false
}
if (
args.nowMs - args.hiddenSinceMs <
(args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
) {
return false
}
return args.terminalTabs.every((tab) => {
if (args.pendingStartupByTabId[tab.id] !== undefined) {
return false
}
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
})
}
export function canParkTerminalTabRenderer(args: {
worktreeId: string
terminalTab: TerminalTabColdParkCandidate
pendingStartupByTabId: Readonly<Record<string, unknown>>
nowMs: number
coldParkDelayMs?: number
}): boolean {
const tab = args.terminalTab
if (tab.isVisible || tab.hasActivityTerminalPortal || tab.hiddenSinceMs === null) {
return false
}
if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) {
return false
}
if (args.pendingStartupByTabId[tab.id] !== undefined) {
return false
}
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
}
export function selectColdParkedTerminalWorktrees(args: {
worktrees: readonly TerminalWorktreeColdParkCandidate[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
}): Set<string> {
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
const hotRetainMs = args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
const hotRetainLimit = Math.max(0, args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT)
const coldParkedWorktreeIds = new Set<string>()
const retainedCandidates: { worktreeId: string; hiddenSinceMs: number }[] = []
for (const worktree of args.worktrees) {
if (
!canParkTerminalWorktreeRenderers({
...worktree,
pendingStartupByTabId: args.pendingStartupByTabId,
nowMs: args.nowMs,
coldParkDelayMs
})
) {
continue
}
const hiddenSinceMs = worktree.hiddenSinceMs
if (hiddenSinceMs === null) {
continue
}
if (args.nowMs - hiddenSinceMs >= hotRetainMs) {
coldParkedWorktreeIds.add(worktree.worktreeId)
continue
}
retainedCandidates.push({
worktreeId: worktree.worktreeId,
hiddenSinceMs
})
}
retainedCandidates.sort((a, b) => {
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
return recencyDelta === 0 ? a.worktreeId.localeCompare(b.worktreeId) : recencyDelta
})
for (const candidate of retainedCandidates.slice(hotRetainLimit)) {
coldParkedWorktreeIds.add(candidate.worktreeId)
}
return coldParkedWorktreeIds
}
export function selectColdParkedTerminalTabs(args: {
worktreeId: string
terminalTabs: readonly TerminalTabColdParkCandidate[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
}): Set<string> {
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
const hotRetainMs = args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
const hotRetainLimit = Math.max(0, args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT)
const coldParkedTabIds = new Set<string>()
const retainedCandidates: { tabId: string; hiddenSinceMs: number }[] = []
for (const tab of args.terminalTabs) {
if (
!canParkTerminalTabRenderer({
worktreeId: args.worktreeId,
terminalTab: tab,
pendingStartupByTabId: args.pendingStartupByTabId,
nowMs: args.nowMs,
coldParkDelayMs
})
) {
continue
}
const hiddenSinceMs = tab.hiddenSinceMs
if (hiddenSinceMs === null) {
continue
}
if (args.nowMs - hiddenSinceMs >= hotRetainMs) {
coldParkedTabIds.add(tab.id)
continue
}
retainedCandidates.push({
tabId: tab.id,
hiddenSinceMs
})
}
retainedCandidates.sort((a, b) => {
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
return recencyDelta === 0 ? a.tabId.localeCompare(b.tabId) : recencyDelta
})
for (const candidate of retainedCandidates.slice(hotRetainLimit)) {
coldParkedTabIds.add(candidate.tabId)
}
return coldParkedTabIds
}
export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
}): number | null {
if (args.hiddenSinceMs === null) {
return null
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
const hotRetainMs = args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
const nextRecheckAtMs = [args.hiddenSinceMs + coldParkDelayMs, args.hiddenSinceMs + hotRetainMs]
.filter((deadlineMs) => deadlineMs > args.nowMs)
.sort((a, b) => a - b)[0]
return nextRecheckAtMs === undefined ? null : nextRecheckAtMs - args.nowMs
}
export function getTerminalTabColdParkRecheckDelayMs(args: {
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
}): number | null {
if (args.hiddenSinceMs === null) {
return null
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
const hotRetainMs = args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
const nextRecheckAtMs = [args.hiddenSinceMs + coldParkDelayMs, args.hiddenSinceMs + hotRetainMs]
.filter((deadlineMs) => deadlineMs > args.nowMs)
.sort((a, b) => a - b)[0]
return nextRecheckAtMs === undefined ? null : nextRecheckAtMs - args.nowMs
}