Skip to content

udp: report an unresolvable bind hostname as a getaddrinfo error - #37707

Open
robobun wants to merge 1 commit into
mainfrom
farm/aae8eba0/udp-bind-dns-error
Open

robobun wants to merge 1 commit into
mainfrom
farm/aae8eba0/udp-bind-dns-error

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.udpSocket({ hostname }) with a hostname that does not resolve rejects with an errno-shaped error: bind ENOENT <host> on glibc, bind ESRCH <host> with no resolver reachable, bind ENOEXEC <host> on macOS. Node reports getaddrinfo ENOTFOUND <host>.
  • The name depends on which EAI_* value getaddrinfo returned and on the platform's sign for it, so one failure is reported differently per OS and per resolver state.
  • Cause: the UDP bind path in usockets returned the getaddrinfo code through the out-parameter every other failure uses for an errno, and the caller named it as whatever errno shares that number.

Fix

  • The getaddrinfo code comes back through a separate dns_error out-parameter and the errno parameter stays 0 on that path, so a resolver code can no longer be read as an errno.
  • Bun.udpSocket maps it the way Bun.connect and fetch() already map a failed lookup (Report DNS lookup failures from fetch() and Bun.connect as ENOTFOUND #32990; Report an unresolvable listen hostname as a getaddrinfo error #37690 does the same for Bun.listen / Bun.serve) and throws getaddrinfo ENOTFOUND <host> with syscall and hostname set, as Node does. Real bind failures (bind EADDRINUSE <host> with .address) and { fd } adoption are unchanged.
  • Other callers: quic ignores the new parameter, except node:quic, which uses dns_error as its close status when the errno is 0. node:dgram resolves through dns.lookup first, so it only reaches this branch via a custom lookup returning a non-IP string.
  • Verification: a new test fails on the released build with the bind ENOENT shape and passes with this change; a second pins the EADDRINUSE shape. CI is green on glibc, musl, macOS and Windows. The numeric errno still differs from Node and is deliberately not asserted.

Background

  • getaddrinfo(3) returns its own EAI_* codes, not errno values; they are negative on glibc (EAI_NONAME = -2) and positive on macOS (EAI_NONAME = 8), so the same integer in the errno channel names a different errno on each platform.
  • usockets (packages/bun-usockets) is the C layer under Bun's sockets. It reports failures to Rust through int * out-parameters, and Rust turns an errno into the JS code string via SystemErrno.
  • c_ares::Error::init_eai is Bun's shared mapping from a raw getaddrinfo code to the lookup error it reports elsewhere (ENOTFOUND and friends; on Windows it folds WSA codes into ENOTFOUND). A separate dns_error channel fed into it is how Bun.connect and fetch() already report lookup failures.
Original description

Repro

const host = Buffer.alloc(64, "a").toString() + ".com"; // 64-byte label: getaddrinfo rejects it locally
try {
  await Bun.udpSocket({ hostname: host, port: 0 });
} catch (e) {
  console.log(e.code, e.syscall, e.hostname, e.message);
}

Before (Linux, 1.4.0 and main):

ENOENT bind undefined bind ENOENT aaaa....com

The code depends on which EAI_* value getaddrinfo returned: EAI_NONAME (-2 on glibc) comes out as ENOENT, EAI_AGAIN (-3, e.g. no resolver reachable) as bind ESRCH <host>. On macOS the constants are positive (EAI_NONAME = 8), so the negated value is abs()ed back into ENOEXEC.

After:

ENOTFOUND getaddrinfo aaaa....com getaddrinfo ENOTFOUND aaaa....com

Cause

bsd_create_udp_socket() in packages/bun-usockets/src/bsd.c did *err = -gai_result; when getaddrinfo() failed. Every other write to err in that function is an errno, and the consumer (UDPSocket::udp_socket in src/runtime/socket/udp_socket.rs) maps it with SystemErrno::init and reports syscall: "bind", so a resolver return code was being named as whatever errno shares its number.

Fix

bsd_create_udp_socket() / us_create_udp_socket() gain a separate int *dns_error out-parameter that receives the raw getaddrinfo() return code (err stays 0 on that path), threaded through uws::udp::Socket::create. udp_socket.rs maps it with c_ares::Error::init_eai and builds the error with system_error_with_syscall_and_hostname(.., "getaddrinfo", hostname). The errno path (bind EADDRINUSE <host> with .address, open <code> for { fd } adoption) is unchanged.

Why this shape: it is the one Bun already uses for a failed lookup everywhere else. Bun.connect and fetch() carry the raw code on a separate dns_error channel and map it with init_eai (#32990), #37690 does the same for Bun.listen / Bun.serve, and this PR is the UDP bind counterpart. It is also what Node reports for the same input: dgram.createSocket("udp4").bind({ address: host }) on Node 26.3 emits getaddrinfo ENOTFOUND <host> with code: "ENOTFOUND", syscall: "getaddrinfo", hostname set and no address, both for the 64-byte label and for name-shaped strings such as "256.256.256.256" (getaddrinfo treats those as names too, so they take the same new branch). The remaining difference from Node is the numeric errno (Node uses the libuv code, Bun the same c-ares value Bun.connect / fetch() report), which the tests deliberately do not pin. Only the errno path is platform-specific on purpose: on Windows init_eai folds the raw WSA codes into ENOTFOUND, exactly as it already does for Bun.connect / fetch().

node:dgram is not affected in practice: with the default lookup it resolves through dns.lookup and hands Bun.udpSocket an IP, so this branch is only reachable through a custom lookup that yields a non-IP string, and that case now gets the resolver error instead of the mis-named errno.

Other us_create_udp_socket callers: the three in quic.c never read the error and pass NULL; node:quic's ensure_bound() always binds an IP literal, and now falls back to dns_error for the close status so a (memory/system) resolver failure there still reports a nonzero status instead of 0.

Not changed here: the UDP connect path already reports a resolver error (connect ENOTFOUND <host>, name DNSException); the existing synchronous throw vs. rejection behaviour is #35217's subject.

Verification

New tests in test/js/bun/udp/udp_socket.test.ts (bind failure errors): the unresolvable-hostname case fails on the released build with the bind ENOENT shape above and passes with this change; the second test pins the bind EADDRINUSE 127.0.0.1 / .address shape of a real bind failure.

bun bd test test/js/bun/udp/        # 272 pass
test/js/node/test/parallel/test-dgram-{error-message-address,bind-error-repeat,bind-fd-error,bind,custom-lookup}.js
test/js/node/test/parallel/test-quic-endpoint-bind{,-failure}.mjs

CI is green on every lane, so the ENOTFOUND expectation holds for the libc getaddrinfo on macOS, Windows and musl as well as glibc.

bsd_create_udp_socket() wrote the negated getaddrinfo() return code into
the errno out-parameter when the host failed to resolve, so
Bun.udpSocket({ hostname }) reported whichever errno shares that number:
"bind ENOENT <host>" for EAI_NONAME on glibc, "bind ESRCH" for EAI_AGAIN,
"bind ENOEXEC" on macOS.

Give bsd_create_udp_socket() / us_create_udp_socket() a separate
dns_error out-parameter carrying the raw getaddrinfo() code, the same
channel the TCP connect and listen paths use, and have udp_socket.rs map
it with c_ares::Error::init_eai into the resolver error Bun.connect and
fetch() already produce: "getaddrinfo ENOTFOUND <host>" with code,
syscall and hostname set. Genuine socket/bind errno failures are
reported as before.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b37245e6-9bb6-4723-848b-bc3c9dc9d1e3

📥 Commits

Reviewing files that changed from the base of the PR and between e7abdf7 and 780fddc.

📒 Files selected for processing (9)
  • packages/bun-usockets/src/bsd.c
  • packages/bun-usockets/src/internal/networking/bsd.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/quic.c
  • packages/bun-usockets/src/udp.c
  • src/runtime/node/quic/endpoint.rs
  • src/runtime/socket/udp_socket.rs
  • src/uws_sys/udp.rs
  • test/js/bun/udp/udp_socket.test.ts

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:47 PM PT - Aug 11th, 2026

✅ @robobun, your commit 780fddc85c75d58a3191ac5ea740b34bc4df6dac passed in Build #92716! 🎉


🧪   To try this PR locally:

bunx bun-pr 37707

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

bun-37707 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the released build (1.4.0) and on main: Bun.udpSocket({ hostname: "<64-byte label>.com" }) rejects with bind ENOENT <host> (EAI_NONAME negated into errno 2); with no resolver reachable it is bind ESRCH <host> instead. With this PR both report getaddrinfo ENOTFOUND <host> / getaddrinfo ETIMEOUT <host> with syscall: "getaddrinfo" and hostname set, the shape Bun.connect and fetch() already use (and #37690 adds for Bun.listen), and the shape Node 26 emits from dgram bind() for the same input.

Test: test/js/bun/udp/udp_socket.test.ts > bind failure errors (fails on the released build, passes with this branch).

CI build 92716 is green on every lane (the only retries were unrelated install/napi tests). Review turned up nothing that needs a code change. Ready for a maintainer.

@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 reviewed this PR and didn't find any bugs. Because it threads an FFI signature change across the C/Rust boundary (bsd.c → udp.c → uws_sys → two Rust callers) and changes user-visible error shapes, a human look would still be worthwhile.

What was reviewed:

  • Verified every bsd_create_udp_socket / us_create_udp_socket / uws::udp::Socket::create call site is updated for the new dns_error out-param (quic.c passes NULL, endpoint.rs and udp_socket.rs pass a local).
  • Checked init_eai(0) returns None on all platforms and the Windows arm's _ => ENOTFOUND catch-all handles raw WSA getaddrinfo codes; the .filter(|_| dns_error != 0) is redundant but harmless.
  • Confirmed the drop(hostname_z) removal is required (now read in the new error branch) and ZBox::as_bytes() excludes the NUL.
  • The new tests use a 64-byte DNS label (no network round-trip) and bind without reuse flags, so both should be hermetic.
Extended reasoning...

Overview

This PR fixes UDP bind error reporting when getaddrinfo() fails: previously the raw EAI_* code was written to the errno out-param and misreported as bind ENOENT/bind ESRCH/bind ENOEXEC depending on platform. The fix adds a separate int *dns_error out-parameter to bsd_create_udp_socket / us_create_udp_socket, threads it through the Rust FFI wrapper, and has UDPSocket::udp_socket map it via c_ares::Error::init_eai + system_error_with_syscall_and_hostname — the exact pattern already used by Bun.connect, fetch(), and (per #37690) Bun.listen. Nine files touched: 5 in bun-usockets (C), 3 Rust files, plus tests.

Security risks

None identified. This is error-message shaping; no new input parsing, no changes to what gets bound or resolved. The unresolvable-hostname test uses an RFC-1035-illegal label so resolution fails locally without touching the network.

Level of scrutiny

Medium. The change is mechanically straightforward and follows a precedent set twice before in this codebase, but it is an FFI ABI change coordinated across C headers, C implementations, and Rust extern declarations — a mismatch here would be a silent miscompile. I grepped every caller of both functions and the Rust wrapper and confirmed all are updated with matching arity/position. The quic.c callers correctly pass NULL (they bind IP literals or ignore the error), and endpoint.rs sensibly falls back to dns_error for the close status so a resolver-level failure there doesn't report status 0.

Other factors

  • The removed drop(hostname_z) is intentional: the new DNS-error branch reads hostname_z.as_bytes() to populate the hostname field, so it must survive past Socket::create. ZBox::as_bytes() excludes the trailing NUL, so the reported hostname is clean.
  • init_eai(0) returns None on both the Windows and POSIX arms, so the .filter(|_| dns_error != 0) is belt-and-suspenders; the fd-adoption path (which never touches dns_error) can't accidentally take the DNS branch.
  • Tests cover both the new resolver-error shape and pin the unchanged bind EADDRINUSE errno shape. The EADDRINUSE test binds two sockets to the same 127.0.0.1:port with no reuse flags, which should fail deterministically; if it turns out flaky on a particular CI lane that would be worth a second look, but it looks sound.
  • Given the cross-language ABI coordination and the user-visible behavior change, deferring to a human reviewer rather than auto-approving.

@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #40986, which adds a bun_dns::is_valid_hostname check to Bun.udpSocket (bind and the connect option), Bun.serve and Bun.listen before the synchronous getaddrinfo (the bind-side counterpart of #40970).

If #40986 lands first, the 64-byte label this test uses is answered in-process with getaddrinfo ENOTFOUND <host> and never reaches the new dns_error out-param. The assertion still passes, but it no longer fails without this change. A well-formed name that glibc rejects locally keeps the test hermetic on Linux: a*b.com, a/b.com, -a.com and ünicode.com all return EAI_NONAME without a query, and is_valid_hostname accepts all four. macOS and Windows need a check in CI.

The two changes are independent otherwise: #40986 does not touch bsd.c, udp.c or udp.rs, and the nxdomain.invalid case is still this PR's.

Jarred-Sumner pushed a commit that referenced this pull request Aug 30, 2026
#40986)

### Problem
- `Bun.serve`, `Bun.listen` and `Bun.udpSocket` (bind and connect)
resolve their hostname with a synchronous `getaddrinfo` in uSockets
(`packages/bun-usockets/src/bsd.c:1340`, `:1733`, `:1818`), even for a
name that can never be a hostname. Some resolvers never answer such a
query, and the call blocks the JS thread (#40970 saw this on the connect
side).
- Its error is also wrong: `Bun.serve({ hostname: "this is not a
hostname" })` throws `ENOENT: no such file or directory, listen`,
`Bun.listen` a bare `Failed to listen at <host>`, `Bun.udpSocket` `bind
ENOENT <host>`, and `dgram`'s `connect()` reports success.

### Fix
- Each path checks the hostname with `bun_dns::is_valid_hostname` before
it calls into uSockets. A rejected name throws `getaddrinfo ENOTFOUND
<host>` with `code`, `syscall` and `hostname`: what `Bun.connect`
reports for it and what Node emits from `server.listen()`.
- `Bun.serve` now strips brackets only around an IPv6 literal: `"[::1]"`
still binds, `"[foo]"` no longer resolves as `foo`.
- A well-formed name that does not resolve (`nxdomain.invalid`) still
gets the wrong errno. #37690 (serve/listen) and #37707 (udp) fix that.
This PR does not touch `bsd.c` or fix #25765.
- Verified: 18 new cases in `test/js/bun/http/serve-listen.test.ts`,
`test/js/bun/net/socket-dns-error.test.ts`,
`test/js/bun/udp/udp_socket.test.ts` and `test/js/bun/udp/dgram.test.ts`
fail on 1.4.1 and pass here. Also ran the serve, socket, net and dgram
suites.

### Background
- `is_valid_hostname` (`src/dns/lib.rs`) accepts an IP literal or an RFC
1035 name (labels of 1 to 63 bytes from the c-ares set, 253 total).
- Node resolves with `dns.lookup` before it binds, so no name reaches a
blocking call.
- Self-reviewed: 2 concerns, both addressed (an unchecked udp `connect`
path; the framing is now the resolver skip).

<details><summary>Notes</summary>

What is and is not shown here:

- The blocking resolver is not observed in this PR. It is the same libc
`getaddrinfo` call, on the same names, that #40970 measured on the
connect side (`dns.lookup` waiting out the macOS resolver timeout for a
label with a space). Linux glibc rejects these names locally, so on
Linux the visible change is only the error.
- No user report exists for a malformed bind hostname. #25765
(`something.localhost` on macOS) is a well-formed name and is #37690's
case.

Repro on 1.4.1 (Linux, no resolver needed for the first four names):

```
serve "this is not a hostname" => ENOENT listen "ENOENT: no such file or directory, listen"
listen "this is not a hostname" => (no code) "Failed to listen at this is not a hostname"
udpSocket "this is not a hostname" => ENOENT bind "bind ENOENT this is not a hostname"
udpSocket connect { hostname: "this is not a hostname" } => DNSException "connect ENOTFOUND this is not a hostname"
dgram socket.connect(1234, "this is not a hostname") via a custom lookup => no error, socket marked connected
serve "aaaa...(64).com" => EMSGSIZE listen
serve "nxdomain.invalid" => EAGAIN listen (left to #37690)
udpSocket "nxdomain.invalid" => ESRCH bind (left to #37707)
```

After this change the first six report `getaddrinfo ENOTFOUND <host>`
(`name: "Error"`, `code: "ENOTFOUND"`, `syscall: "getaddrinfo"`,
`hostname`). `node:net`, `node:http` and `node:dgram` (custom `lookup`)
servers emit the same error object from `listen()` / `bind()`.

Where the check sits:

- `Bun.serve`: at the top of `NewServer::listen`
(`src/runtime/server/mod.rs`), before the uws app is created. The
existing `deinit` path frees the server and `Bun.serve` throws
synchronously, as it does for `EADDRINUSE`. The bracket strip now lives
in one function, `strip_ipv6_brackets`, used by the check and by the
existing `Addr` code. It used to drop the first and last byte of any
hostname that starts with `[`, so `[foo]` resolved as `foo`
(`[localhost]`, `[127.0.0.1]` and `[example.com` were already rejected
by the base URL parse). It now strips only when the inside is an IPv6
literal (`to_ip_address`, so a `%zone` still passes); anything else
keeps its brackets and fails `is_valid_hostname`.
- `Bun.listen` (`src/runtime/socket/Listener.rs`): right after
`SocketConfig::from_js`, only when a port is set (a unix path or an
adopted fd has no hostname). `SocketConfig::from_js` itself is shared
with `Bun.connect`, whose error is delivered asynchronously, so the
check is not placed there.
- `Bun.udpSocket` (`src/runtime/socket/udp_socket.rs`): bind and
`connect.hostname` are both checked right after the config parse, before
the socket is created. Bind failures there throw synchronously today,
and so does this. The `connect` option used to report these names as
`connect ENOTFOUND <host>` (`DNSException`) from the `EAI_*` code; a
well-formed unresolvable `connect.hostname` still does, that block is
#38622's.
- The internal `UDPSocket.jsConnect` that `node:dgram`'s
`socket.connect()` calls gets the same check. `dgram` resolves with
`dns.lookup` first, so only a custom `lookup` can hand it a raw name.
glibc rejected such a name with a code `jsConnect` never read (it checks
for `-1` only), so the socket was marked connected with no error. #38622
reworks that return-code handling; the check here sits before the call
and does not overlap it.

The error is built by `not_a_hostname_error` in
`src/runtime/dns_jsc/cares_jsc.rs` from
`system_error_with_syscall_and_hostname`, the helper `Bun.connect` uses.

For #37690 and #37707: their tests use a 64-byte label, which
`is_valid_hostname` rejects, so once this lands that input is answered
in-process and no longer exercises the `dns_error` plumbing. glibc
rejects `a*b.com`, `a/b.com`, `-a.com` and `ünicode.com` locally
(`EAI_NONAME`, no query sent) while `is_valid_hostname` accepts all
four. Other platforms need checking in CI.

`Bun.listen({ hostname: "[::1]" })` and `Bun.udpSocket({ hostname:
"[::1]" })` never stripped brackets and already fail on 1.4.1 (`Failed
to listen at [::1]`, `bind ENOENT [::1]`). They now fail with
`getaddrinfo ENOTFOUND [::1]`.

Suites run on the debug build: `serve-listen.test.ts` (37 pass),
`socket-dns-error.test.ts` (11 pass), `udp_socket.test.ts` (214 pass),
`dgram.test.ts` + `udp_socket_recv_flags.test.ts` (64 pass),
`serve.test.ts` + `serve-http3.test.ts` + `server-url-invalid.test.ts`
(349 pass, 2 environment failures: root can bind port 1003, egress
proxy), `node-net-server.test.ts`, `serve-epoll-add-fail.test.ts`,
`test/internal/source-lints/` (171 pass), node `test-dgram-bind*`,
`test-dgram-connect*`, `test-dgram-custom-lookup`,
`test-dgram-error-message-address`, `test-net-listen-error`,
`test-http-listening`. `socket.test.ts` and `node-net.test.ts` have the
same `localhost` dual-stack failures on the released bun in this
container.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/udp/udp_socket.test.ts, test/js/bun/udp/dgram.test.ts,
test/js/bun/http/serve-listen.test.ts

<!-- robobun:evidence:end -->

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