fix(pty): clean up failed post-spawn setup

This commit is contained in:
Brennan Benson
2026-07-13 20:39:08 -07:00
parent 0b6417bbb4
commit 97a22a75cf
2 changed files with 69 additions and 5 deletions
+33 -3
View File
@@ -6129,13 +6129,22 @@ describe('registerPtyHandlers', () => {
// awaits a promise that never settles and the tab hangs forever.
registerPtyHandlers(mainWindow as never)
const leafId = '44444444-4444-4444-8444-444444444444'
const spawnArgs = { cols: 80, rows: 24, tabId: 'tab-reservation', leafId }
const paneKey = makePaneKey('tab-reservation', leafId)
const spawnArgs = {
cols: 80,
rows: 24,
tabId: 'tab-reservation',
leafId,
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-reservation' }
}
registerPtyMock.mockImplementationOnce(() => {
throw new Error('boom: post-spawn registration failed')
})
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow('boom')
const orphanedPtyId = (registerPtyMock.mock.calls[0]![0] as { ptyId: string }).ptyId
expect(unregisterPtyMock).toHaveBeenCalledWith(orphanedPtyId)
// A second spawn for the same pane must run a fresh spawn rather than await
// the leaked (never-settled) reservation promise.
@@ -6175,12 +6184,16 @@ describe('registerPtyHandlers', () => {
}
let spawnCount = 0
const providerSpawn = vi.fn(async () => ({ id: `pty-${++spawnCount}` }))
const shutdown = vi
.fn()
.mockRejectedValueOnce(new Error('native shutdown failed'))
.mockResolvedValue(undefined)
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
@@ -6199,7 +6212,9 @@ describe('registerPtyHandlers', () => {
getProfiles: vi.fn()
} as never)
const store = {
persistPtyBinding: vi.fn()
persistPtyBinding: vi.fn(),
upsertPendingLocalPtyShutdown: vi.fn(),
removePendingLocalPtyShutdown: vi.fn()
}
let controller: RuntimeSpawnController | null = null
const runtime = {
@@ -6240,6 +6255,21 @@ describe('registerPtyHandlers', () => {
}
await expect(spawnController.spawn(spawnArgs)).rejects.toThrow('boom')
expect(shutdown).toHaveBeenCalledWith('pty-1', {
immediate: true,
expectedPaneKey: paneKey,
expectedTabId: 'tab-runtime-reservation'
})
expect(store.upsertPendingLocalPtyShutdown).toHaveBeenCalledWith({
ptyId: 'pty-1',
expectedPaneKey: paneKey,
expectedTabId: 'tab-runtime-reservation',
requestedAt: expect.any(Number)
})
expect(store.removePendingLocalPtyShutdown).not.toHaveBeenCalled()
await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(2))
expect(store.removePendingLocalPtyShutdown).toHaveBeenCalledWith('pty-1')
expect(unregisterPtyMock).toHaveBeenCalledWith('pty-1')
// The reservation must be gone, so a second materialization runs a fresh
// provider.spawn instead of awaiting the leaked promise.
+36 -2
View File
@@ -3732,7 +3732,8 @@ export function registerPtyHandlers(
const paneSpawnReservation = materializedPaneKey
? reservePaneSpawn(materializedPaneKey)
: null
let result: PtySpawnResult
let result: PtySpawnResult | undefined
let failedSpawnCleanupAttempted = false
try {
try {
if (args.preAllocatedHandle) {
@@ -3829,6 +3830,7 @@ export function registerPtyHandlers(
// writes, but the pre-existing provider session remains owned.
deletePtyOwnership(result.id)
} else {
failedSpawnCleanupAttempted = true
if (args.connectionId) {
// Why: the binding failed, so the SSH lease is the only durable
// identity that can carry cleanup across an app restart.
@@ -3911,6 +3913,21 @@ export function registerPtyHandlers(
const response = { id: result.id }
return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response)
} catch (err) {
if (result?.isReattach) {
// Why: this request published only routing ownership for an existing
// provider session; failed setup must not kill that session.
deletePtyOwnership(result.id)
} else if (result && !failedSpawnCleanupAttempted) {
try {
await retainFailedSpawnCleanup(result.id, args.connectionId, provider, {
immediate: true,
...(metadataPaneKey ? { expectedPaneKey: metadataPaneKey } : {}),
...(typeof args.tabId === 'string' ? { expectedTabId: args.tabId } : {})
})
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after post-spawn failure:', shutdownErr)
}
}
// Why: once the reservation is created, any later throw — spawn
// failure, persist failure, or a post-spawn helper such as
// registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise
@@ -4642,7 +4659,8 @@ export function registerPtyHandlers(
if (preSpawnHiddenMarkId !== null) {
markHiddenRendererPty(preSpawnHiddenMarkId)
}
let result: PtySpawnResult
let result: PtySpawnResult | undefined
let failedSpawnCleanupAttempted = false
try {
try {
if (preAllocatedHandle) {
@@ -4826,6 +4844,7 @@ export function registerPtyHandlers(
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
if (!result.isReattach) {
failedSpawnCleanupAttempted = true
try {
await retainFailedSpawnCleanup(result.id, args.connectionId, provider, {
immediate: true,
@@ -5030,6 +5049,21 @@ export function registerPtyHandlers(
}
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
} catch (err) {
if (result?.isReattach) {
// Why: this request published only routing ownership for an existing
// provider session; failed setup must not kill that session.
deletePtyOwnership(result.id)
} else if (result && !failedSpawnCleanupAttempted) {
try {
await retainFailedSpawnCleanup(result.id, args.connectionId, provider, {
immediate: true,
...(validatedPaneKey ? { expectedPaneKey: validatedPaneKey } : {}),
...(typeof args.tabId === 'string' ? { expectedTabId: args.tabId } : {})
})
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after post-spawn failure:', shutdownErr)
}
}
// Why: once the reservation is created, any later throw —
// spawn failure, persist failure, or a post-spawn helper such as
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise