diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index be1a6200040..2e8bd1c884a 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -59,20 +59,41 @@ function removeNewSignalListeners( } } -async function withIsolatedSignals(run: () => Promise) { - 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, +) { + 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 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(); 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((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[0]["start"], runtime: runtime as unknown as Parameters[0]["runtime"], }); await startedFirst; + const sigusr1 = captureSignal("SIGUSR1"); + const sigterm = captureSignal("SIGTERM"); expect(start).toHaveBeenCalledTimes(1); await new Promise((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((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[0]["start"], runtime: runtime as unknown as Parameters[0]["runtime"], lockPort: 18789, }); + await new Promise((resolve) => setImmediate(resolve)); + const sigusr1 = captureSignal("SIGUSR1"); + const sigterm = captureSignal("SIGTERM"); + + sigusr1(); + await new Promise((resolve) => setImmediate(resolve)); + sigusr1(); await new Promise((resolve) => setImmediate(resolve)); - process.emit("SIGUSR1"); - await new Promise((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); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 2d78ec46500..dfbcc6b028a 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -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 {