Closes #533
Problem
findFreePort probes a port and releases it before the app binds it, and the gap lasts the app's whole boot time. 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.
Changes
This implements direction 1 from #533, which @iwaseshi also supported there.
RouteStore.claimAppPort(hostnames, pid, pick, force)callspickwith the ports that other live routes and aliases already hold, then reloads the routes under the route lock and writes all of the app's hostnames in one save. If another run claimed the picked port in between, that port joins the excluded set andpickruns again. Each retry excludes one more port, so there is no retry limit to exhaust.pickruns without the lock because it awaits network probes, and other route operations in the same process take the lock synchronously.findFreePorttakes an optionalexcludeset and skips those ports in both the random and the sequential phase.portless run, multi-app runs and turbo runs callclaimAppPortwhen no fixed port is set. A fixed--app-portandportless aliasare unchanged. The hostnames of one app, one per TLD, still share its port.addRouteandclaimAppPortshare the hostname conflict and--forcehandling, so behavior for existing callers is unchanged. If any hostname conflicts,claimAppPortwrites nothing.
No command, flag or output changes, so the README, skill, help text and MCP tools stay as they are.
Compared with #541
#541 fixes the same race by checking the port when each route is written and picking again, up to 5 times, on PortTakenError. This PR skips ports that are already assigned before probing, so a retry only happens when another run claims the same port in the short window between its pick and its write, and the retry has no limit. The new e2e test passes with either approach, so it can land with whichever one you prefer.
Tests
routes.test.ts:claimAppPortskips ports held by other live apps and by aliases, registers every hostname of an app on one port, keeps the app's own previous port, reuses a port whose owner is dead, writes nothing on a hostname conflict, picks again when another run claims the port first, does not hold the lock whilepickawaits (aremoveRoutein the same process during the pick completes immediately and stays removed), and gives 8 concurrent processes 8 different ports (each worker waits insidepick, so without the recheck they would all pick the same port).cli-utils.test.ts:findFreePortskips excluded ports and throws when all of them are excluded.tests/e2e/src/port-race.test.ts: starts 4 runs at once with a preload that makes every run's first pick the same port and a fixture that boots for 1.5 s and keeps running afterEADDRINUSE. It checks that the 4 routes have 4 different ports and that each hostname reaches its own app. Onmainit fails every time (the 4 apps end up on 1 or 2 ports); with this change it passes. Skipped on Windows because cleanup uses process groups.
Locally on macOS 26.5.1 with Node 22, after merging main (v0.17.0): pnpm format:check, pnpm lint, pnpm type-check, the unit tests, and the port-race and parallel e2e tests pass, except 12 tests in cli.test.ts and proxy.test.ts (doctor, privileged proxy startup, one TLS upstream test) that fail on this machine on main too.
A stress run started 8 runs at once, 10 times, with the random pick narrowed to 10 ports so that collisions are likely. On 0.15.12 it gave 17 duplicate ports and 22 bind failures. With this change all 80 runs bound their own port, with no duplicates. With #541 there were no duplicates either, but 5 runs exhausted its 5 retries, because most of the narrowed pool was taken. That pool is artificial; with the real 1000-port range the retry limit is unlikely to matter.
@rikukaInoue is attempting to deploy a commit to the Vercel Labs Team on Vercel.
A member of the Team first needs to authorize it.