Summary
Two apps started at the same time can be given the same app port: findFreePort probes a candidate, closes it, and the child binds it later, so a concurrent portless run can pick the same candidate in between (check-then-use race). The second child fails with EADDRINUSE, but if it keeps running — as watch-mode dev servers do (tsx watch, node --watch, nodemon) — portless keeps its route, and requests to the second app's hostname are served by the first app, with no error anywhere.
#447 fixed the hostless probe (#288); this is the remaining race, reproduced on 0.15.12.
Why it matters
With git worktrees each running a web + API pair, a web app that reaches its API through portless get ends up talking to another worktree's API and database. Vite is not affected because portless passes --strictPort and Vite exits; the misroute needs a child that stays alive after the bind failure.
With 1000 candidate ports, two apps starting together pick the same one with probability about 1/1000, so it is rare but silent when it happens.
Reproduction (macOS 26.5.1, Node 22.22.2, portless 0.15.12)
To hit the race deterministically, a preload forces the first random candidate:
// force.cjs: make the first Math.random() pick FORCE_PORT
const p = Number(process.env.FORCE_PORT); const r = Math.random; let first = true;
Math.random = () => { if (first) { first = false; return (p - 4000 + 0.5) / 1000; } return r(); };export PORTLESS_STATE_DIR=$(mktemp -d) PORTLESS_PORT=18499 PORTLESS_HTTPS=0 PORTLESS_SYNC_HOSTS=0 FORCE_PORT=4466
CLI=$(readlink -f "$(command -v portless)")
# A: listens normally
node -r ./force.cjs "$CLI" run --name a.repro node -e \
'require("http").createServer((q,r)=>r.end("from A\n")).listen(+process.env.PORT,"127.0.0.1")' &
# B: started at the same time; on EADDRINUSE it keeps running, like a watch-mode dev server
node -r ./force.cjs "$CLI" run --name b.repro node -e \
'const s=require("http").createServer((q,r)=>r.end("from B\n"));s.on("error",e=>console.log("B bind failed:",e.code));s.listen(+process.env.PORT,"127.0.0.1");setInterval(()=>{},1e3)' &
sleep 5
portless list
curl -s -H 'Host: b.repro.localhost' http://127.0.0.1:18499/Output:
B bind failed: EADDRINUSE
http://a.repro.localhost:18499 -> localhost:4466
http://b.repro.localhost:18499 -> localhost:4466
from AExpected
B's route should not point at a port B does not own. Either B never gets 4466, or portless notices that B is not listening and removes the route / fails the run.
Possible directions
- After spawning, wait until the port is listening and check that the listener belongs to the child's process tree; otherwise fail the run (or retry with another port).
- Record assigned ports in the route store and skip ports already assigned to a live route when picking, which closes the window between portless processes (it does not cover unrelated processes).
- Reserve picks across concurrent
portless runinvocations with a lock in the state directory while probing.
Happy to help test a fix.
Activity
2We hit this independently and filed #551 before finding this issue; closing that one as a duplicate and moving the data here.
One thing worth adding: the collision rate is not ~1/1000. It scales with how long the child takes to bind, because the probe socket in findFreePort() is closed before the child binds — so the window is the whole boot time of the app, not a few microseconds. A cold Next.js dev server makes it common.
Measured on macOS 15 (Darwin 25.6.0), Node 26.12.1, with a script that mimics findFreePort() and then waits N ms before binding, 8 pickers in parallel:
| boot time before bind | collisions |
|---|---|
0 ms (same process, Promise.all) | 0 / 200 |
| 3 s | 1 / 20 |
| 8 s | 1 / 12 |
| 15 s | 2 / 12 |
With no gap the rate is zero, which confirms the gap — not the random choice — is what makes collisions likely.
In practice, on an 8-app monorepo started by turbo run dev, we got a collision on 4 consecutive pnpm dev runs, a different pair each time:
app-a + app-b -> 4644
app-c + app-d -> 4804
app-c + app-a -> 4923
app-e + app-b -> 4438portless list shows it plainly once you know to look:
4438: app-e app-b <- same port, two appsThe cost is in the symptom, as you describe: a request to one app's hostname returned another app's Next.js HTML page. That is a wrong-but-valid response, so it took us a long detour before we looked at portless list.
Reproduction script, if it is useful alongside your Math.random preload:
// pick.js — mimics findFreePort(), then waits before binding
const net = require("net");
const HOSTS = ["127.0.0.1", "::1", "0.0.0.0", "::"];
const isFreeOn = (p, h) => new Promise(r => {
const s = net.createServer();
s.once("error", e => r(e.code === "EADDRNOTAVAIL" || e.code === "EAFNOSUPPORT"));
s.listen(p, h, () => s.close(() => r(true)));
});
const tryPort = async p => { for (const h of HOSTS) if (!await isFreeOn(p, h)) return false; return true; };
(async () => {
for (let i = 0; i < 50; i++) {
const p = 4000 + Math.floor(Math.random() * 1000);
if (await tryPort(p)) {
console.log(p);
await new Promise(r => setTimeout(r, Number(process.argv[2] || 3000))); // app boot time
const s = net.createServer();
s.listen(p, "0.0.0.0", () => setTimeout(() => { s.close(); process.exit(0); }, 500));
s.on("error", () => process.exit(0));
return;
}
}
})();for t in $(seq 1 20); do
ports=$(for i in $(seq 1 8); do node pick.js 15000 & done; wait)
[ "$(echo "$ports" | sort -u | wc -l)" != "$(echo "$ports" | wc -l)" ] && echo collision
doneAlso a +1 on your direction 1, which #541 now implements. Reading the store under the same lock that writes the route removes the cross-process race, and it needs no OS-specific code. One smaller improvement regardless of which direction lands: when a hostname's registered port is bound by a process other than the registered pid, portless list and the proxy could say so, instead of silently proxying to whoever holds it.
Environment: portless 0.15.13, Node 26.12.1, macOS 15 (Darwin 25.6.0) arm64, 8 apps started in parallel by turbo run dev.
We're happy to send a PR for this. Before writing it, could you pick a direction?
We'd lean toward 1 as a first step. Happy to include a test that reproduces the race.