Fix shared PTY reservation ownership

This commit is contained in:
Brennan Benson
2026-08-01 07:51:22 -07:00
parent b7f31677c8
commit 1466ce8050
19 changed files with 719 additions and 120 deletions
@@ -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'))
})
})
+145
View File
@@ -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)
}
}
}
+10 -2
View File
@@ -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
View File
@@ -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
])
}
+49 -23
View File
@@ -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,
+3
View File
@@ -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
+7
View File
@@ -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.
+2
View File
@@ -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: () => {},