mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 04:46:52 +00:00
fix: ensure CLI exits after command completion
The CLI process would hang indefinitely after commands like `openclaw gateway restart` completed successfully. Two root causes: 1. `runCli()` returned without calling `process.exit()` after `program.parseAsync()` resolved, and Commander.js does not force-exit the process. 2. `daemon-cli/register.ts` eagerly called `createDefaultDeps()` which imported all messaging-provider modules, creating persistent event-loop handles that prevented natural Node exit. Changes: - Add `flushAndExit()` helper that drains stdout/stderr before calling `process.exit()`, preventing truncated piped output in CI/scripts. - Call `flushAndExit()` after both `tryRouteCli()` and `program.parseAsync()` resolve. - Remove unnecessary `void createDefaultDeps()` from daemon-cli registration — daemon lifecycle commands never use messaging deps. - Make `serveAcpGateway()` return a promise that resolves on intentional shutdown (SIGINT/SIGTERM), so `openclaw acp` blocks `parseAsync` for the bridge lifetime and exits cleanly on signal. - Handle the returned promise in the standalone main-module entry point to avoid unhandled rejections. Fixes #12904 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Peter Steinberger
parent
1aa746f042
commit
32dc160fd2
@@ -11,7 +11,7 @@ import { isMainModule } from "../infra/is-main.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
|
||||
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
|
||||
export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const connection = buildGatewayConnectionDetails({
|
||||
config: cfg,
|
||||
@@ -34,6 +34,12 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
|
||||
auth.password;
|
||||
|
||||
let agent: AcpGatewayAgent | null = null;
|
||||
let onClosed!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
onClosed = resolve;
|
||||
});
|
||||
let stopped = false;
|
||||
|
||||
const gateway = new GatewayClient({
|
||||
url: connection.url,
|
||||
token: token || undefined,
|
||||
@@ -50,9 +56,29 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
|
||||
// Resolve only on intentional shutdown (gateway.stop() sets closed
|
||||
// which skips scheduleReconnect, then fires onClose). Transient
|
||||
// disconnects are followed by automatic reconnect attempts.
|
||||
if (stopped) {
|
||||
onClosed();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
gateway.stop();
|
||||
// If no WebSocket is active (e.g. between reconnect attempts),
|
||||
// gateway.stop() won't trigger onClose, so resolve directly.
|
||||
onClosed();
|
||||
};
|
||||
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
|
||||
const input = Writable.toWeb(process.stdout);
|
||||
const output = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
|
||||
const stream = ndJsonStream(input, output);
|
||||
@@ -64,6 +90,7 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
|
||||
}, stream);
|
||||
|
||||
gateway.start();
|
||||
return closed;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): AcpServerOptions {
|
||||
@@ -140,5 +167,8 @@ Options:
|
||||
|
||||
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
serveAcpGateway(opts);
|
||||
serveAcpGateway(opts).catch((err) => {
|
||||
console.error(String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@ export function registerAcpCli(program: Command) {
|
||||
"after",
|
||||
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.openclaw.ai/cli/acp")}\n`,
|
||||
)
|
||||
.action((opts) => {
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
serveAcpGateway({
|
||||
await serveAcpGateway({
|
||||
gatewayUrl: opts.url as string | undefined,
|
||||
gatewayToken: opts.token as string | undefined,
|
||||
gatewayPassword: opts.password as string | undefined,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { createDefaultDeps } from "../deps.js";
|
||||
import {
|
||||
runDaemonInstall,
|
||||
runDaemonRestart,
|
||||
@@ -83,7 +82,4 @@ export function registerDaemonCli(program: Command) {
|
||||
.action(async (opts) => {
|
||||
await runDaemonRestart(opts);
|
||||
});
|
||||
|
||||
// Build default deps (parity with other commands).
|
||||
void createDefaultDeps();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
assertSupportedRuntime();
|
||||
|
||||
if (await tryRouteCli(normalizedArgv)) {
|
||||
await flushAndExit(Number(process.exitCode ?? 0));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,6 +70,25 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
}
|
||||
|
||||
await program.parseAsync(parseArgv);
|
||||
|
||||
// Exit explicitly once the command action resolves. Without this,
|
||||
// eager side-effect imports (e.g. messaging-provider modules) can keep
|
||||
// the Node event loop alive and the CLI hangs after the command finishes.
|
||||
await flushAndExit(Number(process.exitCode ?? 0));
|
||||
}
|
||||
|
||||
/** Drain stdout/stderr so piped output is not truncated, then exit. */
|
||||
async function flushAndExit(code: number): Promise<void> {
|
||||
process.exitCode = code;
|
||||
await Promise.all([
|
||||
new Promise<void>((r) => {
|
||||
process.stdout.write("", () => r());
|
||||
}),
|
||||
new Promise<void>((r) => {
|
||||
process.stderr.write("", () => r());
|
||||
}),
|
||||
]);
|
||||
process.exit();
|
||||
}
|
||||
|
||||
function stripWindowsNodeExec(argv: string[]): string[] {
|
||||
|
||||
Reference in New Issue
Block a user