#533

Concurrent starts can get the same app port; a child that survives EADDRINUSE leaves its route pointing at another app

OpenrikukaInoueopened 13d ago

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 A

Expected

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 run invocations with a lock in the state directory while probing.

Happy to help test a fix.

Activity

2
rikukaInoue12d ago

We're happy to send a PR for this. Before writing it, could you pick a direction?

  1. Skip ports already assigned to a live route when picking, and hold a lock in the state directory around pick + addRoute. Closes the race between portless processes; no OS-specific code.
  2. Option 1, plus verifying after spawn that the port is actually held by the child's process tree (failing the run or retrying otherwise). Also covers unrelated processes, but needs per-OS code to find the listener's PID.
  3. Something else you'd prefer.

We'd lean toward 1 as a first step. Happy to include a test that reproduces the race.

iwaseshi8d ago

We 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 bindcollisions
0 ms (same process, Promise.all)0 / 200
3 s1 / 20
8 s1 / 12
15 s2 / 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   -> 4438

portless list shows it plainly once you know to look:

4438: app-e app-b   <- same port, two apps

The 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
done

Also 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.