Fix timer bounds and ACP teardown ownership
This commit is contained in:
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
Reference in New Issue
Block a user