mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
Fix shared PTY reservation ownership
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
adoptPaneSpawn,
|
||||
awaitPaneSpawnReservation,
|
||||
getPaneSpawnReservation,
|
||||
rejectPaneSpawnReservation,
|
||||
releasePaneSpawn,
|
||||
reservePaneSpawn,
|
||||
resolvePaneSpawnReservation
|
||||
} from './pane-spawn-reservation'
|
||||
|
||||
describe('pane spawn reservation retirement', () => {
|
||||
it('protects a live waiter when the creator retires after resolution', async () => {
|
||||
const reservation = reservePaneSpawn('creator-retires')
|
||||
const waiter = awaitPaneSpawnReservation(reservation)
|
||||
const creator = resolvePaneSpawnReservation('creator-retires', reservation, {
|
||||
id: 'pty-creator-retires',
|
||||
spawnDisposition: 'created' as const
|
||||
})
|
||||
const awaited = await waiter
|
||||
|
||||
expect(releasePaneSpawn(creator.id, creator.spawnRetirementToken!)).toBe(false)
|
||||
expect(adoptPaneSpawn(awaited.id, awaited.spawnRetirementToken!)).toBe(true)
|
||||
})
|
||||
|
||||
it('protects a live creator when a waiter retires after resolution', async () => {
|
||||
const reservation = reservePaneSpawn('waiter-retires')
|
||||
const waiter = awaitPaneSpawnReservation(reservation)
|
||||
const creator = resolvePaneSpawnReservation('waiter-retires', reservation, {
|
||||
id: 'pty-waiter-retires',
|
||||
spawnDisposition: 'created' as const
|
||||
})
|
||||
const awaited = await waiter
|
||||
|
||||
expect(releasePaneSpawn(awaited.id, awaited.spawnRetirementToken!)).toBe(false)
|
||||
expect(adoptPaneSpawn(creator.id, creator.spawnRetirementToken!)).toBe(true)
|
||||
})
|
||||
|
||||
it('retires only after every participant abandons the created PTY', async () => {
|
||||
const reservation = reservePaneSpawn('all-retire')
|
||||
const waiter = awaitPaneSpawnReservation(reservation)
|
||||
const creator = resolvePaneSpawnReservation('all-retire', reservation, {
|
||||
id: 'pty-all-retire',
|
||||
spawnDisposition: 'created' as const
|
||||
})
|
||||
const awaited = await waiter
|
||||
|
||||
expect(releasePaneSpawn(creator.id, creator.spawnRetirementToken!)).toBe(false)
|
||||
expect(releasePaneSpawn(awaited.id, awaited.spawnRetirementToken!)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves creator-only retirement unchanged and clears rejected reservations for retry', async () => {
|
||||
const creatorOnly = reservePaneSpawn('creator-only')
|
||||
const result = resolvePaneSpawnReservation('creator-only', creatorOnly, {
|
||||
id: 'pty-creator-only',
|
||||
spawnDisposition: 'created' as const
|
||||
})
|
||||
expect(result.spawnRetirementToken).toBeUndefined()
|
||||
|
||||
const rejected = reservePaneSpawn('retry')
|
||||
const rejectedWaiter = awaitPaneSpawnReservation(rejected)
|
||||
rejectPaneSpawnReservation('retry', rejected, new Error('spawn failed'))
|
||||
await expect(rejectedWaiter).rejects.toThrow('spawn failed')
|
||||
expect(getPaneSpawnReservation('retry')).toBeUndefined()
|
||||
const retry = reservePaneSpawn('retry')
|
||||
expect(retry).not.toBe(rejected)
|
||||
rejectPaneSpawnReservation('retry', retry, new Error('test cleanup'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { PtySpawnDisposition } from '../../shared/pty-spawn-disposition'
|
||||
import type { PtySpawnResult } from '../providers/types'
|
||||
|
||||
export type PaneSpawnReservationResult = {
|
||||
id: string
|
||||
spawnDisposition: PtySpawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
} & Partial<PtySpawnResult>
|
||||
|
||||
export type PaneSpawnReservation = {
|
||||
promise: Promise<PaneSpawnReservationResult>
|
||||
resolve: (result: PaneSpawnReservationResult) => void
|
||||
reject: (error: unknown) => void
|
||||
creatorRetirementToken: string
|
||||
waiterRetirementTokens: Set<string>
|
||||
}
|
||||
|
||||
type PaneSpawnRetirement = {
|
||||
ptyId: string
|
||||
tokens: Set<string>
|
||||
}
|
||||
|
||||
// Paired clients share the provider operation but retain independent retirement authority.
|
||||
const reservationsByScope = new Map<string, PaneSpawnReservation>()
|
||||
const retirementsByToken = new Map<string, PaneSpawnRetirement>()
|
||||
|
||||
export function getPaneSpawnReservation(scope: string): PaneSpawnReservation | undefined {
|
||||
return reservationsByScope.get(scope)
|
||||
}
|
||||
|
||||
export function reservePaneSpawn(scope: string): PaneSpawnReservation {
|
||||
let resolve!: (result: PaneSpawnReservationResult) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<PaneSpawnReservationResult>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
promise.catch(() => {})
|
||||
const reservation = {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
creatorRetirementToken: randomUUID(),
|
||||
waiterRetirementTokens: new Set<string>()
|
||||
}
|
||||
reservationsByScope.set(scope, reservation)
|
||||
return reservation
|
||||
}
|
||||
|
||||
function clearPaneSpawnReservation(scope: string, reservation: PaneSpawnReservation): void {
|
||||
if (reservationsByScope.get(scope) === reservation) {
|
||||
reservationsByScope.delete(scope)
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectPaneSpawnReservation(
|
||||
scope: string | null | undefined,
|
||||
reservation: PaneSpawnReservation | null | undefined,
|
||||
error: unknown
|
||||
): void {
|
||||
if (!reservation) {
|
||||
return
|
||||
}
|
||||
reservation.reject(error)
|
||||
if (scope) {
|
||||
clearPaneSpawnReservation(scope, reservation)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePaneSpawnReservation<T extends PaneSpawnReservationResult>(
|
||||
scope: string | null | undefined,
|
||||
reservation: PaneSpawnReservation | null | undefined,
|
||||
response: T
|
||||
): T & { spawnRetirementToken?: string } {
|
||||
if (!reservation) {
|
||||
return response
|
||||
}
|
||||
reservation.resolve(response)
|
||||
if (scope) {
|
||||
clearPaneSpawnReservation(scope, reservation)
|
||||
}
|
||||
if (response.spawnDisposition !== 'created' || reservation.waiterRetirementTokens.size === 0) {
|
||||
return response
|
||||
}
|
||||
const tokens = new Set([
|
||||
reservation.creatorRetirementToken,
|
||||
...reservation.waiterRetirementTokens
|
||||
])
|
||||
const retirement = { ptyId: response.id, tokens }
|
||||
for (const token of tokens) {
|
||||
retirementsByToken.set(token, retirement)
|
||||
}
|
||||
return { ...response, spawnRetirementToken: reservation.creatorRetirementToken }
|
||||
}
|
||||
|
||||
export async function awaitPaneSpawnReservation(
|
||||
reservation: PaneSpawnReservation
|
||||
): Promise<PaneSpawnReservationResult> {
|
||||
const token = randomUUID()
|
||||
reservation.waiterRetirementTokens.add(token)
|
||||
const result = await reservation.promise
|
||||
return {
|
||||
...result,
|
||||
spawnDisposition: 'awaited',
|
||||
...(result.spawnDisposition === 'created' ? { spawnRetirementToken: token } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function adoptPaneSpawn(ptyId: string, token: string): boolean {
|
||||
const retirement = retirementsByToken.get(token)
|
||||
if (!retirement || retirement.ptyId !== ptyId) {
|
||||
return false
|
||||
}
|
||||
for (const participantToken of retirement.tokens) {
|
||||
retirementsByToken.delete(participantToken)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function releasePaneSpawn(ptyId: string, token: string): boolean {
|
||||
const retirement = retirementsByToken.get(token)
|
||||
if (!retirement || retirement.ptyId !== ptyId) {
|
||||
return false
|
||||
}
|
||||
retirementsByToken.delete(token)
|
||||
retirement.tokens.delete(token)
|
||||
if (retirement.tokens.size > 0) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function forgetPaneSpawnRetirement(ptyId: string): void {
|
||||
const retirements = new Set(
|
||||
[...retirementsByToken.values()].filter((retirement) => retirement.ptyId === ptyId)
|
||||
)
|
||||
for (const retirement of retirements) {
|
||||
for (const token of retirement.tokens) {
|
||||
retirementsByToken.delete(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8431,8 +8431,16 @@ describe('registerPtyHandlers', () => {
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
resolveSpawn({ id: 'pty-renderer' })
|
||||
await expect(Promise.all([rendererSpawn, runtimeSpawn])).resolves.toEqual([
|
||||
{ id: 'pty-renderer', spawnDisposition: 'created' },
|
||||
{ id: 'pty-renderer', spawnDisposition: 'awaited' }
|
||||
expect.objectContaining({
|
||||
id: 'pty-renderer',
|
||||
spawnDisposition: 'created',
|
||||
spawnRetirementToken: expect.any(String)
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'pty-renderer',
|
||||
spawnDisposition: 'awaited',
|
||||
spawnRetirementToken: expect.any(String)
|
||||
})
|
||||
])
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(store.persistPtyBinding).toHaveBeenCalledWith({
|
||||
|
||||
+34
-69
@@ -85,6 +85,16 @@ import {
|
||||
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
import { createPtySpawnTiming } from './pty-spawn-timing'
|
||||
import { resolvePaneSpawnReservationPathFlavor } from './pty-spawn-reservation-path-flavor'
|
||||
import {
|
||||
adoptPaneSpawn,
|
||||
awaitPaneSpawnReservation,
|
||||
forgetPaneSpawnRetirement,
|
||||
getPaneSpawnReservation,
|
||||
rejectPaneSpawnReservation,
|
||||
releasePaneSpawn,
|
||||
reservePaneSpawn,
|
||||
resolvePaneSpawnReservation
|
||||
} from './pane-spawn-reservation'
|
||||
import {
|
||||
isSafePtySessionId,
|
||||
mintPtySessionId,
|
||||
@@ -219,7 +229,6 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
|
||||
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import type { PtyListedSession } from '../../shared/pty-listed-session'
|
||||
import type { PtySpawnDisposition } from '../../shared/pty-spawn-disposition'
|
||||
|
||||
// ─── Provider Registry ──────────────────────────────────────────────
|
||||
// Routes PTY operations by connectionId (null = local provider).
|
||||
@@ -307,18 +316,6 @@ export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListene
|
||||
let pendingSerializerGenSeq = 0
|
||||
const pendingByPaneKey = new Map<string, { gen: number; ownerWebContentsId: number | null }>()
|
||||
const pendingPaneSerializerCleanupRegistered = new Set<number>()
|
||||
type PaneSpawnReservation = {
|
||||
promise: Promise<PaneSpawnReservationResult>
|
||||
resolve: (result: PaneSpawnReservationResult) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
type PaneSpawnReservationResult = {
|
||||
id: string
|
||||
spawnDisposition: PtySpawnDisposition
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
} & Partial<PtySpawnResult>
|
||||
// Why: paired clients may share one physical spawn, but identical pane ids in another execution/workspace/session scope must remain independent.
|
||||
const paneSpawnReservationsByScope = new Map<string, PaneSpawnReservation>()
|
||||
const paneSpawnProviderIds = new WeakMap<IPtyProvider, string>()
|
||||
let nextPaneSpawnProviderId = 0
|
||||
// Why: one main process can route the same remote provider namespace through
|
||||
@@ -491,60 +488,6 @@ function getPaneSpawnExecutionRuntimeId(args: {
|
||||
: 'native'
|
||||
}
|
||||
|
||||
function reservePaneSpawn(paneKey: string): PaneSpawnReservation {
|
||||
let resolve!: (result: PaneSpawnReservationResult) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<PaneSpawnReservationResult>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
promise.catch(() => {})
|
||||
const reservation = { promise, resolve, reject }
|
||||
paneSpawnReservationsByScope.set(paneKey, reservation)
|
||||
return reservation
|
||||
}
|
||||
|
||||
function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void {
|
||||
if (paneSpawnReservationsByScope.get(paneKey) === reservation) {
|
||||
paneSpawnReservationsByScope.delete(paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
function rejectPaneSpawnReservation(
|
||||
paneKey: string | null | undefined,
|
||||
reservation: PaneSpawnReservation | null | undefined,
|
||||
error: unknown
|
||||
): void {
|
||||
if (!reservation) {
|
||||
return
|
||||
}
|
||||
reservation.reject(error)
|
||||
if (paneKey) {
|
||||
clearPaneSpawnReservation(paneKey, reservation)
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePaneSpawnReservation<T extends PaneSpawnReservationResult>(
|
||||
paneKey: string | null | undefined,
|
||||
reservation: PaneSpawnReservation | null | undefined,
|
||||
response: T
|
||||
): T {
|
||||
if (!reservation) {
|
||||
return response
|
||||
}
|
||||
reservation.resolve(response)
|
||||
if (paneKey) {
|
||||
clearPaneSpawnReservation(paneKey, reservation)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function awaitPaneSpawnReservation(
|
||||
reservation: PaneSpawnReservation
|
||||
): Promise<PaneSpawnReservationResult> {
|
||||
return { ...(await reservation.promise), spawnDisposition: 'awaited' }
|
||||
}
|
||||
|
||||
type CreatorOnlyPtyRetirementToken = {
|
||||
retired: boolean
|
||||
creator: boolean
|
||||
@@ -1652,6 +1595,7 @@ export function clearProviderPtyState(
|
||||
opts: { preserveAgentSessionOwners?: boolean } = {}
|
||||
): void {
|
||||
if (!opts.preserveAgentSessionOwners) {
|
||||
forgetPaneSpawnRetirement(id)
|
||||
agentSessionOwners.release(id)
|
||||
// Why: the launch-account record outlives the app, so only a real teardown
|
||||
// may drop it — a disconnect that can reconnect is not a death, and a reused
|
||||
@@ -4263,7 +4207,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
const existingPaneSpawn = paneSpawnReservationKey
|
||||
? paneSpawnReservationsByScope.get(paneSpawnReservationKey)
|
||||
? getPaneSpawnReservation(paneSpawnReservationKey)
|
||||
: undefined
|
||||
if (existingPaneSpawn) {
|
||||
return await awaitPaneSpawnReservation(existingPaneSpawn)
|
||||
@@ -4711,6 +4655,8 @@ export function registerPtyHandlers(
|
||||
finishTerminalInstall()
|
||||
}
|
||||
},
|
||||
adoptSpawnReservation: (ptyId, token) => adoptPaneSpawn(ptyId, token),
|
||||
releaseSpawnReservation: (ptyId, token) => releasePaneSpawn(ptyId, token),
|
||||
write: (ptyId, data) => {
|
||||
try {
|
||||
getProviderForPty(ptyId).write(ptyId, data)
|
||||
@@ -5520,7 +5466,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
const existingPaneSpawn = paneSpawnReservationKey
|
||||
? paneSpawnReservationsByScope.get(paneSpawnReservationKey)
|
||||
? getPaneSpawnReservation(paneSpawnReservationKey)
|
||||
: undefined
|
||||
if (existingPaneSpawn) {
|
||||
return await awaitPaneSpawnReservation(existingPaneSpawn)
|
||||
@@ -6462,6 +6408,25 @@ export function registerPtyHandlers(
|
||||
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:adoptSpawnReservationSync')
|
||||
ipcMain.on('pty:adoptSpawnReservationSync', (event, args: { id?: unknown; token?: unknown }) => {
|
||||
event.returnValue =
|
||||
typeof args?.id === 'string' &&
|
||||
typeof args?.token === 'string' &&
|
||||
adoptPaneSpawn(args.id, args.token)
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:releaseSpawnReservationSync')
|
||||
ipcMain.on(
|
||||
'pty:releaseSpawnReservationSync',
|
||||
(event, args: { id?: unknown; token?: unknown }) => {
|
||||
event.returnValue =
|
||||
typeof args?.id === 'string' &&
|
||||
typeof args?.token === 'string' &&
|
||||
releasePaneSpawn(args.id, args.token)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
|
||||
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
|
||||
// Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider.
|
||||
|
||||
@@ -176,8 +176,14 @@ describe('mobile folder session mutation races', () => {
|
||||
const spawn = vi.fn(async () => {
|
||||
await attach
|
||||
attached = true
|
||||
return { id: ptyId, incarnationId }
|
||||
return {
|
||||
id: ptyId,
|
||||
incarnationId,
|
||||
spawnDisposition: 'awaited' as const,
|
||||
spawnRetirementToken: 'live-runtime-waiter'
|
||||
}
|
||||
})
|
||||
const adoptSpawnReservation = vi.fn(() => true)
|
||||
const listProcesses = vi.fn(async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
@@ -190,6 +196,7 @@ describe('mobile folder session mutation races', () => {
|
||||
])
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
adoptSpawnReservation,
|
||||
write: () => attached,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
@@ -240,6 +247,7 @@ describe('mobile folder session mutation races', () => {
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: ptyId, worktreeId: folderKey })
|
||||
)
|
||||
expect(adoptSpawnReservation).toHaveBeenCalledExactlyOnceWith(ptyId, 'live-runtime-waiter')
|
||||
expect((await runtime.listMobileSessionTabs(`id:${folderKey}`)).activeTabId).toBe(
|
||||
`${TAB_ID}::${LEAF_ID}`
|
||||
)
|
||||
@@ -414,12 +422,13 @@ describe('mobile folder session mutation races', () => {
|
||||
)
|
||||
|
||||
it.each([
|
||||
['creator', 'created', true],
|
||||
['waiter', 'awaited', false],
|
||||
['reattach consumer', 'reattached', false]
|
||||
['creator-only', 'created', undefined, undefined, true],
|
||||
['shared creator', 'created', 'creator-token', false, false],
|
||||
['shared waiter', 'awaited', 'waiter-token', false, false],
|
||||
['reattach consumer', 'reattached', undefined, undefined, false]
|
||||
] as const)(
|
||||
'lets only the %s retire a stale materialization',
|
||||
async (_consumer, spawnDisposition, shouldKill) => {
|
||||
'retires a stale %s materialization without harming a live reservation peer',
|
||||
async (_consumer, spawnDisposition, spawnRetirementToken, releaseResult, shouldKill) => {
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'materialize-race-folder',
|
||||
projectGroupId: 'materialize-race-group',
|
||||
@@ -474,17 +483,27 @@ describe('mobile folder session mutation races', () => {
|
||||
repo: null,
|
||||
folderWorkspace: folder
|
||||
}))
|
||||
let releaseSpawn!: (value: { id: string; spawnDisposition: typeof spawnDisposition }) => void
|
||||
let releaseSpawn!: (value: {
|
||||
id: string
|
||||
spawnDisposition: typeof spawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
}) => void
|
||||
const spawn = vi.fn(
|
||||
() =>
|
||||
new Promise<{ id: string; spawnDisposition: typeof spawnDisposition }>((resolve) => {
|
||||
new Promise<{
|
||||
id: string
|
||||
spawnDisposition: typeof spawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
}>((resolve) => {
|
||||
releaseSpawn = resolve
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
const releaseSpawnReservation = vi.fn(() => releaseResult ?? false)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
releaseSpawnReservation,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [],
|
||||
spawn
|
||||
@@ -497,7 +516,11 @@ describe('mobile folder session mutation races', () => {
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce())
|
||||
folders.length = 0
|
||||
folders.push({ ...folder, folderPath: '/workspace/materialize-race-moved' })
|
||||
releaseSpawn({ id: 'stale-materialized-pty', spawnDisposition })
|
||||
releaseSpawn({
|
||||
id: 'stale-materialized-pty',
|
||||
spawnDisposition,
|
||||
...(spawnRetirementToken ? { spawnRetirementToken } : {})
|
||||
})
|
||||
|
||||
const results = await activations
|
||||
results.forEach((result) => {
|
||||
@@ -511,6 +534,14 @@ describe('mobile folder session mutation races', () => {
|
||||
} else {
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
}
|
||||
if (spawnRetirementToken) {
|
||||
expect(releaseSpawnReservation).toHaveBeenCalledWith(
|
||||
'stale-materialized-pty',
|
||||
spawnRetirementToken
|
||||
)
|
||||
} else {
|
||||
expect(releaseSpawnReservation).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
expect(published).not.toContainEqual(
|
||||
expect.objectContaining({ activeTabId: `${TAB_ID}::${LEAF_ID}` })
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { FolderWorkspace, ProjectGroup, WorkspaceSessionState } from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
const ipcMain = {
|
||||
on: vi.fn(() => ipcMain),
|
||||
removeListener: vi.fn(() => ipcMain),
|
||||
emit: vi.fn(() => true)
|
||||
}
|
||||
return {
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false },
|
||||
BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) },
|
||||
ipcMain,
|
||||
Notification: vi.fn(),
|
||||
webContents: { fromId: vi.fn(() => null) }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => electronMocks)
|
||||
|
||||
const TAB_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function createSession(worktreeId: string, ptyId: string): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabId: TAB_ID,
|
||||
activeTabIdByWorktree: { [worktreeId]: TAB_ID },
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
id: TAB_ID,
|
||||
ptyId,
|
||||
worktreeId,
|
||||
title: 'Persisted terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey(TAB_ID, LEAF_ID)]: '33333333-3333-4333-8333-333333333333'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(connectionId: string | null) {
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'freshness-folder',
|
||||
projectGroupId: 'freshness-group',
|
||||
name: 'Freshness folder',
|
||||
folderPath: '/workspace/freshness',
|
||||
connectionId,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const group: ProjectGroup = {
|
||||
id: folder.projectGroupId,
|
||||
name: folder.projectGroupId,
|
||||
parentPath: folder.folderPath,
|
||||
connectionId,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const worktreeId = `folder:${folder.id}`
|
||||
const ptyId = connectionId
|
||||
? `ssh:${connectionId}@@persisted-freshness-pty`
|
||||
: 'persisted-freshness-pty'
|
||||
const folders = [folder]
|
||||
let session = createSession(worktreeId, ptyId)
|
||||
const runtime = new OrcaRuntimeService({
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
getFolderWorkspaces: () => folders,
|
||||
getProjectGroups: () => [group],
|
||||
getWorkspaceSession: () => session,
|
||||
getWorkspaceSessionHostIds: () => [
|
||||
folders[0]!.connectionId ? `ssh:${folders[0]!.connectionId}` : 'local'
|
||||
],
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getGitHubCache: () => ({ pr: {}, issue: {} }),
|
||||
setWorktreeMeta: () => undefined as never,
|
||||
removeWorktreeMeta: () => {},
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/workspace',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
})
|
||||
} as never)
|
||||
vi.spyOn(
|
||||
runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: string | null
|
||||
repo: null
|
||||
folderWorkspace: FolderWorkspace
|
||||
}>
|
||||
},
|
||||
'resolveTerminalWorkspaceLaunchScope'
|
||||
).mockImplementation(async () => ({
|
||||
id: worktreeId,
|
||||
path: folders[0]!.folderPath,
|
||||
connectionId: folders[0]!.connectionId ?? null,
|
||||
repo: null,
|
||||
folderWorkspace: folders[0]!
|
||||
}))
|
||||
const pending: {
|
||||
resolve: (result: { id: string; spawnDisposition: 'reattached' }) => void
|
||||
reject: (error: Error) => void
|
||||
}[] = []
|
||||
const spawn = vi.fn(
|
||||
() =>
|
||||
new Promise<{ id: string; spawnDisposition: 'reattached' }>((resolve, reject) => {
|
||||
pending.push({ resolve, reject })
|
||||
})
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => []
|
||||
})
|
||||
return { runtime, folders, folder, worktreeId, ptyId, pending, spawn, kill }
|
||||
}
|
||||
|
||||
describe('mobile terminal materialization freshness', () => {
|
||||
it('bypasses an obsolete reconnect rejection for a current activation', async () => {
|
||||
const connectionId = 'freshness-ssh'
|
||||
const fixture = createFixture(connectionId)
|
||||
const focusTerminal = vi.fn()
|
||||
fixture.runtime.setNotifier({ focusTerminal } as never)
|
||||
const stale = fixture.runtime.activateMobileSessionTab(`id:${fixture.worktreeId}`, TAB_ID)
|
||||
await vi.waitFor(() => expect(fixture.spawn).toHaveBeenCalledOnce())
|
||||
|
||||
fixture.runtime.notifySshStateChanged(connectionId, {
|
||||
targetId: connectionId,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
const current = fixture.runtime.activateMobileSessionTab(`id:${fixture.worktreeId}`, TAB_ID)
|
||||
await vi.waitFor(() => expect(fixture.spawn).toHaveBeenCalledTimes(2))
|
||||
|
||||
fixture.pending[0]!.reject(new Error('obsolete reconnect'))
|
||||
await expect(stale).rejects.toThrow('obsolete reconnect')
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
|
||||
fixture.pending[1]!.resolve({ id: fixture.ptyId, spawnDisposition: 'reattached' })
|
||||
await expect(current).resolves.toMatchObject({ activeTabId: `${TAB_ID}::${LEAF_ID}` })
|
||||
expect(fixture.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bypasses an obsolete folder-routing rejection without unfencing its caller', async () => {
|
||||
const fixture = createFixture(null)
|
||||
const focusTerminal = vi.fn()
|
||||
fixture.runtime.setNotifier({ focusTerminal } as never)
|
||||
const stale = fixture.runtime.activateMobileSessionTab(`id:${fixture.worktreeId}`, TAB_ID)
|
||||
await vi.waitFor(() => expect(fixture.spawn).toHaveBeenCalledOnce())
|
||||
|
||||
fixture.folders[0] = {
|
||||
...fixture.folder,
|
||||
folderPath: '/workspace/freshness-moved',
|
||||
connectionId: 'freshness-route'
|
||||
}
|
||||
const current = fixture.runtime.activateMobileSessionTab(`id:${fixture.worktreeId}`, TAB_ID)
|
||||
await vi.waitFor(() => expect(fixture.spawn).toHaveBeenCalledTimes(2))
|
||||
|
||||
fixture.pending[0]!.reject(new Error('obsolete folder route'))
|
||||
await expect(stale).rejects.toThrow('obsolete folder route')
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
|
||||
fixture.pending[1]!.resolve({ id: fixture.ptyId, spawnDisposition: 'reattached' })
|
||||
await expect(current).resolves.toMatchObject({ activeTabId: `${TAB_ID}::${LEAF_ID}` })
|
||||
expect(fixture.spawn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,9 @@ const BASE_IDENTITY = {
|
||||
worktreeId: 'repo::/worktree',
|
||||
parentTabId: 'tab-a',
|
||||
leafId: 'leaf-a',
|
||||
sessionId: 'ssh:host-a@@session-a'
|
||||
sessionId: 'ssh:host-a@@session-a',
|
||||
workspaceFreshness: 'folder-route-a',
|
||||
reconnectGeneration: 1
|
||||
}
|
||||
|
||||
describe('mobile terminal materialization key', () => {
|
||||
@@ -18,7 +20,9 @@ describe('mobile terminal materialization key', () => {
|
||||
['worktree', { worktreeId: 'repo::/other-worktree' }],
|
||||
['parent tab', { parentTabId: 'tab-b' }],
|
||||
['leaf', { leafId: 'leaf-b' }],
|
||||
['persisted session', { sessionId: 'ssh:host-a@@session-b' }]
|
||||
['persisted session', { sessionId: 'ssh:host-a@@session-b' }],
|
||||
['folder routing freshness', { workspaceFreshness: 'folder-route-b' }],
|
||||
['reconnect generation', { reconnectGeneration: 2 }]
|
||||
])('does not coalesce a different %s', (_label, difference) => {
|
||||
expect(mobileTerminalMaterializationKey({ ...BASE_IDENTITY, ...difference })).not.toBe(
|
||||
mobileTerminalMaterializationKey(BASE_IDENTITY)
|
||||
|
||||
@@ -7,6 +7,8 @@ export function mobileTerminalMaterializationKey(identity: {
|
||||
parentTabId: string
|
||||
leafId: string
|
||||
sessionId?: string
|
||||
workspaceFreshness: string | null
|
||||
reconnectGeneration: number | null
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
identity.executionHostId,
|
||||
@@ -14,6 +16,8 @@ export function mobileTerminalMaterializationKey(identity: {
|
||||
identity.worktreeId,
|
||||
identity.parentTabId,
|
||||
identity.leafId,
|
||||
identity.sessionId ?? null
|
||||
identity.sessionId ?? null,
|
||||
identity.workspaceFreshness,
|
||||
identity.reconnectGeneration
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1554,8 +1554,11 @@ type RuntimePtyController = {
|
||||
wslDistro?: string | null
|
||||
isReattach?: boolean
|
||||
spawnDisposition?: PtySpawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
}>
|
||||
adoptSpawnReservation?(ptyId: string, token: string): boolean
|
||||
releaseSpawnReservation?(ptyId: string, token: string): boolean
|
||||
write(ptyId: string, data: string): boolean
|
||||
kill(ptyId: string): boolean
|
||||
stopAndWait?(
|
||||
@@ -4968,7 +4971,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
private createMobileSessionFolderWorkspaceMutationFence(
|
||||
worktreeId: string
|
||||
): (() => boolean) | undefined {
|
||||
): { accept: () => boolean; freshness: string } | undefined {
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type !== 'folder' || !this.store?.getFolderWorkspaces) {
|
||||
return undefined
|
||||
@@ -4977,16 +4980,19 @@ export class OrcaRuntimeService {
|
||||
.getFolderWorkspaces()
|
||||
.find((candidate) => candidate.id === scope.folderWorkspaceId)
|
||||
if (!workspace) {
|
||||
return () => false
|
||||
return { accept: () => false, freshness: 'missing' }
|
||||
}
|
||||
const expectedFingerprint = this.getFolderWorkspaceInventoryFingerprint(workspace)
|
||||
return () => {
|
||||
const current = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.find((candidate) => candidate.id === scope.folderWorkspaceId)
|
||||
return Boolean(
|
||||
current && this.getFolderWorkspaceInventoryFingerprint(current) === expectedFingerprint
|
||||
)
|
||||
return {
|
||||
freshness: expectedFingerprint,
|
||||
accept: () => {
|
||||
const current = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.find((candidate) => candidate.id === scope.folderWorkspaceId)
|
||||
return Boolean(
|
||||
current && this.getFolderWorkspaceInventoryFingerprint(current) === expectedFingerprint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8057,8 +8063,8 @@ export class OrcaRuntimeService {
|
||||
const worktreeId =
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
|
||||
const acceptWorkspaceInventory =
|
||||
this.createMobileSessionFolderWorkspaceMutationFence(worktreeId)
|
||||
const workspaceMutationFence = this.createMobileSessionFolderWorkspaceMutationFence(worktreeId)
|
||||
const acceptWorkspaceInventory = workspaceMutationFence?.accept
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
await this.refreshMobileSessionPtyRecords(worktreeId)
|
||||
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
|
||||
@@ -8106,14 +8112,6 @@ export class OrcaRuntimeService {
|
||||
if (shouldMaterializePendingTerminal) {
|
||||
const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined
|
||||
const sshConnectionId = sessionId ? parseAppSshPtyId(sessionId)?.connectionId : undefined
|
||||
const reconnectGeneration = sshConnectionId
|
||||
? (this.sshRelayRecoveryGenerationByTargetId.get(sshConnectionId) ?? 0)
|
||||
: null
|
||||
const acceptMaterialization = () =>
|
||||
acceptWorkspaceInventory?.() !== false &&
|
||||
(sshConnectionId === undefined ||
|
||||
(this.sshRelayRecoveryGenerationByTargetId.get(sshConnectionId) ?? 0) ===
|
||||
reconnectGeneration)
|
||||
const targetGroupId = snapshot?.tabGroups?.find((group) =>
|
||||
group.tabOrder.includes(tab.parentTabId)
|
||||
)?.id
|
||||
@@ -8122,13 +8120,23 @@ export class OrcaRuntimeService {
|
||||
const connectionId =
|
||||
sshConnectionId ??
|
||||
(parsedExecutionHost?.kind === 'ssh' ? parsedExecutionHost.targetId : null)
|
||||
const reconnectGeneration = connectionId
|
||||
? (this.sshRelayRecoveryGenerationByTargetId.get(connectionId) ?? 0)
|
||||
: null
|
||||
const acceptMaterialization = () =>
|
||||
acceptWorkspaceInventory?.() !== false &&
|
||||
(connectionId === null ||
|
||||
(this.sshRelayRecoveryGenerationByTargetId.get(connectionId) ?? 0) ===
|
||||
reconnectGeneration)
|
||||
const materializationKey = mobileTerminalMaterializationKey({
|
||||
executionHostId,
|
||||
connectionId,
|
||||
worktreeId,
|
||||
parentTabId: tab.parentTabId,
|
||||
leafId: tab.leafId,
|
||||
sessionId
|
||||
sessionId,
|
||||
workspaceFreshness: workspaceMutationFence?.freshness ?? null,
|
||||
reconnectGeneration
|
||||
})
|
||||
let materialization = this.pendingMobileTerminalMaterializations.get(materializationKey)
|
||||
if (!materialization) {
|
||||
@@ -8492,7 +8500,7 @@ export class OrcaRuntimeService {
|
||||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||||
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
|
||||
const acceptWorkspaceInventory =
|
||||
this.createMobileSessionFolderWorkspaceMutationFence(worktreeId)
|
||||
this.createMobileSessionFolderWorkspaceMutationFence(worktreeId)?.accept
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
|
||||
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
|
||||
this.assertMobileSessionFolderWorkspaceExists(worktreeId)
|
||||
@@ -25461,12 +25469,21 @@ export class OrcaRuntimeService {
|
||||
: {})
|
||||
})
|
||||
if (launchOpts.acceptWorkspaceInventory?.() === false) {
|
||||
if (result.spawnDisposition === 'created') {
|
||||
const shouldKill = result.spawnRetirementToken
|
||||
? this.ptyController.releaseSpawnReservation?.(result.id, result.spawnRetirementToken) ===
|
||||
true
|
||||
: result.spawnDisposition === 'created'
|
||||
if (shouldKill) {
|
||||
this.ptyController.kill(result.id)
|
||||
}
|
||||
if (result.spawnDisposition === 'created') {
|
||||
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
|
||||
}
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
if (result.spawnRetirementToken) {
|
||||
this.ptyController.adoptSpawnReservation?.(result.id, result.spawnRetirementToken)
|
||||
}
|
||||
reportPtySpawnCommitted()
|
||||
if (result.agentSessionEnsure) {
|
||||
const canonicalSurface = result.agentSessionEnsure.owner.surface
|
||||
@@ -26970,9 +26987,18 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
} catch (error) {
|
||||
this.setPairedRendererSessionOwnership(result.id, false)
|
||||
this.ptyController.kill?.(result.id)
|
||||
const shouldKill = result.spawnRetirementToken
|
||||
? this.ptyController.releaseSpawnReservation?.(result.id, result.spawnRetirementToken) ===
|
||||
true
|
||||
: true
|
||||
if (shouldKill) {
|
||||
this.ptyController.kill?.(result.id)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (result.spawnRetirementToken) {
|
||||
this.ptyController.adoptSpawnReservation?.(result.id, result.spawnRetirementToken)
|
||||
}
|
||||
if (createdPty) {
|
||||
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
|
||||
tabId: parentTabId,
|
||||
|
||||
@@ -1498,6 +1498,7 @@ export type PreloadApi = {
|
||||
}) => Promise<{
|
||||
id: string
|
||||
spawnDisposition: PtySpawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
snapshot?: string
|
||||
@@ -1511,6 +1512,8 @@ export type PreloadApi = {
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
agentResumeUnavailable?: true
|
||||
}>
|
||||
adoptSpawnReservation: (id: string, token: string) => boolean
|
||||
releaseSpawnReservation: (id: string, token: string) => boolean
|
||||
write: (id: string, data: string) => void
|
||||
writeAccepted: (id: string, data: string) => Promise<boolean>
|
||||
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
|
||||
|
||||
@@ -928,6 +928,7 @@ const api = {
|
||||
}): Promise<{
|
||||
id: string
|
||||
spawnDisposition: PtySpawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
snapshot?: string
|
||||
snapshotCols?: number
|
||||
@@ -941,6 +942,12 @@ const api = {
|
||||
agentResumeUnavailable?: true
|
||||
}> => ipcRenderer.invoke('pty:spawn', opts),
|
||||
|
||||
adoptSpawnReservation: (id: string, token: string): boolean =>
|
||||
ipcRenderer.sendSync('pty:adoptSpawnReservationSync', { id, token }),
|
||||
|
||||
releaseSpawnReservation: (id: string, token: string): boolean =>
|
||||
ipcRenderer.sendSync('pty:releaseSpawnReservationSync', { id, token }),
|
||||
|
||||
write: (id: string, data: string): void => {
|
||||
ipcRenderer.send('pty:write', { id, data })
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ export type LocalPtySessionMetadata = {
|
||||
export type PtyConnectResult = {
|
||||
id: string
|
||||
spawnDisposition?: PtySpawnDisposition
|
||||
spawnRetirementToken?: string
|
||||
/** The requested session exited while it had no primary pane handler. Its
|
||||
* buffered final data/exit were delivered, so callers must not fresh-spawn. */
|
||||
exitedBeforeAttach?: boolean
|
||||
|
||||
@@ -42,6 +42,8 @@ describe('createIpcPtyTransport', () => {
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
adoptSpawnReservation: vi.fn(() => true),
|
||||
releaseSpawnReservation: vi.fn(() => true),
|
||||
write: vi.fn(),
|
||||
writeAccepted: vi.fn().mockResolvedValue(true),
|
||||
onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => {
|
||||
@@ -1846,6 +1848,85 @@ describe('createIpcPtyTransport', () => {
|
||||
expect(kill).not.toHaveBeenCalledWith('pty-shared-fresh')
|
||||
})
|
||||
|
||||
it('adopts a shared fresh spawn before publishing a live transport', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
const adopt = window.api.pty.adoptSpawnReservation as unknown as ReturnType<typeof vi.fn>
|
||||
spawn.mockResolvedValueOnce({
|
||||
id: 'pty-shared-adopted',
|
||||
spawnDisposition: 'awaited',
|
||||
spawnRetirementToken: 'waiter-adoption'
|
||||
})
|
||||
const transport = createIpcPtyTransport({})
|
||||
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
expect(adopt).toHaveBeenCalledExactlyOnceWith('pty-shared-adopted', 'waiter-adoption')
|
||||
expect(transport.getPtyId()).toBe('pty-shared-adopted')
|
||||
})
|
||||
|
||||
it('releases a destroyed creator without killing a spawn reserved by a live waiter', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
let resolveSpawn!: (value: {
|
||||
id: string
|
||||
spawnDisposition: 'created'
|
||||
spawnRetirementToken: string
|
||||
}) => void
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
const release = window.api.pty.releaseSpawnReservation as unknown as ReturnType<typeof vi.fn>
|
||||
const kill = window.api.pty.kill as unknown as ReturnType<typeof vi.fn>
|
||||
release.mockReturnValueOnce(false)
|
||||
spawn.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSpawn = resolve
|
||||
})
|
||||
)
|
||||
const transport = createIpcPtyTransport({})
|
||||
const connect = transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
transport.destroy?.()
|
||||
resolveSpawn({
|
||||
id: 'pty-live-waiter',
|
||||
spawnDisposition: 'created',
|
||||
spawnRetirementToken: 'creator-token'
|
||||
})
|
||||
await connect
|
||||
|
||||
expect(release).toHaveBeenCalledExactlyOnceWith('pty-live-waiter', 'creator-token')
|
||||
expect(kill).not.toHaveBeenCalledWith('pty-live-waiter')
|
||||
})
|
||||
|
||||
it('kills a shared spawn when the destroyed waiter releases the final reservation', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
let resolveSpawn!: (value: {
|
||||
id: string
|
||||
spawnDisposition: 'awaited'
|
||||
spawnRetirementToken: string
|
||||
}) => void
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
const release = window.api.pty.releaseSpawnReservation as unknown as ReturnType<typeof vi.fn>
|
||||
const kill = window.api.pty.kill as unknown as ReturnType<typeof vi.fn>
|
||||
release.mockReturnValueOnce(true)
|
||||
spawn.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSpawn = resolve
|
||||
})
|
||||
)
|
||||
const transport = createIpcPtyTransport({})
|
||||
const connect = transport.connect({ url: '', callbacks: {} })
|
||||
|
||||
transport.destroy?.()
|
||||
resolveSpawn({
|
||||
id: 'pty-all-retired',
|
||||
spawnDisposition: 'awaited',
|
||||
spawnRetirementToken: 'waiter-token'
|
||||
})
|
||||
await connect
|
||||
|
||||
expect(release).toHaveBeenCalledExactlyOnceWith('pty-all-retired', 'waiter-token')
|
||||
expect(kill).toHaveBeenCalledExactlyOnceWith('pty-all-retired')
|
||||
})
|
||||
|
||||
it('kills a fresh session fallback that resolves after the transport was destroyed', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawnControls: {
|
||||
|
||||
@@ -807,7 +807,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
const spawnDisposition =
|
||||
spawnResult.spawnDisposition ?? (spawnResult.isReattach ? 'reattached' : 'created')
|
||||
const retireFreshSpawn = async (): Promise<void> => {
|
||||
if (spawnDisposition === 'created' && !spawnResult.coldRestore) {
|
||||
const shouldKill = spawnResult.spawnRetirementToken
|
||||
? window.api.pty.releaseSpawnReservation(
|
||||
spawnResult.id,
|
||||
spawnResult.spawnRetirementToken
|
||||
)
|
||||
: spawnDisposition === 'created'
|
||||
if (shouldKill && !spawnResult.coldRestore) {
|
||||
await window.api.pty.kill(spawnResult.id)
|
||||
}
|
||||
}
|
||||
@@ -824,6 +830,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
return spawnResult
|
||||
}
|
||||
|
||||
if (spawnResult.spawnRetirementToken) {
|
||||
window.api.pty.adoptSpawnReservation(spawnResult.id, spawnResult.spawnRetirementToken)
|
||||
}
|
||||
|
||||
ptyId = spawnResult.id
|
||||
connected = true
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function adoptAgentBackgroundSessionTab(args: {
|
||||
launchRegistration: NonNullable<RegisterArgs[2]>
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
runtimeTerminalHandle: string | null
|
||||
spawnRetirementToken?: string
|
||||
onRetire: () => void
|
||||
title?: string
|
||||
}): Promise<{
|
||||
@@ -78,6 +79,7 @@ export async function adoptAgentBackgroundSessionTab(args: {
|
||||
ptyId,
|
||||
runtimeTarget: args.runtimeTarget,
|
||||
runtimeTerminalHandle: args.runtimeTerminalHandle,
|
||||
spawnRetirementToken: args.spawnRetirementToken,
|
||||
onRetire: args.onRetire
|
||||
})
|
||||
) {
|
||||
@@ -90,7 +92,8 @@ export async function adoptAgentBackgroundSessionTab(args: {
|
||||
await retireProvider({
|
||||
ptyId,
|
||||
runtimeTarget: args.runtimeTarget,
|
||||
runtimeTerminalHandle: args.runtimeTerminalHandle
|
||||
runtimeTerminalHandle: args.runtimeTerminalHandle,
|
||||
spawnRetirementToken: args.spawnRetirementToken
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -109,5 +112,8 @@ export async function adoptAgentBackgroundSessionTab(args: {
|
||||
args.runtimeTarget.kind,
|
||||
args.title
|
||||
)
|
||||
if (args.spawnRetirementToken) {
|
||||
window.api.pty.adoptSpawnReservation(ptyId, args.spawnRetirementToken)
|
||||
}
|
||||
return { tab, paneKey, terminalOwnership }
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export async function launchAgentBackgroundSession(
|
||||
let ptyId = '',
|
||||
runtimeTerminalHandle: string | null = null
|
||||
let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined
|
||||
let spawnRetirementToken: string | undefined
|
||||
let tab: ReturnType<typeof store.createTab> | null = null
|
||||
let exitHandled = false,
|
||||
eagerPtyBuffer: EagerPtyHandle | null = null
|
||||
@@ -219,6 +220,7 @@ export async function launchAgentBackgroundSession(
|
||||
}
|
||||
})
|
||||
ptyId = result.id
|
||||
spawnRetirementToken = result.spawnRetirementToken
|
||||
returnedLaunchConfig = result.launchConfig
|
||||
}
|
||||
const adopted = await adoptAgentBackgroundSessionTab({
|
||||
@@ -231,6 +233,7 @@ export async function launchAgentBackgroundSession(
|
||||
launchRegistration,
|
||||
runtimeTarget,
|
||||
runtimeTerminalHandle,
|
||||
spawnRetirementToken,
|
||||
onRetire: () => {
|
||||
exitHandled = true
|
||||
sshStartupDelivery.clear()
|
||||
@@ -238,6 +241,7 @@ export async function launchAgentBackgroundSession(
|
||||
},
|
||||
...(title ? { title } : {})
|
||||
})
|
||||
spawnRetirementToken = undefined
|
||||
if (!adopted) {
|
||||
return null
|
||||
}
|
||||
@@ -310,9 +314,7 @@ export async function launchAgentBackgroundSession(
|
||||
runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId))
|
||||
}
|
||||
runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey))
|
||||
if (ptyId) {
|
||||
await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle })
|
||||
}
|
||||
await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle, spawnRetirementToken })
|
||||
if (createdTab) {
|
||||
// Cleanup closes must not enter the reopen stack.
|
||||
runBestEffortAgentBackgroundCleanups(() =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
||||
import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
|
||||
import { adoptSpawn, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
@@ -133,7 +133,7 @@ async function spawnPane(args: {
|
||||
leafId: string
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
}): Promise<string> {
|
||||
}): Promise<{ id: string; spawnRetirementToken?: string }> {
|
||||
const result = await window.api.pty.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
@@ -145,7 +145,7 @@ async function spawnPane(args: {
|
||||
tabId: args.tabId,
|
||||
leafId: args.leafId
|
||||
})
|
||||
return result.id
|
||||
return { id: result.id, spawnRetirementToken: result.spawnRetirementToken }
|
||||
}
|
||||
|
||||
async function createBackgroundTab(args: {
|
||||
@@ -167,9 +167,9 @@ async function createBackgroundTab(args: {
|
||||
|
||||
const leafId = createBrowserUuid()
|
||||
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId))
|
||||
let ptyId: string
|
||||
let spawnResult: Awaited<ReturnType<typeof spawnPane>>
|
||||
try {
|
||||
ptyId = await spawnPane({
|
||||
spawnResult = await spawnPane({
|
||||
worktree: args.worktree,
|
||||
connectionId: args.connectionId,
|
||||
tabId: tab.id,
|
||||
@@ -181,15 +181,18 @@ async function createBackgroundTab(args: {
|
||||
store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' })
|
||||
throw error
|
||||
}
|
||||
const ptyId = spawnResult.id
|
||||
if (
|
||||
await retireUnownedTerminal({
|
||||
owner: { tabId: tab.id },
|
||||
ptyId,
|
||||
runtimeTarget: { kind: 'local' }
|
||||
runtimeTarget: { kind: 'local' },
|
||||
spawnRetirementToken: spawnResult.spawnRetirementToken
|
||||
})
|
||||
) {
|
||||
throw new Error('The terminal tab was closed before its session finished starting.')
|
||||
}
|
||||
adoptSpawn(spawnResult)
|
||||
store.updateTabPtyId(tab.id, ptyId)
|
||||
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId))
|
||||
registerBackgroundPaneBuffer(tab.id, leafId, ptyId)
|
||||
@@ -205,7 +208,7 @@ async function addSetupSplit(args: {
|
||||
}): Promise<void> {
|
||||
const store = useAppStore.getState()
|
||||
const setupLeafId = createBrowserUuid()
|
||||
const setupPtyId = await spawnPane({
|
||||
const setupSpawn = await spawnPane({
|
||||
worktree: args.worktree,
|
||||
connectionId: args.connectionId,
|
||||
tabId: args.tab.tabId,
|
||||
@@ -213,15 +216,18 @@ async function addSetupSplit(args: {
|
||||
command: buildSetupCommand(args.setup),
|
||||
env: args.setup.envVars
|
||||
})
|
||||
const setupPtyId = setupSpawn.id
|
||||
if (
|
||||
await retireUnownedTerminal({
|
||||
owner: { tabId: args.tab.tabId },
|
||||
ptyId: setupPtyId,
|
||||
runtimeTarget: { kind: 'local' }
|
||||
runtimeTarget: { kind: 'local' },
|
||||
spawnRetirementToken: setupSpawn.spawnRetirementToken
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
adoptSpawn(setupSpawn)
|
||||
store.updateTabPtyId(args.tab.tabId, setupPtyId)
|
||||
store.setTabLayout(
|
||||
args.tab.tabId,
|
||||
|
||||
@@ -2,12 +2,19 @@ import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement'
|
||||
|
||||
export function adoptSpawn(result: { id: string; spawnRetirementToken?: string }): void {
|
||||
if (result.spawnRetirementToken) {
|
||||
window.api.pty.adoptSpawnReservation(result.id, result.spawnRetirementToken)
|
||||
}
|
||||
}
|
||||
|
||||
export async function retireUnownedTerminal(args: {
|
||||
/** Present tab id, or `{ worktreeId }` for a launch whose tab is created after the spawn. */
|
||||
owner: { tabId: string } | { worktreeId: string }
|
||||
ptyId: string
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
runtimeTerminalHandle?: string | null
|
||||
spawnRetirementToken?: string
|
||||
onRetire?: () => void
|
||||
}): Promise<boolean> {
|
||||
const state = useAppStore.getState()
|
||||
@@ -30,14 +37,23 @@ export async function retireProvider(args: {
|
||||
ptyId: string
|
||||
runtimeTarget: RuntimeClientTarget
|
||||
runtimeTerminalHandle?: string | null
|
||||
spawnRetirementToken?: string
|
||||
}): Promise<void> {
|
||||
if (!args.ptyId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (args.runtimeTarget.kind === 'environment' && args.runtimeTerminalHandle) {
|
||||
await callRuntimeRpc(args.runtimeTarget, 'terminal.close', {
|
||||
terminal: args.runtimeTerminalHandle
|
||||
})
|
||||
} else if (args.runtimeTarget.kind === 'local') {
|
||||
await window.api.pty.kill(args.ptyId)
|
||||
const shouldKill = args.spawnRetirementToken
|
||||
? window.api.pty.releaseSpawnReservation(args.ptyId, args.spawnRetirementToken)
|
||||
: true
|
||||
if (shouldKill) {
|
||||
await window.api.pty.kill(args.ptyId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort provider teardown; the retired tab must not be recreated.
|
||||
|
||||
@@ -3117,6 +3117,8 @@ function createShellApi(): NonNullable<Partial<PreloadApi>['shell']> {
|
||||
function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
return {
|
||||
spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')),
|
||||
adoptSpawnReservation: () => false,
|
||||
releaseSpawnReservation: () => false,
|
||||
write: () => {},
|
||||
writeAccepted: () => Promise.resolve(false),
|
||||
resize: () => {},
|
||||
|
||||
Reference in New Issue
Block a user