From 32dc160fd21c0a74c61511a72abeac42d354a202 Mon Sep 17 00:00:00 2001 From: Taylor Asplund Date: Tue, 10 Feb 2026 04:27:49 +0000 Subject: [PATCH] fix: ensure CLI exits after command completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/acp/server.ts | 34 ++++++++++++++++++++++++++++++++-- src/cli/acp-cli.ts | 4 ++-- src/cli/daemon-cli/register.ts | 4 ---- src/cli/run-main.ts | 20 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/acp/server.ts b/src/acp/server.ts index 4a2c835b549..93acc4a523c 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -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 { 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((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; 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); + }); } diff --git a/src/cli/acp-cli.ts b/src/cli/acp-cli.ts index 1be77e71fcd..c86deb48f28 100644 --- a/src/cli/acp-cli.ts +++ b/src/cli/acp-cli.ts @@ -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, diff --git a/src/cli/daemon-cli/register.ts b/src/cli/daemon-cli/register.ts index d1599a206aa..47e3dd09bdf 100644 --- a/src/cli/daemon-cli/register.ts +++ b/src/cli/daemon-cli/register.ts @@ -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(); } diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index bad3f91a21f..de3d056731d 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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 { + process.exitCode = code; + await Promise.all([ + new Promise((r) => { + process.stdout.write("", () => r()); + }), + new Promise((r) => { + process.stderr.write("", () => r()); + }), + ]); + process.exit(); } function stripWindowsNodeExec(argv: string[]): string[] {