mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
0dd332fa73
commit
6e987af9bc
8 changed files with 103 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
25
openclaw.mjs
25
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
|
||||
|
|
|
|||
|
|
@ -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") };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue