mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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:
co-authored by
m4air
thefrikidude
parent
9dfc0bac93
commit
fb313c4745
@@ -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)
|
||||
|
||||
@@ -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]))
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
+39
@@ -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()
|
||||
|
||||
+26
-2
@@ -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
|
||||
|
||||
+38
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user