mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user