fix(e2e): bound kitchen sink gateway teardown

This commit is contained in:
Vincent Koc
2026-05-27 03:52:30 +02:00
parent 97541170ca
commit 81d22e8f53
2 changed files with 57 additions and 7 deletions

View File

@@ -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) {

View File

@@ -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<typeof vi.fn>;
signalCode: NodeJS.Signals | null;
stderr: { destroy: ReturnType<typeof vi.fn> };
stdin: { destroy: ReturnType<typeof vi.fn> };
stdout: { destroy: ReturnType<typeof vi.fn> };
unref: ReturnType<typeof vi.fn>;
};
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);