diff --git a/browse/src/port-allocator.ts b/browse/src/port-allocator.ts index a564c20ff..0365b5ef9 100644 --- a/browse/src/port-allocator.ts +++ b/browse/src/port-allocator.ts @@ -1,12 +1,15 @@ /** * Shared loopback port allocation (#2314, decision 8). * - * One fixed scan range (10000-60000) for EVERY long-lived gstack listener: + * One fixed scan range (10000-49151) 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. + * expect to receive, silently absorbing their traffic as phantom 404s. The + * range therefore ends AT 49151: a max above it would put a fraction of + * picks back inside the pool this module exists to avoid (the original + * 60000 cap left ~22% of allocations in 49152-59999). * * Extracted from server.ts (which had this logic since #486) so * terminal-agent.ts can reuse it without importing the whole server module. @@ -24,7 +27,7 @@ export type FailedPortAttempt = { }; export const RANDOM_PORT_MIN = 10000; -export const RANDOM_PORT_MAX = 60000; +export const RANDOM_PORT_MAX = 49151; // last port BELOW the macOS ephemeral pool (49152-65535) export const RANDOM_PORT_RETRIES = 5; export function normalizePortError(err: unknown): Extract { diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index ca7cde701..21ce5581a 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -952,10 +952,25 @@ function readBrowseToken(): string { // Boot. async function main() { writeClaudeAvailable(); - // #2314: allocate from the shared fixed scan range, then bind. Same - // probe-then-bind semantics as the main server's findPort. - const allocatedPort = await findAvailablePort(); - const server = buildServer(allocatedPort); + // #2314: allocate from the shared fixed scan range, then bind. Probe-then- + // bind has a TOCTOU window — a concurrent process can take the port between + // the probe and Bun.serve, which throws and would kill the boot with no + // retry (main().catch → exit 1). Re-allocate and retry a few times; each + // iteration probes fresh, so only a genuine race lands here. + let server: ReturnType | undefined; + let lastBindErr: unknown; + for (let attempt = 0; attempt < 5 && !server; attempt++) { + const allocatedPort = await findAvailablePort(); + try { + server = buildServer(allocatedPort); + } catch (err) { + lastBindErr = err; + } + } + if (!server) { + console.error(`[terminal-agent] failed to bind after 5 attempts: ${lastBindErr}`); + process.exit(1); + } const port = (server as any).port || (server as any).address?.port; if (!port) { console.error('[terminal-agent] failed to bind: no port'); diff --git a/browse/test/terminal-agent-port-range.test.ts b/browse/test/terminal-agent-port-range.test.ts index ec32b0029..9fcf0e1fe 100644 --- a/browse/test/terminal-agent-port-range.test.ts +++ b/browse/test/terminal-agent-port-range.test.ts @@ -1,6 +1,6 @@ /** * #2314: the terminal-agent must allocate its port from the SAME fixed - * 10000-60000 scan range the main server uses (port-allocator.ts, + * 10000-49151 scan range the main server uses (port-allocator.ts, * decision 8) — never `port: 0`. Binding 0 drew from the OS ephemeral range * (49152-65535 on macOS), where the weeks-lived agent squatted ports that * short-lived `app.listen(0)` test servers expected to receive, absorbing @@ -26,8 +26,10 @@ describe('shared port allocator (#2314)', () => { const port = await findAvailablePort(); expect(port).toBeGreaterThanOrEqual(RANDOM_PORT_MIN); expect(port).toBeLessThan(RANDOM_PORT_MAX); - // The load-bearing property: below the ephemeral floor (49152). - expect(RANDOM_PORT_MAX).toBeLessThanOrEqual(60000); + // The load-bearing property: the WHOLE range sits below the ephemeral + // floor (49152). The original 60000 cap left ~22% of picks inside the + // pool this allocator exists to avoid. + expect(RANDOM_PORT_MAX).toBeLessThan(49152); expect(RANDOM_PORT_MIN).toBeGreaterThanOrEqual(1024); } });