Skip to content

dns: add the loopback family AI_ADDRCONFIG filters out of localhost lookups - #37442

Closed
robobun wants to merge 4 commits into
mainfrom
farm/f1935ed4/dns-localhost-addrconfig
Closed

robobun wants to merge 4 commits into
mainfrom
farm/f1935ed4/dns-localhost-addrconfig

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

On a Linux/glibc machine whose only IPv6 address is ::1 (the default inside a container; /etc/hosts still carries Docker's ::1 localhost line), listening on the name localhost and connecting to the name localhost from the same process fails:

using server = Bun.serve({ port: 0, hostname: "localhost", fetch: () => new Response("ok") });
await fetch(`http://localhost:${server.port}/`);
// TypeError: Unable to connect. Is the computer able to access the url?  code: "ConnectionRefused"

await Bun.connect({ hostname: "localhost", port, socket: { data() {} } });
// error: Failed to connect  code: "ECONNREFUSED"

new WebSocket(`ws://localhost:${port}/`);
// close 1006 "Failed to connect"

The listener is bound to ::1 (ss shows it there); every native client only ever tries 127.0.0.1. test/js/web/fetch/fetch-preconnect.test.ts is a ready-made reproducer: 11 of its 14 tests fail in such a container, on the released build as well as on main; test/js/bun/http/bun-serve-html-entry.test.ts (bun ./index.html binds localhost) fails 5 of 7 the same way. #36543 and #35160 worked around the same thing test by test.

Cause

Two different views of the same /etc/hosts:

  • The listen side (bsd_create_listen_socket in packages/bun-usockets/src/bsd.c) calls getaddrinfo() with AI_PASSIVE and, like upstream uSockets, prefers the AF_INET6 entry. localhost therefore binds ::1.
  • The connect side (internal::work_pool_callback in src/runtime/dns_jsc/dns.rs, which backs fetch(), Bun.connect(), the WebSocket client, bun install, ...) calls getaddrinfo() with AI_ADDRCONFIG. glibc implements that flag by checking whether any non-loopback address of the family is configured, so with no IPv6 on eth0 the ::1 entry is filtered out and the name resolves to 127.0.0.1 only. The TCP connect path is happy to try every address it is given (start_connections opens up to 4 in parallel and advances on failure); it just never sees ::1.

AI_ADDRCONFIG itself is worth keeping: on an IPv4-only machine it is what stops glibc from sending AAAA queries for every real hostname. Its "is this family configured" heuristic is just meaningless for names that resolve to loopback.

Fix

work_pool_callback resolves with AI_ADDRCONFIG exactly as before. If the name is localhost or a name under it (ASCII case-insensitive like getaddrinfo(), with or without the root dot; normalize_dns_name in this file already special-cases the same names for dns.lookup) and the answer contains a single family, it resolves once more without the flag and packs that answer with the originally answered family first (process_results takes the leading family as a parameter now). So:

  • dual-stack hosts (anything with a non-loopback IPv6 address, i.e. most dev machines and CI): the first answer already has both families, nothing changes, no second call;
  • the affected hosts: the list goes from [127.0.0.1] to [127.0.0.1, ::1]. Entry 0 is what it was before, so anything that only takes the first entry (the QUIC client in us_quic_connect_result commits to one address) connects exactly where it did; the TCP path gains ::1 as a parallel candidate and reaches the listener;
  • real hostnames: untouched, including the EAI_NONAME retry from Add fallback for ADDRCONFIG like Chrome's, avoid glibc UDP port 0 hangs #19753;
  • BUN_FEATURE_FLAG_DISABLE_IPV6 / DISABLE_IPV4: the second call is skipped when a family is forced.

The second getaddrinfo() is an /etc/hosts read, once per cache entry, only for localhost names and only on hosts where the first answer was single-family. The only behavioral cost is on those hosts when the server is on 127.0.0.1: one extra connect() to ::1 per new connection, refused synchronously by the kernel, in parallel with the one that succeeds.

Why this is the right thing to do even though Node (v26.3.0 checked on the same container) fails the same way here: Bun's other builds already return ::1 for localhost on such a host. macOS libinfo exempts localhost from its equivalent of AI_ADDRCONFIG (noted in dns_sd.rs), musl's AI_ADDRCONFIG decides per family by probing the loopback address, and the Windows path never sets the flag. glibc was the only build where Bun's own localhost listener was unreachable from Bun's own clients. Browsers, curl, Python and Go all reach a ::1-bound localhost server in the same container.

Not changed: node:net / node:http clients. lookupAndConnect in src/js/node/net.ts adds dns.ADDRCONFIG itself, exactly as Node's does, and keeps failing here exactly as Node does; that is a Node compat question and out of scope.

Found along the way and left for a separate fix: the QUIC client never falls back to the second resolved address, so on any dual-stack host (where localhost already resolves to [::1, 127.0.0.1]) an http3 fetch by name to a 127.0.0.1-bound server times out today. This PR keeps that behavior identical rather than fixing or worsening it (verified below).

Tests

test/js/bun/dns/dns-prefetch.test.ts (existing home of the internal resolver's tests):

  • Deterministic on every platform: dnsIsLocalhostName (new bun:internal-for-testing hook over the predicate) pins which names qualify (localhost, LOCALHOST, localhost., app.localhost, app.localhost., a.b.LocalHost) and which do not (notlocalhost, localhost.example, localhost2, localhost.., 127.0.0.1, ""); dnsCacheSeed gained the optional leading-family argument and a test pins the merge order (["::1","127.0.0.1"] with IPv4 answered first packs as [4, 6], the mirror case as [6, 4], unforced keeps list order).
  • End to end: fetch() against a server bound to the name; and, where the system resolver maps localhost to ::1 at all (skipped otherwise), servers bound to ::1 reached through the name by fetch() (body is server.requestIP()), Bun.connect() with "localhost" and "LOCALHOST" (remoteAddress), and new WebSocket() (server echoes ws.remoteAddress), all asserting "::1". On the affected hosts these five fail without the fix; elsewhere they are regression coverage for trying ::1 at all.
# container with `::1 localhost` in /etc/hosts and no IPv6 on eth0
USE_SYSTEM_BUN=1 bun test test/js/bun/dns/dns-prefetch.test.ts   # 5 end-to-end tests fail (ConnectionRefused / ECONNREFUSED / close 1006); hook tests cannot load
bun bd test test/js/bun/dns/dns-prefetch.test.ts                  # 9 pass
bun bd test test/js/web/fetch/fetch-preconnect.test.ts            # 14 pass (11 failed before)
bun bd test test/js/bun/http/bun-serve-html-entry.test.ts         # 7 pass (5 failed before)

Also run on the debug build: test/js/bun/dns/, test/js/bun/net/socket.test.ts, test/js/web/websocket/websocket.test.js, test/js/web/fetch/fetch-http3-client.test.ts, test/js/web/fetch/fetch.test.ts. Remaining failures there need the public internet, an IPv6 literal through this container's HTTP proxy, or are debug-build gc() timeouts; they fail the same way on the released build.

H3 check (server http3: true, fetch(..., { protocol: "http3" }) by the name localhost): bound to 127.0.0.1, released build 200 and this PR 200; bound to ::1, released build times out and this PR times out the same way. An earlier revision of this PR that simply dropped the flag put ::1 first and turned the first case into a timeout, which is why the answered family is kept at the head.

…solver

The connect-side resolver behind fetch(), Bun.connect() and WebSocket passes
AI_ADDRCONFIG to getaddrinfo(). glibc implements that flag by looking for
non-loopback addresses of each family, so on a machine whose only IPv6
address is ::1 it drops the "::1 localhost" line of /etc/hosts, while
Bun.listen()/Bun.serve() given the name "localhost" bind exactly that entry.
Connecting to the name then only ever tries 127.0.0.1 and is refused.

Skip the flag for "localhost" and names under it. macOS libinfo exempts
localhost from its equivalent filter and musl's AI_ADDRCONFIG probes loopback
itself, so this only changes glibc builds, and it makes them return the same
answer. The connect path already tries every returned address.
@coderabbitai

coderabbitai Bot commented Aug 11, 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: 18 minutes

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: 64fbf8f2-09f6-4ab1-86e8-e41a2a16d863

📥 Commits

Reviewing files that changed from the base of the PR and between 54d6d16 and affebda.

📒 Files selected for processing (4)
  • src/codegen/generate-js2native.ts
  • src/js/internal-for-testing.ts
  • src/runtime/dns_jsc/dns.rs
  • test/js/bun/dns/dns-prefetch.test.ts

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: closed in favor of #38818 (same bug; that PR implements the mechanism requested in review there, which also covers the hosts-file loopback names this PR did not reach).

Closing in favor of #38818, which fixes the same listen/connect mismatch for localhost the way the review there asked for: localhost / *.localhost (trailing dot included) are answered with [::1, 127.0.0.1] without consulting the resolver, and any other name whose AI_ADDRCONFIG answer is loopback addresses of a single family (ip6-localhost and the other hosts-file names) is looked up again without the flag. That covers everything this PR covered, including the in-repo reproducers listed in the comments above (fetch-preconnect, bun-serve-html-entry, socket.test.ts, bun-install --registry, bun-connect-x509), plus the hosts-file names this PR's name check did not reach. The name table from this PR's tests (trailing dots, case, localhost.., localhost.example) carried over there.

Comment thread test/js/bun/dns/dns-prefetch.test.ts Outdated
…family

Dropping AI_ADDRCONFIG outright put ::1 at the head of the list on the
affected hosts (glibc's /etc/hosts order), which changes where consumers that
only take the first entry connect: the QUIC client picks one address and does
not fall back, so an H3 fetch by name against a 127.0.0.1-bound server would
have started failing on exactly the hosts this is for.

Instead, resolve with AI_ADDRCONFIG as before and, for a localhost name whose
answer holds a single family, resolve again without the flag and interleave
that answer with the originally answered family first. The first entry is
unchanged everywhere; the other loopback family becomes an extra candidate
for the TCP connect path, which tries every entry.

Expose the name predicate through bun:internal-for-testing and let
dnsCacheSeed take the leading family, so the name set and the merge order are
checked on every platform; the end-to-end tests now bind ::1 and assert the
client reached it through the name, skipped where localhost has no ::1 entry.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:24 AM PT - Aug 11th, 2026

@robobun, your commit affebdaca6393de6d43952ff3b3a8cf2056a2e58 passed in Build #92310! 🎉


🧪   To try this PR locally:

bunx bun-pr 37442

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

bun-37442 --bun

Comment thread src/js/internal-for-testing.ts Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
@robobun robobun changed the title dns: resolve localhost without AI_ADDRCONFIG so native clients reach a ::1-bound localhost listener dns: add the AI_ADDRCONFIG-filtered loopback family for localhost names so native clients reach a ::1-bound listener Aug 11, 2026
@robobun robobun changed the title dns: add the AI_ADDRCONFIG-filtered loopback family for localhost names so native clients reach a ::1-bound listener dns: add the loopback family AI_ADDRCONFIG filters out of localhost lookups Aug 11, 2026
Comment thread src/runtime/dns_jsc/dns.rs
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

One more in-repo reproducer for this, noticed while triaging a separate report of the same listen/connect mismatch: on a glibc host whose only IPv6 address is ::1 (getaddrinfo("localhost") with AI_ADDRCONFIG returns 127.0.0.1 only, while the AI_PASSIVE lookup the listener uses returns ::1 first), test/js/bun/http/bun-serve-html-entry.test.ts fails 5 of 7 tests with the released 1.4.0 (USE_SYSTEM_BUN=1): bun ./index.html binds localhost, so the listener lands on ::1, and the test's fetch() of the printed http://localhost:<port>/ fails with ConnectionRefused.

Checked on a debug build of this branch (4a11a1e) in that environment: the file passes 7 of 7, and the standalone form (Bun.serve({ hostname: "localhost", port: 0 }) followed by fetch(server.url), i.e. the test this PR adds) goes from ConnectionRefused to 200 while the listener itself still sits on ::1 only, as described above.

@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 again after the trailing-dot and test-handler feedback was addressed and found no further issues. Because it changes the connect-path DNS resolver behind fetch()/Bun.connect()/bun install — including new unsafe FFI that swaps ownership between two getaddrinfo result lists — and makes a deliberate choice to diverge from Node's localhost behavior on glibc, a maintainer sign-off would still be worthwhile.

Checked: add_filtered_loopback_family frees exactly one list on every path and leaves *addrinfo valid for the caller's after_result free; the hints.ai_family != AF_UNSPEC guard skips the second call under DISABLE_IPV6/4; ZBox::as_bytes() excludes the NUL so is_localhost_name sees the bare name; the process_results change is a no-op when AF_UNSPEC is passed (Windows, non-localhost, and all seeded callers).

Extended reasoning...

Overview

The PR changes the internal connect-path DNS resolver in src/runtime/dns_jsc/dns.rs so that when getaddrinfo("localhost", ..., AI_ADDRCONFIG) returns only one address family (glibc's behavior on hosts whose only IPv6 address is ::1), a second getaddrinfo without the flag is issued and its result replaces the first, with the originally-answered family kept at index 0. It adds is_localhost_name (RFC 6761 name predicate, case-insensitive, one trailing dot stripped), add_filtered_loopback_family (the unsafe swap-and-free helper), threads a first_family parameter through process_results/after_result, and exposes two bun:internal-for-testing hooks. Tests in test/js/bun/dns/dns-prefetch.test.ts pin the name set, the merge order, and end-to-end reachability of a ::1-bound server via fetch/Bun.connect/WebSocket.

Security risks

None identified. The change is scoped to names matching localhost / *.localhost (with optional root dot) and only appends a loopback address the system resolver already knows about; it cannot redirect real hostnames. The predicate rejects notlocalhost, localhost.example, and localhost2 (test-pinned), so no suffix-confusion.

Level of scrutiny

High. work_pool_callback backs every native TCP/QUIC connect in the runtime (fetch, Bun.connect, WebSocket client, bun install). add_filtered_loopback_family is ~35 lines of unsafe code that walks a raw addrinfo list, calls libc::getaddrinfo a second time, frees the first list with bun_dns::freeaddrinfo, and hands the replacement back to the caller for it to free — the classic double-owner shape REVIEW.md's memory-safety section flags. I traced each exit: single-family-detected-and-second-call-succeeds frees old and returns new; second-call-fails leaves old in place and returns AF_UNSPEC; mixed-families and forced-family return early without allocating. The caller only reads hints before this call and only frees addrinfo after it, so the in-place mutation of both is sound.

Other factors

Both prior inline comments from earlier runs (missing error/close handlers on the Bun.connect test; trailing-dot FQDN form of localhost) have been addressed — the connect test now asserts socket.remoteAddress synchronously so there is no unsettled promise, and is_localhost_name now strips one trailing . with localhost., app.localhost. → true and localhost.. → false pinned in the test table. The comment-cop bot's long-comment flags were resolved in 4a11a1e. What keeps this from auto-approval is not any open concern but the combination of critical-path scope, unsafe pointer ownership transfer, and the explicit policy decision (documented in the PR description) to diverge from Node's behavior for localhost on glibc — that call is a maintainer's to make.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Another in-repo reproducer for the same mismatch, from a separate report about plain TCP (Bun.listen({ hostname: "localhost" }) binds [::1], Bun.connect({ hostname: "localhost" }) rejects with ECONNREFUSED): in a container with the standard Debian /etc/hosts (127.0.0.1 localhost + ::1 localhost) and no IPv6 address besides ::1, test/js/bun/net/socket.test.ts fails 11 tests on the released build (1.4.0-canary, 9008ae7), 10 of them with ECONNREFUSED from listen+connect on "localhost" (socket.timeout works, connect() should return the socket object, it should only call open once, should not leak file descriptors when connecting, should allow large amounts of data to be sent and received, ...). With BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG=1 as a stand-in for this change, those 10 pass and the only remaining failure is the one that needs www.example.com.

Also checked the other half of that report, which suspected the connect path might stop at the first refused address: with the flag set, getaddrinfo("localhost") here returns [::1, 127.0.0.1], and Bun.connect({ hostname: "localhost" }) to a listener bound to 127.0.0.1 only still connects (::1 refused, falls through to 127.0.0.1). So the resolver filtering is the only thing standing between the name-based connect and a ::1-bound listener, and this change is sufficient for Bun.connect as well.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

One more suite this fixes: test/cli/install/bun-install.test.ts "should support --registry CLI flag" (Bun.listen on localhost, then bun install --registry http://localhost:PORT/). In a container with ::1 localhost in /etc/hosts and no IPv6 beyond loopback it fails on clean main at 9a543cc (ConnectionRefused, the listener's connected mock never fires) and passes at affebda, so the bun install client path is covered by this change as well.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

One more report of the same mismatch reached me (Bun.listen + Bun.connect on "localhost", and net.createServer().listen(0, "localhost") + net.connect({ host: "localhost" })), from a container of the same shape (::1 is the only IPv6 address, /etc/hosts maps localhost to both families). Additional in-repo reproducer: test/js/bun/http/bun-connect-x509.test.ts, whose two Bun.listen({ hostname: "localhost" }) tests fail with ECONNREFUSED there (checked on 1.4.0-canary.1+b7a043103; ss shows the listener on [::1] only). Not opening a separate PR since this one covers the report.

For the record, a complementary option on the listener side, in case it is wanted as a follow-up: resolving the listen hostname in bsd_create_listen_socket (packages/bun-usockets/src/bsd.c) with AI_PASSIVE | AI_ADDRCONFIG, retrying without the flag when that lookup fails, makes localhost bind 127.0.0.1 on exactly these hosts, which is the address every AI_ADDRCONFIG client is handed (this resolver, node:net / node:http in Bun, and Node itself), and is a no-op wherever getaddrinfo already returns both families. That would also cover the node:net half without touching its client-side lookup. glibc 2.41 on this box, host localhost: AI_PASSIVE returns ::1, 127.0.0.1; AI_PASSIVE | AI_ADDRCONFIG returns 127.0.0.1 only; the literals ::1 and :: fail with EAI_ADDRFAMILY under AI_ADDRCONFIG, hence the retry, and a NULL host would need to stay unfiltered so the default listener keeps binding dual-stack ::.

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