fix(terminal): kill agent descendant processes on session teardown (STA-1800) (#8706)

* fix(terminal): kill agent descendant processes on session teardown (STA-1800)

Agent CLIs spawn tool children in detached process groups that PTY
SIGHUP can never reach. Killing an agent session (tab close, retire,
sleep) left those children running as orphans — eight orphaned git
processes burned ~8 cores for up to 11.5h under the agents-running
keep-awake and drained a battery to 8%.

New pty-descendant-termination module: snapshot the ppid tree BEFORE
signalling (a dead root's descendants reparent to pid 1 and become
unfindable), SIGTERM the root group and every descendant, then after a
2s grace SIGKILL survivors gated on a pid+start-time identity re-check
so a recycled pid is never signalled. Snapshot is bounded and never
rejects; failures degrade to today's shell-only kill.

Wired for agent sessions only (plain terminals keep nohup semantics) at
all three POSIX kill sites: local provider shutdown, daemon
TerminalHost immediate kill (the pty:kill path — force-kill bypassed
Session.kill entirely), and daemon Session graceful kill.

Verified live in the built app: an agent pane with a detached-pgid
child; the child survived on the unwired build (three control runs) and
dies within ~5s with the fix. Windows ConPTY and SSH-hosted PTYs keep
the previous foreground-tree contract (documented follow-ups).

* fix(terminal): harden descendant teardown

* fix(terminal): require fresh process snapshots

* fix(terminal): close descendant teardown races

* refactor(terminal): preserve teardown line budget

* fix(terminal): keep descendant teardown fresh and identity-safe

* docs(reliability): record integrated descendant E2E

* fix(terminal): bound descendant teardown work

* fix(terminal): share descendant snapshot indexes

* docs(reliability): record descendant review evidence

* revert: remove speculative descendant hardening
This commit is contained in:
Brennan Benson 2026-07-15 13:25:24 -07:00 committed by GitHub
parent 40c006122d
commit 40d0159926
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1592 additions and 74 deletions

View File

@ -711,15 +711,18 @@
"ssh",
"runtime"
],
"coverageNotes": "A live macOS Electron test proves exact local PTY disappearance after a parked-tab close. Deterministic unit/store tests cover daemon and SSH routing, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/8628"
"https://github.com/stablyai/orca/pull/8628",
"https://github.com/stablyai/orca/pull/8706"
],
"invariant": "Close permanently removes the owned provider session and resume authority even when no TerminalPane is mounted; detach and park preserve both; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; unit tests assert canonical owner dedupe, one teardown owner, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"commands": [
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1"
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
],
"testFiles": [
"src/main/agent-hooks/server-pane-authority.test.ts",
@ -727,12 +730,18 @@
"src/main/ipc/agent-pane-authority-ownership.test.ts",
"src/main/ipc/pty-management.test.ts",
"src/main/persistence.test.ts",
"src/main/pty-descendant-termination.test.ts",
"src/main/daemon/session.test.ts",
"src/main/daemon/terminal-host.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/main/runtime/worktree-teardown.test.ts",
"src/renderer/src/store/slices/agent-pane-authority.test.ts",
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts"
"tests/e2e/terminal-parked-close-retirement.spec.ts",
"tests/e2e/agent-descendant-process-kill.spec.ts"
],
"assertionRefs": [
{
@ -754,6 +763,39 @@
"exact pane retirement removes resume and launch authority while preserving siblings",
"chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes"
]
},
{
"file": "src/main/pty-descendant-termination.test.ts",
"assertions": [
"20 same-turn process-table requests execute one fresh scan while later arrivals start one shared successor inside their own deadline",
"snapshot and escalation readers stop at their deadline",
"production ps reads force locale-independent C timestamps",
"the source scan timestamp survives request resolution and capture-second identities are never escalated with SIGKILL",
"cyclic-looking duplicate PID rows terminate with each descendant visited once and duplicate escalation identities stay unsignalled",
"descendant signals are suppressed after the caller loses root ownership"
]
},
{
"file": "src/main/daemon/terminal-host.test.ts",
"assertions": [
"agent immediate kill rejects reattach while descendant capture is pending",
"a naturally exited session id remains reserved until capture finishes without force-killing its retired PID",
"graceful teardown upgrades to immediate both during and after descendant capture",
"duplicate immediate kill starts one descendant sweep"
]
},
{
"file": "src/main/runtime/worktree-teardown.test.ts",
"assertions": [
"owned provider shutdowns start together so process-table snapshots can coalesce within a batch",
"inventories above 32 sessions never exceed 32 concurrent provider shutdowns"
]
},
{
"file": "tests/e2e/agent-descendant-process-kill.spec.ts",
"assertions": [
"a detached-pgid descendant is alive before agent PTY kill and absent afterward"
]
}
],
"evidenceRuns": [
@ -765,11 +807,29 @@
"result": "passed",
"durationSeconds": 40.3,
"summary": "A fresh E2E build launched an isolated Electron profile, parked a live terminal, closed it through closeTab, and observed its exact PTY disappear."
},
{
"date": "2026-07-14",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 37.7,
"summary": "A current-main integrated fresh-build run proved a detached-pgid child was alive before agent PTY kill and absent afterward on the deadline-safe, root-ownership-gated implementation."
},
{
"date": "2026-07-15",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 78,
"summary": "The cycle-safe, target-indexed, bounded-fanout review head passed from a cold full build; the live detached-pgid descendant test body completed in 4.8 seconds."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "fresh E2E build plus one isolated local Electron parked-close test"
"scope": "fresh E2E build plus isolated local Electron parked-close and descendant-kill tests"
},
"flakeHistory": {
"status": "unknown",
@ -780,8 +840,8 @@
"evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached."
},
"performanceBudget": {
"required": false,
"evidence": "The close path is user-triggered and bounded by canonical live-owner indexing; kill-all scale remains covered by terminal-session.kill-all-surface-cleanup."
"required": true,
"evidence": "The close path is user-triggered and bounded by canonical live-owner indexing. Production ps has a 1s kill timeout; 20 same-turn requests execute one fresh process-table read, while requests arriving after a scan starts immediately share one successor so their deadline is not consumed waiting and no unusable ps starts after timeout. Completed tables are never reused. Bulk worktree shutdown runs in 32-wide batches so each batch can coalesce its initial scan without unbounded provider fanout. Descendant traversal uses a visited set and index cursor; a Node 24 local 100,000-wide synthetic tree fell from 861ms to 16.7ms, and escalation indexes only the requested descendant PIDs instead of duplicating the full process table. Escalation uses the same bounded coordinator, and kill-all store scale remains covered by terminal-session.kill-all-surface-cleanup."
},
"promotionCriteria": [
"Accumulate 100 clean runs or 14 days on required CI platforms.",
@ -791,7 +851,10 @@
"knownGaps": [
"The live Electron proof currently covers macOS local PTYs only.",
"Disconnected SSH relay death still requires reconnect-aware provider ownership.",
"Daemon owner leases and durable retry inventory remain follow-up hardening."
"Daemon owner leases and durable retry inventory remain follow-up hardening.",
"Windows ConPTY, SSH-hosted PTYs, app-quit killAll, and daemon dispose retain foreground-tree-only teardown.",
"A process born in the capture second is SIGTERMed but not SIGKILLed because ps cannot prove its recycled-PID identity.",
"A descendant orphaned before or during root ownership loss requires the separate crash-orphan sweep and is not recovered from a stale kill-time snapshot."
],
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority."
},

View File

@ -74,8 +74,20 @@ could invalidate intentional long-lived `worktree-sleep` checkpoints.
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
behavior.
- Killing processes that deliberately daemonize away from the terminal process
group. This contract covers the PTY and its attached foreground process tree.
- Killing processes that deliberately daemonize away from a plain user
terminal's process group (nohup-style survivors remain user intent there).
For **agent sessions**, close/kill additionally terminates the snapshotted
descendant tree — including detached-pgid children the PTY's SIGHUP cannot
reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with
same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL).
Completed process tables are never reused as signal targets, and identity
checks use C-locale timestamps from the source scan. Later requests start a
fresh same-turn-coalesced successor inside their own deadline instead of
waiting behind older scans. A session is marked as terminating before capture
and keeps request ownership through natural exit, so reattach, duplicate kill,
and graceful-to-immediate upgrade cannot race the snapshot; descendant signals
still require the exact root session/handle to be live. Windows and SSH-hosted
PTYs keep the previous foreground-tree contract for now.
- Changing agent-provider resume commands or permission flags.
- Making a UI close wait for a remote process to exit before the tab disappears.
- Replacing worktree sleep with tab close. Sleep remains resumable by design.

View File

@ -49,6 +49,9 @@ function createMockSubprocess(): SubprocessHandle & {
type DaemonServerPrivate = {
server: Server | null
host: {
kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void>
}
clients: Map<
string,
{
@ -289,6 +292,36 @@ describe('DaemonServer', () => {
expect(result).toBeDefined()
})
it('does not acknowledge kill until asynchronous teardown completes', async () => {
await startServer()
const daemon = server as unknown as DaemonServerPrivate
let finishKill!: () => void
const teardown = new Promise<void>((resolve) => {
finishKill = resolve
})
const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown)
let acknowledged = false
const routed = daemon
.routeRequest('client-1', {
id: 'kill-1',
type: 'kill',
payload: { sessionId: 'agent-session', immediate: true }
})
.then((result) => {
acknowledged = true
return result
})
await Promise.resolve()
expect(kill).toHaveBeenCalledWith('agent-session', { immediate: true })
expect(acknowledged).toBe(false)
finishKill()
await expect(routed).resolves.toEqual({})
expect(acknowledged).toBe(true)
})
it('handles getCwd', async () => {
await startServer()
const c = await connectClient()

View File

@ -498,7 +498,7 @@ export class DaemonServer {
sessionId: request.payload.sessionId,
immediate: request.payload.immediate === true
})
this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
return {}
case 'signal':

View File

@ -1,6 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PRODUCER_PAUSE_FAILSAFE_MS, Session } from './session'
import type { SessionState, ShellReadyState } from './types'
import type { TuiAgent } from '../../shared/types'
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
vi.mock('../pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// Stub the subprocess — Session talks to it via an interface, not child_process directly.
function createMockSubprocess() {
@ -86,6 +92,7 @@ describe('Session', () => {
beforeEach(() => {
vi.useFakeTimers()
subprocess = createMockSubprocess()
killWithDescendantSweepMock.mockReset()
})
afterEach(() => {
@ -98,11 +105,13 @@ describe('Session', () => {
shellReadyTimeoutMs?: number
cols?: number
rows?: number
launchAgent?: TuiAgent
}): Session {
session = new Session({
sessionId: 'test-session',
cols: opts?.cols ?? 80,
rows: opts?.rows ?? 24,
...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}),
subprocess,
shellReadySupported: opts?.shellReadySupported ?? false,
...(opts?.shellReadyTimeoutMs !== undefined
@ -406,6 +415,37 @@ describe('Session', () => {
expect(session.isTerminating).toBe(true)
})
it('non-agent kill stays synchronous and never routes through the descendant sweep', () => {
createSession()
session.kill()
expect(subprocess.killed).toBe(true)
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
})
it('agent kill routes through the descendant sweep with the subprocess as root', () => {
createSession({ launchAgent: 'claude' })
session.kill()
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
subprocess.pid,
expect.any(Function),
expect.objectContaining({ ownsRoot: expect.any(Function) })
)
// The root kill is deferred to the sweep's snapshot-first sequencing.
expect(subprocess.killed).toBe(false)
const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void
killRoot()
expect(subprocess.killed).toBe(true)
})
it('agent kill root callback is a no-op after the session already exited', () => {
createSession({ launchAgent: 'claude' })
session.kill()
const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void
subprocess.simulateExit(0)
killRoot()
expect(subprocess.killed).toBe(false)
})
it('notifies attached clients on exit after kill', async () => {
vi.useRealTimers()
createSession()

View File

@ -9,6 +9,7 @@ import {
type ShellReadyScanState
} from '../shell-ready-marker-scanner'
import { isPowerShellProcess } from '../../shared/shell-process-detection'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { TuiAgent } from '../../shared/types'
import type {
PendingOutputRecord,
@ -186,6 +187,19 @@ export class Session {
return this._isTerminating
}
/** Claims termination synchronously so attach/re-entry cannot race async
* teardown preparation. Returns false when another owner already claimed it. */
beginTermination(): boolean {
if (this._state === 'exited' || this._isTerminating) {
return false
}
this._isTerminating = true
// Why: a paused child can be blocked inside write(); resume before any
// async snapshot so it can handle termination promptly.
this.releaseProducerPause({ resume: true })
return true
}
get pid(): number {
return this.subprocess.pid
}
@ -259,16 +273,44 @@ export class Session {
}
kill(): void {
if (this._state === 'exited' || this._isTerminating) {
if (!this.beginTermination()) {
return
}
this._isTerminating = true
if (!this.launchAgent) {
this.subprocess.kill()
} else {
// Why: agent tool children live in detached process groups a dying
// shell's SIGHUP never reaches. The bounded snapshot briefly defers the
// signal; the kill timer below starts now, so force-dispose timing is
// unaffected.
void killWithDescendantSweep(
this.subprocess.pid,
() => {
this.signalTerminationRoot()
},
{
// Why: if the root exits during ps, its numeric PID can be recycled.
// Never apply that stale snapshot to a different process tree.
ownsRoot: () => this.isAlive
}
)
}
this.scheduleForceDisposeFallback()
}
// Why: a paused child can be blocked inside write(); resume before
// signalling so it can run signal handlers and actually exit.
this.releaseProducerPause({ resume: true })
this.subprocess.kill()
/** Signals a root whose descendant snapshot has completed. */
signalTerminationRoot(): void {
if (this._state !== 'exited') {
this.subprocess.kill()
}
}
/** Starts the existing graceful-kill deadline when a coordinator owns the
* snapshot-first portion of teardown. */
scheduleForceDisposeFallback(): void {
if (this.killTimer) {
return
}
this.killTimer = setTimeout(() => {
if (this._state !== 'exited') {
this.forceDispose()
@ -489,6 +531,7 @@ export class Session {
}
this.#teardownSubprocess()
this._state = 'exited'
this._isTerminating = false
// Why: free the headless emulator's scrollback here too (this path skips
// dispose()). Matches forceDispose(); reaping just drops the map entry.
this.emulator.dispose()

View File

@ -3,6 +3,11 @@ import { Session, type SubprocessHandle } from './session'
import { TerminalHost } from './terminal-host'
import type { TuiAgent } from '../../shared/types'
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
vi.mock('../pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
function createMockSubprocess(
options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {}
): SubprocessHandle {
@ -58,6 +63,7 @@ describe('TerminalHost', () => {
}
beforeEach(() => {
killWithDescendantSweepMock.mockReset()
spawnFn = vi.fn(() => {
const sub = createMockSubprocess() as ReturnType<typeof createMockSubprocess> & {
_onDataCb: ((data: string) => void) | null
@ -397,6 +403,207 @@ describe('TerminalHost', () => {
it('throws for non-existent session', () => {
expect(() => host.kill('missing')).toThrow('Session not found')
})
it('non-agent immediate kill stays synchronous and never routes through the descendant sweep', async () => {
await host.createOrAttach({
sessionId: 'plain-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.kill('plain-1', { immediate: true })
expect(lastSubprocess.forceKill).toHaveBeenCalled()
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
})
it('agent immediate kill routes through the descendant sweep and defers the force-kill to it', async () => {
await host.createOrAttach({
sessionId: 'agent-1',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.kill('agent-1', { immediate: true })
// Why order matters: force-killing first would let orphans reparent to
// pid 1 and escape the sweep's ppid walk entirely.
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
99999,
expect.any(Function),
expect.objectContaining({ ownsRoot: expect.any(Function) })
)
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
expect(host.isKilled('agent-1')).toBe(true)
const finish = killWithDescendantSweepMock.mock.calls[0][1] as () => void
finish()
expect(lastSubprocess.forceKill).toHaveBeenCalled()
expect(lastSubprocess.dispose).toHaveBeenCalled()
})
it('rejects reattach while an agent immediate-kill snapshot is pending', async () => {
let finishSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_pid: number, finish: () => void) =>
new Promise<void>((resolve) => {
finishSweep = () => {
finish()
resolve()
}
})
)
await host.createOrAttach({
sessionId: 'agent-reattach',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const killing = host.kill('agent-reattach', { immediate: true })
await expect(
host.createOrAttach({
sessionId: 'agent-reattach',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found')
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
finishSweep()
await killing
expect(lastSubprocess.forceKill).toHaveBeenCalledOnce()
})
it('coalesces duplicate immediate kill while descendant capture is pending', async () => {
const sweep = new Promise<void>(() => {})
killWithDescendantSweepMock.mockReturnValue(sweep)
await host.createOrAttach({
sessionId: 'agent-duplicate-kill',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const first = host.kill('agent-duplicate-kill', { immediate: true })
// The root can exit while the descendant scan is pending. Duplicate RPCs
// still own the original completion even after the session was reaped.
lastSubprocess._onExitCb?.(0)
const second = host.kill('agent-duplicate-kill', { immediate: true })
expect(killWithDescendantSweepMock).toHaveBeenCalledOnce()
expect(first).toBe(sweep)
expect(second).toBe(first)
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
})
it('keeps a naturally-exited id reserved until teardown finishes without re-killing its pid', async () => {
let completeSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_pid: number, finish: () => void) =>
new Promise<void>((resolve) => {
completeSweep = () => {
finish()
resolve()
}
})
)
await host.createOrAttach({
sessionId: 'agent-natural-exit',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const retiredSubprocess = lastSubprocess
const killing = host.kill('agent-natural-exit', { immediate: true })
retiredSubprocess._onExitCb?.(0)
await expect(
host.createOrAttach({
sessionId: 'agent-natural-exit',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found')
completeSweep()
await killing
expect(retiredSubprocess.forceKill).not.toHaveBeenCalled()
await expect(
host.createOrAttach({
sessionId: 'agent-natural-exit',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).resolves.toEqual(expect.objectContaining({ isNew: true }))
expect(spawnFn).toHaveBeenCalledTimes(2)
})
it('upgrades a pending graceful agent teardown when immediate kill arrives', async () => {
let completeSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_pid: number, finish: () => void) =>
new Promise<void>((resolve) => {
completeSweep = () => {
finish()
resolve()
}
})
)
await host.createOrAttach({
sessionId: 'agent-upgrade-kill',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const graceful = host.kill('agent-upgrade-kill')
const immediate = host.kill('agent-upgrade-kill', { immediate: true })
expect(immediate).toBe(graceful)
expect(lastSubprocess.kill).not.toHaveBeenCalled()
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
completeSweep()
await Promise.all([graceful, immediate])
expect(lastSubprocess.kill).not.toHaveBeenCalled()
expect(lastSubprocess.forceKill).toHaveBeenCalledOnce()
expect(lastSubprocess.dispose).toHaveBeenCalledOnce()
})
it('force-kills when immediate teardown follows a completed graceful snapshot', async () => {
killWithDescendantSweepMock.mockImplementation(async (_pid: number, finish: () => void) =>
finish()
)
await host.createOrAttach({
sessionId: 'agent-post-snapshot-upgrade',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
await host.kill('agent-post-snapshot-upgrade')
expect(lastSubprocess.kill).toHaveBeenCalledOnce()
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
host.kill('agent-post-snapshot-upgrade', { immediate: true })
expect(lastSubprocess.forceKill).toHaveBeenCalledOnce()
expect(lastSubprocess.dispose).toHaveBeenCalledOnce()
})
})
describe('signal', () => {

View File

@ -4,9 +4,14 @@ import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
import { resolveProcessCwd } from '../providers/process-cwd'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types'
import { SessionNotFoundError } from './types'
import {
SessionNotFoundError,
type SessionInfo,
type TakePendingOutputResult,
type TerminalSnapshot
} from './types'
import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
import { TerminalSessionTeardown } from './terminal-session-teardown'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
@ -41,6 +46,7 @@ export type TerminalHostOptions = {
export class TerminalHost {
private sessions = new Map<string, Session>()
private sessionTeardown = new TerminalSessionTeardown(this.sessions, (id) => this.reapSession(id))
private killedTombstones = new Map<string, number>()
private spawnSubprocess: TerminalHostOptions['spawnSubprocess']
private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
@ -61,11 +67,13 @@ export class TerminalHost {
async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {
const existing = this.sessions.get(opts.sessionId)
// Why: a session that has been asked to terminate (kill() called but the
// subprocess hasn't exited yet) must not be reattached. Reattaching would
// hand the caller a handle that races with the in-flight exit, and any
// subsequent operation (write/kill/resize) would fail once the subprocess
// finally exits. Treat terminating sessions the same as fully-exited ones.
// Why: async descendant capture must finish before anyone can attach or
// dispose/recreate this id. Disposing here would kill the root before the
// snapshot and reattaching would hand out a doomed session.
if (this.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) {
throw new SessionNotFoundError(opts.sessionId)
}
if (existing && existing.isAlive && !existing.isTerminating) {
const snapshot = existing.getSnapshot()
existing.detachAllClients()
@ -194,18 +202,14 @@ export class TerminalHost {
this.sessions.get(sessionId)?.resumeProducer()
}
kill(sessionId: string, opts: { immediate?: boolean } = {}): void {
kill(sessionId: string, opts: { immediate?: boolean } = {}): void | Promise<void> {
const pending = this.sessionTeardown.get(sessionId)
if (pending) {
return opts.immediate ? this.sessionTeardown.requestImmediate(sessionId) : pending
}
const session = this.getAliveSession(sessionId)
this.recordTombstone(sessionId)
if (opts.immediate) {
session.forceKillAndDisposeSubprocess()
// Why: the immediate path tears down synchronously without firing the
// session's onExit hook, so reap it here. The graceful path below funnels
// through Session.handleSubprocessExit -> onExit -> reapSession.
this.reapSession(sessionId)
return
}
session.kill()
return this.sessionTeardown.killSession(sessionId, session, opts.immediate === true)
}
// Why: dispose a dead session's headless emulator and drop it from the map so

View File

@ -0,0 +1,113 @@
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { Session } from './session'
type AgentTeardownOperation = {
promise: Promise<void>
immediate: boolean
}
/** Owns agent teardown by session id until descendant capture and root
* signalling finish, even when the root exits and its Session is reaped. */
export class TerminalSessionTeardown {
private operations = new Map<string, AgentTeardownOperation>()
constructor(
private sessions: ReadonlyMap<string, Session>,
private reapSession: (sessionId: string) => void
) {}
get(sessionId: string): Promise<void> | undefined {
return this.operations.get(sessionId)?.promise
}
requestImmediate(sessionId: string): Promise<void> | undefined {
const pending = this.operations.get(sessionId)
if (pending) {
pending.immediate = true
}
return pending?.promise
}
killSession(sessionId: string, session: Session, immediate: boolean): void | Promise<void> {
if (session.launchAgent) {
return this.killAgentSession(sessionId, session, immediate)
}
if (immediate) {
this.finishImmediate(sessionId, session)
} else {
session.kill()
}
}
private killAgentSession(
sessionId: string,
session: Session,
immediate: boolean
): void | Promise<void> {
const pending = this.operations.get(sessionId)
if (pending) {
// Why: an immediate caller is a stronger teardown request and must not
// acknowledge a still-graceful root kill while capture is pending.
pending.immediate ||= immediate
return pending.promise
}
if (!session.beginTermination()) {
// A completed graceful sweep can leave the root alive during its grace
// window. Immediate teardown may safely escalate once no scan is pending.
if (immediate && session.isAlive && session.isTerminating) {
this.finishImmediate(sessionId, session)
}
return
}
if (!immediate) {
session.scheduleForceDisposeFallback()
}
const entry: AgentTeardownOperation = {
promise: Promise.resolve(),
immediate
}
const operation = Promise.resolve(
killWithDescendantSweep(
session.pid,
() => {
// Why: natural exit reaps the PID while ps is running. Never signal that
// stale numeric PID after the Session no longer represents a live root.
if (!session.isAlive) {
return
}
if (entry.immediate) {
this.finishImmediate(sessionId, session)
} else {
session.signalTerminationRoot()
}
},
{
// Why: the descendant rows are only authoritative while this exact
// Session still owns the root PID captured by ps.
ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive
}
)
)
entry.promise = operation
this.operations.set(sessionId, entry)
const clearOperation = (): void => {
if (this.operations.get(sessionId) === entry) {
this.operations.delete(sessionId)
}
}
void operation.then(clearOperation, clearOperation)
return operation
}
private finishImmediate(sessionId: string, session: Session): void {
// Why: the old root may exit and a new same-id Session may appear after
// capture. Only this exact live Session is safe to force-kill and reap.
if (this.sessions.get(sessionId) !== session || !session.isAlive) {
return
}
session.forceKillAndDisposeSubprocess()
this.reapSession(sessionId)
}
}

View File

@ -9,7 +9,9 @@ const {
mkdirSyncMock,
writeFileSyncMock,
spawnMock,
resolveAgentForegroundProcessMock
resolveAgentForegroundProcessMock,
captureDescendantSnapshotMock,
terminateDescendantSnapshotMock
} = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
@ -17,7 +19,9 @@ const {
mkdirSyncMock: vi.fn(),
writeFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
resolveAgentForegroundProcessMock: vi.fn()
resolveAgentForegroundProcessMock: vi.fn(),
captureDescendantSnapshotMock: vi.fn(),
terminateDescendantSnapshotMock: vi.fn()
}))
vi.mock('fs', () => ({
@ -40,6 +44,11 @@ vi.mock('node-pty', () => ({
spawn: spawnMock
}))
vi.mock('../pty-descendant-termination', () => ({
captureDescendantSnapshot: captureDescendantSnapshotMock,
terminateDescendantSnapshot: terminateDescendantSnapshotMock
}))
// Resolve PowerShell family names to deterministic absolute paths (the fs mock
// above otherwise makes every probe miss). The real resolver — which skips the
// Store App Execution Alias stub — is covered in
@ -123,6 +132,9 @@ describe('LocalPtyProvider', () => {
accessSyncMock.mockReturnValue(undefined)
mkdirSyncMock.mockReset()
writeFileSyncMock.mockReset()
captureDescendantSnapshotMock.mockReset()
captureDescendantSnapshotMock.mockResolvedValue(null)
terminateDescendantSnapshotMock.mockReset()
resolveAgentForegroundProcessMock.mockReset()
resolveAgentForegroundProcessMock.mockImplementation(
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
@ -1041,6 +1053,79 @@ describe('LocalPtyProvider', () => {
await provider.shutdown('nonexistent', { immediate: true })
expect(mockProc.kill).not.toHaveBeenCalled()
})
it('waits for an in-flight agent shutdown before reusing the same session id', async () => {
let resolveSnapshot!: (value: null) => void
captureDescendantSnapshotMock.mockReturnValue(
new Promise<null>((resolve) => {
resolveSnapshot = resolve
})
)
const spawnArgs = {
cols: 80,
rows: 24,
sessionId: 'stable-agent-session',
launchAgent: 'claude' as const
}
const spawnCallsBefore = spawnMock.mock.calls.length
const { id } = await provider.spawn(spawnArgs)
const shutdown = provider.shutdown(id, { immediate: true })
const respawn = provider.spawn(spawnArgs)
await Promise.resolve()
expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 1)
resolveSnapshot(null)
await shutdown
await respawn
expect(spawnMock).toHaveBeenCalledTimes(spawnCallsBefore + 2)
})
it('coalesces duplicate shutdown while descendant capture is pending', async () => {
let resolveSnapshot!: (value: null) => void
captureDescendantSnapshotMock.mockReturnValue(
new Promise<null>((resolve) => {
resolveSnapshot = resolve
})
)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
launchAgent: 'claude'
})
const first = provider.shutdown(id, { immediate: true })
const second = provider.shutdown(id, { immediate: true })
expect(captureDescendantSnapshotMock).toHaveBeenCalledOnce()
resolveSnapshot(null)
await Promise.all([first, second])
expect(captureDescendantSnapshotMock).toHaveBeenCalledOnce()
})
it('does not signal a captured tree after the tracked root exits naturally', async () => {
let resolveSnapshot!: (value: {
rootPgid: number
descendants: []
capturedAtMs: number
}) => void
captureDescendantSnapshotMock.mockReturnValue(
new Promise((resolve) => {
resolveSnapshot = resolve
})
)
const { id } = await provider.spawn({
cols: 80,
rows: 24,
launchAgent: 'claude'
})
const shutdown = provider.shutdown(id, { immediate: true })
exitCb?.({ exitCode: 0 })
resolveSnapshot({ rootPgid: mockProc.pid, descendants: [], capturedAtMs: Date.now() })
await shutdown
expect(terminateDescendantSnapshotMock).not.toHaveBeenCalled()
})
})
describe('hasChildProcesses', () => {

View File

@ -54,6 +54,10 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process'
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import {
captureDescendantSnapshot,
terminateDescendantSnapshot
} from '../pty-descendant-termination'
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
@ -69,6 +73,14 @@ const PANE_IDENTITY_ENV_KEYS = [
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
// Why: only agent sessions get descendant tree-kill on shutdown. Agent CLIs
// spawn tool children in detached process groups the PTY's SIGHUP can never
// reach; plain user terminals keep classic semantics where deliberately
// detached (nohup-style) children survive the pane.
const ptyAgentSessionIds = new Set<string>()
// Why: descendant capture is async. Reattach and duplicate shutdown must wait
// for the original owner instead of returning a PTY that is about to die.
const ptyShutdownPromises = new Map<string, Promise<void>>()
const ptyShellName = new Map<string, string>()
const ptyAgentForegroundContextPaths = new Map<string, string[]>()
const ptyTerminalHandle = new Map<string, string>()
@ -181,6 +193,7 @@ function clearPtyState(id: string): void {
runPtyCleanup(id)
disposePtyListeners(id)
ptyProcesses.delete(id)
ptyAgentSessionIds.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
ptyTerminalHandle.delete(id)
@ -341,6 +354,10 @@ export class LocalPtyProvider implements IPtyProvider {
async spawn(args: PtySpawnOptions): Promise<PtySpawnResult> {
const reattachId = normalizeLocalCallerSessionId(args.sessionId)
if (reattachId) {
const pendingShutdown = ptyShutdownPromises.get(reattachId)
if (pendingShutdown) {
await pendingShutdown
}
const existing = ptyProcesses.get(reattachId)
if (existing) {
try {
@ -696,6 +713,12 @@ export class LocalPtyProvider implements IPtyProvider {
const proc = spawnResult.process
ptyProcesses.set(id, proc)
// Why both signals: launchAgent is the caller's explicit intent and
// survives command rewriting (e.g. auth env prefixes); recognition covers
// callers that pass a bare agent command line without the flag.
if (args.launchAgent || startupAgentRecognition) {
ptyAgentSessionIds.add(id)
}
ptyShellName.set(id, getSpawnedShellName(shellPath))
if (finalEnv.ORCA_TERMINAL_HANDLE) {
ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE)
@ -894,27 +917,59 @@ export class LocalPtyProvider implements IPtyProvider {
}
async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
const pending = ptyShutdownPromises.get(id)
if (pending) {
await pending
return
}
const proc = ptyProcesses.get(id)
if (!proc) {
return
}
// Why: disposePtyListeners removes the onExit callback, so the natural
// exit cleanup path from node-pty won't fire. Cleanup and notification
// must happen unconditionally after the try/catch.
// Timer/writer cleanup must happen here too: disposing listeners prevents
// the natural onExit callback from running the usual clearPtyState path.
runPtyCleanup(id)
disposePtyListeners(id)
const operation = this.shutdownTrackedPty(id, proc)
ptyShutdownPromises.set(id, operation)
try {
proc.kill()
} catch {
/* Process may already be dead */
await operation
} finally {
if (ptyShutdownPromises.get(id) === operation) {
ptyShutdownPromises.delete(id)
}
}
destroyPtyProcess(proc, { alreadyKilled: true })
clearPtyState(id)
this.opts.onExit?.(id, -1)
for (const cb of exitListeners) {
cb({ id, code: -1 })
}
private async shutdownTrackedPty(id: string, proc: pty.IPty): Promise<void> {
// Why: the snapshot must precede any signal/destroy — once the shell dies,
// surviving descendants reparent to pid 1 and a ppid walk can't find them.
const descendants = ptyAgentSessionIds.has(id)
? await captureDescendantSnapshot(proc.pid)
: null
// Why the handle re-check: the snapshot is this method's only await, and a
// natural exit may have raced it. Signalling after ownership is lost could
// apply the old numeric PID's snapshot to a recycled, unrelated process.
if (ptyProcesses.get(id) === proc) {
if (descendants) {
// Signal captured children before killing the root so parent links do
// not disappear during the sweep.
terminateDescendantSnapshot(descendants)
}
// Why: disposePtyListeners removes the onExit callback, so the natural
// exit cleanup path from node-pty won't fire. Cleanup and notification
// must happen unconditionally after the try/catch.
// Timer/writer cleanup must happen here too: disposing listeners prevents
// the natural onExit callback from running the usual clearPtyState path.
runPtyCleanup(id)
disposePtyListeners(id)
try {
proc.kill()
} catch {
/* Process may already be dead */
}
destroyPtyProcess(proc, { alreadyKilled: true })
clearPtyState(id)
this.opts.onExit?.(id, -1)
for (const cb of exitListeners) {
cb({ id, code: -1 })
}
}
}

View File

@ -0,0 +1,383 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import {
captureDescendantSnapshot,
collectDescendantRows,
createProcessTableSnapshotReader,
DESCENDANT_KILL_GRACE_MS,
DESCENDANT_SNAPSHOT_TIMEOUT_MS,
killWithDescendantSweep,
parseProcessTable,
terminateDescendantSnapshot,
type ProcessTableCapture,
type ProcessTableRow
} from './pty-descendant-termination'
const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026')
beforeEach(() => {
execFileMock.mockReset()
execFileMock.mockImplementation((...args: unknown[]) => {
const callback = args.at(-1) as (error: Error | null, stdout: string) => void
callback(null, '10 1 10 Mon Jul 13 12:54:47 2026')
})
})
function row(
pid: number,
ppid: number,
pgid: number,
startedAt = 'Mon Jul 13 12:54:47 2026'
): ProcessTableRow {
return { pid, ppid, pgid, startedAt }
}
function tableCapture(rows: ProcessTableRow[], capturedAtMs = CAPTURED_AT_MS): ProcessTableCapture {
return { rows, capturedAtMs }
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
function snapshot(
descendants: ProcessTableRow[],
rootPgid: number | null = 10,
capturedAtMs = CAPTURED_AT_MS
) {
return { rootPgid, descendants, capturedAtMs }
}
describe('parseProcessTable', () => {
it('parses pid/ppid/pgid and keeps the space-containing lstart verbatim', () => {
const rows = parseProcessTable(
[
' 101 1 101 Mon Jul 13 12:54:47 2026',
'42017 101 42017 Tue Jul 14 01:02:03 2026 ',
'',
'not a process line'
].join('\n')
)
expect(rows).toEqual([
{ pid: 101, ppid: 1, pgid: 101, startedAt: 'Mon Jul 13 12:54:47 2026' },
{ pid: 42017, ppid: 101, pgid: 42017, startedAt: 'Tue Jul 14 01:02:03 2026' }
])
})
})
describe('collectDescendantRows', () => {
it('walks detached-pgid descendants once even when a non-atomic table looks cyclic', () => {
// shell(10) -> agent(20, own job pgid) -> detached tool shell(30, own
// session-style pgid) -> git(31). 99 is unrelated.
const table = [
row(10, 1, 10),
row(20, 10, 20),
row(30, 20, 30),
row(31, 30, 30),
row(20, 31, 20), // PID reuse can make a non-atomic ps read look cyclic.
row(99, 1, 99)
]
const snapshot = collectDescendantRows(10, table, CAPTURED_AT_MS)
expect(snapshot.rootPgid).toBe(10)
expect(snapshot.descendants.map((r) => r.pid)).toEqual([20, 30, 31])
expect(snapshot.capturedAtMs).toBe(CAPTURED_AT_MS)
})
it('returns a null root pgid when the root row is already gone', () => {
const snapshot = collectDescendantRows(10, [row(20, 10, 20)], CAPTURED_AT_MS)
expect(snapshot.rootPgid).toBeNull()
expect(snapshot.descendants.map((r) => r.pid)).toEqual([20])
})
})
describe('captureDescendantSnapshot', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('resolves the descendant tree on POSIX', async () => {
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
const result = await captureDescendantSnapshot(10, {
readTable,
platform: 'darwin'
})
expect(result).toEqual(snapshot([row(20, 10, 20)]))
expect(vi.getTimerCount()).toBe(0)
})
it('is a null no-op on Windows', async () => {
const readTable = vi.fn()
expect(await captureDescendantSnapshot(10, { readTable, platform: 'win32' })).toBeNull()
expect(readTable).not.toHaveBeenCalled()
})
it('degrades to null when ps fails', async () => {
const readTable = vi.fn().mockRejectedValue(new Error('ps exploded'))
expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull()
})
it('degrades to null when a custom process-table reader throws synchronously', async () => {
const readTable = vi.fn(() => {
throw new Error('reader exploded')
})
expect(await captureDescendantSnapshot(10, { readTable, platform: 'linux' })).toBeNull()
})
it('degrades to null when ps hangs past the timeout instead of blocking teardown', async () => {
const readTable = vi.fn().mockReturnValue(new Promise<ProcessTableCapture>(() => {}))
const pending = captureDescendantSnapshot(10, {
readTable,
platform: 'darwin',
timeoutMs: 1_000
})
await vi.advanceTimersByTimeAsync(1_000)
expect(await pending).toBeNull()
})
it('gives the production ps subprocess a hard SIGKILL timeout', async () => {
const result = await captureDescendantSnapshot(10, {
platform: 'darwin',
timeoutMs: 321
})
expect(result).not.toBeNull()
expect(execFileMock).toHaveBeenCalledWith(
'ps',
['-axo', 'pid=,ppid=,pgid=,lstart='],
expect.objectContaining({
timeout: 321,
killSignal: 'SIGKILL',
env: expect.objectContaining({ LANG: 'C', LC_ALL: 'C' })
}),
expect.any(Function)
)
})
it('records the identity boundary before ps starts even when it crosses a second', async () => {
vi.setSystemTime(CAPTURED_AT_MS + 900)
execFileMock.mockImplementation((...args: unknown[]) => {
const callback = args.at(-1) as (error: Error | null, stdout: string) => void
vi.setSystemTime(CAPTURED_AT_MS + 1_100)
callback(
null,
['10 1 10 Tue Jul 14 12:00:00 2026', '20 10 20 Tue Jul 14 12:00:00 2026'].join('\n')
)
})
const result = await captureDescendantSnapshot(10, { platform: 'darwin' })
expect(result?.capturedAtMs).toBe(CAPTURED_AT_MS + 900)
expect(result?.descendants).toEqual([row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')])
})
})
describe('terminateDescendantSnapshot', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('SIGTERMs every snapshotted descendant immediately', () => {
const sendSignal = vi.fn()
terminateDescendantSnapshot(snapshot([row(20, 10, 20), row(30, 20, 30)]), {
sendSignal,
readTable: vi.fn().mockResolvedValue(tableCapture([]))
})
expect(sendSignal.mock.calls).toEqual([
[20, 'SIGTERM'],
[30, 'SIGTERM']
])
})
it('SIGKILLs only identity-matched survivors after the grace window', async () => {
const survivor = row(30, 20, 30)
const exited = row(20, 10, 20)
const recycled = row(40, 30, 40)
const ambiguous = row(50, 30, 50)
const sendSignal = vi.fn()
// At escalation time: 30 survives unchanged, 20 is gone, 40's pid now
// belongs to a different (recycled) process with a different start time.
const readTable = vi
.fn()
.mockResolvedValue(
tableCapture([
survivor,
{ ...recycled, startedAt: 'Tue Jul 14 09:00:00 2026' },
ambiguous,
{ ...ambiguous, startedAt: 'Tue Jul 14 10:00:00 2026' }
])
)
terminateDescendantSnapshot(snapshot([exited, survivor, recycled, ambiguous]), {
sendSignal,
readTable
})
sendSignal.mockClear()
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS)
expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']])
})
it('never escalates when the identity re-read fails', async () => {
const sendSignal = vi.fn()
const readTable = vi.fn().mockRejectedValue(new Error('ps exploded'))
terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), { sendSignal, readTable })
sendSignal.mockClear()
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS)
expect(sendSignal).not.toHaveBeenCalled()
})
it('schedules no escalation for an empty descendant set', () => {
const sendSignal = vi.fn()
const readTable = vi.fn()
terminateDescendantSnapshot(snapshot([]), { sendSignal, readTable })
expect(vi.getTimerCount()).toBe(0)
expect(sendSignal).not.toHaveBeenCalled()
})
it('uses the source scan boundary when a caller crosses into the next second', async () => {
const sameSecond = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')
const sendSignal = vi.fn()
terminateDescendantSnapshot(snapshot([sameSecond], 10, CAPTURED_AT_MS + 900), {
sendSignal,
readTable: vi.fn().mockResolvedValue(tableCapture([sameSecond], CAPTURED_AT_MS + 3_000))
})
sendSignal.mockClear()
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS)
expect(sendSignal).not.toHaveBeenCalled()
})
it('bounds a wedged escalation read and releases its deadline timer', async () => {
const sendSignal = vi.fn()
terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), {
sendSignal,
readTable: vi.fn().mockReturnValue(new Promise<ProcessTableCapture>(() => {}))
})
sendSignal.mockClear()
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS + DESCENDANT_SNAPSHOT_TIMEOUT_MS)
expect(sendSignal).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
})
describe('createProcessTableSnapshotReader', () => {
it('coalesces same-turn teardown requests onto one fresh scan', async () => {
const capture = tableCapture([row(10, 1, 10)])
const readFresh = vi.fn().mockResolvedValue(capture)
const readTable = createProcessTableSnapshotReader(readFresh)
const results = await Promise.all(Array.from({ length: 20 }, () => readTable(1_000)))
expect(results.every((result) => result === capture)).toBe(true)
expect(readFresh).toHaveBeenCalledOnce()
await readTable(1_000)
expect(readFresh).toHaveBeenCalledTimes(2)
})
it('queues a post-request scan when another scan has already started', async () => {
const firstGate = deferred<ProcessTableCapture>()
const secondGate = deferred<ProcessTableCapture>()
const readFresh = vi
.fn()
.mockReturnValueOnce(firstGate.promise)
.mockReturnValueOnce(secondGate.promise)
const readTable = createProcessTableSnapshotReader(readFresh)
const first = readTable()
await Promise.resolve()
expect(readFresh).toHaveBeenCalledOnce()
const laterA = readTable()
const laterB = readTable()
await Promise.resolve()
// A successor must begin inside its callers' deadline. Waiting for the
// prior scan can make both callers time out before their own scan starts.
expect(readFresh).toHaveBeenCalledTimes(2)
firstGate.resolve(tableCapture([row(10, 1, 10)], CAPTURED_AT_MS))
await first
const newer = tableCapture([row(20, 1, 20)], CAPTURED_AT_MS + 1_000)
secondGate.resolve(newer)
await expect(Promise.all([laterA, laterB])).resolves.toEqual([newer, newer])
})
it('retries after failed process-table reads', async () => {
const readFresh = vi
.fn()
.mockRejectedValueOnce(new Error('ps failed'))
.mockResolvedValueOnce(tableCapture([]))
const readTable = createProcessTableSnapshotReader(readFresh)
await expect(readTable()).rejects.toThrow('ps failed')
await expect(readTable()).resolves.toEqual(tableCapture([]))
expect(readFresh).toHaveBeenCalledTimes(2)
})
})
describe('killWithDescendantSweep', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('signals descendants after snapshot resolution, then kills the root', async () => {
const events: string[] = []
const sendSignal = vi.fn(() => events.push('descendant-term'))
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
const killRoot = vi.fn(() => events.push('root-kill'))
const pending = killWithDescendantSweep(10, killRoot, {
readTable,
sendSignal,
platform: 'darwin'
})
expect(killRoot).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
await pending
expect(killRoot).toHaveBeenCalledOnce()
expect(sendSignal.mock.calls).toEqual([[20, 'SIGTERM']])
expect(events).toEqual(['descendant-term', 'root-kill'])
})
it('still kills the root when the snapshot is unavailable', async () => {
const sendSignal = vi.fn()
const readTable = vi.fn().mockRejectedValue(new Error('ps exploded'))
const killRoot = vi.fn()
const pending = killWithDescendantSweep(10, killRoot, {
readTable,
sendSignal,
platform: 'darwin'
})
await vi.advanceTimersByTimeAsync(0)
await pending
expect(killRoot).toHaveBeenCalledOnce()
expect(sendSignal).not.toHaveBeenCalled()
})
it('does not signal a captured tree after the caller loses root ownership', async () => {
const sendSignal = vi.fn()
const killRoot = vi.fn()
const readTable = vi.fn().mockResolvedValue(tableCapture([row(10, 1, 10), row(20, 10, 20)]))
const ownsRoot = vi.fn(() => false)
await killWithDescendantSweep(10, killRoot, {
readTable,
sendSignal,
platform: 'darwin',
ownsRoot
})
expect(ownsRoot).toHaveBeenCalledOnce()
expect(sendSignal).not.toHaveBeenCalled()
expect(killRoot).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,313 @@
import { execFile } from 'node:child_process'
export const DESCENDANT_KILL_GRACE_MS = 2_000
export const DESCENDANT_SNAPSHOT_TIMEOUT_MS = 1_000
// Why: a full process table on a busy host can exceed execFile's 1MB default;
// truncation would silently drop descendants from the snapshot.
const PS_MAX_BUFFER_BYTES = 32 * 1024 * 1024
export type ProcessTableRow = {
pid: number
ppid: number
pgid: number
/** ps lstart text, kept verbatim. Delayed SIGKILL additionally requires an
* unambiguous capture-second boundary and matching pgid. */
startedAt: string
}
export type DescendantSnapshot = {
rootPgid: number | null
descendants: ProcessTableRow[]
/** Wall-clock boundary for deciding whether ps's second-resolution lstart
* can safely distinguish this process from a later PID reuse. */
capturedAtMs: number
}
export type ProcessTableCapture = {
rows: ProcessTableRow[]
/** Start boundary of the scan that produced rows, never a later consumer's time. */
capturedAtMs: number
}
export type ProcessTableReader = (timeoutMs?: number) => Promise<ProcessTableCapture>
export type SignalSender = (pid: number, signal: NodeJS.Signals) => void
export function parseProcessTable(psOutput: string): ProcessTableRow[] {
const rows: ProcessTableRow[] = []
for (const line of psOutput.split('\n')) {
// lstart itself contains spaces ("Mon Jul 13 12:54:47 2026"), so only the
// three leading numeric columns are positional.
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
pgid: Number(match[3]),
startedAt: match[4]
})
}
return rows
}
function readFreshProcessTable(
timeoutMs = DESCENDANT_SNAPSHOT_TIMEOUT_MS
): Promise<ProcessTableCapture> {
// Why: identity safety must use the boundary before ps starts. Stamping the
// result later could make a capture-second PID look safe after a rollover.
const capturedAtMs = Date.now()
return new Promise((resolve, reject) => {
execFile(
'ps',
['-axo', 'pid=,ppid=,pgid=,lstart='],
{
maxBuffer: PS_MAX_BUFFER_BYTES,
timeout: timeoutMs,
killSignal: 'SIGKILL',
// Why: ps localizes lstart, but delayed identity checks must parse it
// identically for every user locale.
env: { ...process.env, LANG: 'C', LC_ALL: 'C' }
},
(error, stdout) => {
if (error) {
reject(error)
return
}
resolve({ rows: parseProcessTable(stdout), capturedAtMs })
}
)
})
}
/** Coalesces same-turn teardown bursts but never serves a completed or already
* started scan to a later request, because stale PIDs are unsafe to signal. */
export function createProcessTableSnapshotReader(
readFresh: ProcessTableReader
): ProcessTableReader {
let queued: { promise: Promise<ProcessTableCapture>; started: boolean } | null = null
return (timeoutMs) => {
if (queued && !queued.started) {
return queued.promise
}
const entry: { promise: Promise<ProcessTableCapture>; started: boolean } = {
promise: Promise.resolve(undefined as never),
started: false
}
entry.promise = Promise.resolve().then(() => {
// Why: a later caller's deadline starts when it requests a fresh table.
// Waiting behind an older scan can consume that entire budget, then run
// this subprocess after nobody can use its result.
entry.started = true
return readFresh(timeoutMs)
})
queued = entry
const clearQueued = (): void => {
if (queued === entry) {
queued = null
}
}
void entry.promise.then(clearQueued, clearQueued)
return entry.promise
}
}
const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable)
function readProcessTableBeforeDeadline(
readTable: ProcessTableReader,
timeoutMs: number
): Promise<ProcessTableCapture | null> {
return new Promise((resolve) => {
let settled = false
const finish = (capture: ProcessTableCapture | null): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
resolve(capture)
}
const timer = setTimeout(() => finish(null), timeoutMs)
timer.unref?.()
try {
void readTable(timeoutMs).then(
(rows) => finish(rows),
() => finish(null)
)
} catch {
finish(null)
}
})
}
export function collectDescendantRows(
rootPid: number,
table: ProcessTableRow[],
capturedAtMs = Date.now()
): DescendantSnapshot {
const childrenByPpid = new Map<number, ProcessTableRow[]>()
let rootRow: ProcessTableRow | null = null
for (const row of table) {
if (row.pid === rootPid) {
rootRow = row
continue
}
const siblings = childrenByPpid.get(row.ppid)
if (siblings) {
siblings.push(row)
} else {
childrenByPpid.set(row.ppid, [row])
}
}
const descendants: ProcessTableRow[] = []
const queue = [rootPid]
const visited = new Set(queue)
for (let nextIndex = 0; nextIndex < queue.length; nextIndex += 1) {
const pid = queue[nextIndex]
for (const child of childrenByPpid.get(pid) ?? []) {
// Why: ps is not an atomic snapshot. PID reuse can produce duplicate or
// cyclic-looking rows, which must not hang the Electron main thread.
if (visited.has(child.pid)) {
continue
}
visited.add(child.pid)
descendants.push(child)
queue.push(child.pid)
}
}
return { rootPgid: rootRow?.pgid ?? null, descendants, capturedAtMs }
}
type SnapshotDeps = {
readTable?: ProcessTableReader
platform?: NodeJS.Platform
timeoutMs?: number
}
/**
* Snapshots a PTY root's live descendant tree. Must run BEFORE the root is
* signalled: once the root dies, surviving descendants reparent to pid 1 and
* can no longer be found by a ppid walk. Resolves null (never rejects) on
* Windows, ps failure, or timeout callers then degrade to today's
* shell-only kill.
*/
export async function captureDescendantSnapshot(
rootPid: number,
deps: SnapshotDeps = {}
): Promise<DescendantSnapshot | null> {
const platform = deps.platform ?? process.platform
if (platform === 'win32' || !Number.isInteger(rootPid) || rootPid <= 0) {
return null
}
const readTable = deps.readTable ?? readProcessTable
const timeoutMs = deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS
// Why both layers: the deadline keeps injected/custom readers bounded while
// the production execFile timeout actually kills a wedged ps subprocess.
const capture = await readProcessTableBeforeDeadline(readTable, timeoutMs)
if (!capture) {
return null
}
return collectDescendantRows(rootPid, capture.rows, capture.capturedAtMs)
}
/**
* Standard agent-session kill sequencing: snapshot the descendant tree,
* signal its members, then run the caller's root kill. Callers must not signal
* the root before this runs a dead root's descendants reparent to pid 1 and
* become unfindable. Snapshot failure degrades to killRoot alone.
*/
export async function killWithDescendantSweep(
rootPid: number,
killRoot: () => void,
deps: SnapshotDeps & TerminateDeps & { ownsRoot?: () => boolean } = {}
): Promise<void> {
const snapshot = await captureDescendantSnapshot(rootPid, deps)
try {
// Signal the captured descendants while their parent links still exist;
// killing the root first creates a reparent/PID-reuse window.
if (snapshot && (deps.ownsRoot?.() ?? true)) {
terminateDescendantSnapshot(snapshot, deps)
}
} finally {
killRoot()
}
}
function defaultSendSignal(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(pid, signal)
} catch {
/* already gone */
}
}
type TerminateDeps = {
readTable?: ProcessTableReader
sendSignal?: SignalSender
graceMs?: number
timeoutMs?: number
}
function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean {
const startedAtMs = Date.parse(row.startedAt)
if (!Number.isFinite(startedAtMs)) {
return false
}
// ps lstart is second-resolution. A process born in the capture second can
// be replaced by a different process with the same displayed timestamp.
return startedAtMs < Math.floor(capturedAtMs / 1_000) * 1_000
}
/**
* Terminates a snapshotted descendant tree: SIGTERM every descendant now,
* reaching detached-pgid children the PTY's SIGHUP cannot, then after a grace
* window SIGKILL identity-safe survivors. Processes born in the capture second
* are not escalated because ps cannot distinguish same-second PID reuse.
*/
export function terminateDescendantSnapshot(
snapshot: DescendantSnapshot,
deps: TerminateDeps = {}
): void {
const sendSignal = deps.sendSignal ?? defaultSendSignal
const readTable = deps.readTable ?? readProcessTable
for (const row of snapshot.descendants) {
sendSignal(row.pid, 'SIGTERM')
}
if (snapshot.descendants.length === 0) {
return
}
const timer = setTimeout(() => {
void readProcessTableBeforeDeadline(
readTable,
deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS
).then((capture) => {
if (!capture) {
return
}
const expectedPids = new Set(snapshot.descendants.map((row) => row.pid))
const liveTargets = new Map<number, ProcessTableRow | null>()
// Why: a process table may be large, while one agent's descendants are
// normally few. Index only signal targets instead of duplicating every row.
for (const live of capture.rows) {
if (expectedPids.has(live.pid)) {
// Duplicate PID rows make identity ambiguous, so never escalate them.
liveTargets.set(live.pid, liveTargets.has(live.pid) ? null : live)
}
}
for (const row of snapshot.descendants) {
const live = liveTargets.get(row.pid)
if (
hasUnambiguousStartIdentity(row, snapshot.capturedAtMs) &&
live?.startedAt === row.startedAt &&
live.pgid === row.pgid
) {
sendSignal(row.pid, 'SIGKILL')
}
}
})
}, deps.graceMs ?? DESCENDANT_KILL_GRACE_MS)
timer.unref?.()
}

View File

@ -146,6 +146,71 @@ describe('killAllProcessesForWorktree', () => {
expect(r2.providerStopped).toBe(1)
})
it('starts owned provider shutdowns together so agent snapshots can coalesce', async () => {
const localProvider = createProviderStub(async () => [
{ id: 'w1@@aaaa', cwd: '/tmp', title: 'shell' },
{ id: 'w1@@bbbb', cwd: '/tmp', title: 'shell' }
])
listRegisteredPtysMock.mockReturnValue([])
const releases: (() => void)[] = []
;(localProvider.shutdown as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() =>
new Promise<void>((resolve) => {
releases.push(resolve)
})
)
const teardown = killAllProcessesForWorktree('w1', { localProvider })
await vi.waitFor(() => expect(localProvider.shutdown).toHaveBeenCalledTimes(2))
expect(releases).toHaveLength(2)
for (const release of releases) {
release()
}
await expect(teardown).resolves.toEqual({
runtimeStopped: 0,
providerStopped: 2,
registryStopped: 0
})
})
it('bounds provider shutdown fanout while preserving concurrent batches', async () => {
const sessions = Array.from({ length: 40 }, (_, index) => ({
id: `w1@@${index}`,
cwd: '/tmp',
title: 'shell'
}))
const localProvider = createProviderStub(async () => sessions)
listRegisteredPtysMock.mockReturnValue([])
let active = 0
let maxActive = 0
const releases: (() => void)[] = []
;(localProvider.shutdown as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() =>
new Promise<void>((resolve) => {
active += 1
maxActive = Math.max(maxActive, active)
releases.push(() => {
active -= 1
resolve()
})
})
)
const teardown = killAllProcessesForWorktree('w1', { localProvider })
await vi.waitFor(() => expect(localProvider.shutdown).toHaveBeenCalledTimes(32))
expect(maxActive).toBe(32)
releases.splice(0).forEach((release) => release())
await vi.waitFor(() => expect(localProvider.shutdown).toHaveBeenCalledTimes(40))
releases.splice(0).forEach((release) => release())
await expect(teardown).resolves.toEqual({
runtimeStopped: 0,
providerStopped: 40,
registryStopped: 0
})
})
it('invokes runtime.stopTerminalsForWorktree when runtime is provided', async () => {
const stopTerminalsForWorktree = vi.fn().mockResolvedValue({ stopped: 3 })
const runtime = {

View File

@ -1,6 +1,11 @@
import type { IPtyProvider } from '../providers/types'
import type { OrcaRuntimeService } from './orca-runtime'
import { listRegisteredPtys } from '../memory/pty-registry'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
// Why: normal inventories still coalesce into one process scan, while a stale
// or pathological inventory cannot fan out unbounded provider/RPC shutdowns.
const WORKTREE_TEARDOWN_CONCURRENCY = 32
export type WorktreeTeardownDeps = {
runtime?: OrcaRuntimeService
@ -72,21 +77,18 @@ async function sweepProviderByPrefix(
): Promise<number> {
const prefix = `${worktreeId}@@`
const sessions = await provider.listProcesses().catch(() => [])
let killed = 0
for (const s of sessions) {
if (!s.id.startsWith(prefix)) {
continue
}
const ownedSessions = sessions.filter((session) => session.id.startsWith(prefix))
// Why: agent shutdown snapshots coalesce only when requests begin together;
// serial awaits multiply process-table scans and worktree-delete latency.
await mapWithConcurrency(ownedSessions, WORKTREE_TEARDOWN_CONCURRENCY, async (session) => {
try {
await provider.shutdown(s.id, { immediate: true })
clearStoppedPtyState(s.id, onPtyStopped)
killed += 1
await provider.shutdown(session.id, { immediate: true })
clearStoppedPtyState(session.id, onPtyStopped)
} catch {
// Already dead, or the backend dropped the session — treat as success.
killed += 1
}
}
return killed
})
return ownedSessions.length
}
async function sweepRegistryForWorktree(
@ -95,17 +97,20 @@ async function sweepRegistryForWorktree(
onPtyStopped?: (ptyId: string) => void
): Promise<number> {
const entries = listRegisteredPtys().filter((r) => r.worktreeId === worktreeId)
let killed = 0
for (const entry of entries) {
try {
await localProvider.shutdown(entry.ptyId, { immediate: true })
clearStoppedPtyState(entry.ptyId, onPtyStopped)
killed += 1
} catch {
/* ignore — best-effort */
const stopped = await mapWithConcurrency(
entries,
WORKTREE_TEARDOWN_CONCURRENCY,
async (entry) => {
try {
await localProvider.shutdown(entry.ptyId, { immediate: true })
clearStoppedPtyState(entry.ptyId, onPtyStopped)
return 1
} catch {
return 0
}
}
}
return killed
)
return stopped.reduce<number>((count, value) => count + value, 0)
}
function clearStoppedPtyState(ptyId: string, onPtyStopped?: (ptyId: string) => void): void {

View File

@ -0,0 +1,97 @@
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
// Reproduces the orphaned-descendant battery-drain incident (STA-1800): an
// agent CLI spawns a tool child in a detached process group, the session is
// killed, and the child must not survive. The stand-in agent's first command
// token is literally `claude` so PTY spawn recognition marks the session as an
// agent; tab close → pty.kill routing is already covered by
// terminal-parked-close-retirement.spec.ts, so this spec drives pty.kill.
test('killing an agent PTY terminates its detached-pgid descendants', async ({ orcaPage }) => {
test.skip(process.platform === 'win32', 'descendant tree-kill is POSIX-only for now')
const stage = mkdtempSync(join(tmpdir(), 'orca-agent-descendant-'))
const markerPath = join(stage, 'detached-child.pid')
const spawnerPath = join(stage, 'spawn-detached.cjs')
writeFileSync(
spawnerPath,
[
"const { spawn } = require('node:child_process')",
// detached:true → setsid → own pgid/session, exactly the topology of an
// agent CLI's tool subprocess that a dying shell's SIGHUP cannot reach.
"const child = spawn('sleep', ['31337'], { detached: true, stdio: 'ignore' })",
'child.unref()',
"require('node:fs').writeFileSync(process.argv[2], String(child.pid))",
// Stay alive like a real agent at its prompt: the detached child's ppid
// must remain intact at kill time — a pre-orphaned child is the separate
// crash-path scenario that only the PR-2 sweep can catch.
'setInterval(() => {}, 1000)',
''
].join('\n')
)
const fakeAgentPath = join(stage, 'claude')
writeFileSync(fakeAgentPath, `#!/bin/sh\nexec "${process.execPath}" "${spawnerPath}" "$1"\n`)
chmodSync(fakeAgentPath, 0o755)
let detachedChildPid = 0
try {
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
const ptyId = await orcaPage.evaluate(
async ({ command, cwd, worktreeId: wt }) => {
const result = await window.api.pty.spawn({
cols: 120,
rows: 40,
cwd,
command,
launchAgent: 'claude',
worktreeId: wt
})
return result.id
},
{ command: `'${fakeAgentPath}' '${markerPath}'`, cwd: stage, worktreeId }
)
expect(ptyId).toBeTruthy()
await expect
.poll(() => existsSync(markerPath), {
timeout: 20_000,
message: 'stand-in agent never spawned its detached child'
})
.toBe(true)
detachedChildPid = Number(readFileSync(markerPath, 'utf8').trim())
expect(detachedChildPid).toBeGreaterThan(0)
expect(isProcessAlive(detachedChildPid)).toBe(true)
await orcaPage.evaluate((id) => window.api.pty.kill(id), ptyId)
await expect
.poll(() => isProcessAlive(detachedChildPid), {
timeout: 15_000,
message: `detached descendant ${detachedChildPid} survived the agent PTY kill`
})
.toBe(false)
} finally {
if (detachedChildPid > 0 && isProcessAlive(detachedChildPid)) {
try {
process.kill(detachedChildPid, 'SIGKILL')
} catch {
/* already gone */
}
}
rmSync(stage, { recursive: true, force: true })
}
})