|
|
@@ -74,9 +74,10 @@ function expectedPermission(policy: 'allow' | 'reject', requestKind: string, dec
|
|
|
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
|
|
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
|
|
* subprocess cold-start is variable, and a fixed sleep both flakes and
|
|
|
- * slows the suite. Fails loud if the child never signals readiness.
|
|
|
+ * slows the suite. The caller supplies its effective test budget so child
|
|
|
+ * startup retains the lane's allowance under concurrent coverage.
|
|
|
*/
|
|
|
-async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
|
|
+async function waitForFile(file: string, timeoutMs: number): Promise<void> {
|
|
|
const deadline = Date.now() + timeoutMs
|
|
|
while (!existsSync(file)) {
|
|
|
if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`)
|
|
|
@@ -724,7 +725,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
- it('aborting the required signal cancels a running child', async () => {
|
|
|
+ it('aborting the required signal cancels a running child', async ({ task }) => {
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
|
|
|
const readyFile = join(tmp, 'ready')
|
|
|
try {
|
|
|
@@ -733,7 +734,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await ctx.subagents.start('acp', request('p', controller.signal))
|
|
|
// Wait until the child's prompt is in flight (condition, not a sleep),
|
|
|
// then cancel — so we exercise the mid-run session/cancel path.
|
|
|
- await waitForFile(readyFile)
|
|
|
+ await waitForFile(readyFile, task.timeout)
|
|
|
controller.abort('test')
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
@@ -900,7 +901,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
await realChild?.done
|
|
|
})
|
|
|
|
|
|
- it('reports only the safe teardown failure when cancelled startup rollback fails', async () => {
|
|
|
+ it('reports only the safe teardown failure when cancelled startup rollback fails', async ({ task }) => {
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'acp-cancelled-rollback-'))
|
|
|
const ready = join(tmp, 'ready')
|
|
|
const go = join(tmp, 'go')
|
|
|
@@ -923,7 +924,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
},
|
|
|
onError: (error) => { errors.push(error.message) },
|
|
|
})
|
|
|
- await waitForFile(ready)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
controller.abort()
|
|
|
writeFileSync(go, 'go')
|
|
|
const error = await starting.catch((cause: unknown) => cause)
|
|
|
@@ -940,7 +941,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
|
|
|
+ it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async ({ task }) => {
|
|
|
// 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.
|
|
|
@@ -964,7 +965,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
// 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)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
// 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.
|
|
|
@@ -977,7 +978,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
|
|
|
+ it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async ({ task }) => {
|
|
|
// The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
|
|
|
// down on connection close, NOT on a signal) — and it has no SIGTERM handler.
|
|
|
// Its EOF teardown can itself await a signal-trapping grandchild (a bash
|
|
|
@@ -1011,7 +1012,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
const run = await startAcpRun(request(), spec)
|
|
|
// Wait until the child is fully booted with its prompt in flight (its ACP
|
|
|
// stdin reader is attached), so dispose's stdin EOF reaches a live child.
|
|
|
- await waitForFile(ready)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
await run.dispose()
|
|
|
// dispose returned via the natural-exit tier — the EOF-driven flush landed
|
|
|
// despite taking longer than the SIGTERM grace.
|
|
|
@@ -1021,7 +1022,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('terminates a child that ignores EOF using the host platform semantics', async () => {
|
|
|
+ it('terminates a child that ignores EOF using the host platform semantics', async ({ task }) => {
|
|
|
// 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-'))
|
|
|
@@ -1048,7 +1049,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
},
|
|
|
}
|
|
|
run = await startAcpRun(request(), spec)
|
|
|
- await waitForFile(ready)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
await run.dispose()
|
|
|
const outcome = await child!.done
|
|
|
expect(outcome.signal).toBeNull()
|
|
|
@@ -1071,7 +1072,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('rejects after cleanup when the signal aborts during newSession', async () => {
|
|
|
+ it('rejects after cleanup when the signal aborts during newSession', async ({ task }) => {
|
|
|
// Gate the child at newSession: it signals `ready` and blocks until `go`.
|
|
|
// We cancel WHILE newSession is pending (sessionId still undefined, so the
|
|
|
// backend cannot send session/cancel) — the `cancelled` flag alone must
|
|
|
@@ -1083,23 +1084,40 @@ describe('dsh-subagent-acp', () => {
|
|
|
const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
|
|
|
const controller = new AbortController()
|
|
|
const starting = ctx.subagents.start('acp', request('p', controller.signal))
|
|
|
- await waitForFile(ready) // newSession is now in flight, sessionId undefined
|
|
|
- controller.abort('early')
|
|
|
- writeFileSync(go, 'go') // let newSession resolve
|
|
|
- await expect(starting).rejects.toThrow('aborted before the ACP child started')
|
|
|
+ // Observe rejection before readiness; cleanup still owns an unpublished
|
|
|
+ // child if readiness or an assertion fails.
|
|
|
+ const settled = starting.then(
|
|
|
+ run => ({ kind: 'started' as const, run }),
|
|
|
+ (error: unknown) => ({ kind: 'failed' as const, error }),
|
|
|
+ )
|
|
|
+ try {
|
|
|
+ await waitForFile(ready, task.timeout) // newSession is now in flight, sessionId undefined
|
|
|
+ controller.abort('early')
|
|
|
+ writeFileSync(go, 'go') // let newSession resolve
|
|
|
+ await expect(starting).rejects.toThrow('aborted before the ACP child started')
|
|
|
+ } finally {
|
|
|
+ controller.abort('test cleanup')
|
|
|
+ writeFileSync(go, 'go')
|
|
|
+ try {
|
|
|
+ const outcome = await settled
|
|
|
+ if (outcome.kind === 'started') await outcome.run.dispose()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ }
|
|
|
} finally {
|
|
|
rmSync(tmp, { recursive: true, force: true })
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('bridges the request signal to a session/cancel mid-run', async () => {
|
|
|
+ it('bridges the request signal to a session/cancel mid-run', async ({ task }) => {
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-'))
|
|
|
const readyFile = join(tmp, 'ready')
|
|
|
try {
|
|
|
const controller = new AbortController()
|
|
|
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
|
|
|
const run = await ctx.subagents.start('acp', request('p', controller.signal))
|
|
|
- await waitForFile(readyFile)
|
|
|
+ await waitForFile(readyFile, task.timeout)
|
|
|
controller.abort()
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
@@ -1357,7 +1375,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
expect(errors).toEqual([rawMessage])
|
|
|
})
|
|
|
|
|
|
- it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
|
|
|
+ it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async ({ task }) => {
|
|
|
// Same trap scenario as the direct startAcpRun escalation test, but the
|
|
|
// graces arrive via the PLUGIN CONFIG through the registered provider — so a
|
|
|
// regression that stops threading config into AcpRunSpec (falling back to
|
|
|
@@ -1379,7 +1397,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
disposeGraceMs: 150,
|
|
|
})
|
|
|
const run = await ctx.subagents.start('acp', request())
|
|
|
- await waitForFile(ready)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
await expect(Promise.race([
|
|
|
run.dispose(),
|
|
|
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }),
|
|
|
@@ -1587,7 +1605,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
await run.dispose()
|
|
|
})
|
|
|
|
|
|
- it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
|
|
|
+ it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async ({ task }) => {
|
|
|
// The child hangs, we cancel, and instead of answering the child exits hard
|
|
|
// — the pending prompt RPC rejects. With a cancel already requested, the
|
|
|
// backend's catch path must settle `aborted` (the failure is the cancel
|
|
|
@@ -1598,7 +1616,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
|
|
|
const controller = new AbortController()
|
|
|
const run = await ctx.subagents.start('acp', request('p', controller.signal))
|
|
|
- await waitForFile(ready)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
controller.abort('crash it')
|
|
|
const result = await run.result
|
|
|
expect(result.stopReason).toBe('aborted')
|
|
|
@@ -1609,7 +1627,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
|
|
|
+ it('settles aborted on signal even when the child IGNORES session/cancel', async ({ task }) => {
|
|
|
// The signal contract requires `result` to settle `aborted`. A child that hangs
|
|
|
// its prompt AND ignores session/cancel must not wedge the parent — the
|
|
|
// backend's own cancel-settle path resolves `aborted` without the child's
|
|
|
@@ -1620,7 +1638,7 @@ describe('dsh-subagent-acp', () => {
|
|
|
const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
|
|
|
const controller = new AbortController()
|
|
|
const run = await ctx.subagents.start('acp', request('p', controller.signal))
|
|
|
- await waitForFile(ready)
|
|
|
+ await waitForFile(ready, task.timeout)
|
|
|
controller.abort('test')
|
|
|
// Bound it: a regression (cancel only notifies the child, which ignores it)
|
|
|
// would hang result forever — fail loud instead of stalling the suite.
|