fix(subprocess): honor Windows termination semantics

This commit is contained in:
imccyu
2026-07-18 16:25:51 +08:00
parent d36dc6ece9
commit 64f64724e5
8 changed files with 48 additions and 38 deletions

View File

@@ -8,7 +8,7 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. Disposal resolves only after child exit. Every run uses a fresh process; process pooling is not implemented.
## Capabilities and context
@@ -24,8 +24,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th
| `cwd` | process cwd | Child process and ACP session working directory. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
| `disposeGraceMs` | `3000` | POSIX grace after SIGTERM before SIGKILL; unused on Windows. |
```yaml
- id: subagent-acp

View File

@@ -46,7 +46,7 @@ export interface Config {
* before the parent escalates to a signal.
*/
disposeEofGraceMs?: number
/** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
/** POSIX grace period (ms) between `SIGTERM` and `SIGKILL`; unused on Windows. */
disposeGraceMs?: number
}

View File

@@ -56,9 +56,9 @@ export interface AcpRunSpec {
*/
disposeEofGraceMs: number
/**
* Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in
* {@link SubagentRun.dispose}. The plugin fills this from its
* `disposeGraceMs` config.
* POSIX grace period (ms) between `SIGTERM` and `SIGKILL` in
* {@link SubagentRun.dispose}; unused on Windows. The plugin fills this from
* its `disposeGraceMs` config.
*/
disposeGraceMs: number
/**
@@ -75,7 +75,7 @@ export interface AcpRunSpec {
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/**
@@ -290,9 +290,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
if (disposal !== undefined) return disposal
request.signal.removeEventListener('abort', onAbort)
requestCancel()
// The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces
// from stdin EOF, including the final flush, so this backend uses a wider
// EOF grace before signals escalate.
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
// stdin EOF, including the final flush, so this backend uses a wider EOF
// grace before process termination escalates.
disposal = disposeProcess()
return disposal
},

View File

@@ -263,13 +263,9 @@ describe('dsh-subagent-acp', () => {
}
})
it.skipIf(process.platform === 'win32')('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
// A child that keeps its loop alive past stdin EOF (so the graceful window
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
// — dispose returns there, never reaching the SIGKILL tier. The child touches
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
// run and the marker would be absent — making this a GENUINE middle-tier guard.
it('terminates a child that ignores EOF using the host platform semantics', async () => {
// POSIX uses the catchable SIGTERM tier and records the marker. Windows has
// no distinct graceful signal, so disposal skips directly to forced exit.
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
const ready = join(tmp, 'ready')
const sigterm = join(tmp, 'sigterm')
@@ -283,7 +279,7 @@ describe('dsh-subagent-acp', () => {
MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm,
},
// Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM.
// Tiny EOF grace so the ignored-EOF window elapses quickly.
disposeEofGraceMs: 150,
disposeGraceMs: 2000,
}
@@ -294,9 +290,7 @@ describe('dsh-subagent-acp', () => {
run.dispose(),
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }),
])).resolves.toBeUndefined()
// The child caught SIGTERM and exited — proof the middle rung fired (not a
// jump straight to the uncatchable SIGKILL).
expect(existsSync(sigterm)).toBe(true)
expect(existsSync(sigterm)).toBe(process.platform !== 'win32')
} finally {
rmSync(tmp, { recursive: true, force: true })
}