mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
fix(terminal): harden descendant teardown
This commit is contained in:
@@ -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, duplicate-kill coalescing, bounded/coalesced process-table reads, 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 cannot reattach or start duplicate cleanup; process-table work is bounded and coalesced; 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 reject pending-capture reattach, coalesce duplicate teardown and 20 process-table requests, 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",
|
||||
"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,17 @@
|
||||
"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/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 +762,27 @@
|
||||
"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 concurrent and short-sequential process-table requests execute one fresh scan",
|
||||
"snapshot and escalation readers stop at their deadline",
|
||||
"capture-second process identities are never escalated with SIGKILL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/terminal-host.test.ts",
|
||||
"assertions": [
|
||||
"agent immediate kill rejects reattach while descendant capture is pending",
|
||||
"duplicate immediate kill starts one descendant sweep"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/agent-descendant-process-kill.spec.ts",
|
||||
"assertions": [
|
||||
"a detached-pgid descendant is alive before agent PTY kill and absent afterward"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -780,8 +809,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; an in-flight plus 50ms completed-snapshot coordinator makes 20 concurrent or short-sequential requests execute one fresh process-table read, and escalation uses the same coordinator and deadline. 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 +820,9 @@
|
||||
"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."
|
||||
],
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -78,9 +78,10 @@ could invalidate intentional long-lived `worktree-sleep` checkpoints.
|
||||
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` (SIGTERM, grace window, then
|
||||
identity-checked SIGKILL). Windows and SSH-hosted PTYs keep the previous
|
||||
foreground-tree contract for now.
|
||||
reach — via `pty-descendant-termination.ts` (bounded/coalesced snapshot,
|
||||
SIGTERM, grace window, then identity-safe SIGKILL). A session is marked as
|
||||
terminating before capture so reattach cannot race teardown. 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.
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -187,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
|
||||
}
|
||||
@@ -260,14 +273,9 @@ export class Session {
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
if (this._state === 'exited' || this._isTerminating) {
|
||||
if (!this.beginTermination()) {
|
||||
return
|
||||
}
|
||||
this._isTerminating = true
|
||||
|
||||
// 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 })
|
||||
if (!this.launchAgent) {
|
||||
this.subprocess.kill()
|
||||
} else {
|
||||
@@ -275,7 +283,7 @@ export class Session {
|
||||
// shell's SIGHUP never reaches. The bounded snapshot briefly defers the
|
||||
// signal; the kill timer below starts now, so force-dispose timing is
|
||||
// unaffected.
|
||||
killWithDescendantSweep(this.subprocess.pid, () => {
|
||||
void killWithDescendantSweep(this.subprocess.pid, () => {
|
||||
if (this._state !== 'exited') {
|
||||
this.subprocess.kill()
|
||||
}
|
||||
@@ -502,6 +510,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()
|
||||
|
||||
@@ -436,6 +436,59 @@ describe('TerminalHost', () => {
|
||||
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 () => {
|
||||
killWithDescendantSweepMock.mockReturnValue(new Promise<void>(() => {}))
|
||||
await host.createOrAttach({
|
||||
sessionId: 'agent-duplicate-kill',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
launchAgent: 'claude',
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
host.kill('agent-duplicate-kill', { immediate: true })
|
||||
host.kill('agent-duplicate-kill', { immediate: true })
|
||||
|
||||
expect(killWithDescendantSweepMock).toHaveBeenCalledOnce()
|
||||
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('signal', () => {
|
||||
|
||||
@@ -62,11 +62,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 (existing?.isTerminating) {
|
||||
throw new SessionNotFoundError(opts.sessionId)
|
||||
}
|
||||
|
||||
if (existing && existing.isAlive && !existing.isTerminating) {
|
||||
const snapshot = existing.getSnapshot()
|
||||
existing.detachAllClients()
|
||||
@@ -194,7 +196,7 @@ 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 session = this.getAliveSession(sessionId)
|
||||
this.recordTombstone(sessionId)
|
||||
if (opts.immediate) {
|
||||
@@ -209,10 +211,12 @@ export class TerminalHost {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (!session.beginTermination()) {
|
||||
return
|
||||
}
|
||||
// Why: agent tool children live in detached process groups the shell's
|
||||
// death never signals; the bounded snapshot defers teardown at most ~1s.
|
||||
killWithDescendantSweep(session.pid, finish)
|
||||
return
|
||||
return killWithDescendantSweep(session.pid, finish)
|
||||
}
|
||||
session.kill()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -999,6 +1011,54 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasChildProcesses', () => {
|
||||
|
||||
@@ -77,6 +77,9 @@ const ptyProcesses = new Map<string, pty.IPty>()
|
||||
// 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>()
|
||||
@@ -344,6 +347,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 {
|
||||
@@ -903,19 +910,43 @@ 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
|
||||
}
|
||||
const operation = this.shutdownTrackedPty(id, proc)
|
||||
ptyShutdownPromises.set(id, operation)
|
||||
try {
|
||||
await operation
|
||||
} finally {
|
||||
if (ptyShutdownPromises.get(id) === operation) {
|
||||
ptyShutdownPromises.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
let descendantsSignalled = false
|
||||
// Why the handle re-check: the snapshot is this method's only await, and a
|
||||
// natural exit (or same-id respawn) may have raced it. That path already
|
||||
// ran clearPtyState and notified exit — only the descendant sweep remains.
|
||||
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)
|
||||
descendantsSignalled = true
|
||||
}
|
||||
// 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.
|
||||
@@ -935,7 +966,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
cb({ id, code: -1 })
|
||||
}
|
||||
}
|
||||
if (descendants) {
|
||||
if (descendants && !descendantsSignalled) {
|
||||
// Natural exit may have won while the snapshot was in flight. The
|
||||
// captured descendants still belong to that retired process identity.
|
||||
terminateDescendantSnapshot(descendants)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
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 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 #${pid}`
|
||||
startedAt = 'Mon Jul 13 12:54:47 2026'
|
||||
): ProcessTableRow {
|
||||
return { pid, ppid, pgid, startedAt }
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -46,13 +70,14 @@ describe('collectDescendantRows', () => {
|
||||
row(31, 30, 30),
|
||||
row(99, 1, 99)
|
||||
]
|
||||
const snapshot = collectDescendantRows(10, table)
|
||||
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)])
|
||||
const snapshot = collectDescendantRows(10, [row(20, 10, 20)], CAPTURED_AT_MS)
|
||||
expect(snapshot.rootPgid).toBeNull()
|
||||
expect(snapshot.descendants.map((r) => r.pid)).toEqual([20])
|
||||
})
|
||||
@@ -68,8 +93,13 @@ describe('captureDescendantSnapshot', () => {
|
||||
|
||||
it('resolves the descendant tree on POSIX', async () => {
|
||||
const readTable = vi.fn().mockResolvedValue([row(10, 1, 10), row(20, 10, 20)])
|
||||
const snapshot = await captureDescendantSnapshot(10, { readTable, platform: 'darwin' })
|
||||
expect(snapshot).toEqual({ rootPgid: 10, descendants: [row(20, 10, 20)] })
|
||||
const result = await captureDescendantSnapshot(10, {
|
||||
readTable,
|
||||
platform: 'darwin',
|
||||
now: () => CAPTURED_AT_MS
|
||||
})
|
||||
expect(result).toEqual(snapshot([row(20, 10, 20)]))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('is a null no-op on Windows', async () => {
|
||||
@@ -83,6 +113,13 @@ describe('captureDescendantSnapshot', () => {
|
||||
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<ProcessTableRow[]>(() => {}))
|
||||
const pending = captureDescendantSnapshot(10, {
|
||||
@@ -93,6 +130,21 @@ describe('captureDescendantSnapshot', () => {
|
||||
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,
|
||||
now: () => CAPTURED_AT_MS
|
||||
})
|
||||
expect(result).not.toBeNull()
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'ps',
|
||||
['-axo', 'pid=,ppid=,pgid=,lstart='],
|
||||
expect.objectContaining({ timeout: 321, killSignal: 'SIGKILL' }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminateDescendantSnapshot', () => {
|
||||
@@ -103,14 +155,13 @@ describe('terminateDescendantSnapshot', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('SIGTERMs the root group and every descendant immediately', () => {
|
||||
it('SIGTERMs every snapshotted descendant immediately', () => {
|
||||
const sendSignal = vi.fn()
|
||||
terminateDescendantSnapshot(
|
||||
{ rootPgid: 10, descendants: [row(20, 10, 20), row(30, 20, 30)] },
|
||||
{ sendSignal, readTable: vi.fn().mockResolvedValue([]) }
|
||||
)
|
||||
terminateDescendantSnapshot(snapshot([row(20, 10, 20), row(30, 20, 30)]), {
|
||||
sendSignal,
|
||||
readTable: vi.fn().mockResolvedValue([])
|
||||
})
|
||||
expect(sendSignal.mock.calls).toEqual([
|
||||
[-10, 'SIGTERM'],
|
||||
[20, 'SIGTERM'],
|
||||
[30, 'SIGTERM']
|
||||
])
|
||||
@@ -126,10 +177,7 @@ describe('terminateDescendantSnapshot', () => {
|
||||
const readTable = vi
|
||||
.fn()
|
||||
.mockResolvedValue([survivor, { ...recycled, startedAt: 'Tue Jul 14 09:00:00 2026' }])
|
||||
terminateDescendantSnapshot(
|
||||
{ rootPgid: 10, descendants: [exited, survivor, recycled] },
|
||||
{ sendSignal, readTable }
|
||||
)
|
||||
terminateDescendantSnapshot(snapshot([exited, survivor, recycled]), { sendSignal, readTable })
|
||||
sendSignal.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS)
|
||||
expect(sendSignal.mock.calls).toEqual([[30, 'SIGKILL']])
|
||||
@@ -138,10 +186,7 @@ describe('terminateDescendantSnapshot', () => {
|
||||
it('never escalates when the identity re-read fails', async () => {
|
||||
const sendSignal = vi.fn()
|
||||
const readTable = vi.fn().mockRejectedValue(new Error('ps exploded'))
|
||||
terminateDescendantSnapshot(
|
||||
{ rootPgid: 10, descendants: [row(20, 10, 20)] },
|
||||
{ sendSignal, readTable }
|
||||
)
|
||||
terminateDescendantSnapshot(snapshot([row(20, 10, 20)]), { sendSignal, readTable })
|
||||
sendSignal.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(DESCENDANT_KILL_GRACE_MS)
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
@@ -150,9 +195,63 @@ describe('terminateDescendantSnapshot', () => {
|
||||
it('schedules no escalation for an empty descendant set', () => {
|
||||
const sendSignal = vi.fn()
|
||||
const readTable = vi.fn()
|
||||
terminateDescendantSnapshot({ rootPgid: 10, descendants: [] }, { sendSignal, readTable })
|
||||
terminateDescendantSnapshot(snapshot([]), { sendSignal, readTable })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(sendSignal.mock.calls).toEqual([[-10, 'SIGTERM']])
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not SIGKILL when second-resolution start identity is ambiguous', async () => {
|
||||
const sameSecond = row(20, 10, 20, 'Tue Jul 14 12:00:00 2026')
|
||||
const sendSignal = vi.fn()
|
||||
terminateDescendantSnapshot(snapshot([sameSecond]), {
|
||||
sendSignal,
|
||||
readTable: vi.fn().mockResolvedValue([sameSecond])
|
||||
})
|
||||
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<ProcessTableRow[]>(() => {}))
|
||||
})
|
||||
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 concurrent and short sequential teardown scans', async () => {
|
||||
let now = 1_000
|
||||
const readFresh = vi.fn().mockResolvedValue([row(10, 1, 10)])
|
||||
const readTable = createProcessTableSnapshotReader(readFresh, {
|
||||
reuseMs: 50,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
await Promise.all(Array.from({ length: 20 }, () => readTable(1_000)))
|
||||
await readTable(1_000)
|
||||
expect(readFresh).toHaveBeenCalledOnce()
|
||||
|
||||
now += 51
|
||||
await readTable(1_000)
|
||||
expect(readFresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not cache failed process-table reads', async () => {
|
||||
const readFresh = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ps failed'))
|
||||
.mockResolvedValueOnce([])
|
||||
const readTable = createProcessTableSnapshotReader(readFresh)
|
||||
await expect(readTable()).rejects.toThrow('ps failed')
|
||||
await expect(readTable()).resolves.toEqual([])
|
||||
expect(readFresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,26 +263,36 @@ describe('killWithDescendantSweep', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('kills the root only after the snapshot resolves, then sweeps it', async () => {
|
||||
const sendSignal = vi.fn()
|
||||
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([row(10, 1, 10), row(20, 10, 20)])
|
||||
const killRoot = vi.fn()
|
||||
killWithDescendantSweep(10, killRoot, { readTable, sendSignal, platform: 'darwin' })
|
||||
const killRoot = vi.fn(() => events.push('root-kill'))
|
||||
const pending = killWithDescendantSweep(10, killRoot, {
|
||||
readTable,
|
||||
sendSignal,
|
||||
platform: 'darwin',
|
||||
now: () => CAPTURED_AT_MS
|
||||
})
|
||||
expect(killRoot).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await pending
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
expect(sendSignal.mock.calls).toEqual([
|
||||
[-10, 'SIGTERM'],
|
||||
[20, 'SIGTERM']
|
||||
])
|
||||
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()
|
||||
killWithDescendantSweep(10, killRoot, { readTable, sendSignal, platform: 'darwin' })
|
||||
const pending = killWithDescendantSweep(10, killRoot, {
|
||||
readTable,
|
||||
sendSignal,
|
||||
platform: 'darwin'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await pending
|
||||
expect(killRoot).toHaveBeenCalledOnce()
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process'
|
||||
|
||||
export const DESCENDANT_KILL_GRACE_MS = 2_000
|
||||
export const DESCENDANT_SNAPSHOT_TIMEOUT_MS = 1_000
|
||||
export const PROCESS_TABLE_REUSE_MS = 50
|
||||
// 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
|
||||
@@ -10,17 +11,20 @@ export type ProcessTableRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
pgid: number
|
||||
/** ps lstart text, kept verbatim. (pid, startedAt) is the identity token that
|
||||
* gates the delayed SIGKILL so it can never land on a recycled pid. */
|
||||
/** 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 ProcessTableReader = () => Promise<ProcessTableRow[]>
|
||||
export type ProcessTableReader = (timeoutMs?: number) => Promise<ProcessTableRow[]>
|
||||
export type SignalSender = (pid: number, signal: NodeJS.Signals) => void
|
||||
|
||||
export function parseProcessTable(psOutput: string): ProcessTableRow[] {
|
||||
@@ -42,12 +46,18 @@ export function parseProcessTable(psOutput: string): ProcessTableRow[] {
|
||||
return rows
|
||||
}
|
||||
|
||||
function readProcessTable(): Promise<ProcessTableRow[]> {
|
||||
function readFreshProcessTable(
|
||||
timeoutMs = DESCENDANT_SNAPSHOT_TIMEOUT_MS
|
||||
): Promise<ProcessTableRow[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'ps',
|
||||
['-axo', 'pid=,ppid=,pgid=,lstart='],
|
||||
{ maxBuffer: PS_MAX_BUFFER_BYTES },
|
||||
{
|
||||
maxBuffer: PS_MAX_BUFFER_BYTES,
|
||||
timeout: timeoutMs,
|
||||
killSignal: 'SIGKILL'
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
@@ -59,9 +69,79 @@ function readProcessTable(): Promise<ProcessTableRow[]> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Coalesces teardown bursts onto one process-table subprocess and briefly
|
||||
* reuses the completed table so sequential worktree cleanup stays O(1) scans. */
|
||||
export function createProcessTableSnapshotReader(
|
||||
readFresh: ProcessTableReader,
|
||||
deps: { reuseMs?: number; now?: () => number } = {}
|
||||
): ProcessTableReader {
|
||||
const reuseMs = deps.reuseMs ?? PROCESS_TABLE_REUSE_MS
|
||||
const now = deps.now ?? Date.now
|
||||
let inFlight: Promise<ProcessTableRow[]> | null = null
|
||||
let cached: { rows: ProcessTableRow[]; expiresAt: number } | null = null
|
||||
|
||||
return (timeoutMs) => {
|
||||
if (cached && now() <= cached.expiresAt) {
|
||||
return Promise.resolve(cached.rows)
|
||||
}
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const request = readFresh(timeoutMs).then((rows) => {
|
||||
cached = { rows, expiresAt: now() + reuseMs }
|
||||
return rows
|
||||
})
|
||||
inFlight = request
|
||||
void request.then(
|
||||
() => {
|
||||
if (inFlight === request) {
|
||||
inFlight = null
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (inFlight === request) {
|
||||
inFlight = null
|
||||
}
|
||||
}
|
||||
)
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable)
|
||||
|
||||
function readProcessTableBeforeDeadline(
|
||||
readTable: ProcessTableReader,
|
||||
timeoutMs: number
|
||||
): Promise<ProcessTableRow[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (rows: ProcessTableRow[] | null): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(rows)
|
||||
}
|
||||
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[]
|
||||
table: ProcessTableRow[],
|
||||
capturedAtMs = Date.now()
|
||||
): DescendantSnapshot {
|
||||
const childrenByPpid = new Map<number, ProcessTableRow[]>()
|
||||
let rootRow: ProcessTableRow | null = null
|
||||
@@ -86,13 +166,14 @@ export function collectDescendantRows(
|
||||
queue.push(child.pid)
|
||||
}
|
||||
}
|
||||
return { rootPgid: rootRow?.pgid ?? null, descendants }
|
||||
return { rootPgid: rootRow?.pgid ?? null, descendants, capturedAtMs }
|
||||
}
|
||||
|
||||
type SnapshotDeps = {
|
||||
readTable?: ProcessTableReader
|
||||
platform?: NodeJS.Platform
|
||||
timeoutMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,39 +193,37 @@ export async function captureDescendantSnapshot(
|
||||
}
|
||||
const readTable = deps.readTable ?? readProcessTable
|
||||
const timeoutMs = deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS
|
||||
try {
|
||||
// Why the timeout: the snapshot sits in front of the actual kill; a wedged
|
||||
// ps must degrade to shell-only kill, not block session teardown.
|
||||
const table = await Promise.race([
|
||||
readTable(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs).unref?.())
|
||||
])
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
return collectDescendantRows(rootPid, table)
|
||||
} catch {
|
||||
// Why both layers: the deadline keeps injected/custom readers bounded while
|
||||
// the production execFile timeout actually kills a wedged ps subprocess.
|
||||
const capturedAtMs = (deps.now ?? Date.now)()
|
||||
const table = await readProcessTableBeforeDeadline(readTable, timeoutMs)
|
||||
if (!table) {
|
||||
return null
|
||||
}
|
||||
return collectDescendantRows(rootPid, table, capturedAtMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard agent-session kill sequencing: snapshot the descendant tree, only
|
||||
* then run the caller's root kill, then sweep the snapshot. 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.
|
||||
* 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 function killWithDescendantSweep(
|
||||
export async function killWithDescendantSweep(
|
||||
rootPid: number,
|
||||
killRoot: () => void,
|
||||
deps: SnapshotDeps & TerminateDeps = {}
|
||||
): void {
|
||||
void captureDescendantSnapshot(rootPid, deps).then((snapshot) => {
|
||||
killRoot()
|
||||
): 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) {
|
||||
terminateDescendantSnapshot(snapshot, deps)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
killRoot()
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSendSignal(pid: number, signal: NodeJS.Signals): void {
|
||||
@@ -159,14 +238,24 @@ 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 now (root process group +
|
||||
* every descendant, reaching detached-pgid children the PTY's SIGHUP cannot),
|
||||
* then after a grace window SIGKILL whatever from the snapshot is still alive.
|
||||
* The SIGKILL wave re-reads the process table and only signals pids whose
|
||||
* start time still matches the snapshot, so a recycled pid is never killed.
|
||||
* 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,
|
||||
@@ -174,9 +263,6 @@ export function terminateDescendantSnapshot(
|
||||
): void {
|
||||
const sendSignal = deps.sendSignal ?? defaultSendSignal
|
||||
const readTable = deps.readTable ?? readProcessTable
|
||||
if (snapshot.rootPgid !== null && snapshot.rootPgid > 0) {
|
||||
sendSignal(-snapshot.rootPgid, 'SIGTERM')
|
||||
}
|
||||
for (const row of snapshot.descendants) {
|
||||
sendSignal(row.pid, 'SIGTERM')
|
||||
}
|
||||
@@ -184,19 +270,25 @@ export function terminateDescendantSnapshot(
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
void readTable().then(
|
||||
(table) => {
|
||||
const liveByPid = new Map(table.map((row) => [row.pid, row]))
|
||||
for (const row of snapshot.descendants) {
|
||||
if (liveByPid.get(row.pid)?.startedAt === row.startedAt) {
|
||||
sendSignal(row.pid, 'SIGKILL')
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
/* table read failed — leave survivors rather than risk recycled pids */
|
||||
void readProcessTableBeforeDeadline(
|
||||
readTable,
|
||||
deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS
|
||||
).then((table) => {
|
||||
if (!table) {
|
||||
return
|
||||
}
|
||||
)
|
||||
const liveByPid = new Map(table.map((row) => [row.pid, row]))
|
||||
for (const row of snapshot.descendants) {
|
||||
const live = liveByPid.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?.()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user