fix(dev): release run-node build lock on SIGINT/SIGTERM/exit (#69785)

This commit is contained in:
Patrick Erichsen
2026-04-21 09:33:19 -07:00
committed by GitHub
parent e6e83e6ccf
commit acb27bac3a
3 changed files with 113 additions and 3 deletions
+9
View File
@@ -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,
+23 -2
View File
@@ -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") {
+81 -1
View File
@@ -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);
});
});
});
});