From 38e4d91f5e1f2d181e9eed154c444c8d99191483 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Thu, 5 Nov 2026 13:16:33 -0400 Subject: [PATCH] fix(runtime): exit once the invocation is complete A successful run only returned from run(); the process then waited for the event loop to drain. On the Bun release binary the pooled keep-alive sockets of globalThis.fetch held it for ~50 s after the answer was printed. All background work (update check, telemetry flush) is already awaited inside run(), so add runToExit(), which awaits run(), flushes stdout/stderr and exits with the process exit code, and use it in the bl and kscli entries. Co-Authored-By: Claude Sonnet 5.5 --- packages/cli/src/main.ts | 8 ++-- packages/kscli/src/main.ts | 8 ++-- packages/runtime/src/create-cli.ts | 20 ++++++++ packages/runtime/src/index.ts | 2 +- packages/runtime/tests/run-to-exit.test.ts | 55 ++++++++++++++++++++++ 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 packages/runtime/tests/run-to-exit.test.ts diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index ea836fc29..b0654d8f6 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,4 +1,4 @@ -import { createCli } from "bailian-cli-runtime"; +import { createCli, runToExit } from "bailian-cli-runtime"; import { commandAliases, commands } from "./commands.ts"; import { commandPackPolicy } from "./command-pack-policy.ts"; import pkg from "../package.json" with { type: "json" }; @@ -12,7 +12,7 @@ const quickStartTasks = [ "介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。\n Explain what Bailian CLI can help me accomplish, and recommend how to use it based on my needs.", ] as const; -void createCli( +const cli = createCli( { ...commands, ...commandAliases }, { binName: "bl", @@ -23,4 +23,6 @@ void createCli( quickStartTasks, commandPacks: commandPackPolicy, }, -).run(); +); + +void runToExit(cli); diff --git a/packages/kscli/src/main.ts b/packages/kscli/src/main.ts index fbd0f0453..fb2a10bad 100644 --- a/packages/kscli/src/main.ts +++ b/packages/kscli/src/main.ts @@ -1,10 +1,12 @@ -import { createCli } from "bailian-cli-runtime"; +import { createCli, runToExit } from "bailian-cli-runtime"; import { commands } from "./commands.ts"; import pkg from "../package.json" with { type: "json" }; -void createCli(commands, { +const cli = createCli(commands, { binName: "kscli", version: pkg.version, clientName: "knowledge-studio-cli", npmPackage: "knowledge-studio-cli", -}).run(); +}); + +void runToExit(cli); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index cbaa26a6e..f70952654 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -134,6 +134,26 @@ function handleInterrupt(): void { void flushTelemetry(500).finally(() => process.exit(130)); } +/** + * Run the CLI and end the process once the invocation is complete. + * + * Every background task (update check, telemetry flush) is awaited inside `run()`, so nothing + * useful is left once it resolves. Without an explicit exit, keep-alive sockets held by the HTTP + * client can keep a finished process alive for tens of seconds (observed on the Bun release binary). + * Errors still exit through `handleError`. + */ +export async function runToExit(cli: Cli, argv?: string[]): Promise { + await cli.run(argv); + const code = process.exitCode ?? 0; + const flush = (stream: NodeJS.WriteStream) => + new Promise((done) => { + stream.write("", () => done()); + }); + await flush(process.stdout); + await flush(process.stderr); + process.exit(code); +} + /** Keep EPIPE protection after run() returns, without retaining completed invocations. */ function handleStdoutPipeError(error: NodeJS.ErrnoException): void { if (error.code === "EPIPE") process.exit(0); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6203e5f08..121a86f4f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -2,7 +2,7 @@ // each entrypoint (bl / rag / …) injects its own command set via createCli(). // Entrypoint factory + identity -export { createCli, registerInterruptHandler } from "./create-cli.ts"; +export { createCli, registerInterruptHandler, runToExit } from "./create-cli.ts"; export { buildCommandSchema, serializeCommandSchema } from "./introspect.ts"; export type { CommandSchema, CommandSchemaNode } from "./introspect.ts"; export type { Cli, CliOptions } from "./create-cli.ts"; diff --git a/packages/runtime/tests/run-to-exit.test.ts b/packages/runtime/tests/run-to-exit.test.ts new file mode 100644 index 000000000..e617f16ff --- /dev/null +++ b/packages/runtime/tests/run-to-exit.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { runToExit } from "../src/index.ts"; + +const originalExitCode = process.exitCode; + +afterEach(() => { + process.exitCode = originalExitCode; + vi.restoreAllMocks(); +}); + +function stubExit() { + return vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); +} + +test("runToExit exits with code 0 once run() resolves", async () => { + const exit = stubExit(); + process.exitCode = undefined; + const events: string[] = []; + const run = vi.fn(async () => { + events.push("run"); + }); + exit.mockImplementation((() => { + events.push("exit"); + }) as never); + + await runToExit({ run }, ["x"]); + + expect(run).toHaveBeenCalledWith(["x"]); + expect(events).toEqual(["run", "exit"]); + expect(exit).toHaveBeenCalledWith(0); +}); + +test("runToExit keeps the exit code the run set", async () => { + const exit = stubExit(); + await runToExit({ + async run() { + process.exitCode = 3; + }, + }); + + expect(exit).toHaveBeenCalledWith(3); +}); + +test("runToExit does not exit while run() is still pending", async () => { + const exit = stubExit(); + let finish!: () => void; + const pending = runToExit({ run: () => new Promise((resolve) => (finish = resolve)) }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(exit).not.toHaveBeenCalled(); + + finish(); + await pending; + expect(exit).toHaveBeenCalledTimes(1); +});