mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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]`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user