openclaw/test/scripts/watch-node.test.ts
RoboClaw e96f6921f3
fix(dev): preserve active replies when stopping the Gateway (#182281)
* fix(dev): honor Gateway drain budgets in source launchers

Use the shared Gateway service stop budget for foreground source runs and allow the outer watcher to finish the same drain during shutdown or replacement. Preserve the short generic-command and build deadlines and existing Mantis lifecycle authorization.

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>

* fix(dev): preserve active replies when stopping the Gateway

Worked on by:
- @steipete

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
OpenClaw-Publication: 971c0fff-ebe1-4ae9-b9a0-519321c380e1

---------

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
2026-10-24 13:59:09 +08:00

96 lines
2.7 KiB
TypeScript

import { EventEmitter } from "node:events";
import { afterEach, expect, it, vi } from "vitest";
import { runWatch, watchReady } from "./watch-node.test-support.js";
class FakeProcess extends EventEmitter {
execPath = process.execPath;
pid = 12345;
}
class FakeChild extends EventEmitter {
signals: string[] = [];
constructor(public pid?: number) {
super();
}
kill(signal: string): boolean {
this.signals.push(signal);
if (signal === "SIGKILL") {
this.emit("exit", null, "SIGKILL");
}
return true;
}
}
afterEach(() => vi.useRealTimers());
function start(
children: FakeChild[],
signalProcess?: (pid: number, signal: NodeJS.Signals | 0) => void,
env?: NodeJS.ProcessEnv,
) {
vi.useFakeTimers();
const fakeProcess = new FakeProcess();
const run = runWatch({
args: ["gateway"],
createWatcher: () => ({ close: async () => {} }),
env,
process: fakeProcess as unknown as NodeJS.Process,
signalProcess,
spawn: () => children.shift() as never,
});
return { run, fakeProcess };
}
async function expectEscalation(
{ run, fakeProcess }: ReturnType<typeof start>,
child: FakeChild,
graceMs: number,
) {
let resolvedCode: Awaited<typeof run> | undefined;
const settled = run.then((code) => (resolvedCode = code));
fakeProcess.emit("SIGTERM");
await vi.advanceTimersByTimeAsync(graceMs - 1);
expect(resolvedCode).toBeUndefined();
expect(child.signals).toEqual(["SIGTERM"]);
await vi.advanceTimersByTimeAsync(1);
await expect(settled).resolves.toBe(process.platform === "win32" ? 143 : "SIGKILL");
expect(child.signals).toEqual(["SIGTERM", "SIGKILL"]);
}
it.each([false, true])(
"waits and escalates when the child ignores SIGTERM (repair=%s)",
async (repair) => {
const runner = new FakeChild();
const child = repair ? new FakeChild() : runner;
const children = repair ? [runner, child] : [child];
const watch = start(children, undefined, repair ? {} : undefined);
await watchReady();
if (repair) {
runner.emit("exit", 1, null);
expect(children).toHaveLength(0);
await watchReady();
}
await expectEscalation(watch, child, repair ? 5_000 : 335_000);
},
);
it("force-cleans the child process group when the leader exits after shutdown", async () => {
const child = new FakeChild(4_242);
const groupSignals: Array<[number, string | number]> = [];
const { run, fakeProcess } = start([child], (pid, signal) => {
groupSignals.push([pid, signal]);
});
await watchReady();
fakeProcess.emit("SIGTERM");
expect(groupSignals).toEqual([[-4_242, "SIGTERM"]]);
child.emit("exit", 0, null);
await expect(run).resolves.toBe(143);
expect(groupSignals).toEqual([
[-4_242, "SIGTERM"],
[-4_242, "SIGKILL"],
]);
});