Repository navigation
Replace c-ares with a DNS resolver in safe Rust - #47076
dylan-conway wants to merge 40 commits into
Conversation
The dependency, its update workflow and its version macro are gone. `process.versions.ares` stays, as in Node.js, and now reports a release number instead of a commit. The build does not link after this commit: `bun_cares_sys` still declares the `ares_*` functions. The following commits replace them with Rust.
The crate does no I/O itself: sockets, files, the clock and randomness come through an `Io` trait, and finished requests wait in a queue instead of calling back. It follows c-ares 1.34.9 as Bun configured it. bun_cares_sys is deleted. bun_runtime still refers to it, so the build stays broken until the runtime moves over.
The runtime's Resolver now owns a `Channel` over `SystemIo`, which answers the crate's `Io` trait with bun_sys sockets and the event loop's polls. One map from request key to waiting promises replaces the sixteen 32-slot pending caches and their intrusive lists for everything the channel answers. The timer is set for the channel's next timeout instead of ticking every second. The build links again.
…ocal server A datagram refused by the server arrives as EPOLLERR alone, which was neither readable nor writable, so the event loop span until the query timed out.
What Windows, macOS and Android report about their resolvers reaches the crate as plain data through Io::settings(), so the rules for it compile and can be tested on any host. SystemIo reads it: GetAdaptersAddresses and the registry, dns_configuration_copy, the net.dns properties. simulation::run() drives a whole channel from a text script and logs every socket call, packet, timer and completion. bun:internal-for-testing exposes it as dnsSimulate().
… world - A hosts file where many lines share an address took quadratic time to read. - ndots was 0, not 1, when the system's configuration could not be read. - Taking an answered query off its connection was linear in the queries in flight. - A probe that could not open a socket left its server marked as being probed for good. - An answer without records is cached for what its SOA record says, not for an hour. - Error is a thiserror enum.
|
Updated 11:01 PM PT - Nov 9th, 2026
✅ @claude, your commit 5f5d5c6638ef44cd11906e595e9671fd3578e1ee passed in 🧪 To try this PR locally: bunx bun-pr 47076That installs a local version of the PR into your bun-47076 --bun |
- iphlpapi.lib came in with c-ares's objects. libuv needs it too. - clippy, mordant and the byte-search lint. - The query id is drawn before anything else in a send, so the same random bytes lead to the same choices as in c-ares. - Queries due at the same instant time out last sent first. - A datagram whose source port was not reported is accepted. - Idle connections are closed server by server. - The services file is read by glibc's rules.
Channel is no longer generic: it holds a boxed Io, and knows sockets and requests by number. The generic one was compiled twice into every binary, for the system and for the simulator. - Servers whose sort keys are equal while the list is replaced are ordered as in c-ares's skip list. - An interface name after a nameserver has to fit IF_NAMESIZE. - A record's class is checked before its RDATA, and a URI target that does not print is EBADSTR. - hive_array's unit tests no longer use the method the last commit removed. - The localhost lookup test does not depend on the machine's hosts file.
…adratic - test/js/node/dns/dns-simulation-c-ares.txt holds 194 scripts for the simulation with what c-ares 1.34.9 logged for them in the same world. 189 have to go the same way here. The other 5 are the differences that are meant, and hold the log that is expected here as well. - A record with 16,000 options, or a reply with thousands of names, took time quadratic in their number. Options are now found by id and names to point back at by their text. What is parsed and written is the same. - A NUL byte ends a line of the services file, as it does for glibc. - The tests' DNS server sends its UDP replies one after the other, sends one again if it failed for the sake of an earlier one whose socket was closed, and fails the test that owns it for any other error.
test/js/node/dns/c-ares-driver/regenerate.ts builds c-ares 1.34.9 from a checkout, with the calls it makes of libc renamed to the driver's, and writes its logs in dns-simulation-c-ares.txt again. It gives the file that is checked in, byte for byte. Nothing builds it in CI. The scripts that guard a difference that was fixed are now named after what they guard.
321e5d9 to
e7236b1
Compare
… simplify the resolver From going through c-ares's history: - hosts: lines that share a name or an address are no longer merged, and a file that was replaced is read again whatever its mtime - lookup() takes only addresses of the type a query asked for - an answer that replaces another in the cache is not evicted when the other was due - a question longer than 255 bytes on the wire is EBADNAME - resolv.conf: a TAB is a blank, the last of `domain` and `search` counts; nsswitch.conf: comments, and one that cannot be read is as one that is not there - a link-local server is not bound to the local IPv6 address - a UDP datagram that cannot be parsed ends the connection only if it has the id of a query sent on it - `localhost.` is localhost - setServers() does not count a probe as a pending query - the AAAA query of a lookup stops being retried also after a name that the cache answered From review: Record is Message, Kind is RecordType, the requests have Node's names, dead parameters, states and error codes are gone, items no other crate uses are not pub, and the files derived from c-ares carry its licence notices.
The script commands `etc` and `readable` had no script that used them, and no script got an answer to a CNAME question. `touch` is gone from the driver as it is from the simulation.
The test and regenerate.ts each parsed the file. It has a header that gives its format and how to add an entry. regenerate.ts refuses a checkout of c-ares that is not at the pinned commit, and finds the sources itself.
…stion that was sent again What the dropped test checked is pinned without a clock by three scripts of dns-simulation-c-ares.txt. A handler of the test server that throws now fails the test that owns the server.
It had Cell and ElTimespec from dns.rs, which no longer uses them, so macOS did not build.
Returning from insert() in that case hid the failure, which the mordant check does not allow.
- a lookup had two match arms that could no longer be reached, and told an answer's status and what it added apart for nothing - `finish` checked the RCODE in three places - bare boolean parameters are enums or lists, derives nothing uses are gone, every file says what it is, comments are at most 100 columns wide - `word()` scans once instead of searching the set of spaces for each byte - the script language of the simulation gives a file an inode, so that a hosts file replaced by one of the same size and time can be tested - the crate's documentation says where it differs from c-ares - log labels and an accessor still had the names of functions that were renamed
- titles that named a topic, or a property the test did not check, are reworded - a truncated answer is really not remembered, a system without /etc is tried, resolveCname() of an address alone is ENOTFOUND - a question that a slow machine made the resolver send again is counted once - the tests of a port that nothing listens on no longer run beside tests that could be given the port - two tests whose scripts were also in dns-simulation-c-ares.txt are gone - one soa() and one parse() for the three files that had their own - the driver needs no flag to keep quiet at the end - comments in other tests no longer explain themselves by functions of c-ares that are gone
CI notes5f5d5c6, build #139606 (passed: 188 of 188 jobs)The result: green. No test failed on any platform. All 188 jobs passed: Linux (glibc, musl, ASAN), Windows x64 and arm64, macOS arm64 and x64. (The status was red for a while because the 4 macOS x64 jobs expired in the queue three times before an agent took them.) All 9 GitHub checks passed. Main's own build of 98cda0d, #139578, passed 175 of 175.
A merge of main up to 98cda0d (6 commits), because it conflicted in Main is green again, so this build can be too. The rewritten ahead-of-time compiler (#47942, #48120) is in this merge. Main's 38 failing tests went with it: #139525 on af566a3 passed 175 of 175. The solver whose answer depended on the order of its rules is gone, and with it the one test that failed on this branch alone ( What the merge needed:
The second commit adds Run before the push, Linux x64, on the merged tree:
b3e456b, build #137888 (finished 05:09 UTC, 6 Nov: red as foreseen, with main's 38 failing tests and one more; 128 jobs passed, every macOS one among them, and 60 failed)The result: the failing tests are main's 38, every one of them, and
A merge of main up to b1305dd (43 commits), because it conflicted in 10 files. Expect this build to be red, mostly from main. Main's own builds have had the same 41 failed jobs and 38 failing tests, 37 of them under How the conflicts were resolved
What git does not flag
Before the push, on the merged tree (Linux x64)
949031e, build #135399: passed, 188 of 188 jobs (finished 23:39 UTC, 29 Oct)Every platform passed, macOS x64 among them, with no error annotation. macOS x64 shard 1, which failed on 0b8f26c, passed, and neither The status was red three times on the way (20:08, 22:05 and 23:05 UTC): macOS test jobs expiring in the queue before they started, which BuildKite queues again by itself. The arm64 shards got agents after 2 to 4 hours and the x64 ones after nearly 5. #47389 has the numbers on the x64 agents. All GitHub checks are green,
A merge of main up to 73a2043, because #47087 conflicted.
Before the push, on the merged tree (Linux x64):
Main has since moved to 9fe1cd6 (#47382), which does not conflict, so it is not merged. 718e06b, build #135328 (all 180 Linux and Windows jobs passed; the 8 macOS jobs had not got an agent in 1 h 50 min when the next push came)One commit, tests only: the fix for the one failure on 0b8f26c, Checked before the push, on a release build of this branch (Linux x64):
Main was 5 commits ahead (69f4077) with no conflict and no change to what the table of built-ins is made from, so it is not merged. #47316 changes those files, and #47293 does by its title; when either lands, 0b8f26c, build #134997 (finished 11:28 UTC, 29 Oct: 188 jobs passed, 1 failed, on one test)Every Linux and Windows job passed: 181 of 181, with no error annotation. All 14 macOS got agents at 10:34 (arm64) and 11:00 UTC (x64), after the jobs had expired in the queue three times, as in other branches' builds. All 4 arm64 shards passed, and x64 shards 0, 2 and 3 (shard 3 on macOS x64 shard 1 failed on
Two commits: cc4be3b, the fix for
Before the push, on the merged tree (Linux x64): the debug + ASAN suites gave the same numbers as before, with no sanitizer report; Node's own tests 37 of 37 and 85 of 85; all of the engine's tests, which I now run before every push: 1,599 IR tests here, 5,037 in WebKit and 2,916 native tests, none failing; a release build with LTO and 6bd18b4, build #134938 (175 jobs passed and 5 failed, all on one test; the 8 macOS jobs had not got an agent when the next push cancelled them)
A merge of main, up to e64b59d. No conflicts, but two things in #47129 bear on this PR, and CI builds the head of the branch, not its merge with main:
The trim tests ran on a local release build with LTO and All GitHub checks are green, 711b3cb, build #134746Two merges of main, up to 37a5e3a, because main conflicted with this branch, and did again while the first merge was being checked. Nothing under
Before the push, on Linux x64 with a debug + ASAN build of the merged tree: the DNS, worker-teardown and socket suites gave the same numbers as on 857f19e, with no sanitizer report; Final but for two soft jobs that the next push cancelled: 306 jobs passed and one that counts failed, on one test, below; 69
To reproduce on fcc1ea09: 857f19e, build #134347 (finished 13:23 UTC)Tests only: a script for a difference from c-ares that fuzzing found (
3ffb48b (as of 06:50 UTC)All 14 EC2 capacity came back: the builds I sampled that started after about 05:22 UTC (#134288 to #134312) have no Linux job cancelled. All 32 macOS jobs expired waiting for an agent and were queued again; macOS jobs are waiting in those other builds too. Two things make it red, and neither ran any of this PR's code:
The rest of what is red is in
Build #134170 on 0dd6940 (merge of main, with
|
| main 11c3671 | 0dd6940 | 3ffb48b | |
|---|---|---|---|
hello world --aot --trim, mapped bytes |
26,948,810 | 27,075,795 | 26,875,027 |
| DNS code that is always kept | 85,778 | 217,113 | 12,850 |
removable with Dns |
0.26 MB | 0.12 MB | 0.32 MB |
Dns's minimum for ELF goes from 100,000 to 150,000, which fails the link on the state before the fix. I could not link Mach-O or COFF here, so those numbers are not measured and the macho minimum is as it was.
2. test/aot/perf/start.test.ts: privateKilobytes of bun-serve 2008 -> 3980 and of node-http 3036 -> 4952. Not the resolver: transparent huge pages on .bss, by the luck of the link.
- Bun opts its heaps out of THP mapping by mapping (
MI_DEFAULT_ALLOW_THP = 0, JSC's reservations,bun_alloc's arena). Insmapsthey all carrynh..bss, about 2.09 MB, does not. - With
/sys/kernel/mm/transparent_hugepage/enabledatalways, the first write to a 2 MiB stretch that lies wholly inside.bssfaults a whole huge page..bssis barely over 2 MiB, so whether there is such a stretch depends on the address the link gave it. - The numbers fit: each program grows by 2048 KB less what it had touched in that stretch, so
node-httpgrows by 56 KB less thanbun-serve. #134177 (claude/picohttpparser-rust-replacement-aogv4j) failed the same test with 3992 and 4964: again 56 KB apart. - Reproduced locally with the same server code and a 1 MB dummy array in
.datathat moves.bssfrom0x098f9c80to0x099f9c80, so that0x09a00000..0x09c00000lies inside it:
| THP | program | as linked | .bss moved by 1 MB |
moved, with the patch below |
|---|---|---|---|---|
always |
bun-serve |
2152 KB | 4084 KB (AnonHugePages: 2048 kB in .bss) |
2152 KB |
always |
node-http |
3200 KB | 5136 KB | 3200 KB |
madvise |
bun-serve |
2160 KB | 2152 KB | |
madvise |
node-http |
3192 KB | 3200 KB |
So any branch can hit this, and users on always kernels (the comment in mimalloc.ts names RHEL, Amazon Linux, Arch) pay 2 MB whenever a release links that way. I found no fix on main or in an open PR. It has nothing to do with DNS, so it is not in this PR. 3ffb48b moves .bss again, so this test may pass or fail on it for the same reason.
Proposed patch, tested as in the table (glibc only: musl needs the same call from another hook)
diff --git a/src/jsc/bindings/workaround-missing-symbols.cpp b/src/jsc/bindings/workaround-missing-symbols.cpp
index e1478771..d80acb86 100644
--- a/src/jsc/bindings/workaround-missing-symbols.cpp
+++ b/src/jsc/bindings/workaround-missing-symbols.cpp
@@ -482,9 +482,21 @@ static int bun_libc_csu_init(int argc, char** argv, char** envp)
BUN_SYMVER(__libc_start_main, BUN_GLIBC_BASE);
extern "C" int __libc_start_main(int (*)(int, char**, char**), int, char**, int (*)(int, char**, char**), void (*)(), void (*)(), void*);
extern "C" void Bun__askForAddressSpaceToStart();
+extern "C" char __bss_start[];
+extern "C" char _end[];
+// Under THP `enabled=always` the first write to a 2 MiB stretch that lies wholly in .bss faults a whole huge page.
+// Whether there is such a stretch depends on where the link put .bss. The heaps opt out the same way (mimalloc.ts).
+static void optBssOutOfHugePages()
+{
+ uintptr_t page = static_cast<uintptr_t>(sysconf(_SC_PAGESIZE));
+ uintptr_t start = (reinterpret_cast<uintptr_t>(__bss_start) + page - 1) & ~(page - 1);
+ uintptr_t end = (reinterpret_cast<uintptr_t>(_end) + page - 1) & ~(page - 1);
+ if (start < end) madvise(reinterpret_cast<void*>(start), end - start, MADV_NOHUGEPAGE);
+}
extern "C" int __wrap___libc_start_main(int (*main)(int, char**, char**), int argc, char** argv,
int (*init)(int, char**, char**), void (*fini)(), void (*rtld_fini)(), void* stack_end)
{
+ optBssOutOfHugePages();
// libstdc++ allocates in an initializer. AddressSanitizer starts in one, and its address space can't be limited.
#if !__has_feature(address_sanitizer)
Bun__askForAddressSpaceToStart();Earlier
- #134057 on fd841cb passed. It was first marked failed because the two
darwin any x64 - test-bunjobs expired waiting for an agent, as in other branches' builds that night; the retries passed. - #134016 on 5987b6c: one macOS arm64 shard failed
node-http.test.ts,native-source-onclose-leak.test.tsandserve-body-leak.test.ts, all large loopback transfers that stalled, on a host that took 42 min for a shard that takes about 20. The first two use127.0.0.1only and run no resolver code. It did not repeat on #134057.
Generated by Claude Code
A mutation run over src/dns_resolver left 302 of 994 mutants alive under
dns-simulation.test.ts. These scripts kill 194 of them. They cover limits
(name, alias, port and option lengths), the latency spans behind the timeout,
address sorting labels and scopes, escapes, opcodes and extended RCODEs.
82 give the log of c-ares 1.34.9. One is a `differs:` entry for a difference
that is already listed: the end of a TCP stream that came with data.
A script may now say {x*n} for x n times, so that a 64 KB answer is one line.
Dropping a Resolver closes its channel, which statically reaches most of src/dns_resolver. Code outside the Dns feature drops refs on a resolver (the drop glue of RuntimeState, VM teardown), so every trimmed executable kept the resolver. c-ares hid the same edge behind callbacks stored at query time. - the one place a resolver is freed, and the close at VM teardown, are removable_body!(Dns, ..) - the VM holds its resolver as a RefPtr like everyone else, in place of a value in a box with a ref that pinned it. GlobalData is gone. - the link fails if less than 150,000 bytes go with Dns on ELF. It was 100,000, which the state before this met. hello world built with --compile --aot --trim, bytes mapped, on Linux x64 with LTO: 26,948,810 on main, 27,075,795 before this, 26,875,027 now. Not measured for Mach-O and COFF.
c-ares reports an empty options string as out of memory, drops the system's configuration and falls back to 127.0.0.1 without a search list. With `RES_OPTIONS=`, `dns.getServers()` gave ["127.0.0.1"] in 1.4.4. src/dns_resolver reads it as no options. Found by fuzzing script text. The checked-in logs gain the script with both logs. A macOS resolver's options go through the same function in both, which the driver for c-ares cannot run, so that has a test of its own.
One conflict, in src/runtime/dns_jsc/dns.rs: main changed `uv_loop` of the uws loop from a field to a method inside `on_dns_socket_state`, which this branch removed with c-ares. This branch's side is kept. Its own Windows code asks the VM for the loop (`vm.uv_loop()`), which did not change.
One conflict, in `cluster_raw_bind` of src/runtime/node/node_cluster_binding.rs: this branch parses the address in Rust where main still calls `ares_inet_pton`, and main zero-initializes the `addrinfo` hints below it with the safe `ffi::zeroed()`. Both are kept.
No conflicts. Main now has tests of `--trim` that use DNS (test/bundler/trim-by-use/programs/dns.ts and dns-resolve-of-bun.ts), which pass on this branch's resolver, and a WebKit that fixes the race behind the failure of test/aot/engine-tests-native-12.test.ts on the previous commit.
`dnsSimulate` in bun:internal-for-testing adds an entry to the table of built-ins, which moves the numbers of the entries behind it. With those numbers AnalyzeFlow finds one more value, `builtin(FunctionPrototype)`, for `process.env[key]` under a key made at run time, so 11 check lines of custom-accessor-reached-computed-key.ir no longer matched. Nothing else in the output changes. Only the numbers matter: with `dnsSimulate` kept and another entry taken out, so that the numbers are main's again, the old lines match.
One conflict: main edited src/cares_sys/c_ares.rs, which this branch deletes. It stays deleted. The edit was that `uv__winsock_ensure` is now a `safe fn`, so its call needs no `unsafe`. `SystemIo::new` calls it too, on Windows, and gets the same change: the workspace denies warnings, and an unused `unsafe` is one.
In that program `process.env[key]` stands beside `RegExp[key]`. With main's table of built-ins the type of the first has no Function.prototype in it. With any other table it has: the file is the one IR text that fails on this branch, and also on #47071, #47074 and #47076, which add or remove built-ins of their own and have nothing else in common with it. Written by test/aot/tools/update-ir-tests.ts. 11 lines change, each by that one member of the union.
In that program `process.env[key]` stands beside `RegExp[key]`. With main's table of built-ins the type of the first has no Function.prototype in it. With any other table it has: the file is the one IR text that fails on this branch, and also on #47071, #47074 and #47076, which add or remove built-ins of their own and have nothing else in common with it. Written by test/aot/tools/update-ir-tests.ts. 11 lines change, each by that one member of the union.
|
A note for after this PR. The callback forms of I have the On this PR the native half goes on |
…ode:http tests `objects-closed-in-other-ways.ts` and `objects-closed-through-others.ts` fail on macOS x64 in CI, on this branch and on others. The server drops the connection after the exchange that the test breaks on purpose. On a busy machine the client sends the next request on that connection before it notices, so `fetch` rejects and plain Bun prints one more `TypeError` than the compiled program. A request that fails before the handler ran is now sent again, up to three times. A request that the handler did see is judged as before. The same change as in #47316, so that it merges without a conflict.
Conflict in src/js/node/tls.ts: main dropped the type annotations from the lines where this branch names dns_resolver_jsc.bind.ts. Main's form is kept, with this branch's file name. src/js/ahead-of-time/node/tls.ts, new on main, is a second text of the same module. It named cares_jsc.bind.ts, which this branch removes, so it takes the same rename, and the SHA-256 of the first text at its head is the one of this branch's tls.ts.
HOSTALIASES, LOCALDOMAIN and RES_OPTIONS steer the resolver behind node:dns. The list of variables that a trimmed executable is compared with a whole one under lost CARES_HOSTS with c-ares and did not gain these.
What does this PR do?
Removes the dependency on c-ares. The DNS client behind
node:dns'sresolve*(),reverse(),lookupService()andResolver, and behindBun.dns.lookup()'s"c-ares"backend, is now written in safe Rust.Draft. In CI, as of
fd841cb7, the whole test suite passed on Linux, Windows and macOS, x64 and aarch64. Since then main was merged in, withbun build --compile --aot, and one commit keeps the resolver out of trimmed executables (below). CI is running on that. What is left is listed at the end.src/dns_resolver(bun_dns_resolver, new,forbid(unsafe_code)for the whole crate, depends onbun_coreonly)Channelis a state machine: the caller passes the time into each call, says which socket became readable or writable, and then takes the finished requests out of a queue. Everything the resolver needs from the system (sockets, files, the environment, random bytes, routes for address sorting, the platform's resolver settings) comes through theIotrait, which the channel holds as aBox<dyn Io>.message: the DNS message reader and writer (name compression, escapes, every record type c-ares knows).reply: whatares_parse_*_replymade of a message, for eachresolve*().channel: queries, retries, timeouts from the per-server latency buckets, failover and probing of failed servers, retiring a UDP socket after a timeout, TCP after truncation, TCP Fast Open, the query cache.lookup:getaddrinfo(hosts file, search list,ndots,HOSTALIASES, RFC 6724 sorting),gethostbyaddr,getnameinfo.system_config,hosts:resolv.conf,nsswitch.conf,LOCALDOMAIN,RES_OPTIONS,hosts,services. What Windows (GetAdaptersAddresses,GetBestRoute2, the registry), macOS (dns_configuration_copy) and Android (net.dns*) report arrives as plain data, so the rules for it compile and are tested on every host.simulation: runs a whole channel in a world described by a text script (files, settings, routes, sockets, the clock, the random seed) and returns a log of every socket call, packet, timer and completion.bun:internal-for-testingexposes it asdnsSimulate(). Nothing else reaches it.src/runtime/dns_jscsystem_io.rs(new) implementsIoonbun_sysand owns theFilePolls (uv_poll_ton Windows).system_settings.rs(new) asks each platform for its settings.dns.rsloses about 2,400 lines: one request type and one map of requests in flight replace six request structs, a macro per record type and fifteen 32-slotHiveArraycaches. Requests for the same thing still share one query.getaddrinfobackends (libc, libuv,dns_sd) are unchanged.ares_inet_pton/ares_inet_ntophad users outside DNS (SocketAddress,BlockList, UDP sockets,node:cluster, uWS's remote address). They now usebun_core::ip_address, which reads and prints the same.bun_sys::socket(new):socket,connect,bind,getsockname,recvfrom,setsockoptfor callers that are not uSockets.src/cares_sys, the update workflow. The attribution stays inLICENSE.md, becausesrc/dns_resolveris derived from c-ares 1.34.9.The goal was no observable change, quirks included. These are the changes I know of:
ECONNREFUSEDat onceEPOLLERRalone, which Bun passed on as neither readable nor writable. The event loop span at 100% CPU until the query ran out of tries: 22 s andETIMEOUTon main, 30 ms here. Node.js reportsECONNREFUSEDENOTFOUNDin a running process for that long. RFC 2308 section 5new Resolver({ timeout: 250, tries: 1 })took up to a second longerresolveAny()of an answer that holds only a CNAME gives[][undefined, undefined]. Node.js gives the CNAME. Not changed herecancel(), or was answered with TC and sent again over TCP to another server, is probed again laterdns.setServers()replaces servers with queries in flight, an old and a new server can compare equal, and the one that sorts second is skippedsearchwith no domain or only delimiters,LOCALDOMAIN=or one with a non-printing byte, a Windows suffix list of only delimiters) no longer throws away the rest of the system's configuration127.0.0.1RES_OPTIONSthat is set and empty is no options127.0.0.1with no search list.RES_OPTIONS= bun -e 'console.log(require("dns").getServers())'prints["127.0.0.1"]in 1.4.4. From readingares_sysconfig_mac.c, a macOS resolver whose options are there and empty did the same589b5887).10.0.0.1 a band10.0.0.2 b cmadearesolve to10.0.0.2as well64b36bb5). A file moved into place with an older mtime was never readBun.dns.lookup()takes A records only from the answer to its A query and AAAA records only from the answer to its AAAA query.resolveCname()of an answer that holds an address and no CNAME isENOTFOUND4d4986ff).family: 6could give IPv4 addresses, a server that put both in both answers gave each address twice, andresolveCname()gave the name that was asked forea7810d9, not merged)EBADNAME(ENOTFOUNDin JS)resolv.confa TAB is a blank, and the last ofdomainandsearchcounts. Innsswitch.confa#starts a commentdomainaftersearch, and reads words of a comment as sources. All three were fixed once (1.5.2, 1.7.x) and lost in rewritesnsswitch.confthat cannot be read is as one that is not thereresolv.conftoo and asks127.0.0.1. Fixed in 1.11.0 and lost againsetLocalAddress()'s IPv6 addressd974c556) and lost againlocalhost.is localhostsetServers()does not throw "there are pending queries" for a probe of a failed server that nobody waits forENOMEMdns://URIs asnameserverinresolv.conf,sortlist,/etc/netsvc.confand/etc/svc.confare not readlookupService()reads theservicesfile itself, by the rules of glibc's parsergetservbyport_r, so an NSS source other thanfilesis no longer asked, and other systems' parsers differ in what they make of a malformed line. Two things glibc does are not copied. It gives up on the whole file at a line that does not fit the 4 KiB buffer c-ares passed with its aliases. And it misreads a last line that is indented and has no newline (__nss_readlinemoves it without its NUL)Bun.dns.lookup(name, { flags: AI_ALL, backend: "c-ares" })no longer readsCARES_HOSTSAI_ALLhas the value ofARES_AI_ENVHOSTSby accidentSocketAddressnever haserrnoEMSGSIZE) only for an IPv4 address with too many octetsprocess.versions.aresis the constant1.34.9Known differences from Node.js that were there before and are not changed:
resolveTxt()returns one array per character-string and not per record,resolveAny()leaves out CNAME records,resolve4()of a CNAME without an address resolves with[], andENODATAis reported asENOTFOUND.How did you verify your code works?
New tests through public APIs. Their expected results were recorded with a debug build of the parent commit, which links c-ares, so they compare Bun before with Bun after. They pass on both by design, except five: one for the first row of the table, two for the second and two for the row about the type a query asked for.
test/js/node/dns/dns-server.ts(new): a scripted DNS server on127.0.0.1, UDP and TCP on one port. A test sees every query and chooses each reply byte for byte.test/js/node/dns/dns-resolver-class.test.ts, 55 new tests: every record type andresolveAny(); the bytes of a query; each RCODE; empty answers; CNAME chains and their TTLs; compressed names and escapes; replies with the wrong id, name or type; five kinds of malformed reply; truncation and TCP (a reply of nearly 64 KiB, several queries on one connection, a server that hangs up); timeouts andtries; failover; the query cache, negative answers included; identical requests in flight; 300 queries at once;cancel(), also with a new query made from a cancelled one's callback;setServers()with a query in flight;setLocalAddress(); names that cannot be sent and how the others are encoded.test/js/bun/dns/resolve-dns.test.ts, 8 new tests in subprocesses:Bun.dns.lookup()with the"c-ares"backend for both families, aliases, the search list,ndots, trailing dots,HOSTALIASES, literal addresses andlocalhost.test/js/node/dns,test/js/bun/dns, everytest/js/node/test/parallel/test-dns*.js,test-net-dns-*,test-worker-dns-terminate*,test-dgram-*,test-tls-canonical-ip,test-blocklist,socketaddress.spec.ts,udp_socket.test.ts,dgram.test.ts. The machine has no internet, so the tests that need it fail the same way on both.New tests of the resolver itself (
test/js/node/dns/dns-simulation.test.ts, 50 tests throughdnsSimulate()). They cover what a test cannot arrange on a real machine, and run on every platform since the platform is part of the script:resolv.confandnsswitch.conf, the settings of Windows, macOS and Android, search lists of several domains, reloading of the hosts file, RFC 6724 with made-up routes, the length of each timeout, sockets that cannot be opened, TCP answers a byte at a time or several at once, partial writes, Fast Open, theservicesfile, and how long an answer is remembered. No Bun that used c-ares has this function, so these were written from c-ares's source and not recorded. They were then compared with c-ares itself: a driver links c-ares at Bun's pin, unmodified but for its random number generator, with every libc call that touches the world redirected to the same scripted world, and prints the same log. When last compared, of the 95 scripts these tests ran, 10 were about the settings of other platforms and 3 used a command the driver lacks. Of the other 82, 66 gave the same log, packet ids and jittered timers included, and each of the 16 that did not is a row of the table.Logs of c-ares, checked in (
test/js/node/dns/dns-simulation-c-ares.txt, 358 more tests in the same file). 149 scripts were picked from the fuzzing corpus so that together they reach every line of the resolver that a script for Linux can reach. The others are one small script each for a difference that fuzzing found and that is fixed, for a CVE or a bug fix in c-ares's history, or for a row of the table. 83 came out of a mutation run in the fuzzing session: of 994 mutants of the crate that compile, the tests killed 692 before these scripts and 886 with them, and it judged the 108 that are left to be equivalent or to show only in what no log prints. 329 have to give the log that unmodified c-ares gave. 29 are differences that are meant, with both logs.test/js/node/dns/c-ares-driver/regenerate.tsbuilds the driver against a checkout of c-ares and writes the logs again. It gives the file that is checked in, byte for byte. Nothing builds it in CI, and it can go if 1,600 lines of C are not wanted in the tree.c-ares's history. All 3,037 commits up to upstream's
mainof 19 October 2026, and every CVE and advisory, were read for bugs that a port could have: those fixed before the pin (is the fix here?), those fixed after it (is the bug here?), and those fixed once and lost in a rewrite. Of 49 CVE rows that apply to this code, 44 were fine as they were. What was found is in the table above, each with a script. One bug was the port's own: after a search name that the cache answered, the AAAA query of a lookup went on being retried with an IPv4 address in hand. Left as c-ares has them, on purpose: names from servers are escaped but not refused for bytes outside[A-Za-z0-9-._/*](the second half of the fix for CVE-2021-3672, which upstream dropped in 1.21 and which would refuse DNS-SD names), the QR bit is not checked, the cache has no cap on its entries, and a query is written again into the same TCP connection on a retry.The same binary with and without c-ares. A script asks a hostile local server 16,565 questions through
node:dns, from 3,000 seeds, and prints each result whole: the JS value, or every field of the error. I ran it twice on a debug build of main at the merge base and twice on a debug build of this branch. 16,523 lines are the same in all four runs. The others vary from run to run on either build, because how many datagrams one read finds depends on timing. There was no line on which the two runs of each build agree and the builds do not, apart from 143 results ofresolveAny()that lose theirnulls (in the table). With the fixes from c-ares's history there are 10 more, 9 by the row aboutresolveCname()and 1 by the row about datagrams. The renaming and simplifying after that changed none.Under ASAN, two seeded scripts do at random what is hard on the binding:
cancel(),setServers()with queries in flight, requests made from completions, resolvers dropped and collected, the server's socket closed, TCP answers cut off, the global resolver,Bun.dns.lookup(),lookupService(), workers terminated with queries in flight. 824 seeds: no crash, no report, and every request settles.--compile --aot --trim. The resolver is a part of theDnsfeature: its poll, timer and deferred-error arms indispatch.rs, the one place aResolveris freed, and the close at VM teardown are guarded. Built alike on Linux x64 (LTO, trimmable), a trimmed hello world maps 26,948,810 bytes on main and 26,875,027 here, and 12,850 bytes of DNS code are always kept (the cache underfetch()andconnect()) where main keeps 85,778. Not measured for Mach-O and COFF. Trimmed executables were run by hand: every kind of query against a local server with the output of the plain run,fetch()oflocalhostin a program withoutDns,process.exit()with two queries in flight, and a worker terminated with 20 in flight.By hand, the same script under the debug build and Node.js 24: all of the above kinds of query against a local server, a worker terminated with 20 queries in flight, a resolver dropped and collected with a query in flight, and a closed port. Same output.
Still to do
system_settings.rsand the Windows half ofbun_sys::sockethave run, but only on the network settings those machines have. The struct layouts were compared with thewindows-syscrate and are asserted at compile time.ares_parse_*_replyequivalent against c-ares's, 840 million inputs to the address parsers, 65 million raw scripts, and 32 million scripts with the settings of Windows, macOS and Android, which have no c-ares to compare with and were checked for rules instead (every request ends once, every socket is closed once, no timer is left). No panic, hang or unbounded memory. Every difference found is either fixed or in the table. Line coverage of the crate by the corpus is between 93% and 100% per file, excepterror.rs, whose functions only the binding calls. What is not reached is mostly defensive returns..bssis not opted out of transparent huge pages as the heaps are, so with THP atalwaysa release whose link puts an aligned 2 MiB stretch inside.bssuses 2 MB more, andtest/aot/perf/start.test.tsfails on such a branch. The reproduction and a tested patch are in the CI notes comment below.node:dgramsends a datagram to a closed port on Linux, its nextsend()to another, live peer fails withECONNREFUSEDand nothing is sent. An unconnected socket can also emiterrorfor it. Node.js does neither.bsd.csays why (IP_RECVERR). The tests' DNS server sends the reply again.Generated by Claude Code