diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 4e26a6526..a3cf8efa3 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -91,6 +91,42 @@ function createRuntimeWithExitSignal(exitCallOrder?: string[]) { } describe("runGatewayLoop", () => { + it("exits 0 on SIGTERM after graceful close", async () => { + vi.clearAllMocks(); + + await withIsolatedSignals(async () => { + const close = vi.fn(async () => {}); + let resolveStarted: (() => void) | null = null; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + const start = vi.fn(async () => { + resolveStarted?.(); + return { close }; + }); + const { runtime, exited } = createRuntimeWithExitSignal(); + + vi.resetModules(); + const { runGatewayLoop } = await import("./run-loop.js"); + const _loopPromise = runGatewayLoop({ + start: start as unknown as Parameters[0]["start"], + runtime: runtime as unknown as Parameters[0]["runtime"], + }); + + await started; + await new Promise((resolve) => setImmediate(resolve)); + + process.emit("SIGTERM"); + + await expect(exited).resolves.toBe(0); + expect(close).toHaveBeenCalledWith({ + reason: "gateway stopping", + restartExpectedMs: null, + }); + expect(runtime.exit).toHaveBeenCalledWith(0); + }); + }); + it("restarts after SIGUSR1 even when drain times out, and resets lanes for the new iteration", async () => { vi.clearAllMocks(); diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 0824195a2..6a4df1db7 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -1,161 +1,8 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; - -const waitForReady = async ( - proc: ReturnType, - chunksOut: string[], - chunksErr: string[], - timeoutMs: number, -) => { - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const stdout = chunksOut.join(""); - const stderr = chunksErr.join(""); - cleanup(); - reject( - new Error( - `timeout waiting for gateway to start\n` + - `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, - ), - ); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timer); - proc.off("exit", onExit); - proc.off("message", onMessage); - proc.stdout?.off("data", onStdout); - }; - - const onExit = () => { - const stdout = chunksOut.join(""); - const stderr = chunksErr.join(""); - cleanup(); - reject( - new Error( - `gateway exited before ready (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` + - `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, - ), - ); - }; - - const onMessage = (msg: unknown) => { - if (msg && typeof msg === "object" && "ready" in msg) { - cleanup(); - resolve(); - } - }; - - const onStdout = (chunk: unknown) => { - if (String(chunk).includes("READY")) { - cleanup(); - resolve(); - } - }; - - proc.once("exit", onExit); - proc.on("message", onMessage); - proc.stdout?.on("data", onStdout); - }); -}; +import { describe, it } from "vitest"; describe("gateway SIGTERM", () => { - let child: ReturnType | null = null; - - afterEach(() => { - if (!child || child.killed) { - return; - } - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - child = null; - }); - - it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-test-")); - const out: string[] = []; - const err: string[] = []; - - const nodeBin = process.execPath; - const env = { - ...process.env, - OPENCLAW_NO_RESPAWN: "1", - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_SKIP_CHANNELS: "1", - OPENCLAW_SKIP_GMAIL_WATCHER: "1", - OPENCLAW_SKIP_CRON: "1", - OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1", - OPENCLAW_SKIP_CANVAS_HOST: "1", - }; - const bootstrapPath = path.join(stateDir, "openclaw-entry-bootstrap.cjs"); - const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts"); - const jitiPath = require.resolve("jiti"); - fs.writeFileSync( - bootstrapPath, - [ - `const jiti = require(${JSON.stringify(jitiPath)})(__filename);`, - `const { runGatewayLoop } = jiti(${JSON.stringify(runLoopPath)});`, - "(async () => {", - " await runGatewayLoop({", - " start: async () => {", - ' process.stdout.write("READY\\\\n");', - " if (process.send) process.send({ ready: true });", - " const keepAlive = setInterval(() => {}, 1000);", - " return { close: async () => clearInterval(keepAlive) };", - " },", - " runtime: { exit: (code) => process.exit(code) },", - " });", - "})().catch((err) => {", - " console.error(err);", - " process.exitCode = 1;", - "});", - ].join("\n"), - "utf8", - ); - const childArgs = [bootstrapPath]; - - child = spawn(nodeBin, childArgs, { - cwd: process.cwd(), - env, - stdio: ["ignore", "pipe", "pipe", "ipc"], - }); - - const proc = child; - if (!proc) { - throw new Error("failed to spawn gateway"); - } - - child.stdout?.setEncoding("utf8"); - child.stderr?.setEncoding("utf8"); - child.stdout?.on("data", (d) => out.push(String(d))); - child.stderr?.on("data", (d) => err.push(String(d))); - - await waitForReady(proc, out, err, 150_000); - - proc.kill("SIGTERM"); - - const result = await new Promise<{ - code: number | null; - signal: NodeJS.Signals | null; - }>((resolve) => proc.once("exit", (code, signal) => resolve({ code, signal }))); - - if (result.code !== 0 && !(result.code === null && result.signal === "SIGTERM")) { - const stdout = out.join(""); - const stderr = err.join(""); - throw new Error( - `expected exit code 0, got code=${String(result.code)} signal=${String(result.signal)}\n` + - `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, - ); - } - if (result.code === null && result.signal === "SIGTERM") { - return; - } - expect(result.signal).toBeNull(); + it.skip("covered by runGatewayLoop signal tests in src/cli/gateway-cli/run-loop.test.ts", () => { + // Kept as a placeholder to document why the old child-process integration + // case was retired: it duplicated run-loop signal coverage at high runtime cost. }); });