diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 9f3adece797..2863601ef8a 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -28,6 +28,8 @@ const INSTALL_TIMEOUT_MS = readPositiveInt( ); const RPC_TIMEOUT_MS = readPositiveInt(process.env.OPENCLAW_KITCHEN_SINK_RPC_CALL_MS, 60000); const MAX_RSS_MIB = readPositiveInt(process.env.OPENCLAW_KITCHEN_SINK_MAX_RSS_MIB, 2048); +const GATEWAY_TEARDOWN_GRACE_MS = 10000; +const GATEWAY_TEARDOWN_KILL_GRACE_MS = 2000; const OUTPUT_CAPTURE_CHARS = readPositiveInt( process.env.OPENCLAW_KITCHEN_SINK_OUTPUT_CAPTURE_CHARS, 1024 * 1024, @@ -537,18 +539,34 @@ async function startGateway(runner, port, env, logPath) { return child; } -async function stopGateway(child) { - if (!child || child.exitCode !== null) { +export async function stopGateway(child, options = {}) { + if (!child || child.exitCode !== null || child.signalCode !== null) { return; } + const teardownGraceMs = Math.max(0, options.teardownGraceMs ?? GATEWAY_TEARDOWN_GRACE_MS); + const killGraceMs = Math.max(0, options.killGraceMs ?? GATEWAY_TEARDOWN_KILL_GRACE_MS); + const exited = new Promise((resolve) => child.once("exit", resolve)); + const waitForExit = async (ms) => + child.exitCode !== null || child.signalCode !== null + ? true + : await Promise.race([exited.then(() => true), delay(ms).then(() => false)]); + signalGateway(child, "SIGTERM"); - const started = Date.now(); - while (child.exitCode === null && Date.now() - started < 10000) { - await delay(100); + if (await waitForExit(teardownGraceMs)) { + return; } - if (child.exitCode === null) { - signalGateway(child, "SIGKILL"); + signalGateway(child, "SIGKILL"); + if (await waitForExit(killGraceMs)) { + return; } + releaseUnsettledGatewayChild(child); +} + +function releaseUnsettledGatewayChild(child) { + child.stdin?.destroy?.(); + child.stdout?.destroy?.(); + child.stderr?.destroy?.(); + child.unref?.(); } function signalGateway(child, signal) { diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts index 42de35e4369..68336d316db 100644 --- a/test/scripts/kitchen-sink-rpc-walk.test.ts +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -15,6 +16,7 @@ import { runCommand, sampleProcess, sampleWindowsProcessByPort, + stopGateway, summarizeProcessSamples, usesBuiltOpenClawEntry, } from "../../scripts/e2e/kitchen-sink-rpc-walk.mjs"; @@ -39,6 +41,36 @@ describe("kitchen-sink RPC isolated state", () => { }); }); +describe("kitchen-sink RPC gateway teardown", () => { + it("releases gateway handles when the process ignores teardown signals", async () => { + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null; + kill: ReturnType; + signalCode: NodeJS.Signals | null; + stderr: { destroy: ReturnType }; + stdin: { destroy: ReturnType }; + stdout: { destroy: ReturnType }; + unref: ReturnType; + }; + child.exitCode = null; + child.signalCode = null; + child.kill = vi.fn(() => true); + child.stderr = { destroy: vi.fn() }; + child.stdin = { destroy: vi.fn() }; + child.stdout = { destroy: vi.fn() }; + child.unref = vi.fn(); + + await stopGateway(child, { killGraceMs: 1, teardownGraceMs: 1 }); + + expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); + expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); + expect(child.stdin.destroy).toHaveBeenCalledOnce(); + expect(child.stdout.destroy).toHaveBeenCalledOnce(); + expect(child.stderr.destroy).toHaveBeenCalledOnce(); + expect(child.unref).toHaveBeenCalledOnce(); + }); +}); + describe("kitchen-sink RPC command output capture", () => { it("keeps a bounded tail and tracks truncated output", () => { const first = appendBoundedOutput({ text: "", truncatedChars: 0 }, "abcdef", 5);