Files
gstack/browse/test/terminal-agent-port-range.test.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

79 lines
3.0 KiB
TypeScript

/**
* #2314: the terminal-agent must allocate its port from the SAME fixed
* 10000-60000 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
* their traffic as phantom 404s across every Node test suite on the machine.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import {
findAvailablePort,
RANDOM_PORT_MIN,
RANDOM_PORT_MAX,
} from '../src/port-allocator';
const AGENT_TS = path.resolve(import.meta.dir, '..', 'src', 'terminal-agent.ts');
const SERVER_TS = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
describe('shared port allocator (#2314)', () => {
test('allocates inside the fixed scan range, never the ephemeral range', async () => {
for (let i = 0; i < 5; i++) {
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);
expect(RANDOM_PORT_MIN).toBeGreaterThanOrEqual(1024);
}
});
test('explicit free port is honored', async () => {
// Find a free port by binding 0, then ask the allocator for exactly it.
const free = await new Promise<number>((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const p = (srv.address() as net.AddressInfo).port;
srv.close(() => resolve(p));
});
});
expect(await findAvailablePort(free)).toBe(free);
});
test('explicit occupied port throws an actionable error', async () => {
const srv = net.createServer();
await new Promise<void>((resolve, reject) => {
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => resolve());
});
const occupied = (srv.address() as net.AddressInfo).port;
try {
await expect(findAvailablePort(occupied)).rejects.toThrow(/in use/);
} finally {
await new Promise<void>((r) => srv.close(() => r()));
}
});
});
describe('terminal-agent uses the shared allocator (static tripwire)', () => {
test('terminal-agent.ts never binds port: 0', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Strip comments so the explanatory history above the bind doesn't trip.
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
expect(code).not.toMatch(/port:\s*0\b/);
expect(src).toContain("from './port-allocator'");
expect(src).toContain('findAvailablePort');
});
test('server.ts routes findPort through the same allocator', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toContain("from './port-allocator'");
expect(src).toMatch(/findAvailablePort\(BROWSE_PORT\)/);
});
});