diff --git a/scripts/run-node.d.mts b/scripts/run-node.d.mts index eab53d43c61..34e48d847db 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -13,6 +13,15 @@ export function resolveBuildRequirement(deps: { configFiles: string[]; }): { shouldBuild: boolean; reason: string }; +export function acquireRunNodeBuildLock(deps: { + cwd: string; + args: readonly string[]; + env: NodeJS.ProcessEnv; + fs: unknown; + process: NodeJS.Process; + stderr: { write: (value: string) => void }; +}): Promise<() => void>; + export function runNodeMain(params?: { spawn?: ( cmd: string, diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index d6543311735..286c582e23a 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -459,7 +459,7 @@ const removeStaleBuildLock = (deps, lockDir, staleMs) => { } }; -const acquireRunNodeBuildLock = async (deps) => { +export const acquireRunNodeBuildLock = async (deps) => { const lockRoot = path.join(deps.cwd, ".artifacts"); const lockDir = path.join(lockRoot, "run-node-build.lock"); const timeoutMs = parsePositiveIntegerEnv( @@ -501,8 +501,29 @@ const acquireRunNodeBuildLock = async (deps) => { } catch { // Owner metadata is diagnostic only; the directory itself is the lock. } + let released = false; + const removeLockDir = () => { + if (released) { + return; + } + released = true; + try { + deps.fs.rmSync(lockDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; a follow-up waiter will fall back to staleness + // detection if the directory is still present. + } + }; + const onSignal = () => removeLockDir(); + const onExit = () => removeLockDir(); + deps.process.on("SIGINT", onSignal); + deps.process.on("SIGTERM", onSignal); + deps.process.on("exit", onExit); return () => { - deps.fs.rmSync(lockDir, { recursive: true, force: true }); + deps.process.off("SIGINT", onSignal); + deps.process.off("SIGTERM", onSignal); + deps.process.off("exit", onExit); + removeLockDir(); }; } catch (error) { if (error?.code !== "EEXIST") { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 0af519bda88..60e03daeeaf 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -3,7 +3,11 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { resolveBuildRequirement, runNodeMain } from "../../scripts/run-node.mjs"; +import { + acquireRunNodeBuildLock, + resolveBuildRequirement, + runNodeMain, +} from "../../scripts/run-node.mjs"; import { bundledDistPluginFile, bundledPluginFile, @@ -1071,4 +1075,80 @@ describe("run-node script", () => { expect(spawnCalls).toEqual([expectedBuildSpawn(), statusCommandSpawn()]); }); }); + + describe("acquireRunNodeBuildLock", () => { + const lockDeps = (tmp: string, fakeProcess: NodeJS.Process) => ({ + cwd: tmp, + args: ["status"], + env: { OPENCLAW_RUNNER_LOG: "0" }, + fs: fsSync, + process: fakeProcess, + stderr: { write: () => true } as unknown as NodeJS.WriteStream, + }); + + it("releases the lock directory when the wrapper receives SIGINT", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fsSync.existsSync(lockDir)).toBe(true); + + fakeProcess.emit("SIGINT"); + expect(fsSync.existsSync(lockDir)).toBe(false); + + // Normal release after signal must be a no-op, not throw. + expect(() => release()).not.toThrow(); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + expect(fakeProcess.listenerCount("exit")).toBe(0); + }); + }); + + it("releases the lock directory when the wrapper receives SIGTERM", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fsSync.existsSync(lockDir)).toBe(true); + + fakeProcess.emit("SIGTERM"); + expect(fsSync.existsSync(lockDir)).toBe(false); + expect(() => release()).not.toThrow(); + }); + }); + + it("releases the lock directory on process exit", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fsSync.existsSync(lockDir)).toBe(true); + + fakeProcess.emit("exit"); + expect(fsSync.existsSync(lockDir)).toBe(false); + expect(() => release()).not.toThrow(); + }); + }); + + it("detaches signal listeners after a normal release", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + const fakeProcess = createFakeProcess(); + const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock"); + + const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess)); + expect(fakeProcess.listenerCount("SIGINT")).toBe(1); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(1); + expect(fakeProcess.listenerCount("exit")).toBe(1); + + release(); + expect(fsSync.existsSync(lockDir)).toBe(false); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + expect(fakeProcess.listenerCount("exit")).toBe(0); + }); + }); + }); });