fix(browse): port allocator range actually stays below the ephemeral floor; terminal-agent retries a raced bind

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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 13:28:57 -07:00
co-authored by Claude Fable 5
parent e86dcd6a22
commit c10a9736b8
3 changed files with 30 additions and 10 deletions
+6 -3
View File
@@ -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<PortCheckResult, { available: false }> {
+19 -4
View File
@@ -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<typeof buildServer> | 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');
@@ -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);
}
});