Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/main/daemon/daemon-exit-receipt-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
import type { DaemonRequestRouter } from './daemon-request-router'
import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations'
import type { DaemonRequest } from './types'
import { MAX_EXIT_RECEIPTS, type TerminalHostSessionRecord } from './terminal-host-session-record'
import { localProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry'
import {
inspectExitedIncarnationFromRuntimeController,
releaseExitedIncarnationFromRuntimeController
} from '../ipc/pty/runtime/operations'

describe('exit receipt consumption is bookkeeping, not terminal shutdown', () => {
let harness: Awaited<ReturnType<typeof startDaemonAdapterHarness>>
let subprocess: ReturnType<typeof createMockSubprocess>
let previousProvider: typeof localProvider

beforeEach(async () => {
previousProvider = localProvider
harness = await startDaemonAdapterHarness(() => {
subprocess = createMockSubprocess()
return subprocess
})
setLocalPtyProvider(harness.adapter)
})

afterEach(async () => {
vi.restoreAllMocks()
setLocalPtyProvider(previousProvider)
harness?.adapter.dispose()
await harness?.server.shutdown()
if (harness) {
rmSync(harness.dir, { recursive: true, force: true })
}
})

function router(): DaemonRequestRouter {
return (harness.server as unknown as { requestRouter: DaemonRequestRouter }).requestRouter
}

async function exited(id: string) {
const spawned = await harness.adapter.spawn({ sessionId: id, cols: 80, rows: 24 })
const incarnation = spawned.incarnationId!
subprocess._simulateExit(0)
expect(await inspectExitedIncarnationFromRuntimeController(id, incarnation)).toBe(true)
return incarnation
}

it.each(['current', 'legacy without kill fence'] as const)(
'preserves a replacement process on a %s daemon',
async (version) => {
const id = 'receipt-replaced'
const oldIncarnation = await exited(id)
const replacement = await harness.adapter.spawn({ sessionId: id, cols: 80, rows: 24 })
const child = subprocess
expect(replacement.incarnationId).not.toBe(oldIncarnation)
if (version === 'legacy without kill fence') {
const originalRoute = router().route.bind(router())
vi.spyOn(router(), 'route').mockImplementation((clientId, request) => {
if (request.type === 'consumeExitReceipt') {
throw new Error('Unknown request type: consumeExitReceipt')
}
// v1.4.199 reads sessionId/immediate but ignores the new kill condition.
const compatible: DaemonRequest =
request.type === 'kill'
? {
...request,
payload: {
sessionId: request.payload.sessionId,
immediate: request.payload.immediate
}
}
: request
return originalRoute(clientId, compatible)
})
}

await releaseExitedIncarnationFromRuntimeController(id, oldIncarnation)

expect(child.forceKill).not.toHaveBeenCalled()
expect(child.kill).not.toHaveBeenCalled()
expect(await harness.adapter.probePtyLiveness(id)).toBe(true)
}
)

it('leaves another client’s replacement spawn preparation untouched', async () => {
const id = 'receipt-preparing'
const incarnation = await exited(id)
const preparations = (
router() as unknown as {
options: { preparations: DaemonPtySpawnPreparations }
}
).options.preparations
// Register at the real admission boundary before native spawn has completed.
const preparation = preparations.register(id, 'replacement-client', 'spawn-request', undefined)
try {
await releaseExitedIncarnationFromRuntimeController(id, incarnation)
expect(preparation.canceled).toBe(false)
expect(preparation.controller.signal.aborted).toBe(false)
await expect(preparations.prepareUnlessCanceled(id, preparation)).resolves.toBeUndefined()
} finally {
preparations.finish(id, preparation)
}
})

it('cannot stop even a live process with the matching incarnation', async () => {
const id = 'receipt-still-live'
const spawned = await harness.adapter.spawn({ sessionId: id, cols: 80, rows: 24 })
const child = subprocess
await releaseExitedIncarnationFromRuntimeController(id, spawned.incarnationId!)
expect(child.kill).not.toHaveBeenCalled()
expect(child.forceKill).not.toHaveBeenCalled()
expect(await harness.adapter.probePtyLiveness(id)).toBe(true)
})

it('returns no exit proof after eviction and safely accepts late acknowledgement', async () => {
await harness.adapter.spawn({ sessionId: 'live-sentinel', cols: 80, rows: 24 })
const host = (
harness.server as unknown as {
host: { sessions: Map<string, TerminalHostSessionRecord> }
}
).host
for (let i = 0; i < MAX_EXIT_RECEIPTS; i++) {
host.sessions.set(`old-${i}`, { incarnationId: `old-inc-${i}`, code: 0 })
}
expect(await inspectExitedIncarnationFromRuntimeController('old-0', 'old-inc-0')).toBe(true)
const fresh = await exited('fresh-exit')
expect(await inspectExitedIncarnationFromRuntimeController('old-0', 'old-inc-0')).toBe(false)
expect(await inspectExitedIncarnationFromRuntimeController('old-1', 'old-inc-1')).toBe(true)
expect(await inspectExitedIncarnationFromRuntimeController('fresh-exit', fresh)).toBe(true)
expect(await harness.adapter.probePtyLiveness('live-sentinel')).toBe(true)
await expect(harness.adapter.consumeExitReceipt('old-0', 'old-inc-0')).resolves.toBeUndefined()
})

it('consumes only matching receipts and tolerates duplicate or missing acknowledgements', async () => {
const id = 'receipt-repeat'
const incarnation = await exited(id)
await harness.adapter.consumeExitReceipt(id, 'different-incarnation')
expect(await inspectExitedIncarnationFromRuntimeController(id, incarnation)).toBe(true)
await harness.adapter.consumeExitReceipt(id, incarnation)
expect(await inspectExitedIncarnationFromRuntimeController(id, incarnation)).toBe(false)
await expect(harness.adapter.consumeExitReceipt(id, incarnation)).resolves.toBeUndefined()
await expect(harness.adapter.consumeExitReceipt('absent', incarnation)).resolves.toBeUndefined()
})

it('leaves evidence intact when an old host rejects consumption', async () => {
const id = 'receipt-unsupported'
const incarnation = await exited(id)
const originalRoute = router().route.bind(router())
const calls = vi.spyOn(router(), 'route').mockImplementation((clientId, request) => {
if (request.type === 'consumeExitReceipt') {
throw new Error('Unknown request type: consumeExitReceipt')
}
return originalRoute(clientId, request)
})
await expect(
releaseExitedIncarnationFromRuntimeController(id, incarnation)
).resolves.toBeUndefined()
expect(await inspectExitedIncarnationFromRuntimeController(id, incarnation)).toBe(true)
expect(calls.mock.calls.some(([, request]) => request.type === 'kill')).toBe(false)
})

it('preserves history and does not mark an observed exit as an explicit kill', async () => {
const id = 'receipt-history'
const incarnation = await exited(id)
const state = harness.adapter as unknown as {
historyManager: unknown
killedSessionTombstones: Map<string, number>
}
const originalHistory = state.historyManager
const removeSession = vi.fn(async () => {})
state.historyManager = { removeSession, closeSession: vi.fn(async () => {}) }
try {
await releaseExitedIncarnationFromRuntimeController(id, incarnation)
expect(removeSession).not.toHaveBeenCalled()
expect(state.killedSessionTombstones.has(id)).toBe(false)
expect(await inspectExitedIncarnationFromRuntimeController(id, incarnation)).toBe(false)
} finally {
state.historyManager = originalHistory
}
})
})
132 changes: 132 additions & 0 deletions src/main/daemon/daemon-inspection-incarnation-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// A worker whose shell ended while Orca was closed leaves no route behind: nothing in this
// process claims its id any more. The incarnation-scoped question is exactly the one that has to
// survive that, so both daemon-fronting providers must carry it (and its `expectedIncarnationId`)
// to a daemon instead of answering `terminal_gone` from their own bookkeeping.
import { describe, expect, it, vi } from 'vitest'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { IPtyProvider } from '../providers/types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'

const INCARNATION = 'c0ffee00-0000-4000-8000-000000000001'

const EXITED: PtyProcessInspection = {
foregroundProcess: null,
hasChildProcesses: false,
foregroundProcessEvidence: {
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs: 0,
ptyId: 'pty-away',
ptyIncarnationId: INCARNATION,
verdict: 'exited',
reason: 'pty_exit_0'
}
}

function createAdapter(sessions: string[] = []): DaemonPtyAdapter {
return {
hasPty: vi.fn((id: string) => sessions.includes(id)),
inspectProcess: vi.fn(async () => EXITED),
consumeExitReceipt: vi.fn(async () => {}),
listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: 'daemon' }))),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
onWriteUnavailable: vi.fn(() => () => {}),
onBackgroundStreamEvent: vi.fn(() => () => {})
} as unknown as DaemonPtyAdapter
}

function createFallbackProvider(): IPtyProvider {
return {
hasPty: vi.fn(() => false),
inspectProcess: vi.fn(async () => EXITED),
listProcesses: vi.fn(async () => []),
onData: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
onWriteUnavailable: vi.fn(() => () => {}),
onBackgroundStreamEvent: vi.fn(() => () => {})
} as unknown as IPtyProvider
}

describe('DaemonPtyRouter incarnation-scoped inspection', () => {
it('routes receipt consumption like inspection without retiring its route', async () => {
const current = createAdapter()
const legacy = createAdapter(['pty-live'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.consumeExitReceipt('pty-away', INCARNATION)
expect(current.consumeExitReceipt).toHaveBeenCalledWith('pty-away', INCARNATION)
await router.consumeExitReceipt('pty-live', INCARNATION)
expect(legacy.consumeExitReceipt).toHaveBeenCalledWith('pty-live', INCARNATION)
await router.inspectProcess('pty-live', { expectedIncarnationId: INCARNATION })
expect(legacy.inspectProcess).toHaveBeenCalled()
})
it('asks the current daemon about an unclaimed id when the caller names an incarnation', async () => {
const current = createAdapter()
const router = new DaemonPtyRouter({ current, legacy: [createAdapter()] })

await expect(
router.inspectProcess('pty-away', { expectedIncarnationId: INCARNATION })
).resolves.toEqual(EXITED)
expect(current.inspectProcess).toHaveBeenCalledWith('pty-away', {
expectedIncarnationId: INCARNATION
})
})

it('forwards the incarnation to the daemon that still claims the id', async () => {
const legacy = createAdapter(['pty-live'])
const router = new DaemonPtyRouter({ current: createAdapter(), legacy: [legacy] })

await router.inspectProcess('pty-live', { expectedIncarnationId: INCARNATION })

expect(legacy.inspectProcess).toHaveBeenCalledWith('pty-live', {
expectedIncarnationId: INCARNATION
})
})
})

describe('DegradedDaemonPtyProvider incarnation-scoped inspection', () => {
it('consumes unclaimed daemon evidence without using fallback shutdown', async () => {
const current = createAdapter()
const fallback = createFallbackProvider()
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
await provider.consumeExitReceipt('pty-away', INCARNATION)
expect(current.consumeExitReceipt).toHaveBeenCalledWith('pty-away', INCARNATION)
})
it('asks the current daemon about an unclaimed id when the caller names an incarnation', async () => {
const current = createAdapter()
const provider = new DegradedDaemonPtyProvider({
current,
legacy: [],
fallback: createFallbackProvider()
})

await expect(
provider.inspectProcess('pty-away', { expectedIncarnationId: INCARNATION })
).resolves.toEqual(EXITED)
expect(current.inspectProcess).toHaveBeenCalledWith('pty-away', {
expectedIncarnationId: INCARNATION
})
})

it('forwards inspection options to the provider that owns the session', async () => {
const current = createAdapter(['pty-live'])
const provider = new DegradedDaemonPtyProvider({
current,
legacy: [],
fallback: createFallbackProvider()
})
await provider.discoverDaemonSessions()

await provider.inspectProcess('pty-live', {
expectedIncarnationId: INCARNATION,
steadyState: true
})

expect(current.inspectProcess).toHaveBeenCalledWith('pty-live', {
expectedIncarnationId: INCARNATION,
steadyState: true
})
})
})
47 changes: 47 additions & 0 deletions src/main/daemon/daemon-pty-adapter-kill-incarnation-fence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { rmSync } from 'node:fs'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'

describe('DaemonPtyAdapter shutdown incarnation fence', () => {
let adapter: DaemonPtyAdapter
let server: Awaited<ReturnType<typeof startDaemonAdapterHarness>>['server']
let tempDir: string
let lastSubprocess: ReturnType<typeof createMockSubprocess>

beforeEach(async () => {
const harness = await startDaemonAdapterHarness(() => {
lastSubprocess = createMockSubprocess()
return lastSubprocess
})
adapter = harness.adapter
server = harness.server
tempDir = harness.dir
})

afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
})

it('refuses over the wire to end a shell that is not the named incarnation', async () => {
const sessionId = 'kill-fence'
const spawned = await adapter.spawn({ cols: 80, rows: 24, sessionId })
const live = lastSubprocess

await expect(
adapter.shutdown(sessionId, { immediate: true, expectedIncarnationId: 'an-older-shell' })
).rejects.toThrow('PTY incarnation mismatch')
expect(live.kill).not.toHaveBeenCalled()
expect(live.forceKill).not.toHaveBeenCalled()

await adapter.shutdown(sessionId, {
immediate: true,
expectedIncarnationId: spawned.incarnationId
})
expect(live.forceKill).toHaveBeenCalled()
})
})
5 changes: 5 additions & 0 deletions src/main/daemon/daemon-pty-process-inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { clientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection'

export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots {
async consumeExitReceipt(id: string, incarnationId: string): Promise<void> {
// Predecessors reject this distinct request; never fall back to shutdown.
await this.client.request('consumeExitReceipt', { sessionId: id, incarnationId })
}

// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean {
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
Expand Down
Loading