fix(install): preserve shared Node compile caches (#113837)

This commit is contained in:
Peter Steinberger
2026-07-25 13:17:58 -07:00
committed by GitHub
parent 3e4fbe8329
commit a18c314377
4 changed files with 99 additions and 191 deletions
@@ -28,7 +28,6 @@ export function runPluginRegistryPostinstallMigration(
): Promise<unknown>;
export function isSourceCheckoutRoot(params: unknown): unknown;
export function pruneBundledPluginSourceNodeModules(params?: Record<string, unknown>): void;
export function pruneOpenClawCompileCache(params?: Record<string, unknown>): void;
export function runBundledPluginPostinstall(params?: Record<string, unknown>): void;
export function isDirectPostinstallInvocation(params?: Record<string, unknown>): boolean;
export const MAX_INSTALLED_DIST_SCAN_ENTRIES: 100000;
+1 -56
View File
@@ -20,7 +20,7 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve as pathResolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs";
@@ -111,8 +111,6 @@ const BAILEYS_MEDIA_UPLOAD_WITH_FETCH_DISPATCHER_REPLACEMENT = [
const BAILEYS_MEDIA_ONCE_IMPORT_RE = /import\s+\{\s*once\s*\}\s+from\s+['"]events['"]/u;
const BAILEYS_MEDIA_ASYNC_CONTEXT_RE =
/async\s+function\s+encryptedStream|encryptedStream\s*=\s*async/u;
const NODE_COMPILE_CACHE_VERSION_DIR_RE = /^v\d+\.\d+\.\d+-/u;
class InstalledDistScanLimitError extends Error {}
function normalizeRelativePath(filePath) {
@@ -905,53 +903,6 @@ function shouldRunBundledPluginPostinstall(params) {
return true;
}
function isCompileCachePrunePermissionDenied(error) {
return error?.code === "EACCES" || error?.code === "EPERM";
}
export function pruneOpenClawCompileCache(params = {}) {
const env = params.env ?? process.env;
const pathExists = params.existsSync ?? existsSync;
const readDir = params.readdirSync ?? readdirSync;
const remove = params.rmSync ?? rmSync;
const log = params.log ?? console;
const baseDirs = [
env.NODE_DISABLE_COMPILE_CACHE ? "" : env.NODE_COMPILE_CACHE,
join(tmpdir(), "node-compile-cache"),
].filter((value, index, values) => value && values.indexOf(value) === index);
for (const baseDir of baseDirs) {
if (!pathExists(baseDir)) {
continue;
}
try {
for (const entry of readDir(baseDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !NODE_COMPILE_CACHE_VERSION_DIR_RE.test(entry.name)) {
continue;
}
try {
remove(join(baseDir, entry.name), {
recursive: true,
force: true,
maxRetries: 2,
retryDelay: 100,
});
} catch (error) {
if (isCompileCachePrunePermissionDenied(error)) {
continue;
}
log.warn?.(`[postinstall] could not prune OpenClaw compile cache: ${String(error)}`);
}
}
} catch (error) {
if (isCompileCachePrunePermissionDenied(error)) {
continue;
}
log.warn?.(`[postinstall] could not prune OpenClaw compile cache: ${String(error)}`);
}
}
}
export function runBundledPluginPostinstall(params = {}) {
const env = params.env ?? process.env;
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
@@ -961,12 +912,6 @@ export function runBundledPluginPostinstall(params = {}) {
if (env?.[DISABLE_POSTINSTALL_ENV]?.trim()) {
return;
}
pruneOpenClawCompileCache({
env,
existsSync: pathExists,
rmSync: params.rmSync,
log,
});
if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) {
try {
pruneBundledPluginSourceNodeModules({
+31
View File
@@ -88,6 +88,37 @@ describe("entry compile cache", () => {
expect(path.basename(directory)).toMatch(/^\d+-\d+$/);
});
it("invalidates a replaced installation without deleting shared compile caches", async () => {
const root = tempDirs.make("openclaw-compile-cache-package-reinstall-");
const packageJsonPath = path.join(root, "package.json");
const cacheRoot = path.join(root, ".node-cache");
await fs.writeFile(packageJsonPath, '{"version":"2026.4.29"}\n', "utf8");
const originalDirectory = resolveOpenClawCompileCacheDirectory({
env: { NODE_COMPILE_CACHE: cacheRoot },
installRoot: root,
});
await fs.mkdir(originalDirectory, { recursive: true });
const originalCacheEntry = path.join(originalDirectory, "keep.txt");
await fs.writeFile(originalCacheEntry, "previous cached installation\n", "utf8");
await fs.writeFile(
packageJsonPath,
'{"version":"2026.4.29","installation":"replacement"}\n',
"utf8",
);
const replacementDirectory = resolveOpenClawCompileCacheDirectory({
env: { NODE_COMPILE_CACHE: cacheRoot },
installRoot: root,
});
expect(replacementDirectory).toContain(path.join("openclaw", "2026.4.29"));
expect(replacementDirectory).not.toBe(originalDirectory);
await expect(fs.readFile(originalCacheEntry, "utf8")).resolves.toBe(
"previous cached installation\n",
);
});
it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => {
const root = tempDirs.make("openclaw-compile-cache-respawn-");
await fs.mkdir(path.join(root, "src"), { recursive: true });
+67 -134
View File
@@ -1,8 +1,9 @@
// Postinstall Bundled Plugins tests cover postinstall bundled plugins script behavior.
import { existsSync as existsSyncOriginal, readFileSync as readFileSyncOriginal } from "node:fs";
import { spawnSync } from "node:child_process";
import { readFileSync as readFileSyncOriginal } from "node:fs";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts";
import {
@@ -11,7 +12,6 @@ import {
isSourceCheckoutRoot,
isDirectPostinstallInvocation,
MAX_INSTALLED_DIST_SCAN_ENTRIES,
pruneOpenClawCompileCache,
pruneInstalledPackageDist,
pruneLegacyPluginRuntimeDepsState,
pruneBundledPluginSourceNodeModules,
@@ -68,13 +68,6 @@ async function writeBaileysMediaFile(packageRoot: string, text: string) {
}
describe("bundled plugin postinstall", () => {
function existsSyncWithoutGlobalCompileCache(value: string) {
if (path.resolve(value) === path.join(tmpdir(), "node-compile-cache")) {
return false;
}
return existsSyncOriginal(value);
}
it("recognizes direct invocation through symlinked temp prefixes", () => {
const realpathSync = vi.fn((value: string) =>
value.replace(/^\/var\/folders\//u, "/private/var/folders/"),
@@ -89,131 +82,72 @@ describe("bundled plugin postinstall", () => {
).toBe(true);
});
it("prunes Node versioned compile cache dirs during package postinstall", () => {
const configuredBase = path.join("/tmp", "openclaw-cache");
const defaultBase = path.join(tmpdir(), "node-compile-cache");
const removed: string[] = [];
const existsSync = vi.fn((value: string) => value === configuredBase || value === defaultBase);
const readdirSync = vi.fn((value: string) => {
if (value === configuredBase) {
return [
{ name: "v22.13.1-x64-efe9a9df-1001", isDirectory: () => true },
{ name: "openclaw", isDirectory: () => true },
{ name: "README", isDirectory: () => false },
];
it.each([
{ cacheMode: "disabled", disableCompileCache: "1" },
{ cacheMode: "enabled", disableCompileCache: undefined },
])(
"preserves shared default and configured Node caches during $cacheMode packaged postinstall",
async ({ disableCompileCache }) => {
const packageRoot = await createTempDirAsync("openclaw-packaged-compile-cache-");
const scriptRoot = path.join(packageRoot, "scripts");
const temporaryRoot = path.join(packageRoot, "temporary");
const configuredCacheRoot = path.join(packageRoot, "configured-node-cache");
const defaultCacheRoot = path.join(temporaryRoot, "node-compile-cache");
const sentinels = [
path.join(defaultCacheRoot, "v22.22.3-x64-another-app", "keep.txt"),
path.join(defaultCacheRoot, "v24.15.0-x64-other-install", "keep.txt"),
path.join(configuredCacheRoot, "v25.9.0-x64-another-app", "keep.txt"),
path.join(configuredCacheRoot, "v26.4.0-x64-other-install", "keep.txt"),
];
await fs.mkdir(path.join(scriptRoot, "lib"), { recursive: true });
await fs.mkdir(path.join(packageRoot, "home"), { recursive: true });
await fs.writeFile(
path.join(packageRoot, "package.json"),
'{"name":"openclaw","type":"module","version":"2026.7.2"}\n',
);
await fs.copyFile(
fileURLToPath(new URL("../../scripts/postinstall-bundled-plugins.mjs", import.meta.url)),
path.join(scriptRoot, "postinstall-bundled-plugins.mjs"),
);
await fs.copyFile(
fileURLToPath(new URL("../../scripts/lib/package-dist-imports.mjs", import.meta.url)),
path.join(scriptRoot, "lib", "package-dist-imports.mjs"),
);
for (const sentinel of sentinels) {
await fs.mkdir(path.dirname(sentinel), { recursive: true });
await fs.writeFile(sentinel, "owned by another Node application\n");
}
if (value === defaultBase) {
return [{ name: "v24.14.1-x64-efe9a9df-1001", isDirectory: () => true }];
const result = spawnSync(
process.execPath,
[path.join(scriptRoot, "postinstall-bundled-plugins.mjs")],
{
cwd: packageRoot,
encoding: "utf8",
env: {
...process.env,
HOME: path.join(packageRoot, "home"),
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: undefined,
OPENCLAW_HOME: path.join(packageRoot, "home"),
OPENCLAW_STATE_DIR: path.join(packageRoot, "state"),
STATE_DIRECTORY: undefined,
NODE_COMPILE_CACHE: configuredCacheRoot,
NODE_DISABLE_COMPILE_CACHE: disableCompileCache,
TEMP: temporaryRoot,
TMP: temporaryRoot,
TMPDIR: temporaryRoot,
},
},
);
expect(result.status, result.stderr).toBe(0);
for (const sentinel of sentinels) {
await expectPathExists(sentinel);
}
throw new Error(`unexpected readdir: ${value}`);
});
const rmSync = vi.fn((value: string) => {
removed.push(value);
});
pruneOpenClawCompileCache({
env: { NODE_COMPILE_CACHE: configuredBase },
existsSync,
readdirSync,
rmSync,
log: { warn: vi.fn() },
});
expect(removed).toEqual([
path.join(configuredBase, "v22.13.1-x64-efe9a9df-1001"),
path.join(defaultBase, "v24.14.1-x64-efe9a9df-1001"),
]);
expect(removed).not.toContain(path.join(configuredBase, "openclaw"));
for (const cacheDir of removed) {
expect(rmSync).toHaveBeenCalledWith(cacheDir, {
recursive: true,
force: true,
maxRetries: 2,
retryDelay: 100,
});
}
});
it("keeps pruning sibling compile cache dirs after one removal fails", () => {
const configuredBase = path.join("/tmp", "openclaw-cache");
const attempted: string[] = [];
const warn = vi.fn();
const firstCacheDir = path.join(configuredBase, "v22.13.1-x64-efe9a9df-1001");
const secondCacheDir = path.join(configuredBase, "v22.13.1-x64-efe9a9df-1002");
const rmSync = vi.fn((value: string) => {
attempted.push(value);
if (value === firstCacheDir) {
throw new Error("locked");
}
});
pruneOpenClawCompileCache({
env: { NODE_COMPILE_CACHE: configuredBase },
existsSync: vi.fn((value: string) => value === configuredBase),
readdirSync: vi.fn(() => [
{ name: path.basename(firstCacheDir), isDirectory: () => true },
{ name: path.basename(secondCacheDir), isDirectory: () => true },
]),
rmSync,
log: { warn },
});
expect(attempted).toEqual([firstCacheDir, secondCacheDir]);
expect(warn).toHaveBeenCalledWith(
"[postinstall] could not prune OpenClaw compile cache: Error: locked",
);
});
it("does not warn when compile-cache pruning hits EACCES or EPERM (shared caches)", () => {
const base = path.join("/tmp", "openclaw-shared-compile-cache");
const dirA = path.join(base, "v22.13.1-x64-efe9a9df-1001");
const dirB = path.join(base, "v22.13.1-x64-efe9a9df-1002");
const warn = vi.fn();
const rmSync = vi.fn((value: string) => {
if (value === dirA) {
throw Object.assign(new Error(`permission denied pruning ${value}`), { code: "EACCES" });
}
if (value === dirB) {
throw Object.assign(new Error(`operation not permitted pruning ${value}`), {
code: "EPERM",
});
}
});
pruneOpenClawCompileCache({
env: { NODE_COMPILE_CACHE: base },
existsSync: vi.fn((value: string) => value === base),
readdirSync: vi.fn(() => [
{ name: path.basename(dirA), isDirectory: () => true },
{ name: path.basename(dirB), isDirectory: () => true },
]),
rmSync,
log: { warn },
});
expect(rmSync).toHaveBeenCalledTimes(2);
expect(warn).not.toHaveBeenCalled();
});
it("does not warn when the compile-cache base directory cannot be listed (EACCES)", () => {
const base = path.join("/tmp", "openclaw-compile-cache-no-list");
const warn = vi.fn();
const rmSync = vi.fn();
const err = Object.assign(new Error(`EACCES: ${base}`), { code: "EACCES" });
pruneOpenClawCompileCache({
env: { NODE_COMPILE_CACHE: base },
existsSync: vi.fn(() => true),
readdirSync: vi.fn(() => {
throw err;
}),
rmSync,
log: { warn },
});
expect(rmSync).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
},
);
it("patches the Baileys upload helper dispatcher guard", async () => {
const packageRoot = await createTempDirAsync("openclaw-baileys-postinstall-");
@@ -631,7 +565,6 @@ describe("bundled plugin postinstall", () => {
STATE_DIRECTORY: systemState,
},
packageRoot,
existsSync: existsSyncWithoutGlobalCompileCache,
log,
});