test: fix windows runtime and restart loop harnesses

This commit is contained in:
Peter Steinberger
2026-03-09 07:22:16 +00:00
committed by Vincent Koc
parent dbabaa0fb2
commit 2b8828ea46
2 changed files with 92 additions and 42 deletions

View File

@@ -59,20 +59,41 @@ function removeNewSignalListeners(
}
}
async function withIsolatedSignals(run: () => Promise<void>) {
const beforeSigterm = new Set(
process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>,
);
const beforeSigint = new Set(process.listeners("SIGINT") as Array<(...args: unknown[]) => void>);
const beforeSigusr1 = new Set(
process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>,
);
function addedSignalListener(
signal: NodeJS.Signals,
existing: Set<(...args: unknown[]) => void>,
): (() => void) | null {
const listeners = process.listeners(signal) as Array<(...args: unknown[]) => void>;
for (let i = listeners.length - 1; i >= 0; i -= 1) {
const listener = listeners[i];
if (listener && !existing.has(listener)) {
return listener as () => void;
}
}
return null;
}
async function withIsolatedSignals(
run: (helpers: { captureSignal: (signal: NodeJS.Signals) => () => void }) => Promise<void>,
) {
const existingListeners = {
SIGTERM: new Set(process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>),
SIGINT: new Set(process.listeners("SIGINT") as Array<(...args: unknown[]) => void>),
SIGUSR1: new Set(process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>),
} satisfies Record<NodeJS.Signals, Set<(...args: unknown[]) => void>>;
const captureSignal = (signal: NodeJS.Signals) => {
const listener = addedSignalListener(signal, existingListeners[signal]);
if (!listener) {
throw new Error(`expected new ${signal} listener`);
}
return () => listener();
};
try {
await run();
await run({ captureSignal });
} finally {
removeNewSignalListeners("SIGTERM", beforeSigterm);
removeNewSignalListeners("SIGINT", beforeSigint);
removeNewSignalListeners("SIGUSR1", beforeSigusr1);
removeNewSignalListeners("SIGTERM", existingListeners.SIGTERM);
removeNewSignalListeners("SIGINT", existingListeners.SIGINT);
removeNewSignalListeners("SIGUSR1", existingListeners.SIGUSR1);
}
}
@@ -144,10 +165,11 @@ describe("runGatewayLoop", () => {
it("exits 0 on SIGTERM after graceful close", async () => {
vi.clearAllMocks();
await withIsolatedSignals(async () => {
await withIsolatedSignals(async ({ captureSignal }) => {
const { close, runtime, exited } = await createSignaledLoopHarness();
const sigterm = captureSignal("SIGTERM");
process.emit("SIGTERM");
sigterm();
await expect(exited).resolves.toBe(0);
expect(close).toHaveBeenCalledWith({
@@ -161,7 +183,7 @@ describe("runGatewayLoop", () => {
it("restarts after SIGUSR1 even when drain times out, and resets lanes for the new iteration", async () => {
vi.clearAllMocks();
await withIsolatedSignals(async () => {
await withIsolatedSignals(async ({ captureSignal }) => {
getActiveTaskCount.mockReturnValueOnce(2).mockReturnValueOnce(0);
waitForActiveTasks.mockResolvedValueOnce({ drained: false });
@@ -171,6 +193,8 @@ describe("runGatewayLoop", () => {
const closeFirst = vi.fn(async () => {});
const closeSecond = vi.fn(async () => {});
const closeThird = vi.fn(async () => {});
const { runtime, exited } = createRuntimeWithExitSignal();
const start = vi.fn<StartServer>();
let resolveFirst: (() => void) | null = null;
@@ -191,24 +215,28 @@ describe("runGatewayLoop", () => {
return { close: closeSecond };
});
start.mockRejectedValueOnce(new Error("stop-loop"));
let resolveThird: (() => void) | null = null;
const startedThird = new Promise<void>((resolve) => {
resolveThird = resolve;
});
start.mockImplementationOnce(async () => {
resolveThird?.();
return { close: closeThird };
});
const { runGatewayLoop } = await import("./run-loop.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const loopPromise = runGatewayLoop({
void runGatewayLoop({
start: start as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
runtime: runtime as unknown as Parameters<typeof runGatewayLoop>[0]["runtime"],
});
await startedFirst;
const sigusr1 = captureSignal("SIGUSR1");
const sigterm = captureSignal("SIGTERM");
expect(start).toHaveBeenCalledTimes(1);
await new Promise<void>((resolve) => setImmediate(resolve));
process.emit("SIGUSR1");
sigusr1();
await startedSecond;
expect(start).toHaveBeenCalledTimes(2);
@@ -224,9 +252,10 @@ describe("runGatewayLoop", () => {
expect(markGatewaySigusr1RestartHandled).toHaveBeenCalledTimes(1);
expect(resetAllLanes).toHaveBeenCalledTimes(1);
process.emit("SIGUSR1");
sigusr1();
await expect(loopPromise).rejects.toThrow("stop-loop");
await startedThird;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closeSecond).toHaveBeenCalledWith({
reason: "gateway restarting",
restartExpectedMs: 1500,
@@ -235,13 +264,20 @@ describe("runGatewayLoop", () => {
expect(markGatewayDraining).toHaveBeenCalledTimes(2);
expect(resetAllLanes).toHaveBeenCalledTimes(2);
expect(acquireGatewayLock).toHaveBeenCalledTimes(3);
sigterm();
await expect(exited).resolves.toBe(0);
expect(closeThird).toHaveBeenCalledWith({
reason: "gateway stopping",
restartExpectedMs: null,
});
});
});
it("releases the lock before exiting on spawned restart", async () => {
vi.clearAllMocks();
await withIsolatedSignals(async () => {
await withIsolatedSignals(async ({ captureSignal }) => {
const lockRelease = vi.fn(async () => {});
acquireGatewayLock.mockResolvedValueOnce({
release: lockRelease,
@@ -255,11 +291,12 @@ describe("runGatewayLoop", () => {
const exitCallOrder: string[] = [];
const { runtime, exited } = await createSignaledLoopHarness(exitCallOrder);
const sigusr1 = captureSignal("SIGUSR1");
lockRelease.mockImplementation(async () => {
exitCallOrder.push("lockRelease");
});
process.emit("SIGUSR1");
sigusr1();
await exited;
expect(lockRelease).toHaveBeenCalled();
@@ -271,40 +308,45 @@ describe("runGatewayLoop", () => {
it("forwards lockPort to initial and restart lock acquisitions", async () => {
vi.clearAllMocks();
await withIsolatedSignals(async () => {
await withIsolatedSignals(async ({ captureSignal }) => {
const closeFirst = vi.fn(async () => {});
const closeSecond = vi.fn(async () => {});
restartGatewayProcessWithFreshPid.mockReturnValueOnce({ mode: "disabled" });
const closeThird = vi.fn(async () => {});
const { runtime, exited } = createRuntimeWithExitSignal();
const start = vi
.fn()
.mockResolvedValueOnce({ close: closeFirst })
.mockResolvedValueOnce({ close: closeSecond })
.mockRejectedValueOnce(new Error("stop-loop"));
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
.mockResolvedValueOnce({ close: closeThird });
const { runGatewayLoop } = await import("./run-loop.js");
const loopPromise = runGatewayLoop({
void runGatewayLoop({
start: start as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
runtime: runtime as unknown as Parameters<typeof runGatewayLoop>[0]["runtime"],
lockPort: 18789,
});
await new Promise<void>((resolve) => setImmediate(resolve));
const sigusr1 = captureSignal("SIGUSR1");
const sigterm = captureSignal("SIGTERM");
sigusr1();
await new Promise<void>((resolve) => setImmediate(resolve));
sigusr1();
await new Promise<void>((resolve) => setImmediate(resolve));
process.emit("SIGUSR1");
await new Promise<void>((resolve) => setImmediate(resolve));
process.emit("SIGUSR1");
await expect(loopPromise).rejects.toThrow("stop-loop");
expect(acquireGatewayLock).toHaveBeenNthCalledWith(1, { port: 18789 });
expect(acquireGatewayLock).toHaveBeenNthCalledWith(2, { port: 18789 });
expect(acquireGatewayLock).toHaveBeenNthCalledWith(3, { port: 18789 });
sigterm();
await expect(exited).resolves.toBe(0);
});
});
it("exits when lock reacquire fails during in-process restart fallback", async () => {
vi.clearAllMocks();
await withIsolatedSignals(async () => {
await withIsolatedSignals(async ({ captureSignal }) => {
const lockRelease = vi.fn(async () => {});
acquireGatewayLock
.mockResolvedValueOnce({
@@ -317,7 +359,8 @@ describe("runGatewayLoop", () => {
});
const { start, exited } = await createSignaledLoopHarness();
process.emit("SIGUSR1");
const sigusr1 = captureSignal("SIGUSR1");
sigusr1();
await expect(exited).resolves.toBe(1);
expect(acquireGatewayLock).toHaveBeenCalledTimes(2);

View File

@@ -229,9 +229,16 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.runtime}-path-`));
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const runtimePath = path.join(binDir, params.runtime);
fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
fs.chmodSync(runtimePath, 0o755);
const runtimePath =
process.platform === "win32"
? path.join(binDir, `${params.runtime}.cmd`)
: path.join(binDir, params.runtime);
const runtimeBody =
process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n";
fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 });
if (process.platform !== "win32") {
fs.chmodSync(runtimePath, 0o755);
}
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
try {