fix(cli-demo): interrupt Loader boot on signals
Race Loader startup with the process abort signal so SIGINT and SIGTERM can settle the one-shot CLI even when initialization has not returned a Context. If boot settles after cancellation, dispose the late context asynchronously instead of recreating the wait. Contain late boot rejection and report a late disposal failure on stderr. Cover prompt interruption, late context disposal, late boot rejection, and cleanup failure with focused CLI regressions.
This commit is contained in:
@@ -294,6 +294,53 @@ function renderResult(outputFormat: OutputFormat, result: CliResult): string {
|
||||
return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Race Loader boot with cancellation without abandoning a context that becomes
|
||||
* available after the caller has been released. Waiting for that late context
|
||||
* would recreate the signal hang, so its disposal and diagnostics run detached.
|
||||
*/
|
||||
async function bootInterruptibly(
|
||||
start: () => Promise<Context>,
|
||||
signal: AbortSignal | undefined,
|
||||
disposeLateContext: (ctx: Context) => Promise<void>,
|
||||
reportLateDisposalFailure: (error: unknown) => void,
|
||||
): Promise<Context> {
|
||||
if (signal === undefined) return await start()
|
||||
if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal))
|
||||
|
||||
let onAbort!: () => void
|
||||
const interruptedBoot = new Promise<never>((_resolve, reject) => {
|
||||
onAbort = (): void => {
|
||||
reject(new CliInterruptedError(interruptionReason(signal)))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
/* v8 ignore next -- closes registration against a non-standard synchronously mutating signal */
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
const booting = Promise.resolve().then(start)
|
||||
try {
|
||||
return await Promise.race([booting, interruptedBoot])
|
||||
} catch (error: unknown) {
|
||||
// The awaited race permits the signal to change after the preflight check.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (signal.aborted) {
|
||||
void booting.then(
|
||||
async (lateContext) => {
|
||||
try {
|
||||
await disposeLateContext(lateContext)
|
||||
} catch (error: unknown) {
|
||||
reportLateDisposalFailure(error)
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a non-completed turn reason for stderr.
|
||||
* @param reason - durable turn ending to describe.
|
||||
@@ -349,8 +396,12 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime =
|
||||
let diagnostic: string | undefined
|
||||
try {
|
||||
loadEnvironment(CLI_NAME, cwd, line => writeStderr(line))
|
||||
ctx = await bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd))
|
||||
if (runtime.signal?.aborted === true) throw new CliInterruptedError(interruptionReason(runtime.signal))
|
||||
ctx = await bootInterruptibly(
|
||||
() => bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd)),
|
||||
runtime.signal,
|
||||
disposeContext,
|
||||
error => writeStderr(`${CLI_NAME}: dispose after interrupted boot failed: ${toError(error).message}\n`),
|
||||
)
|
||||
const result = await runOneShot(ctx, {
|
||||
task: command.task,
|
||||
...runtime.signal === undefined ? {} : { signal: runtime.signal },
|
||||
|
||||
@@ -199,6 +199,83 @@ describe('runOneShot and executeCli', () => {
|
||||
expect(stderr).toContain('boot exploded')
|
||||
})
|
||||
|
||||
it('interrupts Loader boot and contains every late boot outcome', async () => {
|
||||
const abort = new AbortController()
|
||||
const lateContext = new Context()
|
||||
liveContexts.push(lateContext)
|
||||
const boot = Promise.withResolvers<Context>()
|
||||
const disposed = Promise.withResolvers<undefined>()
|
||||
let disposeCalls = 0
|
||||
let stderr = ''
|
||||
const running = executeCli(['task'], {
|
||||
signal: abort.signal,
|
||||
boot: () => boot.promise,
|
||||
loadEnv: () => {},
|
||||
writeStdout: () => {},
|
||||
writeStderr: (chunk) => { stderr += chunk },
|
||||
dispose: async (ctx) => {
|
||||
disposeCalls += 1
|
||||
await ctx.fiber.dispose()
|
||||
disposed.resolve(undefined)
|
||||
},
|
||||
})
|
||||
abort.abort('received SIGTERM')
|
||||
await expect(running).resolves.toBe(1)
|
||||
expect(stderr).toContain('received SIGTERM')
|
||||
expect(disposeCalls).toBe(0)
|
||||
boot.resolve(lateContext)
|
||||
await disposed.promise
|
||||
expect(disposeCalls).toBe(1)
|
||||
|
||||
const rejectedBoot = Promise.withResolvers<Context>()
|
||||
const rejectedAbort = new AbortController()
|
||||
const rejected = executeCli(['task'], {
|
||||
signal: rejectedAbort.signal,
|
||||
boot: () => rejectedBoot.promise,
|
||||
loadEnv: () => {},
|
||||
writeStdout: () => {},
|
||||
writeStderr: () => {},
|
||||
})
|
||||
rejectedAbort.abort('stop rejected boot')
|
||||
await expect(rejected).resolves.toBe(1)
|
||||
rejectedBoot.reject(new Error('late boot rejection'))
|
||||
await Promise.resolve()
|
||||
|
||||
let ordinaryBootStderr = ''
|
||||
const ordinaryBootFailure = await executeCli(['task'], {
|
||||
signal: new AbortController().signal,
|
||||
boot: async () => { throw new Error('ordinary boot failure') },
|
||||
loadEnv: () => {},
|
||||
writeStdout: () => {},
|
||||
writeStderr: (chunk) => { ordinaryBootStderr += chunk },
|
||||
})
|
||||
expect(ordinaryBootFailure).toBe(1)
|
||||
expect(ordinaryBootStderr).toContain('ordinary boot failure')
|
||||
|
||||
const failedCleanupBoot = Promise.withResolvers<Context>()
|
||||
const failedCleanupAbort = new AbortController()
|
||||
const cleanupFailure = Promise.withResolvers<undefined>()
|
||||
const failedCleanupContext = new Context()
|
||||
liveContexts.push(failedCleanupContext)
|
||||
const failedCleanup = executeCli(['task'], {
|
||||
signal: failedCleanupAbort.signal,
|
||||
boot: () => failedCleanupBoot.promise,
|
||||
loadEnv: () => {},
|
||||
writeStdout: () => {},
|
||||
writeStderr: (chunk) => {
|
||||
if (chunk.includes('dispose after interrupted boot failed: late cleanup')) cleanupFailure.resolve(undefined)
|
||||
},
|
||||
dispose: async (ctx) => {
|
||||
await ctx.fiber.dispose()
|
||||
throw new Error('late cleanup')
|
||||
},
|
||||
})
|
||||
failedCleanupAbort.abort('stop failed cleanup boot')
|
||||
await expect(failedCleanup).resolves.toBe(1)
|
||||
failedCleanupBoot.resolve(failedCleanupContext)
|
||||
await cleanupFailure.promise
|
||||
})
|
||||
|
||||
it('renders text, flushes a persisted fresh session, and disposes the context', async () => {
|
||||
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
|
||||
const output = await invoke(ctx, ['task'])
|
||||
|
||||
Reference in New Issue
Block a user