mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(pty): fence folder route spawn reservations
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user