fix(cli): defer CLI-only module imports after version fast path (#162311)

Defers the CLI lifecycle modules until after the `--version` fast path, so `openclaw --version` no longer imports them.

Proof: built base/candidate in secretless Docker, median of 15 runs. Package-root `--version` 110.8 -> 97.6 ms with 312 -> 253 synchronously imported modules; all six lifecycle families no longer load there. The profiled launcher path changes within noise (114.3 -> 113.8 ms). `--version`, `-V`, `--help` and `status --json` produce byte-identical output on both, and the deferred modules still load for commands that need them. Regression tests fail before and pass after.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
SunnyShu 2026-10-01 20:31:44 +08:00 • committed by GitHub
parent 1b896e6327
commit ec8df3aa0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 31 deletions

View file

@ -7,7 +7,6 @@ import { format } from "node:util";
import { resolveCliArgvInvocation } from "./cli/argv-invocation.js";
import { isRootHelpInvocation } from "./cli/argv.js";
import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js";
import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from "./cli/one-shot-exit.js";
import {
tryOutputPrecomputedCommandHelp,
type PrecomputedCommandHelpDeps,
@ -19,7 +18,6 @@ import {
isUpdateAdmissionInvocation,
tryRunUpdateAdmissionBeforeStartup,
} from "./cli/run-main-update-admission.js";
import { withCliProcessScope } from "./cli/runtime-cleanup-scope.js";
import {
configureGatewayStartupTraceConsoleFormatting,
createGatewayDispatchStartupTrace,
@ -240,8 +238,10 @@ if (
gatewayEntryStartupTrace.mark("argv");
if (!tryHandleRootVersionFastPath(process.argv)) {
const run = (finalize?: () => Promise<void>) =>
withCliProcessScope(() => runMainOrRootHelp(process.argv, { finalize }));
const run = async (finalize?: () => Promise<void>) => {
const { withCliProcessScope } = await import("./cli/runtime-cleanup-scope.js");
return withCliProcessScope(() => runMainOrRootHelp(process.argv, { finalize }));
};
const managedNodeStatePath = getManagedNodeHostStatePath();
if (managedNodeStatePath) {
const { withExistingOpenClawStateSchema } =
@ -371,6 +371,8 @@ export async function runMainOrRootHelp(
// mode so the envelope is written here. Only failures before runCli are startup failures.
let commandStarted = false;
let failureHandler: Awaited<ReturnType<typeof prepareCliFailureHandler>> | undefined;
const { runCliWithExitFinalization, requestExitAfterOneShotOutput } =
await import("./cli/one-shot-exit.js");
await runCliWithExitFinalization({
finalize: deps.finalize,
run: async () => {

View file

@ -13,9 +13,19 @@ vi.mock("node:fs", async (importOriginal) => ({
vi.mock("./cli/run-main.js", () => ({
runCli: vi.fn(async () => undefined),
}));
const lifecycleImports = vi.hoisted(() => ({ failureOutput: vi.fn() }));
vi.mock("./cli/one-shot-exit.js", () => ({
runCliWithExitFinalization: vi.fn(),
}));
vi.mock("./cli/failure-output.js", () => {
lifecycleImports.failureOutput();
return {
formatCliFailureLines: vi.fn(() => []),
formatCliJsonFailure: vi.fn(),
isExpectedCliError: vi.fn(() => false),
};
});
vi.mock("./entry.version-fast-path.js", () => ({
tryHandleRootVersionFastPath: vi.fn(() => false),
}));
@ -25,6 +35,26 @@ vi.mock("./infra/is-main.js", () => ({
vi.mock("./infra/package-lifecycle.js", () => ({
completePendingPackageLifecycle: vi.fn(async () => true),
}));
vi.mock("./library.js", () => ({
applyTemplate: vi.fn(),
createDefaultDeps: vi.fn(),
deriveSessionKey: vi.fn(),
describePortOwner: vi.fn(),
ensureBinary: vi.fn(),
ensurePortAvailable: vi.fn(),
getReplyFromConfig: vi.fn(),
handlePortError: vi.fn(),
loadConfig: vi.fn(),
monitorWebChannel: vi.fn(),
normalizeE164: vi.fn(),
PortInUseError: class PortInUseError extends Error {},
promptYesNo: vi.fn(),
resolveSessionKey: vi.fn(),
resolveStorePath: vi.fn(),
runCommandWithTimeout: vi.fn(),
runExec: vi.fn(),
waitForever: vi.fn(),
}));
const originalArgv = process.argv;
const originalExitCode = process.exitCode;
@ -37,6 +67,7 @@ describe("legacy package executable entrypoint", () => {
vi.mocked(tryHandleRootVersionFastPath).mockReturnValue(false);
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(completePendingPackageLifecycle).mockResolvedValue(true);
lifecycleImports.failureOutput.mockClear();
process.argv = ["node", "dist/index.js", "status"];
});
@ -58,6 +89,24 @@ describe("legacy package executable entrypoint", () => {
expect(tryHandleRootVersionFastPath).toHaveBeenCalledWith(process.argv);
expect(runMain.runCli).not.toHaveBeenCalled();
expect(exitFinalization.runCliWithExitFinalization).not.toHaveBeenCalled();
expect(lifecycleImports.failureOutput).not.toHaveBeenCalled();
});
it("loads CLI failure modules only after the version fast path declines", async () => {
process.argv = ["node", "dist/index.js", "status"];
await import("./index.js?legacy-cli-start" as "./index.js");
expect(lifecycleImports.failureOutput).toHaveBeenCalledOnce();
});
it("keeps library imports free of CLI failure modules", async () => {
vi.mocked(isMainModule).mockReturnValue(false);
const entry = await import("./index.js?legacy-library-entry" as "./index.js");
expect(typeof entry.loadConfig).toBe("function");
expect(lifecycleImports.failureOutput).not.toHaveBeenCalled();
});
it("completes pending lifecycle before loading the CLI entry graph", async () => {

View file

@ -6,6 +6,7 @@ import process from "node:process";
import { fileURLToPath } from "node:url";
import { resolveCliArgvInvocation } from "./cli/argv-invocation.js";
import { tryRunUpdateAdmissionBeforeStartup } from "./cli/run-main-update-admission.js";
import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js";
import { isMainModule } from "./infra/is-main.js";
const isMain = isMainModule({
@ -31,27 +32,8 @@ if (
}
}
const [
{ formatCliFailureLines, formatCliJsonFailure, isExpectedCliError },
{ isJsonOutputModeActive },
{ runCliWithExitFinalization },
{ withCliProcessScope },
{ installDistEsmResolveFastPath },
{ tryHandleRootVersionFastPath },
{ formatUncaughtError },
{ runFatalErrorHooks },
{ installUnhandledRejectionHandler, isBenignUncaughtExceptionError, isUncaughtExceptionHandled },
] = await Promise.all([
import("./cli/failure-output.js"),
import("./cli/json-output-mode.js"),
import("./cli/one-shot-exit.js"),
import("./cli/runtime-cleanup-scope.js"),
import("./entry.esm-resolve-fast-path.js"),
import("./entry.version-fast-path.js"),
import("./infra/errors.js"),
import("./infra/fatal-error-hooks.js"),
import("./infra/unhandled-rejections.js"),
]);
const handledRootVersion =
isMain && !handledAdmission && tryHandleRootVersionFastPath(process.argv);
type LegacyCliDeps = {
runCli: (
@ -102,12 +84,6 @@ export async function runLegacyCliEntry(
await runCli(argv, options);
}
if (isMain && !handledAdmission) {
installDistEsmResolveFastPath(import.meta.url);
}
const handledRootVersion =
isMain && !handledAdmission && tryHandleRootVersionFastPath(process.argv);
if (!isMain) {
({
applyTemplate,
@ -132,6 +108,31 @@ if (!isMain) {
}
if (isMain && !handledRootVersion && !handledAdmission) {
const [
{ formatCliFailureLines, formatCliJsonFailure, isExpectedCliError },
{ isJsonOutputModeActive },
{ runCliWithExitFinalization },
{ withCliProcessScope },
{ installDistEsmResolveFastPath: installFastPath },
{ formatUncaughtError },
{ runFatalErrorHooks },
{
installUnhandledRejectionHandler,
isBenignUncaughtExceptionError,
isUncaughtExceptionHandled,
},
] = await Promise.all([
import("./cli/failure-output.js"),
import("./cli/json-output-mode.js"),
import("./cli/one-shot-exit.js"),
import("./cli/runtime-cleanup-scope.js"),
import("./entry.esm-resolve-fast-path.js"),
import("./infra/errors.js"),
import("./infra/fatal-error-hooks.js"),
import("./infra/unhandled-rejections.js"),
]);
installFastPath(import.meta.url);
const { defaultRuntime, restoreRuntimeTerminalState } = await import("./runtime.js");
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.