From 6e987af9bc9b446f3ef6f03049a611be1a005aec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 11:29:41 -0700 Subject: [PATCH] fix(launcher): keep the Node compile-cache path short on Windows (#162841) 2026.9.7 lengthened the compile-cache marker from - to build-, which pushed cache paths on Windows into the directory-length window where Node 24's module.enableCompileCache() never returns (nodejs/node#66438); every OpenClaw process start could then spin at full CPU. The shared cache owner now uses a 16-character hash of the full build id as the marker, refuses Windows cache paths over 200 characters with a recorded warning (cache off for that process), and no longer propagates an unsafe inherited cache path to children. Legacy namespace handling and POSIX behavior are unchanged; native Windows/Node 24.18 completes the packaged CLI with a 220-character TEMP in 2.4 s. Closes #162821 --- docs/help/environment.md | 4 ++++ node-compile-cache.d.mts | 3 ++- node-compile-cache.mjs | 19 +++++++++++++++--- openclaw.mjs | 25 ++++++++++++++---------- src/entry.compile-cache.test.ts | 29 ++++++++++++++++++++++++++++ src/entry.compile-cache.ts | 4 +++- src/infra/node-compile-cache-env.ts | 17 +++++++++++++--- src/infra/node-compile-cache.test.ts | 28 +++++++++++++++++++-------- 8 files changed, 103 insertions(+), 26 deletions(-) diff --git a/docs/help/environment.md b/docs/help/environment.md index c7b4e4ac6ba9..3948bcf4c7e7 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -76,6 +76,10 @@ Packaged OpenClaw uses `NODE_COMPILE_CACHE/openclaw//` when directory otherwise. Child processes reuse the same build namespace. Source checkouts keep their existing cache-disable policy. +Release builds use a stable 16-character hash of the build ID. On Windows, +OpenClaw skips cache paths longer than 200 characters with a warning and disables +unsafe inherited caches for child processes, avoiding a Node startup hang. + The compile-cache bootstrap owner starts best-effort maintenance in a background worker that does not keep CLI commands alive. Node permission mode skips this maintenance because workers do not inherit its filesystem restrictions; cache diff --git a/node-compile-cache.d.mts b/node-compile-cache.d.mts index 3fd58021e31e..f7240acff75f 100644 --- a/node-compile-cache.d.mts +++ b/node-compile-cache.d.mts @@ -1,5 +1,6 @@ export function resolveOpenClawCompileCacheDirectory(params: { installRoot: string; env?: NodeJS.ProcessEnv; -}): string; +}): string | undefined; +export function resolveSafeNodeCompileCacheDirectory(directory: string): string | undefined; export function maintainOpenClawCompileCache(directory: string): Promise; diff --git a/node-compile-cache.mjs b/node-compile-cache.mjs index 9debae01e6bb..075bdb77a87d 100644 --- a/node-compile-cache.mjs +++ b/node-compile-cache.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFileSync, statSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -7,7 +8,7 @@ import { isMainThread, Worker, workerData } from "node:worker_threads"; const MAX_BYTES = 512 * 1024 * 1024; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000; -const BUILD_MARKER_RE = /^(?:\d+-\d+|no-package-json|build-[A-Za-z0-9._-]+)$/; +const BUILD_MARKER_RE = /^(?:[a-f0-9]{16}|\d+-\d+|no-package-json|build-[A-Za-z0-9._-]+)$/; const sanitize = (value) => { const segment = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); return segment && segment !== "." && segment !== ".." ? segment : "unknown"; @@ -30,7 +31,7 @@ export function resolveOpenClawCompileCacheDirectory({ installRoot, env = proces readFileSync(path.join(installRoot, "dist", "build-info.json"), "utf8"), ); if (typeof build.buildId === "string" && build.buildId.trim()) { - marker = `build-${sanitize(build.buildId).slice(0, 96)}`; + marker = createHash("sha256").update(build.buildId).digest("hex").slice(0, 16); } } catch { // Older packages use the installation metadata above. @@ -44,7 +45,19 @@ export function resolveOpenClawCompileCacheDirectory({ installRoot, env = proces ) { base = path.dirname(path.dirname(path.dirname(base))); } - return path.join(base, "openclaw", version, marker); + return resolveSafeNodeCompileCacheDirectory(path.join(base, "openclaw", version, marker)); +} + +export function resolveSafeNodeCompileCacheDirectory(directory) { + // Node can hang at 240/245 characters (and 283 for other path structures): + // https://github.com/nodejs/node/issues/66438. Leave room for Node's cache leaf. + if (process.platform === "win32" && path.resolve(directory).length > 200) { + process.stderr.write( + "[openclaw] Compile cache disabled: Windows cache path exceeds 200 characters.\n", + ); + return undefined; + } + return directory; } export async function maintainOpenClawCompileCache(directory) { diff --git a/openclaw.mjs b/openclaw.mjs index 215af2f03603..f8f5c5267e4b 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -149,6 +149,9 @@ const respawnWithPackagedCompileCacheIfNeeded = () => { return false; } const desiredDirectory = resolvePackagedCompileCacheDirectory(); + if (!desiredDirectory) { + return false; + } const desired = path.resolve(desiredDirectory); if ( path.resolve(currentDirectory) === desired || @@ -710,16 +713,18 @@ if (isBrowserNativeHostInvocation) { ) { try { const directory = resolvePackagedCompileCacheDirectory(); - const baseDirectory = path.resolve(directory); - const result = module.enableCompileCache(directory); - void maintainOpenClawCompileCache(directory); - const enabled = module.constants?.compileCacheStatus?.ENABLED; - if (enabled !== undefined && result?.status === enabled) { - // Bootstrap adapter for src/infra/node-compile-cache-env.ts: preserve the first - // successful input without importing runtime code before cache activation. - const key = Symbol.for("openclaw.nodeCompileCacheBase"); - const owner = (globalThis[key] ??= {}); - owner.baseDirectory ??= baseDirectory; + if (directory) { + const baseDirectory = path.resolve(directory); + const result = module.enableCompileCache(directory); + void maintainOpenClawCompileCache(directory); + const enabled = module.constants?.compileCacheStatus?.ENABLED; + if (enabled !== undefined && result?.status === enabled) { + // Bootstrap adapter for src/infra/node-compile-cache-env.ts: preserve the first + // successful input without importing runtime code before cache activation. + const key = Symbol.for("openclaw.nodeCompileCacheBase"); + const owner = (globalThis[key] ??= {}); + owner.baseDirectory ??= baseDirectory; + } } } catch { // Ignore errors diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts index 3196803090d3..652680059cbd 100644 --- a/src/entry.compile-cache.test.ts +++ b/src/entry.compile-cache.test.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { expectDefined, readStringValue } from "@openclaw/normalization-core"; import { @@ -55,6 +56,7 @@ import { resolveEntryInstallRoot, respawnWithoutOpenClawCompileCacheIfNeeded, } from "./entry.compile-cache.js"; +import { resolveNodeCompileCacheEnv } from "./infra/node-compile-cache-env.js"; function enabledDirectory(callIndex = 0): string { const [directory] = expectDefined(enableCompileCache.mock.calls[callIndex], "cache enable call"); @@ -150,6 +152,33 @@ describe("entry compile cache", () => { expect(path.basename(directory)).toMatch(/^\d+-\d+$/); }); + it("skips cache activation with a warning when Windows TEMP makes the path too long", () => { + vi.spyOn(os, "tmpdir").mockReturnValue(path.join(root, "x".repeat(200))); + withMockedPlatform("win32", () => { + enableOpenClawCompileCache({ env: {}, installRoot: root }); + }); + expect(enableCompileCache).not.toHaveBeenCalled(); + expect(writeStderr).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("Compile cache disabled: Windows cache path exceeds 200 characters"), + ); + }); + + it.each([200, 201])("bounds Windows child cache paths at 200 characters: %s", (length) => { + const directory = path.join(root, "x".repeat(length - root.length - 1)); + const env = { NODE_COMPILE_CACHE: directory, KEEP: "unchanged" }; + withMockedPlatform("win32", () => { + const childEnv = resolveNodeCompileCacheEnv(env); + if (length === 200) { + expect(childEnv).toBe(env); + expect(writeStderr).not.toHaveBeenCalled(); + } else { + expect(childEnv).toEqual({ NODE_DISABLE_COMPILE_CACHE: "1", KEEP: "unchanged" }); + expect(writeStderr).toHaveBeenCalledOnce(); + } + }); + expect(env.NODE_COMPILE_CACHE).toBe(directory); + }); + it("retires a replaced installation without deleting other applications' compile caches", async () => { const packageJsonPath = path.join(root, "package.json"); const env = { NODE_COMPILE_CACHE: path.join(root, ".node-cache") }; diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts index a84fc35a3ebc..8ebe2e2479bc 100644 --- a/src/entry.compile-cache.ts +++ b/src/entry.compile-cache.ts @@ -167,7 +167,9 @@ export function enableOpenClawCompileCache(params: { } try { const directory = resolveOpenClawCompileCacheDirectory(params); - enableOwnedNodeCompileCache(directory); + if (directory) { + enableOwnedNodeCompileCache(directory); + } } catch { // Best-effort only; never block startup. } diff --git a/src/infra/node-compile-cache-env.ts b/src/infra/node-compile-cache-env.ts index 57abfbc07f49..6f1e73246bda 100644 --- a/src/infra/node-compile-cache-env.ts +++ b/src/infra/node-compile-cache-env.ts @@ -1,6 +1,9 @@ import * as module from "node:module"; import path from "node:path"; -import { maintainOpenClawCompileCache } from "../../node-compile-cache.mjs"; +import { + maintainOpenClawCompileCache, + resolveSafeNodeCompileCacheDirectory, +} from "../../node-compile-cache.mjs"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; // The launcher publishes this same fact before importing built runtime chunks. @@ -23,10 +26,18 @@ export function enableOwnedNodeCompileCache(directory: string): void { } export function resolveNodeCompileCacheEnv(env = process.env): NodeJS.ProcessEnv { - if (env.NODE_COMPILE_CACHE !== undefined || env.NODE_DISABLE_COMPILE_CACHE !== undefined) { + if (env.NODE_DISABLE_COMPILE_CACHE !== undefined) { return env; } // Getter and ALREADY_ENABLED directories are Node-owned leaves, not child cache bases. - const directory = compileCacheOwner().baseDirectory; + const directory = env.NODE_COMPILE_CACHE ?? compileCacheOwner().baseDirectory; + if (directory && !resolveSafeNodeCompileCacheDirectory(directory)) { + const disabled: NodeJS.ProcessEnv = { ...env, NODE_DISABLE_COMPILE_CACHE: "1" }; + delete disabled.NODE_COMPILE_CACHE; + return disabled; + } + if (env.NODE_COMPILE_CACHE !== undefined) { + return env; + } return directory ? { ...env, NODE_COMPILE_CACHE: directory } : env; } diff --git a/src/infra/node-compile-cache.test.ts b/src/infra/node-compile-cache.test.ts index 6f1c4fc3d4d7..f3ccd34bef46 100644 --- a/src/infra/node-compile-cache.test.ts +++ b/src/infra/node-compile-cache.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, expect, it } from "vitest"; import { maintainOpenClawCompileCache, @@ -38,19 +39,30 @@ it("keeps inherited cache namespaces flat and retires superseded builds", async await fs.mkdir(path.join(root, "dist")); await fs.writeFile(path.join(root, "package.json"), '{"version":"2026.9.6"}'); const base = path.join(root, "cache"); - let inherited = base; + let inherited = path.join(base, "openclaw", "2026.9.6", "build-legacy"); + const directories = new Map(); for (let invocation = 0; invocation < 30; invocation++) { - const buildId = invocation % 2 === 0 ? "build-a" : "build-b"; + const buildId = `2026.9.7-release-c074824a27c${invocation % 2}-2026-09-29T23-33-45.013Z`; await fs.writeFile(path.join(root, "dist", "build-info.json"), JSON.stringify({ buildId })); - const directory = resolveOpenClawCompileCacheDirectory({ - installRoot: root, - env: { NODE_COMPILE_CACHE: inherited }, - }); - expect(directory).toBe(path.join(base, "openclaw", "2026.9.6", `build-${buildId}`)); + const directory = expectDefined( + resolveOpenClawCompileCacheDirectory({ + installRoot: root, + env: { NODE_COMPILE_CACHE: inherited }, + }), + "compile cache directory", + ); + expect(path.dirname(directory)).toBe(path.join(base, "openclaw", "2026.9.6")); + expect(path.basename(directory)).toMatch(/^[a-f0-9]{16}$/); + if (directories.has(buildId)) { + expect(directory).toBe(directories.get(buildId)); + } else { + expect([...directories.values()]).not.toContain(directory); + directories.set(buildId, directory); + } await fs.mkdir(directory, { recursive: true }); await sparseFile(path.join(directory, "bytecode"), 32 * MiB); await maintainOpenClawCompileCache(directory); - expect(await fs.readdir(path.dirname(directory))).toEqual([`build-${buildId}`]); + expect(await fs.readdir(path.dirname(directory))).toEqual([path.basename(directory)]); inherited = directory; } });