Fix timer bounds and ACP teardown ownership

This commit is contained in:
pku-xht
2026-08-05 05:24:23 +08:00
parent 075b6b0756
commit 0ccd847ef3
36 changed files with 130 additions and 96 deletions
+7 -5
View File
@@ -17,6 +17,7 @@ import type {
SubagentProvider,
SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
@@ -54,10 +55,11 @@ export interface Config {
/**
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
* window to flush persistence and tear down its own nested subprocesses
* before the parent escalates to a signal.
* before the parent escalates to a signal. Must not exceed
* `MAX_TIMER_DELAY_MS`.
*/
disposeEofGraceMs?: number
/** Termination confirmation window (ms), including forced exit on every platform. */
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
disposeGraceMs?: number
}
@@ -72,10 +74,10 @@ export const Config: z<Config> = z.object({
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A dispose grace must be a positive finite number (it bounds the teardown wait). */
/** A dispose grace must fit the single Node timer that owns its teardown tier. */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-acp: ${name} must be a positive finite number`)
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
+10 -15
View File
@@ -62,9 +62,9 @@ export interface AcpRunSpec {
*/
disposeEofGraceMs: number
/**
* Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after
* `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin
* fills this from its `disposeGraceMs` config.
* Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX
* waits this long after `SIGTERM` before `SIGKILL`, while Windows
* force-terminates directly. The plugin fills it from `disposeGraceMs`.
*/
disposeGraceMs: number
/**
@@ -105,14 +105,12 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boo
* Cooperative teardown ladder for an out-of-process agent, over the seam's
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
* window to flush persistence and reap its own descendants), then the
* terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded
* confirmation wait.
* terminate() escalation (SIGTERM → spec grace → SIGKILL) and its
* whole-tree exit proof.
* @param child - the spawned ACP child's handle.
* @param eofGraceMs - tier-1 window after stdin EOF.
* @param graceMs - confirmation window after the escalation's SIGKILL.
* @throws when the tree still has not exited `graceMs` after forced termination.
*/
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise<void> {
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise<void> {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (child.pid <= 0) {
@@ -121,13 +119,10 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe
}
child.stdin?.end()
if (await treeExitsWithin(child, eofGraceMs)) return
// terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace
// (this plugin passes disposeGraceMs there), so the bound covers both the
// escalation window and an equal confirmation window after the SIGKILL.
// terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is
// the process owner's exit proof, not a second derived grace that can overflow.
child.terminate()
if (!(await treeExitsWithin(child, graceMs * 2))) {
throw new Error('ACP child process tree did not exit within its dispose windows')
}
await child.waitForExit()
}
/**
@@ -235,7 +230,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs))
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []