Skip to content

node:net: fall back to 0.0.0.0 when listen() gets no host and the kernel has no IPv6 - #43201

Open
robobun wants to merge 3 commits into
mainfrom
robobun/d1711195/net-listen-ipv4-fallback
Open

robobun wants to merge 3 commits into
mainfrom
robobun/d1711195/net-listen-ipv4-fallback

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On a kernel without IPv6 (ipv6.disable=1), net.createServer().listen(port) emits Error: Failed to listen at :: with no code. Node binds 0.0.0.0. So do node:http and Bun.serve().
  • src/js/node/net.ts:3840 turns a missing host into "::". For "::", getaddrinfo returns only an AF_INET6 entry, so uSockets has no IPv4 entry to try.
  • bsd_create_listen_socket (packages/bun-usockets/src/bsd.c:1371) passes NULL as the errno out-param of bsd_create_socket. The EAFNOSUPPORT from socket() is lost and Listener.rs cannot set code. The unix listen path (bsd.c:1548) does the same.

Fix

  • Both listen paths pass their error out-param to bsd_create_socket. Bun.listen({ hostname: "::" }) now throws with code: "EAFNOSUPPORT", errno and syscall: "listen".
  • net.ts retries with "0.0.0.0" when the host was defaulted and the code is EAFNOSUPPORT. An explicit "::" reports listen EAFNOSUPPORT: address family not supported ::, as Node does.
  • When the fallback itself fails, the error names 0.0.0.0, as in Node.
  • Verified: test/js/node/net/node-net-server.test.ts (new test, stock bun fails it with Failed to listen at ::). Also tcp-server.test.ts, net-syscall-fault.test.ts, cluster.test.ts.

Background

  • uSockets is the C socket layer under Bun.listen, Bun.serve and node:net. bsd_create_listen_socket calls getaddrinfo, then tries each AF_INET6 entry, then each AF_INET entry.
  • Listener.rs is the Rust side of Bun.listen. It builds the JS error from the C call's int *error out-param and sets code only when that is not 0.
  • The test compiles a C function with bun:ffi's cc in a child process. It installs a seccomp-bpf filter that fails socket(<family>) with a chosen errno, as an IPv6-less kernel does. It logs SKIP when the filter cannot be installed.
Notes

Real runs with a standalone seccomp denier (prctl(PR_SET_NO_NEW_PRIVS), then SECCOMP_RET_ERRNO|EAFNOSUPPORT when nr == __NR_socket && args[0] == AF_INET6, then execvp):

##### bun 1.4.3-canary.1+b52d51348
net listen(0)       -> error undefined Failed to listen at ::
net listen({port})  -> error undefined Failed to listen at ::
net listen(0, "::") -> error undefined Failed to listen at ::
http listen(0)      -> listening {"address":"0.0.0.0","family":"IPv4","port":39737}
Bun.listen ::       -> error undefined undefined undefined Failed to listen at ::
##### this branch
net listen(0)       -> listening {"family":"IPv4","address":"0.0.0.0","port":34479}
net listen({port})  -> listening {"family":"IPv4","address":"0.0.0.0","port":33083}
net listen(0, "::") -> error EAFNOSUPPORT listen EAFNOSUPPORT: address family not supported ::
http listen(0)      -> listening {"address":"0.0.0.0","family":"IPv4","port":46069}
Bun.listen ::       -> error EAFNOSUPPORT 97 listen Failed to listen at ::
##### node v26.3.0
net listen(0)       -> listening {"address":"0.0.0.0","family":"IPv4","port":36493}
net listen({port})  -> listening {"address":"0.0.0.0","family":"IPv4","port":42031}
net listen(0, "::") -> error EAFNOSUPPORT listen EAFNOSUPPORT: address family not supported ::

Scope of the retry. Node's setupListenHandle retries 0.0.0.0 after any synchronous failure of the :: bind. This change retries only on EAFNOSUPPORT. Other codes (for example EACCES, where Node reports 0.0.0.0:80) keep their current behavior. EADDRINUSE is not a retry case in Node either: libuv's uv__tcp_bind defers it to listen(), so Node reports :::PORT.

sysctl net.ipv6.conf.all.disable_ipv6=1 (the Docker default for a container without IPv6) is not this case. socket(AF_INET6) and bind("::") still succeed there.

Cluster. A worker's primary binds through SharedHandle (bsd_create_bound_socket with a NULL host, which already walks both families) or through RoundRobinHandle, which calls net.Server.listen in the primary and gets this fallback.

Windows. bsd_create_socket reports WSAGetLastError() and SystemErrno::init maps WSAEAFNOSUPPORT, the same way the existing EADDRINUSE path works. Not run on a Windows host without IPv6.

Open PRs #36710 and #37690 touch lines next to this one in bsd_create_listen_socket for other errors (EADDRINUSE from bind, getaddrinfo failures). The changes are independent.

The errno out-param after a successful listen. bsd_create_socket resets *err to 0 on entry, so a failed AF_INET6 attempt no longer leaves a stale code behind when the AF_INET attempt succeeds. Callers read it only when the returned socket is null.

The test stacks a second filter that fails socket(AF_UNIX) with EMFILE to cover the unix listen path: listen(path) now reports code: "EMFILE". It also keeps the first server open and listens on the same port again with no host: the error is listen EADDRINUSE: address already in use 0.0.0.0:PORT with err.address === "0.0.0.0".

Also ran the Node tests test-net-bind-twice, test-net-eaddrinuse, test-net-server-call-listen-multiple-times, test-net-server-listen-path, test-net-server-try-ports, test-cluster-eaccess, test-cluster-eaddrinuse and test-cluster-shared-handle-bind-error. The test runs on Linux x64 and arm64.

getaddrinfo failures in bsd_create_listen_socket still return without an error code. #37690 is open for that path.

…nel has no IPv6

node:net turns a missing host into "::". On a kernel without IPv6,
socket(AF_INET6) fails with EAFNOSUPPORT and the server emitted a
code-less "Failed to listen at ::" error. Node binds 0.0.0.0 there.

bsd_create_listen_socket discarded the errno from socket(), so
Bun.listen could not attach a code. Pass the out-param through. In
net.ts, retry with 0.0.0.0 when the host was defaulted and the error
is EAFNOSUPPORT. An explicit "::" keeps the error, now with Node's
"listen EAFNOSUPPORT: address family not supported" message.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: 6f8c91d6-26f6-4757-8152-530b8c7251b8

📥 Commits

Reviewing files that changed from the base of the PR and between 422179d and faca6ef.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/bsd.c
  • src/js/node/net.ts
  • test/js/node/net/node-net-server.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

The change propagates native socket errors, adds IPv4 fallback for defaulted TCP hosts after EAFNOSUPPORT, improves listen error reporting, and adds Linux regression coverage.

Changes

Listen error handling

Layer / File(s) Summary
Native socket error propagation
packages/bun-usockets/src/bsd.c
IPv6, IPv4, and Unix listener creation now passes the caller’s error pointer to socket creation.
Node listen fallback and formatting
src/js/node/net.ts
Defaulted TCP hosts retry with 0.0.0.0 after EAFNOSUPPORT. Errors report the attempted address and format EAFNOSUPPORT as “address family not supported.”
Listen fallback regression coverage
test/js/node/net/node-net-server.test.ts
A Linux architecture-gated test covers IPv4 fallback, explicit IPv6 failures, Bun listener behavior, and Unix-socket errors when IPv6 socket creation is blocked.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to faca6

Unspecified TCP listeners retain an IPv4 fallback when IPv6 is unavailable, while explicit IPv6 requests continue to report their socket error. No actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: IPv4 fallback when listen() receives no host and IPv6 is unavailable.
Description check ✅ Passed The description explains the problem, implementation, scope, behavior, and verification results. It does not use the exact template headings, but it provides the required information and is complete.

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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:08 PM PT - Sep 17th, 2026

✅ @robobun, your commit d738034ac7d8d8c7493bf6fb5e89dc9f3eec9e00 passed in Build #117469! 🎉


🧪   To try this PR locally:

bunx bun-pr 43201

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

bun-43201 --bun

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary.1+b52d51348 with a seccomp filter that fails socket(AF_INET6, ...) with EAFNOSUPPORT (the result an ipv6.disable=1 kernel gives): net.createServer().listen(0) emits Failed to listen at :: with code undefined, while http.createServer().listen(0) binds 0.0.0.0. Node v26.3.0 under the same filter binds 0.0.0.0. With this branch the output matches Node line for line. The before and after runs are in the Notes block of the description.

The new test in test/js/node/net/node-net-server.test.ts installs the same filter in a child process. It also covers the unix listen path and a busy port after the fallback. It fails on the released binary and passes with this change.

CI (build 117469) is green. The new test runs there and does not skip: node-net-server.test.ts reports 27 pass with no SKIP line on debian 13 x64 and alpine 3.23 x64.

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 packages/bun-usockets/src/bsd.c — pre-existing: Callers of Bun.listen({ unix }) and net.Server.listen(path) still get a bare Failed to listen at <path> with no code, errno or syscall when socket(AF_UNIX) fails (EMFILE/ENFILE). This PR fixes that lost-errno class for the TCP loops but the sibling in internal_bsd_create_listen_socket_unix at bsd.c:1548 still passes NULL to bsd_create_socket. Fix: pass the function's error out-param there too so Listener.rs (errno != 0 branch) attaches code/errno/syscall, covering the 3 listen-side sites (bsd.c:1371, bsd.c:1388, bsd.c:1548).

    Extended reasoning...

    The PR's own description identifies the mechanism: bsd_create_socket only writes errno when its err pointer is non-NULL, and Listener.rs at src/runtime/socket/Listener.rs:534-556 only sets syscall/errno/code when errno != 0. internal_bsd_create_listen_socket_unix (bsd.c:1545-1568) receives int* error from bsd_create_listen_socket_unix but calls bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL) at bsd.c:1548 and returns LIBUS_SOCKET_ERROR without touching *error. Listener.rs then reads errno == 0 and throws a plain Error with only the message. Trigger: process at its fd limit (EMFILE) or system-wide (ENFILE) calling Bun.listen({unix: path}) or net.createServer().listen("/tmp/x.sock"); node reports listen EMFILE: too many open files /tmp/x.sock with code EMFILE, Bun reports Failed to listen at /tmp/x.sock with no code. Same on the base branch; the PR fixes only the two TCP loops. One-line change to pass error.

    Verification: pre-existing — triggers when socket(AF_UNIX, ...) itself fails (fd exhaustion EMFILE/ENFILE, ENOBUFS/ENOMEM) for Bun.listen({ unix }) or net.Server.listen(path). Mechanism verified: packages/bun-usockets/src/bsd.c:1545-1551 internal_bsd_create_listen_socket_unix(..., int* error) calls bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL) and on LIBUS_SOCKET_ERROR returns immediately…

  • 🟣 packages/bun-usockets/src/bsd.c — Users who pass an unresolvable hostname to net.Server.listen or Bun.listen still get a bare Failed to listen at <host> with no code, errno or syscall after this merges, unlike Node's ENOTFOUND. bsd.c:1363-1364 returns LIBUS_SOCKET_ERROR on getaddrinfo failure without writing *error, so Listener.rs:537 attaches nothing and the new err?.code gate at net.ts:4005 never matches. Fix: every failure exit of bsd_create_listen_socket must write *error (map the EAI_* result to an errno as bsd_create_bound_socket does at bsd.c:1281-1287), so the JS error carries a code on this path like the socket()/bind() paths now do. [also at: packages/bun-usockets/src/bsd.c:1363 - On a host where getaddrinfo refuses the numeric :: literal without IPv6 (Windows with IPv6 uninstalled is the case the PR says it did not run), net.Server.listen(port) still fails with a code-less Failed to listen at :: and never reaches the new 0.0.0.0 retry. bsd.c:1363-1364 returns…]

    Extended reasoning...

    Pre-existing on the base branch (verified: base bsd.c:1363-1364 is byte-identical), but this PR is the change that makes bsd_create_listen_socket report errno for its other failure exits and adds a JS-side consumer that keys on err.code, which leaves this one exit as the sole code-less path in the same function. The sibling helper bsd_create_bound_socket at bsd.c:1281-1287 already writes *error on getaddrinfo failure, so the omission is an inconsistency within the same file. Trace: listen(0, "no.such.host"); net.ts:3839 clusterHost set; listenInCluster; kRealListen; Bun.listen at net.ts:4002; Listener.rs:469; context.c:387; bsd.c:1363 getaddrinfo returns EAI_NONAME; 1364 returns without touching *error; Listener.rs:537 errno==0 so no code/syscall/errno; net.ts:4005 rethrows; user sees Failed to listen at no.such.host with err.code undefined. Population: every caller with a DNS-resolved listen host, per listen…

    Verification: pre-existing (security-irrelevant). Triggering condition: net.Server.listen(port, "<unresolvable hostname>") on the primary/exclusive path, or Bun.listen({ hostname: "<unresolvable>" }). Mechanism verified in the head checkout: - /home/claude/bun/packages/bun-usockets/src/bsd.c:1363-1365 (unchanged by the PR): `if (getaddrinfo(host, port_string, &hints, &result)) { return… | pre-existing…

Comment thread src/js/node/net.ts
Comment thread test/js/node/net/node-net-server.test.ts
…address

internal_bsd_create_listen_socket_unix dropped the errno from
socket(AF_UNIX) the same way, so listen(path) failed without a code.

When the 0.0.0.0 fallback itself fails, the error named "::". Format
it with the address Bun.listen reports on the error, as Node does.

The test covers both, and logs a SKIP line when the seccomp filter
cannot be installed.
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the two findings outside the diff:

  • internal_bsd_create_listen_socket_unix (bsd.c:1548): fixed in this push. It now passes error to bsd_create_socket. The test stacks a second seccomp filter that fails socket(AF_UNIX) with EMFILE and checks that listen(path) reports code: "EMFILE".
  • getaddrinfo failures in bsd_create_listen_socket: not changed here. Report an unresolvable listen hostname as a getaddrinfo error #37690 is open for that path and maps the result to an error code.

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

The follow-up commit addresses both points from the earlier pass (error address now comes from err.address, seccomp install failure now skips). Beyond the inline findings, I also checked the cluster-worker kRealListen call at net.ts:4200 (it passes sharedFd, so it takes the fd branch and never reaches the retry) and the bsd.c out-param change for stale errno after an IPv6 bind failure followed by an IPv4 success (bsd_create_socket zeroes *err on entry, so the success path now reports 0 rather than the earlier bind errno).

Extended reasoning...

Two inline findings remain (the once(server, "error") fixture hang and the pre-existing retry scope narrower than Node's), so this is the findings-present ruled-out note only. Items ruled out from my own reading: the second kRealListen caller in the cluster-worker path passes an fd and never hits the Bun.listen retry, so the missing hostDefaulted argument there is harmless; _http_server.ts defines its own kRealListen and is unaffected; in bsd.c, threading error through bsd_create_socket inside the address loops cannot leave a stale code because that function resets *err to 0 on entry and bsd_bind_listen_fd only writes on failure, so a successful listen reports 0 and a failed one reports the last attempt's errno.

Comment thread test/js/node/net/node-net-server.test.ts Outdated
Comment thread src/js/node/net.ts

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

The latest push settles the fixture's listenError on both listening and error, which covers the point from my last pass; I re-reviewed the full diff and found no bugs, but since this changes net.Server.listen() semantics in a core module, a maintainer's look at the retry scope (EAFNOSUPPORT only, vs Node's retry on any synchronous :: failure) would still be worthwhile.

What was reviewed:

  • bsd.c: error is now reset to 0 by bsd_create_socket on each attempt and every later failure path in bsd_bind_listen_fd/us_internal_bind_and_listen writes it, so a stale code from the AF_INET6 attempt can no longer leak into a successful or failed AF_INET result; error is never NULL (Rust passes &mut c_int).
  • net.ts: hostDefaulted is only true on the clusterHost == null path, so explicit hosts, DNS-resolved hosts, unix paths and fd listens never hit the fallback; err.address from Listener.rs equals hostname on every non-fallback path, so existing error messages are unchanged.
  • Test: checked the ASAN lane concern with bun:ffi cc() — two existing tests (test/regression/issue/26249.test.ts, 20144) already compile via cc() without an ASAN skip, and the skip-on-seccomp-denied handling matches fs-stat-seccomp-linux.test.ts.
Extended reasoning...

Overview

The PR touches three files: packages/bun-usockets/src/bsd.c (pass the error out-param through to bsd_create_socket in both TCP family loops and the unix listen path), src/js/node/net.ts (new hostDefaulted argument to kRealListen, a try/catch around Bun.listen that retries with 0.0.0.0 when the host was defaulted and the code is EAFNOSUPPORT, use of err.address when formatting the emitted listen error, and an EAFNOSUPPORT entry in uvListenErrorDescription), and a new seccomp-based test in test/js/node/net/node-net-server.test.ts. The commit since my previous review (d738034a) is test-only and makes the fixture's listenError helper settle when listen() unexpectedly succeeds, which is what my prior inline note asked for.

Security risks

None identified. The C change only widens which errno is reported; it does not alter socket creation, binding, or privilege handling. The JS change is a narrowly gated retry (hostDefaulted && code === "EAFNOSUPPORT") that binds the IPv4 wildcard, which is exactly what Node does in the same situation. The test installs a seccomp filter in a child process only, via PR_SET_NO_NEW_PRIVS + SECCOMP_MODE_FILTER, and exits with a SKIP marker when the environment refuses it.

Level of scrutiny

Moderate. The blast radius is small: on any kernel with IPv6, the only observable change is that bsd_create_listen_socket now leaves *error == 0 after a successful later-family attempt (previously it could hold a stale code from a failed AF_INET6 bind, which callers never read on success anyway), and the emitted listen error uses err.address, which Listener.rs sets to the same hostname string net.ts passed in. I traced hostDefaulted: listenInCluster passes address == null, which is null only when clusterHost is null (no host given) or on the fd path, and the fd path never reaches Bun.listen. The DNS-lookup branch and cluster _getServer branch never pass hostDefaulted. The design choice that still merits a human look is that Node's setupListenHandle retries 0.0.0.0 after any synchronous failure of the defaulted :: bind, while this PR retries only on EAFNOSUPPORT; the author documents this as intentional, and I flagged it as non-blocking in a previous run.

Other factors

The bug hunt exited on a dry streak with no findings. The one candidate it examined (ASAN lane failure from cc()) is ruled out by precedent: test/regression/issue/26249.test.ts and test/regression/issue/20144 already run bun:ffi cc() in CI without an isASAN gate, and the ASAN skips in cc.test.ts are about TinyCC's error-path longjmp, which a successful compile does not hit. The test asserts exact error codes, messages, and addresses, drains stdout/stderr concurrently, and uses tempDir/await using. I did not run the test locally (no debug build available in this session), so CI is the arbiter of whether the seccomp filter installs on the Linux runners; the sibling fs-stat-seccomp-linux.test.ts suggests it does. Minor style nits (an it.skipIf without a reason string, process.platform !== "linux" instead of the harness isLinux) are not worth a separate comment.

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.

1 participant