From 4d1d0cd0211d99d4ef30eed5d68835ddd587638e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 07:21:04 +0100 Subject: [PATCH] fix: stabilize gateway watch runtime deps --- extensions/anthropic/package.json | 3 ++ scripts/stage-bundled-plugin-runtime-deps.mjs | 36 ++++++++++++- .../stage-bundled-plugin-runtime-deps.test.ts | 50 ++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index 4fc8f8e8d59..9ef7fcff69f 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -11,6 +11,9 @@ "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { + "bundle": { + "stageRuntimeDependencies": true + }, "extensions": [ "./index.ts" ] diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index bcdd274879b..63ebe614c39 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -6,6 +6,9 @@ import { pathToFileURL } from "node:url"; import semverSatisfies from "semver/functions/satisfies.js"; 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]; + function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } @@ -25,6 +28,22 @@ function removePathIfExists(targetPath) { fs.rmSync(targetPath, { recursive: true, force: true }); } +function isTransientTempRemoveError(error) { + return ( + !!error && + typeof error === "object" && + typeof error.code === "string" && + TRANSIENT_TEMP_REMOVE_ERROR_CODES.has(error.code) + ); +} + +function sleepSync(ms) { + if (!Number.isFinite(ms) || ms <= 0) { + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + function makeTempDir(parentDir, prefix) { return fs.mkdtempSync(path.join(parentDir, prefix)); } @@ -907,7 +926,22 @@ function removeStaleRuntimeDepsTempDirs(pluginDir) { } for (const entry of fs.readdirSync(pluginDir, { withFileTypes: true })) { if (entry.name.startsWith(".openclaw-runtime-deps-")) { - removePathIfExists(path.join(pluginDir, entry.name)); + const targetPath = path.join(pluginDir, entry.name); + for (let attempt = 0; attempt <= TEMP_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) { + try { + removePathIfExists(targetPath); + break; + } catch (error) { + if (!isTransientTempRemoveError(error)) { + throw error; + } + const delay = TEMP_REMOVE_RETRY_DELAYS_MS[attempt]; + if (delay === undefined) { + break; + } + sleepSync(delay); + } + } } } } diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index dc1273bc3f7..f745ac988f5 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { collectRuntimeDependencyInstallManifest, collectRuntimeDependencyInstallSpecs, @@ -16,6 +16,10 @@ type RuntimeDepsStampParams = { }; describe("stageBundledPluginRuntimeDeps", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + function createBundledPluginFixture(params: { packageJson: Record; pluginId?: string; @@ -253,6 +257,50 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe("2\n"); }); + it("retries stale temp dir cleanup races before staging runtime deps", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "left-pad": "1.3.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const staleTempDir = path.join(pluginDir, ".openclaw-runtime-deps-copy-stale"); + fs.mkdirSync(staleTempDir, { recursive: true }); + fs.writeFileSync(path.join(staleTempDir, "marker.txt"), "stale\n", "utf8"); + const realRmSync = fs.rmSync.bind(fs); + let cleanupAttempts = 0; + vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + if (String(target) === staleTempDir && cleanupAttempts === 0) { + cleanupAttempts += 1; + const error = new Error("Directory not empty") as NodeJS.ErrnoException; + error.code = "ENOTEMPTY"; + throw error; + } + if (String(target) === staleTempDir) { + cleanupAttempts += 1; + } + return realRmSync(target, options); + }); + + 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(cleanupAttempts).toBe(2); + expect(fs.existsSync(staleTempDir)).toBe(false); + expect(fs.readFileSync(path.join(pluginDir, "node_modules", "marker.txt"), "utf8")).toBe( + "installed\n", + ); + }); + it("restages when installed root runtime dependency contents change", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {