From cc343febfbbb97610f17339036ec3785f151dae0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 07:01:27 +0100 Subject: [PATCH] fix: tolerate runtime deps temp cleanup races --- src/plugins/bundled-runtime-deps.test.ts | 57 ++++++++++++++++++++++++ src/plugins/bundled-runtime-deps.ts | 7 ++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 1ba15e5ba07..c334e63a62b 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -261,6 +261,63 @@ describe("installBundledRuntimeDeps", () => { ); }); + it("does not fail an isolated runtime deps install when temp cleanup races", () => { + const installRoot = makeTempDir(); + const installExecutionRoot = makeTempDir(); + const realRmSync = fs.rmSync.bind(fs); + let blockedCleanup = false; + vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + if ( + !blockedCleanup && + path.basename(String(target)).startsWith(".openclaw-runtime-deps-copy-") + ) { + blockedCleanup = true; + const error = new Error("Directory not empty") as NodeJS.ErrnoException; + error.code = "ENOTEMPTY"; + throw error; + } + return realRmSync(target, options); + }); + spawnSyncMock.mockImplementation((_command, _args, options) => { + const cwd = String(options?.cwd ?? ""); + fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true }); + fs.writeFileSync( + path.join(cwd, "node_modules", "tokenjuice", "package.json"), + JSON.stringify({ name: "tokenjuice", version: "0.6.1" }), + ); + return { + pid: 123, + output: [], + stdout: "", + stderr: "", + signal: null, + status: 0, + }; + }); + + expect(() => + installBundledRuntimeDeps({ + installRoot, + installExecutionRoot, + missingSpecs: ["tokenjuice@0.6.1"], + env: {}, + }), + ).not.toThrow(); + + expect(blockedCleanup).toBe(true); + expect( + JSON.parse( + fs.readFileSync( + path.join(installRoot, "node_modules", "tokenjuice", "package.json"), + "utf8", + ), + ), + ).toEqual({ + name: "tokenjuice", + version: "0.6.1", + }); + }); + it("rejects invalid install specs before spawning npm", () => { expect(() => createBundledRuntimeDepsInstallArgs(["tokenjuice@https://evil.example/t.tgz"]), diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 9ec9c11f7d3..5dca7aa081b 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -382,7 +382,12 @@ function replaceNodeModulesDir(targetDir: string, sourceDir: string): void { fs.rmSync(targetDir, { recursive: true, force: true }); fs.renameSync(stagedDir, targetDir); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Stale temp dirs are swept at the next runtime-deps pass. Do not fail + // a node_modules replacement on a transient cleanup race. + } } }