From cc96aefe25bb208aa17dd5620a433d528ccda4be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 17 Sep 2026 19:23:01 -0700 Subject: [PATCH] fix(test): keep Gateway sandbox ports out of the client range (#151140) Fix intermittent Canvas and dashboard sandbox failures in Linux real-Gateway tests. The shared fixture previously selected a kernel client-range Gateway port while the lazy sandbox used the adjacent, unchecked port. An outbound connection could occupy that sandbox port before its listener started. Use the existing port-block allocator to check Gateway and sandbox listeners outside the Linux client range, then retain exact Gateway-port reservation across CLI work and restarts. Explicit caller-owned ports remain caller-owned. Production behavior and browser waits are unchanged. Validation: exact-head CI run 35297154119 is green; recorded Linux proof covers the original collision, 78 helper tests, 20 Canvas file repetitions, and the complete 30-file/60-test real-Gateway suite. Committed-branch Codex review is scoped-clean and the landing rebase is patch-identical. Related: #151003, #150983 --- ...openclaw-test-instance.acquisition.test.ts | 41 ++++++++++++++++++- test/helpers/openclaw-test-instance.ts | 15 +++---- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/test/helpers/openclaw-test-instance.acquisition.test.ts b/test/helpers/openclaw-test-instance.acquisition.test.ts index 65ce518eed08..614251a2117e 100644 --- a/test/helpers/openclaw-test-instance.acquisition.test.ts +++ b/test/helpers/openclaw-test-instance.acquisition.test.ts @@ -14,6 +14,39 @@ import { createDeferred, withTestTimeout } from "./promise.js"; import { runQaGatewayFixture } from "./qa-gateway-cleanup.js"; describe("createOpenClawTestInstance acquisition", () => { + it.skipIf(process.platform !== "linux")( + "keeps Gateway and deferred sandbox listeners outside the kernel client-port range", + async () => { + const [low, high] = (await fs.readFile("/proc/sys/net/ipv4/ip_local_port_range", "utf8")) + .trim() + .split(/\s+/u) + .map(Number); + const instance = await createOpenClawTestInstance({ name: "sandbox-port-allocation" }); + const sandbox = net.createServer(); + await runQaGatewayFixture( + async () => { + for (const port of [instance.port, instance.port + 1]) { + expect(port < low! || port > high!, `listener ${port} overlaps ${low}–${high}`).toBe( + true, + ); + } + await new Promise((resolve, reject) => { + sandbox.once("error", reject); + sandbox.listen(instance.port + 1, "127.0.0.1", resolve); + }); + expect(sandbox.listening).toBe(true); + }, + () => + sandbox.listening + ? new Promise((resolve, reject) => { + sandbox.close((error) => (error ? reject(error) : resolve())); + }) + : undefined, + () => instance.cleanup(), + ); + }, + ); + it.each(["state", "config", "rollback failure"] as const)( "joins and rolls back owner cancellation during %s acquisition", async (stage) => { @@ -37,7 +70,9 @@ describe("createOpenClawTestInstance acquisition", () => { const allocated = await mkdtemp(...args); if (args[0].endsWith("instance-owner-cancel-")) { root = await fs.realpath(allocated); - const address = serverSpy.mock.results[0]?.value.address(); + const address = serverSpy.mock.results + .find((result) => result.type === "return" && result.value.listening) + ?.value.address(); reservedPort = address && typeof address !== "string" ? address.port : undefined; if (stage === "state") { entered.resolve(); @@ -162,7 +197,9 @@ describe("createOpenClawTestInstance acquisition", () => { const mkdtemp = fs.mkdtemp; const allocationSpy = vi.spyOn(fs, "mkdtemp").mockImplementation(async (...args) => { if (args[0].endsWith("instance-wrapper-failure-")) { - const address = serverSpy.mock.results[0]?.value.address(); + const address = serverSpy.mock.results + .find((result) => result.type === "return" && result.value.listening) + ?.value.address(); reservedPort = address && typeof address !== "string" ? address.port : undefined; expect(reservedPort).toBeTypeOf("number"); if (stage === "state") { diff --git a/test/helpers/openclaw-test-instance.ts b/test/helpers/openclaw-test-instance.ts index f416f30c96fa..a0a9e51a6d7f 100644 --- a/test/helpers/openclaw-test-instance.ts +++ b/test/helpers/openclaw-test-instance.ts @@ -30,6 +30,7 @@ import { createOpenClawTestState, type OpenClawTestState, } from "../../src/test-utils/openclaw-test-state.js"; +import { getDeterministicFreePortBlock } from "../../src/test-utils/ports.js"; import { sleep } from "../../src/utils.js"; import { decodeUtf8Tail } from "./bounded-child-output.js"; import { runQaGatewayFixture } from "./qa-gateway-cleanup.js"; @@ -263,7 +264,7 @@ async function resolveGatewayEntrypoint(cwd: string): Promise { } async function reserveGatewayPort( - port = 0, + port: number, verifyCleanup?: OpenClawTestInstanceOptions["verifyCleanup"], ) { // A probe must not retain a connection that can delay release before spawn. @@ -280,11 +281,7 @@ async function reserveGatewayPort( resolve(); }); }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("failed to reserve gateway port"); - } - return { port: address.port, release }; + return { release }; } catch (error) { return await runQaGatewayFixture( async (): Promise => { @@ -716,7 +713,11 @@ export async function createOpenClawTestInstance( signal?.addEventListener("abort", closeAdmission, { once: true }); try { signal?.throwIfAborted(); - port = options.port ?? (reservation = await reserveGatewayPort(0, options.verifyCleanup)).port; + // The lazy sandbox uses port + 1; keep both listeners out of Linux's client-port pool. + port = options.port ?? (await getDeterministicFreePortBlock({ offsets: [0, 1] })); + if (options.port === undefined) { + reservation = await reserveGatewayPort(port, options.verifyCleanup); + } signal?.throwIfAborted(); state = await createOpenClawTestState({ label: options.name,