diff --git a/src/main/ipc/pane-spawn-reservation.test.ts b/src/main/ipc/pane-spawn-reservation.test.ts new file mode 100644 index 00000000000..af279b5ce26 --- /dev/null +++ b/src/main/ipc/pane-spawn-reservation.test.ts @@ -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')) + }) +}) diff --git a/src/main/ipc/pane-spawn-reservation.ts b/src/main/ipc/pane-spawn-reservation.ts new file mode 100644 index 00000000000..85ef304c9d6 --- /dev/null +++ b/src/main/ipc/pane-spawn-reservation.ts @@ -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 + +export type PaneSpawnReservation = { + promise: Promise + resolve: (result: PaneSpawnReservationResult) => void + reject: (error: unknown) => void + creatorRetirementToken: string + waiterRetirementTokens: Set +} + +type PaneSpawnRetirement = { + ptyId: string + tokens: Set +} + +// Paired clients share the provider operation but retain independent retirement authority. +const reservationsByScope = new Map() +const retirementsByToken = new Map() + +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((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + promise.catch(() => {}) + const reservation = { + promise, + resolve, + reject, + creatorRetirementToken: randomUUID(), + waiterRetirementTokens: new Set() + } + 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( + 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 { + 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) + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 7f64d36b2c6..2a0b835f77b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7da9db7f14d..4d25bd57fa7 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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() const pendingPaneSerializerCleanupRegistered = new Set() -type PaneSpawnReservation = { - promise: Promise - resolve: (result: PaneSpawnReservationResult) => void - reject: (error: unknown) => void -} -type PaneSpawnReservationResult = { - id: string - spawnDisposition: PtySpawnDisposition - launchConfig?: SleepingAgentLaunchConfig -} & Partial -// 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() const paneSpawnProviderIds = new WeakMap() 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((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( - 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 { - 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. diff --git a/src/main/runtime/mobile-folder-session-mutation-race.test.ts b/src/main/runtime/mobile-folder-session-mutation-race.test.ts index e7eb7591889..45bf551a077 100644 --- a/src/main/runtime/mobile-folder-session-mutation-race.test.ts +++ b/src/main/runtime/mobile-folder-session-mutation-race.test.ts @@ -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}` }) diff --git a/src/main/runtime/mobile-terminal-materialization-freshness.test.ts b/src/main/runtime/mobile-terminal-materialization-freshness.test.ts new file mode 100644 index 00000000000..c9fe338776e --- /dev/null +++ b/src/main/runtime/mobile-terminal-materialization-freshness.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/mobile-terminal-materialization-key.test.ts b/src/main/runtime/mobile-terminal-materialization-key.test.ts index 81e913015b6..40000a8f280 100644 --- a/src/main/runtime/mobile-terminal-materialization-key.test.ts +++ b/src/main/runtime/mobile-terminal-materialization-key.test.ts @@ -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) diff --git a/src/main/runtime/mobile-terminal-materialization-key.ts b/src/main/runtime/mobile-terminal-materialization-key.ts index e6b2e613492..938ffaee5ad 100644 --- a/src/main/runtime/mobile-terminal-materialization-key.ts +++ b/src/main/runtime/mobile-terminal-materialization-key.ts @@ -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 ]) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 750bef7e67d..ef3e04b5819 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6a852f76e45..541176e49f5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index f38686d0776..425f53c509a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 }) }, diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 32029bf6e52..77ca498aa9f 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 3fbbfd1e4b2..c805643c507 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -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 + const adopt = window.api.pty.adoptSpawnReservation as unknown as ReturnType + 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 + const release = window.api.pty.releaseSpawnReservation as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + 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 + const release = window.api.pty.releaseSpawnReservation as unknown as ReturnType + const kill = window.api.pty.kill as unknown as ReturnType + 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: { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 287885a07fd..138b26bd6b1 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -807,7 +807,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const spawnDisposition = spawnResult.spawnDisposition ?? (spawnResult.isReattach ? 'reattached' : 'created') const retireFreshSpawn = async (): Promise => { - 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 diff --git a/src/renderer/src/lib/adopt-agent-background-session-tab.ts b/src/renderer/src/lib/adopt-agent-background-session-tab.ts index ec604137645..eb02e7b9831 100644 --- a/src/renderer/src/lib/adopt-agent-background-session-tab.ts +++ b/src/renderer/src/lib/adopt-agent-background-session-tab.ts @@ -63,6 +63,7 @@ export async function adoptAgentBackgroundSessionTab(args: { launchRegistration: NonNullable 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 } } diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index f65204d6558..b52232ba540 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -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 | 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(() => diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 4c4c1603d0b..f14037f244e 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -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 -}): Promise { +}): 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> 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 { 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, diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts index 40a0b0afb60..df66d1907fc 100644 --- a/src/renderer/src/lib/retire-unowned-background-terminal.ts +++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts @@ -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 { const state = useAppStore.getState() @@ -30,14 +37,23 @@ export async function retireProvider(args: { ptyId: string runtimeTarget: RuntimeClientTarget runtimeTerminalHandle?: string | null + spawnRetirementToken?: string }): Promise { + 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. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6e6367a5afa..6f70c0b3727 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3117,6 +3117,8 @@ function createShellApi(): NonNullable['shell']> { function createPtyApi(): NonNullable['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: () => {},