From 36d3722a964a4b8fe5fcb7e46989e1f4e86efda0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 23:27:59 +0100 Subject: [PATCH] fix(cli): disable source checkout compile cache --- CHANGELOG.md | 1 + openclaw.mjs | 45 ++++++++++- src/entry.compile-cache.test.ts | 109 ++++++++++++++++++++++++++ src/entry.compile-cache.ts | 114 +++++++++++++++++++++++++++ src/entry.ts | 24 +++--- test/openclaw-launcher.e2e.test.ts | 119 +++++++++++++++++++++++++++++ 6 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 src/entry.compile-cache.test.ts create mode 100644 src/entry.compile-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1a83278e2..3164382c354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber. - CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020. - Daemon/service: only emit hard-coded version-manager paths such as `~/.volta/bin`, `~/.asdf/shims`, `~/.bun/bin`, and fnm/pnpm fallbacks into gateway and node service PATHs when the directories exist, so `openclaw doctor` no longer flags `gateway.path.non-minimal` against a PATH the daemon just wrote. Env-driven roots and stable user-bin dirs remain unconditional. Fixes #71944; carries forward #71964. Thanks @Sanjays2402. +- CLI/startup: disable Node's module compile cache automatically for live source-checkout launchers so in-place `pnpm build` updates are visible to the next `openclaw` CLI invocation. Fixes #73037. Thanks @LouisGameDev. - Channels/commands: make generated `/dock-*` commands switch the active session reply route through `session.identityLinks` instead of falling through to normal chat. Fixes #69206; carries forward #73033. Thanks @clawbones and @michaelatamuk. - Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar. - Gateway/startup: keep primary-model startup prewarm on scoped metadata preparation, let native approval bootstraps retry outside channel startup, and skip the global hook runner when no `gateway_start` hook is registered, so clean post-ready sidecar work stays off the critical path. Refs #72846. Thanks @RayWoo, @livekm0309, and @mrz1836. diff --git a/openclaw.mjs b/openclaw.mjs index 62bea9e9487..b280daed5c9 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; import { fileURLToPath } from "node:url"; @@ -38,8 +39,48 @@ const ensureSupportedNodeVersion = () => { ensureSupportedNodeVersion(); +const isSourceCheckoutLauncher = () => + existsSync(new URL("./.git", import.meta.url)) || + existsSync(new URL("./src/entry.ts", import.meta.url)); + +const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined; +const isNodeCompileCacheRequested = () => + process.env.NODE_COMPILE_CACHE !== undefined && !isNodeCompileCacheDisabled(); + +const respawnWithoutCompileCacheIfNeeded = () => { + if (!isSourceCheckoutLauncher()) { + return false; + } + if (process.env.OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED === "1") { + return false; + } + if (!module.getCompileCacheDir?.() && !isNodeCompileCacheRequested()) { + return false; + } + const env = { + ...process.env, + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", + }; + delete env.NODE_COMPILE_CACHE; + const result = spawnSync( + process.execPath, + [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], + { + stdio: "inherit", + env, + }, + ); + if (result.error) { + throw result.error; + } + process.exit(result.status ?? 1); +}; + +respawnWithoutCompileCacheIfNeeded(); + // https://nodejs.org/api/module.html#module-compile-cache -if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) { +if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) { try { module.enableCompileCache(); } catch { diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts new file mode 100644 index 00000000000..87af57b24e2 --- /dev/null +++ b/src/entry.compile-cache.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js"; +import { + buildOpenClawCompileCacheRespawnPlan, + isSourceCheckoutInstallRoot, + resolveEntryInstallRoot, + shouldEnableOpenClawCompileCache, +} from "./entry.compile-cache.js"; + +describe("entry compile cache", () => { + const tempDirs: string[] = []; + + afterEach(() => { + cleanupTempDirs(tempDirs); + }); + + it("resolves install roots from source and dist entry paths", () => { + expect(resolveEntryInstallRoot("/repo/openclaw/src/entry.ts")).toBe("/repo/openclaw"); + expect(resolveEntryInstallRoot("/repo/openclaw/dist/entry.js")).toBe("/repo/openclaw"); + expect(resolveEntryInstallRoot("/pkg/openclaw/entry.js")).toBe("/pkg/openclaw"); + }); + + it("treats git and source entry markers as source checkouts", async () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-source-"); + await fs.writeFile(path.join(root, ".git"), "gitdir: .git/worktrees/openclaw\n", "utf8"); + + expect(isSourceCheckoutInstallRoot(root)).toBe(true); + }); + + it("disables compile cache for source-checkout installs", async () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-src-entry-"); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.writeFile(path.join(root, "src", "entry.ts"), "export {};\n", "utf8"); + + expect( + shouldEnableOpenClawCompileCache({ + env: {}, + installRoot: root, + }), + ).toBe(false); + }); + + it("keeps compile cache enabled for packaged installs unless disabled by env", () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-"); + + expect(shouldEnableOpenClawCompileCache({ env: {}, installRoot: root })).toBe(true); + expect( + shouldEnableOpenClawCompileCache({ + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + installRoot: root, + }), + ).toBe(false); + }); + + it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-"); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.writeFile(path.join(root, "src", "entry.ts"), "export {};\n", "utf8"); + + const plan = buildOpenClawCompileCacheRespawnPlan({ + currentFile: path.join(root, "dist", "entry.js"), + env: { NODE_COMPILE_CACHE: "/tmp/openclaw-cache" }, + execArgv: ["--no-warnings"], + execPath: "/usr/bin/node", + installRoot: root, + argv: ["/usr/bin/node", path.join(root, "dist", "entry.js"), "status", "--json"], + }); + + expect(plan).toEqual({ + command: "/usr/bin/node", + args: ["--no-warnings", path.join(root, "dist", "entry.js"), "status", "--json"], + env: { + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", + }, + }); + }); + + it("does not respawn packaged installs when NODE_COMPILE_CACHE is configured", () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-respawn-"); + + expect( + buildOpenClawCompileCacheRespawnPlan({ + currentFile: path.join(root, "dist", "entry.js"), + env: { NODE_COMPILE_CACHE: "/tmp/openclaw-cache" }, + installRoot: root, + }), + ).toBeUndefined(); + }); + + it("does not respawn source checkouts twice", async () => { + const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-once-"); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.writeFile(path.join(root, "src", "entry.ts"), "export {};\n", "utf8"); + + expect( + buildOpenClawCompileCacheRespawnPlan({ + currentFile: path.join(root, "dist", "entry.js"), + env: { + NODE_COMPILE_CACHE: "/tmp/openclaw-cache", + OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", + }, + installRoot: root, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts new file mode 100644 index 00000000000..c03399b51bc --- /dev/null +++ b/src/entry.compile-cache.ts @@ -0,0 +1,114 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { enableCompileCache, getCompileCacheDir } from "node:module"; +import path from "node:path"; + +export function resolveEntryInstallRoot(entryFile: string): string { + const entryDir = path.dirname(entryFile); + const entryParent = path.basename(entryDir); + return entryParent === "dist" || entryParent === "src" ? path.dirname(entryDir) : entryDir; +} + +export function isSourceCheckoutInstallRoot(installRoot: string): boolean { + return ( + existsSync(path.join(installRoot, ".git")) || + existsSync(path.join(installRoot, "src", "entry.ts")) + ); +} + +function isNodeCompileCacheDisabled(env: NodeJS.ProcessEnv | undefined): boolean { + return env?.NODE_DISABLE_COMPILE_CACHE !== undefined; +} + +function isNodeCompileCacheRequested(env: NodeJS.ProcessEnv | undefined): boolean { + return env?.NODE_COMPILE_CACHE !== undefined && !isNodeCompileCacheDisabled(env); +} + +export function shouldEnableOpenClawCompileCache(params: { + env?: NodeJS.ProcessEnv; + installRoot: string; +}): boolean { + if (isNodeCompileCacheDisabled(params.env)) { + return false; + } + return !isSourceCheckoutInstallRoot(params.installRoot); +} + +export type OpenClawCompileCacheRespawnPlan = { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; +}; + +export function buildOpenClawCompileCacheRespawnPlan(params: { + currentFile: string; + env?: NodeJS.ProcessEnv; + execArgv?: string[]; + execPath?: string; + installRoot: string; + argv?: string[]; + compileCacheDir?: string; +}): OpenClawCompileCacheRespawnPlan | undefined { + const env = params.env ?? process.env; + if (!isSourceCheckoutInstallRoot(params.installRoot)) { + return undefined; + } + if (env.OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED === "1") { + return undefined; + } + if (!params.compileCacheDir && !isNodeCompileCacheRequested(env)) { + return undefined; + } + const nextEnv: NodeJS.ProcessEnv = { + ...env, + NODE_DISABLE_COMPILE_CACHE: "1", + OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", + }; + delete nextEnv.NODE_COMPILE_CACHE; + return { + command: params.execPath ?? process.execPath, + args: [ + ...(params.execArgv ?? process.execArgv), + params.currentFile, + ...(params.argv ?? process.argv).slice(2), + ], + env: nextEnv, + }; +} + +export function respawnWithoutOpenClawCompileCacheIfNeeded(params: { + currentFile: string; + installRoot: string; +}): boolean { + const plan = buildOpenClawCompileCacheRespawnPlan({ + currentFile: params.currentFile, + installRoot: params.installRoot, + compileCacheDir: getCompileCacheDir?.(), + }); + if (!plan) { + return false; + } + const result = spawnSync(plan.command, plan.args, { + stdio: "inherit", + env: plan.env, + }); + if (result.error) { + throw result.error; + } + process.exit(result.status ?? 1); + return true; +} + +export function enableOpenClawCompileCache(params: { + env?: NodeJS.ProcessEnv; + installRoot: string; +}): void { + if (!shouldEnableOpenClawCompileCache(params)) { + return; + } + try { + enableCompileCache(); + } catch { + // Best-effort only; never block startup. + } +} diff --git a/src/entry.ts b/src/entry.ts index a7cab31b955..11a6db1d679 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -1,15 +1,19 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { enableCompileCache } from "node:module"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { isRootHelpInvocation } from "./cli/argv.js"; import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; +import { + enableOpenClawCompileCache, + resolveEntryInstallRoot, + respawnWithoutOpenClawCompileCacheIfNeeded, +} from "./entry.compile-cache.js"; import { buildCliRespawnPlan } from "./entry.respawn.js"; import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js"; -import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; +import { normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; import { installProcessWarningFilter } from "./infra/warning-filter.js"; @@ -43,17 +47,19 @@ if ( ) { // Imported as a dependency — skip all entry-point side effects. } else { + const entryFile = fileURLToPath(import.meta.url); + const installRoot = resolveEntryInstallRoot(entryFile); + respawnWithoutOpenClawCompileCacheIfNeeded({ + currentFile: entryFile, + installRoot, + }); process.title = "openclaw"; ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); normalizeEnv(); - if (!isTruthyEnvValue(process.env.NODE_DISABLE_COMPILE_CACHE)) { - try { - enableCompileCache(); - } catch { - // Best-effort only; never block startup. - } - } + enableOpenClawCompileCache({ + installRoot, + }); if (shouldForceReadOnlyAuthStore(process.argv)) { process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 6b573ad9c48..3282ef5a70b 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -19,6 +19,37 @@ async function addSourceTreeMarker(fixtureRoot: string): Promise { await fs.writeFile(path.join(fixtureRoot, "src", "entry.ts"), "export {};\n", "utf8"); } +async function addGitMarker(fixtureRoot: string): Promise { + await fs.writeFile(path.join(fixtureRoot, ".git"), "gitdir: .git/worktrees/openclaw\n", "utf8"); +} + +async function addCompileCacheProbe(fixtureRoot: string): Promise { + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import module from "node:module";', + "process.stdout.write(", + ' `${module.getCompileCacheDir?.() ? "cache:enabled" : "cache:disabled"};respawn:${process.env.OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED ?? "0"}`', + ");", + ].join("\n"), + "utf8", + ); +} + +function launcherEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env = { ...process.env, ...extra }; + delete env.NODE_COMPILE_CACHE; + delete env.NODE_DISABLE_COMPILE_CACHE; + for (const [key, value] of Object.entries(extra)) { + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + } + return env; +} + describe("openclaw launcher", () => { const fixtureRoots: string[] = []; @@ -36,6 +67,7 @@ describe("openclaw launcher", () => { const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { cwd: fixtureRoot, + env: launcherEnv(), encoding: "utf8", }); @@ -49,6 +81,7 @@ describe("openclaw launcher", () => { const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { cwd: fixtureRoot, + env: launcherEnv(), encoding: "utf8", }); @@ -62,6 +95,7 @@ describe("openclaw launcher", () => { const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { cwd: fixtureRoot, + env: launcherEnv(), encoding: "utf8", }); @@ -71,4 +105,89 @@ describe("openclaw launcher", () => { expect(result.stderr).toContain("pnpm install && pnpm build"); expect(result.stderr).toContain("github:openclaw/openclaw#"); }); + + it("keeps compile cache off for source-checkout launchers", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addSourceTreeMarker(fixtureRoot); + await addCompileCacheProbe(fixtureRoot); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv(), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("cache:disabled;respawn:0"); + }); + + it("respawns source-checkout launchers without inherited NODE_COMPILE_CACHE", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + await addCompileCacheProbe(fixtureRoot); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("cache:disabled;respawn:1"); + }); + + it.runIf(process.platform !== "win32")( + "respawns symlinked source-checkout launchers without inherited NODE_COMPILE_CACHE", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + await addCompileCacheProbe(fixtureRoot); + const linkParent = makeTempDir(fixtureRoots, "openclaw-launcher-link-"); + const linkedRoot = path.join(linkParent, "openclaw-linked"); + await fs.symlink(fixtureRoot, linkedRoot, "dir"); + + const result = spawnSync(process.execPath, [path.join(linkedRoot, "openclaw.mjs")], { + cwd: linkParent, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(linkParent, ".node-compile-cache"), + }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("cache:disabled;respawn:1"); + }, + ); + + it("does not respawn packaged launchers when NODE_COMPILE_CACHE is configured", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addCompileCacheProbe(fixtureRoot); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("cache:enabled;respawn:0"); + }); + + it("enables compile cache for packaged launchers", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addCompileCacheProbe(fixtureRoot); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv(), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("cache:enabled;respawn:0"); + }); });