fix: prevent duplicate gateway watchers

This commit is contained in:
Peter Steinberger
2026-04-05 23:22:34 +01:00
parent e91405ebf9
commit cef64f0b5a
4 changed files with 260 additions and 11 deletions

View File

@@ -1,4 +1,5 @@
declare module "../../scripts/watch-node.mjs" {
export function resolveWatchLockPath(cwd: string, args?: string[]): string;
export function runWatchMain(params?: {
spawn?: (
cmd: string,
@@ -10,6 +11,9 @@ declare module "../../scripts/watch-node.mjs" {
args?: string[];
env?: NodeJS.ProcessEnv;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
signalProcess?: (pid: number, signal: NodeJS.Signals | 0) => void;
lockDisabled?: boolean;
}): Promise<number>;
}

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
@@ -12,6 +13,20 @@ const VOICE_CALL_MANIFEST = bundledPluginFile("voice-call", "openclaw.plugin.jso
const VOICE_CALL_PACKAGE = bundledPluginFile("voice-call", "package.json");
const VOICE_CALL_INDEX = bundledPluginFile("voice-call", "index.ts");
const VOICE_CALL_RUNTIME = bundledPluginFile("voice-call", "src/runtime.ts");
type WatchRunParams = NonNullable<Parameters<typeof runWatchMain>[0]> & {
lockDisabled?: boolean;
signalProcess?: (pid: number, signal: NodeJS.Signals | 0) => void;
sleep?: (ms: number) => Promise<void>;
};
const runWatch = (params: WatchRunParams) => runWatchMain(params);
const resolveTestWatchLockPath = (cwd: string, args: string[]) =>
path.join(
cwd,
".local",
"watch-node",
`${createHash("sha256").update(cwd).update("\0").update(args.join("\0")).digest("hex").slice(0, 12)}.json`,
);
const createFakeProcess = () =>
Object.assign(new EventEmitter(), {
@@ -39,11 +54,12 @@ describe("watch-node script", () => {
fs.mkdirSync(path.join(cwd, "src", "infra"), { recursive: true });
fs.mkdirSync(path.join(cwd, "extensions", "voice-call"), { recursive: true });
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
cwd,
createWatcher,
env: { PATH: "/usr/bin" },
lockDisabled: true,
now: () => 1700000000000,
process: fakeProcess,
spawn,
@@ -104,9 +120,10 @@ describe("watch-node script", () => {
it("terminates child on SIGINT and returns shell interrupt code", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -124,9 +141,10 @@ describe("watch-node script", () => {
it("terminates child on SIGTERM and returns shell terminate code", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -144,9 +162,10 @@ describe("watch-node script", () => {
it("returns the child exit code when the runner exits on its own", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force", "--help"],
createWatcher,
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -174,9 +193,10 @@ describe("watch-node script", () => {
const createWatcher = vi.fn(() => watcher);
const fakeProcess = createFakeProcess();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -195,13 +215,14 @@ describe("watch-node script", () => {
it("forces no-respawn for watch children even when supervisor hints are inherited", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
env: {
LAUNCH_JOB_LABEL: "ai.openclaw.gateway",
PATH: "/usr/bin",
},
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -255,9 +276,10 @@ describe("watch-node script", () => {
const createWatcher = vi.fn(() => watcher);
const fakeProcess = createFakeProcess();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -305,9 +327,10 @@ describe("watch-node script", () => {
it("kills child and exits when watcher emits an error", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const runPromise = runWatchMain({
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
lockDisabled: true,
process: fakeProcess,
spawn,
});
@@ -319,4 +342,66 @@ describe("watch-node script", () => {
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(watcher.close).toHaveBeenCalledTimes(1);
});
it("replaces an existing watcher lock holder before starting", async () => {
const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness();
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-watch-node-lock-"));
const lockPath = resolveTestWatchLockPath(cwd, ["gateway", "--force"]);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(
lockPath,
`${JSON.stringify({
pid: 2121,
command: "gateway --force",
createdAt: new Date(1_700_000_000_000).toISOString(),
cwd,
watchSession: "existing-session",
})}\n`,
"utf8",
);
let existingWatcherAlive = true;
const signalProcess = vi.fn((pid: number, signal: NodeJS.Signals | 0) => {
if (signal === 0) {
if (pid === 2121 && existingWatcherAlive) {
return;
}
throw Object.assign(new Error("ESRCH"), { code: "ESRCH" });
}
if (pid === 2121 && signal === "SIGTERM") {
existingWatcherAlive = false;
return;
}
throw new Error(`unexpected signal ${signal} for pid ${pid}`);
});
const runPromise = runWatch({
args: ["gateway", "--force"],
createWatcher,
cwd,
now: () => 1_700_000_000_000,
process: fakeProcess,
signalProcess,
sleep: async () => {},
spawn,
});
await new Promise((resolve) => setImmediate(resolve));
expect(signalProcess).toHaveBeenCalledWith(2121, "SIGTERM");
expect(spawn).toHaveBeenCalledTimes(1);
expect(JSON.parse(fs.readFileSync(lockPath, "utf8"))).toMatchObject({
pid: 4242,
command: "gateway --force",
watchSession: "1700000000000-4242",
});
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(fs.existsSync(lockPath)).toBe(false);
expect(watcher.close).toHaveBeenCalledTimes(1);
});
});