Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/runner/cloudflare-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ export class CloudflareRunner {
JSON.stringify(wranglerConfig, null, 2),
);

// Allocate a unique port to avoid collisions when running tests in parallel
const port = allocatePort();
// Allocate a unique port confirmed free to avoid collisions in parallel
const port = await allocatePort();

let wranglerProcess: ChildProcess | null = null;
let stdout = "";
Expand Down
34 changes: 32 additions & 2 deletions src/runner/port-allocator.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
/**
* Shared port allocator for test runners.
* Assigns unique ports to avoid collisions when running tests in parallel.
*
* Probes each candidate port with a temporary TCP server to ensure it is
* actually free before handing it out.
*/

import * as net from "net";

let nextPort = 10000 + Math.floor(Math.random() * 40000);

export function allocatePort(): number {
return nextPort++;
/**
* Check whether a port is available by briefly binding to it.
* Binds on both IPv4 and IPv6 loopback to match what most servers do.
*/
function isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.on("error", () => resolve(false));
server.listen(port, "0.0.0.0", () => {
server.close(() => resolve(true));
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment claims IPv6 check but code only checks IPv4

Medium Severity

The JSDoc for isPortFree states it "Binds on both IPv4 and IPv6 loopback to match what most servers do," but the implementation only calls server.listen(port, "0.0.0.0", ...), which binds exclusively on IPv4 all-interfaces (not even loopback). No IPv6 check (e.g., on :: or ::1) is performed. On systems where localhost resolves to ::1 first (common on modern Linux/macOS), a port occupied on IPv6 would be reported as free, defeating the purpose of this PR.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e2f2b03. Configure here.

}

/**
* Allocate a port that is confirmed free at the moment of allocation.
* Tries up to 200 sequential candidates before giving up.
*/
export async function allocatePort(): Promise<number> {
for (let attempts = 0; attempts < 200; attempts++) {
const candidate = nextPort++;
if (await isPortFree(candidate)) {
return candidate;
}
}
throw new Error("Failed to find a free port after 200 attempts");
}
2 changes: 1 addition & 1 deletion src/runner/python-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ ${dependencies.map(d => ` ${d},`).join('\n')}

// Assign a unique port for MCP SSE transport tests
if (transportMode === 'sse') {
env.MCP_SSE_PORT = String(allocatePort());
env.MCP_SSE_PORT = String(await allocatePort());
}

try {
Expand Down
Loading