From 1700901ae698448ee96b63637097c6fdd8768aec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:48:49 -0700 Subject: [PATCH] fix(pty): fence folder route spawn reservations --- src/main/ipc/pty.test.ts | 144 ++++++++++++++++++ src/main/ipc/pty.ts | 10 ++ src/main/runtime/orca-runtime.ts | 30 +++- src/shared/stable-pane-id.test.ts | 28 +++- src/shared/stable-pane-id.ts | 24 ++- .../terminal-spawn-reservation-freshness.ts | 9 ++ 6 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 src/shared/terminal-spawn-reservation-freshness.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2a0b835f77b..84e3ac4fd1a 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -8255,6 +8255,7 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn((value) => { controller = value }), + getTerminalSpawnReservationFreshness: vi.fn(() => 'unchanged-route'), createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), @@ -8381,6 +8382,7 @@ describe('registerPtyHandlers', () => { setPtyController: vi.fn((value) => { controller = value }), + getTerminalSpawnReservationFreshness: vi.fn(() => 'unchanged-route'), createPreAllocatedTerminalHandle: vi.fn(() => 'term_trusted'), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), @@ -8452,6 +8454,148 @@ describe('registerPtyHandlers', () => { }) }) + it.each(['runtime', 'renderer'] as const)( + 'isolates a current folder route from a stale %s reservation creator', + async (staleCreator) => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + cwd: string + worktreeId: string + tabId: string + leafId: string + sessionId: string + persistHostSessionBinding: true + spawnReservationFreshness?: string + acceptWorkspaceInventory?: () => boolean + }): Promise<{ id: string }> + } + const folder = { + id: 'route-race-folder', + projectGroupId: 'route-race-group', + folderPath: process.cwd(), + connectionId: null + } + const group = { + id: folder.projectGroupId, + parentPath: folder.folderPath, + connectionId: null + } + const pending: { + cwd: string | undefined + resolve: (result: { id: string }) => void + }[] = [] + const providerSpawn = vi.fn( + (options: { cwd?: string }) => + new Promise<{ id: string }>((resolve) => { + pending.push({ cwd: options.cwd, resolve }) + }) + ) + const shutdown = vi.fn() + installDaemonTestProvider({ spawn: providerSpawn, shutdown }) + const store = { + getFolderWorkspace: vi.fn(() => folder), + getFolderWorkspaces: vi.fn(() => [folder]), + getProjectGroups: vi.fn(() => [group]), + getRepos: vi.fn(() => []), + persistPtyBinding: vi.fn(), + removePtyBindingIfMatches: vi.fn() + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + getTerminalSpawnReservationFreshness: vi.fn(() => `folder-route:${folder.folderPath}`), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_route_race'), + preAllocateHandleForPty: vi.fn(() => 'term_route_race'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + retireFreshPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + releaseRejectedPtyRegistrationFence: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + const spawnController = controller as unknown as RuntimeSpawnController + const worktreeId = `folder:${folder.id}` + const tabId = 'tab-route-race' + const leafId = '44444444-4444-4444-8444-444444444444' + const paneKey = makePaneKey(tabId, leafId) + const sessionId = 'route-race-session' + const runtimeSpawn = (cwd: string, freshness: string, accept: () => boolean) => + spawnController.spawn({ + cols: 80, + rows: 24, + cwd, + worktreeId, + tabId, + leafId, + sessionId, + persistHostSessionBinding: true, + spawnReservationFreshness: freshness, + acceptWorkspaceInventory: accept + }) + const rendererSpawn = (cwd: string) => + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + worktreeId, + tabId, + leafId, + sessionId, + env: { ORCA_PANE_KEY: paneKey } + }) as Promise<{ id: string }> + const oldPath = folder.folderPath + const oldFreshness = runtime.getTerminalSpawnReservationFreshness() + const stale = + staleCreator === 'runtime' + ? runtimeSpawn(oldPath, oldFreshness, () => folder.folderPath === oldPath) + : rendererSpawn(oldPath) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + + folder.folderPath = join(process.cwd(), 'src') + group.parentPath = folder.folderPath + const newFreshness = runtime.getTerminalSpawnReservationFreshness() + const current = + staleCreator === 'runtime' + ? rendererSpawn(folder.folderPath) + : runtimeSpawn(folder.folderPath, newFreshness, () => true) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(2)) + expect(pending.map(({ cwd }) => cwd)).toEqual([oldPath, folder.folderPath]) + + pending[0]!.resolve({ id: 'pty-stale-old-route' }) + await (staleCreator === 'runtime' + ? expect(stale).rejects.toThrow('tab_not_found') + : expect(stale).resolves.toMatchObject({ id: 'pty-stale-old-route' })) + pending[1]!.resolve({ id: 'pty-current-new-route' }) + await expect(current).resolves.toMatchObject({ id: 'pty-current-new-route' }) + + expect(providerSpawn).toHaveBeenCalledTimes(2) + if (staleCreator === 'runtime') { + expect(shutdown).toHaveBeenCalledWith('pty-stale-old-route', { immediate: true }) + } + expect(shutdown).not.toHaveBeenCalledWith('pty-current-new-route', expect.anything()) + expect(store.persistPtyBinding).toHaveBeenLastCalledWith( + expect.objectContaining({ + ptyId: 'pty-current-new-route', + startupCwd: join(process.cwd(), 'src') + }) + ) + } + ) + it('keeps identical pane ids independent across worktrees and sessions', async () => { const resolvers: ((result: { id: string }) => void)[] = [] const providerSpawn = vi.fn( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4d25bd57fa7..1d1440fbaed 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3858,6 +3858,10 @@ export function registerPtyHandlers( // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. runtime?.setPtyController({ spawn: async (args) => { + const spawnReservationFreshness = + args.spawnReservationFreshness ?? + runtime?.getTerminalSpawnReservationFreshness?.(args.worktreeId, args.connectionId) ?? + null const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise @@ -4148,6 +4152,8 @@ export function registerPtyHandlers( remotePathFlavor: (provider as IPtyProvider).getExecutionHostPathFlavor?.() }), workspaceId: hostSessionBinding?.worktreeId, + spawnPath: cwd, + routeOrReconnectFreshness: spawnReservationFreshness, sessionId: callerRequestedSessionId ? getRelayPtyId(args.connectionId, callerRequestedSessionId) : null @@ -5048,6 +5054,8 @@ export function registerPtyHandlers( } } ) => { + const spawnReservationFreshness = + runtime?.getTerminalSpawnReservationFreshness?.(args.worktreeId, args.connectionId) ?? null const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -5251,6 +5259,8 @@ export function registerPtyHandlers( remotePathFlavor: (provider as IPtyProvider).getExecutionHostPathFlavor?.() }), workspaceId: args.worktreeId, + spawnPath: cwd, + routeOrReconnectFreshness: spawnReservationFreshness, sessionId: args.sessionId ? getRelayPtyId(args.connectionId, args.sessionId) : null }) : null diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ef3e04b5819..633acecb14c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -244,6 +244,7 @@ import { type RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { mobileTerminalMaterializationKey } from './mobile-terminal-materialization-key' +import { terminalSpawnReservationFreshness } from '../../shared/terminal-spawn-reservation-freshness' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' @@ -1294,6 +1295,7 @@ type TerminalCreateOptions = { agentSessionCreateOperationId?: string signal?: AbortSignal acceptWorkspaceInventory?: () => boolean + spawnReservationFreshness?: string // Why: idempotent create operations must retain their fence after the PTY // exists, even if later runtime publication fails. onPtySpawnCommitted?: () => void @@ -1547,6 +1549,7 @@ type RuntimePtyController = { agentSessionCreateOperationId?: string signal?: AbortSignal acceptWorkspaceInventory?: () => boolean + spawnReservationFreshness?: string onPtySpawnCommitted?: () => void }): Promise<{ id: string @@ -4996,6 +4999,19 @@ export class OrcaRuntimeService { } } + getTerminalSpawnReservationFreshness( + worktreeId: string | undefined, + connectionId: string | null | undefined + ): string | null { + const workspaceFreshness = worktreeId + ? (this.createMobileSessionFolderWorkspaceMutationFence(worktreeId)?.freshness ?? null) + : null + const reconnectGeneration = connectionId + ? (this.sshRelayRecoveryGenerationByTargetId.get(connectionId) ?? 0) + : null + return terminalSpawnReservationFreshness({ workspaceFreshness, reconnectGeneration }) + } + private createFolderWorkspaceInventoryFence( folderWorkspaces: readonly FolderWorkspace[] ): () => boolean { @@ -8128,6 +8144,10 @@ export class OrcaRuntimeService { (connectionId === null || (this.sshRelayRecoveryGenerationByTargetId.get(connectionId) ?? 0) === reconnectGeneration) + const spawnReservationFreshness = terminalSpawnReservationFreshness({ + workspaceFreshness: workspaceMutationFence?.freshness ?? null, + reconnectGeneration + }) const materializationKey = mobileTerminalMaterializationKey({ executionHostId, connectionId, @@ -8173,6 +8193,7 @@ export class OrcaRuntimeService { launchConfig: agentStartup.launchConfig, launchAgent: tab.launchAgent, targetGroupId, + spawnReservationFreshness, acceptWorkspaceInventory: () => acceptMaterialization() && !this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab) @@ -25457,6 +25478,9 @@ export class OrcaRuntimeService { ...(launchOpts.acceptWorkspaceInventory ? { acceptWorkspaceInventory: launchOpts.acceptWorkspaceInventory } : {}), + ...(launchOpts.spawnReservationFreshness + ? { spawnReservationFreshness: launchOpts.spawnReservationFreshness } + : {}), ...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), // Why: a headless-created pane has no renderer session writer. Persist @@ -26221,6 +26245,7 @@ export class OrcaRuntimeService { launchConfig?: SleepingAgentLaunchConfig signal?: AbortSignal acceptWorkspaceInventory?: () => boolean + spawnReservationFreshness?: string | null } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) @@ -26251,7 +26276,10 @@ export class OrcaRuntimeService { // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. deferMobileSessionPublish: true, signal: opts.signal, - acceptWorkspaceInventory: opts.acceptWorkspaceInventory + acceptWorkspaceInventory: opts.acceptWorkspaceInventory, + ...(opts.spawnReservationFreshness + ? { spawnReservationFreshness: opts.spawnReservationFreshness } + : {}) }) const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { diff --git a/src/shared/stable-pane-id.test.ts b/src/shared/stable-pane-id.test.ts index 2221840ba46..b7f12624117 100644 --- a/src/shared/stable-pane-id.test.ts +++ b/src/shared/stable-pane-id.test.ts @@ -131,7 +131,9 @@ describe('stable pane ids', () => { executionRuntime: 'wsl:Ubuntu', pathFlavor: 'windows' as const, workspaceId: 'worktree:repo-1::/workspaces/project', - sessionId: 'session-1' + sessionId: 'session-1', + spawnPath: 'C:\\Workspaces\\Project', + routeOrReconnectFreshness: 'route-1' } const canonical = makePaneSpawnReservationKey(base) @@ -141,7 +143,9 @@ describe('stable pane ids', () => { { executionRuntime: 'native' }, { workspaceId: 'repo-1::/workspaces/other' }, { workspaceId: 'folder:repo-1::/workspaces/project' }, - { sessionId: 'session-2' } + { sessionId: 'session-2' }, + { spawnPath: 'C:\\Workspaces\\Other' }, + { routeOrReconnectFreshness: 'route-2' } ]) { expect(makePaneSpawnReservationKey({ ...base, ...distinct })).not.toBe(canonical) } @@ -156,4 +160,24 @@ describe('stable pane ids', () => { }) ).toBeNull() }) + + it('canonicalizes the final spawn path with execution-host semantics', () => { + const base = { + paneKey: PANE_KEY, + providerId: 'provider-1', + pathFlavor: 'windows' as const, + workspaceId: 'folder:folder-1', + spawnPath: '\\\\Server\\Share\\Project\\' + } + expect(makePaneSpawnReservationKey(base)).toBe( + makePaneSpawnReservationKey({ ...base, spawnPath: '//server/share/project' }) + ) + expect(makePaneSpawnReservationKey({ ...base, pathFlavor: 'posix' })).not.toBe( + makePaneSpawnReservationKey({ + ...base, + pathFlavor: 'posix', + spawnPath: '//server/share/project' + }) + ) + }) }) diff --git a/src/shared/stable-pane-id.ts b/src/shared/stable-pane-id.ts index 78bc8601c51..c4116da2af2 100644 --- a/src/shared/stable-pane-id.ts +++ b/src/shared/stable-pane-id.ts @@ -38,15 +38,15 @@ export function makePaneKey(tabId: string, stableLeafId: string): PaneKey { return `${tabId}:${stableLeafId}` as PaneKey } -function canonicalPaneReservationWorkspacePath( - worktreePath: string, +function canonicalPaneReservationPath( + path: string, pathFlavor: PaneSpawnReservationPathFlavor ): string { // Why: leading // is valid on POSIX; syntax alone cannot prove Windows ownership. - if (pathFlavor !== 'windows' || !isWindowsAbsolutePathLike(worktreePath)) { - return worktreePath + if (pathFlavor !== 'windows' || !isWindowsAbsolutePathLike(path)) { + return path } - const normalized = normalizeRuntimePathSeparators(worktreePath) + const normalized = normalizeRuntimePathSeparators(path) const trimmed = /^[A-Za-z]:\/$/.test(normalized) ? normalized : normalized.replace(/\/+$/, '') const wslUnc = trimmed.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/[\s\S]*)?$/i) return wslUnc ? `//wsl/${wslUnc[1].toLowerCase()}${wslUnc[2] ?? ''}` : trimmed.toLowerCase() @@ -69,7 +69,7 @@ function canonicalWorkspaceIdentity( : workspaceId const parsed = splitWorktreeId(unscopedId) return parsed - ? [kind, parsed.repoId, canonicalPaneReservationWorkspacePath(parsed.worktreePath, pathFlavor)] + ? [kind, parsed.repoId, canonicalPaneReservationPath(parsed.worktreePath, pathFlavor)] : [kind, unscopedId] } @@ -81,6 +81,8 @@ export function makePaneSpawnReservationKey(args: { pathFlavor: PaneSpawnReservationPathFlavor workspaceId?: string sessionId?: string | null + spawnPath?: string | null + routeOrReconnectFreshness?: string | null }): PaneSpawnReservationKey | null { const workspace = args.workspaceId ? canonicalWorkspaceIdentity(args.workspaceId, args.pathFlavor) @@ -89,13 +91,19 @@ export function makePaneSpawnReservationKey(args: { const connectionId = args.connectionId?.trim() || null const executionRuntime = args.executionRuntime?.trim() || null const sessionId = args.sessionId?.trim() || null + const spawnPath = args.spawnPath + ? canonicalPaneReservationPath(args.spawnPath, args.pathFlavor) + : null + const routeOrReconnectFreshness = args.routeOrReconnectFreshness?.trim() || null if ( !workspace || !providerId || providerId.length > 256 || (connectionId?.length ?? 0) > 512 || (executionRuntime?.length ?? 0) > 256 || - (sessionId?.length ?? 0) > 512 + (sessionId?.length ?? 0) > 512 || + (spawnPath?.length ?? 0) > 32_768 || + (routeOrReconnectFreshness?.length ?? 0) > 4_096 ) { return null } @@ -104,6 +112,8 @@ export function makePaneSpawnReservationKey(args: { connectionId, executionRuntime, workspace, + spawnPath, + routeOrReconnectFreshness, sessionId, args.paneKey ]) as PaneSpawnReservationKey diff --git a/src/shared/terminal-spawn-reservation-freshness.ts b/src/shared/terminal-spawn-reservation-freshness.ts new file mode 100644 index 00000000000..8cc4354531f --- /dev/null +++ b/src/shared/terminal-spawn-reservation-freshness.ts @@ -0,0 +1,9 @@ +export function terminalSpawnReservationFreshness(args: { + workspaceFreshness: string | null + reconnectGeneration: number | null +}): string | null { + if (args.workspaceFreshness === null && args.reconnectGeneration === null) { + return null + } + return JSON.stringify([args.workspaceFreshness, args.reconnectGeneration]) +}