Conversation
…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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review. WalkthroughThe change propagates native socket errors, adds IPv4 fallback for defaulted TCP hosts after ChangesListen error handling
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to 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)
Comment |
|
Updated 8:08 PM PT - Sep 17th, 2026
✅ @robobun, your commit d738034ac7d8d8c7493bf6fb5e89dc9f3eec9e00 passed in 🧪 To try this PR locally: bunx bun-pr 43201That installs a local version of the PR into your bun-43201 --bun |
|
Status Reproduced on bun 1.4.3-canary.1+b52d51348 with a seccomp filter that fails The new test in CI (build 117469) is green. The new test runs there and does not skip: |
There was a problem hiding this comment.
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 ofBun.listen({ unix })andnet.Server.listen(path)still get a bareFailed to listen at <path>with nocode,errnoorsyscallwhensocket(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'serrorout-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* errorfrom 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 reportslisten EMFILE: too many open files /tmp/x.sockwith code EMFILE, Bun reportsFailed to listen at /tmp/x.sockwith no code. Same on the base branch; the PR fixes only the two TCP loops. One-line change to passerror.Verification: pre-existing — triggers when
socket(AF_UNIX, ...)itself fails (fd exhaustion EMFILE/ENFILE, ENOBUFS/ENOMEM) forBun.listen({ unix })ornet.Server.listen(path). Mechanism verified:packages/bun-usockets/src/bsd.c:1545-1551internal_bsd_create_listen_socket_unix(..., int* error)callsbsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL)and onLIBUS_SOCKET_ERRORreturns immediately… -
🟣
packages/bun-usockets/src/bsd.c— Users who pass an unresolvable hostname to net.Server.listen or Bun.listen still get a bareFailed to listen at <host>with nocode,errnoorsyscallafter this merges, unlike Node'sENOTFOUND. bsd.c:1363-1364 returns LIBUS_SOCKET_ERROR on getaddrinfo failure without writing*error, so Listener.rs:537 attaches nothing and the newerr?.codegate 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-lessFailed 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.hostwith 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, orBun.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…
…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.
|
On the two findings outside the diff:
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:erroris now reset to 0 bybsd_create_socketon each attempt and every later failure path inbsd_bind_listen_fd/us_internal_bind_and_listenwrites it, so a stale code from the AF_INET6 attempt can no longer leak into a successful or failed AF_INET result;erroris never NULL (Rust passes&mut c_int).net.ts:hostDefaultedis only true on theclusterHost == nullpath, so explicit hosts, DNS-resolved hosts, unix paths and fd listens never hit the fallback;err.addressfromListener.rsequalshostnameon every non-fallback path, so existing error messages are unchanged.- Test: checked the ASAN lane concern with
bun:fficc()— two existing tests (test/regression/issue/26249.test.ts,20144) already compile viacc()without an ASAN skip, and the skip-on-seccomp-denied handling matchesfs-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.
Problem
ipv6.disable=1),net.createServer().listen(port)emitsError: Failed to listen at ::with nocode. Node binds0.0.0.0. So donode:httpandBun.serve().src/js/node/net.ts:3840turns a missing host into"::". For"::",getaddrinforeturns only an AF_INET6 entry, so uSockets has no IPv4 entry to try.bsd_create_listen_socket(packages/bun-usockets/src/bsd.c:1371) passesNULLas the errno out-param ofbsd_create_socket. TheEAFNOSUPPORTfromsocket()is lost andListener.rscannot setcode. The unix listen path (bsd.c:1548) does the same.Fix
errorout-param tobsd_create_socket.Bun.listen({ hostname: "::" })now throws withcode: "EAFNOSUPPORT",errnoandsyscall: "listen".net.tsretries with"0.0.0.0"when the host was defaulted and the code isEAFNOSUPPORT. An explicit"::"reportslisten EAFNOSUPPORT: address family not supported ::, as Node does.0.0.0.0, as in Node.test/js/node/net/node-net-server.test.ts(new test, stock bun fails it withFailed to listen at ::). Alsotcp-server.test.ts,net-syscall-fault.test.ts,cluster.test.ts.Background
Bun.listen,Bun.serveand node:net.bsd_create_listen_socketcallsgetaddrinfo, then tries each AF_INET6 entry, then each AF_INET entry.Listener.rsis the Rust side ofBun.listen. It builds the JS error from the C call'sint *errorout-param and setscodeonly when that is not 0.bun:ffi'sccin a child process. It installs a seccomp-bpf filter that failssocket(<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), thenSECCOMP_RET_ERRNO|EAFNOSUPPORTwhennr == __NR_socket && args[0] == AF_INET6, thenexecvp):Scope of the retry. Node's
setupListenHandleretries0.0.0.0after any synchronous failure of the::bind. This change retries only onEAFNOSUPPORT. Other codes (for exampleEACCES, where Node reports0.0.0.0:80) keep their current behavior.EADDRINUSEis not a retry case in Node either: libuv'suv__tcp_binddefers it tolisten(), 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)andbind("::")still succeed there.Cluster. A worker's primary binds through
SharedHandle(bsd_create_bound_socketwith a NULL host, which already walks both families) or throughRoundRobinHandle, which callsnet.Server.listenin the primary and gets this fallback.Windows.
bsd_create_socketreportsWSAGetLastError()andSystemErrno::initmapsWSAEAFNOSUPPORT, the same way the existingEADDRINUSEpath works. Not run on a Windows host without IPv6.Open PRs #36710 and #37690 touch lines next to this one in
bsd_create_listen_socketfor other errors (EADDRINUSEfrombind,getaddrinfofailures). The changes are independent.The
errnoout-param after a successful listen.bsd_create_socketresets*errto 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)withEMFILEto cover the unix listen path:listen(path)now reportscode: "EMFILE". It also keeps the first server open and listens on the same port again with no host: the error islisten EADDRINUSE: address already in use 0.0.0.0:PORTwitherr.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-eaddrinuseandtest-cluster-shared-handle-bind-error. The test runs on Linux x64 and arm64.getaddrinfofailures inbsd_create_listen_socketstill return without an error code. #37690 is open for that path.