From a18c31437727c7a319ede155202c435286c6d8c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 13:17:58 -0700 Subject: [PATCH] fix(install): preserve shared Node compile caches (#113837) --- scripts/postinstall-bundled-plugins.d.mts | 1 - scripts/postinstall-bundled-plugins.mjs | 57 +---- src/entry.compile-cache.test.ts | 31 +++ .../postinstall-bundled-plugins.test.ts | 201 ++++++------------ 4 files changed, 99 insertions(+), 191 deletions(-) diff --git a/scripts/postinstall-bundled-plugins.d.mts b/scripts/postinstall-bundled-plugins.d.mts index 3c0b8f03fe84..12a29c6a0920 100644 --- a/scripts/postinstall-bundled-plugins.d.mts +++ b/scripts/postinstall-bundled-plugins.d.mts @@ -28,7 +28,6 @@ export function runPluginRegistryPostinstallMigration( ): Promise; export function isSourceCheckoutRoot(params: unknown): unknown; export function pruneBundledPluginSourceNodeModules(params?: Record): void; -export function pruneOpenClawCompileCache(params?: Record): void; export function runBundledPluginPostinstall(params?: Record): void; export function isDirectPostinstallInvocation(params?: Record): boolean; export const MAX_INSTALLED_DIST_SCAN_ENTRIES: 100000; diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index b97a538926eb..7cf0909ac439 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -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({ diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts index c1e2d5f9f51a..5658b84608e3 100644 --- a/src/entry.compile-cache.test.ts +++ b/src/entry.compile-cache.test.ts @@ -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 }); diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index af7e3b905377..1e78a81d3246 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -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, });