#541

fix(cli): give concurrent runs different app ports

Opendibas-npwants to merge intofrom

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.addRoute takes the hostnames of the app being registered, one per TLD. With them, the store throws PortTakenError when 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-app portless through spawnProxiedApp, and the turbo runner now pick the port and register the route together. On PortTakenError they 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-port and portless alias are 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 B

Tests

New cases in routes.test.ts under "addRoute with portSharers":

  • Another live route on the port makes addRoute throw PortTakenError and leaves the store unchanged. This fails on main.
  • 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

3
vercel[bot]11d ago

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

iwaseshi9d ago

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