fix(gateway): bound e2e HTTP helper responses

This commit is contained in:
Vincent Koc
2026-05-27 03:13:35 +02:00
parent bba429831c
commit 6509da7555
3 changed files with 117 additions and 3 deletions

View File

@@ -15,7 +15,6 @@ Docs: https://docs.openclaw.ai
- Gateway: keep dev smoke scripts on the current protocol version and make the kitchen-sink RPC walk fail on dropped diagnostics or aggregate Gateway RSS spikes.
- Gateway: make the CPU scenario checker fail when completed Gateway runs report hot CPU observations instead of only writing them to artifacts.
- CLI: bound startup-memory probes so a hung startup command fails with timeout guidance instead of hanging the memory gate indefinitely.
## 2026.5.26
### Highlights

View File

@@ -0,0 +1,73 @@
import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { postJson } from "./gateway-e2e-harness.js";
let server: Server | undefined;
afterEach(async () => {
if (!server) {
return;
}
await new Promise<void>((resolve, reject) => {
server?.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
server = undefined;
});
async function listen(handler: Parameters<typeof createServer>[0]): Promise<string> {
server = createServer(handler);
await new Promise<void>((resolve) => {
server?.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not get a TCP address");
}
return `http://127.0.0.1:${address.port}`;
}
describe("postJson", () => {
it("times out stalled Gateway HTTP helpers", async () => {
const baseUrl = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.write('{"ok":');
});
await expect(postJson(`${baseUrl}/stall`, {}, undefined, { timeoutMs: 25 })).rejects.toThrow(
"timed out after 25ms",
);
});
it("uses a wall-clock timeout instead of an idle socket timeout", async () => {
const baseUrl = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
const interval = setInterval(() => {
res.write(" ");
}, 5);
res.on("close", () => {
clearInterval(interval);
});
});
await expect(postJson(`${baseUrl}/slow`, {}, undefined, { timeoutMs: 30 })).rejects.toThrow(
"timed out after 30ms",
);
});
it("rejects oversized Gateway HTTP helper responses", async () => {
const baseUrl = await listen((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: "x".repeat(128) }));
});
await expect(
postJson(`${baseUrl}/large`, {}, undefined, { maxResponseBytes: 32 }),
).rejects.toThrow("response exceeded 32 bytes");
});
});

View File

@@ -23,6 +23,13 @@ export type GatewayInstance = OpenClawTestInstance;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 10_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 15_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const POST_JSON_TIMEOUT_MS = 15_000;
const POST_JSON_MAX_RESPONSE_BYTES = 1024 * 1024;
export type PostJsonOptions = {
maxResponseBytes?: number;
timeoutMs?: number;
};
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const inst = await createOpenClawTestInstance({ name });
@@ -43,10 +50,32 @@ export async function postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
options: PostJsonOptions = {},
): Promise<{ status: number; json: unknown }> {
const payload = JSON.stringify(body);
const parsed = new URL(url);
const timeoutMs = options.timeoutMs ?? POST_JSON_TIMEOUT_MS;
const maxResponseBytes = options.maxResponseBytes ?? POST_JSON_MAX_RESPONSE_BYTES;
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
let settled = false;
let responseBytes = 0;
let timeout: NodeJS.Timeout | undefined;
const finish = (result: { status: number; json: unknown } | { error: Error }) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
if ("error" in result) {
reject(result.error);
return;
}
resolve(result);
};
const req = httpRequest(
{
method: "POST",
@@ -63,6 +92,14 @@ export async function postJson(
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
responseBytes += Buffer.byteLength(chunk, "utf8");
if (responseBytes > maxResponseBytes) {
const error = new Error(`POST ${url} response exceeded ${maxResponseBytes} bytes`);
req.destroy(error);
res.destroy(error);
finish({ error });
return;
}
data += chunk;
});
res.on("end", () => {
@@ -74,11 +111,16 @@ export async function postJson(
json = data;
}
}
resolve({ status: res.statusCode ?? 0, json });
finish({ status: res.statusCode ?? 0, json });
});
res.on("error", (error) => finish({ error }));
},
);
req.on("error", reject);
timeout = setTimeout(() => {
req.destroy(new Error(`POST ${url} timed out after ${timeoutMs}ms`));
}, timeoutMs);
timeout.unref?.();
req.on("error", (error) => finish({ error }));
req.write(payload);
req.end();
});