From 412c33a8754abe941da7fea4e1590f3a159078d8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:12:35 -0700 Subject: [PATCH] Establish PTY daemon identity before first await in spawn flow Move identity setup, session ID minting, and hidden delivery state to the beginning of preflight, ensuring these complete synchronously before any awaited operations. Defer async operations like folder workspace validation; add liveness tracking for SSH provider failures. Refactor pane spawn reservation to prevent concurrent spawns from creating duplicate providers. --- src/main/ipc/pty/host-env/spawn-cwd.ts | 9 +-- src/main/ipc/pty/ipc/inspect.ts | 2 + src/main/ipc/pty/ipc/spawn-begin.ts | 4 +- src/main/ipc/pty/ipc/spawn-options.ts | 6 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 81 ++++++++++++++++----- src/main/ipc/pty/ipc/spawn-types.ts | 2 +- src/main/ipc/pty/register-handlers.ts | 5 +- src/main/ipc/pty/runtime/controller-deps.ts | 2 +- src/main/ipc/pty/runtime/controller.ts | 7 +- src/main/ipc/pty/runtime/kill.ts | 47 ++++++++++-- src/main/ipc/pty/runtime/operations.ts | 70 ++++++++++++++++-- src/main/ipc/pty/runtime/spawn-early.ts | 11 +-- src/main/ipc/pty/runtime/spawn-execute.ts | 6 +- src/main/ipc/pty/runtime/spawn-options.ts | 69 ++++++++++-------- src/main/ipc/pty/runtime/spawn-preflight.ts | 5 +- src/main/ipc/pty/runtime/spawn.ts | 40 +++++++++- 16 files changed, 281 insertions(+), 85 deletions(-) diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 8a12ce7bf6e..03bac6611be 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -12,20 +12,19 @@ import { } from '../../../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' -export async function assertFolderWorkspacePtyPathUsable( +export function assertFolderWorkspacePtyPathUsable( store: Store | undefined, worktreeId: string | undefined -): Promise { +): Promise | void { const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null if (!store || workspaceScope?.type !== 'folder') { return } - const status = await getFolderWorkspacePathStatus( + return getFolderWorkspacePathStatus( store, { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) + ).then(assertFolderWorkspacePathUsable) } export function resolvePtySpawnStartupCwd( diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index d95d57772ba..74cd5fb64d0 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -9,6 +9,7 @@ import { visitPtyProcessListingsInBatches } from '../../../providers/pty-process-list-admission' import type { PtyListedSession } from '../../../../shared/pty-listed-session' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' import { ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, @@ -70,6 +71,7 @@ export function installPtyInspectIpcHandlers(deps: { runtime?.onPtyExit(args.id, -1, incarnationId) rememberSyntheticKillExit(args.id) sendPtyExitToRenderer({ id: args.id, code: -1 }) + runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) return } const shutdownProvider = provider ?? getProviderForPty(args.id) diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index dbc752a0db3..0b59d347e4d 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -97,8 +97,8 @@ export async function beginPtyIpcSpawn( ) : null ctx.earlyWorktreeId = args.worktreeId - ctx.paneSpawnReservationKey = - ctx.earlyStablePaneOwner && earlyReservationKey ? earlyReservationKey : null + // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. + ctx.paneSpawnReservationKey = earlyReservationKey ctx.paneSpawnReservation = ctx.paneSpawnReservationKey ? reservePaneSpawn(ctx.paneSpawnReservationKey) : null diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 3aa91b93172..26bdde4da32 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -156,6 +156,10 @@ export async function buildPtyIpcSpawnOptions( if (ctx.preSpawnHiddenMarkId !== null) { ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) } - ctx.releaseWorktreeSpawn = await ctx.deps.runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined return null } diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 1e5ee57eb0f..f40b46e5dd5 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -20,10 +20,37 @@ import type { PtyIpcSpawnState } from './spawn-state' export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - if (!ctx.earlyStablePaneOwner) { - await ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) - } + // Establish daemon identity before the first await so hidden delivery is gated before byte zero. ctx.provider = getProvider(args.connectionId) + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.initiallyHidden = args.initiallyHidden === true + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + if (!ctx.earlyStablePaneOwner) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } ctx.spawnTiming.mark('stable_adoption_setup') ctx.preAdoptedStablePane = ctx.earlyStablePaneOwner && ctx.earlyWorktreeId @@ -40,7 +67,10 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : null ctx.spawnTiming.mark('stable_adoption') if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { - await ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } } if (!ctx.preAdoptedStablePane) { // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. @@ -131,6 +161,34 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) if (freshSpawnRecovery) { await freshSpawnRecovery + const previousHiddenMarkId = ctx.preSpawnHiddenMarkId + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (previousHiddenMarkId !== ctx.preSpawnHiddenMarkId) { + if (previousHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(previousHiddenMarkId, false) + } + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + } } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) @@ -147,24 +205,9 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis }) : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride - ctx.isDaemonHostSpawn = - !args.connectionId && - !(ctx.provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(ctx.provider) // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn - ctx.effectiveSessionId = - args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - ctx.effectiveSessionAppId = - ctx.effectiveSessionId !== undefined - ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) - : undefined - ctx.effectiveSessionRelayId = - ctx.effectiveSessionId !== undefined - ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) - : undefined ctx.expectedWslDistro = !args.connectionId ? (resolveWslSessionContext({ cwd: ctx.cwd, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 8a5f9f3f918..68e1cc62227 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -88,7 +88,7 @@ export type PtySpawnIpcDeps = { } getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined adoptStablePane: (args: AdoptStablePaneArgs) => Promise - assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 7b127f28518..ba4323520a5 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -174,8 +174,9 @@ export function registerPtyHandlers( mainWindow.webContents.on('did-finish-load', finishLoadHandler) } - const assertFolderWorkspacePtyPathUsable = (worktreeId: string | undefined): Promise => - assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) + const assertFolderWorkspacePtyPathUsable = ( + worktreeId: string | undefined + ): Promise | void => assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 25e839ece25..ab1f8da6e1f 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -46,7 +46,7 @@ export type PtyRuntimeControllerDeps = { env: T, launch: CodexResumeLaunch ) => T - assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 0b382eb067a..f018da9bcef 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -21,6 +21,7 @@ import { hasRendererSerializerFromRuntimeController, inspectProcessFromRuntimeController, listProcessesFromRuntimeController, + listProcessesWithHostScopeFromRuntimeController, probePtyLivenessFromRuntimeController, resizePtyFromRuntimeController, serializeProviderBufferFromRuntimeController, @@ -47,7 +48,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi // attach. Attach-only and false-on-doubt: never creates or resizes. attach: (ptyId) => attachPtyFromRuntimeController(deps, ptyId), kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), - retireRejectedPty: (ptyId) => retireRejectedPtyFromRuntimeController(deps, ptyId), + retireRejectedPty: (ptyId, stopConfirmed) => + retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), @@ -57,7 +59,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), - listProcesses: (connectionId) => listProcessesFromRuntimeController(connectionId), + listProcesses: (connectionId) => listProcessesFromRuntimeController(deps, connectionId), + listProcessesWithHostScope: () => listProcessesWithHostScopeFromRuntimeController(deps), serializeBuffer: (ptyId, opts) => { // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. return requestSerializedBuffer(ptyId, opts) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 81f43decf0a..a7bfe764957 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -1,4 +1,5 @@ import type { IPtyProvider } from '../../../providers/types' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' import { getProvider, getProviderForPty } from '../provider/registry' @@ -35,7 +36,8 @@ export function killPtyFromRuntimeController( runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + return false } return false } @@ -67,6 +69,12 @@ export function killPtyFromRuntimeController( // Why: close runtime tails without clearing provider ownership, so // a retry can still target a PTY that survived the failed shutdown. if (!retired) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) } }) @@ -90,7 +98,8 @@ export function killPtyFromRuntimeController( export function retireRejectedPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, - ptyId: string + ptyId: string, + stopConfirmed: boolean ): void { const { runtime, @@ -101,6 +110,19 @@ export function retireRejectedPtyFromRuntimeController( finishPtyShutdown } = deps rememberRetiredRejectedPty(ptyId) + if (!stopConfirmed) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + if (!ptyOwnership.has(ptyId)) { + return + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ id: ptyId, code: -1 }) + return + } // Why: a completed stop already cleared provider state, tombstoned the lease and told the // renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an // SSH pane retires for good instead of staying preserved by the stop's negative exit. @@ -197,7 +219,7 @@ export async function stopAndWaitPtyFromRuntimeController( runtime?.onPtyExit(ptyId, -1, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) } return false } @@ -210,6 +232,12 @@ export async function stopAndWaitPtyFromRuntimeController( }) } catch (err) { if (!isPtyAlreadyGoneError(err)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) @@ -218,9 +246,16 @@ export async function stopAndWaitPtyFromRuntimeController( } try { if (!(await verifyPtyStopped(provider, ptyId, opts))) { + runtime?.markPtyLivenessLive?.(ptyId) return false } } catch (err) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } console.warn( `[pty] Failed to verify PTY ${ptyId} stopped: ${ err instanceof Error ? err.message : String(err) @@ -230,9 +265,11 @@ export async function stopAndWaitPtyFromRuntimeController( } const incarnationId = finishPtyShutdown(ptyId, connectionId, store) if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1, incarnationId) + // The owning provider's fresh inventory observed absence, so this is a + // death certificate even when its exit event was missed. + runtime?.onPtyExit(ptyId, 0, incarnationId) rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) + sendPtyExitToRenderer({ id: ptyId, code: 0 }) } return true } diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 127992cb964..0ff23a3f92e 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -1,10 +1,21 @@ import type { IPtyProvider } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { PtyProcessInfo } from '../../../providers/pty-process-info' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { ptyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { rendererSerializerReadiness } from '../pane/serializer-state' -import { getProvider, getProviderForPty, localProvider, sshProviders } from '../provider/registry' +import { + getProvider, + getProviderForPty, + localProvider, + registeredPtyProviders +} from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' import type { PtyRuntimeControllerDeps } from './controller-deps' @@ -141,18 +152,61 @@ export function hasPtyFromRuntimeController(ptyId: string): boolean | null { } } -export async function listProcessesFromRuntimeController(connectionId?: string | null) { +function markSshInventoryUnverifiable( + runtime: PtyRuntimeControllerDeps['runtime'], + connectionId: string, + error: unknown +): void { + const reason = error instanceof Error ? error.message : String(error) + for (const [ptyId, ownerConnectionId] of ptyOwnership) { + if (ownerConnectionId === connectionId) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) + } + } +} + +export async function listProcessesWithHostScopeFromRuntimeController( + deps: PtyRuntimeControllerDeps +): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const providerSessions = await Promise.all( + registeredPtyProviders().map(async ({ provider, connectionId }) => { + const hostId: ExecutionHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + try { + return { processes: await provider.listProcesses(), hostId } + } catch (error) { + if (!connectionId) { + throw error + } + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + +export async function listProcessesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + connectionId?: string | null +) { if (connectionId === null) { return localProvider.listProcesses() } if (connectionId !== undefined) { - return getProvider(connectionId).listProcesses() + try { + return await getProvider(connectionId).listProcesses() + } catch (error) { + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + throw error + } } - const providerSessions = await Promise.all([ - localProvider.listProcesses(), - ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) - ]) - return providerSessions.flat() + return (await listProcessesWithHostScopeFromRuntimeController(deps)).processes } export function resizePtyFromRuntimeController(ptyId: string, cols: number, rows: number): boolean { diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index bfc4345f13e..ed412153dcb 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -6,9 +6,10 @@ import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-rel import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { RuntimePtySpawnState } from './spawn-state' -export async function adoptMaterializedRuntimePtySpawn( - ctx: RuntimePtySpawnState -): Promise { +export function adoptMaterializedRuntimePtySpawn( + ctx: RuntimePtySpawnState, + startupAlreadyAwaited = false +): Promise | PtySpawnResult | null { const args = ctx.args ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined ctx.codexHomeLaunchStartedSequence = !args.connectionId @@ -24,8 +25,8 @@ export async function adoptMaterializedRuntimePtySpawn( ) : new Map() const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise + if (startupPromise && !startupAlreadyAwaited) { + return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) } if (!ctx.preAdoptedStablePane?.materialized) { return null diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 6be11cda9db..5adb0437d77 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -19,7 +19,11 @@ import type { RuntimePtySpawnState } from './spawn-state' export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { const args = ctx.args - ctx.releaseWorktreeSpawn = await ctx.deps.runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined try { if (args.preAllocatedHandle) { ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index d4d4e6abd22..4eaeddbb16c 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -170,44 +170,53 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.onPtySpawnCommitted = ctx.reportPtySpawnCommitted } - ctx.paneSpawnReservationKey = makePaneSpawnReservationKey( + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( args.worktreeId, args.connectionId, ctx.spawnIdentityPaneKey ) - const existingPaneSpawn = ctx.paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(ctx.paneSpawnReservationKey) - : undefined - if (existingPaneSpawn) { - const concurrentResult = await existingPaneSpawn.promise - const concurrentOwner = resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - if ( - !concurrentOwner?.handle || - concurrentOwner.ptyId !== concurrentResult.id || - (concurrentOwner.incarnationId !== undefined && - concurrentResult.incarnationId !== undefined && - concurrentOwner.incarnationId !== concurrentResult.incarnationId) - ) { - throw new Error('terminal_pane_owner_unknown') - } - return { - id: concurrentOwner.ptyId, - ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), - stablePaneOwner: { - handle: concurrentOwner.handle, - tabId: concurrentOwner.tabId, - leafId: concurrentOwner.leafId + if ( + ctx.paneSpawnReservationKey && + ctx.paneSpawnReservationKey !== resolvedPaneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const existingPaneSpawn = ctx.spawnIdentityPaneKey + ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) + : undefined + if (existingPaneSpawn) { + const concurrentResult = await existingPaneSpawn.promise + const concurrentOwner = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + if ( + !concurrentOwner?.handle || + concurrentOwner.ptyId !== concurrentResult.id || + (concurrentOwner.incarnationId !== undefined && + concurrentResult.incarnationId !== undefined && + concurrentOwner.incarnationId !== concurrentResult.incarnationId) + ) { + throw new Error('terminal_pane_owner_unknown') + } + return { + id: concurrentOwner.ptyId, + ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), + stablePaneOwner: { + handle: concurrentOwner.handle, + tabId: concurrentOwner.tabId, + leafId: concurrentOwner.leafId + } } } } ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) - ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ctx.paneSpawnReservation ??= ctx.paneSpawnReservationKey ? reservePaneSpawn(ctx.paneSpawnReservationKey) : null ctx.stablePaneOwner = null diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 4de4f24cca9..083cedca3aa 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -43,7 +43,10 @@ export async function prepareRuntimePtySpawn( ): Promise { const args = ctx.args if (!ctx.preAdoptedStablePane) { - await ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } } ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) ctx.provider = getProvider(args.connectionId) diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 88563916bfb..784e4c78a7e 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -1,5 +1,13 @@ import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' -import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey, + rejectPaneSpawnReservation, + reservePaneSpawn +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' import type { PtyRuntimeControllerDeps } from './controller-deps' import { adoptMaterializedRuntimePtySpawn } from './spawn-early' import { prepareRuntimePtySpawn } from './spawn-preflight' @@ -29,7 +37,35 @@ export async function spawnPtyFromRuntimeController( args: RuntimePtySpawnArgs ) { const ctx = createRuntimePtySpawnState(deps, args) - const materialized = await adoptMaterializedRuntimePtySpawn(ctx) + if (!args.adoptedStablePane) { + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const paneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + leafId + ? makePaneKey(args.tabId, leafId) + : null + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const existingOwner = paneKey + ? resolveStablePaneOwner( + deps.runtime, + deps.store, + paneKey, + args.worktreeId, + args.connectionId + ) + : null + if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { + ctx.paneSpawnReservationKey = ownerKey + ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) + } + } + const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) + const materialized = + materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise if (materialized) { return toRuntimeSpawnReply(materialized) }