Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/research/WEB_BROWSER_PORT_2026-10-07.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions tests/web/browser-port.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((resolve) => occupied.close(() => resolve()));
}
});
48 changes: 48 additions & 0 deletions web/host/browser-port.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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.");
}
10 changes: 2 additions & 8 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -394,14 +395,7 @@ export class WebHost {

async start() {
await this.adapter.initialize();
await new Promise<void>((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
Expand Down
Loading