Skill Tracer
Skills › gstack › browse › port-allocator.ts

port-allocator.ts

140 lines · browse/src/port-allocator.ts at 3f79d81

1/**
2 * Shared loopback port allocation (#2314, decision 8).
3 *
4 * One fixed scan range (10000-49151) for EVERY long-lived gstack listener:
5 * the main browse daemon and the terminal-agent. Binding `port: 0` instead
6 * hands out a port from the OS EPHEMERAL range (49152-65535 on macOS) — the
7 * same pool every short-lived test server draws from — so a daemon that
8 * lives for weeks ends up squatting ports that `app.listen(0)` test servers
9 * expect to receive, silently absorbing their traffic as phantom 404s. The
10 * range therefore ends AT 49151: a max above it would put a fraction of
11 * picks back inside the pool this module exists to avoid (the original
12 * 60000 cap left ~22% of allocations in 49152-59999).
13 *
14 * Extracted from server.ts (which had this logic since #486) so
15 * terminal-agent.ts can reuse it without importing the whole server module.
16 */
18import * as net from 'net';
20export type PortCheckResult =
21 | { available: true }
22 | { available: false; code?: string; message: string };
24export type FailedPortAttempt = {
25 port: number;
26 result: Extract<PortCheckResult, { available: false }>;
27};
29export const RANDOM_PORT_MIN = 10000;
30export const RANDOM_PORT_MAX = 49151; // last port BELOW the macOS ephemeral pool (49152-65535)
31export const RANDOM_PORT_RETRIES = 5;
33export function normalizePortError(err: unknown): Extract<PortCheckResult, { available: false }> {
34 const maybeNodeError = err as NodeJS.ErrnoException | undefined;
35 return {
36 available: false,
37 code: maybeNodeError?.code,
38 message: maybeNodeError?.message || String(err),
39 };
42export function isOccupiedPort(result: Extract<PortCheckResult, { available: false }>): boolean {
43 return result.code === 'EADDRINUSE';
46export function formatPortFailureDetail(attempt: FailedPortAttempt): string {
47 const { code, message } = attempt.result;
48 return code ? `${attempt.port} (${code}: ${message})` : `${attempt.port} (${message})`;
51export function formatExplicitPortUnavailableError(
52 port: number,
53 result: Extract<PortCheckResult, { available: false }>
54): Error {
55 if (isOccupiedPort(result)) {
56 return new Error(`[browse] Port ${port} (from BROWSE_PORT env) is in use`);
57 }
59 const detail = result.code ? `${result.code}: ${result.message}` : result.message;
60 return new Error(
61 `[browse] Cannot bind BROWSE_PORT=${port} on 127.0.0.1 (${detail}). ` +
62 `This usually means localhost port binding is blocked by the current sandbox or OS permissions, ` +
63 `not that the port is occupied. Allow localhost binding, or run browse from an unrestricted terminal.`
64 );
67export function formatRandomPortUnavailableError(attempts: FailedPortAttempt[]): Error {
68 const blockingAttempts = attempts.filter((attempt) => !isOccupiedPort(attempt.result));
70 if (blockingAttempts.length > 0) {
71 const last = blockingAttempts[blockingAttempts.length - 1];
72 return new Error(
73 `[browse] Cannot bind localhost ports after ${attempts.length} attempts in range ` +
74 `${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}. Last error: ${formatPortFailureDetail(last)}. ` +
75 `This usually means the current sandbox or OS permissions are blocking localhost port binding, ` +
76 `not that every sampled port is occupied. Allow localhost binding, set BROWSE_PORT to an approved ` +
77 `port, or run browse from an unrestricted terminal.`
78 );
79 }
81 return new Error(
82 `[browse] No available port after ${RANDOM_PORT_RETRIES} attempts in range ` +
83 `${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}; every sampled port was already in use`
84 );
87// Test if a port is available by binding and immediately releasing.
88// Uses net.createServer instead of Bun.serve to avoid a race condition
89// in the Node.js polyfill where listen/close are async but the caller
90// expects synchronous bind semantics. See: #486
91export function checkPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
92 return new Promise((resolve) => {
93 const srv = net.createServer();
94 let settled = false;
95 const finish = (result: PortCheckResult) => {
96 if (settled) return;
97 settled = true;
98 resolve(result);
99 };
101 srv.once('error', (err) => finish(normalizePortError(err)));
102 try {
103 srv.listen(port, hostname, () => {
104 srv.close(() => finish({ available: true }));
105 });
106 } catch (err) {
107 finish(normalizePortError(err));
108 }
109 });
112export function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
113 return checkPortAvailable(port, hostname).then((result) => result.available);
116/**
117 * Find a port: the explicit override when given, otherwise a random port in
118 * the fixed 10000-49151 scan range with bounded retries. NEVER `port: 0` —
119 * see the module header for why the ephemeral range is off-limits.
120 */
121export async function findAvailablePort(explicitPort?: number | null): Promise<number> {
122 if (explicitPort) {
123 const result = await checkPortAvailable(explicitPort);
124 if (result.available) {
125 return explicitPort;
126 }
127 throw formatExplicitPortUnavailableError(explicitPort, result);
128 }
130 const attempts: FailedPortAttempt[] = [];
131 for (let attempt = 0; attempt < RANDOM_PORT_RETRIES; attempt++) {
132 const port = RANDOM_PORT_MIN + Math.floor(Math.random() * (RANDOM_PORT_MAX - RANDOM_PORT_MIN));
133 const result = await checkPortAvailable(port);
134 if (result.available) {
135 return port;
136 }
137 attempts.push({ port, result });
138 }
139 throw formatRandomPortUnavailableError(attempts);