fix(launcher): keep the Node compile-cache path short on Windows (#162841)

2026.9.7 lengthened the compile-cache marker from <mtime>-<size> to build-<buildId>, 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
This commit is contained in:
Peter Steinberger 2026-10-01 11:29:41 -07:00 • committed by GitHub
parent 0dd332fa73
commit 6e987af9bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 103 additions and 26 deletions

View file

@ -76,6 +76,10 @@ Packaged OpenClaw uses `NODE_COMPILE_CACHE/openclaw/<version>/<build>` 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

View file

@ -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<void>;

View file

@ -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) {

View file

@ -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

View file

@ -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") };

View file

@ -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.
}

View file

@ -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;
}

View file

@ -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<string, string>();
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;
}
});