Skip to content

dns: re-read system nameservers when network config changes - #34781

Open
robobun wants to merge 16 commits into
mainfrom
farm/0e1868d2/dns-config-change-reinit
Open

dns: re-read system nameservers when network config changes#34781
robobun wants to merge 16 commits into
mainfrom
farm/0e1868d2/dns-config-change-reinit

Conversation

@robobun

@robobun robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What

The c-ares channel backing dns.resolve* / dns.reverse / Bun.dns reads the system nameserver list once when it's created (src/runtime/dns_jsc/dns.rs get_channel()) and never again. After a VPN connect, Wi-Fi switch, or DHCP renew the resolver keeps querying the boot-time servers for the life of the process, so lookups time out or return stale even though the OS resolver has moved on. dns.lookup and fetch go through the OS resolver and are unaffected, but their connect-path cache (internal::GlobalCache, default 30 s TTL) also serves stale addresses for the rest of the window.

How

Two layers, both landing here:

OS config-change watcher (new src/runtime/dns_jsc/config_watcher.rs): one process-wide watch, armed lazily on the first c-ares channel creation, does not keep the event loop alive.

  • Linux: inotify on /etc filtering resolv.conf/nsswitch.conf
  • macOS: notify_register_file_descriptor on SystemConfiguration's DNS-config key
  • Windows: NotifyIpInterfaceChange (callback only touches atomics / the Guarded cache, so no JS-thread marshaling)

On fire it bumps a process-global AtomicU64 generation and drops the connect-path getaddrinfo cache. Each Resolver::get_channel() compares its snapshot against the current generation and, if stale and the user hasn't called setServers(), cancels+destroys the channel so the next query re-reads fresh system config. This mirrors c-ares' own ares_event_configchg.c, which we can't call directly because it's bound to c-ares' private event thread.

Node-parity ensure_servers() fallback for platforms/containers where the watcher can't register (readonly /etc, seccomp, Android, other UNIX): reproduces ChannelWrap::EnsureServers from nodejs/node#13076 / nodejs/node#61453. If the previous query hit ECONNREFUSED, servers are still the system default, and the channel's only server is loopback (c-ares' empty-resolv.conf fallback), recreate the channel.

Why not ares_reinit()

ares_reinit() is the natural primitive (it re-reads system config, preserves user-set servers, and requeues in-flight queries), but with CARES_THREADS enabled (we build with it) it spawns a background thread that calls our sock_state_cb off the JS thread when servers change (traced: ares_sysconfig_applyares_servers_remove_staleares_close_connection → cb). Our on_dns_socket_state touches JsCell<PollsMap> and FilePoll/uv_poll, which aren't thread-safe. So we destroy + lazy-recreate on the JS thread instead; pending queries see ECANCELLED (same as Resolver.cancel()), which is strictly better than the 5-20 s timeout they'd hit against the now-unreachable server.

Why not just match Node

Node's EnsureServers only covers "started offline → loopback fallback". The VPN/Wi-Fi-switch case is nodejs/node#49485, closed wontfix ("too complex, too platform-specific"). The per-OS watchers here are straight ports of what c-ares already ships internally.

Testing

test/js/bun/dns/dns-config-change.test.ts:

  • generation bump forces channel recreate, getServers() round-trips the same system list
  • user-set setServers() survives a generation bump
  • (Linux) pointing the watch at a temp dir via BUN_DNS_CONFIG_WATCH_DIR and touching resolv.conf there bumps the generation through the real inotify path

bun:internal-for-testing gains dnsConfigGeneration() / dnsConfigChanged() so the tests don't need to touch /etc.

rust:check-all passes on all 10 targets.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/dns/dns-config-change.test.ts

The c-ares channel backing dns.resolve*/dns.reverse/Bun.dns reads system
DNS config once at creation and never again. After a VPN connect, Wi-Fi
switch, or DHCP renew the resolver keeps querying the boot-time servers
for the life of the process.

Two layers:

- OS config watcher (process-wide, lazy, best-effort): inotify on /etc
  (Linux), notify(3) on SystemConfiguration's dns key (macOS),
  NotifyIpInterfaceChange (Windows). On fire it bumps a generation
  counter and drops the connect-path getaddrinfo cache. Each Resolver
  checks the generation in get_channel() and recreates its channel when
  stale, unless the user pinned servers with setServers(). Mirrors
  c-ares' own ares_event_configchg.c, which we can't use because it's
  tied to c-ares' private event thread.

- Node-parity ensure_servers() fallback (nodejs/node#13076, #61453) for
  platforms where the watcher can't register: if the previous query hit
  ECONNREFUSED, servers are still default, and the only server is
  loopback (c-ares' empty-resolv.conf fallback), recreate the channel.

ares_reinit() would be the natural primitive but with CARES_THREADS it
spawns a background thread that calls sock_state_cb off the JS thread,
which our poll bookkeeping can't handle, so we destroy + lazy-recreate
instead (pending queries see ECANCELLED, same as Resolver.cancel()).

New is_servers_default / query_last_ok / config_generation state on
Resolver, a GlobalCache::invalidate_all(), a new PollTag::DnsConfig, and
bun:internal-for-testing hooks for the generation counter so tests can
exercise the mechanism without touching /etc.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The DNS runtime now watches operating-system configuration changes, invalidates shared DNS cache state, and reinitializes eligible resolver channels when generations change. Test-only bindings and coverage were added for forced changes, pinned servers, resolver recreation, and Linux file watching.

DNS configuration changes

Layer / File(s) Summary
DNS watcher and poll integration
src/io/posix_event_loop.rs, src/runtime/dispatch.rs, src/runtime/dns_jsc/config_watcher.rs, src/runtime/dns_jsc/mod.rs, src/jsc/rare_data.rs, src/runtime/jsc_hooks.rs
Adds DNS poll ownership, platform-specific watchers, generation tracking, per-VM watcher storage, event-loop dispatch, cache invalidation, and termination cleanup.
Resolver channel and cache updates
src/runtime/dns_jsc/dns.rs
Tracks configuration generations and query results, recreates eligible c-ares channels, replays local bindings, preserves explicitly configured servers, and updates shared cache entries.
Test bridge and DNS behavior validation
src/jsc/bindings/InternalForTesting.*, src/js/internal-for-testing.ts, src/runtime/dns_jsc/config_watcher.rs, test/js/bun/dns/dns-config-change.test.ts
Exposes DNS generation controls to tests and validates forced changes, pinned servers, resolver recreation, and Linux watcher events.

Possibly related PRs

  • oven-sh/bun#34455: Extends the DNS termination path that now also uninstalls the DNS configuration watcher.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: DNS now re-reads system nameservers when network config changes.
Description check ✅ Passed The description covers both required areas with what changed and how it was verified, including tests and rationale.

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

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:57 AM PT - Jul 20th, 2026

@dylan-conway, your commit f950419c4023a7c03d64c8cfd7642798c32accba passed in Build #76223! 🎉


🧪   To try this PR locally:

bunx bun-pr 34781

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

bun-34781 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. DNS resolution prefers Global IPv6 on Link-Local only interface (VPN/Cisco AnyConnect) causes timeout #25619 - macOS VPN (Cisco AnyConnect) connect/disconnect causes DNS to use stale nameservers; the PR's macOS SystemConfiguration DNS-change watcher and GlobalCache invalidation directly target this scenario.
  2. mongoose querySrv ECONNREFUSED #27180 - querySrv ECONNREFUSED on Windows; the PR's ensure_servers() ECONNREFUSED-on-loopback fallback and Windows NotifyIpInterfaceChange watcher address the stale c-ares channel root cause.
  3. DNSException: querySrv ECONNREFUSED since Bun v1.3.5 with MongoDB@7.0.0 #25718 - querySrv ECONNREFUSED on Windows with MongoDB SRV (regressed in v1.3.5); PR's ensure_servers() fallback and Windows channel-reset logic target this directly.
  4. Bun DNS lookup not working for c-ares provider. #24970 - c-ares backend returns ECONNREFUSED for all lookups on Linux; PR's ensure_servers() fallback and Linux inotify watcher are the exact fix path.
  5. Bun.com is showing connection refused when using VPN #25444 - Connection refused when on VPN; VPN changes system nameservers and the stale c-ares channel fails to pick them up — the PR's cross-platform config-change watcher addresses this.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #25619
Fixes #27180
Fixes #25718
Fixes #24970
Fixes #25444

🤖 Generated with Claude Code

Comment thread src/runtime/dns_jsc/config_watcher.rs
Comment thread src/runtime/dns_jsc/dns.rs

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/dns_jsc/dns.rs`:
- Around line 4818-4831: Update ensure_servers so detecting a non-loopback
channel does not mutate is_servers_default; return without changing the
user-pinned server state, or track fallback recovery with a separate state flag.
Preserve watcher-driven reinitialization for later VPN, DHCP, or configuration
changes when setServers() was never used.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c90c66e9-8dcf-46ac-888b-cc061e4fa010

📥 Commits

Reviewing files that changed from the base of the PR and between 5c28061 and f4d041f.

📒 Files selected for processing (9)
  • src/io/posix_event_loop.rs
  • src/js/internal-for-testing.ts
  • src/jsc/bindings/InternalForTesting.cpp
  • src/jsc/bindings/InternalForTesting.h
  • src/runtime/dispatch.rs
  • src/runtime/dns_jsc/config_watcher.rs
  • src/runtime/dns_jsc/dns.rs
  • src/runtime/dns_jsc/mod.rs
  • test/js/bun/dns/dns-config-change.test.ts

Comment thread src/runtime/dns_jsc/dns.rs
robobun and others added 2 commits July 20, 2026 06:37
…n ECONNREFUSED

Store the POSIX watcher's FilePoll in a per-VM RareData slot rather than
gating on a process-global flag, so a Worker that creates the first
c-ares channel doesn't permanently mask the main thread's watcher when
it terminates. NotifyIpInterfaceChange stays process-global on Windows
since the callback itself is process-scoped.

Drop the is_servers_default.set(false) in ensure_servers() when the
channel's servers aren't loopback: in Node it's a harmless memoization,
but here the same flag gates check_config_change(), so one transient
ECONNREFUSED against real system servers would disable the config
watcher for the life of the resolver.
Comment thread src/runtime/dns_jsc/dns.rs
Comment thread src/runtime/dns_jsc/config_watcher.rs Outdated
robobun and others added 2 commits July 20, 2026 07:04
…n_sys::linux inotify

setLocalAddress() writes directly onto the c-ares channel via
ares_set_local_ip4/ip6 and was stored nowhere on the Resolver, so a
config-change recreate silently dropped the binding. Stash the last
IPv4/IPv6 values on Resolver and replay them after Channel::init.

Also switch the Linux inotify calls to bun_sys::linux::{inotify_init1,
inotify_add_watch, IN::*} and the macOS O_NONBLOCK set to
bun_sys::set_nonblocking, matching INotifyWatcher.rs and path_watcher.rs
rather than hand-rolling the FFI.
Comment thread src/runtime/dns_jsc/config_watcher.rs Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread test/js/bun/dns/dns-config-change.test.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/js/bun/dns/dns-config-change.test.ts (1)

137-143: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Replace the fixed 50 ms sleep with an event-loop yield.

The deadline is useful, but setTimeout(..., 50) introduces an arbitrary wall-clock delay and violates test conventions. Yield one macrotask/event-loop turn instead, such as await Bun.sleep(0), while retaining the deadline.

As per coding guidelines, tests must avoid fixed setTimeout waits. Based on learnings, Bun.sleep(0) is an acceptable macrotask barrier.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/dns/dns-config-change.test.ts` around lines 137 - 143, Replace
the fixed 50 ms setTimeout wait in the dnsConfigGeneration polling loop with an
event-loop yield such as Bun.sleep(0). Preserve the existing deadline check and
error behavior while allowing the loop to retry on the next macrotask.

Sources: Coding guidelines, Learnings

src/runtime/dns_jsc/config_watcher.rs (1)

85-112: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Watching only /etc misses some systemd-resolved updates.
When /etc/resolv.conf points at /run/systemd/resolve/resolv.conf, the changes happen outside /etc, so this watch never fires for upstream-server changes. Add a fallback watch for the resolved target or document the limitation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/dns_jsc/config_watcher.rs` around lines 85 - 112, Update
open_watch_fd to also watch the systemd-resolved target directory/file when
/etc/resolv.conf points to /run/systemd/resolve/resolv.conf, while retaining the
existing configurable BUN_DNS_CONFIG_WATCH_DIR behavior. Ensure changes to the
resolved target trigger the same watcher and close the inotify descriptor if any
required watch setup fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/dns/dns-config-change.test.ts`:
- Around line 51-54: Condense the comment above the
setLocalAddress/config-change test to three or fewer `//` lines, preserving both
the c-ares channel replay invariant and the reason the test verifies behavior
indirectly because no public getter exists.
- Around line 56-57: Replace the require calls in the spawned child sources,
including the scripts around dnsConfigChanged and dnsConfigGeneration, with
module-scope import statements. Keep the imported modules and bindings
unchanged; only use dynamic loading if that specific behavior is under test.

---

Outside diff comments:
In `@src/runtime/dns_jsc/config_watcher.rs`:
- Around line 85-112: Update open_watch_fd to also watch the systemd-resolved
target directory/file when /etc/resolv.conf points to
/run/systemd/resolve/resolv.conf, while retaining the existing configurable
BUN_DNS_CONFIG_WATCH_DIR behavior. Ensure changes to the resolved target trigger
the same watcher and close the inotify descriptor if any required watch setup
fails.

In `@test/js/bun/dns/dns-config-change.test.ts`:
- Around line 137-143: Replace the fixed 50 ms setTimeout wait in the
dnsConfigGeneration polling loop with an event-loop yield such as Bun.sleep(0).
Preserve the existing deadline check and error behavior while allowing the loop
to retry on the next macrotask.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: af87be0f-b1fc-45b0-b463-d34c7e763c3c

📥 Commits

Reviewing files that changed from the base of the PR and between f4d041f and 3b7f98a.

📒 Files selected for processing (4)
  • src/jsc/rare_data.rs
  • src/runtime/dns_jsc/config_watcher.rs
  • src/runtime/dns_jsc/dns.rs
  • test/js/bun/dns/dns-config-change.test.ts

Comment thread test/js/bun/dns/dns-config-change.test.ts Outdated
Comment thread test/js/bun/dns/dns-config-change.test.ts Outdated
robobun and others added 2 commits July 20, 2026 07:23
…t, test nits

Add config_watcher::uninstall() that unregisters the FilePoll and closes
the inotify fd (Linux) / calls notify_cancel (macOS), and call it from
close_dns_for_terminate() so a terminating Worker doesn't leak an inotify
instance per lifetime. The watcher is now a boxed struct in the RareData
slot so macOS can stash the notify token alongside the poll.

Snapshot the config-generation before Channel::init rather than after so
a concurrent NotifyIpInterfaceChange bump on Windows can't be absorbed
between init's config read and the snapshot store.

Tests: fold the three subprocess assertions into one {stdout, exitCode}
combined check (stderr is still surfaced in the failure message but not
asserted empty), replace the 50ms poll sleep with Bun.sleep(0) + deadline,
and trim the setLocalAddress comment to three lines.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/dns_jsc/config_watcher.rs (1)

95-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a Drop guard for Watcher instead of manual close_watch_fd pairing.

Watcher owns a native fd (and, on macOS, a notify(3) token) but has no Drop impl; both cleanup call sites (install()'s register-failure path and uninstall()) must remember to call close_watch_fd manually. A future refactor that constructs/returns a Watcher through a new path could easily leak the fd/token.

As per coding guidelines, "Pair every native resource acquisition with a release at the acquisition site; arm RAII or Drop guards before fallible calls."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/dns_jsc/config_watcher.rs` around lines 95 - 179, Add a Drop
implementation for Watcher that calls close_watch_fd using its owned poll fd and
macOS token, then remove the manual close_watch_fd calls from install() failure
handling and uninstall(). Ensure Watcher is fully initialized before any
fallible operation so its Drop cleanup covers every acquired native resource.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/runtime/dns_jsc/config_watcher.rs`:
- Around line 95-179: Add a Drop implementation for Watcher that calls
close_watch_fd using its owned poll fd and macOS token, then remove the manual
close_watch_fd calls from install() failure handling and uninstall(). Ensure
Watcher is fully initialized before any fallible operation so its Drop cleanup
covers every acquired native resource.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dc2b2c62-aed3-4817-93e0-bdeabd991810

📥 Commits

Reviewing files that changed from the base of the PR and between 3b7f98a and 01a7a40.

📒 Files selected for processing (4)
  • src/runtime/dns_jsc/config_watcher.rs
  • src/runtime/dns_jsc/dns.rs
  • src/runtime/jsc_hooks.rs
  • test/js/bun/dns/dns-config-change.test.ts

Comment thread src/runtime/dns_jsc/dns.rs
Comment thread test/js/bun/dns/dns-config-change.test.ts Outdated
…etLocalAddress test

check_timeouts() now reads self.channel.get() instead of routing through
get_channel_or_error(): get_channel() can reset + re-init the channel
since this PR, and on init failure get_channel_or_error() throws a JS
exception that the timer dispatch path has no way to propagate. The
timer's only job is to time out queries on the existing channel; if it
was reset, those queries were already ECANCELLED.

Drop the setLocalAddress-survives-recreate test: ares_set_local_ip4 is a
void C setter and there is no public getter, so the test cannot observe
whether replay_local_address() ran. A test that passes with the fix
reverted advertises coverage that doesn't exist; the underlying replay
code remains in place.
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread test/js/bun/dns/dns-config-change.test.ts Outdated
robobun and others added 2 commits July 20, 2026 08:26
…LocalAddress

get_channel() can now destroy the existing channel (check_config_change /
ensure_servers call reset_channel), so a raw *mut Channel captured before
a JS coercion that re-enters get_channel() is a UAF. Refactor both
set_local_address() and set_channel_servers() to do all JS coercions
(to_slice, get_index, coerce_to_i32, to_bun_string) before calling
get_channel_or_error(), per the 'coerce first while holding no raw
pointers' rule.

set_channel_servers now takes &self + is_global instead of a raw channel
pointer, eliminating the redundant get_channel_from_vm() re-entry that
could also invalidate the held pointer on Windows.

Tests switched to test.concurrent since each spawns an independent child
process.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/dns_jsc/dns.rs (1)

4090-4091: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Represent unset local addresses explicitly.

0.0.0.0 and :: are valid bindings, but these zero sentinels make replay_local_address() skip them. Use Cell<Option<u32>> and Cell<Option<[u8; 16]>>, then replay Some(...) values; add regressions for both unspecified addresses across a channel reset.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/dns_jsc/dns.rs` around lines 4090 - 4091, Update the state
containing local_ip4 and local_ip6 to use Cell<Option<u32>> and Cell<Option<[u8;
16]>>, representing unset addresses as None rather than zero values. Adjust all
initialization, assignment, and replay_local_address() logic to replay only
Some(...) values while preserving valid 0.0.0.0 and :: bindings, and add
regression coverage for both unspecified addresses across a channel reset.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/dns_jsc/dns.rs`:
- Around line 5837-5840: Condense the safety comment at
src/runtime/dns_jsc/dns.rs lines 5837-5840 to no more than three lines while
preserving the channel-reset, coercion re-entry, and UAF rationale; apply the
same three-line limit and equivalent rationale to the server-coercion comment at
lines 5927-5930.
- Around line 5841-5855: Update setLocalAddress around the stash_local_address
calls to parse and validate both address arguments without mutating resolver
state, including duplicate-family rejection, before committing either value.
Only after the complete argument set succeeds should both local-address cells be
updated and the existing replay/reset behavior proceed; failed calls must leave
the prior binding unchanged.

In `@test/js/bun/dns/dns-config-change.test.ts`:
- Line 20: Update the test “dns resolver re-initializes after a config-change
signal” so it observes channel initialization or recreation rather than only
comparing system server lists. Add a test-only channel-init signal or controlled
DNS configuration that changes after dnsConfigChanged(), ensuring the test fails
when get_channel() ignores the generation or check_config_change() is removed.

---

Outside diff comments:
In `@src/runtime/dns_jsc/dns.rs`:
- Around line 4090-4091: Update the state containing local_ip4 and local_ip6 to
use Cell<Option<u32>> and Cell<Option<[u8; 16]>>, representing unset addresses
as None rather than zero values. Adjust all initialization, assignment, and
replay_local_address() logic to replay only Some(...) values while preserving
valid 0.0.0.0 and :: bindings, and add regression coverage for both unspecified
addresses across a channel reset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 34095eb2-6410-4380-b971-d668dfca4cb0

📥 Commits

Reviewing files that changed from the base of the PR and between 01a7a40 and 895698d.

📒 Files selected for processing (2)
  • src/runtime/dns_jsc/dns.rs
  • test/js/bun/dns/dns-config-change.test.ts

Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread src/runtime/dns_jsc/dns.rs Outdated
Comment thread test/js/bun/dns/dns-config-change.test.ts
robobun and others added 2 commits July 20, 2026 08:40
…ments

Switch local_ip4/ip6 to Cell<Option<..>> so 0.0.0.0 / :: (valid 'any'
bindings) aren't conflated with unset. set_local_address now parses and
validates all arguments into locals before committing either cell, so a
throwing call leaves the prior stash unchanged. Trim the two coercion
comments to three lines.

@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.

No new issues found in this revision — the coerce-before-fetch reordering in set_local_address/set_channel_servers, the parse-then-commit split for setLocalAddress, and the direct self.channel.get() in check_timeouts all look correct. Deferring to a human because this is a new ~350-line cross-platform native subsystem (inotify / notify(3) / NotifyIpInterfaceChange FFI, FilePoll lifecycle, off-thread callback on Windows touching GlobalCache) that changes get_channel() from lazy-init-only to potentially destructive.

What was reviewed:

  • reset_channel() ordering vs. sock_state_cb re-entry and pending-query ECANCELLED delivery
  • Windows on_change runs off-thread → invalidate_global_cache(): confirmed it only takes the Guarded lock and heap-frees refcount-0 Requests (no JS-heap access)
  • inotify event parsing: kernel guarantees no partial events per read(), so name_off + name_len stays in-bounds
  • setServers([]) now sets is_servers_default = false and passes the active-queries check — matches the pre-PR ordering
Extended reasoning...

Overview

Adds a per-OS DNS-config-change watcher (src/runtime/dns_jsc/config_watcher.rs, ~350 lines new) that bumps a process-global generation counter when /etc/resolv.conf (Linux inotify), the SystemConfiguration DNS key (macOS notify(3)), or IP interface state (Windows NotifyIpInterfaceChange) changes. Resolver::get_channel() now checks the generation and, if stale and servers are still system-default, destroys and lazily recreates the c-ares channel. Also adds a Node-parity ensure_servers() loopback-fallback recovery, replays setLocalAddress() bindings across recreates, and invalidates the connect-path getaddrinfo cache on config change. Touches 11 files: the new watcher module, dns.rs (channel lifecycle + setServers/setLocalAddress reordering), rare_data.rs / posix_event_loop.rs / dispatch.rs (FilePoll plumbing), jsc_hooks.rs (uninstall on VM teardown), and test-only hooks in InternalForTesting.{cpp,h} / internal-for-testing.ts.

Security risks

Low. The only externally-influenced parsing is the Linux inotify event stream (kernel-originated, header-length-driven slice into a stack buffer — kernel guarantees whole events per read so no partial-record OOB). No new user-controlled input paths; BUN_DNS_CONFIG_WATCH_DIR is a test-only env override that just changes the inotify watch directory.

Level of scrutiny

High. This is new native code with per-platform FFI (notify_register_file_descriptor, NotifyIpInterfaceChange), a new FilePoll owner tag with install/uninstall lifecycle tied to VM teardown, and a semantic change to get_channel() that makes it destructive. The Windows backend fires bump_generation() on a system threadpool thread, which then locks GlobalCache and heap-frees entries — I verified Request::deinit only drops owned heap boxes (no JS-heap or FilePoll access), but cross-thread mimalloc frees and the interaction with any other GlobalCache reader deserve a maintainer's eyes. Five prior review rounds on this PR already caught a UAF (raw *mut Channel held across re-entrant get_channel()), a leaked pending exception from check_timeouts, a partial-commit-on-throw in setLocalAddress, and two vacuous tests — the fix pattern is sound now, but the density of subtle issues found so far argues for human sign-off.

Other factors

All prior inline findings are resolved. The remaining CodeRabbit note (test doesn't directly observe channel recreation) was acknowledged as a follow-up. Tests are subprocess-isolated and test.concurrent; the Linux inotify test uses a temp-dir override so it doesn't touch /etc. rust:check-all reportedly passes on all 10 targets but CI hasn't run on the final commit yet.

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #76174 failures are all [flaky]-tagged and in unrelated code paths:

  • es-module-lexer.test.ts (Windows x64, timeout)
  • 27272.test.ts (Windows x64, S3 InternalError from the service)
  • spawn.test.ts (Windows aarch64, timeout)
  • 30205.test.ts (Debian asan, NapiFinalizerTask leak)
  • test-http-client-leaky-with-double-response.js (Windows x64, timeout)
  • test-fs-promises-file-handle-readFile.js (Debian x64)

The new dns-config-change.test.ts passes on every lane that ran it, and the existing node-dns.test.js setServers/getServers tests pass on all lanes. Diff is green on everything DNS-related; ready for review.

@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.

No new findings on the latest revision — the parse-then-commit refactor for setLocalAddress/setServers and the Option<> local-address stash look correct. Deferring to a human for sign-off given the scope: new per-platform OS watcher subsystem (inotify / notify(3) / NotifyIpInterfaceChange) plus c-ares channel destroy/recreate lifecycle changes.

What was reviewed:

  • get_channel() re-entrancy after it became destructive — set_local_address/set_channel_servers now coerce all JS args before fetching the channel, and check_timeouts reads self.channel.get() directly.
  • Windows off-thread bump_generation()invalidate_global_cache()GlobalCache is Guarded + Send, Request::deinit is a plain Box drop with no thread-affine data.
  • Per-VM watcher lifecycle — uninstall() wired into close_dns_for_terminate(); FilePoll/fd/notify-token released together.
  • Earlier rounds: setServers([]) short-circuit, ECONNREFUSED loopback fallback, is_servers_default gating, vacuous test dropped, test.concurrent.
Extended reasoning...

Overview

This PR adds a process-wide DNS-config-change watcher so the c-ares channel backing dns.resolve* re-reads system nameservers after VPN/Wi-Fi/DHCP changes. New file config_watcher.rs (~356 lines) implements per-OS backends (Linux inotify on /etc, macOS notify(3) on the SystemConfiguration DNS key, Windows NotifyIpInterfaceChange), bumps an AtomicU64 generation, and invalidates the connect-path getaddrinfo cache. dns.rs gains check_config_change()/ensure_servers()/reset_channel()/replay_local_address(), five new Resolver fields, note_query_result() at four callback sites, and a substantial refactor of set_local_address()/set_channel_servers() to parse-then-commit. Plumbing: new PollTag::DnsConfig, RareData slot, dispatch arm, jsc_hooks uninstall, and two bun:internal-for-testing hooks.

Security risks

None identified. The BUN_DNS_CONFIG_WATCH_DIR env override (Linux only) redirects the inotify watch to a test directory but is bounded to a 512-byte stack buffer and only affects which directory is watched — it doesn't influence what nameservers c-ares reads. No user-controlled data reaches the FFI paths.

Level of scrutiny

High. This is native code with per-platform raw FFI, a new FilePoll owner tag, and a change that makes get_channel() destructive (it can now ares_destroy an existing channel). The PR has been through five prior review rounds that surfaced and fixed real issues: a UAF from re-entrant get_channel() while holding a raw *mut Channel across JS coercions, a pending-exception leak from check_timeouts, partial-state commit on setLocalAddress error, per-VM vs process-global watcher lifecycle for Workers, and a vacuous test. All are resolved in the current revision. The Windows callback runs on a system threadpool thread; I verified invalidate_global_cache() only touches the Guarded<GlobalCache> mutex and Request::deinit is a plain Box drop, matching the existing unsafe impl Send contract on that type.

Other factors

The PR description notes tests are platform-gated and defer to CI ("no test proof · iteration 0"). No human reviewer has weighed in yet. Given the cross-platform surface, the number of memory-safety issues iterated through to reach this state, and the design decision to destroy+recreate rather than ares_reinit() (well-justified in the description but non-obvious), a maintainer should sign off on the overall approach and confirm CI is green across all three OS backends before merge.

@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.

No new issues found in this pass, but this adds ~350 lines of platform-specific FFI (inotify, notify(3), NotifyIpInterfaceChange) and changes get_channel() from lazy-init-only to potentially-destructive — worth a maintainer's eyes on the three OS backends and the Windows off-thread callback path.

What was reviewed:

  • invalidate_global_cache() from the Windows threadpool callback — GlobalCache is Guarded<>-locked and Request has no JS-thread-affine fields on Windows, so Request::deinit off-thread looks sound.
  • inotify event parsing — name_len is kernel-supplied and buf is zero-initialized; a short read reads zeros, not OOB.
  • The set_channel_servers reorder (arg coercion now precedes the active-queries check and channel fetch) — no user-JS runs after get_channel_or_error(), and the empty-array path folds into head = null with the same c-ares semantics.
  • Earlier findings (leaked exception in check_timeouts, re-entrant get_channel UAF in set_local_address/set_channel_servers, partial-commit in setLocalAddress) are all confirmed addressed in the current diff.
Extended reasoning...

Overview

Adds a per-process DNS config-change watcher (src/runtime/dns_jsc/config_watcher.rs, ~350 lines new) with three OS backends: Linux/Android inotify on /etc, macOS notify_register_file_descriptor on the SystemConfiguration DNS key, and Windows NotifyIpInterfaceChange. Bumps a global AtomicU64 generation on fire; each Resolver::get_channel() compares its snapshot and, if stale and servers weren't user-pinned, destroys and recreates the c-ares channel. Also adds a Node-parity ensure_servers() loopback-recovery fallback, invalidates the connect-path GlobalCache on config change, and refactors set_local_address() / set_channel_servers() to do all JS coercions before touching the (now-destructible) channel pointer. Plumbing: new PollTag::DnsConfig + dispatch arm, per-VM RareData slot, bun:internal-for-testing hooks, and a 3-test file.

Security risks

Low. The inotify parser reads kernel-supplied inotify_event records into a fixed 4 KB stack buffer; name_len is used to slice within that zero-initialized buffer, so a malformed record would panic (bounds-checked) or read zeros, not OOB. The BUN_DNS_CONFIG_WATCH_DIR override is copied into a 512-byte stack buffer with an explicit length guard. No user-controlled data reaches the FFI declarations. The Windows callback only touches an atomic and a mutex-guarded cache.

Level of scrutiny

High — this warrants a human maintainer. It introduces raw FFI across three platforms (two of which can't be exercised on a Linux CI runner beyond compile), changes get_channel() from monotonic to destructive (which already surfaced one UAF and one leaked-exception bug during earlier review rounds), runs a callback on a Windows system threadpool thread that frees heap allocations, and hand-parses kernel inotify records. The design decisions (why not ares_reinit(), why per-VM watcher on POSIX vs process-global on Windows, why cancel+destroy instead of reinit) are well-argued in the PR description but are exactly the kind of architectural calls a maintainer should sign off on.

Other factors

The PR has already been through three automated review rounds that found and fixed real bugs (leaked JS exception from check_timeouts, re-entrant-get_channel UAF in setLocalAddress/setServers, partial state commit on setLocalAddress failure, a vacuous test). All prior inline comments are resolved. CI is green on DNS-related tests across all lanes. Test coverage is reasonable for the generation-counter and Linux inotify paths but the macOS/Windows backends are compile-checked only (author noted this). The set_channel_servers refactor moves the pending-queries check to after argument coercion — a minor observable ordering change (a throwing toString on an array element now fires before ERR_DNS_SET_SERVERS_FAILED), which seems acceptable but is a behavior delta a maintainer should be aware of.

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.

2 participants