From 4514a731700a21005b5a6f802c71e1c583055fa0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 08:39:30 +0100 Subject: [PATCH] fix: preserve live runtime deps temp dirs --- CHANGELOG.md | 1 + scripts/stage-bundled-plugin-runtime-deps.mjs | 54 ++++++++++++++- .../stage-bundled-plugin-runtime-deps.test.ts | 67 +++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf48b30b0e..ad42f520df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon. - macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar. - Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain. +- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc. - Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng. - Plugins/Windows: normalize lazy plugin service override imports before Node ESM loading so drive-letter browser-control module paths no longer fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Fixes #72573; supersedes #72599 and #72582. Thanks @llzzww316, @feineryonah-byte, and @WuKongAI-CMU. - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index db8a5eafd7c..df5bdc8fa5a 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -8,6 +8,7 @@ import { resolveNpmRunner } from "./npm-runner.mjs"; const TRANSIENT_TEMP_REMOVE_ERROR_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); const TEMP_REMOVE_RETRY_DELAYS_MS = [10, 25, 50]; +const TEMP_OWNER_FILE = "owner.json"; function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); @@ -48,13 +49,26 @@ function makeTempDir(parentDir, prefix) { return fs.mkdtempSync(path.join(parentDir, prefix)); } +function writeRuntimeDepsTempOwner(tempDir) { + writeJson(path.join(tempDir, TEMP_OWNER_FILE), { + pid: process.pid, + createdAtMs: Date.now(), + }); +} + +function makeOwnedTempDir(parentDir, prefix) { + const tempDir = makeTempDir(parentDir, prefix); + writeRuntimeDepsTempOwner(tempDir); + return tempDir; +} + function sanitizeTempPrefixSegment(value) { const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-"); return normalized.length > 0 ? normalized : "plugin"; } function makePluginOwnedTempDir(pluginDir, label) { - return makeTempDir(pluginDir, `.openclaw-runtime-deps-${label}-`); + return makeOwnedTempDir(pluginDir, `.openclaw-runtime-deps-${label}-`); } function assertPathIsNotSymlink(targetPath, label) { @@ -84,6 +98,7 @@ function replaceDirAtomically(targetPath, sourcePath) { try { if (fs.existsSync(targetPath)) { fs.renameSync(targetPath, backupPath); + writeRuntimeDepsTempOwner(backupPath); movedExistingTarget = true; } fs.renameSync(sourcePath, targetPath); @@ -100,7 +115,7 @@ function writeJsonAtomically(targetPath, value) { assertPathIsNotSymlink(targetPath, "write runtime deps stamp"); const targetParentDir = path.dirname(targetPath); fs.mkdirSync(targetParentDir, { recursive: true }); - const tempDir = makeTempDir( + const tempDir = makeOwnedTempDir( targetParentDir, `.openclaw-runtime-deps-stamp-${sanitizeTempPrefixSegment(path.basename(targetPath))}-`, ); @@ -954,6 +969,35 @@ function readRuntimeDepsStamp(stampPath) { } } +function readRuntimeDepsTempOwner(tempDir) { + try { + const owner = readJson(path.join(tempDir, TEMP_OWNER_FILE)); + return owner && typeof owner === "object" ? owner : null; + } catch { + return null; + } +} + +function isLiveProcess(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function shouldRemoveRuntimeDepsTempDir(tempDir) { + const owner = readRuntimeDepsTempOwner(tempDir); + if (!owner || typeof owner.pid !== "number") { + return true; + } + return !isLiveProcess(owner.pid); +} + function removeStaleRuntimeDepsTempDirs(pluginDir) { if (!fs.existsSync(pluginDir)) { return; @@ -961,6 +1005,9 @@ function removeStaleRuntimeDepsTempDirs(pluginDir) { for (const entry of fs.readdirSync(pluginDir, { withFileTypes: true })) { if (entry.name.startsWith(".openclaw-runtime-deps-")) { const targetPath = path.join(pluginDir, entry.name); + if (!shouldRemoveRuntimeDepsTempDir(targetPath)) { + continue; + } for (let attempt = 0; attempt <= TEMP_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) { try { removePathIfExists(targetPath); @@ -1243,7 +1290,10 @@ export function stageBundledPluginRuntimeDeps(params = {}) { } export const __testing = { + removeStaleRuntimeDepsTempDirs, + replaceDirAtomically, runNpmInstall, + writeRuntimeDepsTempOwner, }; if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index a03facfbf25..fa543db54d1 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -327,6 +327,73 @@ describe("stageBundledPluginRuntimeDeps", () => { ); }); + it("keeps runtime deps temp dirs owned by a live build process", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "left-pad": "1.3.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const activeTempDir = path.join(pluginDir, ".openclaw-runtime-deps-stage-active"); + fs.mkdirSync(activeTempDir, { recursive: true }); + stageBundledPluginRuntimeDepsTesting.writeRuntimeDepsTempOwner(activeTempDir); + fs.writeFileSync(path.join(activeTempDir, "marker.txt"), "active\n", "utf8"); + + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + installPluginRuntimeDepsImpl: ({ fingerprint, stampPath }: RuntimeDepsStampParams) => { + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(nodeModulesDir, "marker.txt"), "installed\n", "utf8"); + writeRuntimeDepsStamp(stampPath, fingerprint); + }, + }); + + expect(fs.readFileSync(path.join(activeTempDir, "marker.txt"), "utf8")).toBe("active\n"); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( + "installed\n", + ); + }); + + it("restores atomically replaced dirs when concurrent cleanup runs during rename failure", () => { + const parentDir = createTempDir("openclaw-runtime-deps-replace-"); + const targetPath = path.join(parentDir, "node_modules"); + const sourcePath = path.join(parentDir, "source-node_modules"); + fs.mkdirSync(targetPath, { recursive: true }); + fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8"); + fs.mkdirSync(sourcePath, { recursive: true }); + fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8"); + + const realRenameSync = fs.renameSync.bind(fs); + let backupPath: string | null = null; + vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => { + const oldPathString = String(oldPath); + const newPathString = String(newPath); + if ( + oldPathString === targetPath && + path.basename(newPathString).startsWith(".openclaw-runtime-deps-backup-") + ) { + backupPath = newPathString; + return realRenameSync(oldPath, newPath); + } + if (oldPathString === sourcePath && newPathString === targetPath) { + expect(backupPath).not.toBeNull(); + stageBundledPluginRuntimeDepsTesting.removeStaleRuntimeDepsTempDirs(parentDir); + expect(fs.existsSync(path.join(backupPath ?? "", "marker.txt"))).toBe(true); + throw new Error("rename failed after backup"); + } + return realRenameSync(oldPath, newPath); + }); + + expect(() => + stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath), + ).toThrow("rename failed after backup"); + + expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("original\n"); + }); + it("restages when installed root runtime dependency contents change", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {