Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/io/posix_event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ pub enum PollTag {
ParentDeathWatchdog,
LifecycleScriptSubprocessOutputReader,
MemoryPressure,
DnsConfig,
}

/// Compatibility module — call sites in `bun_runtime`/`bun_install` still spell
Expand All @@ -246,6 +247,7 @@ pub mod poll_tag {
pub const LIFECYCLE_SCRIPT_SUBPROCESS_OUTPUT_READER: PollTag =
PollTag::LifecycleScriptSubprocessOutputReader;
pub const MEMORY_PRESSURE: PollTag = PollTag::MemoryPressure;
pub const DNS_CONFIG: PollTag = PollTag::DnsConfig;
}

#[derive(Copy, Clone)]
Expand Down
8 changes: 8 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,14 @@ export const isMemoryPressureWatcherInstalled: () => boolean = $newCppFunction(
0,
);

export const dnsConfigGeneration: () => number = $newCppFunction(
"InternalForTesting.cpp",
"jsFunction_dnsConfigGeneration",
0,
);

export const dnsConfigChanged: () => void = $newCppFunction("InternalForTesting.cpp", "jsFunction_dnsConfigChanged", 0);

export const getEventLoopStats: () => { activeTasks: number; concurrentRef: number; numPolls: number } =
$newRustFunction("event_loop.rs", "getActiveTasks", 0);

Expand Down
13 changes: 13 additions & 0 deletions src/jsc/bindings/InternalForTesting.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_BunString_toThreadSafeRefCountDelta, (JSC::J

extern "C" void Bun__MemoryPressure__emit(JSC::JSGlobalObject* global, int level);
extern "C" bool Bun__MemoryPressure__isInstalled(JSC::JSGlobalObject* global);
extern "C" uint64_t Bun__DNSConfig__generation();
extern "C" void Bun__DNSConfig__bump();

// Synthetically fire process.on("memoryPressure") so tests can exercise the
// emit path without depending on real OS memory pressure.
Expand All @@ -124,4 +126,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_isMemoryPressureWatcherInstalled, (JSC::JSGl
return JSValue::encode(jsBoolean(Bun__MemoryPressure__isInstalled(defaultGlobalObject(globalObject))));
}

JSC_DEFINE_HOST_FUNCTION(jsFunction_dnsConfigGeneration, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
return JSValue::encode(jsNumber(static_cast<double>(Bun__DNSConfig__generation())));
}

JSC_DEFINE_HOST_FUNCTION(jsFunction_dnsConfigChanged, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
Bun__DNSConfig__bump();
return encodedJSUndefined();
}

}
2 changes: 2 additions & 0 deletions src/jsc/bindings/InternalForTesting.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunction_BunString_toThreadSafeRefCountDelta);
JSC_DECLARE_HOST_FUNCTION(jsFunction_lowercaseHeaderNameSIMD);
JSC_DECLARE_HOST_FUNCTION(jsFunction_emitMemoryPressure);
JSC_DECLARE_HOST_FUNCTION(jsFunction_isMemoryPressureWatcherInstalled);
JSC_DECLARE_HOST_FUNCTION(jsFunction_dnsConfigGeneration);
JSC_DECLARE_HOST_FUNCTION(jsFunction_dnsConfigChanged);

}
12 changes: 12 additions & 0 deletions src/jsc/rare_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ pub struct RareData {
/// `Box`; lazy-init on the first `process.on("memoryPressure", ...)` listener.
pub memory_pressure_watcher: Option<NonNull<c_void>>,

/// `bun_runtime::dns_jsc::config_watcher` FilePoll — erased; lazy-init on
/// this VM's first c-ares channel. Per-VM so a Worker's watcher dies with
/// the Worker instead of masking the main thread's.
pub dns_config_watcher: Option<NonNull<c_void>>,

/// Watch-mode restart needs to RST every listen socket so the new process
/// can rebind without `EADDRINUSE`. Written on the JS thread; drained on
/// the watcher thread — hence the mutex (PORTING.md §Concurrency: lock
Expand Down Expand Up @@ -325,6 +330,7 @@ impl Default for RareData {
mime_types: None,
node_fs_stat_watcher_scheduler: None,
memory_pressure_watcher: None,
dns_config_watcher: None,
listening_sockets_for_watch_mode: Mutex::new(Vec::new()),
temp_pipe_read_buffer: None,
s3_default_client: Strong::empty(),
Expand Down Expand Up @@ -635,6 +641,12 @@ impl RareData {
&mut self.memory_pressure_watcher
}

/// Raw slot — lazy-init body lives in `bun_runtime::dns_jsc::config_watcher`.
#[inline]
pub fn dns_config_watcher_slot(&mut self) -> &mut Option<NonNull<c_void>> {
&mut self.dns_config_watcher
}

// ── lazy-init: hot_map ─────────────────────────────────────────────────
pub fn hot_map(&mut self) -> &mut HotMap {
self.hot_map.get_or_insert_with(HotMap::init)
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,15 @@ pub unsafe fn __bun_run_file_poll(poll: *mut FilePoll, size_or_offset: i64) {
// SAFETY: `poll` is live per `__bun_run_file_poll`'s contract.
crate::node::memory_pressure::on_poll(unsafe { &mut *poll }, size_or_offset);
}
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
poll_tag::DNS_CONFIG => {
// SAFETY: `poll` is live per `__bun_run_file_poll`'s contract.
crate::dns_jsc::config_watcher::on_poll(unsafe { &mut *poll });
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
poll_tag::DNS_CONFIG => {
debug_assert!(false, "DnsConfig poll on unsupported target");
}
poll_tag::PARENT_DEATH_WATCHDOG => {
let wd = owner_as!(bun_io::parent_death_watchdog::ParentDeathWatchdog);
// Mac-only — debug-assert elsewhere (Linux uses prctl(PR_SET_PDEATHSIG)).
Expand Down
286 changes: 286 additions & 0 deletions src/runtime/dns_jsc/config_watcher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
//! Process-wide DNS-config-change watcher.
//!
//! c-ares reads the system nameserver list once when a channel is created and
//! never again, so after a VPN connect / Wi-Fi switch / DHCP renew the
//! resolver keeps querying the boot-time servers. Node's `ChannelWrap::
//! EnsureServers` works around only the "started offline → 127.0.0.1 fallback"
//! case (nodejs/node#13076); the general case is nodejs/node#49485, closed
//! wontfix.
//!
//! We do better: arm one OS-native change notification per process and bump a
//! global generation counter when it fires. Each `Resolver::get_channel()`
//! checks the counter and, if stale (and the user hasn't called `setServers`),
//! destroys and lazily recreates its channel so the next query re-reads the
//! current system config. The connect-path `GlobalCache` is invalidated at the
//! same time so `fetch()`/sockets don't serve stale `getaddrinfo` results for
//! the rest of the TTL window.
//!
//! Backends (mirroring c-ares' own `ares_event_configchg.c`, which we can't
//! use directly because it's bound to c-ares' private event thread):
//! - Linux: `inotify` on `/etc` filtering `resolv.conf` / `nsswitch.conf`.
//! - macOS: `notify_register_file_descriptor` on SystemConfiguration's
//! DNS-config notify key.
//! - Windows: `NotifyIpInterfaceChange`. The callback runs on a system
//! threadpool thread; it only touches atomics / the `Guarded` cache, so
//! no marshaling to the JS thread is needed.
//! - elsewhere: no-op; the reactive loopback fallback in
//! `Resolver::ensure_servers` still applies.
//!
//! Installed lazily on the first c-ares channel creation, lives for the
//! process, does not keep the event loop alive.

use core::sync::atomic::{AtomicU64, Ordering};

use bun_jsc::virtual_machine::VirtualMachine;

bun_output::declare_scope!(DNSConfigWatcher, visible);

static GENERATION: AtomicU64 = AtomicU64::new(0);

#[inline]
pub fn generation() -> u64 {
GENERATION.load(Ordering::Relaxed)
}

/// Record that the system DNS configuration changed: bump the generation so
/// every `Resolver` recreates its channel on next use, and drop any cached
/// `getaddrinfo` results from the connect-path cache.
pub fn bump_generation() {
GENERATION.fetch_add(1, Ordering::Relaxed);
super::internal::invalidate_global_cache();
bun_output::scoped_log!(DNSConfigWatcher, "generation bumped");
}

/// Arm the OS watcher. On POSIX the FilePoll lives in this VM's hive, so the
/// install is per-VM (a Worker's watcher dies with the Worker rather than
/// masking the main thread's). On Windows `NotifyIpInterfaceChange` is
/// process-scoped, so a process-global flag is correct. Best-effort: if the
/// backend can't register we silently fall back to the reactive loopback check.
pub fn install(vm: &VirtualMachine) {
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
posix::install(vm);
Comment thread
robobun marked this conversation as resolved.
#[cfg(windows)]
windows::install(vm);
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
windows
)))]
let _ = vm;
}

// ────────────────────────────────────────────────────────────────────────────
// POSIX backend: inotify (Linux) / notify(3) (macOS), polled via FilePoll
// ────────────────────────────────────────────────────────────────────────────

#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
mod posix {
use core::ptr::NonNull;

use bun_io::posix_event_loop::{FilePoll, Flags, Owner, poll_tag};
use bun_jsc::virtual_machine::VirtualMachine;
use bun_sys::Fd;

#[cfg(any(target_os = "linux", target_os = "android"))]
fn open_watch_fd() -> Option<Fd> {
use core::ffi::c_char;
use bun_sys::linux::{IN, inotify_add_watch, inotify_init1};

let fd = inotify_init1(IN::NONBLOCK | IN::CLOEXEC);
if fd < 0 {
return None;
}
// Test override so CI can point the watch at a temp dir instead of /etc.
let mut buf = [0u8; 512];
let dir: *const c_char =
match bun_core::getenv_z(bun_core::zstr!("BUN_DNS_CONFIG_WATCH_DIR")) {
Some(v) if v.len() < buf.len() => {
buf[..v.len()].copy_from_slice(v);
buf.as_ptr().cast()
}
_ => c"/etc".as_ptr(),
};
// SAFETY: `dir` is NUL-terminated, `fd` is the live inotify instance.
if unsafe { inotify_add_watch(fd, dir, IN::CREATE | IN::MODIFY | IN::MOVED_TO | IN::ONLYDIR) }
< 0
{
let _ = bun_sys::close(Fd::from_native(fd));
return None;
}
Some(Fd::from_native(fd))
}

#[cfg(target_os = "macos")]
fn open_watch_fd() -> Option<Fd> {
use core::ffi::{c_char, c_int};
const NOTIFY_STATUS_OK: u32 = 0;
unsafe extern "C" {
fn notify_register_file_descriptor(
name: *const c_char,
fd: *mut c_int,
flags: c_int,
token: *mut c_int,
) -> u32;
}
// `dns_configuration_notify_key()` in SystemConfiguration has returned
// this constant since 10.4; hardcoding it avoids a dlsym round-trip.
let key = c"com.apple.system.SystemConfiguration.dns_configuration";
let mut fd: c_int = -1;
let mut token: c_int = 0;
// SAFETY: FFI; out-params are stack locals, key is NUL-terminated.
let rc = unsafe { notify_register_file_descriptor(key.as_ptr(), &mut fd, 0, &mut token) };
if rc != NOTIFY_STATUS_OK || fd < 0 {
return None;
}
let fd = Fd::from_native(fd);
let _ = bun_sys::set_nonblocking(fd);
Some(fd)
}

pub(super) fn install(vm: &VirtualMachine) {
if VirtualMachine::get_mut()
.rare_data()
.dns_config_watcher_slot()
.is_some()
{
return;
}
let Some(fd) = open_watch_fd() else {
return;
};
let ctx = vm.loop_ctx();
let poll = FilePoll::init(
ctx,
fd,
Default::default(),
Owner::new(poll_tag::DNS_CONFIG, NonNull::<()>::dangling().as_ptr()),
);
// SAFETY: `poll` is the fresh hive slot; `platform_event_loop` is the live uws loop.
if unsafe { (*poll).register(ctx.platform_event_loop(), Flags::Readable, false) }.is_err() {
// SAFETY: fresh hive slot never handed out.
unsafe { (*poll).deinit() };
let _ = bun_sys::close(fd);
return;
}
// SAFETY: `poll` just successfully registered; exclusive on the JS thread.
unsafe { (*poll).disable_keeping_process_alive(ctx) };
*VirtualMachine::get_mut()
.rare_data()
.dns_config_watcher_slot() = NonNull::new(poll.cast());
Comment thread
robobun marked this conversation as resolved.
Outdated
}

/// `__bun_run_file_poll` dispatch target for `poll_tag::DNS_CONFIG`.
pub fn on_poll(poll: &mut FilePoll) {
let fd = poll.fd;

#[cfg(any(target_os = "linux", target_os = "android"))]
let triggered = {
const HDR: usize = 16; // sizeof(struct inotify_event) up to `name[]`
let mut buf = [0u8; 4096];
let mut hit = false;
loop {
let n = match bun_sys::read(fd, &mut buf) {
Ok(n) if n > 0 => n,
_ => break,
};
let mut off = 0usize;
while off + HDR <= n {
// The header is `{ wd:i32, mask:u32, cookie:u32, len:u32 }`;
// we only need `len` (bytes 12..16). The kernel never
// returns a partial event.
let name_len =
u32::from_ne_bytes(buf[off + 12..off + 16].try_into().unwrap()) as usize;
let name_off = off + HDR;
let name = &buf[name_off..name_off + name_len];
let name = name.split(|&b| b == 0).next().unwrap_or(name);
if name == b"resolv.conf" || name == b"nsswitch.conf" {
hit = true;
}
off = name_off + name_len;
}
}
hit
};

#[cfg(target_os = "macos")]
let triggered = {
let mut any = false;
let mut t: i32 = 0;
// SAFETY: `t` is a 4-byte stack slot; fd is the live notify fd.
while let Ok(n) = bun_sys::read(fd, unsafe {
core::slice::from_raw_parts_mut((&mut t as *mut i32).cast::<u8>(), 4)
}) {
if n < 4 {
break;
}
any = true;
}
any
};

if triggered {
super::bump_generation();
}
}
}

#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
pub use posix::on_poll;

// ────────────────────────────────────────────────────────────────────────────
// Windows backend: NotifyIpInterfaceChange on the system threadpool
// ────────────────────────────────────────────────────────────────────────────

#[cfg(windows)]
mod windows {
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, Ordering};

use bun_jsc::virtual_machine::VirtualMachine;

type HANDLE = *mut c_void;
const AF_UNSPEC: u16 = 0;

static INSTALLED: AtomicBool = AtomicBool::new(false);

unsafe extern "system" {
fn NotifyIpInterfaceChange(
family: u16,
callback: unsafe extern "system" fn(ctx: *mut c_void, row: *mut c_void, kind: i32),
ctx: *mut c_void,
initial: u8,
handle: *mut HANDLE,
) -> u32;
}

unsafe extern "system" fn on_change(_ctx: *mut c_void, _row: *mut c_void, _kind: i32) {
super::bump_generation();
}

pub(super) fn install(_vm: &VirtualMachine) {
if INSTALLED.swap(true, Ordering::Relaxed) {
return;
}
let mut handle: HANDLE = core::ptr::null_mut();
// SAFETY: FFI; `handle` is a stack out-param, callback has `system` ABI.
// Handle is intentionally leaked; the watch lives for the process.
let _ = unsafe {
NotifyIpInterfaceChange(AF_UNSPEC, on_change, core::ptr::null_mut(), 0, &mut handle)
};
}
}

// ────────────────────────────────────────────────────────────────────────────
// test hooks
// ────────────────────────────────────────────────────────────────────────────

#[unsafe(no_mangle)]
pub extern "C" fn Bun__DNSConfig__generation() -> u64 {
generation()
}

#[unsafe(no_mangle)]
pub extern "C" fn Bun__DNSConfig__bump() {
bump_generation();
}
Loading
Loading