Fix PTY spawn lifecycle: handle concurrent races and cleanup abandoned a

Properly release Agent Teams leader handles when spawns are abandoned or fail,
restore provisional PTY sizes on reattachment, and settle concurrent spawn races
for the same pane. Add validation guards for destroyed renderers and improve
handler re-registration to reset delivery state before bridging a new window.
This commit is contained in:
Jinjing
2026-08-24 13:36:18 -07:00
parent ea26a1ee0b
commit 59dc14ec2f
34 changed files with 602 additions and 82 deletions
@@ -48,8 +48,13 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
)
describe('registerPtyHandlers', () => {
const { handlers, mainWindow, createMockProc, installObservableDaemonTestProvider } =
setupPtyIpcSuite()
const {
handlers,
mainWindow,
mainWindowIpcEvent,
createMockProc,
installObservableDaemonTestProvider
} = setupPtyIpcSuite()
describe('agent_started telemetry', () => {
// Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event.
@@ -190,7 +195,7 @@ describe('registerPtyHandlers', () => {
expect(responseChannelRegistrations.length).toBe(1)
// Drain the in-flight requests so the test doesn't leak timers.
for (const requestId of getSentRequestIds()) {
listener(null, { requestId, snapshot: null })
listener(mainWindowIpcEvent, { requestId, snapshot: null })
}
await Promise.all(inflight)
})
@@ -202,11 +207,11 @@ describe('registerPtyHandlers', () => {
const requestIdA = ids[0]
const requestIdB = ids[1]
listener(null, {
listener(mainWindowIpcEvent, {
requestId: requestIdB,
snapshot: { data: 'B-data', cols: 80, rows: 24 }
})
listener(null, {
listener(mainWindowIpcEvent, {
requestId: requestIdA,
snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' }
})
@@ -224,11 +229,11 @@ describe('registerPtyHandlers', () => {
const pending = controller.serializeBuffer('pty-1')
const realRequestId = getSentRequestIds()[0]
listener(null, {
listener(mainWindowIpcEvent, {
requestId: 'not-a-real-id',
snapshot: { data: 'irrelevant', cols: 1, rows: 1 }
})
listener(null, { requestId: undefined, snapshot: null })
listener(mainWindowIpcEvent, { requestId: undefined, snapshot: null })
let resolved = false
void pending.then(() => {
@@ -237,7 +242,10 @@ describe('registerPtyHandlers', () => {
await new Promise((r) => setTimeout(r, 0))
expect(resolved).toBe(false)
listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } })
listener(mainWindowIpcEvent, {
requestId: realRequestId,
snapshot: { data: 'ok', cols: 80, rows: 24 }
})
await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 })
})
it('resolves to null and removes the entry when the 750ms timeout fires', async () => {
@@ -255,7 +263,7 @@ describe('registerPtyHandlers', () => {
const { listener, controller } = setup()
const pending = controller.serializeBuffer('pty-bad')
const requestId = getSentRequestIds()[0]
listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } })
listener(mainWindowIpcEvent, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } })
await expect(pending).resolves.toBeNull()
})
})
@@ -153,6 +153,62 @@ describe('registerPtyHandlers', () => {
})
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('bounds keep-history inventory polls by the settlement deadline', async () => {
vi.useFakeTimers()
try {
const listProcesses = vi.fn(async (_opts?: { deadlineMs?: number }) => [])
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(async () => undefined),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
) => Promise<boolean>
}
const callerDeadlineMs = Date.now() + 5_000
const stopPromise = controller.stopAndWait('local-pty', {
keepHistory: true,
deadlineMs: callerDeadlineMs
})
await vi.advanceTimersByTimeAsync(1_000)
await expect(stopPromise).resolves.toBe(true)
expect(listProcesses.mock.calls[0]?.[0]).toEqual({ deadlineMs: callerDeadlineMs })
const settlementDeadlines = listProcesses.mock.calls
.slice(1)
.map(([opts]) => opts?.deadlineMs)
expect(settlementDeadlines.length).toBeGreaterThan(0)
expect(new Set(settlementDeadlines)).toEqual(new Set([callerDeadlineMs - 4_000]))
} finally {
vi.useRealTimers()
}
})
it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => {
const exitListeners = new Set<
(payload: { id: string; code: number; incarnationId?: string }) => void
@@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => {
})
expect(warnSpy).toHaveBeenCalledWith(
'[pty] hidden-delivery gate is dropping bytes for a visible/active pty',
expect.objectContaining({ id: result.id, visible: true })
expect.objectContaining({ id: redactPtyIdForDiagnostics(result.id), visible: true })
)
// Unhiding resolves the contradiction.
@@ -206,6 +206,24 @@ describe('registerPtyHandlers', () => {
vi.useRealTimers()
}
})
it('releases a paused producer when handlers re-register for a replacement window', () => {
vi.useFakeTimers()
try {
const provider = installObservableDaemonTestProvider()
registerPtyHandlers(mainWindow as never)
mainWindow.webContents.send.mockClear()
provider.emitData('flood-pty', 'x'.repeat(320 * 1024))
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
// Re-registration hands delivery to a new window; the outgoing session must run its real
// lifecycle reset before the bridge is neutralized or this shell stays paused forever.
registerPtyHandlers(mainWindow as never)
expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty')
} finally {
vi.useRealTimers()
}
})
it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => {
vi.useFakeTimers()
try {
@@ -131,6 +131,124 @@ describe('registerPtyHandlers', () => {
await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' })
expect(providerSpawn).toHaveBeenCalledOnce()
})
it('adopts a runtime reservation created during renderer preflight', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
command?: string
sessionId?: string
tabId: string
leafId: string
env: Record<string, string>
}): Promise<{ id: string }>
}
let resolveProviderSpawn!: (result: { id: string }) => void
const providerSpawn = vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
resolveProviderSpawn = resolve
})
)
setLocalPtyProvider({
spawn: providerSpawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
controller = value
}),
createPreAllocatedTerminalHandle: vi.fn(() => 'term-concurrent'),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
let releaseAuth!: (value: {
configDir: string
envPatch: Record<string, never>
stripAuthEnv: false
provenance: string
}) => void
const prepareClaudeAuth = vi.fn(
() =>
new Promise<{
configDir: string
envPatch: Record<string, never>
stripAuthEnv: false
provenance: string
}>((resolve) => {
releaseAuth = resolve
})
)
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
prepareClaudeAuth
)
const tabId = 'tab-mid-preflight-runtime'
const leafId = '55555555-5555-4555-8555-555555555555'
const paneKey = makePaneKey(tabId, leafId)
const paneArgs = {
cols: 80,
rows: 24,
sessionId: 'pty-runtime-reservation',
tabId,
leafId,
env: { ORCA_PANE_KEY: paneKey }
}
const rendererSpawn = handlers.get('pty:spawn')!(null, {
...paneArgs,
command: 'claude'
})
await vi.waitFor(() => expect(prepareClaudeAuth).toHaveBeenCalledOnce())
const runtimeSpawn = (controller as unknown as RuntimeSpawnController).spawn({
...paneArgs,
command: 'printf runtime'
})
await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce())
releaseAuth({
configDir: '/tmp/claude',
envPatch: {},
stripAuthEnv: false,
provenance: 'managed:test'
})
await new Promise<void>((resolve) => setImmediate(resolve))
resolveProviderSpawn({ id: 'pty-runtime-reservation' })
await expect(runtimeSpawn).resolves.toEqual({ id: 'pty-runtime-reservation' })
await expect(rendererSpawn).resolves.toMatchObject({
id: 'pty-runtime-reservation',
isReattach: true
})
expect(providerSpawn).toHaveBeenCalledOnce()
await expect(
handlers.get('pty:getSize')!(null, { id: 'pty-runtime-reservation' })
).resolves.toEqual({ cols: 80, rows: 24 })
})
it('reuses renderer spawn when runtime materialization starts for the same pane', async () => {
type RuntimeSpawnController = {
spawn(args: {
@@ -408,6 +408,7 @@ describe('registerPtyHandlers', () => {
const destroyedListeners: (() => void)[] = []
const sender = {
id: 42,
isDestroyed: () => false,
once: vi.fn((event: string, listener: () => void) => {
if (event === 'destroyed') {
destroyedListeners.push(listener)
@@ -422,6 +423,20 @@ describe('registerPtyHandlers', () => {
destroyedListeners[0]()
expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false)
})
it('does not retain a serializer declaration from an already-destroyed renderer', async () => {
registerPtyHandlers(mainWindow as never)
const paneKey = makePaneKey('tab-dead', '77777777-7777-4777-8777-777777777777')
const sender = {
id: 43,
isDestroyed: () => true,
once: vi.fn()
}
await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey })
expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false)
expect(sender.once).not.toHaveBeenCalled()
})
it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => {
const runtime = {
setPtyController: vi.fn(),
@@ -91,6 +91,21 @@ describe('registerPtyHandlers', () => {
).resolves.toBe(null)
expect(hasPty).not.toHaveBeenCalled()
})
it.each(['remote:env-1@@terminal-1', 'ssh:missing-host@@pty-1'])(
'never routes process inspection for %s to the local provider',
async (id) => {
const inspectProcess = vi.fn()
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider({ inspectProcess } as never)
await expect(handlers.get('pty:inspectProcess')!(null, { id })).resolves.toEqual({
foregroundProcess: null,
hasChildProcesses: false,
unavailable: true
})
expect(inspectProcess).not.toHaveBeenCalled()
}
)
it('lists duplicate SSH relay session ids as distinct app sessions', async () => {
registerPtyHandlers(mainWindow as never)
const shutdownA = vi.fn(async () => undefined)
@@ -5,6 +5,12 @@ import { createHash } from 'node:crypto'
import { delimiter } from 'node:path'
import { makePaneKey } from '../../shared/stable-pane-id'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { ClaudeAgentTeamsService } from '../runtime/claude-agent-teams-service'
import {
clearPaneSpawnReservation,
makePaneSpawnReservationKey,
reservePaneSpawn
} from './pty/pane/spawn-reservation'
import { registerPtyHandlers, registerSshPtyProvider } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
@@ -500,4 +506,121 @@ describe('registerPtyHandlers', () => {
id: spawned.id
})
})
it('releases the Agent Teams leader of a spawn that loses the pane mid-preflight', async () => {
const leafId = '22222222-2222-4222-8222-222222222222'
const teams = new ClaudeAgentTeamsService()
let handleSeq = 0
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => `term_agent_teams_${++handleSeq}`),
prepareClaudeAgentTeamsLeaderForHandle: vi.fn(
async (args: { handle: string; baseEnv?: Record<string, string> }) =>
teams.createLaunchEnv({
leaderHandle: args.handle,
baseEnv: args.baseEnv ?? {},
shimDir: '/tmp/orca-agent-teams-shim',
shimBin: null
})
),
releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => {
teams.removeTeamForLeaderHandle(handle)
}),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
// Why: no worktreeId, so the reservation key only resolves after env assembly — the
// window where a concurrent winner's reservation appears mid-preflight.
const spawnArgs = {
cols: 80,
rows: 24,
cwd: '/repo',
command: 'claude --teammate-mode auto',
tabId: 'tab-1',
leafId,
env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' },
launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} },
launchAgent: 'claude'
}
const winner = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs)) as {
id: string
}
expect(teams.getActiveTeamCount()).toBe(1)
const reservationKey = makePaneSpawnReservationKey(
undefined,
undefined,
makePaneKey('tab-1', leafId)
)!
const winnerReservation = reservePaneSpawn(reservationKey)
winnerReservation.resolve({ id: winner.id })
try {
const loser = await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs)
expect(loser).toMatchObject({ id: winner.id, isReattach: true })
} finally {
clearPaneSpawnReservation(reservationKey, winnerReservation)
}
const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle
expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2)
expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle)
// The losing request must not grow the team count it can never bind to a PTY.
expect(teams.getActiveTeamCount()).toBe(1)
})
it('releases the Agent Teams leader when its provider spawn fails', async () => {
const leafId = '33333333-3333-4333-8333-333333333333'
const teams = new ClaudeAgentTeamsService()
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams_failure'),
prepareClaudeAgentTeamsLeaderForHandle: vi.fn(
async (args: { handle: string; baseEnv?: Record<string, string> }) =>
teams.createLaunchEnv({
leaderHandle: args.handle,
baseEnv: args.baseEnv ?? {},
shimDir: '/tmp/orca-agent-teams-shim',
shimBin: null
})
),
releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => {
teams.removeTeamForLeaderHandle(handle)
}),
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
spawnMock.mockImplementation(() => {
throw new Error('provider spawn failed')
})
registerPtyHandlers(mainWindow as never, runtime as never)
await expect(
handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
cwd: '/repo',
command: 'claude --teammate-mode auto',
tabId: 'tab-1',
leafId,
worktreeId: 'wt-1',
env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' },
launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} },
launchAgent: 'claude'
})
).rejects.toThrow()
expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(
'term_agent_teams_failure'
)
expect(teams.getActiveTeamCount()).toBe(0)
})
})
+1 -1
View File
@@ -159,7 +159,7 @@ export function warnIfDroppingHiddenBytesForVisiblePty(
}
session.lastHiddenDropContradictionWarnAtMs = now
console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', {
id,
id: redactPtyIdForDiagnostics(id),
droppedChars,
visible: visibleRendererPtys.has(id),
active: activeRendererPtys.has(id),
+25 -22
View File
@@ -37,15 +37,22 @@ export function sendModelRestoreNeededMarker(
id: string,
reason: PtyModelRestoreReason,
markerSeq: number | undefined
): void {
): boolean {
if (session.mainWindow.isDestroyed()) {
return
return false
}
session.mainWindow.webContents.send('pty:modelRestoreNeeded', {
id,
reason,
...(typeof markerSeq === 'number' ? { markerSeq } : {})
})
try {
session.mainWindow.webContents.send('pty:modelRestoreNeeded', {
id,
reason,
...(typeof markerSeq === 'number' ? { markerSeq } : {})
})
} catch (error) {
// Why: a disposed render frame throws synchronously here, and this rides the data path.
console.error('[pty] renderer model-restore marker send failed', error)
return false
}
return true
}
export function sendPtyDataToRenderer(
@@ -114,21 +121,17 @@ export function sendPtyDataToRenderer(
projectionsTransferred = true
}
}
if (session.rendererDeliveryRestoreNeededPtys.has(id)) {
try {
sendModelRestoreNeededMarker(
session,
id,
'delivery-heal',
session.runtime?.getPtyOutputSequence(id)
)
session.rendererDeliveryRestoreNeededPtys.delete(id)
} catch (error) {
console.error(
'[pty] renderer delivery-heal marker send failed; restore remains pending',
error
)
}
if (
session.rendererDeliveryRestoreNeededPtys.has(id) &&
sendModelRestoreNeededMarker(
session,
id,
'delivery-heal',
session.runtime?.getPtyOutputSequence(id)
)
) {
// Why cleared only on a successful send: an unsent marker leaves the restore pending.
session.rendererDeliveryRestoreNeededPtys.delete(id)
}
return { sent: true, projectionsTransferred }
}
+12 -1
View File
@@ -42,8 +42,19 @@ export function syncPtyBackgroundedDelivery(
known: rendererVisibilityKnownPtys.has(id),
visible: visibleRendererPtys.has(id)
})
const previous = session.backgroundedDeliverySyncByPty.get(id)
session.backgroundedDeliverySyncByPty.set(id, background)
provider.setPtyBackgrounded(id, background)
try {
provider.setPtyBackgrounded(id, background)
} catch (error) {
// Why: a recorded state the provider never took would short-circuit every later sync for this PTY.
if (previous === undefined) {
session.backgroundedDeliverySyncByPty.delete(id)
} else {
session.backgroundedDeliverySyncByPty.set(id, previous)
}
console.error('[pty] setPtyBackgrounded failed; delivery sync state rolled back', error)
}
}
export function resyncBackgroundedDeliveriesAfterGateReset(session: PtyIpcSession): void {
+3 -1
View File
@@ -45,7 +45,9 @@ export function installSessionSshOutputIntake(session: PtyIpcSession): void {
return release
},
finalizeExit: (event) => {
session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, {
hostExitConfirmed: true
})
session.finalizePtyExitForRenderer(event)
},
pauseProvider: (generation, id) => {
+12 -3
View File
@@ -231,9 +231,18 @@ export function installPtyInspectIpcHandlers(deps: {
}
)
ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
)
ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => {
// Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error.
if (
typeof args?.id !== 'string' ||
!args.id ||
args.id.startsWith('remote:') ||
!hasPtyProviderForInspection(args.id)
) {
return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const }
}
return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
})
ipcMain.handle(
'pty:confirmForegroundProcess',
@@ -84,6 +84,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void {
})
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers.
ipcMain.removeAllListeners('pty:ackColdRestore')
ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => {
const provider = tryGetProviderForPty(args.id)
if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') {
@@ -92,6 +93,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void {
})
// Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path.
ipcMain.removeAllListeners('pty:ackData')
ipcMain.on(
'pty:ackData',
(_event, args: { id: string; charCount?: number; processedChars?: number }) => {
@@ -114,6 +116,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void {
}
)
ipcMain.removeAllListeners('pty:deliveryResyncResponse')
ipcMain.on(
'pty:deliveryResyncResponse',
(_event, args: { requestId: number; processedCharsByPty: Record<string, number> }) => {
@@ -142,6 +145,7 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void {
)
// Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane.
ipcMain.removeHandler('pty:reportRendererDeliveryState')
ipcMain.handle(
'pty:reportRendererDeliveryState',
(_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => {
+7 -2
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags'
import { isMainWindowPtyIpcEvent } from './write-input'
import type { PtyIpcSession, SerializeResult } from '../session'
export function settleSerializeRequest(
@@ -22,7 +23,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void {
ipcMain.on(
'pty:serializeBuffer:response',
(
_event,
event,
args: {
requestId?: string
snapshot?: {
@@ -35,7 +36,11 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void {
} | null
}
) => {
if (typeof args?.requestId !== 'string') {
// Why: the snapshot seeds terminal restore state, so only the main window may settle it.
if (
!isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) ||
typeof args?.requestId !== 'string'
) {
return
}
const snapshot = args.snapshot
+1
View File
@@ -25,6 +25,7 @@ import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx)
ctx.agentTeamsLeaderHandle = null
// Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback).
// Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot.
+1
View File
@@ -89,6 +89,7 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise<voi
handle: ctx.preAllocatedHandle,
baseEnv: ctx.baseEnv ?? {}
})
ctx.agentTeamsLeaderHandle = ctx.preAllocatedHandle
ctx.baseEnv = {
...ctx.baseEnv,
...prepared.env
+17 -1
View File
@@ -56,6 +56,7 @@ export async function buildPtyIpcSpawnOptions(
? { prevalidatedCwd: ctx.prevalidatedCwd }
: {}),
env: ctx.spawnEnv,
historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true,
...(ctx.isMintedSessionId ? { isNewSession: true } : {})
}
if (!args.connectionId && !ctx.isDaemonHostSpawn) {
@@ -140,7 +141,22 @@ export async function buildPtyIpcSpawnOptions(
? paneSpawnReservationsByOwnerKey.get(ctx.paneSpawnReservationKey)
: undefined
if (existingPaneSpawnAfterPreflight) {
return { ...(await existingPaneSpawnAfterPreflight.promise), isReattach: true }
const reattach = {
...(await existingPaneSpawnAfterPreflight.promise),
isReattach: true as const
}
// Why: discard only a distinct provisional id; the winner may have committed this id's real size.
if (ctx.effectiveSessionId !== undefined) {
const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId
if (provisionalSizeKey !== reattach.id) {
if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) {
ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach)
} else {
ptySizes.delete(provisionalSizeKey)
}
}
}
return reattach
}
ctx.paneSpawnReservation = ctx.paneSpawnReservationKey
? reservePaneSpawn(ctx.paneSpawnReservationKey)
+36 -2
View File
@@ -1,13 +1,34 @@
import { rejectPaneSpawnReservation } from '../pane/spawn-reservation'
import { ptySizes } from '../delivery/visibility-state'
import { beginPtyIpcSpawn } from './spawn-begin'
import { preparePtyIpcSpawnPreflight } from './spawn-preflight'
import { assemblePtyIpcSpawnEnv } from './spawn-env'
import { buildPtyIpcSpawnOptions } from './spawn-options'
import { executePtyIpcSpawn } from './spawn-execute'
import { commitPtyIpcSpawn } from './spawn-commit'
import { createPtyIpcSpawnState } from './spawn-state'
import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void {
if (!ctx.agentTeamsLeaderHandle) {
return
}
ctx.deps.runtime?.releaseClaudeAgentTeamsLeaderForHandle?.(ctx.agentTeamsLeaderHandle)
ctx.agentTeamsLeaderHandle = null
}
function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void {
if (ctx.effectiveSessionId === undefined) {
return
}
const key = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId
if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) {
ptySizes.set(key, ctx.sessionSizeBeforeAttach)
} else {
ptySizes.delete(key)
}
}
export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) {
const ctx = createPtyIpcSpawnState(deps, args)
const early = await beginPtyIpcSpawn(ctx)
@@ -17,10 +38,23 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
try {
await preparePtyIpcSpawnPreflight(ctx)
await assemblePtyIpcSpawnEnv(ctx)
await buildPtyIpcSpawnOptions(ctx)
const earlyReserved = await buildPtyIpcSpawnOptions(ctx)
if (earlyReserved) {
// Why: this request lost the pane to the reservation winner, so its
// pre-allocated leader handle never binds to a PTY. Nothing else can
// evict the team env assembly created for it — exit/close cleanup keys
// off handleByPtyId — so every lost race would leak one team forever.
releaseAbandonedAgentTeamsLeader(ctx)
return earlyReserved
}
await executePtyIpcSpawn(ctx)
return await commitPtyIpcSpawn(ctx)
} catch (err) {
releaseAbandonedAgentTeamsLeader(ctx)
restoreProvisionalPtySize(ctx)
if (ctx.preSpawnHiddenMarkId !== null) {
ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false)
}
if (ctx.pendingRegistrationPtyId) {
deps.runtime?.cancelPendingPtyRegistration?.(
ctx.pendingRegistrationPtyId,
+3
View File
@@ -52,6 +52,8 @@ export type PtyIpcSpawnState = {
baseEnv: Record<string, string> | undefined
effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined
preAllocatedHandle: string | null
/** Leader handle this spawn allocated Agent Teams state for; must be released if the spawn is abandoned. */
agentTeamsLeaderHandle: string | null
requestedAgentTeamsPath: string | undefined
agentTeamsEnvToDelete: string[] | undefined
stablePaneKey: string | null
@@ -124,6 +126,7 @@ export function createPtyIpcSpawnState(
baseEnv: undefined,
effectiveLaunchConfig: undefined,
preAllocatedHandle: null,
agentTeamsLeaderHandle: null,
requestedAgentTeamsPath: undefined,
agentTeamsEnvToDelete: undefined,
stablePaneKey: null,
-4
View File
@@ -44,7 +44,6 @@ export function createPtyWriteInput(deps: {
) => boolean
} {
const { mainWindow, runtime, clearHiddenRendererResizeOutput } = deps
void mainWindow
const reportUnavailablePtyWrite = (id: string, error: unknown): void => {
if (
@@ -124,9 +123,6 @@ export function createPtyWriteInput(deps: {
}
}
type PtyWritePayload = { id: string; data: string }
type PtyViewportClaimPayload = { id: string; cols: number; rows: number }
const isPtyWritePayload = (value: unknown): value is PtyWritePayload =>
typeof value === 'object' &&
value !== null &&
+5 -1
View File
@@ -57,7 +57,11 @@ export function installPtyWriteIpcHandlers(deps: {
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
)
: runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
).catch(() => false)
).catch((error) => {
// Why: a failed claim silently discards every gated keystroke for this pane.
console.error('[pty] remote desktop host claim failed; gated input will be discarded', error)
return false
})
hostViewportClaimTails.set(args.id, claim)
void claim.then(() => {
if (hostViewportClaimTails.get(args.id) === claim) {
@@ -14,6 +14,9 @@ import {
// multiple SSH relays; coordinate claims above every provider boundary too.
export const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
let agentSessionOwnerReconciliation: Promise<void> | null = null
// Why: this reconcile gates spawn; a wedged relay listing must fail closed (rejecting
// with unknown ownership) instead of blocking the spawn path indefinitely.
const OWNER_LISTING_DEADLINE_MS = 5_000
// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay
// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn
@@ -67,10 +70,13 @@ export async function reconcileAgentSessionOwnerListings(): Promise<void> {
{ provider: localProvider, connectionId: null },
...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId }))
]
const deadlineMs = Date.now() + OWNER_LISTING_DEADLINE_MS
const listings = await Promise.all(
providers.map(async ({ provider, connectionId }) => ({
connectionId,
sessions: await provider.listProcesses()
sessions: await provider.listProcesses({ deadlineMs }).catch(() => {
throw new Error('agent_session_ownership_unknown')
})
}))
)
const advertisedOwners: AgentSessionOwnerBinding[] = []
+7
View File
@@ -34,6 +34,13 @@ export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string |
if (!isValidPaneKey(normalizedPaneKey)) {
return null
}
// Why: a re-registered ptyId would otherwise leave its old paneKey pointing here.
const previousPaneKey = ptyPaneKey.get(ptyId)
if (previousPaneKey && previousPaneKey !== normalizedPaneKey) {
if (paneKeyPtyId.get(previousPaneKey) === ptyId) {
paneKeyPtyId.delete(previousPaneKey)
}
}
ptyPaneKey.set(ptyId, normalizedPaneKey)
paneKeyPtyId.set(normalizedPaneKey, ptyId)
return normalizedPaneKey
@@ -39,6 +39,12 @@ export function declarePendingPaneSerializer(
sender: WebContents | undefined
): number {
const gen = ++pendingSerializerGenSeq
// Why: a sender destroyed before the declaration never fires 'destroyed' again,
// so its entry would strand in pendingByPaneKey and suppress the daemon seed forever.
if (sender?.isDestroyed()) {
cleanupPendingPaneSerializersForSender(sender.id)
return gen
}
registerPendingPaneSerializerCleanup(sender)
const replaced = pendingByPaneKey.get(paneKey)
if (replaced) {
+9 -3
View File
@@ -110,10 +110,16 @@ export async function verifyPtyStopped(
if (!opts?.keepHistory) {
return true
}
const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS
const settleDeadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS
// Why: deadlineMs is absolute, so the settle poll must not outlive the caller's teardown budget.
const deadline =
opts.deadlineMs !== undefined ? Math.min(settleDeadline, opts.deadlineMs) : settleDeadline
while (Date.now() < deadline) {
await delay(KEEP_HISTORY_STOP_POLL_MS)
if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) {
await delay(Math.min(KEEP_HISTORY_STOP_POLL_MS, deadline - Date.now()))
if (Date.now() >= deadline) {
break
}
if (await isProviderPtyLive(provider, ptyId, deadline)) {
return false
}
}
+6 -1
View File
@@ -49,7 +49,12 @@ export function getProviderForPty(ptyId: string): IPtyProvider {
export function hasPtyProviderForInspection(ptyId: string): boolean {
// Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors.
const connectionId = ptyOwnership.get(ptyId)
return connectionId == null || sshProviders.has(connectionId)
if (connectionId === undefined) {
// Why: mirror getProviderForPty — an unowned id still routes by its encoded SSH owner.
const parsedSshId = parseAppSshPtyId(ptyId)
return !parsedSshId || sshProviders.has(parsedSshId.connectionId)
}
return connectionId === null || sshProviders.has(connectionId)
}
export function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string {
+4
View File
@@ -32,6 +32,7 @@ import {
setRendererGateResetState
} from './provider/listener-lifecycle'
import {
resetRendererDeliveryAccountingForLifecycleReset,
setResetRendererDeliveryAccountingForLifecycleReset,
clearRendererDispatcherReadyWatchdog
} from './delivery/debug'
@@ -65,6 +66,9 @@ export function registerPtyHandlers(
store?: Store,
options?: PtyIpcSessionOptions
): void {
// Why first: the outgoing session owns the producer pauses, so its real reset must run
// before the bridge is neutralized or a PTY paused during re-registration stays paused.
resetRendererDeliveryAccountingForLifecycleReset()
// Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure.
clearRendererDispatcherReadyWatchdog()
setResetRendererDeliveryAccountingForLifecycleReset(() => {})
+7
View File
@@ -83,6 +83,13 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
...(ctx.env ? { launchEnv: ctx.env } : {})
})
}
// Why: the adopted branch returns before the normal settle site, so the
// reservation must be resolved here or every later spawn for this pane
// awaits a promise that never settles.
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, {
...ctx.result,
isReattach: true
})
return {
id: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
+17 -13
View File
@@ -11,19 +11,23 @@ export function adoptMaterializedRuntimePtySpawn(
startupAlreadyAwaited = false
): Promise<PtySpawnResult | null> | PtySpawnResult | null {
const args = ctx.args
ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
ctx.codexHomeLaunchStartedSequence = !args.connectionId
? allocatePtyLifecycleSequence()
: undefined
ctx.preAdoptedStablePane = args.adoptedStablePane ?? null
ctx.reattachedCodexHomeRoutes = !args.connectionId
? new Map(
snapshotCodexPaneHomeRoutes([
ctx.preAdoptedStablePane?.result.id,
args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined
])
)
: new Map<string, CodexPaneHomeRoute | null>()
// Why: the re-entry after the startup await must not re-mint the lifecycle
// sequence or re-snapshot routes taken before the barrier.
if (!startupAlreadyAwaited) {
ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
ctx.codexHomeLaunchStartedSequence = !args.connectionId
? allocatePtyLifecycleSequence()
: undefined
ctx.preAdoptedStablePane = args.adoptedStablePane ?? null
ctx.reattachedCodexHomeRoutes = !args.connectionId
? new Map(
snapshotCodexPaneHomeRoutes([
ctx.preAdoptedStablePane?.result.id,
args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined
])
)
: new Map<string, CodexPaneHomeRoute | null>()
}
const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId)
if (startupPromise && !startupAlreadyAwaited) {
return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true))
+14 -1
View File
@@ -42,6 +42,7 @@ export async function buildRuntimePtySpawnOptions(
rows: args.rows,
cwd: ctx.cwd,
env: ctx.env,
historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true,
...(ctx.isNewDaemonSession ? { isNewSession: true } : {})
}
if (!args.connectionId && !ctx.isDaemonHostSpawn) {
@@ -203,7 +204,7 @@ export async function buildRuntimePtySpawnOptions(
) {
throw new Error('terminal_pane_owner_unknown')
}
return {
const reattach = {
id: concurrentOwner.ptyId,
...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}),
stablePaneOwner: {
@@ -212,6 +213,18 @@ export async function buildRuntimePtySpawnOptions(
leafId: concurrentOwner.leafId
}
}
// Why: the winner owns its id's size; only a distinct losing session id remains provisional.
if (ctx.sessionId !== undefined) {
const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.sessionId
if (provisionalSizeKey !== reattach.id) {
if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) {
ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach)
} else {
ptySizes.delete(provisionalSizeKey)
}
}
}
return reattach
}
}
ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId)
+28 -14
View File
@@ -8,6 +8,7 @@ import {
reservePaneSpawn
} from '../pane/spawn-reservation'
import { resolveStablePaneOwner } from '../pane/stable-owner'
import { ptySizes } from '../delivery/visibility-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
import { adoptMaterializedRuntimePtySpawn } from './spawn-early'
import { prepareRuntimePtySpawn } from './spawn-preflight'
@@ -32,6 +33,18 @@ function toRuntimeSpawnReply(result: {
}
}
function restoreProvisionalPtySize(ctx: ReturnType<typeof createRuntimePtySpawnState>): void {
if (ctx.sessionId === undefined) {
return
}
const key = ctx.effectiveSessionAppId ?? ctx.sessionId
if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) {
ptySizes.set(key, ctx.sessionSizeBeforeAttach)
} else {
ptySizes.delete(key)
}
}
export async function spawnPtyFromRuntimeController(
deps: PtyRuntimeControllerDeps,
args: RuntimePtySpawnArgs
@@ -63,24 +76,25 @@ export async function spawnPtyFromRuntimeController(
ctx.paneSpawnReservation = reservePaneSpawn(ownerKey)
}
}
const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx)
const materialized =
materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise
if (materialized) {
return toRuntimeSpawnReply(materialized)
}
const earlyAdopt = await prepareRuntimePtySpawn(ctx)
if (earlyAdopt) {
return toRuntimeSpawnReply(earlyAdopt)
}
const earlyReserved = await buildRuntimePtySpawnOptions(ctx)
if (earlyReserved) {
return toRuntimeSpawnReply(earlyReserved)
}
try {
const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx)
const materialized =
materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise
if (materialized) {
return toRuntimeSpawnReply(materialized)
}
const earlyAdopt = await prepareRuntimePtySpawn(ctx)
if (earlyAdopt) {
return toRuntimeSpawnReply(earlyAdopt)
}
const earlyReserved = await buildRuntimePtySpawnOptions(ctx)
if (earlyReserved) {
return toRuntimeSpawnReply(earlyReserved)
}
await executeRuntimePtySpawn(ctx)
return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx))
} catch (err) {
restoreProvisionalPtySize(ctx)
if (ctx.pendingRegistrationPtyId) {
deps.runtime?.cancelPendingPtyRegistration?.(
ctx.pendingRegistrationPtyId,
+1 -1
View File
@@ -115,7 +115,7 @@ export type PtyIpcSession = {
id: string,
reason: PtyModelRestoreReason,
markerSeq: number | undefined
) => void
) => boolean
updateProducerFlowControl: (id: string) => void
applyCumulativeAck: (id: string, processedChars: number) => number
readCurrentPtyRendererDeliveryDebugSnapshot: () => PtyRendererDeliveryDebugSnapshot
+6
View File
@@ -30341,6 +30341,12 @@ export class OrcaRuntimeService {
})
}
// Why: a leader handle that never binds to a PTY (lost pane race) has no exit
// or close path to evict its team, so the abandoning caller must release it.
releaseClaudeAgentTeamsLeaderForHandle(handle: string): void {
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
}
private waitForNewLeafInTab(
tabId: string,
existingLeafKeys: Set<string>,