Conversation
…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.
|
Warning Review limit reached
Next review available in: 18 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
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 |
…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.
|
Updated 8:24 AM PT - Aug 11th, 2026
✅ @robobun, your commit affebdaca6393de6d43952ff3b3a8cf2056a2e58 passed in 🧪 To try this PR locally: bunx bun-pr 37442That installs a local version of the PR into your bun-37442 --bun |
|
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 Checked on a debug build of this branch (4a11a1e) in that environment: the file passes 7 of 7, and the standalone form ( |
There was a problem hiding this comment.
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.
|
Another in-repo reproducer for the same mismatch, from a separate report about plain TCP ( Also checked the other half of that report, which suspected the connect path might stop at the first refused address: with the flag set, |
|
One more suite this fixes: |
|
One more report of the same mismatch reached me ( For the record, a complementary option on the listener side, in case it is wanted as a follow-up: resolving the listen hostname in |
What
On a Linux/glibc machine whose only IPv6 address is
::1(the default inside a container;/etc/hostsstill carries Docker's::1 localhostline), listening on the namelocalhostand connecting to the namelocalhostfrom the same process fails:The listener is bound to
::1(ssshows it there); every native client only ever tries127.0.0.1.test/js/web/fetch/fetch-preconnect.test.tsis 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.htmlbindslocalhost) 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:bsd_create_listen_socketinpackages/bun-usockets/src/bsd.c) callsgetaddrinfo()withAI_PASSIVEand, like upstream uSockets, prefers the AF_INET6 entry.localhosttherefore binds::1.internal::work_pool_callbackinsrc/runtime/dns_jsc/dns.rs, which backsfetch(),Bun.connect(), the WebSocket client,bun install, ...) callsgetaddrinfo()withAI_ADDRCONFIG. glibc implements that flag by checking whether any non-loopback address of the family is configured, so with no IPv6 oneth0the::1entry is filtered out and the name resolves to127.0.0.1only. The TCP connect path is happy to try every address it is given (start_connectionsopens up to 4 in parallel and advances on failure); it just never sees::1.AI_ADDRCONFIGitself 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_callbackresolves withAI_ADDRCONFIGexactly as before. If the name islocalhostor a name under it (ASCII case-insensitive likegetaddrinfo(), with or without the root dot;normalize_dns_namein this file already special-cases the same names fordns.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_resultstakes the leading family as a parameter now). So:[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 inus_quic_connect_resultcommits to one address) connects exactly where it did; the TCP path gains::1as a parallel candidate and reaches the listener;EAI_NONAMEretry 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/hostsread, 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 on127.0.0.1: one extraconnect()to::1per 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
::1forlocalhoston such a host. macOS libinfo exemptslocalhostfrom its equivalent ofAI_ADDRCONFIG(noted indns_sd.rs), musl'sAI_ADDRCONFIGdecides per family by probing the loopback address, and the Windows path never sets the flag. glibc was the only build where Bun's ownlocalhostlistener was unreachable from Bun's own clients. Browsers, curl, Python and Go all reach a::1-boundlocalhostserver in the same container.Not changed:
node:net/node:httpclients.lookupAndConnectinsrc/js/node/net.tsaddsdns.ADDRCONFIGitself, 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
localhostalready resolves to[::1, 127.0.0.1]) anhttp3fetch by name to a127.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):dnsIsLocalhostName(newbun:internal-for-testinghook 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,"");dnsCacheSeedgained 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).fetch()against a server bound to the name; and, where the system resolver mapslocalhostto::1at all (skipped otherwise), servers bound to::1reached through the name byfetch()(body isserver.requestIP()),Bun.connect()with"localhost"and"LOCALHOST"(remoteAddress), andnew WebSocket()(server echoesws.remoteAddress), all asserting"::1". On the affected hosts these five fail without the fix; elsewhere they are regression coverage for trying::1at all.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-buildgc()timeouts; they fail the same way on the released build.H3 check (server
http3: true,fetch(..., { protocol: "http3" })by the namelocalhost): bound to127.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::1first and turned the first case into a timeout, which is why the answered family is kept at the head.