Merge branch 'codex/simp-shared-acp-test-launcher' into codex/simp-trim-hook-snapshot-noise
This commit is contained in:
@@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent {
|
|||||||
stderr(): string
|
stderr(): string
|
||||||
/** Resolve when a future session update matches the predicate. */
|
/** Resolve when a future session update matches the predicate. */
|
||||||
waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise<SessionNotification['update']>
|
waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise<SessionNotification['update']>
|
||||||
/** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, ACP parsing, and client callbacks. */
|
/** Close the process and drain its streams and callbacks; rejects promptly if fallback termination is refused. */
|
||||||
close(signal?: NodeJS.Signals): Promise<void>
|
close(signal?: NodeJS.Signals): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,8 +231,29 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
|||||||
// signal can leave the subprocess live. Force termination, await the
|
// signal can leave the subprocess live. Force termination, await the
|
||||||
// already-observed exit edge, and only then propagate the child error so
|
// already-observed exit edge, and only then propagate the child error so
|
||||||
// callers may safely remove cwd/session resources after close rejects.
|
// callers may safely remove cwd/session resources after close rejects.
|
||||||
child.kill('SIGKILL')
|
const fallbackError = Promise.withResolvers<Error>()
|
||||||
await exited
|
const observeFallbackError = (error: Error): void => { fallbackError.resolve(error) }
|
||||||
|
child.once('error', observeFallbackError)
|
||||||
|
if (!child.kill('SIGKILL')) {
|
||||||
|
child.off('error', observeFallbackError)
|
||||||
|
closeUpdateStream()
|
||||||
|
throw new AggregateError(
|
||||||
|
[failure, new Error('Fallback SIGKILL was not accepted by the child process')],
|
||||||
|
'ACP test agent failed and fallback termination was refused',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const fallbackFailure = await Promise.race([
|
||||||
|
exited.then((): undefined => undefined),
|
||||||
|
fallbackError.promise,
|
||||||
|
])
|
||||||
|
child.off('error', observeFallbackError)
|
||||||
|
if (fallbackFailure !== undefined) {
|
||||||
|
closeUpdateStream()
|
||||||
|
throw new AggregateError(
|
||||||
|
[failure, fallbackFailure],
|
||||||
|
'ACP test agent failed and fallback termination was refused',
|
||||||
|
)
|
||||||
|
}
|
||||||
await drained
|
await drained
|
||||||
closeUpdateStream()
|
closeUpdateStream()
|
||||||
throw failure
|
throw failure
|
||||||
|
|||||||
@@ -131,6 +131,65 @@ describe('runScenario', () => {
|
|||||||
expect(launched.stderr()).toContain('late inherited stderr')
|
expect(launched.stderr()).toContain('late inherited stderr')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects promptly when fallback termination is refused', async () => {
|
||||||
|
const { dir } = await scenario({})
|
||||||
|
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||||
|
await launched.spawned
|
||||||
|
|
||||||
|
const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' })
|
||||||
|
const originalKill = launched.child.kill.bind(launched.child)
|
||||||
|
const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false)
|
||||||
|
const closed = new Promise<void>(resolve => launched.child.once('close', () => { resolve() }))
|
||||||
|
try {
|
||||||
|
launched.child.emit('error', childFailure)
|
||||||
|
const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error)
|
||||||
|
expect(rejection).toBeInstanceOf(AggregateError)
|
||||||
|
expect(rejection).toMatchObject({
|
||||||
|
message: 'ACP test agent failed and fallback termination was refused',
|
||||||
|
errors: [
|
||||||
|
childFailure,
|
||||||
|
expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
|
||||||
|
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
|
||||||
|
} finally {
|
||||||
|
kill.mockRestore()
|
||||||
|
originalKill('SIGKILL')
|
||||||
|
await closed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects promptly when fallback termination emits an error', async () => {
|
||||||
|
const { dir } = await scenario({})
|
||||||
|
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||||
|
await launched.spawned
|
||||||
|
|
||||||
|
const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' })
|
||||||
|
const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' })
|
||||||
|
const originalKill = launched.child.kill.bind(launched.child)
|
||||||
|
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
|
||||||
|
if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure))
|
||||||
|
return signal === 'SIGKILL'
|
||||||
|
})
|
||||||
|
const closed = new Promise<void>(resolve => launched.child.once('close', () => { resolve() }))
|
||||||
|
try {
|
||||||
|
launched.child.emit('error', childFailure)
|
||||||
|
const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error)
|
||||||
|
expect(rejection).toBeInstanceOf(AggregateError)
|
||||||
|
expect(rejection).toMatchObject({
|
||||||
|
message: 'ACP test agent failed and fallback termination was refused',
|
||||||
|
errors: [childFailure, fallbackFailure],
|
||||||
|
})
|
||||||
|
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
|
||||||
|
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
|
||||||
|
} finally {
|
||||||
|
kill.mockRestore()
|
||||||
|
originalKill('SIGKILL')
|
||||||
|
await closed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => {
|
it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => {
|
||||||
const { dir, fixtureFile } = await scenario({ permissionProbe: true })
|
const { dir, fixtureFile } = await scenario({ permissionProbe: true })
|
||||||
let releasePermission: (() => void) | undefined
|
let releasePermission: (() => void) | undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user