fix(remote): stabilize shared control and terminal parking (#11656)

* fix(remote): stabilize shared control and terminal parking

* fix(remote): harden parking review edge cases

* fix(terminal): restore parked local floating buffer

* fix(ci): drop superseded paired parking evidence

* fix(terminal): preserve floating park watchers

* fix(ci): include web client in paired e2e artifact

* fix(ci): reuse renderer build for paired e2e
This commit is contained in:
Brennan Benson
2026-07-31 00:22:53 -07:00
committed by GitHub
parent ef9e6ab9a8
commit e467b3ff7b
34 changed files with 1425 additions and 97 deletions
+11
View File
@@ -6736,6 +6736,17 @@ describe('registerPtyHandlers', () => {
})
})
it('settles a stale renderer process inspection as unavailable', async () => {
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider({ hasPty: vi.fn(() => false) } as never)
await expect(handlers.get('pty:inspectProcess')!(null, { id: 'gone-pty' })).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
})
})
// Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync).
describe('pty:getSize reports applied size, not requested size', () => {
function setupProviderWithAppliedSize(args: {
+5 -2
View File
@@ -68,7 +68,10 @@ import { isPwshAvailable } from '../pwsh'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { inspectPtyProviderProcess } from '../providers/pty-process-inspection'
import {
inspectPtyProviderProcess,
inspectPtyProviderProcessForRenderer
} from '../providers/pty-process-inspection'
import {
PtyProcessListAdmission,
visitPtyProcessListingsInBatches
@@ -6178,7 +6181,7 @@ export function registerPtyHandlers(
)
ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
inspectPtyProviderProcess(getProviderForPty(args.id), args.id)
inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
)
ipcMain.handle(
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import type { IPtyProvider } from './types'
import { inspectPtyProviderProcess } from './pty-process-inspection'
import {
inspectPtyProviderProcess,
inspectPtyProviderProcessForRenderer
} from './pty-process-inspection'
describe('PTY provider process inspection', () => {
it('rejects a missing provider PTY instead of returning idle evidence', async () => {
@@ -25,6 +28,27 @@ describe('PTY provider process inspection', () => {
expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
})
it('returns unavailable to the renderer when a stale PTY is gone', async () => {
const provider = {
hasPty: vi.fn(() => false)
} as unknown as IPtyProvider
await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-missing')).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
})
})
it('preserves non-stale renderer inspection failures', async () => {
const failure = new Error('daemon unavailable')
const provider = {
inspectProcess: vi.fn().mockRejectedValue(failure)
} as unknown as IPtyProvider
await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-1')).rejects.toBe(failure)
})
it('preserves an unavailable inspection result', async () => {
const inspection = {
foregroundProcess: null,
@@ -25,3 +25,17 @@ export async function inspectPtyProviderProcess(
const hasChildProcesses = await provider.hasChildProcesses(ptyId)
return { foregroundProcess, hasChildProcesses }
}
export async function inspectPtyProviderProcessForRenderer(
provider: IPtyProvider,
ptyId: string
): Promise<PtyProcessInspection> {
try {
return await inspectPtyProviderProcess(provider, ptyId)
} catch (error) {
if (error instanceof Error && error.message === 'terminal_gone') {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
throw error
}
}
+16 -5
View File
@@ -89,6 +89,7 @@ import { getTerminalWorktreeColdParkRecheckDelayMs } from './terminal-pane/termi
import {
TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
canParkTerminalWorktreeRenderers,
isParkRestorableTerminalPty,
selectPairedRuntimeParkingEnvironmentIds,
selectColdParkedTerminalWorktrees,
type TerminalWorktreeColdParkCandidate
@@ -112,7 +113,8 @@ import {
disposeAllParkedTerminalWatchers,
pruneParkedTerminalWatchers,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
syncParkedTerminalTabWatchers,
terminalWatcherLiveWorkspaceIds
} from './terminal-pane/terminal-parked-tab-watchers'
import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
@@ -1204,8 +1206,15 @@ function Terminal(): React.JSX.Element | null {
}
}
const activationHostSupportsDeferral = canDeferColdActivationTabsForHost({
executionHostId: activeWorktreeDeferralHostId
executionHostId: activeWorktreeDeferralHostId,
pairedRuntimeParkingEnvironmentIds
})
const isColdActivationPtyEligible = (ptyId: string): boolean =>
isRemoteRuntimePtyId(ptyId)
? isParkRestorableTerminalPty(ptyId, renderedActiveWorktreeId, {
pairedRuntimeParkingEnvironmentIds
})
: terminalProviderHasAuthoritativeSnapshot(ptyId)
if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) {
lastActivationWorktreeIdRef.current = renderedActiveWorktreeId
const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab]))
@@ -1226,7 +1235,7 @@ function Terminal(): React.JSX.Element | null {
canWatcherCoverParkedTerminalTab(
renderedActiveWorktreeId,
tab,
terminalProviderHasAuthoritativeSnapshot
isColdActivationPtyEligible
)
)
},
@@ -1243,7 +1252,7 @@ function Terminal(): React.JSX.Element | null {
!canWatcherCoverParkedTerminalTab(
renderedActiveWorktreeId,
tab,
terminalProviderHasAuthoritativeSnapshot
isColdActivationPtyEligible
)
) {
immediateTabIds.add(tab.id)
@@ -1286,7 +1295,9 @@ function Terminal(): React.JSX.Element | null {
)
// Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer.
useEffect(() => {
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
pruneParkedTerminalWatchers(
terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id))
)
for (const workspace of workspaceSurfaces) {
if (
anyMountedWorktreeHasLayout &&
@@ -137,6 +137,7 @@ const mocks = vi.hoisted(() => ({
setTabColor: vi.fn(),
setTabCustomTitle: vi.fn(),
setTabPaneExpanded: vi.fn(),
shouldDeferParkedPtyExitTabClose: vi.fn(),
useContextualTour: vi.fn()
}))
@@ -144,6 +145,10 @@ const saveDialogBox = vi.hoisted(() => ({
fileId: null as string | null
}))
const parkingBox = vi.hoisted(() => ({
parkedTabIds: new Set<string>()
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return {
@@ -205,6 +210,14 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({
}
}))
vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', () => ({
useTerminalTabColdParking: () => parkingBox.parkedTabIds
}))
vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', () => ({
shouldDeferParkedPtyExitTabClose: mocks.shouldDeferParkedPtyExitTabClose
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({
isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing
}))
@@ -574,6 +587,22 @@ function findByTypeName(node: unknown, typeName: string): ReactElementLike {
return found
}
function findAllByTypeName(node: unknown, typeName: string): ReactElementLike[] {
const found: ReactElementLike[] = []
visit(node, (entry) => {
const candidate =
typeof entry.type === 'function' || typeof entry.type === 'object'
? ((entry.type as { displayName?: string; name?: string }).displayName ??
(entry.type as { displayName?: string; name?: string }).name ??
'')
: entry.type
if (candidate === typeName) {
found.push(entry)
}
})
return found
}
function findByProp(node: unknown, propName: string): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
@@ -780,6 +809,7 @@ describe('FloatingTerminalPanel close behavior', () => {
hookRuntime.index = 0
hookRuntime.values = []
saveDialogBox.fileId = null
parkingBox.parkedTabIds = new Set()
resetStore()
// Why: the open-maximized intent is a module singleton; drain any leftover
// from a prior test so it cannot bleed into an unrelated render.
@@ -800,6 +830,7 @@ describe('FloatingTerminalPanel close behavior', () => {
mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false)
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
mocks.shouldDeferParkedPtyExitTabClose.mockReturnValue(false)
const localStorage = {
clear: vi.fn(),
getItem: vi.fn(() => null),
@@ -1477,6 +1508,20 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(openPane.props.isVisible).toBe(true)
})
it('does not mount floating terminal panes selected for cold parking', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })])
parkingBox.parkedTabIds = new Set(['tab-2'])
await renderPanel(true)
runEffects()
await Promise.resolve()
const element = await renderPanel(true)
expect(findAllByTypeName(element, 'TerminalPane').map((pane) => pane.props.tabId)).toEqual([
'tab-1'
])
})
it('routes titlebar Cmd+T to the floating workspace', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
const element = await renderPanel(true)
@@ -2483,11 +2528,16 @@ describe('FloatingTerminalPanel close behavior', () => {
const element = await renderPanel(true, onOpenChange)
const terminalPane = findByTypeName(element, 'TerminalPane')
;(terminalPane.props.onPtyExit as () => void)()
expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'pty-exit' })
;(terminalPane.props.onPtyExit as (ptyId: string) => void)('pty-1')
expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'pty-1')
expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-1', {
lifecyclePtyId: 'pty-1',
reason: 'pty-exit'
})
expect(mocks.closeTab).not.toHaveBeenCalled()
expect(onOpenChange).not.toHaveBeenCalled()
mocks.closeTab.mockClear()
mocks.closeTerminalTab.mockClear()
;(terminalPane.props.onCloseTab as () => void)()
// Explicit pane close routes through the confirmed-close authority, not a raw pty-exit prune.
expect(mocks.closeTerminalTab).toHaveBeenCalledWith(
@@ -2498,6 +2548,23 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(onOpenChange).not.toHaveBeenCalled()
})
it('preserves split siblings when a parked PTY exits during reveal', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
mocks.shouldDeferParkedPtyExitTabClose.mockReturnValueOnce(true)
await renderPanel(true)
runEffects()
await Promise.resolve()
const element = await renderPanel(true)
const terminalPane = findByTypeName(element, 'TerminalPane')
;(terminalPane.props.onPtyExit as (ptyId: string) => void)('split-pty')
expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'split-pty')
expect(mocks.closeTerminalTab).not.toHaveBeenCalled()
expect(mocks.closeTab).not.toHaveBeenCalled()
})
it('renders and closes simulator tabs in the floating workspace', async () => {
const tab = setFloatingSimulatorTab()
@@ -12,6 +12,8 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual-
import TabBar from '@/components/tab-bar/TabBar'
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
import TerminalPane, { type TerminalPaneHandle } from '@/components/terminal-pane/TerminalPane'
import { shouldDeferParkedPtyExitTabClose } from '@/components/terminal-pane/terminal-parked-tab-watchers'
import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking'
import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy'
import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
import { Button } from '@/components/ui/button'
@@ -109,6 +111,7 @@ import { translate } from '@/i18n/i18n'
import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal'
import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inputs'
const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const
const NO_ACTIVITY_TERMINAL_PORTALS = []
const EditorPanel = lazy(() => import('@/components/editor/EditorPanel'))
@@ -349,6 +352,27 @@ export function FloatingTerminalPanel({
? activeTab.entityId
: null
const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs])
const terminalAssignments = useMemo(() => {
const assignments = new Map<string, { groupId: string; isActiveInGroup: boolean }>()
for (const tab of unifiedTabs) {
if (tab.contentType === 'terminal') {
assignments.set(tab.entityId, {
groupId: tab.groupId,
isActiveInGroup: tab.entityId === activeTerminalId
})
}
}
return assignments
}, [activeTerminalId, unifiedTabs])
const parkedTerminalTabIds = useTerminalTabColdParking({
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
terminalTabs: tabs,
assignments: terminalAssignments,
isWorktreeActive: open,
coldParkTerminalPanes: false,
shouldMeasureHiddenWorktree: false,
activityTerminalPortals: NO_ACTIVITY_TERMINAL_PORTALS
})
const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>(
() =>
groupTabs
@@ -1817,33 +1841,43 @@ export function FloatingTerminalPanel({
}
>
{cwd
? tabs.map((tab) => {
const isActive = tab.id === activeTerminalId
return (
<div
key={`${tab.id}-${tab.generation ?? 0}`}
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<TerminalPane
ref={terminalPaneRegistry.getRefCallback(tab.id)}
tabId={tab.id}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
cwd={cwd}
isActive={isActive}
// Why: the closed panel is only CSS-hidden, so gate
// visibility on `open` too. This routes the floating
// terminal through the standard hidden-terminal
// suspend/resume path: no live WebGL context (or glyph
// atlas to corrupt) while hidden, and the resume on
// reopen rebuilds the renderer from scratch.
isVisible={isActive && open}
onPtyExit={() => closeTab(tab.id, { reason: 'pty-exit' })}
onCloseTab={() => closeFloatingItemConfirmed(tab.id)}
/>
</div>
)
})
? tabs
.filter((tab) => !parkedTerminalTabIds.has(tab.id))
.map((tab) => {
const isActive = tab.id === activeTerminalId
return (
<div
key={`${tab.id}-${tab.generation ?? 0}`}
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<TerminalPane
ref={terminalPaneRegistry.getRefCallback(tab.id)}
tabId={tab.id}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
cwd={cwd}
isActive={isActive}
// Why: the closed panel is only CSS-hidden, so gate
// visibility on `open` too. This routes the floating
// terminal through the standard hidden-terminal
// suspend/resume path: no live WebGL context (or glyph
// atlas to corrupt) while hidden, and the resume on
// reopen rebuilds the renderer from scratch.
isVisible={isActive && open}
onPtyExit={(ptyId) => {
if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) {
return
}
closeTerminalTab(tab.id, {
reason: 'pty-exit',
lifecyclePtyId: ptyId
})
}}
onCloseTab={() => closeFloatingItemConfirmed(tab.id)}
/>
</div>
)
})
: null}
{browserTabs.map((tab) => {
const isActive = tab.id === activeBrowserTab?.id
@@ -55,10 +55,10 @@ async function drainFakeTimerWork(limit = 20): Promise<void> {
// 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> {
async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise<void> {
const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry')
parkedWatchersByTabId.set(tabId, {
worktreeId: 'wt-1',
worktreeId,
tabPtyId: ptyId,
paneIdByPtyId: new Map([[ptyId, 1]]),
disposersByPtyId: new Map([[ptyId, () => {}]])
@@ -20243,6 +20243,62 @@ describe('connectPanePty', () => {
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
})
it('restores a local main-model snapshot after a contentless park reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const localPtyId = 'global-floating-terminal@@terminal-1'
const transport = createMockTransport(localPtyId)
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
transport.getPtyId.mockReturnValue(localPtyId)
return sessionId ? { id: localPtyId, isReattach: true } : null
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
getMainBufferSnapshot.mockResolvedValue({
data: 'FLOATING-PARK-RESTORE-OK\r\n',
cols: 113,
rows: 32,
seq: 558,
source: 'headless'
})
await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal')
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'global-floating-terminal': [{ id: 'tab-1', ptyId: localPtyId }]
},
ptyIdsByTabId: { 'tab-1': [localPtyId] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: localPtyId }
}
}
}
const pane = createPane(1)
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
worktreeId: 'global-floating-terminal',
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: localPtyId }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
for (let step = 0; step < 30; step += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(2)
}
expect(getMainBufferSnapshot).toHaveBeenCalledOnce()
expect(getMainBufferSnapshot).toHaveBeenCalledWith(localPtyId, { scrollbackRows: 5000 })
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: localPtyId })
)
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId)
expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK')
})
it('falls back to relay replay when the SSH model snapshot stalls', async () => {
const { connectPanePty } = await import('./pty-connection')
const { SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS } = await import('./ssh-reattach-model-restore')
@@ -7822,7 +7822,9 @@ export function connectPanePty(
// the subscribe screen without keeping the old xterm mounted.
let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null
if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) {
if (isRemoteRuntimePtyId(ptyId)) {
if (parseAppSshPtyId(ptyId)) {
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
} else {
try {
prefetchedParkModelSnapshot = await serializeHiddenOutputSnapshot(ptyId, {
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
@@ -7830,8 +7832,6 @@ export function connectPanePty(
} catch {
prefetchedParkModelSnapshot = null
}
} else {
prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot()
}
if (!isCurrentReattachPayload()) {
return false
@@ -35,7 +35,8 @@ export {
disposeParkedTerminalWatchersForPtyIds,
disposeParkedTerminalWatchersForWorktree,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers
pruneParkedTerminalWatchers,
terminalWatcherLiveWorkspaceIds
} from './terminal-parked-watcher-registry'
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
export {
@@ -8,16 +8,24 @@ import {
hasPreHandlerPtyExit
} from './pty-pre-handler-buffer'
import {
capturedPanesByTabId,
parkedWatchersByTabId,
pruneParkedTerminalWatchers
pruneParkedTerminalWatchers,
terminalWatcherLiveWorkspaceIds
} from './terminal-parked-watcher-registry'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
const TAB_ID = 'removed-parked-tab'
const PTY_ID = 'removed-worktree@@parked-pty'
const FLOATING_TAB_ID = 'floating-parked-tab'
const FLOATING_PTY_ID = `${FLOATING_TERMINAL_WORKTREE_ID}@@parked-pty`
describe('terminal parked watcher registry removal', () => {
afterEach(() => {
parkedWatchersByTabId.delete(TAB_ID)
parkedWatchersByTabId.delete(FLOATING_TAB_ID)
capturedPanesByTabId.delete(TAB_ID)
capturedPanesByTabId.delete(FLOATING_TAB_ID)
clearPreHandlerPtyState(PTY_ID)
})
@@ -51,4 +59,35 @@ describe('terminal parked watcher registry removal', () => {
expect(exit).not.toHaveBeenCalled()
expect(delayedData).not.toHaveBeenCalled()
})
it('keeps the floating workspace while pruning a removed workspace', () => {
const removedDispose = vi.fn()
const floatingDispose = vi.fn()
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: 'removed-worktree',
tabPtyId: PTY_ID,
paneIdByPtyId: new Map([[PTY_ID, 1]]),
disposersByPtyId: new Map([[PTY_ID, removedDispose]])
})
parkedWatchersByTabId.set(FLOATING_TAB_ID, {
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
tabPtyId: FLOATING_PTY_ID,
paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]),
disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]])
})
capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] })
capturedPanesByTabId.set(FLOATING_TAB_ID, {
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
panes: []
})
pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds([]))
expect(parkedWatchersByTabId.has(TAB_ID)).toBe(false)
expect(capturedPanesByTabId.has(TAB_ID)).toBe(false)
expect(removedDispose).toHaveBeenCalledOnce()
expect(parkedWatchersByTabId.has(FLOATING_TAB_ID)).toBe(true)
expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true)
expect(floatingDispose).not.toHaveBeenCalled()
})
})
@@ -8,6 +8,7 @@
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
*/
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
export type ParkedTerminalPaneCapture = {
ptyId: string | null
@@ -51,6 +52,11 @@ export function getParkedTerminalWatcherTabIds(): string[] {
return Array.from(parkedWatchersByTabId.keys())
}
// Why: the floating workspace is synthetic, so repo/folder surface lists never include it.
export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable<string>): Set<string> {
return new Set([...workspaceIds, FLOATING_TERMINAL_WORKTREE_ID])
}
/**
* 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),
@@ -6,6 +6,7 @@ import type { TerminalTab } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
storeState: {
pendingStartupByTabId: {} as Record<string, unknown>,
runtimeStatusByEnvironmentId: new Map(),
settings: {} as Record<string, unknown>,
terminalLayoutsByTabId: {} as Record<string, { ptyIdsByLeafId?: Record<string, string> }>
},
@@ -75,6 +76,34 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
mocks.exemptTabIds = new Set()
mocks.exemptSelectCalls = 0
mocks.storeState.terminalLayoutsByTabId = {}
mocks.storeState.runtimeStatusByEnvironmentId = new Map()
})
it('parks paired-runtime tabs only when their exact host advertises restore', () => {
const environmentId = 'paired-env'
const remoteArgs = {
...hookArgs(false),
terminalTabs: [
{ ...terminalTab('tab-1'), ptyId: `remote:${environmentId}@@term-1` },
{ ...terminalTab('tab-2'), ptyId: `remote:${environmentId}@@term-2` }
]
}
for (const [advertisedEnvironmentId, expected] of [
[environmentId, new Set(['tab-2'])],
['other-env', new Set()]
] as const) {
mocks.storeState.runtimeStatusByEnvironmentId = new Map([
[advertisedEnvironmentId, { status: { capabilities: ['terminal.paired-parking.v1'] } }]
])
const { result, unmount } = renderHook(() => useTerminalTabColdParking(remoteArgs))
act(() => {
vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
})
expect(result.current).toEqual(expected)
unmount()
}
})
// Why: the worktree layer preserves hiddenSince through a background-measure
@@ -16,6 +16,7 @@ import {
import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines'
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
selectPairedRuntimeParkingEnvironmentIds,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from './terminal-hidden-view-parking'
@@ -90,6 +91,11 @@ export function useTerminalTabColdParking(args: {
const terminalSshParkingEnabled = useAppStore(
(state) => state.settings?.terminalSshViewParking !== false
)
const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId)
const pairedRuntimeParkingEnvironmentIds = useMemo(
() => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId),
[runtimeStatusByEnvironmentId]
)
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
// Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince
// survives a background-measure window so per-tab park deadlines stay in
@@ -189,7 +195,10 @@ export function useTerminalTabColdParking(args: {
parkingEnabled: terminalParkingEnabled,
nowMs,
parkCooldownUntilMs: measureParkCooldownUntilRef.current,
restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
restorePolicy: {
sshParkingEnabled: terminalSshParkingEnabled,
pairedRuntimeParkingEnvironmentIds
},
...overrides
})
// Why: a tab the byte watchers cannot cover (no capture, no layout
@@ -238,6 +247,7 @@ export function useTerminalTabColdParking(args: {
assignments,
isWorktreeActive,
pendingStartupByTabId,
pairedRuntimeParkingEnvironmentIds,
shouldMeasureHiddenWorktree,
terminalParkingEnabled,
terminalSshParkingEnabled,
@@ -205,10 +205,22 @@ describe('cold activation tab deferral', () => {
const tabIds = (count: number): string[] =>
Array.from({ length: count }, (_, index) => `tab-${index + 1}`)
it('enables deferral only for a positively resolved local execution host', () => {
it('enables deferral for local and snapshot-capable paired execution hosts', () => {
expect(canDeferColdActivationTabsForHost({ executionHostId: 'local' })).toBe(true)
expect(canDeferColdActivationTabsForHost({ executionHostId: 'ssh:ssh-1' })).toBe(false)
expect(canDeferColdActivationTabsForHost({ executionHostId: 'runtime:runtime-1' })).toBe(false)
expect(
canDeferColdActivationTabsForHost({
executionHostId: 'runtime:runtime-1',
pairedRuntimeParkingEnvironmentIds: new Set(['runtime-1'])
})
).toBe(true)
expect(
canDeferColdActivationTabsForHost({
executionHostId: 'runtime:runtime-1',
pairedRuntimeParkingEnvironmentIds: new Set(['runtime-2'])
})
).toBe(false)
expect(canDeferColdActivationTabsForHost({ executionHostId: null })).toBe(false)
})
@@ -2,6 +2,7 @@ import {
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
type BackgroundMountTerminalWorktreeDetail
} from '@/constants/terminal'
import { parseExecutionHostId } from '../../../../shared/execution-host'
const pendingMounts = new Map<string, BackgroundMountTerminalWorktreeDetail>()
const requestListeners = new Set<() => void>()
@@ -153,10 +154,18 @@ export function canMountTerminalWorkspaceForStartup(args: {
export function canDeferColdActivationTabsForHost(args: {
executionHostId: string | null
pairedRuntimeParkingEnvironmentIds?: ReadonlySet<string>
}): boolean {
// Why: restored identities can be transient or stale while remote ownership
// hydrates. Only positively confirmed local execution has daemon snapshots.
return args.executionHostId === 'local'
const host = parseExecutionHostId(args.executionHostId)
if (host?.kind === 'local') {
return true
}
// Why: remote ownership must match the exact host advertising bounded
// snapshots; stale runtime identities stay eager instead of losing output.
return (
host?.kind === 'runtime' &&
args.pairedRuntimeParkingEnvironmentIds?.has(host.environmentId) === true
)
}
function replaceActivationDeferredMountTabs(
@@ -12,10 +12,12 @@ import {
} from './remote-runtime-shared-control-ready'
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
import { requestSharedControl } from './remote-runtime-shared-control-requests'
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability'
import * as sharedControlState from './remote-runtime-shared-control-state'
import * as sharedControlSend from './remote-runtime-shared-control-send'
import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close'
import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close'
import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness'
import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions'
import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start'
@@ -43,6 +45,7 @@ export class RemoteRuntimeSharedControlConnection {
private lastError: string | null = null
private readonly pendingRequests = new Map<string, SharedControlPendingRequest<unknown>>()
private readonly subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
private readonly retiredRequestIds = new SharedControlRetiredRequestIds()
private readonly readyWaiters: SharedControlReadyWaiter[] = []
private everReady = false
private readonly socketGeneration = new SharedControlSocketGeneration()
@@ -72,7 +75,8 @@ export class RemoteRuntimeSharedControlConnection {
params,
timeoutMs,
ensureReady: () => this.ensureReadyWithTimeout(timeoutMs),
send: (requestId) => this.sendRequest(requestId)
send: (requestId) => this.sendRequest(requestId),
retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId)
})
}
@@ -203,6 +207,7 @@ export class RemoteRuntimeSharedControlConnection {
deviceToken: this.pairing.deviceToken,
pendingRequests: this.pendingRequests,
subscriptions: this.subscriptions,
retiredRequestIds: this.retiredRequestIds,
readyWaiters: this.readyWaiters,
setState: (state) => {
this.state = state
@@ -256,10 +261,12 @@ export class RemoteRuntimeSharedControlConnection {
}
private closeSubscription(requestId: string): void {
sharedControlSubscriptions.closeSharedControlLogicalSubscription({
closeSharedControlConnectionSubscription({
subscriptions: this.subscriptions,
subscription: this.subscriptions.get(requestId),
request: (method, params) => this.sendSubscriptionCleanupRequest(method, params)
retiredRequestIds: this.retiredRequestIds,
requestId,
deviceToken: this.pairing.deviceToken,
send: (payload) => this.sendEncrypted(payload)
})
this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed')
}
@@ -273,15 +280,6 @@ export class RemoteRuntimeSharedControlConnection {
})
}
private sendSubscriptionCleanupRequest(method: string, params: unknown): void {
sharedControlSubscriptions.sendSharedControlCleanupRequest({
deviceToken: this.pairing.deviceToken,
method,
params,
send: (payload) => this.sendEncrypted(payload)
})
}
private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void {
if (
!this.socketGeneration.acceptClose({
@@ -1,9 +1,8 @@
import type { parseRemoteRuntimeRpcFrame } from './remote-runtime-request-frames'
import { logUnknownSharedControlResponse } from './remote-runtime-shared-control-diagnostics-log'
import {
handleSharedControlLogicalResponse,
sendSharedControlCleanupRequest
} from './remote-runtime-shared-control-subscriptions'
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
import { sendRetiredSharedControlCleanupRequest } from './remote-runtime-shared-control-subscription-close'
import { handleSharedControlLogicalResponse } from './remote-runtime-shared-control-subscriptions'
import {
refreshSharedControlPendingRequestTimeouts,
resolveSharedControlPendingResponse
@@ -20,6 +19,7 @@ export function dispatchSharedControlFrame(args: {
frame: SharedControlFrame
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
deviceToken: string
send: (payload: unknown) => boolean
}): void {
@@ -36,18 +36,27 @@ export function dispatchSharedControlFrame(args: {
subscription,
response,
request: (method, params) =>
sendSharedControlCleanupRequest({
sendRetiredSharedControlCleanupRequest({
retiredRequestIds: args.retiredRequestIds,
deviceToken: args.deviceToken,
method,
params,
send: args.send
})
})
if (!args.subscriptions.has(response.id)) {
args.retiredRequestIds.retire(response.id)
}
return
}
if (args.pendingRequests.has(response.id)) {
resolveSharedControlPendingResponse(args.pendingRequests, response.id, response)
args.retiredRequestIds.retire(response.id)
return
}
if (args.retiredRequestIds.has(response.id)) {
return
}
@@ -3,6 +3,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version'
import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch'
import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol'
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
import { resolveSharedControlReadyWaiters } from './remote-runtime-shared-control-state'
import type {
SharedControlConnectionState,
@@ -19,6 +20,7 @@ export function handleSharedControlTextFrame(args: {
environmentId?: string
pendingRequests: Map<string, SharedControlPendingRequest<unknown>>
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
readyWaiters: SharedControlReadyWaiter[]
setState: (state: SharedControlConnectionState) => void
handleSocketClosed: (error: RemoteRuntimeClientError) => void
@@ -65,6 +67,7 @@ export function handleSharedControlTextFrame(args: {
frame: parsed.frame,
pendingRequests: args.pendingRequests,
subscriptions: args.subscriptions,
retiredRequestIds: args.retiredRequestIds,
deviceToken: args.deviceToken,
send: args.sendEncrypted
})
@@ -21,6 +21,7 @@ export function requestSharedControl<TResult>(args: {
timeoutMs: number
ensureReady: () => Promise<void>
send: (requestId: string) => void
retireRequestId?: (requestId: string) => void
// Why: default off — ordinary short RPCs keep an absolute deadline. Only
// long-polls routed through this path opt in so keepalives extend them.
refreshTimeoutOnKeepalive?: boolean
@@ -48,6 +49,7 @@ export function requestSharedControl<TResult>(args: {
}
pendingRequests.delete(requestId)
releaseRemoteRuntimePreparedRequest(pending)
args.retireRequestId?.(requestId)
// Why: one stalled method does not prove the shared socket is dead;
// socket liveness owns connection-wide teardown so other RPCs survive.
pending.reject(remoteRuntimeTimeoutError())
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
describe('SharedControlRetiredRequestIds', () => {
it('retains recent ids through repeated late frames and expires them', () => {
let now = 1_000
const ids = new SharedControlRetiredRequestIds({
ttlMs: 100,
now: () => now
})
ids.retire('request-1')
expect(ids.has('request-1')).toBe(true)
expect(ids.has('request-1')).toBe(true)
now += 101
expect(ids.has('request-1')).toBe(false)
expect(ids.size).toBe(0)
})
it('evicts the oldest ids at its configured bound', () => {
const ids = new SharedControlRetiredRequestIds({ maxIds: 2 })
ids.retire('request-1')
ids.retire('request-2')
ids.retire('request-3')
expect(ids.size).toBe(2)
expect(ids.has('request-1')).toBe(false)
expect(ids.has('request-2')).toBe(true)
expect(ids.has('request-3')).toBe(true)
})
it('refreshes an existing id to the newest eviction rank', () => {
const ids = new SharedControlRetiredRequestIds({ maxIds: 2 })
ids.retire('request-1')
ids.retire('request-2')
ids.retire('request-1')
ids.retire('request-3')
expect(ids.has('request-1')).toBe(true)
expect(ids.has('request-2')).toBe(false)
expect(ids.has('request-3')).toBe(true)
})
it('expires ids correctly after the clock moves backward', () => {
let now = 1_000
const ids = new SharedControlRetiredRequestIds({
ttlMs: 100,
now: () => now
})
ids.retire('request-1')
now = 900
ids.retire('request-2')
now = 1_001
expect(ids.has('request-1')).toBe(true)
expect(ids.has('request-2')).toBe(false)
})
})
@@ -0,0 +1,53 @@
const DEFAULT_MAX_RETIRED_REQUEST_IDS = 2_048
const DEFAULT_RETIRED_REQUEST_ID_TTL_MS = 60_000
export class SharedControlRetiredRequestIds {
private readonly ids = new Map<string, number>()
private readonly maxIds: number
private readonly ttlMs: number
private readonly now: () => number
constructor(
options: {
maxIds?: number
ttlMs?: number
now?: () => number
} = {}
) {
this.maxIds = Math.max(1, options.maxIds ?? DEFAULT_MAX_RETIRED_REQUEST_IDS)
this.ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_RETIRED_REQUEST_ID_TTL_MS)
this.now = options.now ?? Date.now
}
retire(requestId: string): void {
const now = this.now()
this.pruneExpired(now)
this.ids.delete(requestId)
this.ids.set(requestId, now + this.ttlMs)
while (this.ids.size > this.maxIds) {
const oldestId = this.ids.keys().next().value
if (oldestId === undefined) {
return
}
this.ids.delete(oldestId)
}
}
has(requestId: string): boolean {
this.pruneExpired(this.now())
return this.ids.has(requestId)
}
get size(): number {
this.pruneExpired(this.now())
return this.ids.size
}
private pruneExpired(now: number): void {
for (const [requestId, expiresAt] of this.ids) {
if (expiresAt <= now) {
this.ids.delete(requestId)
}
}
}
}
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
import {
closeSharedControlConnectionSubscription,
sendRetiredSharedControlCleanupRequest
} from './remote-runtime-shared-control-subscription-close'
import { createSharedControlSubscription } from './remote-runtime-shared-control-subscriptions'
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
describe('shared-control subscription retirement', () => {
it('retires a closed subscription and its cleanup request', () => {
const subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
const subscription = createSharedControlSubscription({
requestId: 'request-1',
method: 'runtime.clientEvents.subscribe',
params: null,
retainedParamsBytes: 0,
callbacks: { onResponse: vi.fn(), onError: vi.fn() }
})
subscription.sent = true
subscription.remoteSubscriptionId = 'subscription-1'
subscriptions.set(subscription.requestId, subscription)
const retiredRequestIds = new SharedControlRetiredRequestIds()
let cleanupRequestId = ''
closeSharedControlConnectionSubscription({
subscriptions,
retiredRequestIds,
requestId: subscription.requestId,
deviceToken: 'device-token',
send: (payload) => {
cleanupRequestId = (payload as { id: string }).id
return true
}
})
expect(subscriptions.size).toBe(0)
expect(retiredRequestIds.has(subscription.requestId)).toBe(true)
expect(retiredRequestIds.has(cleanupRequestId)).toBe(true)
})
it('does not retire an unsent cleanup request', () => {
const retiredRequestIds = new SharedControlRetiredRequestIds()
let cleanupRequestId = ''
sendRetiredSharedControlCleanupRequest({
retiredRequestIds,
deviceToken: 'device-token',
method: 'runtime.clientEvents.unsubscribe',
params: null,
send: (payload) => {
cleanupRequestId = (payload as { id: string }).id
return false
}
})
expect(retiredRequestIds.has(cleanupRequestId)).toBe(false)
})
})
@@ -0,0 +1,44 @@
import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids'
import {
closeSharedControlLogicalSubscription,
sendSharedControlCleanupRequest
} from './remote-runtime-shared-control-subscriptions'
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
export function closeSharedControlConnectionSubscription(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
requestId: string
deviceToken: string
send: (payload: unknown) => boolean
}): void {
const subscription = args.subscriptions.get(args.requestId)
closeSharedControlLogicalSubscription({
subscriptions: args.subscriptions,
subscription,
request: (method, params) =>
sendRetiredSharedControlCleanupRequest({
retiredRequestIds: args.retiredRequestIds,
deviceToken: args.deviceToken,
method,
params,
send: args.send
})
})
if (subscription && !args.subscriptions.has(args.requestId)) {
args.retiredRequestIds.retire(args.requestId)
}
}
export function sendRetiredSharedControlCleanupRequest(args: {
retiredRequestIds: SharedControlRetiredRequestIds
deviceToken: string
method: string
params: unknown
send: (payload: unknown) => boolean
}): void {
const requestId = sendSharedControlCleanupRequest(args)
if (requestId) {
args.retiredRequestIds.retire(requestId)
}
}
@@ -86,15 +86,17 @@ export function sendSharedControlCleanupRequest(args: {
method: string
params: unknown
send: (payload: unknown) => boolean
}): void {
}): string | null {
// Why: cleanup is best-effort and often runs during teardown; send it
// synchronously so close() cannot race the async request path.
args.send({
id: randomUUID(),
const requestId = randomUUID()
const sent = args.send({
id: requestId,
deviceToken: args.deviceToken,
method: args.method,
params: args.params
})
return sent ? requestId : null
}
export function replaySharedControlSubscriptions(args: {