fix(pty): fence folder route spawn reservations

This commit is contained in:
Brennan Benson
2026-08-01 08:48:49 -07:00
parent 1466ce8050
commit 1700901ae6
6 changed files with 235 additions and 10 deletions
+144
View File
@@ -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(
+10
View File
@@ -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
+29 -1
View File
@@ -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<RuntimeMobileSessionCreateTerminalResult> {
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) {
+26 -2
View File
@@ -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'
})
)
})
})
+17 -7
View File
@@ -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
@@ -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])
}