Bound ACP dispose with SIGKILL escalation; skip spawn when pre-aborted (Codex review round 1)

Two lifecycle findings from the review:

- A (blocker): dispose() could hang forever. It only sent SIGTERM and awaited
  exit, with no escalation — a child that traps SIGTERM (or our acp-agent if it
  doesn't quiesce on stdin EOF) would wedge dispose, stranding tool-subagent's
  finally cleanup and orphaning child-owned work (e.g. bash subprocesses). dispose
  now: ends stdin (graceful ACP close so the child can flush + exit), SIGTERM,
  then escalates to SIGKILL if it doesn't exit within a grace period
  (DEFAULT_DISPOSE_GRACE_MS, injectable via spec.disposeGraceMs), awaiting the
  certain exit. Mirrors the bash executor's bounded teardown. Regression test
  drives a SIGTERM-trapping mock subprocess and asserts dispose returns promptly
  — proven to hang (red) without the escalation.

- B: an already-aborted request still spawned the configured binary. startAcpRun
  now returns an inert already-aborted run BEFORE spawning, so a pre-cancelled
  request launches nothing. Test points the command at `touch <sentinel>` and
  asserts the sentinel never appears.

The dispose regression test exposed (via systematic-debugging) that the child
must signal trap-armed readiness before the test cancels — a bare timeout raced
the trap install and the default SIGTERM handler killed the child, making the
guard a no-op. The mock now touches its ready file once the trap is in place and
the test waits on that condition. The `cancelled` flag moved onto a holder object
so TS control-flow doesn't narrow the catch-time read to always-false.
This commit is contained in:
Tianyi Cui
2026-06-22 11:39:30 +08:00
parent f393043b03
commit 6801130f8c
3 changed files with 126 additions and 27 deletions
@@ -148,3 +148,17 @@ new AgentSideConnection(
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
),
)
// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process
// neither quiesces on EOF nor dies on the graceful signal — exercising the
// backend dispose path's SIGKILL escalation. Without this the process exits
// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so
// a test waits for that CONDITION before disposing (the trap must be in place,
// not merely the process spawned — otherwise SIGTERM hits the default handler).
if (process.env.MOCK_TRAP_SIGTERM === '1') {
process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ })
// Keep the event loop alive (a bare timer) so nothing else lets it exit.
setInterval(() => { /* stay alive until SIGKILL */ }, 1000)
if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed')
}
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, toAcpPrompt } from '../src/run.ts'
import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
/**
* Keyless integration tests for the ACP subagent backend. Each spawns a REAL
@@ -159,15 +159,63 @@ describe('dsh-subagent-acp', () => {
}
})
it('settles aborted without running the child when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const ctx = await setup({ MOCK_TEXT: 'never seen' })
const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal })
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
await run.dispose()
it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => {
// A pre-aborted request must not even launch the configured binary. Point
// the command at one that would create a sentinel file if it ever ran, and
// assert the sentinel never appears.
const tmp = mkdtempSync(join(tmpdir(), 'acp-preabort-'))
const sentinel = join(tmp, 'spawned')
try {
const controller = new AbortController()
controller.abort()
const run = startAcpRun(
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal },
// `touch <sentinel>` — runs only if the process is actually spawned.
{ command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} },
)
const result = await run.result
expect(result.stopReason).toBe('aborted')
expect(result.output).toEqual([])
// cancel/dispose on the inert run are safe no-ops.
run.cancel('noop')
await run.dispose()
// The binary was never launched — no sentinel.
expect(existsSync(sentinel)).toBe(false)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
// The child traps SIGTERM and keeps its event loop alive, so a graceful
// term alone would hang dispose forever. With a short grace, dispose must
// escalate to SIGKILL and return once the process is actually gone.
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
const ready = join(tmp, 'trap-armed')
try {
const spec: AcpRunSpec = {
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
disposeGraceMs: 150,
}
const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec)
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
// sleep) — otherwise SIGTERM races the trap install and the default handler
// terminates the child, never exercising the escalation.
await waitForFile(ready)
// Don't await result (the child hangs). Dispose must still return promptly
// via the SIGKILL escalation — bound it so a regression (no escalation)
// fails loud instead of hanging the suite.
await expect(Promise.race([
run.dispose(),
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — no SIGKILL escalation')) }, 4000) }),
])).resolves.toBeUndefined()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => {