mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 17:05:28 +02:00
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>
81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
/**
|
|
* #2314: the terminal-agent must allocate its port from the SAME fixed
|
|
* 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
|
|
* 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: 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);
|
|
}
|
|
});
|
|
|
|
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\)/);
|
|
});
|
|
});
|