Skip to content

html dev server: keep an explicit port, warn when the default port moves - #42020

Open
robobun wants to merge 3 commits into
mainfrom
robobun/332196f9/html-dev-server-pinned-port
Open

robobun wants to merge 3 commits into
mainfrom
robobun/332196f9/html-dev-server-pinned-port

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun ./index.html --port=N serves on N+1 when N is in use, with no notice. PORT=N does the same, and bun --port=N ./index.html falls back to 3000. Bun.serve({ port: N }) throws EADDRINUSE. Anything that was told N talks to the wrong server.
  • The cause is the retry loop in src/js/internal/html.ts:246. It retried port + 1 five times whatever named the port. Its fallback came from its own argv/env parse, which never saw bun --port.

Fix

  • A port named by --port, --host=h:N, bun --port, PORT, BUN_PORT or NODE_PORT stays fixed. On EADDRINUSE it prints error: Failed to start server. Is port N in use? and exits 1, as next dev does.
  • Only a default port moves: 3000, or bunfig serve.port. It tries the next 10 ports and prints warn: Port N is in use, using port M instead. to stderr.
  • The TCP listen errors from Bun.serve() gain a port own property, like node's listen errors. The wrapper reads it instead of repeating the port lookup.
  • Verified: test/js/bun/http/bun-serve-html-entry.test.ts (3 new tests, all fail on stock bun) and one in serve.test.ts. Self-reviewed: 4 concerns, 3 addressed, 1 deferred (Notes).

Background

  • bun ./index.html runs src/js/internal/html.ts, a wrapper that imports the HTML files as HTMLBundles and calls Bun.serve({ static }). Flags after the entry are in process.argv, those before it in process.execArgv.
  • With port undefined, Bun.serve() picks it: the env vars, then bun --port or bunfig serve.port, else 3000. The wrapper cannot see the bunfig value, hence port on the error.
Notes
  • Found by an automated browser-execution differential of the HTML bundler, not by a user report. Reproduced on bun 1.4.3 (canary f42e980), Linux x64, with the port held by a plain socket.listen():
    • bun ./index.html --port=41321 -> url: http://127.0.0.1:41322/, exit 0
    • PORT=41321 bun ./index.html -> 41322, same for --host=127.0.0.1:41321 and BUN_PORT
    • bun --port=41321 ./index.html -> url: http://127.0.0.1:3000/
    • bunfig [serve] port = 41321 -> 3000
  • After the change (debug build): the explicit forms print the one-line error and exit 1. A taken default 3000 serves on 3001 with warn: Port 3000 is in use, using port 3001 instead. A taken bunfig serve.port walks up from that port. --hostname=h:N counts as explicit too.
  • The port lookup lives in src/runtime/server/ServerConfig.rs:647 (BUN_PORT, PORT, NODE_PORT, then transform_options.port, which both bun --port and bunfig serve.port set). An environment variable pins the port only when its value is the port that was refused, so PORT=abc (which Bun.serve() ignores) still gets the default-port fallback instead of a confusing error about port 3000.
  • bunfig serve.port is documented as "the default port for Bun.serve", so it is treated as a movable default, not a pinned port.
  • Any other listen failure (EACCES on a privileged port, for example) now prints its message on one line and exits 1 too, instead of a stack trace into internal:html. This also holds for a fallback candidate during the walk. Errors that are not listen failures are still thrown.
  • The fallback test does not fetch the fallback server on Windows. The held port comes from port: 0 and so sits in the Windows dynamic range, and Windows can hand the child's explicitly bound fallback port to a concurrent port: 0 listener in another process as well (netstat showed two LISTENING rows for 127.0.0.1:<port> from two PIDs on the CI image). A fetch can then reach the other server. Real default ports such as 3000 are outside that range. The warn line and the banner port are still asserted there.
  • The warn line goes to stderr so it survives a piped stdout (the harness that found this ran bun ./index.html | grep url:).
  • The fallback test sets bunfig serve.port to a port the test holds, so it exercises the default-port path without touching port 3000 in CI.
  • The old loop retried the refused port once more before moving on (defaultPort++ started at the same number). The new loop starts at N+1 and stops at 65535.
  • Under --watch/--hot a pinned port that is in use leaves the process up for the next reload, as for any failing entry point.
  • Review concerns: (1) process.exit(1) killed --watch/--hot sessions that used to stay alive on a failing entry: addressed, the wrapper now sets process.exitCode and returns. (2) port was only on the EADDRINUSE branch: addressed, all three TCP branches carry it. (3) An invalid or late-set PORT made the wrapper report a port it never asked for: addressed by the value match above. (4) The new stderr lines key their styling on Bun.enableANSIColors (stdout or stderr is a TTY), like the rest of this file. html entry, inspector: key banner styling on the fd the banner is written to #41887 adds per-fd flags for built-in JS. Whichever lands second should key these two lines on the stderr flag. Deferred to that sequencing.
  • Not addressed here: address on the listen error (node has it). Bun.serve() may bind several addresses for one hostname, so there is no single obvious value. Left for a follow-up if wanted.
  • This does not fix Multiple instances of the fullstack dev server reuse the same ports #18132 (dual-stack localhost binds that hide a conflict). usockets: stop the listen addrinfo loop on EADDRINUSE so listen(port, "localhost") reports the conflict #36710 is the complementary usockets change. The new tests pass --hostname=127.0.0.1 for that reason.
  • Under --hot, if the process that held a default port goes away and the HTML file is edited, the reload binds the original port and the fallback listener stays open. Stock bun does the same. Out of scope.
  • Both touched test files pass in full with the debug build (the pre-existing root-port EACCES test needs a non-root user).
  • Out of scope, tracked separately: an error thrown by html.ts's start() after its first await is reported twice (visible with bun ./index.html --port=99999).

no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts, test/js/bun/http/bun-serve-html-entry.test.ts

…oves

`bun ./index.html --port=N` caught EADDRINUSE and retried `port + 1` up
to five times in silence, whatever named the port. `--port`, `--host=h:N`,
PORT/BUN_PORT/NODE_PORT and the built-in default all walked, and
`bun --port=N ./index.html` fell back to 3000 instead. Anything that was
told the port out of band then talked to the wrong server.

A port named by a flag or an environment variable now stays fixed: the
command prints the listen error on one line and exits 1, like
Bun.serve(). Under --watch/--hot the process stays up for the next
reload, as for any other failing entry point. Only a default port (3000,
or `serve.port` in bunfig.toml) moves to the next free one, and it
prints `warn: Port N is in use, using port M instead.` to stderr when it
does.

The TCP listen errors from Bun.serve() gain a `port` own property, like
node's listen errors, so the wrapper knows which default was refused
without repeating Bun.serve()'s port lookup.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 10 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a634ce41-3ac1-44ac-9373-c3ae88028c96

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and df43b9c.

📒 Files selected for processing (4)
  • src/js/internal/html.ts
  • src/runtime/server/mod.rs
  • test/js/bun/http/bun-serve-html-entry.test.ts
  • test/js/bun/http/serve.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

CI (build 113083): every lane passes the new tests, including all Windows and macOS lanes. The one red lane is test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, which also fails on main and is unrelated to this diff.

Reproduced on bun 1.4.3 (Linux x64) with the port held by another listener:

d=$(mktemp -d); cd $d; printf '<!doctype html><h1>hi</h1>' > index.html
python3 -c "import socket,time;s=socket.socket();s.bind(('127.0.0.1',41321));s.listen(5);time.sleep(30)" &
bun ./index.html --port=41321 --hostname=127.0.0.1      # url: http://127.0.0.1:41322/ (no notice)
PORT=41321 bun ./index.html --hostname=127.0.0.1        # url: http://127.0.0.1:41322/
bun --port=41321 ./index.html --hostname=127.0.0.1      # url: http://127.0.0.1:3000/

With this branch the three commands print error: Failed to start server. Is port 41321 in use? and exit 1. A taken default port still moves, with warn: Port 3000 is in use, using port 3001 instead. on stderr.

Tests: bun bd test test/js/bun/http/bun-serve-html-entry.test.ts (3 new, fail on stock bun), bun bd test test/js/bun/http/serve.test.ts -t "EADDRINUSE error names".

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:54 AM PT - Sep 8th, 2026

❌ @robobun, your commit df43b9c has 1 failures in Build #113083 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42020

That installs a local version of the PR into your bun-42020 executable, so you can run:

bun-42020 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/internal/html.ts Resolved
…ck walk too, keep the lint happy

A non-EADDRINUSE listen error on a fallback candidate (EACCES on the
next port, for example) was rethrown with a stack trace. Route it to
the same one-line error and exit code 1 as the first attempt.

Read error.code/error.port once (no-duplicate-conditional-property-access).

Test: on Windows the held port sits in the dynamic range, and Windows
can hand the child's explicitly bound fallback port to a concurrent
port-0 listener in another process as well, so do not assert
reachability there. Match the banner URL only as a whole line.
Comment thread src/js/internal/html.ts Resolved
Comment thread src/js/internal/html.ts Resolved
Comment thread src/js/internal/html.ts Resolved
Comment thread src/runtime/server/mod.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiple instances of the fullstack dev server reuse the same ports

2 participants