Skip to content

Commit d886816

Browse files
committed
fix(e2e): bound bundled runtime HTTP probes
1 parent 8fa4fad commit d886816

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const COMMAND_TIMEOUT_MS = readPositiveInt(
2525
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_COMMAND_MS,
2626
120000,
2727
);
28+
const HTTP_PROBE_TIMEOUT_MS = readPositiveInt(
29+
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS,
30+
5000,
31+
);
2832

2933
function readPositiveInt(raw, fallback) {
3034
const parsed = Number.parseInt(String(raw || ""), 10);
@@ -279,7 +283,7 @@ async function waitForReady(params) {
279283
throw new Error(`gateway exited before ready\n${tailFile(params.logPath)}`);
280284
}
281285
try {
282-
const res = await fetch(`http://127.0.0.1:${params.port}/readyz`);
286+
const res = await fetchHttpProbeStatus(params.port, "/readyz");
283287
if (res.ok) {
284288
return;
285289
}
@@ -300,9 +304,31 @@ function logShowsGatewayReady(logPath) {
300304
return log.includes("[gateway] ready");
301305
}
302306

303-
async function httpOk(port, pathName) {
307+
async function fetchHttpProbeStatus(port, pathName, options = {}) {
308+
const { timeoutMs = HTTP_PROBE_TIMEOUT_MS } = options;
309+
const controller = new AbortController();
310+
const clearProbeTimer = timeoutMs
311+
? setTimeout(() => {
312+
controller.abort();
313+
}, timeoutMs)
314+
: undefined;
315+
try {
316+
const res = await fetch(`http://127.0.0.1:${port}${pathName}`, {
317+
signal: controller.signal,
318+
});
319+
const status = { ok: res.ok, status: res.status };
320+
await res.body?.cancel().catch(() => {});
321+
return status;
322+
} finally {
323+
if (clearProbeTimer) {
324+
clearTimeout(clearProbeTimer);
325+
}
326+
}
327+
}
328+
329+
export async function httpOk(port, pathName, options = {}) {
304330
try {
305-
const res = await fetch(`http://127.0.0.1:${port}${pathName}`);
331+
const res = await fetchHttpProbeStatus(port, pathName, options);
306332
return res.ok;
307333
} catch {
308334
return false;
@@ -314,7 +340,7 @@ async function assertHttpOk(port, pathName) {
314340
let lastError;
315341
while (Date.now() - started < RPC_READY_TIMEOUT_MS) {
316342
try {
317-
const res = await fetch(`http://127.0.0.1:${port}${pathName}`);
343+
const res = await fetchHttpProbeStatus(port, pathName);
318344
if (res.ok) {
319345
return;
320346
}
@@ -332,7 +358,7 @@ async function assertReadyzProbe(options) {
332358
let lastError;
333359
while (Date.now() - started < RPC_READY_TIMEOUT_MS) {
334360
try {
335-
const res = await fetch(`http://127.0.0.1:${options.port}/readyz`);
361+
const res = await fetchHttpProbeStatus(options.port, "/readyz");
336362
if (res.ok) {
337363
return;
338364
}

test/scripts/bundled-plugin-install-uninstall-probe.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { spawnSync } from "node:child_process";
22
import fs from "node:fs";
3+
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
4+
import { createServer as createNetServer, type Server as NetServer, type Socket } from "node:net";
35
import os from "node:os";
46
import path from "node:path";
57
import { pathToFileURL } from "node:url";
@@ -93,6 +95,37 @@ function runRuntimeSmoke(root: string, args: string[]) {
9395
});
9496
}
9597

98+
async function listenOnLoopback(server: HttpServer | NetServer): Promise<number> {
99+
return new Promise((resolve, reject) => {
100+
const onError = (error: Error) => {
101+
server.off("error", onError);
102+
reject(error);
103+
};
104+
server.once("error", onError);
105+
server.listen(0, "127.0.0.1", () => {
106+
server.off("error", onError);
107+
const address = server.address();
108+
if (!address || typeof address === "string") {
109+
reject(new Error("server did not bind to a TCP port"));
110+
return;
111+
}
112+
resolve(address.port);
113+
});
114+
});
115+
}
116+
117+
async function closeServer(server: HttpServer | NetServer): Promise<void> {
118+
await new Promise<void>((resolve, reject) => {
119+
server.close((error?: Error) => {
120+
if (error) {
121+
reject(error);
122+
return;
123+
}
124+
resolve();
125+
});
126+
});
127+
}
128+
96129
afterEach(() => {
97130
for (const dir of tempDirs.splice(0)) {
98131
fs.rmSync(dir, { force: true, recursive: true });
@@ -135,6 +168,47 @@ describe("bundled plugin install/uninstall probe", () => {
135168
expect(Date.now() - startedAt).toBeLessThan(2_500);
136169
});
137170

171+
it("accepts successful runtime HTTP probes", async () => {
172+
const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
173+
const server = createHttpServer((_request, response) => {
174+
response.writeHead(204);
175+
response.end();
176+
});
177+
178+
try {
179+
const port = await listenOnLoopback(server);
180+
181+
await expect(runtimeSmoke.httpOk(port, "/healthz", { timeoutMs: 1000 })).resolves.toBe(true);
182+
} finally {
183+
await closeServer(server);
184+
}
185+
});
186+
187+
it("bounds stalled runtime HTTP probes", async () => {
188+
const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
189+
const sockets = new Set<Socket>();
190+
const server = createNetServer((socket) => {
191+
sockets.add(socket);
192+
socket.on("close", () => {
193+
sockets.delete(socket);
194+
});
195+
});
196+
197+
try {
198+
const port = await listenOnLoopback(server);
199+
const startedAt = Date.now();
200+
201+
await expect(runtimeSmoke.httpOk(port, "/healthz", { timeoutMs: 100 })).resolves.toBe(false);
202+
203+
expect(Date.now() - startedAt).toBeLessThan(2_500);
204+
} finally {
205+
for (const socket of sockets) {
206+
socket.destroy();
207+
}
208+
await closeServer(server);
209+
}
210+
});
211+
138212
it("creates runtime smoke state with OPENCLAW_HOME at the test home", async () => {
139213
const runtimeSmoke = await import(pathToFileURL(runtimeSmokePath).href);
140214
const env = runtimeSmoke.createIsolatedStateEnv("runtime-env");

0 commit comments

Comments
 (0)