Files
gstack/browse/src/port-allocator.ts
T
Garry TanandClaude Fable 5 0d4d554e13 fix(browse): terminal-agent allocates from the fixed port scan range, not port:0 (#2314)
The terminal-agent bound `Bun.serve({ port: 0 })` and kept that OS-assigned
port for its whole (weeks-long) lifetime. `port: 0` draws from the OS
EPHEMERAL range (49152-65535 on macOS) — the exact pool every short-lived
`app.listen(0)` test server draws from — so the agent squatted ports that
test suites expected to receive and silently absorbed their traffic as
phantom 404s (two squatting daemons verified in the report).

Fix per decision 8: extract the main server's port allocation into
browse/src/port-allocator.ts (checkPortAvailable / isPortAvailable /
findAvailablePort + the 10000-60000 range constants and the actionable
sandbox-vs-occupied error formatters, all verbatim from server.ts) and make
BOTH long-lived listeners use it — server.ts's findPort is now a thin
findAvailablePort(BROWSE_PORT) wrapper, and terminal-agent's buildServer
takes a pre-allocated port from the same range. No terminal-port consumer
carries a range assumption (they read the port file), verified by grep.

Tests: terminal-agent-port-range (new — allocator stays inside
10000-60000 and below the 49152 ephemeral floor, explicit-port honored,
occupied-explicit throws, static tripwires pin no-port:0 in
terminal-agent.ts and the shared wrapper in server.ts) + findport +
terminal-agent-integration/session-routing/detach-reattach +
dual-listener: 67 pass, 0 fail.

Fixes #2314.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:59:07 -07:00

138 lines
5.2 KiB
TypeScript

/**
* Shared loopback port allocation (#2314, decision 8).
*
* One fixed scan range (10000-60000) for EVERY long-lived gstack listener:
* the main browse daemon and the terminal-agent. Binding `port: 0` instead
* hands out a port from the OS EPHEMERAL range (49152-65535 on macOS) — the
* same pool every short-lived test server draws from — so a daemon that
* lives for weeks ends up squatting ports that `app.listen(0)` test servers
* expect to receive, silently absorbing their traffic as phantom 404s.
*
* Extracted from server.ts (which had this logic since #486) so
* terminal-agent.ts can reuse it without importing the whole server module.
*/
import * as net from 'net';
export type PortCheckResult =
| { available: true }
| { available: false; code?: string; message: string };
export type FailedPortAttempt = {
port: number;
result: Extract<PortCheckResult, { available: false }>;
};
export const RANDOM_PORT_MIN = 10000;
export const RANDOM_PORT_MAX = 60000;
export const RANDOM_PORT_RETRIES = 5;
export function normalizePortError(err: unknown): Extract<PortCheckResult, { available: false }> {
const maybeNodeError = err as NodeJS.ErrnoException | undefined;
return {
available: false,
code: maybeNodeError?.code,
message: maybeNodeError?.message || String(err),
};
}
export function isOccupiedPort(result: Extract<PortCheckResult, { available: false }>): boolean {
return result.code === 'EADDRINUSE';
}
export function formatPortFailureDetail(attempt: FailedPortAttempt): string {
const { code, message } = attempt.result;
return code ? `${attempt.port} (${code}: ${message})` : `${attempt.port} (${message})`;
}
export function formatExplicitPortUnavailableError(
port: number,
result: Extract<PortCheckResult, { available: false }>
): Error {
if (isOccupiedPort(result)) {
return new Error(`[browse] Port ${port} (from BROWSE_PORT env) is in use`);
}
const detail = result.code ? `${result.code}: ${result.message}` : result.message;
return new Error(
`[browse] Cannot bind BROWSE_PORT=${port} on 127.0.0.1 (${detail}). ` +
`This usually means localhost port binding is blocked by the current sandbox or OS permissions, ` +
`not that the port is occupied. Allow localhost binding, or run browse from an unrestricted terminal.`
);
}
export function formatRandomPortUnavailableError(attempts: FailedPortAttempt[]): Error {
const blockingAttempts = attempts.filter((attempt) => !isOccupiedPort(attempt.result));
if (blockingAttempts.length > 0) {
const last = blockingAttempts[blockingAttempts.length - 1];
return new Error(
`[browse] Cannot bind localhost ports after ${attempts.length} attempts in range ` +
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}. Last error: ${formatPortFailureDetail(last)}. ` +
`This usually means the current sandbox or OS permissions are blocking localhost port binding, ` +
`not that every sampled port is occupied. Allow localhost binding, set BROWSE_PORT to an approved ` +
`port, or run browse from an unrestricted terminal.`
);
}
return new Error(
`[browse] No available port after ${RANDOM_PORT_RETRIES} attempts in range ` +
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}; every sampled port was already in use`
);
}
// Test if a port is available by binding and immediately releasing.
// Uses net.createServer instead of Bun.serve to avoid a race condition
// in the Node.js polyfill where listen/close are async but the caller
// expects synchronous bind semantics. See: #486
export function checkPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
return new Promise((resolve) => {
const srv = net.createServer();
let settled = false;
const finish = (result: PortCheckResult) => {
if (settled) return;
settled = true;
resolve(result);
};
srv.once('error', (err) => finish(normalizePortError(err)));
try {
srv.listen(port, hostname, () => {
srv.close(() => finish({ available: true }));
});
} catch (err) {
finish(normalizePortError(err));
}
});
}
export function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
return checkPortAvailable(port, hostname).then((result) => result.available);
}
/**
* Find a port: the explicit override when given, otherwise a random port in
* the fixed 10000-60000 scan range with bounded retries. NEVER `port: 0` —
* see the module header for why the ephemeral range is off-limits.
*/
export async function findAvailablePort(explicitPort?: number | null): Promise<number> {
if (explicitPort) {
const result = await checkPortAvailable(explicitPort);
if (result.available) {
return explicitPort;
}
throw formatExplicitPortUnavailableError(explicitPort, result);
}
const attempts: FailedPortAttempt[] = [];
for (let attempt = 0; attempt < RANDOM_PORT_RETRIES; attempt++) {
const port = RANDOM_PORT_MIN + Math.floor(Math.random() * (RANDOM_PORT_MAX - RANDOM_PORT_MIN));
const result = await checkPortAvailable(port);
if (result.available) {
return port;
}
attempts.push({ port, result });
}
throw formatRandomPortUnavailableError(attempts);
}