From c10a9736b89f92e3d95b8260cea395adeaa01f29 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 13:28:57 -0700 Subject: [PATCH] fix(browse): port allocator range actually stays below the ephemeral floor; terminal-agent retries a raced bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RANDOM_PORT_MAX was 60000 while the module header documents 49152-65535 as the pool to avoid — ~22% of allocations landed back inside it, preserving the phantom-404 squatting class for both the daemon and the weeks-lived terminal-agent. The cap is now 49151 and the range test pins the true property (< 49152) instead of the old <= 60000 tautology. terminal-agent boot also re-allocates and retries up to 5 times when Bun.serve throws in the probe-then-bind TOCTOU window — previously a concurrent bind killed the boot with no retry via main().catch → exit 1. Co-Authored-By: Claude Fable 5 --- browse/src/port-allocator.ts | 9 +++++--- browse/src/terminal-agent.ts | 23 +++++++++++++++---- browse/test/terminal-agent-port-range.test.ts | 8 ++++--- 3 files changed, 30 insertions(+), 10 deletions(-) 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); } });