Skip to content

usockets: stop the listen addrinfo loop on EADDRINUSE so listen(port, "localhost") reports the conflict - #36710

Open
robobun wants to merge 6 commits into
mainfrom
farm/02f33cfd/listen-localhost-eaddrinuse
Open

robobun wants to merge 6 commits into
mainfrom
farm/02f33cfd/listen-localhost-eaddrinuse

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #15560.

Repro

const net = require("net");
const a = net.createServer();
a.listen(0, "localhost", () => {
  const port = a.address().port;
  const b = net.createServer();
  b.on("error", e => console.log("B", e.code));
  b.listen(port, "localhost", () => console.log("B bound", b.address()));
});

Before: B bound { address: '127.0.0.1', port: <same>, family: 'IPv4' }
After (and in Node): B EADDRINUSE

Cause

bsd_create_listen_socket calls getaddrinfo(host) and walks every result, IPv6 first then IPv4, so that hosts with a disabled v6 stack can still bind the v4 loopback. It treated every bind() failure as a reason to move on to the next addrinfo, so when localhost resolved to both ::1 and 127.0.0.1, a second listen(port, "localhost") that got EADDRINUSE on ::1 would silently bind 127.0.0.1 instead of surfacing the error. Two dev servers both "listened on localhost:5173" and Vite's port probe never saw a conflict.

Node resolves the host once via dns.lookup and binds that single address, so the second listener sees EADDRINUSE.

Fix

Keep walking the addrinfo list for errors that mean the family is unusable (EAFNOSUPPORT, EADDRNOTAVAIL, socket() failures), but stop and return the error when bind() reports EADDRINUSE or EACCES: those mean the port is owned, and binding the other loopback family is not a correct fallback. packages/bun-usockets/src/bsd.c is the shared sink for Bun.listen, Bun.serve, net.Server and http.Server, so the one change covers all of them.

node:cluster follow-up

Stopping the addrinfo fallback exposed that test-net-listen-shared-ports.js on Windows only passed because of it: the primary's RoundRobinHandle binds the requested port and each worker then re-binds it itself, so worker2 was reaching 'listening' by quietly taking 127.0.0.1 after ::1 reported in-use. On Linux the same test was already failing on main (the primary's dual-stack wildcard blocks both loopback addresses outright).

_http_server.ts already handles this for http.Server in a cluster worker by binding with reusePort. Do the same for net.Server: the primary's RoundRobinHandle and the worker's listenOnPrimaryHandle now pass reusePort: true, so the port is shared via SO_REUSEPORT (Linux) / SO_REUSEADDR (Windows) instead of via the addrinfo fallback. test-net-listen-shared-ports.js now passes on both Linux and Windows. The full cluster round-robin rework (primary stops accepting, port: 0 resolved once) is tracked in #30603.

Verification

$ bun bd test test/js/node/net/node-net.test.ts -t "EADDRINUSE when the port is already held on localhost"
(pass) listen(port, "localhost") reports EADDRINUSE when the port is already held on localhost

$ bun bd test test/js/node/http/node-http.test.ts -t "silently binding the other loopback"
(pass) node:http > listen(port, 'localhost') reports EADDRINUSE instead of silently binding the other loopback family

$ bun bd test/js/node/test/sequential/test-net-listen-shared-ports.js   # Linux and Windows
(exit 0)

Both new tests fail on main with second listen bound {"address":"127.0.0.1",...}.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts

bsd_create_listen_socket walks every getaddrinfo result (IPv6 then IPv4)
so that a host whose v6 stack is disabled can still bind the v4 loopback.
It was treating every bind failure as a reason to try the next address,
so when `localhost` resolved to both ::1 and 127.0.0.1, a second
`server.listen(port, "localhost")` that hit EADDRINUSE on ::1 would
silently bind 127.0.0.1 instead of surfacing the error. Two dev servers
both claimed localhost:5173 (Vite's port probe never saw a conflict).

Keep the fallback for errors that mean the family is unusable, but treat
EADDRINUSE and EACCES as terminal so the caller sees them, matching
Node's listen(port, host) semantics.

Fixes #15560
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fe2ca856-46fb-4a78-9919-797423d3635c

📥 Commits

Reviewing files that changed from the base of the PR and between 9fbf095 and a296d26.

📒 Files selected for processing (2)
  • packages/bun-usockets/src/bsd.c
  • test/js/node/net/node-net.test.ts
💤 Files with no reviewable changes (1)
  • test/js/node/net/node-net.test.ts

Walkthrough

Changes

The change makes EADDRINUSE and EACCES bind errors fatal, enables reusePort for cluster listeners, and adds regression tests for duplicate localhost bindings.

Localhost bind handling

Layer / File(s) Summary
Fatal bind error handling
packages/bun-usockets/src/bsd.c
IPv6 and IPv4 listen attempts now record configuration errors and stop on EADDRINUSE or EACCES. Other errors keep the fallback behavior.
Cluster listener reuse
src/js/internal/cluster/RoundRobinHandle.ts, src/js/node/net.ts
Cluster listeners now force reusePort: true.
Duplicate localhost binding coverage
test/js/node/http/node-http.test.ts, test/js/node/net/node-net.test.ts
Tests verify that duplicate localhost listeners emit EADDRINUSE and close both servers.

Possibly related PRs

  • oven-sh/bun#36140: Both PRs modify packages/bun-usockets/src/bsd.c error propagation and errno handling for listen and socket failures.

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: stopping address-family fallback so localhost conflicts report EADDRINUSE.
Description check ✅ Passed The description explains the problem, cause, fix, cluster follow-up, and verification results, covering the template requirements.
Linked Issues check ✅ Passed The changes satisfy issue #15560 by surfacing EADDRINUSE for occupied localhost ports instead of silently binding another loopback address.
Out of Scope Changes check ✅ Passed The cluster reusePort changes and regression tests directly support the listen error fix and its required compatibility behavior.

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

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:34 PM PT - Aug 1st, 2026

❌ @robobun, your commit a296d26 has 2 failures in Build #87468 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36710

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

bun-36710 --bun

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. binding privileged port incorrectly reports EADDRINUSE instead of EACCES on macos #30363 - This PR makes EACCES fatal in the addrinfo loop and preserves the real errno, so binding a privileged port without permission will now correctly report EACCES instead of falling through and reporting EADDRINUSE

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #30363

🤖 Generated with Claude Code

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #30363: that one is a separate bug. Bun.serve's on_listen_failed only reads the real errno on Linux (the #[cfg(target_os = "linux")] block in src/runtime/server/mod.rs); on macOS it falls through to the hardcoded EADDRINUSE message because errno is not preserved through the uWS listen callback there. This PR changes the addrinfo walk in bsd_create_listen_socket, which doesn't touch that propagation path, so #30363 is not fixed here.

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

No issues found, but this changes the fallback semantics of the shared TCP listen path (Bun.listen/Bun.serve/net.Server/http.Server), so it's worth a human sign-off.

What was reviewed:

  • *error is captured in us_internal_bind_and_listen before bsd_close_socket runs, so close() clobbering errno doesn't affect the fatal check.
  • The *error = 0 reset before each attempt keeps a stale errno from an earlier iteration from tripping the early exit when bsd_set_reuse/IPV6_V6ONLY fails without writing *error.
  • LIBUS_ERR maps to WSAGetLastError() on Windows and the fatal set uses WSAEADDRINUSE/WSAEACCES, so the platform gate is consistent.
  • Tests still pass (though don't exercise the fallback) on hosts where localhost resolves to a single family — no false failure introduced.
Extended reasoning...

Overview

The PR modifies bsd_create_listen_socket in packages/bun-usockets/src/bsd.c to stop iterating the getaddrinfo result list when bind() (or listen()) fails with EADDRINUSE/EACCES, instead of falling through and binding the other loopback family. It adds a small helper bsd_bind_error_is_fatal, platform-gated errno constants, and a *error = 0 reset before each attempt. Two regression tests are added (net and http) that listen twice on (port, "localhost") and assert the second listen fails with EADDRINUSE.

Security risks

None identified. This tightens listen behavior (fails earlier rather than silently binding a different address); it does not relax any check, expose new surface, or handle untrusted input differently.

Level of scrutiny

Medium-high. The C change itself is small and reads correctly — LIBUS_ERR expands to errno/WSAGetLastError() so *error holds the real bind/listen errno, it's captured before bsd_close_socket, and the pre-attempt *error = 0 prevents a stale value from short-circuiting when bsd_bind_listen_fd fails in a path that doesn't write *error (bsd_set_reuse, IPV6_V6ONLY setsockopt). But this is the single shared sink for every TCP listen in Bun, and it is a deliberate behavior change: a second server on (port, "localhost") that previously bound 127.0.0.1 after ::1 reported EADDRINUSE will now fail. That matches Node and fixes #15560, but it's the kind of user-visible semantics shift a maintainer should confirm rather than a bot.

Other factors

The tests are hermetic (local servers, port: 0 for the first bind, error events wired to reject, cleanup in finally) and follow the file's existing patterns. On CI hosts where localhost resolves to only one family the tests still pass — they just don't exercise the multi-family fallback — so no flake is introduced. The bug hunting system reported no findings. Given the shared-path scope and cross-platform errno handling, deferring to a human reviewer.

The addrinfo-loop fix in bsd.c exposed that the Windows pass of
test-net-listen-shared-ports.js depended on the old fallback: worker2
only reached 'listening' because bind(::1) failing over to 127.0.0.1
masked the port conflict. On Linux the same test already failed on
main for the same reason (the primary's RoundRobinHandle holds the
port the worker then tries to re-bind).

Bind both the primary's RoundRobinHandle and the worker's
listenOnPrimaryHandle with reusePort so every cluster participant can
share the port (SO_REUSEPORT on Linux, SO_REUSEADDR on Windows), which
is the same approach _http_server.ts already uses for http.Server in a
cluster worker. The full cluster round-robin rework is in #30603.
Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/net.ts 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.

I didn't find bugs, but this touches the shared native listen path (bsd_create_listen_socket feeds Bun.serve, Bun.listen, net.Server, and http.Server) and adds a reusePort: true cluster workaround whose Windows behavior depends on how LIBUS_LISTEN_REUSE_PORT degrades there — worth a human look, and CI on the post-cluster-fix commits should be green first.

What was reviewed:

  • bsd.c: the early-return correctly closes the fd and frees addrinfo on both loops; *error = 0 before each bind prevents a stale errno from a prior iteration being read as fatal.
  • net.ts cluster path: the forced reusePort only applies in the !isPrimary && !exclusive branch, so non-cluster and exclusive: true listens still honor the caller's flag.
  • Checked that bsd_close_socket runs before the fatal-error return so errno isn't clobbered — it writes to *error first, so the saved value survives the close.
Extended reasoning...

Overview

The PR changes bsd_create_listen_socket in packages/bun-usockets/src/bsd.c to stop iterating getaddrinfo results when bind() fails with EADDRINUSE or EACCES, instead of falling through to the next address family. It also forces reusePort: true on the primary's RoundRobinHandle server and on cluster workers' net.Server bind (matching what _http_server.ts already does), and adds regression tests to node-net.test.ts and node-http.test.ts.

Security risks

None identified. The change tightens error reporting (surfaces EADDRINUSE/EACCES instead of silently binding a different address), which is the safer direction.

Level of scrutiny

High. bsd_create_listen_socket is the single sink for every TCP listen in Bun — Bun.serve, Bun.listen, net.Server, http.Server, the dev server. A regression here breaks every server. The bsd.c change itself reads correctly (fd closed, freeaddrinfo called, *error zeroed before each attempt so a stale value can't trip the fatal check), but the behavioral change and its cross-platform interaction with the cluster workaround warrant human review.

Other factors

  • The cluster reusePort: true change is explicitly a workaround for Bun's incomplete handle-passing (tracked in #30603). On Windows, bsd_set_reuseport returns ENOTSUP and bsd_set_reuse swallows it unless DISALLOW_REUSE_PORT_FAILURE is set — so whether reusePort: true actually enables address sharing on Windows depends on whether the JS-side option also sets LIBUS_LISTEN_REUSE_ADDR. The PR description asserts test-net-listen-shared-ports.js now passes on Windows, but the last CI status in the timeline is for the pre-cluster-fix commit (c997ea7) where that test timed out on both Windows targets; CI for the follow-up commits should be confirmed.
  • The comment-cop bot flagged earlier verbose comments; those were tightened in later commits and the threads are resolved.
  • Tests are well-formed (error events wired to reject, cleanup in finally, no sleeps) and cover both net.Server and http.Server entry points.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

On the Windows reusePort question: Bun.listen({ reusePort: true }) maps to LIBUS_LISTEN_REUSE_PORT | LIBUS_LISTEN_REUSE_ADDR in SocketConfig::socket_flags() (src/runtime/socket/Handlers.rs). On Windows bsd_set_reuseport returns ENOTSUP (swallowed by bsd_set_reuse), while bsd_set_reuseaddr sets SO_REUSEADDR, which is what allows multiple binds of the same address there.

Verified on a local Windows x64 build at 2620482:

P binds [::]:12357   (reusePort: true)
A binds ::1:12357    (reusePort: true)
B binds ::1:12357    (reusePort: true)  -> ok

and test-net-listen-shared-ports.js exits 0 on both Linux and Windows. The earlier Windows timeouts in #87120 were on c997ea7 (before the cluster commit); #87288 is the run covering the current head.

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bun-usockets/src/bsd.c`:
- Around line 1240-1250: Update bsd_bind_listen_fd so every failure path,
including bsd_set_reuse() and the IPv6 IPV6_V6ONLY setsockopt() failure, assigns
the corresponding socket error to *error before returning. Preserve the existing
caller flow so bsd_bind_error_is_fatal() always receives the actual failure code
rather than zero.

In `@test/js/node/http/node-http.test.ts`:
- Around line 1315-1318: Update the finally blocks in
test/js/node/http/node-http.test.ts lines 1315-1318 and
test/js/node/net/node-net.test.ts lines 1894-1897 to await server shutdown
callbacks. Await a.close() and conditionally await b.close() only when b is
listening, preserving cleanup for failed-listen cases and applying the same
change to both tests.

In `@test/js/node/net/node-net.test.ts`:
- Around line 1873-1878: Update the regression comment above the affected test
to contain only the issue URL, removing the explanatory implementation details
while leaving the test behavior unchanged.
- Around line 1879-1893: Update both regression tests at
test/js/node/net/node-net.test.ts:1879-1893 and
test/js/node/http/node-http.test.ts:1298-1314 to resolve localhost with { all:
true } before binding, verify that both IPv4 and IPv6 addresses are available,
and skip with an explicit reason when either family is absent. Keep the existing
bind and EADDRINUSE assertions unchanged for dual-family hosts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a2da0835-4ebe-4cf8-8c35-aa66f94fb3eb

📥 Commits

Reviewing files that changed from the base of the PR and between f91d5c9 and 9fbf095.

📒 Files selected for processing (5)
  • packages/bun-usockets/src/bsd.c
  • src/js/internal/cluster/RoundRobinHandle.ts
  • src/js/node/net.ts
  • test/js/node/http/node-http.test.ts
  • test/js/node/net/node-net.test.ts

Comment thread packages/bun-usockets/src/bsd.c Resolved
Comment thread test/js/node/http/node-http.test.ts Resolved
Comment thread test/js/node/net/node-net.test.ts Resolved
Comment thread test/js/node/net/node-net.test.ts Resolved
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Build #87468 ran the full suite on a296d26. The only lanes that failed are unrelated to this diff:

  • robobun: EC2 agent provisioning error for the alpine-3.23-aarch64 test lane (Unsupported: The requested configuration is currently not supported)
  • test/cli/install/bun-upgrade.test.ts on Windows 11 aarch64: Canary builds are not available for this platform yet (same failure seen on main; already reported to main-break triage from #87120)
  • 13 [flaky] tests across lanes (napi GC, request-clone-leak, fetch-leak, pretty-format-overflow, fastutf8stream-reopen, etc.), each of which passed alone or on retry

None of the new or changed tests in this PR failed. In particular test-net-listen-shared-ports.js passed on both Windows lanes and every POSIX lane, confirming the cluster reusePort follow-up works in CI.

(Earlier builds #87288/#87369 never ran: fleet-wide agent outage, every job Expired with 0s runtime.)

Ready for review.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #37720, which touches the same two lines in bsd_bind_listen_fd(): it moves the IPV6_V6ONLY setsockopt into a shared bsd_set_v6only() (the return LIBUS_SOCKET_ERROR; body this PR adds *error = LIBUS_ERR; to is unchanged), so whichever of the two lands second needs a one-hunk rebase. It also adds a setsockopt_v6only rule to socketFaultInjection in bun:internal-for-testing that fires inside that helper, which would let the IPV6_V6ONLY half of this change be asserted directly: arm it, Bun.listen({ hostname: "::1", port: 0, ... }), and check the thrown error's code (see the setsockopt_v6only block in test/js/bun/net/socket-syscall-fault.test.ts, which currently only asserts the message because *error is not written yet).

@robobun

robobun commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

#47224 walks the getaddrinfo chain in resolver order in all three bsd.c sinks and stops on EADDRINUSE for a named host, which covers the bsd.c hunk of this PR. The net.ts and RoundRobinHandle.ts reusePort hunks here predate the cluster rework in #31829.

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.

Bun thinks it can bind two processes to the same port

2 participants