perf(terminal): park and defer restored plain-shell tabs whose sessions no longer exist (#39725)

Restored tabs whose local daemon session is gone no longer mount and
respawn a shell at startup; they park and respawn on open.

- Separate pty:getGoneSessionIds probe, started unawaited at startup so
  it never gates reconnect; the authoritative-capability call is unchanged.
- Binding a PTY id (spawn, cold restore, attach) clears its gone mark so a
  revived pane gets parked exit/byte watchers again; late answers for bound
  ids are ignored.
- Agent tabs (launchAgent or sleeping record) and mixed live/gone splits
  keep the mounted restore path.

Replaces #37410.

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: thefrikidude <100475418+thefrikidude@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-07 21:11:15 -08:00
committed by GitHub
co-authored by m4air thefrikidude
parent 9dfc0bac93
commit fb313c4745
18 changed files with 309 additions and 75 deletions
+49 -33
View File
@@ -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<string[]> => {
const ids = new Set<string>()
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<string>()
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<boolean | null> => {
if (typeof args?.id !== 'string' || isRemoteRuntimePtyId(args.id)) {
// Remote runtime handles need no local startup wait and cannot be inspected authoritatively here.
@@ -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)
+2
View File
@@ -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<string[]>
hasPty: (id: string) => Promise<boolean | null>
moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise<TerminalLeafMoveResult>
getMainBufferSnapshot: (
@@ -194,6 +194,8 @@ export const ptySessionControlApi = {
ids: string[]
): Promise<{ id: string; authoritative: boolean | null }[]> =>
ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }),
getGoneSessionIds: (ids: string[]): Promise<string[]> =>
ipcRenderer.invoke('pty:getGoneSessionIds', { ids }),
hasPty: (id: string): Promise<boolean | null> => ipcRenderer.invoke('pty:hasPty', { id }),
moveLeafToNewTab: (request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> =>
ipcRenderer.invoke('pty:moveLeafToNewTab', request),
@@ -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', () =>
@@ -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]))
@@ -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
}
@@ -82,6 +82,7 @@ type MockStoreState = {
}
>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
sleepingAgentSessionsByPaneKey: Record<string, { tabId?: string }>
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()
@@ -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<typeof useAppStore.getState>
): 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}:`)
)
)
}
@@ -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<void> => {
if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) {
return
@@ -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<ReturnType<typeof window.api.pty.spawn>>
let resolveSpawn: (value: SpawnResult) => void = () => {}
vi.mocked(window.api.pty.spawn).mockReturnValueOnce(
new Promise<SpawnResult>((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' })
@@ -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
@@ -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
}
@@ -0,0 +1,63 @@
import { publishTerminalProviderCapabilityChange } from './terminal-provider-capability-revision'
const goneSessionPtyIds = new Set<string>()
// A bound PTY is live under its id, so a late gone answer for it is stale.
const boundSessionPtyIds = new Set<string>()
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<string[]>) | undefined = window.api.pty.getGoneSessionIds
): Promise<void> {
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<string>): 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
}
@@ -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<SnapshotCapability[]>
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()
}
}
@@ -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.
@@ -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(() => ({
@@ -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.