Skip to content

Commit 38e4d91

Browse files
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 <noreply@anthropic.com>
1 parent 2c29049 commit 38e4d91

5 files changed

Lines changed: 86 additions & 7 deletions

File tree

‎packages/cli/src/main.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createCli } from "bailian-cli-runtime";
1+
import { createCli, runToExit } from "bailian-cli-runtime";
22
import { commandAliases, commands } from "./commands.ts";
33
import { commandPackPolicy } from "./command-pack-policy.ts";
44
import pkg from "../package.json" with { type: "json" };
@@ -12,7 +12,7 @@ const quickStartTasks = [
1212
"介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。\n Explain what Bailian CLI can help me accomplish, and recommend how to use it based on my needs.",
1313
] as const;
1414

15-
void createCli(
15+
const cli = createCli(
1616
{ ...commands, ...commandAliases },
1717
{
1818
binName: "bl",
@@ -23,4 +23,6 @@ void createCli(
2323
quickStartTasks,
2424
commandPacks: commandPackPolicy,
2525
},
26-
).run();
26+
);
27+
28+
void runToExit(cli);

‎packages/kscli/src/main.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { createCli } from "bailian-cli-runtime";
1+
import { createCli, runToExit } from "bailian-cli-runtime";
22
import { commands } from "./commands.ts";
33
import pkg from "../package.json" with { type: "json" };
44

5-
void createCli(commands, {
5+
const cli = createCli(commands, {
66
binName: "kscli",
77
version: pkg.version,
88
clientName: "knowledge-studio-cli",
99
npmPackage: "knowledge-studio-cli",
10-
}).run();
10+
});
11+
12+
void runToExit(cli);

‎packages/runtime/src/create-cli.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,26 @@ function handleInterrupt(): void {
134134
void flushTelemetry(500).finally(() => process.exit(130));
135135
}
136136

137+
/**
138+
* Run the CLI and end the process once the invocation is complete.
139+
*
140+
* Every background task (update check, telemetry flush) is awaited inside `run()`, so nothing
141+
* useful is left once it resolves. Without an explicit exit, keep-alive sockets held by the HTTP
142+
* client can keep a finished process alive for tens of seconds (observed on the Bun release binary).
143+
* Errors still exit through `handleError`.
144+
*/
145+
export async function runToExit(cli: Cli, argv?: string[]): Promise<void> {
146+
await cli.run(argv);
147+
const code = process.exitCode ?? 0;
148+
const flush = (stream: NodeJS.WriteStream) =>
149+
new Promise<void>((done) => {
150+
stream.write("", () => done());
151+
});
152+
await flush(process.stdout);
153+
await flush(process.stderr);
154+
process.exit(code);
155+
}
156+
137157
/** Keep EPIPE protection after run() returns, without retaining completed invocations. */
138158
function handleStdoutPipeError(error: NodeJS.ErrnoException): void {
139159
if (error.code === "EPIPE") process.exit(0);

‎packages/runtime/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// each entrypoint (bl / rag / …) injects its own command set via createCli().
33

44
// Entrypoint factory + identity
5-
export { createCli, registerInterruptHandler } from "./create-cli.ts";
5+
export { createCli, registerInterruptHandler, runToExit } from "./create-cli.ts";
66
export { buildCommandSchema, serializeCommandSchema } from "./introspect.ts";
77
export type { CommandSchema, CommandSchemaNode } from "./introspect.ts";
88
export type { Cli, CliOptions } from "./create-cli.ts";
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { afterEach, expect, test, vi } from "vite-plus/test";
2+
import { runToExit } from "../src/index.ts";
3+
4+
const originalExitCode = process.exitCode;
5+
6+
afterEach(() => {
7+
process.exitCode = originalExitCode;
8+
vi.restoreAllMocks();
9+
});
10+
11+
function stubExit() {
12+
return vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
13+
}
14+
15+
test("runToExit exits with code 0 once run() resolves", async () => {
16+
const exit = stubExit();
17+
process.exitCode = undefined;
18+
const events: string[] = [];
19+
const run = vi.fn(async () => {
20+
events.push("run");
21+
});
22+
exit.mockImplementation((() => {
23+
events.push("exit");
24+
}) as never);
25+
26+
await runToExit({ run }, ["x"]);
27+
28+
expect(run).toHaveBeenCalledWith(["x"]);
29+
expect(events).toEqual(["run", "exit"]);
30+
expect(exit).toHaveBeenCalledWith(0);
31+
});
32+
33+
test("runToExit keeps the exit code the run set", async () => {
34+
const exit = stubExit();
35+
await runToExit({
36+
async run() {
37+
process.exitCode = 3;
38+
},
39+
});
40+
41+
expect(exit).toHaveBeenCalledWith(3);
42+
});
43+
44+
test("runToExit does not exit while run() is still pending", async () => {
45+
const exit = stubExit();
46+
let finish!: () => void;
47+
const pending = runToExit({ run: () => new Promise<void>((resolve) => (finish = resolve)) });
48+
49+
await new Promise((resolve) => setTimeout(resolve, 20));
50+
expect(exit).not.toHaveBeenCalled();
51+
52+
finish();
53+
await pending;
54+
expect(exit).toHaveBeenCalledTimes(1);
55+
});

0 commit comments

Comments
 (0)