diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 674ed0176d2..bf2a4743ef5 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -11,6 +11,8 @@ import { type ResolvedPtyHost } from '../provider/registry' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { parsePtySessionId } from '../../../../shared/terminal/pty/lifecycle/pty-session-id-format' +import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { installPtySessionListingIpcHandler } from './session-listing' import { ptySizes } from '../delivery/visibility-state' import { isValidPaneKey } from '../pane/key-state' @@ -34,49 +36,63 @@ export function installPtyInspectIpcHandlers(deps: { installPtySessionListingIpcHandler(deps) + // Await the local provider only when a local id is present; validation drops empty, oversized and repeated ids. + const readInspectablePtyIds = async (args: { ids?: unknown }): Promise => { + const ids = new Set() + for (const value of Array.isArray(args?.ids) ? args.ids.slice(0, 512) : []) { + if (typeof value === 'string' && value.length > 0 && value.length <= 512) { + ids.add(value) + } + } + if ([...ids].some((id) => resolvePtyExecutionHost(id) === LOCAL_EXECUTION_HOST_ID)) { + await getLocalPtyProviderStartupPromise() + } + return [...ids] + } + ipcMain.handle( 'pty:getAuthoritativeBufferSnapshotCapabilities', - async (_event, args: { ids?: unknown }) => { - const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] - const hasLocalPtyId = ids.some( - (value) => - typeof value === 'string' && - value.length > 0 && - value.length <= 512 && - resolvePtyExecutionHost(value) === LOCAL_EXECUTION_HOST_ID - ) - if (hasLocalPtyId) { - await getLocalPtyProviderStartupPromise() - } - const capabilities: { id: string; authoritative: boolean | null }[] = [] - const seen = new Set() - for (const value of ids) { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - seen.has(value) - ) { - continue - } - seen.add(value) - const provider = tryGetProviderForPty(value) + async (_event, args: { ids?: unknown }) => + (await readInspectablePtyIds(args)).map((id) => { + const provider = tryGetProviderForPty(id) // Missing inspection support is non-authoritative; unattached SSH relays remain retryable. - capabilities.push({ - id: value, + return { + id, authoritative: !provider - ? isDispatchablePtyHost(resolvePtyExecutionHost(value)) + ? isDispatchablePtyHost(resolvePtyExecutionHost(id)) ? null : false : provider.canProvideAuthoritativeBufferSnapshot - ? provider.canProvideAuthoritativeBufferSnapshot(value) + ? provider.canProvideAuthoritativeBufferSnapshot(id) : false - }) - } - return capabilities - } + } + }) ) + ipcMain.removeHandler('pty:getGoneSessionIds') + // Only a local provider's explicit absent answer marks a session gone; SSH and remote hosts keep their own parking rules. + ipcMain.handle('pty:getGoneSessionIds', async (_event, args: { ids?: unknown }) => { + // Bare legacy ids resolve local by fallback and may be old SSH panes; only daemon-minted ids qualify. + const ids = (await readInspectablePtyIds(args)).filter( + (id) => + resolvePtyExecutionHost(id) === LOCAL_EXECUTION_HOST_ID && + parsePtySessionId(id).worktreeId !== null + ) + // Bounded so a large restore cannot flood the daemon socket during reconnect. + const gone = await mapWithConcurrency(ids, 8, async (id) => { + const provider = tryGetProviderForPty(id) + if (!provider?.probePtyLiveness || provider.canProvideAuthoritativeBufferSnapshot?.(id)) { + return false + } + try { + return (await provider.probePtyLiveness(id)) === false + } catch { + return false + } + }) + return ids.filter((_id, index) => gone[index]) + }) + ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { if (typeof args?.id !== 'string' || isRemoteRuntimePtyId(args.id)) { // Remote runtime handles need no local startup wait and cannot be inspected authoritatively here. diff --git a/src/main/ipc/pty/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty/pty-session-liveness-and-ownership.test.ts index ce1fd1022d2..159ca4d5809 100644 --- a/src/main/ipc/pty/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty/pty-session-liveness-and-ownership.test.ts @@ -50,7 +50,31 @@ vi.mock('../../codex/history/index/codex-state-db-backfill-recovery', () => ) describe('registerPtyHandlers', () => { - const { handlers, mainWindow } = setupPtyIpcSuite() + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + it('reports only daemon-minted local sessions the provider answers are absent', async () => { + const minted = (name: string): string => `repo::/worktree@@${name}` + registerPtyHandlers(mainWindow as never) + installDaemonTestProvider({ + canProvideAuthoritativeBufferSnapshot: (id: string) => id === minted('snapshot'), + probePtyLiveness: async (id: string) => + id === minted('live') ? true : id === minted('unobserved') ? null : false + }) + + await expect( + handlers.get('pty:getGoneSessionIds')!(null, { + ids: [ + minted('dead'), + minted('live'), + minted('unobserved'), + minted('snapshot'), + // A legacy bare id resolves local by fallback but may be an old SSH pane. + 'legacy-bare-pty', + 'remote:env-1@@terminal-1' + ] + }) + ).resolves.toEqual([minted('dead')]) + }) it('never answers liveness for a paired-runtime handle from the local registry', async () => { const hasPty = vi.fn(() => false) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index a2f0817337c..d8293daa96d 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -198,6 +198,8 @@ export type PtyApi = { getAuthoritativeBufferSnapshotCapabilities?: ( ids: string[] ) => Promise<{ id: string; authoritative: boolean | null }[]> + /** Local sessions the provider answers are absent; omitted where no local main owns sessions. */ + getGoneSessionIds?: (ids: string[]) => Promise hasPty: (id: string) => Promise moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise getMainBufferSnapshot: ( diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 99e12dff8c3..ee2e6dc7e2d 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -194,6 +194,8 @@ export const ptySessionControlApi = { ids: string[] ): Promise<{ id: string; authoritative: boolean | null }[]> => ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), + getGoneSessionIds: (ids: string[]): Promise => + ipcRenderer.invoke('pty:getGoneSessionIds', { ids }), hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise => ipcRenderer.invoke('pty:moveLeafToNewTab', request), diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index a189e116dfb..a8c203d9de5 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -29,6 +29,7 @@ import { collectTerminalProviderSnapshotPtyIds, refreshTerminalProviderSnapshotCapabilities } from '../components/terminal/terminal-provider-snapshot-capability' +import { probeTerminalProviderGoneSessions } from '../components/terminal/terminal-provider-gone-sessions' import { getRepoExecutionHostId, isOwnedSshTargetId, @@ -262,10 +263,11 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) + const restoredPtyIds = collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) + // Unawaited: liveness answers only widen parking later and must never delay reconnect. + void probeTerminalProviderGoneSessions(restoredPtyIds) await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => - refreshTerminalProviderSnapshotCapabilities( - collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) - ) + refreshTerminalProviderSnapshotCapabilities(restoredPtyIds) ) reconnectStarted = true await timeRendererStartupStep('reconnect-terminals', () => diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index c3ed4c81207..652bda9a579 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -15,7 +15,7 @@ import { anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } fro import { isParkRestorableTerminalPty } from './terminal-pane/parking/retention/terminal-hidden-view-parking' import { canWatcherCoverParkedTerminalTab } from './terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime/remote-runtime-pty-id' -import { terminalProviderHasAuthoritativeSnapshot } from './terminal/terminal-provider-snapshot-capability' +import { terminalProviderAllowsParking } from './terminal/terminal-provider-snapshot-capability' import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' export function applyTerminalColdActivation(controller: TerminalParkingFoundation) { @@ -107,7 +107,7 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio ? isParkRestorableTerminalPty(ptyId, renderedActiveWorktreeId, { pairedRuntimeParkingEnvironmentIds }) - : terminalProviderHasAuthoritativeSnapshot(ptyId) + : terminalProviderAllowsParking(ptyId) if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) { lastActivationWorktreeIdRef.current = renderedActiveWorktreeId const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab])) diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts index a41c3727e52..9791d646ae4 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts @@ -2,6 +2,7 @@ import { isTerminalLeafId } from '../../../../../../shared/workspace-layout/stab import { isProvenProcessExit } from '../../../../../../shared/terminal/pty/lifecycle/terminal-exit-cause' import { isRemoteRuntimePtyId } from '../../../../../../shared/remote-runtime/remote-runtime-pty-id' import { useAppStore } from '@/store/index' +import { terminalProviderSessionIsGone } from '../../../terminal/terminal-provider-gone-sessions' import { closeTerminalTab } from '../../../terminal/terminal-tab-actions' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { subscribeToPtyExit } from '../../pty-transport/delivery/pty-dispatcher' @@ -51,7 +52,9 @@ export function startParkedPtyWatcher(args: { !ptyId || entry.disposersByPtyId.has(ptyId) || !isTerminalLeafId(pane.leafId) || - !isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy) + !isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy) || + // A gone session has no process to watch; reveal respawns it. + terminalProviderSessionIsGone(ptyId) ) { return } diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts index a08a62981c5..0a0cf207692 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts @@ -82,6 +82,7 @@ type MockStoreState = { } > runtimePaneTitlesByTabId: Record> + sleepingAgentSessionsByPaneKey: Record settings: { terminalSshViewParking?: boolean } | null runtimeStatusByEnvironmentId: Map< string, @@ -108,6 +109,11 @@ import { synchronizeTerminalProviderSnapshotCapabilities } from '../../../terminal/terminal-provider-snapshot-capability' import { + markTerminalProviderSessionBound, + probeTerminalProviderGoneSessions +} from '../../../terminal/terminal-provider-gone-sessions' +import { + canWatcherCoverParkedTerminalTab, shouldDeferParkedPtyExitTabClose, syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers' @@ -150,6 +156,7 @@ describe('terminal-parked-tab-watchers', () => { tabsByWorktree: {}, terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, settings: null, runtimeStatusByEnvironmentId: new Map(), clearTabLaunchAgent: vi.fn(), @@ -212,6 +219,38 @@ describe('terminal-parked-tab-watchers', () => { expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) }) + it('closes a parked tab when its PTY exits after the gone mark is cleared', async () => { + await probeTerminalProviderGoneSessions([PTY_ID], async () => [PTY_ID]) + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + markTerminalProviderSessionBound(PTY_ID) + syncParked() + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) + + expect(closeTerminalTab).toHaveBeenCalledWith(TAB_ID, expect.anything()) + }) + + it('keeps a split tab mounted when only some pane sessions are gone', async () => { + await probeTerminalProviderGoneSessions([PTY_ID], async () => [PTY_ID]) + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(false) + }) + + it('keeps a gone agent tab mounted so its resume path is unchanged', async () => { + await probeTerminalProviderGoneSessions([PTY_ID], async () => [PTY_ID]) + mockStoreState.tabsByWorktree[WORKTREE_ID] = [ + { id: TAB_ID, ptyId: PTY_ID, launchAgent: 'claude' } + ] + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(false) + }) + it('consumes the exit and drops the parked entry after the last-watcher close resolves', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts index 9c581cc6014..d960715dac7 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts @@ -3,7 +3,8 @@ import { isTerminalLeafId } from '../../../../../../shared/workspace-layout/stab import { useAppStore } from '@/store/index' import { isRemoteExecutionHostPtyId } from '../../../../../../shared/remote-execution-host-pty-id' import { discardPreHandlerPtyState } from '../../pty-transport/delivery/pty-pre-handler-buffer' -import { terminalProviderHasAuthoritativeSnapshot } from '../../../terminal/terminal-provider-snapshot-capability' +import { terminalProviderAllowsParking } from '../../../terminal/terminal-provider-snapshot-capability' +import { terminalProviderSessionIsGone } from '../../../terminal/terminal-provider-gone-sessions' import { isParkRestorableTerminalPty, type TerminalParkRestorePolicy @@ -27,7 +28,7 @@ import { export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean const allowOrdinaryParkRestore = (ptyId: string): boolean => - isRemoteExecutionHostPtyId(ptyId) || terminalProviderHasAuthoritativeSnapshot(ptyId) + isRemoteExecutionHostPtyId(ptyId) || terminalProviderAllowsParking(ptyId) // SSH fact watchers use local main too; coverage follows restore policy, not daemon-only snapshots. function parkRestorePolicyFromState(state: { @@ -63,6 +64,29 @@ export function canWatcherCoverParkedTerminalTab( isTerminalLeafId(pane.leafId) && isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) && isPtyEligible(pane.ptyId) + ) && + !goneSessionsKeepTabMounted(worktreeId, tab, panes, state) + ) +} + +/** A live sibling's exit would close a parked tab whose gone panes carry no watcher; an agent tab keeps its mounted restore path. */ +function goneSessionsKeepTabMounted( + worktreeId: string, + tab: ParkableTerminalTabModel, + panes: readonly ParkedTerminalPaneCapture[], + state: ReturnType +): boolean { + const goneCount = panes.filter( + (pane) => pane.ptyId !== null && terminalProviderSessionIsGone(pane.ptyId) + ).length + if (goneCount === 0) { + return false + } + return ( + goneCount < panes.length || + state.tabsByWorktree[worktreeId]?.some((t) => t.id === tab.id && t.launchAgent) === true || + Object.entries(state.sleepingAgentSessionsByPaneKey).some( + ([paneKey, record]) => record.tabId === tab.id || paneKey.startsWith(`${tab.id}:`) ) ) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts index 1fa9509c354..d0806ac1424 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts @@ -13,6 +13,7 @@ import { isPreHandlerPtyStateDiscarded } from '../delivery/pty-pre-handler-buffer' import { projectIpcPtyConnectResult } from './ipc-pty-connect-result' +import { markTerminalProviderSessionBound } from '../../../terminal/terminal-provider-gone-sessions' import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2e-barrier' import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from '../host-ownership/ssh-session-gone-verdict' @@ -107,6 +108,8 @@ export async function connectIpcPty( trustAnswer === 'agent-asks' ) settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, spawnResult.id) + // Any resolved spawn or cold restore is live under this id, even if this transport was destroyed meanwhile. + markTerminalProviderSessionBound(spawnResult.id) const retireDisposedFreshSpawn = async (): Promise => { if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { return diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts index fbd43513a36..25866b80bbf 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts @@ -390,6 +390,44 @@ describe('createIpcPtyTransport', () => { coldTransport.disconnect() }) + it('stops gone-parking a session whose cold restore resolves after its transport was destroyed', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const gone = await import('../../../terminal/terminal-provider-gone-sessions') + const { canWatcherCoverParkedTerminalTab } = + await import('../../parking/output-watchers/terminal-parked-tab-watchers') + const { captureParkedTerminalPaneCandidates } = + await import('../../parking/output-watchers/terminal-parked-watcher-registry') + const REVIVED_PTY_ID = 'repo::/wt@@revived' + const tab = { id: 'tab-revived', ptyId: REVIVED_PTY_ID } + captureParkedTerminalPaneCandidates(tab.id, 'repo::/wt', [ + { + ptyId: REVIVED_PTY_ID, + paneId: 1, + leafId: '11111111-1111-4111-8111-111111111111', + drivesTabTitle: true + } + ]) + await gone.probeTerminalProviderGoneSessions([REVIVED_PTY_ID], async () => [REVIVED_PTY_ID]) + expect(canWatcherCoverParkedTerminalTab('repo::/wt', tab)).toBe(true) + type SpawnResult = Awaited> + let resolveSpawn: (value: SpawnResult) => void = () => {} + vi.mocked(window.api.pty.spawn).mockReturnValueOnce( + new Promise((resolve) => { + resolveSpawn = resolve + }) + ) + + const transport = createIpcPtyTransport({}) + const connecting = transport.connect({ url: '', sessionId: REVIVED_PTY_ID, callbacks: {} }) + await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalled()) + transport.destroy?.() + resolveSpawn({ id: REVIVED_PTY_ID, coldRestore: { scrollback: '', cwd: '/repo' } }) + await connecting + + // Live again without an authoritative snapshot, so it must stay mounted where its own watchers run. + expect(canWatcherCoverParkedTerminalTab('repo::/wt', tab)).toBe(false) + }) + it('passes startup commands through PTY spawn instead of writing them after connect', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-1' }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts index a417c398521..e9912c7aad3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts @@ -2,6 +2,7 @@ import { getConnectionExecutionHostId } from '../../../../../../shared/execution import { attachIpcPty } from './ipc-pty-attach' import { connectIpcPty } from './ipc-pty-connect' import { createIpcPtySessionHandlers } from './ipc-pty-session-handlers' +import { markTerminalProviderSessionBound } from '../../../terminal/terminal-provider-gone-sessions' import { createPtyInputWriteQueue } from '../input/pty-input-write-queue' import { createPtyOutputProcessor } from '../output/pty-output-processor' import { createPtyPreconnectInputBuffer } from '../input/pty-preconnect-input-buffer' @@ -79,6 +80,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const bind = (id: string): void => { ptyId = id connected = true + markTerminalProviderSessionBound(id) } const setCallbacks = (callbacks: typeof storedCallbacks): void => { storedCallbacks = callbacks diff --git a/src/renderer/src/components/terminal/terminal-provider-capability-revision.ts b/src/renderer/src/components/terminal/terminal-provider-capability-revision.ts new file mode 100644 index 00000000000..574200d15b6 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-provider-capability-revision.ts @@ -0,0 +1,18 @@ +let capabilityRevision = 0 +const capabilityRevisionListeners = new Set<() => void>() + +export function publishTerminalProviderCapabilityChange(): void { + capabilityRevision += 1 + for (const listener of capabilityRevisionListeners) { + listener() + } +} + +export function subscribeTerminalProviderSnapshotCapability(listener: () => void): () => void { + capabilityRevisionListeners.add(listener) + return () => capabilityRevisionListeners.delete(listener) +} + +export function getTerminalProviderSnapshotCapabilityRevision(): number { + return capabilityRevision +} diff --git a/src/renderer/src/components/terminal/terminal-provider-gone-sessions.ts b/src/renderer/src/components/terminal/terminal-provider-gone-sessions.ts new file mode 100644 index 00000000000..c4f96a75620 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-provider-gone-sessions.ts @@ -0,0 +1,63 @@ +import { publishTerminalProviderCapabilityChange } from './terminal-provider-capability-revision' + +const goneSessionPtyIds = new Set() +// A bound PTY is live under its id, so a late gone answer for it is stale. +const boundSessionPtyIds = new Set() +let goneProbeGeneration = 0 + +/** Startup only: restored ids the provider says no longer exist. Never awaited before reconnect; unknown ids keep today's mounted path. */ +export async function probeTerminalProviderGoneSessions( + ptyIds: readonly string[], + resolveGone: ((ids: string[]) => Promise) | undefined = window.api.pty.getGoneSessionIds +): Promise { + const generation = goneProbeGeneration + const candidates = ptyIds.filter((id) => id.length > 0 && !boundSessionPtyIds.has(id)) + if (!resolveGone || candidates.length === 0) { + return + } + const sizeBefore = goneSessionPtyIds.size + for (let offset = 0; offset < candidates.length; offset += 512) { + const gone = await resolveGone(candidates.slice(offset, offset + 512)).catch(() => []) + if (generation !== goneProbeGeneration) { + return + } + for (const id of gone.filter((goneId) => !boundSessionPtyIds.has(goneId))) { + goneSessionPtyIds.add(id) + } + } + if (goneSessionPtyIds.size !== sizeBefore) { + publishTerminalProviderCapabilityChange() + } +} + +/** Spawn or attach proves the session lives under this id, so a pane revived after a gone answer gets ordinary watchers again. */ +export function markTerminalProviderSessionBound(ptyId: string): void { + boundSessionPtyIds.add(ptyId) + if (goneSessionPtyIds.delete(ptyId)) { + publishTerminalProviderCapabilityChange() + } +} + +/** The provider answered that this restored session no longer exists, so a pane remount respawns it and nothing is lost by parking. */ +export function terminalProviderSessionIsGone(ptyId: string): boolean { + return goneSessionPtyIds.has(ptyId) +} + +export function pruneTerminalProviderGoneSessions(live: ReadonlySet): void { + for (const ids of [goneSessionPtyIds, boundSessionPtyIds]) { + for (const id of ids) { + if (!live.has(id)) { + ids.delete(id) + } + } + } +} + +/** Returns whether any gone answer was dropped, so the caller can publish one change. */ +export function clearTerminalProviderGoneSessions(): boolean { + const hadGone = goneSessionPtyIds.size > 0 + goneSessionPtyIds.clear() + boundSessionPtyIds.clear() + goneProbeGeneration += 1 + return hadGone +} diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts index 421e8d73002..e590f093abf 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts @@ -1,3 +1,10 @@ +import { publishTerminalProviderCapabilityChange } from './terminal-provider-capability-revision' +import { + clearTerminalProviderGoneSessions, + pruneTerminalProviderGoneSessions, + terminalProviderSessionIsGone +} from './terminal-provider-gone-sessions' + type SnapshotCapability = { id: string; authoritative: boolean | null } type SnapshotCapabilityResolver = (ids: string[]) => Promise export type SnapshotCapabilityTab = { id: string; ptyId?: string | null } @@ -23,24 +30,6 @@ const CAPABILITY_RESOLUTION_TIMEOUT_MS = 1_000 let lastSynchronizedLivePtyIds: readonly string[] | null = null let earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY let synchronizationGeneration = 0 -let capabilityRevision = 0 -const capabilityRevisionListeners = new Set<() => void>() - -function publishCapabilityChange(): void { - capabilityRevision += 1 - for (const listener of capabilityRevisionListeners) { - listener() - } -} - -export function subscribeTerminalProviderSnapshotCapability(listener: () => void): () => void { - capabilityRevisionListeners.add(listener) - return () => capabilityRevisionListeners.delete(listener) -} - -export function getTerminalProviderSnapshotCapabilityRevision(): number { - return capabilityRevision -} export function collectTerminalProviderSnapshotPtyIds( state: SnapshotCapabilityBindingState @@ -141,6 +130,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( authoritativeSnapshotByPtyId.delete(cachedId) } } + pruneTerminalProviderGoneSessions(live) for (const pendingId of unknownCapabilityRetryAtByPtyId.keys()) { if (!live.has(pendingId)) { unknownCapabilityRetryAtByPtyId.delete(pendingId) @@ -160,7 +150,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( } refreshEarliestUnknownCapabilityRetry() if (capabilityChanged) { - publishCapabilityChange() + publishTerminalProviderCapabilityChange() } return unknownCapabilityRetryDelayMs(nowMs) } @@ -173,7 +163,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( if (generation !== synchronizationGeneration) { // Return zero to preserve a superseded caller’s retry chain; the winning startup pass ignores its return value. if (capabilityChanged) { - publishCapabilityChange() + publishTerminalProviderCapabilityChange() } return 0 } @@ -186,7 +176,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( if (generation !== synchronizationGeneration) { // Supersession must preserve the caller’s timer chain for later slow re-asks. if (capabilityChanged) { - publishCapabilityChange() + publishTerminalProviderCapabilityChange() } return 0 } @@ -212,7 +202,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( } refreshEarliestUnknownCapabilityRetry() if (capabilityChanged) { - publishCapabilityChange() + publishTerminalProviderCapabilityChange() } return unknownCapabilityRetryDelayMs(observedAtMs === undefined ? Date.now() : nowMs) } @@ -231,7 +221,7 @@ export async function refreshTerminalProviderSnapshotCapabilities( } refreshEarliestUnknownCapabilityRetry() if (capabilityChanged) { - publishCapabilityChange() + publishTerminalProviderCapabilityChange() } return synchronizeTerminalProviderSnapshotCapabilities(livePtyIds, resolveCapabilities) } @@ -258,10 +248,14 @@ export function terminalProviderHasAuthoritativeSnapshot(ptyId: string): boolean return authoritativeSnapshotByPtyId.get(ptyId) === true } +export function terminalProviderAllowsParking(ptyId: string): boolean { + return authoritativeSnapshotByPtyId.get(ptyId) === true || terminalProviderSessionIsGone(ptyId) +} + export function clearTerminalProviderSnapshotCapabilities(): void { - const capabilityChanged = [...authoritativeSnapshotByPtyId.values()].some( - (authoritative) => authoritative - ) + const capabilityChanged = + clearTerminalProviderGoneSessions() || + [...authoritativeSnapshotByPtyId.values()].some((authoritative) => authoritative) authoritativeSnapshotByPtyId.clear() unknownCapabilityRetryAtByPtyId.clear() unknownCapabilityAttemptsByPtyId.clear() @@ -269,6 +263,6 @@ export function clearTerminalProviderSnapshotCapabilities(): void { earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY synchronizationGeneration += 1 if (capabilityChanged) { - publishCapabilityChange() + publishTerminalProviderCapabilityChange() } } diff --git a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts index 0b2b6e69b3c..40847b9ced3 100644 --- a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts +++ b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts @@ -1,11 +1,11 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useAppStore } from '@/store' import { createTerminalProviderSnapshotBoundPtyIdsSelector } from './terminal-provider-snapshot-bound-pty-ids' +import { startTerminalProviderSnapshotCapabilitySynchronization } from './terminal-provider-snapshot-capability' import { getTerminalProviderSnapshotCapabilityRevision, - subscribeTerminalProviderSnapshotCapability, - startTerminalProviderSnapshotCapabilitySynchronization -} from './terminal-provider-snapshot-capability' + subscribeTerminalProviderSnapshotCapability +} from './terminal-provider-capability-revision' export function useTerminalProviderSnapshotCapability(enabled: boolean): number { // Match startup’s full PTY set or synchronization prunes valid split/reconnect answers; the stable selector avoids scans/restarts on title or focus writes. diff --git a/src/renderer/src/startup/startup-degraded-recovery.test.ts b/src/renderer/src/startup/startup-degraded-recovery.test.ts index 4138c4e070e..93d3531c602 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.test.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.test.ts @@ -6,6 +6,9 @@ vi.mock('../components/terminal/terminal-provider-snapshot-capability', () => ({ collectTerminalProviderSnapshotPtyIds: () => [], refreshTerminalProviderSnapshotCapabilities: async () => undefined })) +vi.mock('../components/terminal/terminal-provider-gone-sessions', () => ({ + probeTerminalProviderGoneSessions: async () => undefined +})) vi.mock('../store', async () => { const { create } = await import('zustand') const useAppStore = create(() => ({ diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index 2546e7ea6dc..8a48a3afa16 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -14,6 +14,7 @@ import { collectTerminalProviderSnapshotPtyIds, refreshTerminalProviderSnapshotCapabilities } from '../components/terminal/terminal-provider-snapshot-capability' +import { probeTerminalProviderGoneSessions } from '../components/terminal/terminal-provider-gone-sessions' type DegradedStartupRecoveryArgs = { error: unknown @@ -104,9 +105,9 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr try { await window.api.app.awaitFirstWindowStartupServices() await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - await refreshTerminalProviderSnapshotCapabilities( - collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) - ) + const restoredPtyIds = collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) + void probeTerminalProviderGoneSessions(restoredPtyIds) + await refreshTerminalProviderSnapshotCapabilities(restoredPtyIds) await reconnectPersistedTerminals(abortSignal) await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() // Reconnect does not set the success chain’s final flag; release restoration readers on degraded boot too.