fix(cli): exit cleanly on subcommand-group --help (lazy register handle leak) (#111433)

Lazy command-group registration must import the real command tree to render complete help. On macOS with NODE_USE_SYSTEM_CA=1, those imports start Node's system CA loader worker; natural shutdown can block in CleanupCachedRootCertificates while joining LoadSystemCACertificates.

Request the existing stream-flushed one-shot exit immediately after successful Commander help parsing, covering both CommanderError and normal-return plugin help without affecting leaf command execution.
This commit is contained in:
Peter Steinberger
2026-07-19 08:10:56 -07:00
committed by GitHub
parent 356bec6f5d
commit 223235044a
3 changed files with 131 additions and 47 deletions
+82 -45
View File
@@ -10,24 +10,34 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
const execFileAsync = promisify(execFile);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const CHILD_PROCESS_TIMEOUT_MS = 30_000;
const LAZY_GROUP_HELP_CASES = [
{ group: "backup", usageCommand: "backup" },
{ group: "capability", usageCommand: "infer|capability" },
{ group: "channels", usageCommand: "channels" },
{ group: "clawbot", usageCommand: "clawbot" },
{ group: "daemon", usageCommand: "daemon" },
{ group: "hooks", usageCommand: "hooks" },
{ group: "infer", usageCommand: "infer|capability" },
{ group: "migrate", usageCommand: "migrate" },
{ group: "node", usageCommand: "node" },
{ group: "security", usageCommand: "security" },
{ group: "update", usageCommand: "update" },
] as const;
describe("CLI help process exit", () => {
it.each([
{ args: ["--help"], usage: "Usage: openclaw [options] [command]" },
{ args: ["path", "--help"], usage: "Usage: openclaw path [options] [command]" },
])("exits promptly after $args", async ({ args, usage }) => {
const root = tempDirs.make("openclaw-help-exit-");
const stateDir = path.join(root, "state");
const configPath = path.join(stateDir, "openclaw.json");
const tlsImportGuardPath = path.join(root, "forbid-tls-import.mjs");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ plugins: { entries: { "oc-path": { enabled: true } } } }),
);
await fs.writeFile(
tlsImportGuardPath,
`import { registerHooks } from "node:module";
async function createHelpProcessFixture() {
const root = tempDirs.make("openclaw-help-exit-");
const stateDir = path.join(root, "state");
const configPath = path.join(stateDir, "openclaw.json");
const tlsImportGuardPath = path.join(root, "forbid-tls-import.mjs");
const keepAlivePath = path.join(root, "keep-alive.mjs");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ plugins: { entries: { "oc-path": { enabled: true } } } }),
);
await fs.writeFile(
tlsImportGuardPath,
`import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "node:tls" || specifier === "tls") {
@@ -37,38 +47,65 @@ registerHooks({
},
});
`,
);
);
await fs.writeFile(keepAlivePath, "setInterval(() => {}, 60_000);\n");
return { root, stateDir, configPath, tlsImportGuardPath, keepAlivePath };
}
const result = await execFileAsync(
process.execPath,
[
"--import",
pathToFileURL(tlsImportGuardPath).href,
"--import",
"tsx",
"src/entry.ts",
...args,
],
{
cwd: path.resolve("."),
encoding: "utf8",
env: {
...process.env,
HOME: root,
NODE_ENV: undefined,
NODE_OPTIONS: undefined,
NODE_USE_SYSTEM_CA: "1",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_STATE_DIR: stateDir,
VITEST: undefined,
},
killSignal: "SIGKILL",
timeout: CHILD_PROCESS_TIMEOUT_MS,
async function runHelpProcess(params: {
args: string[];
forbidTlsImport?: boolean;
keepAlive?: boolean;
}) {
const fixture = await createHelpProcessFixture();
return await execFileAsync(
process.execPath,
[
...(params.forbidTlsImport
? ["--import", pathToFileURL(fixture.tlsImportGuardPath).href]
: []),
...(params.keepAlive ? ["--import", pathToFileURL(fixture.keepAlivePath).href] : []),
"--import",
"tsx",
"src/entry.ts",
...params.args,
],
{
cwd: path.resolve("."),
encoding: "utf8",
env: {
...process.env,
HOME: fixture.root,
NODE_ENV: undefined,
NODE_OPTIONS: undefined,
NODE_USE_SYSTEM_CA: "1",
OPENCLAW_CONFIG_PATH: fixture.configPath,
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_STATE_DIR: fixture.stateDir,
VITEST: undefined,
},
);
killSignal: "SIGKILL",
timeout: CHILD_PROCESS_TIMEOUT_MS,
},
);
}
describe("CLI help process exit", () => {
it.each([
{ args: ["--help"], usage: "Usage: openclaw [options] [command]" },
{ args: ["path", "--help"], usage: "Usage: openclaw path [options] [command]" },
])("exits promptly after $args", async ({ args, usage }) => {
const result = await runHelpProcess({ args, forbidTlsImport: true });
expect(result.stderr).toBe("");
expect(result.stdout).toContain(usage);
});
it.each(LAZY_GROUP_HELP_CASES)("exits promptly after $group --help", async (testCase) => {
const { group, usageCommand } = testCase;
const result = await runHelpProcess({ args: [group, "--help"], keepAlive: true });
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`Usage: openclaw ${usageCommand} [options] [command]`);
});
});
+34
View File
@@ -133,6 +133,7 @@ const startProxyMock = vi.hoisted(() =>
);
const stopProxyMock = vi.hoisted(() => vi.fn<(handle: unknown) => Promise<void>>(async () => {}));
const flushExitAfterOneShotOutputMock = vi.hoisted(() => vi.fn());
const requestExitAfterOneShotOutputMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
@@ -208,6 +209,7 @@ vi.mock("./dotenv.js", () => ({
vi.mock("./one-shot-exit.js", () => ({
flushExitAfterOneShotOutput: flushExitAfterOneShotOutputMock,
requestExitAfterOneShotOutput: requestExitAfterOneShotOutputMock,
}));
vi.mock("../infra/env.js", async (importOriginal) => ({
@@ -3771,6 +3773,38 @@ describe("runCli exit behavior", () => {
process.exitCode = exitCode;
});
it("requests a flushed one-shot exit after Commander renders help", async () => {
const exitCode = process.exitCode;
const program = {
commands: [{ name: () => "security" }],
parseAsync: vi
.fn()
.mockRejectedValueOnce(new CommanderError(0, "commander.helpDisplayed", "help displayed")),
};
buildProgramMock.mockReturnValueOnce(program);
await runCli(["node", "openclaw", "security", "--help"]);
expect(requestExitAfterOneShotOutputMock).toHaveBeenCalledOnce();
expect(flushExitAfterOneShotOutputMock).toHaveBeenCalledOnce();
expect(process.exitCode).toBe(0);
process.exitCode = exitCode;
});
it("requests a flushed one-shot exit when plugin group help returns normally", async () => {
const program = {
commands: [{ name: () => "memory" }],
parseAsync: vi.fn().mockResolvedValueOnce(undefined),
};
buildProgramMock.mockReturnValueOnce(program);
resolvePluginCliRootOwnerIdsMock.mockReturnValueOnce(["memory-core"]);
await runCli(["node", "openclaw", "memory", "--help"]);
expect(requestExitAfterOneShotOutputMock).toHaveBeenCalledOnce();
expect(flushExitAfterOneShotOutputMock).toHaveBeenCalledOnce();
});
it("loads the real primary command before rendering command help", async () => {
const program = {
commands: [{ name: () => "doctor" }],
+15 -2
View File
@@ -35,7 +35,7 @@ import {
resolveGatewayRunPreBootstrapOptions,
} from "./gateway-run-argv.js";
import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js";
import { flushExitAfterOneShotOutput } from "./one-shot-exit.js";
import { flushExitAfterOneShotOutput, requestExitAfterOneShotOutput } from "./one-shot-exit.js";
import { tryOutputPrecomputedCommandHelp } from "./precomputed-help.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { formatCliCommandSuggestions } from "./program/command-suggestions.js";
@@ -1116,6 +1116,7 @@ export async function runCli(argv: string[] = process.argv) {
});
}
let flushedHelpExit = false;
try {
if (shouldUseRootHelpFastPath(normalizedArgv)) {
const { loadRootHelpRenderOptionsForConfigSensitivePlugins } =
@@ -1406,13 +1407,23 @@ export async function runCli(argv: string[] = process.argv) {
);
stopStartupProgress();
let completedHelpOrVersion = false;
try {
await startupTrace.measure("parse", () => program.parseAsync(parseArgv));
completedHelpOrVersion = isHelpOrVersionInvocation;
} catch (error) {
if (!isCommanderParseExit(error)) {
throw error;
}
process.exitCode = error.exitCode;
completedHelpOrVersion = isHelpOrVersionInvocation && error.exitCode === 0;
}
if (completedHelpOrVersion) {
// Lazy command-group registrars can import native/runtime resources solely to
// render complete help. Exit after Commander has rendered and streams flush.
requestExitAfterOneShotOutput();
flushExitAfterOneShotOutput();
flushedHelpExit = true;
}
} finally {
stopStartupProgress();
@@ -1423,7 +1434,9 @@ export async function runCli(argv: string[] = process.argv) {
await disposeCliAgentHarnesses();
await closeCliMemoryManagers();
pauseNonTtyStdinForCliExit();
flushExitAfterOneShotOutput();
if (!flushedHelpExit) {
flushExitAfterOneShotOutput();
}
}
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */