Skip to content

Start integration servers again when another process takes their port - #396

Merged
Brooooooklyn merged 2 commits into
mainfrom
fix/dev-server-port-race
Oct 8, 2026
Merged

Brooooooklyn merged 2 commits into
mainfrom
fix/dev-server-port-race

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Problem

solid-pages-playground.test.ts > solid-pages client (dev) failed once in a full local run and passed on rerun:

Error: Timed out waiting for server at http://127.0.0.1:52761 (waited 45000ms)
Port 52761 is in use, trying another one...
Port 52762 is in use, trying another one...
  ➜  Local:   http://127.0.0.1:52763/

Every dev and preview server test starts the same way. The test picks a free port, frees it, and starts the server. The server binds the port 1–7 s later. Anything can take the port in that gap.

solid test                         another process
getPort() → 52761, then frees it
spawn `vp dev --port 52761`
  (Vite loads for 1–7 s)           binds 52761  ◄── the gap
Vite: 52761 in use → tries 52762   (52762 = the next test's port)
Vite: 52762 in use → binds 52763
test polls 52761 for 45 s ─────────► never our server → timeout

Two things make it worse:

  • The OS hands out ports in order, so tests that start together get ports next to each other (seen: 59293, 59294, 59295 in the same 10 ms). When Vite moves to port + 1, it lands on another test's port. That test then fails too.
  • Vite without --strictPort never stops. It moves silently, and the test only finds out after the full timeout.

Who took 52761

The process is gone, so it cannot be named for that run. What I checked:

Check Result
Is our own port hold still open? No. It is freed right after spawn. Holding it makes Vite print exactly this message, but the trace shows it is freed seconds before Vite binds.
Can the OS give the freed port to another listen(0)? No on macOS. Ports go out in order (measured 54 ports/s, a full cycle takes about 5 min). Ports held by wildcard listeners are skipped too.
Who binds explicit port numbers in a run? Traced every Node listen() in 6 full runs (+2 overlapping pairs): only each test server on its own port (and inspector ports near 9229). No collision happened in those runs.
Who binds explicit ports outside a run? Long-running dev servers from other sessions: Miniflare restarts workerd on the port it had before (--socket-addr=entry=127.0.0.1:55332).
CI (Linux) The OS can reuse a freed port right away. #386 saw another test's listen(0) get the port.

So the port was taken by a process that binds a port number it chose itself. On Linux CI, another test's listen(0) can also take it. The test design turns any of these into a 45 s timeout. The fix does not depend on which process it was.

What this PR changes

startServer(spawnServer, timeout, options)
  ┌─► pick free port P
  │   spawn server with --port P --strictPort
  │   wait until it prints its URL with P ("Local: …:P/", "Listening on …:P")
  │   then wait for HTTP on P
  │     ├─ ready ─────────────────────────────► return { child, base, output }
  │     ├─ server exits "already in use" ──┐
  │     ├─ Vite prints "P is in use" ──────┤ (Astro: no strict flag)
  │     │                                  ▼
  └─────┴──── kill it, try a new port (up to 3 times)
        └─ any other failure ─────────────► throw, as before
  • All 30 server starts in 21 test files use startServer().
  • Vite servers get --strictPort, so they never move into another test's port.
  • An answer on P only counts after our server printed its URL with P. Before that, the answer can come from the process that took P.
  • getPort() and the port hold are gone. The hold only covered the time until spawn, not the seconds until the server binds.
  • TESTING.md tells new tests to use startServer() with --strictPort.

Decisions

Choice Why
Strict port + retry, not "use whatever URL the server prints" Servers never take another test's port.
Wait for the server's own URL line before counting answers Vite, Astro, Node, Bun and Deno print it only once they listen on P. No fixed delay.
Nuxt: printsUrl: false Nuxt prints no URL under Vitest (NODE_ENV=test). Its test checks the page body Nuxt + Void instead.
Retry only when the output says the port is taken Real start failures still fail at once, with the same output.
3 attempts One retry fixes a single collision. A third covers a second one. More would hide a real problem.
Astro: watch for Vite's move message astro dev has no --strictPort. The message is enough to restart it.
No longer timeouts Timeouts are unchanged. A taken port now costs about 1 s instead of 45 s.

Known limits

  • Nuxt has no strict option and moves to a random port without a message. It still times out if its port is taken. It never takes another test's port. It prints no URL under Vitest, so only its page body check shows the answer is ours.
  • On macOS, the built Node, Bun and Deno servers listen on all addresses and do not fail when another process holds 127.0.0.1 on the same port. They print their URL, so there is nothing to retry. Requests to 127.0.0.1 then reach the other process.

Testing

Command Result
Solid test on main with a process binding its port (and port + 1) right after spawn Same failure as reported: Port N is in use ×2, 45 s timeout
Same, with this PR (dev, preview, and Astro) Pass. Log: Another process took port N; starting the server on a new port.
An HTTP server answers on the port right after spawn (real vp dev, ssr playground) Without the URL check: returns in 13 ms, page is the other server, ours exits Port N is already in use. With it: retries, returns our page, server still running
vp test run packages/void/test/unit/integration-helpers.test.ts 9 passed (4 new: taken port, other HTTP server on the port, moved server, real failure not retried). The retry tests fail with retries turned off; the HTTP server test fails with the URL check turned off.
vp test run packages/solid/test/integration/solid-pages-playground.test.ts ×3 32/32 each, 6.1–6.4 s
The 21 files that use startServer() ×2 21/21 files each, 17.4 s
Astro, Vite dev and preview, Analog, Node and Bun server tests with CI=true Pass
vp test run --project parallel-integration ×2 52/52 files each, 19.1–20.2 s (main: 27.6–27.9 s)
vp run test --base origin/main (selects all 691 files) 690 passed, 1 skipped
vp test run 690 passed, 1 skipped
vp lint, vp fmt --check, vp run check pass

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T13:49:06.088693Z b40f70a New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://fix-dev-server-port-race-void-cloud.voidzero-docs.workers.dev (commit b40f70a)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://cc0f49d3-void-cloud.voidzero-docs.workers.dev b40f70a 2026-10-08T13:48:49.409Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://0065ec6c-void-cloud.voidzero-docs.workers.dev b46dafc 2026-10-08T11:50:14.990Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://03886e22-void-cloud.voidzero-docs.workers.dev 34196c3 2026-10-08T11:30:57.193Z Visit the dashboard ↗

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34196c3fbe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/integration-helpers.ts
Brooooooklyn and others added 2 commits October 8, 2026 21:18
Integration tests chose a free port, released it, then spawned a dev or
preview server that binds it seconds later. Another process could bind
the port in that gap. Vite then moved to the next port, which is often
the port another test was just given, and the test kept polling the old
port until it timed out after 45 s.

startServer() now owns the whole start: it picks a port, spawns the
server, and waits for it. Vite servers get --strictPort, so a taken port
makes them exit instead of moving into another test's port. When a
server reports that its port is taken, startServer() starts it again on
a new port, up to three times. Servers that cannot be strict (Astro)
are restarted when they report the move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Another HTTP server that took the chosen port could answer before the
started server bound it. startServer then returned at once, and the test
ran against that other server while ours exited with "already in use".

Answers now count only after the server prints its URL with the chosen
port (Vite and Astro "Local: http://127.0.0.1:<port>/", the Node, Bun and
Deno servers "Listening on http://...:<port>"). A server that exits on the
taken port is still started again on a new one. Nuxt prints no URL under
Vitest, so its test opts out and keeps relying on its page body check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the fix/dev-server-port-race branch from b46dafc to b40f70a Compare October 8, 2026 13:46
Brooooooklyn added a commit that referenced this pull request Oct 8, 2026
## Problem

A hosted app (napi-rs) returned about 100 worker 500s in one day. The
5xx alert email pointed to `void project requests` and `void project
logs`. Neither command showed the cause:

```
$ void project requests --status 5xx
16:30:14 GET  500 static 1897ms        <- the worker answered, but the label says "static"

$ void project logs --level error
16:30:12 GET https://napi.rs/sponsors.svg 500
  [error] TypeError: Cannot read properties of undefined (reading 'href')
                                        <- no stack, although the API returns it

13:00:25 POST https://internal/__void/scheduled 500
  [error] AggregateError: Scheduled jobs failed
                                        <- which job failed, and why, is lost
```

Three causes:

```
request label   dispatch: a path with a file extension (/sponsors.svg) stays "static",
                even when the app's own route answered it
logs stack      CLI: prints only `Name: message`; drops errorInfo[].stack and exception.stack
cron error      runtime: Workers log capture keeps an error's own message and stack,
                but not AggregateError.errors, so the inner errors vanish
```

## What this PR changes

```
request label (dispatch metering)
  dotted path on an SSR deploy, no platform failure
    ├─ manifest says "not a file" and the worker answers the path ─> ssr
    ├─ worker owns the path and returns 5xx or times out ─────────> ssr
    └─ listed file, asset-layer answer, platform asset failure ───> static (unchanged)
  The ISR write-back checks the path directly, so these routes never enter the ISR cache.

void project logs
  [error] TypeError: Cannot read properties of undefined (reading 'href')
              at assets/resvg-gkFxLqVH.js:14881:64      <- dimmed frames under the
              at assets/resvg-gkFxLqVH.js:15409:3          argument that logged them
  [exception] frames print the same way

scheduled jobs
  job fails ─> console.error('[void] Scheduled job crons/x.ts failed for cron "0 5 * * *":', error)
              (logged at once, with the original error, so its stack is kept)
  all jobs done ─> throw AggregateError(errors, 'Scheduled jobs failed: crons/x.ts')
              (HTTP 500 and the shared onError are unchanged)
```

Real output from this branch's CLI against the live napi-rs data:

```
15:23:32 GET https://napi.rs/sponsors.svg 500
  [error] TypeError: Cannot read properties of undefined (reading 'href')
              at assets/resvg-gkFxLqVH.js:14881:64
              at assets/resvg-gkFxLqVH.js:15409:3
              at new Promise (<anonymous>)
              at assets/resvg-gkFxLqVH.js:15408:19
              at assets/client-7iHZB5sW.js:16:49
```

## Decisions

| Question | Decision | Why |
|---|---|---|
| How does dispatch know the worker answered? | Reuse the asset-manifest
answer it already reads for the static cache | No new KV read on the hot
path |
| New request type such as `worker`? | No, use `ssr` | The CLI, docs and
usage queries already know `ssr` |
| Fix cron errors in the shared Hono `onError`? | No, in the scheduled
runner | `onError` serves every route; the runner knows the job file and
cron |
| Change the platform API or tail worker for stacks? | No, CLI only |
The API already returns `errorInfo[].stack` and `exception.stack` |
| `void platform deployment logs` (operator command) | Not changed |
Outside the reported problem |

## Rollout

```
merge ─> release void ──> users upgrade the CLI ──────> stacks print     (no redeploy needed)
                     └──> apps redeploy ──────────────> cron errors named
       deploy hosted dispatch ─────────────────────────> new rows labeled ssr
       (old Analytics rows keep "static")
```

## Known limits

- Some app routes on paths with a file extension can still be labeled
`static` when they succeed or return 4xx. This applies to requests with
a Cookie or Authorization header, to HEAD, Range and POST requests, and
to SSR apps that uploaded no static files. On framework deploys, these
requests also stay `static` when they fail with a 5xx. A correct label
would need an asset-manifest read on every credentialed asset request.
That is too much cost for a display label. On Void SSR apps, server
errors are labeled `ssr` for every request shape.
- A failed cron now shows two log lines on the hosted path: the per-job
line and the `AggregateError` line.
- Framework cron runners and queue runners have the same error-loss
pattern. They are not changed here.

## Testing

| Check | Result |
|---|---|
| New tests on old code | fail (dispatch 7+, logs rendering 1, scheduled
dispatch 2) |
| Changed test files | 5 files, 353 tests pass |
| `vp run test --base origin/main` | 475 files, 10329 tests pass |
| `vp lint`, `vp run check` | pass (31 typecheck tasks) |
| `vp test run` | 689 files pass. 1 file failed on a port race in the
Solid dev-server test: the test waited on a port that `vp dev` found
busy. It passes on rerun. The race is fixed in #396. |
| Branch CLI against live napi-rs logs | frames print under every error
|
| `vp dev` fixture with one failing cron job | per-job line with file,
cron, message and stack; the other job runs; HTTP 500 |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn merged commit c123e9d into main Oct 8, 2026
20 checks passed
@Brooooooklyn
Brooooooklyn deleted the fix/dev-server-port-race branch October 8, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant