mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
Closes #309. Full credit to @gigadeniga for the diagnosis — the root cause + proposed fix in that issue are exactly what landed here. ## The bug \`npx hyperframes preview\` failed deterministically on Crostini (ChromeOS Linux) with \`Ports 3002–3101 are all in use\`, even when nothing was actually listening on any of them. ## Why \`testPortOnAllHosts\` ran four probes in parallel: \`\`\`ts const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"]; const results = await Promise.all(hosts.map((h) => isPortAvailableOnHost(port, h))); \`\`\` Each probe binds a socket and then calls \`server.close()\`. Close is async — the socket stays open until its callback fires on the next event-loop tick. While it's open, the wildcard binds (\`0.0.0.0\`, \`::\`) that include the loopback address race the still-open loopback socket and return \`EADDRINUSE\` spuriously. On Crostini this happens 100% of the time; other Linux configs hit it intermittently; macOS is less predictable. Net effect: every port in the 100-port scan range appears busy and the preview refuses to start. Reproduces on any Linux box with the standalone snippet from the issue: \`\`\` 127.0.0.1: OK 0.0.0.0: EADDRINUSE ← false positive ::1: OK ::: EADDRINUSE ← false positive \`\`\` ## Fix Serialize the probes. Each socket is fully closed before the next opens, eliminating the race window entirely. \`\`\`ts for (const host of hosts) { const available = await isPortAvailableOnHost(port, host); if (!available) return false; } return true; \`\`\` Kept the four-host check rather than collapsing to just \`0.0.0.0\` + \`::\` — the multi-host coverage is load-bearing for the devbox / SSH-forwarding case where a port is free on loopback but held on the wildcard. Sequentializing is the smaller, less-behaviourally-affecting fix. ## Regression tests \`packages/cli/src/server/portUtils.test.ts\` — three cases binding real sockets, no mocks: - **Returns true for a genuinely free port** — directly reproduces the Crostini bug; would fail on Linux against the parallel implementation. - **Returns false when the port is occupied on \`0.0.0.0\`** — confirms the multi-host check still catches the devbox scenario. - **Releases each probe socket before the next run** — two back-to-back calls for the same free port both return true, pinning the sequential contract against future refactors that might try to reparallelize for perf. ## Test plan - [x] \`bunx vitest run packages/cli/src/server/portUtils.test.ts\` — 3/3 pass - [x] Full CLI suite — 109/109 pass - [x] \`tsc --noEmit\` clean ## Notes - Independent of any version bump; ship whenever. - Probing 4 hosts serially adds at most ~tens of milliseconds per port on the scan (binds are very fast on loopback). The worst-case cost shows up when the first port in the range is free — previously 1 parallel round-trip, now 4 sequential — and it's imperceptible (\`preview\` bind is a one-time startup cost, not a hot path).
This commit is contained in:
@@ -49,15 +49,37 @@ function isPortAvailableOnHost(port: number, host: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;
|
||||
|
||||
/**
|
||||
* Test a port across IPv4 and IPv6 interfaces in parallel. A port is only
|
||||
* unavailable if ANY host reports EADDRINUSE. This catches the devbox bug
|
||||
* where a port is free on localhost but occupied on 0.0.0.0 via SSH forwarding.
|
||||
* Test a port across IPv4 and IPv6 interfaces. A port is only available if
|
||||
* EVERY host binds and releases cleanly — that catches the devbox class of
|
||||
* bug where a port is free on `127.0.0.1` but held on `0.0.0.0` via SSH
|
||||
* forwarding.
|
||||
*
|
||||
* **Must be sequential, not Promise.all.** Binding `127.0.0.1` holds the
|
||||
* socket open until `server.close()` resolves on the next event-loop tick.
|
||||
* In parallel, the wildcard `0.0.0.0` / `::` tests race that still-open
|
||||
* socket and return spurious `EADDRINUSE` — which makes every port in the
|
||||
* scan range look occupied and the preview server refuse to start. Repro
|
||||
* on Linux (Crostini on ChromeOS in the reporting environment, issue #309)
|
||||
* is deterministic; on macOS/Windows the behaviour is less consistent but
|
||||
* the race is there all the same. Serializing each bind past its close
|
||||
* callback eliminates the window entirely.
|
||||
*
|
||||
* `probe` is injectable for deterministic testing of the sequential
|
||||
* contract — callers in production pass nothing and get the real socket
|
||||
* probe. Tests can pass a recording fake that tracks in-flight probes.
|
||||
*/
|
||||
export async function testPortOnAllHosts(port: number): Promise<boolean> {
|
||||
const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"];
|
||||
const results = await Promise.all(hosts.map((h) => isPortAvailableOnHost(port, h)));
|
||||
return results.every(Boolean);
|
||||
export async function testPortOnAllHosts(
|
||||
port: number,
|
||||
probe: (port: number, host: string) => Promise<boolean> = isPortAvailableOnHost,
|
||||
): Promise<boolean> {
|
||||
for (const host of PORT_PROBE_HOSTS) {
|
||||
const available = await probe(port, host);
|
||||
if (!available) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Existing instance detection ────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user