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
This commit is contained in:
Peter Steinberger 2026-09-17 19:23:01 -07:00 • committed by GitHub
parent b10b53175f
commit cc96aefe25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 47 additions and 9 deletions

View file

@ -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<void>((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<void>((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") {

View file

@ -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<string[]> {
}
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<never> => {
@ -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,