Problem
findFreePort probes a port and releases it before the app binds it. Two portless run invocations started together could pick the same port. The second app failed with EADDRINUSE, and when it kept running, as tsx watch, node --watch and nodemon do, portless kept its route, so its hostname served the first app with no error (#533).
Changes
RouteStore.addRoutetakes the hostnames of the app being registered, one per TLD. With them, the store throwsPortTakenErrorwhen a live route of another app already uses the port. The check runs under the lock that writes the route, so two runs that picked the same port cannot both register it.portless run, multi-appportlessthroughspawnProxiedApp, and the turbo runner now pick the port and register the route together. OnPortTakenErrorthey pick another port, up to 5 times. The check goes by hostname, not PID, so it also keeps two apps of one multi-app run off the same port, because those all register under one PID before any of them binds.- A fixed
--app-portandportless aliasare unchanged. A picked port also skips a port that an alias points at. The hostnames of one app, one per TLD, still share its port.
The README and skill already promise a free port ("assigns a random free port (4000-4999)"). This makes that hold across concurrent runs, without a new command, flag or output, so the docs, help text and MCP tools stay as they are.
Closes #533
Proof
macOS, the reproduction from the issue: a preload forces both runs' first random pick to 4466, and B keeps running after a bind failure.
# main
http://a.repro.localhost:18499 -> localhost:4466
http://b.repro.localhost:18499 -> localhost:4466
curl b.repro -> from A
B bind failed: EADDRINUSE
# this branch
http://a.repro.localhost:18499 -> localhost:4466
http://b.repro.localhost:18499 -> localhost:4512
curl b.repro -> from BTests
New cases in routes.test.ts under "addRoute with portSharers":
- Another live route on the port makes
addRoutethrowPortTakenErrorand leaves the store unchanged. This fails onmain. - Another app of the same process on the port also throws.
- A route whose owner is dead does not block the port.
- The hostnames of one app can share its port.
- Without the list, a port can still be shared, as with a fixed
--app-port.
certs.test.ts (#429) fails on main and on this branch on my machine, as noted in #540.
#460 also changes how app ports are picked, with appPortStrategy. The two may conflict where findFreePort is called.
Activity
3We hit #533 independently on an 8-app monorepo and filed #551 before finding it; data is now on #533, and this approach matches what we would have asked for.
Two things from our side that may help this get reviewed:
The rate is higher than #533 estimates. The window is the child's whole boot time, not a few microseconds, because findFreePort() closes the probe socket before the child binds. With a cold Next.js dev server we got a collision on 4 consecutive pnpm dev runs, a different pair each time. Measured against a script that mimics findFreePort() and waits N ms before binding, 8 pickers in parallel: 0 ms -> 0/200, 3 s -> 1/20, 8 s -> 1/12, 15 s -> 2/12. Details and the script are in my comment on #533.
Checking by hostname rather than PID matters for us. We start all 8 apps through turbo run dev, so they register under one run before any of them binds - which, as you note, a PID check would not catch.
The branch currently conflicts with main (portless compose and #444 landed since you opened it), so it will need a rebase before anyone can run it.
Happy to try this branch against our monorepo and report back if that is useful.
@dibas-np is attempting to deploy a commit to the Vercel Labs Team on Vercel.
A member of the Team first needs to authorize it.