Skip to content

Commit 34c3827

Browse files
committed
fix(e2e): close rpc rtt gateway log handles
1 parent 54fe0e7 commit 34c3827

3 files changed

Lines changed: 181 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
5959
- Release/CI/E2E: write package Telegram Docker artifacts to unique per-run directories by default so parallel live/RTT runs cannot overwrite evidence.
6060
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
6161
- Release/CI/E2E: retry generated temp-state cleanup after removal failures and route plugin lifecycle measurement edits to their owner tests.
62+
- Release/CI/E2E: close parent gateway log handles after spawning RPC RTT probes so repeated measurements do not leak file descriptors.
6263
- Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold.
6364
- Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`.
6465
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.

scripts/measure-rpc-rtt.mjs

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,17 @@ export async function waitForGatewayReady({
102102
child.once("exit", (code, signal) => {
103103
childExit = { code, signal };
104104
});
105+
const getChildExit = () =>
106+
childExit ??
107+
(child.exitCode != null || child.signalCode != null
108+
? { code: child.exitCode, signal: child.signalCode }
109+
: null);
105110
while (Date.now() - startedAt < readyTimeoutMs) {
106-
if (childExit) {
111+
const observedExit = getChildExit();
112+
if (observedExit) {
107113
const stderr = await fs.readFile(stderrPath, "utf8").catch(() => "");
108114
throw new Error(
109-
`gateway exited before readiness code=${childExit.code ?? "null"} signal=${childExit.signal ?? "null"}\n${stderr.slice(-4000)}`,
115+
`gateway exited before readiness code=${observedExit.code ?? "null"} signal=${observedExit.signal ?? "null"}\n${stderr.slice(-4000)}`,
110116
);
111117
}
112118
for (const endpoint of ["/readyz", "/healthz"]) {
@@ -144,6 +150,92 @@ async function stopGateway(child) {
144150
}
145151
}
146152

153+
async function closeFileHandles(handles) {
154+
const results = await Promise.allSettled(
155+
handles.filter(Boolean).map((handle) => handle.close()),
156+
);
157+
const failedClose = results.find((result) => result.status === "rejected");
158+
if (failedClose) {
159+
throw failedClose.reason;
160+
}
161+
}
162+
163+
export async function startGateway({
164+
configPath,
165+
env = process.env,
166+
openImpl = fs.open,
167+
port,
168+
repoRoot,
169+
spawnImpl = spawn,
170+
stderrPath,
171+
stdoutPath,
172+
tempRoot,
173+
token,
174+
}) {
175+
const stdout = await openImpl(stdoutPath, "w");
176+
let stderr;
177+
try {
178+
stderr = await openImpl(stderrPath, "w");
179+
} catch (error) {
180+
try {
181+
await closeFileHandles([stdout]);
182+
} catch {}
183+
throw error;
184+
}
185+
186+
let child;
187+
try {
188+
child = spawnImpl(
189+
"pnpm",
190+
[
191+
"openclaw",
192+
"gateway",
193+
"run",
194+
"--port",
195+
String(port),
196+
"--bind",
197+
"loopback",
198+
"--allow-unconfigured",
199+
],
200+
{
201+
cwd: repoRoot,
202+
env: {
203+
...env,
204+
HOME: path.join(tempRoot, "home"),
205+
XDG_CONFIG_HOME: path.join(tempRoot, "xdg-config"),
206+
XDG_DATA_HOME: path.join(tempRoot, "xdg-data"),
207+
XDG_CACHE_HOME: path.join(tempRoot, "xdg-cache"),
208+
OPENCLAW_CONFIG_PATH: configPath,
209+
OPENCLAW_STATE_DIR: path.join(tempRoot, "state"),
210+
OPENCLAW_GATEWAY_TOKEN: token,
211+
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
212+
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
213+
OPENCLAW_SKIP_CANVAS_HOST: "1",
214+
OPENCLAW_NO_RESPAWN: "1",
215+
OPENCLAW_TEST_FAST: "1",
216+
},
217+
stdio: ["ignore", stdout.fd, stderr.fd],
218+
},
219+
);
220+
} catch (error) {
221+
try {
222+
await closeFileHandles([stdout, stderr]);
223+
} catch {}
224+
throw error;
225+
}
226+
227+
try {
228+
await closeFileHandles([stdout, stderr]);
229+
} catch (error) {
230+
try {
231+
await stopGateway(child);
232+
} catch {}
233+
throw error;
234+
}
235+
236+
return child;
237+
}
238+
147239
function quantile(sorted, q) {
148240
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * q) - 1))];
149241
}
@@ -329,40 +421,15 @@ async function main() {
329421
2,
330422
)}\n`,
331423
);
332-
const stdout = await fs.open(stdoutPath, "w");
333-
const stderr = await fs.open(stderrPath, "w");
334-
gatewayChild = spawn(
335-
"pnpm",
336-
[
337-
"openclaw",
338-
"gateway",
339-
"run",
340-
"--port",
341-
String(port),
342-
"--bind",
343-
"loopback",
344-
"--allow-unconfigured",
345-
],
346-
{
347-
cwd: repoRoot,
348-
env: {
349-
...process.env,
350-
HOME: path.join(tempRoot, "home"),
351-
XDG_CONFIG_HOME: path.join(tempRoot, "xdg-config"),
352-
XDG_DATA_HOME: path.join(tempRoot, "xdg-data"),
353-
XDG_CACHE_HOME: path.join(tempRoot, "xdg-cache"),
354-
OPENCLAW_CONFIG_PATH: configPath,
355-
OPENCLAW_STATE_DIR: path.join(tempRoot, "state"),
356-
OPENCLAW_GATEWAY_TOKEN: token,
357-
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
358-
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
359-
OPENCLAW_SKIP_CANVAS_HOST: "1",
360-
OPENCLAW_NO_RESPAWN: "1",
361-
OPENCLAW_TEST_FAST: "1",
362-
},
363-
stdio: ["ignore", stdout.fd, stderr.fd],
364-
},
365-
);
424+
gatewayChild = await startGateway({
425+
configPath,
426+
port,
427+
repoRoot,
428+
stderrPath,
429+
stdoutPath,
430+
tempRoot,
431+
token,
432+
});
366433
await waitForGatewayReady({ child: gatewayChild, port, stderrPath });
367434

368435
const requireFromOpenClaw = createRequire(path.join(repoRoot, "package.json"));

test/scripts/measure-rpc-rtt.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,84 @@
11
import { EventEmitter } from "node:events";
22
import { describe, expect, it, vi } from "vitest";
3-
import { waitForGatewayReady } from "../../scripts/measure-rpc-rtt.mjs";
3+
import { startGateway, waitForGatewayReady } from "../../scripts/measure-rpc-rtt.mjs";
44

55
describe("scripts/measure-rpc-rtt.mjs", () => {
6+
it("closes parent gateway log handles after spawning", async () => {
7+
const child = Object.assign(new EventEmitter(), {
8+
exitCode: null,
9+
kill: vi.fn(),
10+
signalCode: null,
11+
});
12+
const stdout = { close: vi.fn().mockResolvedValue(undefined), fd: 41 };
13+
const stderr = { close: vi.fn().mockResolvedValue(undefined), fd: 42 };
14+
const openImpl = vi.fn().mockResolvedValueOnce(stdout).mockResolvedValueOnce(stderr);
15+
const spawnImpl = vi.fn().mockReturnValue(child);
16+
17+
await expect(
18+
startGateway({
19+
configPath: "/tmp/openclaw.json",
20+
env: { PATH: "/bin" },
21+
openImpl,
22+
port: 23456,
23+
repoRoot: "/repo",
24+
spawnImpl,
25+
stderrPath: "/tmp/stderr.log",
26+
stdoutPath: "/tmp/stdout.log",
27+
tempRoot: "/tmp/rpc-rtt",
28+
token: "secret-token",
29+
}),
30+
).resolves.toBe(child);
31+
32+
expect(openImpl).toHaveBeenNthCalledWith(1, "/tmp/stdout.log", "w");
33+
expect(openImpl).toHaveBeenNthCalledWith(2, "/tmp/stderr.log", "w");
34+
expect(spawnImpl).toHaveBeenCalledWith(
35+
"pnpm",
36+
[
37+
"openclaw",
38+
"gateway",
39+
"run",
40+
"--port",
41+
"23456",
42+
"--bind",
43+
"loopback",
44+
"--allow-unconfigured",
45+
],
46+
expect.objectContaining({
47+
cwd: "/repo",
48+
env: expect.objectContaining({
49+
HOME: "/tmp/rpc-rtt/home",
50+
OPENCLAW_CONFIG_PATH: "/tmp/openclaw.json",
51+
OPENCLAW_GATEWAY_TOKEN: "secret-token",
52+
OPENCLAW_STATE_DIR: "/tmp/rpc-rtt/state",
53+
PATH: "/bin",
54+
}),
55+
stdio: ["ignore", 41, 42],
56+
}),
57+
);
58+
expect(stdout.close).toHaveBeenCalledTimes(1);
59+
expect(stderr.close).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it("fails readiness immediately when the gateway already exited", async () => {
63+
const child = Object.assign(new EventEmitter(), {
64+
exitCode: 1,
65+
signalCode: null,
66+
});
67+
const fetchImpl = vi.fn();
68+
69+
await expect(
70+
waitForGatewayReady({
71+
child,
72+
fetchImpl,
73+
port: 12345,
74+
readyTimeoutMs: 10_000,
75+
sleepMs: 1,
76+
stderrPath: "/no/such/stderr.log",
77+
}),
78+
).rejects.toThrow("gateway exited before readiness code=1 signal=null");
79+
expect(fetchImpl).not.toHaveBeenCalled();
80+
});
81+
682
it("bounds readiness probes and keeps polling after a stalled response", async () => {
783
const child = new EventEmitter();
884
const fetchImpl = vi

0 commit comments

Comments
 (0)