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`
|
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.
|
* Render a non-completed turn reason for stderr.
|
||||||
* @param reason - durable turn ending to describe.
|
* @param reason - durable turn ending to describe.
|
||||||
@@ -349,8 +396,12 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime =
|
|||||||
let diagnostic: string | undefined
|
let diagnostic: string | undefined
|
||||||
try {
|
try {
|
||||||
loadEnvironment(CLI_NAME, cwd, line => writeStderr(line))
|
loadEnvironment(CLI_NAME, cwd, line => writeStderr(line))
|
||||||
ctx = await bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd))
|
ctx = await bootInterruptibly(
|
||||||
if (runtime.signal?.aborted === true) throw new CliInterruptedError(interruptionReason(runtime.signal))
|
() => 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, {
|
const result = await runOneShot(ctx, {
|
||||||
task: command.task,
|
task: command.task,
|
||||||
...runtime.signal === undefined ? {} : { signal: runtime.signal },
|
...runtime.signal === undefined ? {} : { signal: runtime.signal },
|
||||||
|
|||||||
@@ -199,6 +199,83 @@ describe('runOneShot and executeCli', () => {
|
|||||||
expect(stderr).toContain('boot exploded')
|
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 () => {
|
it('renders text, flushes a persisted fresh session, and disposes the context', async () => {
|
||||||
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
|
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
|
||||||
const output = await invoke(ctx, ['task'])
|
const output = await invoke(ctx, ['task'])
|
||||||
|
|||||||
Reference in New Issue
Block a user