diff --git a/docs/research/WEB_BROWSER_PORT_2026-10-07.md b/docs/research/WEB_BROWSER_PORT_2026-10-07.md new file mode 100644 index 00000000..a50fb32c --- /dev/null +++ b/docs/research/WEB_BROWSER_PORT_2026-10-07.md @@ -0,0 +1,26 @@ +# Browser-compatible Web Host ports + +- Status: validated source and isolated transport observation +- Created / verified: 2026-10-07 +- Source: `origin/main` at `3cb2ecfe1bfbb98252651885311d309f83749428` +- Issue: [#694](https://github.com/openpi-dev/openpi/issues/694) +- Supersedes: none + +## Verified facts + +The original `WebHost.start()` retained any OS-assigned TCP port. An isolated +Node HTTP listener successfully bound `127.0.0.1:6000`, while native Fetch +rejected its URL with cause `bad port` before HTTP dispatch. A full Windows +test run also encountered that Fetch error after automatic Host allocation. +[Fetch port blocking](https://fetch.spec.whatwg.org/#port-blocking) applies to +HTTP(S) even when the TCP listener is healthy. + +## Fix and verification boundary + +`listenBrowserPort` rejects explicitly configured blocked ports. Automatic +allocation closes blocked listeners and retries at most eight times before +reporting failure. Readiness is published only after a usable port is retained. +Deterministic transport tests cover rejection, retry with a real listener and +Fetch request, exhaustion cleanup, and original bind errors. These are isolated +source tests, not installed Pi, live model, or graphical browser acceptance. +No new runtime authority or persisted configuration is introduced. diff --git a/tests/web/browser-port.test.ts b/tests/web/browser-port.test.ts new file mode 100644 index 00000000..a7fa237f --- /dev/null +++ b/tests/web/browser-port.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import { listenBrowserPort } from "../../web/host/browser-port.ts"; + +test("explicit browser-blocked ports fail without opening a listener", async () => { + for (const port of [6000, 6667, 10080]) { + const server = createServer(); + await assert.rejects( + listenBrowserPort(server, port, "127.0.0.1"), + /blocked by browsers/u, + ); + assert.equal(server.listening, false); + assert.equal(server.listenerCount("error"), 0); + } +}); + +test("synchronous listen failures release the temporary error listener", async () => { + const server = createServer(); + await assert.rejects(listenBrowserPort(server, -1, "127.0.0.1"), { + code: "ERR_SOCKET_BAD_PORT", + }); + assert.equal(server.listening, false); + assert.equal(server.listenerCount("error"), 0); +}); + +test("automatic allocation releases a blocked port and retains the usable retry", async (t) => { + const server = createServer((_request, response) => response.end("ready")); + const address = server.address.bind(server); + let samples = 0; + t.mock.method(server, "address", () => { + const current = address(); + assert.ok(current && typeof current !== "string"); + return ++samples === 1 ? { ...current, port: 6000 } : current; + }); + try { + const port = await listenBrowserPort(server, 0, "127.0.0.1"); + assert.ok(samples >= 2); + assert.equal(server.listenerCount("error"), 0); + assert.equal( + await (await fetch(`http://127.0.0.1:${port}`)).text(), + "ready", + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("automatic allocation has a finite retry bound and closes its final listener", async (t) => { + const server = createServer(); + let attempts = 0; + t.mock.method(server, "address", () => { + attempts++; + return { address: "127.0.0.1", family: "IPv4", port: 6000 }; + }); + await assert.rejects( + listenBrowserPort(server, 0, "127.0.0.1"), + /after 8 attempts/u, + ); + assert.equal(attempts, 8); + assert.equal(server.listening, false); + assert.equal(server.listenerCount("error"), 0); +}); + +test("an occupied explicit port preserves the bind error without retry", async () => { + const occupied = createServer(); + const port = await listenBrowserPort(occupied, 0, "127.0.0.1"); + const contender = createServer(); + try { + await assert.rejects(listenBrowserPort(contender, port, "127.0.0.1"), { + code: "EADDRINUSE", + }); + assert.equal(contender.listening, false); + assert.equal(contender.listenerCount("error"), 0); + } finally { + await new Promise((resolve) => occupied.close(() => resolve())); + } +}); diff --git a/web/host/browser-port.ts b/web/host/browser-port.ts new file mode 100644 index 00000000..f15e0085 --- /dev/null +++ b/web/host/browser-port.ts @@ -0,0 +1,48 @@ +import type { Server } from "node:http"; + +// HTTP(S) ports blocked by Fetch, even when the TCP listener is healthy. +// https://fetch.spec.whatwg.org/#port-blocking +const blockedPorts = new Set([ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, + 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, + 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, + 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, + 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, + 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080, +]); + +export async function listenBrowserPort( + server: Server, + requestedPort: number, + host: string, +) { + if (requestedPort !== 0 && blockedPorts.has(requestedPort)) { + throw new Error(`Web port ${requestedPort} is blocked by browsers; choose another port or 0 for automatic allocation.`); + } + for (let attempt = 0; attempt < 8; attempt++) { + await new Promise((resolve, reject) => { + const failed = (error: Error) => reject(error); + server.once("error", failed); + try { + server.listen(requestedPort, host, () => { + server.removeListener("error", failed); + resolve(); + }); + } catch (error) { + server.removeListener("error", failed); + reject(error); + } + }); + const address = server.address(); + if (address && typeof address !== "string" && !blockedPorts.has(address.port)) { + return address.port; + } + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + if (!address || typeof address === "string") { + throw new Error("Web host did not expose a TCP port"); + } + } + throw new Error("Web host could not allocate a browser-compatible port after 8 attempts."); +} diff --git a/web/host/web-host.ts b/web/host/web-host.ts index c8f0a3a0..0970e6c4 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -6,6 +6,7 @@ import { timingSafeEqual, } from "node:crypto"; import { readFile } from "node:fs/promises"; +import { listenBrowserPort } from "./browser-port.ts"; import { validProviderDiscovery } from "../runtime/provider-model-discovery.ts"; import { createServer, @@ -394,14 +395,7 @@ export class WebHost { async start() { await this.adapter.initialize(); - await new Promise((resolve, reject) => { - this.server.once("error", reject); - this.server.listen(this.requestedPort, HOST, () => resolve()); - }); - const address = this.server.address(); - if (!address || typeof address === "string") - throw new Error("Web host did not expose a TCP port"); - this.port = address.port; + this.port = await listenBrowserPort(this.server, this.requestedPort, HOST); this.publish("web_host_started", { port: this.port, ...(this.runtime.workspaceSelected === true