dns(macOS): replace getaddrinfo_async_start with DNSServiceGetAddrInfo over a shared connection

Merged
RO
3mo ago
+1279-702

Problem

On macOS, dns.lookup() and the internal connect-path resolver use libinfo's getaddrinfo_async_start. Despite the name, libinfo implements this by dispatching a blocking si_addrinfo onto the libdispatch overcommit root queue (si_module.c:1173):

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, DISPATCH_QUEUE_OVERCOMMIT), ^{ si_async_launchpad(req); });

So each distinct in-flight lookup parks one worker thread in-process. Measured on Darwin 25.5 arm64 with distinct unresolvable hostnames (to defeat same-host coalescing), threads via ps -M:

N=50    baseline threads=9  -> PEAK threads=64
N=200   baseline threads=9  -> PEAK threads=214
N=500   baseline threads=9  -> PEAK threads=513  (+26 MB RSS)
N=1000  baseline threads=9  -> PEAK threads=525

Linear until libdispatch's own ~512-thread ceiling; threads do not retire promptly after completion.

Why this is the same resolver

libinfo's search_addrinfo only consults modules that implement sim_addrinfo; ds_module (Open Directory / LDAP) sets it to NULL with the literal comment /* host lookups not supported */ (ds_module.c:2241-2275). Only mdns_module (→ DNSServiceQueryRecord → mDNSResponder) and file_module (→ /etc/hosts) do. mDNSResponder itself watches /etc/hosts. So getaddrinfo() on macOS = mDNSResponder + /etc/hosts, and the thread wrapper is the only thing this change removes.

Fix

Talk to mDNSResponder directly via DNSServiceGetAddrInfo over a single DNSServiceCreateConnection + kDNSServiceFlagsShareConnection primary: one Unix-domain socket to the daemon, one kqueue knote, zero extra threads regardless of in-flight count. Both dns.lookup() (the GetAddrInfoRequest path) and the internal connect-path resolver (internal::Request) now go through the shared connection.

Completion on a shared connection can't use kDNSServiceFlagsMoreComing (it's collective across all sub-refs). Instead this tracks per-family pending bits: dnssd_clientstub always passes a family-tagged sockaddr in the callback, including for NoSuchRecord and Timeout (dnssd_clientstub.c handle_addrinfo_response), so a query is ready once every requested family has reported in. kDNSServiceFlagsReturnIntermediates + kDNSServiceFlagsTimeout ensure each family eventually does.

If mDNSResponder is unreachable (DNSServiceCreateConnection fails, e.g. in a sandbox), both paths fall back to the existing blocking getaddrinfo on the work pool.

Deleted: the lib_info dlopen/dlsym module, getaddrinfo_send_reply (the mach-port reply bridge in io_darwin.cpp), the per-request machport FilePolls.

Test

Covered by the existing DNS suites, which run through this backend on macOS: test/js/node/dns/node-dns.test.js (119), test/js/bun/dns/dns-interleave.test.ts, test/js/node/net/node-net.test.ts (67), test/js/node/dgram/.

Thread behaviour was measured by hand rather than asserted in CI: 200 concurrent dns.lookup() of distinct .invalid hostnames, sampling ps -M <pid> while in flight — peak ≈ baseline + 200 before, ≈ baseline after. An earlier dns-sd-threads.test.ts asserting this was dropped at the maintainer's request.

Notes

  • protocol is passed as IPv4|IPv6 explicitly (not 0), because the completion check needs to know which families were requested. This drops the daemon's "skip family with no routable address" heuristic (the AI_ADDRCONFIG equivalent). The existing BUN_FEATURE_FLAG_DISABLE_IPV4/IPV6 env vars still constrain it, and happy-eyeballs handles unreachable addresses at connect time.
  • BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO=1 still forces the work-pool fallback (name kept for compat).

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/dns/dns-sd-threads.test.ts