From ae419fb692322bdb48facc1394807ad25e8b1e3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:27:10 +0800 Subject: [PATCH] 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. --- packages/examples/cli-demo/src/cli.ts | 55 +++++++++++++- packages/examples/cli-demo/tests/cli.spec.ts | 77 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 1f19e3ddf5..63c08ae39f 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -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, + signal: AbortSignal | undefined, + disposeLateContext: (ctx: Context) => Promise, + reportLateDisposalFailure: (error: unknown) => void, +): Promise { + if (signal === undefined) return await start() + if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal)) + + let onAbort!: () => void + const interruptedBoot = new Promise((_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 }, diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 4daaea7e03..fcdf8a3004 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -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() + const disposed = Promise.withResolvers() + 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() + 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() + const failedCleanupAbort = new AbortController() + const cleanupFailure = Promise.withResolvers() + 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'])