diff --git a/src/runtime/node.rs b/src/runtime/node.rs index 3f2c9278df82..b66912479b43 100644 --- a/src/runtime/node.rs +++ b/src/runtime/node.rs @@ -93,6 +93,7 @@ pub mod fs; // fs.watch() / fs.watchFile() backends — declared here so `fs::watch` / // `fs::watch_file` can reach the real `Arguments` / `FSWatcher` / // `StatWatcher` types instead of opaque local stand-ins. +#[cfg(not(windows))] #[path = "node/path_watcher.rs"] pub mod path_watcher; #[cfg(windows)] diff --git a/src/runtime/node/fs_events.rs b/src/runtime/node/fs_events.rs index 691ba6a1afc5..4d3c74dba95c 100644 --- a/src/runtime/node/fs_events.rs +++ b/src/runtime/node/fs_events.rs @@ -1,3 +1,4 @@ +use core::cell::UnsafeCell; use core::ffi::{c_char, c_int, c_long, c_void}; use core::ptr::{self, NonNull}; use core::sync::atomic::{AtomicPtr, Ordering}; @@ -9,7 +10,7 @@ use bun_threading::{Mutex, Semaphore, UnboundedQueue}; // Both siblings are wired into `crate::node`, and intra-crate module cycles // are fine in Rust, so import the real shapes instead of mirroring them. use super::node_fs_watcher::Event; -use super::path_watcher::EventType; +use super::node_fs_watcher::WatchEventKind; pub(crate) type CFAbsoluteTime = f64; pub(crate) type CFTimeInterval = f64; @@ -92,10 +93,9 @@ pub(crate) const K_FS_EVENTS_RENAMED: c_int = K_FS_EVENT_STREAM_EVENT_FLAG_ITEM_ | K_FS_EVENT_STREAM_EVENT_FLAG_ITEM_RENAMED; static FSEVENTS_DEFAULT_LOOP_MUTEX: Mutex = Mutex::new(); -// PORTING.md §Global mutable state: written under FSEVENTS_DEFAULT_LOOP_MUTEX, -// read with double-checked-locking. AtomicPtr gives safe load/store; the mutex -// serialises the init/teardown writes (Acquire/Release publishes the pointee). -static FSEVENTS_DEFAULT_LOOP: AtomicPtr = AtomicPtr::new(ptr::null_mut()); +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +static FSEVENTS_DEFAULT_LOOP: std::sync::OnceLock<&'static FSEventsLoop> = + std::sync::OnceLock::new(); #[cfg(unix)] fn dlsym(handle: *mut c_void, symbol: &core::ffi::CStr) -> Option { @@ -132,6 +132,7 @@ pub struct CoreFoundation { CFIndex, *const c_void, ) -> CFArrayRef, + pub retain: unsafe extern "C" fn(CFTypeRef) -> CFTypeRef, pub release: unsafe extern "C" fn(CFTypeRef), pub run_loop_add_source: unsafe extern "C" fn(CFRunLoopRef, CFRunLoopSourceRef, CFStringRef), @@ -151,8 +152,7 @@ pub struct CoreFoundation { pub run_loop_default_mode: *const CFStringRef, } -// SAFETY: `handle` is a leaked dlopen handle (never dlclosed; see deinit note -// below) and `run_loop_default_mode` points at a process-static CFStringRef +// SAFETY: `handle` is a leaked dlopen handle (never dlclosed) and `run_loop_default_mode` points at a process-static CFStringRef // inside the loaded framework. Everything else is a resolved fn pointer. // Sharing/sending bitwise copies across threads is sound. unsafe impl Send for CoreFoundation {} @@ -165,9 +165,6 @@ impl CoreFoundation { pub fn get() -> CoreFoundation { *FSEVENTS_CF.get_or_init(init_core_foundation) } - - // We never deinit this: the dlopen handle is intentionally leaked for the - // process lifetime. } // Clone/Copy: bitwise OK — `handle` is a leaked dlopen handle held for the @@ -207,9 +204,6 @@ impl CoreServices { pub fn get() -> CoreServices { *FSEVENTS_CS.get_or_init(init_core_services) } - - // We never deinit this: the dlopen handle is intentionally leaked for the - // process lifetime. } // Write-once fn-ptr tables; `OnceLock` provides the one-init + acquire/release @@ -230,6 +224,8 @@ fn init_core_foundation() -> CoreFoundation { handle: fsevents_cf_handle, array_create: dlsym(fsevents_cf_handle, c"CFArrayCreate") .unwrap_or_else(|| panic!("Cannot Load CoreFoundation")), + retain: dlsym(fsevents_cf_handle, c"CFRetain") + .unwrap_or_else(|| panic!("Cannot Load CoreFoundation")), release: dlsym(fsevents_cf_handle, c"CFRelease") .unwrap_or_else(|| panic!("Cannot Load CoreFoundation")), run_loop_add_source: dlsym(fsevents_cf_handle, c"CFRunLoopAddSource") @@ -289,18 +285,36 @@ fn init_core_services() -> CoreServices { } pub struct FSEventsLoop { - pub signal_source: CFRunLoopSourceRef, - pub mutex: Mutex, - pub loop_: CFRunLoopRef, + signal_source: AtomicPtr, + loop_: AtomicPtr, + mutex: Mutex, sem: Semaphore, - pub thread: Option>, - pub tasks: UnboundedQueue, - pub watchers: Vec>>, - pub watcher_count: u32, - pub fsevent_stream: FSEventStreamRef, - pub paths: Option>, - pub cf_paths: CFArrayRef, - pub has_scheduled_watchers: bool, + tasks: UnboundedQueue, + thread: UnsafeCell>>, + state: UnsafeCell, +} + +struct FSEventsLoopState { + watchers: Vec>>, + watcher_count: u32, + has_scheduled_watchers: bool, + fsevent_stream: FSEventStreamRef, + paths: Option>, + cf_paths: CFArrayRef, +} + +// SAFETY: cross-thread pointers are `AtomicPtr`; `state` is only accessed under `mutex`; `thread` is only touched by `init()`/`shutdown()` on the JS thread. +unsafe impl Sync for FSEventsLoop {} +// SAFETY: no thread-affine data; the loop is a leaked `&'static` singleton and all shared access is synchronized per the `Sync` impl above. +unsafe impl Send for FSEventsLoop {} + +impl FSEventsLoop { + #[inline] + #[allow(clippy::mut_from_ref)] + unsafe fn state(&self) -> &mut FSEventsLoopState { + // SAFETY: the caller holds `self.mutex`, so this is the only live reference to `state`. + unsafe { &mut *self.state.get() } + } } pub struct Task { @@ -316,12 +330,11 @@ impl Task { callback(ctx); } - pub fn new(ctx: &mut T, callback: fn(&mut T)) -> Task { + pub fn new(ctx: &'static T, callback: fn(&T)) -> Task { Task { - // SAFETY: fn(&mut T) and fn(*mut ()) have identical single-pointer ABI; - // ctx is always a valid &mut T at call time (see run()). - callback: unsafe { bun_ptr::cast_fn_ptr::(callback) }, - ctx: std::ptr::from_mut::(ctx).cast::<()>(), + // SAFETY: `fn(&T)` and `fn(*mut ())` have identical single-pointer ABI, and `ctx` is a valid `&T` at call time. + callback: unsafe { bun_ptr::cast_fn_ptr::(callback) }, + ctx: core::ptr::from_ref::(ctx).cast_mut().cast::<()>(), } } } @@ -357,24 +370,25 @@ impl ConcurrentTask { } impl FSEventsLoop { - pub fn cf_thread_loop(&mut self) { + fn cf_thread_loop(&'static self) { bun_core::Output::Source::configure_named_thread(zstr!("CFThreadLoop")); let cf = CoreFoundation::get(); + let signal_source = self.signal_source.load(Ordering::Relaxed); // SAFETY: CF fn pointers loaded via dlsym; signal_source is valid unsafe { - self.loop_ = (cf.run_loop_get_current)(); + // Retain the run loop so it outlives this thread's pthread-TSD destructor; `shutdown()` releases it after `thread.join()`. + let loop_ = (cf.retain)((cf.run_loop_get_current)()); + self.loop_.store(loop_, Ordering::Release); - (cf.run_loop_add_source)(self.loop_, self.signal_source, *cf.run_loop_default_mode); + (cf.run_loop_add_source)(loop_, signal_source, *cf.run_loop_default_mode); self.sem.post(); (cf.run_loop_run)(); - (cf.run_loop_remove_source)(self.loop_, self.signal_source, *cf.run_loop_default_mode); + (cf.run_loop_remove_source)(loop_, signal_source, *cf.run_loop_default_mode); } - - self.loop_ = ptr::null_mut(); } // Runs in CF thread, executed after `enqueueTaskConcurrent()`. Body @@ -384,8 +398,8 @@ impl FSEventsLoop { if arg.is_null() { return; } - // SAFETY: arg was set to `this: *mut FSEventsLoop` in init() - let this = unsafe { bun_ptr::callback_ctx::(arg) }; + // SAFETY: `arg` is the leaked `&'static FSEventsLoop` set as `ctx.info` in `init()`. + let this: &FSEventsLoop = unsafe { &*arg.cast::() }; let concurrent = this.tasks.pop_batch(); let count = concurrent.count; @@ -409,27 +423,35 @@ impl FSEventsLoop { } } - pub fn init() -> Result<*mut FSEventsLoop, bun_core::Error> { - let this = bun_core::heap::into_raw(Box::new(FSEventsLoop { - signal_source: ptr::null_mut(), + pub fn init() -> Result<&'static FSEventsLoop, bun_core::Error> { + // Owning raw pointer first, shared view second: the error paths below reclaim + // through `this_ptr`, which must not be derived from a shared reference. + let this_ptr: *mut FSEventsLoop = bun_core::heap::into_raw(Box::new(FSEventsLoop { + signal_source: AtomicPtr::new(ptr::null_mut()), + loop_: AtomicPtr::new(ptr::null_mut()), mutex: Mutex::new(), - loop_: ptr::null_mut(), sem: Semaphore::default(), - thread: None, tasks: UnboundedQueue::default(), - watchers: Vec::new(), - watcher_count: 0, - fsevent_stream: ptr::null_mut(), - paths: None, - cf_paths: ptr::null_mut(), - has_scheduled_watchers: false, + thread: UnsafeCell::new(None), + state: UnsafeCell::new(FSEventsLoopState { + watchers: Vec::new(), + watcher_count: 0, + has_scheduled_watchers: false, + fsevent_stream: ptr::null_mut(), + paths: None, + cf_paths: ptr::null_mut(), + }), })); + // SAFETY: just allocated and exclusively owned; the CF thread only sees it after spawn. + let this: &'static FSEventsLoop = unsafe { &*this_ptr }; let cf = CoreFoundation::get(); let mut ctx = CFRunLoopSourceContext { version: 0, - info: this.cast::(), + info: core::ptr::from_ref::(this) + .cast_mut() + .cast::(), retain: None, release: None, copy_description: None, @@ -444,33 +466,38 @@ impl FSEventsLoop { let signal_source = unsafe { (cf.run_loop_source_create)(ptr::null_mut(), 0, &raw mut ctx) }; if signal_source.is_null() { + // SAFETY: nothing else has seen the allocation (published only on Ok). + drop(unsafe { bun_core::heap::take(this_ptr) }); return Err(bun_core::err!("FailedToCreateCoreFoudationSourceLoop")); } + this.signal_source.store(signal_source, Ordering::Relaxed); - // SAFETY: this is a valid freshly-boxed pointer + let handle = match std::thread::Builder::new() + .name("CFThreadLoop".into()) + .spawn(move || this.cf_thread_loop()) + { + Ok(handle) => handle, + Err(_) => { + // SAFETY: the source was never scheduled on a run loop and the allocation + // was never published, so both are exclusively owned here. + unsafe { + (cf.release)(signal_source.cast()); + drop(bun_core::heap::take(this_ptr)); + } + return Err(bun_core::err!("FailedToSpawnFSEventsThread")); + } + }; + // SAFETY: `thread` is only touched by `init()`/`shutdown()` on the JS thread; the CF thread never accesses it. unsafe { - (*this).signal_source = signal_source; - // The raw `this` pointer is moved - // into the closure; the FSEventsLoop is heap-allocated and outlives - // the thread (joined in Drop). - let this_addr = this as usize; - (*this).thread = Some( - std::thread::Builder::new() - .name("CFThreadLoop".into()) - .spawn(move || { - // SAFETY: see above — `this` is a valid heap allocation for the thread's lifetime. - (*(this_addr as *mut FSEventsLoop)).cf_thread_loop() - }) - .expect("failed to spawn thread"), - ); - - // sync threads - (*this).sem.wait(); + *this.thread.get() = Some(handle); } + + // sync threads + this.sem.wait(); Ok(this) } - fn enqueue_task_concurrent(&mut self, task: Task) { + fn enqueue_task_concurrent(&self, task: Task) { let cf = CoreFoundation::get(); let concurrent = bun_core::heap::into_raw(Box::new(ConcurrentTask { task: Task { @@ -484,8 +511,13 @@ impl FSEventsLoop { unsafe { ConcurrentTask::from(&mut *concurrent, task, true); self.tasks.push(NonNull::new_unchecked(concurrent)); - (cf.run_loop_source_signal)(self.signal_source); - (cf.run_loop_wake_up)(self.loop_); + } + let signal_source = self.signal_source.load(Ordering::Relaxed); + let loop_ = self.loop_.load(Ordering::Acquire); + // SAFETY: CF fn pointers loaded via dlsym; handles valid per above. + unsafe { + (cf.run_loop_source_signal)(signal_source); + (cf.run_loop_wake_up)(loop_); } } @@ -503,8 +535,8 @@ impl FSEventsLoop { let paths_ptr = event_paths as *const *const c_char; // SAFETY: event_paths is a `char **` of length num_events per FSEvents API let paths = unsafe { bun_core::ffi::slice(paths_ptr, num_events) }; - // SAFETY: info was set to self in _schedule() - let loop_ = unsafe { bun_ptr::callback_ctx::(info) }; + // SAFETY: `info` is the leaked `&'static FSEventsLoop` set as `ctx.info` in `_schedule()`. + let loop_: &FSEventsLoop = unsafe { &*info.cast::() }; // SAFETY: event_flags is an array of length num_events per FSEvents API let event_flags = unsafe { bun_core::ffi::slice(event_flags.cast_const(), num_events) }; @@ -515,8 +547,10 @@ impl FSEventsLoop { // on freed memory. Holding the lock also prevents `registerWatcher` // from reallocating the `watchers` buffer mid-iteration. let _guard = loop_.mutex.lock_guard(); + // SAFETY: holding `mutex` — see `FSEventsLoop::state`. + let state = unsafe { loop_.state() }; - for watcher in loop_.watchers.slice() { + for watcher in state.watchers.slice() { let Some(handle) = *watcher else { continue }; // `handle` is alive while held under the mutex (see comment above); // `BackRef` invariant (pointee outlives holder) holds for this @@ -574,10 +608,10 @@ impl FSEventsLoop { } } - let event_type: EventType = if is_rename { - EventType::Rename + let event_type: WatchEventKind = if is_rename { + WatchEventKind::Rename } else { - EventType::Change + WatchEventKind::Change }; handle.emit(event_type.to_event(path.into()), is_file); } @@ -586,31 +620,30 @@ impl FSEventsLoop { } // Runs on CF Thread - pub fn _schedule(&mut self) { + fn _schedule(&self) { let _guard = self.mutex.lock_guard(); - self.has_scheduled_watchers = false; - let watcher_count = self.watcher_count; - - // Reshaped for borrowck — defer slicing self.watchers until after - // the early-exit checks so the &mut self for fsevent_stream/paths doesn't conflict. + // SAFETY: holding `mutex` — see `FSEventsLoop::state`. + let state = unsafe { self.state() }; + state.has_scheduled_watchers = false; + let watcher_count = state.watcher_count; let cf = CoreFoundation::get(); let cs = CoreServices::get(); // SAFETY: all CF/CS calls below operate on handles we own unsafe { - if !self.fsevent_stream.is_null() { - let stream = self.fsevent_stream; + if !state.fsevent_stream.is_null() { + let stream = state.fsevent_stream; // Stop emitting events (cs.fs_event_stream_stop)(stream); // Release stream (cs.fs_event_stream_invalidate)(stream); (cs.fs_event_stream_release)(stream); - self.fsevent_stream = ptr::null_mut(); + state.fsevent_stream = ptr::null_mut(); } // clean old paths - if let Some(p) = self.paths.take() { + if let Some(p) = state.paths.take() { for s in p.iter() { if !s.is_null() { (cf.release)(*s); @@ -618,9 +651,9 @@ impl FSEventsLoop { } drop(p); } - if !self.cf_paths.is_null() { - let cfp = self.cf_paths; - self.cf_paths = ptr::null_mut(); + if !state.cf_paths.is_null() { + let cfp = state.cf_paths; + state.cf_paths = ptr::null_mut(); (cf.release)(cfp); } @@ -628,12 +661,10 @@ impl FSEventsLoop { return; } - let watchers = self.watchers.slice(); - let mut paths: Box<[*mut c_void]> = vec![ptr::null_mut(); watcher_count as usize].into_boxed_slice(); let mut count: u32 = 0; - for w in watchers { + for w in state.watchers.slice() { if let Some(watcher) = *w { // SAFETY: watcher alive under mutex; its `path` borrows from the // owning PathWatcher, whose `ZBox` storage is NUL-terminated, so @@ -655,7 +686,9 @@ impl FSEventsLoop { ptr::null(), ); let mut ctx = FSEventStreamContext { - info: std::ptr::from_mut(self).cast::(), + info: core::ptr::from_ref::(self) + .cast_mut() + .cast::(), ..Default::default() }; @@ -706,7 +739,7 @@ impl FSEventsLoop { (cs.fs_event_stream_schedule_with_run_loop)( r#ref, - self.loop_, + self.loop_.load(Ordering::Relaxed), *cf.run_loop_default_mode, ); if (cs.fs_event_stream_start)(r#ref) == 0 { @@ -722,104 +755,102 @@ impl FSEventsLoop { (cs.fs_event_stream_release)(r#ref); return; } - self.fsevent_stream = r#ref; - self.paths = Some(paths); - self.cf_paths = cf_paths; + state.fsevent_stream = r#ref; + state.paths = Some(paths); + state.cf_paths = cf_paths; } } - fn register_watcher(&mut self, watcher: *mut FSEventsWatcher) { - { - let _guard = self.mutex.lock_guard(); - if self.watcher_count as usize == self.watchers.len() { - self.watcher_count += 1; - self.watchers.push(NonNull::new(watcher)); - } else { - let watchers = self.watchers.slice_mut(); - for (i, w) in watchers.iter_mut().enumerate() { - let _ = i; - if w.is_none() { - *w = NonNull::new(watcher); - self.watcher_count += 1; - break; - } + fn register_watcher(&'static self, watcher: *mut FSEventsWatcher) { + let _guard = self.mutex.lock_guard(); + // SAFETY: holding `mutex` — see `FSEventsLoop::state`. + let state = unsafe { self.state() }; + if state.watcher_count as usize == state.watchers.len() { + state.watcher_count += 1; + state.watchers.push(NonNull::new(watcher)); + } else { + for w in state.watchers.slice_mut() { + if w.is_none() { + *w = NonNull::new(watcher); + state.watcher_count += 1; + break; } } + } - if !self.has_scheduled_watchers { - self.has_scheduled_watchers = true; - } else { - return; - } + if !state.has_scheduled_watchers { + state.has_scheduled_watchers = true; + } else { + return; } - // Enqueue after dropping the guard so we can take &mut self twice; - // safe to release first since enqueue only pushes to a lock-free queue and - // signals CF, and `_schedule` re-acquires the mutex on the CF thread. - let task = Task::new(self, FSEventsLoop::_schedule); - self.enqueue_task_concurrent(task); + self.enqueue_task_concurrent(Task::new(self, FSEventsLoop::_schedule)); } - fn unregister_watcher(&mut self, watcher: *mut FSEventsWatcher) { - { - let _guard = self.mutex.lock_guard(); - // Reshaped for borrowck — capture len before mutable iteration - let len = self.watchers.len() as usize; - let watchers = self.watchers.slice_mut(); - for i in 0..len { - if let Some(item) = watchers[i] { - if item.as_ptr() == watcher { - watchers[i] = None; - // if is the last one just pop - if i == len - 1 { - let _ = self.watchers.pop(); - } - self.watcher_count -= 1; - break; + fn unregister_watcher(&'static self, watcher: *mut FSEventsWatcher) { + let _guard = self.mutex.lock_guard(); + // SAFETY: holding `mutex` — see `FSEventsLoop::state`. + let state = unsafe { self.state() }; + let len = state.watchers.len() as usize; + for i in 0..len { + if let Some(item) = state.watchers.slice_mut()[i] { + if item.as_ptr() == watcher { + state.watchers.slice_mut()[i] = None; + // if is the last one just pop + if i == len - 1 { + let _ = state.watchers.pop(); } + state.watcher_count -= 1; + break; } } + } - // Rebuild the FSEventStream on the CF thread so it stops firing for - // the path we just removed. Without this the stream keeps delivering - // events for freed paths until another register happens to - // reschedule. `_events_cb` tolerates the interim (it sees `null` and - // skips) because both sides hold `this.mutex`. - if !self.has_scheduled_watchers { - self.has_scheduled_watchers = true; - } else { - return; - } + // Rebuild the FSEventStream on the CF thread so it stops firing for + // the path we just removed. Without this the stream keeps delivering + // events for freed paths until another register happens to + // reschedule. `_events_cb` tolerates the interim (it sees `null` and + // skips) because both sides hold `this.mutex`. + if !state.has_scheduled_watchers { + state.has_scheduled_watchers = true; + } else { + return; } - // Reshaped for borrowck — see register_watcher - let task = Task::new(self, FSEventsLoop::_schedule); - self.enqueue_task_concurrent(task); + self.enqueue_task_concurrent(Task::new(self, FSEventsLoop::_schedule)); } // Runs on CF loop to close the loop - fn _stop(&mut self) { + fn _stop(&self) { let cf = CoreFoundation::get(); - // SAFETY: self.loop_ is the CF thread's current run loop - unsafe { (cf.run_loop_stop)(self.loop_) }; + // SAFETY: runs on the CF thread — this is our own run loop. + unsafe { (cf.run_loop_stop)(self.loop_.load(Ordering::Relaxed)) }; } -} -impl Drop for FSEventsLoop { - fn drop(&mut self) { + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + fn shutdown(&'static self) { + // SAFETY: `thread` is only touched here and in `init()`, always on the JS thread under `FSEVENTS_DEFAULT_LOOP_MUTEX`. + let Some(thread) = (unsafe { (*self.thread.get()).take() }) else { + return; // already shut down + }; // signal close and wait - // Reshaped for borrowck — build Task (stores raw ptr) before re-borrowing &mut self - let stop_task = Task::new(self, FSEventsLoop::_stop); - self.enqueue_task_concurrent(stop_task); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } + self.enqueue_task_concurrent(Task::new(self, FSEventsLoop::_stop)); + let _ = thread.join(); + let cf = CoreFoundation::get(); + let loop_ = self.loop_.swap(ptr::null_mut(), Ordering::Relaxed); + debug_assert!(!loop_.is_null()); + // SAFETY: retained in `cf_thread_loop`; sole owner after join. + unsafe { (cf.release)(loop_) }; + let signal_source = self.signal_source.swap(ptr::null_mut(), Ordering::Relaxed); + debug_assert!(!signal_source.is_null()); // SAFETY: signal_source is a valid CF object until released here - unsafe { (cf.release)(self.signal_source) }; - self.signal_source = ptr::null_mut(); + unsafe { (cf.release)(signal_source) }; - if self.watcher_count > 0 { - while let Some(watcher) = self.watchers.pop() { + let _guard = self.mutex.lock_guard(); + // SAFETY: holding `mutex` — see `FSEventsLoop::state`. + let state = unsafe { self.state() }; + if state.watcher_count > 0 { + while let Some(watcher) = state.watchers.pop() { if let Some(w) = watcher { // `w` is a registered, not-yet-freed watcher; `BackRef` // invariant holds. `loop_` is a `Cell`, so the write goes @@ -828,8 +859,6 @@ impl Drop for FSEventsLoop { } } } - - // Vec storage freed by its own Drop (or explicit deinit) } } @@ -843,13 +872,7 @@ pub struct FSEventsWatcher { pub path: bun_ptr::RawSlice, pub callback: Callback, pub flush_callback: UpdateEndCallback, - // Stored as a raw pointer because the loop is - // shared with the CFRunLoop thread and mutated through `unregister_watcher` - // on drop; holding a `&'static FSEventsLoop` and casting it to `*mut` would - // be UB (write through pointer derived from shared ref). `Cell` so - // `FSEventsLoop::drop` can null it through a shared `BackRef` (the watcher - // is otherwise only read via `&self` on the CF thread under the mutex). - pub loop_: core::cell::Cell>>, + pub loop_: core::cell::Cell>, pub recursive: bool, pub ctx: *mut c_void, } @@ -858,13 +881,9 @@ pub type Callback = fn(ctx: *mut c_void, event: Event, is_file: bool); pub(crate) type UpdateEndCallback = fn(ctx: *mut c_void); impl FSEventsWatcher { - /// # Safety - /// `loop_` must point to a valid, live `FSEventsLoop` (the heap-allocated - /// global default loop from `FSEventsLoop::init`) for the lifetime of the - /// returned watcher; mutable access to its watcher list is serialized by - /// `loop_.mutex` inside `register_watcher`. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub(crate) fn init( - loop_: NonNull, + loop_: &'static FSEventsLoop, path: &[u8], recursive: bool, callback: Callback, @@ -880,8 +899,7 @@ impl FSEventsWatcher { ctx, }); - // SAFETY: caller contract — see `# Safety` above. - unsafe { (*loop_.as_ptr()).register_watcher(&raw mut *this) }; + loop_.register_watcher(&raw mut *this); this } @@ -897,14 +915,7 @@ impl FSEventsWatcher { impl Drop for FSEventsWatcher { fn drop(&mut self) { if let Some(loop_) = self.loop_.get() { - // SAFETY: `loop_` is the heap-allocated global default loop (see - // FSEventsLoop::init); it outlives every watcher, and is only set to - // None here by FSEventsLoop::drop *after* draining watchers. Mutable - // access to the watcher list is serialized by `self.mutex` inside - // unregister_watcher. - unsafe { - (*loop_.as_ptr()).unregister_watcher(std::ptr::from_mut(self)); - } + loop_.unregister_watcher(std::ptr::from_mut(self)); } } } @@ -916,52 +927,34 @@ pub fn watch( update_end: UpdateEndCallback, ctx: *mut c_void, ) -> Result, bun_core::Error> { - let loop_ = FSEVENTS_DEFAULT_LOOP.load(Ordering::Acquire); - if let Some(loop_) = NonNull::new(loop_) { - // SAFETY: `loop_` is the heap-allocated global default loop published - // under `FSEVENTS_DEFAULT_LOOP_MUTEX`; valid for the program lifetime. + if let Some(&loop_) = FSEVENTS_DEFAULT_LOOP.get() { return Ok(FSEventsWatcher::init( loop_, path, recursive, callback, update_end, ctx, )); } let _guard = FSEVENTS_DEFAULT_LOOP_MUTEX.lock_guard(); - let mut loop_ = FSEVENTS_DEFAULT_LOOP.load(Ordering::Acquire); - if loop_.is_null() { - loop_ = FSEventsLoop::init()?; - FSEVENTS_DEFAULT_LOOP.store(loop_, Ordering::Release); - // First loop ever created → arrange `close_and_wait` to run from - // `Bun__onExit`, which runs it BEFORE - // `runExitCallbacks()`, so push to the pre-exit list rather than - // the generic atexit list (storage lives in bun_core; forward dep). - bun_core::Global::add_pre_exit_callback(close_and_wait_on_exit); - } - // SAFETY: `loop_` is the heap-allocated global default loop (just created or - // re-read under the mutex); valid for the program lifetime. - let loop_ = NonNull::new(loop_).expect("FSEventsLoop::init returned non-null"); + let loop_: &'static FSEventsLoop = match FSEVENTS_DEFAULT_LOOP.get() { + Some(&l) => l, + None => { + let l = FSEventsLoop::init()?; + let _ = FSEVENTS_DEFAULT_LOOP.set(l); + bun_core::Global::add_pre_exit_callback(close_and_wait_on_exit); + l + } + }; Ok(FSEventsWatcher::init( loop_, path, recursive, callback, update_end, ctx, )) } -/// `extern "C"` thunk so this fits `bun_core::Global::ExitFn`. extern "C" fn close_and_wait_on_exit() { close_and_wait() } pub(crate) fn close_and_wait() { - #[cfg(not(target_os = "macos"))] - { - return; - } - #[cfg(target_os = "macos")] - { - let loop_ = FSEVENTS_DEFAULT_LOOP.load(Ordering::Acquire); - if !loop_.is_null() { - let _guard = FSEVENTS_DEFAULT_LOOP_MUTEX.lock_guard(); - // SAFETY: loop_ was heap-allocated in FSEventsLoop::init(); reconstitute to run Drop - unsafe { drop(bun_core::heap::take(loop_)) }; - FSEVENTS_DEFAULT_LOOP.store(ptr::null_mut(), Ordering::Release); - } + if let Some(&loop_) = FSEVENTS_DEFAULT_LOOP.get() { + let _guard = FSEVENTS_DEFAULT_LOOP_MUTEX.lock_guard(); + loop_.shutdown(); } } diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index e2a4848c4bdb..32fe842073d6 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -287,6 +287,26 @@ pub type EventPathString = StringOrBytesToDecode; #[cfg(not(windows))] pub type EventPathString = Box<[u8]>; +/// The kind of change a watcher backend reports for a path, before it becomes a JS event. +/// Every backend (inotify, kqueue, FSEvents, Windows) produces exactly these two. +#[derive(Copy, Clone, Default, Eq, PartialEq, strum::IntoStaticStr)] +pub enum WatchEventKind { + #[strum(serialize = "rename")] + Rename, + #[strum(serialize = "change")] + #[default] + Change, +} + +impl WatchEventKind { + pub fn to_event(self, path: EventPathString) -> Event { + match self { + WatchEventKind::Rename => Event::Rename(path), + WatchEventKind::Change => Event::Change(path), + } + } +} + pub enum Event { Rename(EventPathString), Change(EventPathString), @@ -294,7 +314,7 @@ pub enum Event { /// An event with no filename, surfaced to JS with `null`, matching node: /// `Change` when the OS event queue overflowed and changes were lost, /// `Rename` when libuv could not convert a name to UTF-8 (Windows). - NoFilename(path_watcher::EventType), + NoFilename(WatchEventKind), Abort, Close, } @@ -850,12 +870,12 @@ impl FSWatcher { } /// `Event::NoFilename`: deliver `(event, null)` regardless of encoding. - fn emit_null_filename(&self, event_type: path_watcher::EventType) { + fn emit_null_filename(&self, event_type: WatchEventKind) { match event_type { - path_watcher::EventType::Rename => { + WatchEventKind::Rename => { self.emit_with_filename::<{ EventType::Rename }>(JSValue::NULL); } - path_watcher::EventType::Change => { + WatchEventKind::Change => { self.emit_with_filename::<{ EventType::Change }>(JSValue::NULL); } } diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index 90d734ca8ea6..4610a3aab788 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -57,7 +57,7 @@ use bun_wyhash::hash; use bun_jsc::VirtualMachineRef as VirtualMachine; -use crate::node::node_fs_watcher::{Event, EventPathString, FSWatcher}; +use crate::node::node_fs_watcher::{Event, FSWatcher, WatchEventKind}; #[cfg(target_os = "macos")] use crate::node::fs_events as fsevents; @@ -159,26 +159,11 @@ impl PathWatcherManager { return Ok(m); } - // Process-lifetime singleton. Hand the allocation off via - // `heap::release`; it is published into - // `DEFAULT_MANAGER` below and lives until process exit — except on the - // `Platform::init` error path, which is the one place it is reclaimed. - let m: &'static mut PathWatcherManager = - bun_core::heap::release(Box::new(PathWatcherManager::default())); - if let Err(e) = Platform::init(m) { - // SAFETY: `m` came from `release(Box::new(..))` above and has not - // been published — reclaim it so the failed init isn't a leak. - unsafe { - drop(bun_core::heap::take( - std::ptr::from_mut::(m), - )) - }; - return Err(e); - } + let m = Platform::init()?; // Holding DEFAULT_MANAGER_MUTEX with `.get()` having returned `None` // above, so this is the first publish; `set` cannot fail. - let _ = DEFAULT_MANAGER.set(&*m); - Ok(&*m) + let _ = DEFAULT_MANAGER.set(m); + Ok(m) } /// Build the dedup key into `buf`. Not null-terminated; only used as a hashmap key. @@ -226,24 +211,6 @@ pub struct PathWatcher { platform: PlatformWatch, } -#[derive(Copy, Clone, Default, Eq, PartialEq, strum::IntoStaticStr)] -pub enum EventType { - #[strum(serialize = "rename")] - Rename, - #[strum(serialize = "change")] - #[default] - Change, -} - -impl EventType { - pub fn to_event(self, path: EventPathString) -> Event { - match self { - EventType::Rename => Event::Rename(path), - EventType::Change => Event::Change(path), - } - } -} - /// Per-handler duplicate suppression. /// /// Suppresses only exact duplicates: same path hash *and* same event type @@ -256,14 +223,14 @@ pub(crate) struct ChangeEvent { #[cfg(not(windows))] hash: u64, #[cfg(not(windows))] - event_type_: EventType, + event_type_: WatchEventKind, #[cfg(not(windows))] timestamp: i64, } #[cfg(not(windows))] impl ChangeEvent { - fn should_emit(&mut self, hash: u64, timestamp: i64, event_type: EventType) -> bool { + fn should_emit(&mut self, hash: u64, timestamp: i64, event_type: WatchEventKind) -> bool { let time_diff = timestamp - self.timestamp; if self.timestamp == 0 || time_diff > 1 @@ -291,7 +258,7 @@ impl PathWatcher { /// Called from the platform reader thread with `manager.mutex` held. /// `rel_path` is borrowed — `onPathUpdatePosix` dupes it before enqueuing. #[cfg(not(windows))] - fn emit(&mut self, event_type: EventType, rel_path: &[u8], is_file: bool) { + fn emit(&mut self, event_type: WatchEventKind, rel_path: &[u8], is_file: bool) { let timestamp = bun_core::time::milli_timestamp(); let h = hash(rel_path); for entry in self.handlers.iterator() { @@ -311,7 +278,7 @@ impl PathWatcher { /// `should_emit` would fold the two into one; node (libuv) delivers both. /// Caller holds `manager.mutex`. #[cfg(any(target_os = "linux", target_os = "android"))] - fn emit_unsuppressed(&mut self, event_type: EventType, rel_path: &[u8], is_file: bool) { + fn emit_unsuppressed(&mut self, event_type: WatchEventKind, rel_path: &[u8], is_file: bool) { for &ctx in self.handlers.keys() { (FSWatcher::ON_PATH_UPDATE)(Some(ctx), event_type.to_event(rel_path.into()), is_file); } @@ -323,7 +290,11 @@ impl PathWatcher { #[cfg(any(target_os = "linux", target_os = "android"))] fn emit_overflow(&mut self) { for &ctx in self.handlers.keys() { - (FSWatcher::ON_PATH_UPDATE)(Some(ctx), Event::NoFilename(EventType::Change), false); + (FSWatcher::ON_PATH_UPDATE)( + Some(ctx), + Event::NoFilename(WatchEventKind::Change), + false, + ); } } @@ -367,7 +338,6 @@ impl PathWatcher { // is therefore scoped to the region where exclusivity actually holds, so // clippy's `&mut` rewrite would be unsound here, not just stylistic. #[allow(clippy::not_unsafe_ptr_arg_deref)] - #[allow(dead_code)] pub(crate) fn detach(this: *mut PathWatcher, ctx: *mut c_void) { // SAFETY: `this` is a live PathWatcher created via `PathWatcher::new`. Read // `manager` via the raw pointer so no `&mut PathWatcher` is asserted before @@ -659,13 +629,6 @@ type Platform = Kqueue; #[cfg(target_os = "freebsd")] type PlatformWatch = KqueueWatch; -// win_watcher.rs imports `EventType` from this file, so this module must -// compile on Windows even though none of the code paths run. The stub keeps -// `Platform::*` resolvable while the actual Windows backend lives in -// win_watcher.rs. -#[cfg(windows)] -type Platform = WindowsStub; - #[cfg(target_arch = "wasm32")] compile_error!("path_watcher: unsupported target"); @@ -738,27 +701,30 @@ mod inotify_masks { #[cfg(any(target_os = "linux", target_os = "android"))] impl Linux { - fn init(manager: &mut PathWatcherManager) -> sys::Result<()> { + fn init() -> sys::Result<&'static PathWatcherManager> { use bun_sys::linux::IN; let rc = sys::linux::inotify_init1(IN::CLOEXEC); if rc < 0 { return Err(sys::Error::from_code_int(sys::last_errno(), Tag::watch)); } + // Owning raw pointer first, shared view second: the spawn error arm reclaims + // through `manager_ptr`, which must not be derived from a shared reference. + let manager_ptr = bun_core::heap::into_raw(Box::new(PathWatcherManager::default())); + // SAFETY: just allocated and exclusively owned; published only on Ok. + let manager: &'static PathWatcherManager = unsafe { &*manager_ptr }; manager.platform_fd.set(Fd::from_native(rc)); // The manager is process-global and never torn down, so the reader thread is // a daemon — detach it instead of stashing a handle we'd never join. - let mgr_ptr = std::ptr::from_mut::(manager) as usize; - match std::thread::Builder::new().spawn(move || { - // SAFETY: manager is process-global (&'static), never freed. - Linux::thread_main(unsafe { &*(mgr_ptr as *const PathWatcherManager) }) - }) { + match std::thread::Builder::new().spawn(move || Linux::thread_main(manager)) { Ok(handle) => drop(handle), // detach Err(_) => { manager.platform_fd.get().close(); + // SAFETY: the thread never started and the manager was never published. + drop(unsafe { bun_core::heap::take(manager_ptr) }); return Err(sys::Error::from_code(E::ENOMEM, Tag::watch)); } } - Ok(()) + Ok(manager) } /// Caller holds `manager.mutex`. @@ -984,7 +950,7 @@ impl Linux { let w = unsafe { &mut *o.watcher }; if o.subpath.as_bytes().is_empty() && (w_is_file || !w_recursive) { w.emit_unsuppressed( - EventType::Rename, + WatchEventKind::Rename, path::basename(w_path), w_is_file, ); @@ -1014,7 +980,7 @@ impl Linux { }; let is_dir_child = ev.mask & IN::ISDIR != 0; - let event_type: EventType = if ev.mask + let event_type: WatchEventKind = if ev.mask & (IN::CREATE | IN::DELETE | IN::DELETE_SELF @@ -1023,9 +989,9 @@ impl Linux { | IN::MOVED_TO) != 0 { - EventType::Rename + WatchEventKind::Rename } else { - EventType::Change + WatchEventKind::Change }; // Dispatch to every owner of this wd. The recursive branch below calls @@ -1143,7 +1109,7 @@ impl Linux { if !entry_is_file { let _ = Linux::add_one(manager, watcher, abs, entry_rel); } - watcher.emit(EventType::Rename, entry_rel, entry_is_file); + watcher.emit(WatchEventKind::Rename, entry_rel, entry_is_file); }, ); } @@ -1202,8 +1168,9 @@ impl Drop for DarwinWatch { #[cfg(target_os = "macos")] impl Darwin { - fn init(_: &mut PathWatcherManager) -> sys::Result<()> { - Ok(()) + fn init() -> sys::Result<&'static PathWatcherManager> { + // SAFETY: just allocated; nothing after this can fail. + Ok(unsafe { &*bun_core::heap::into_raw(Box::new(PathWatcherManager::default())) }) } /// Caller does NOT hold `manager.mutex` — `FSEvents.watch()` takes the FSEvents @@ -1289,8 +1256,8 @@ impl Darwin { // yet unlinked us, so no other `&mut PathWatcher` exists for this allocation. let watcher = unsafe { &mut *watcher_ptr }; match event { - Event::Rename(path) => watcher.emit(EventType::Rename, &path, is_file), - Event::Change(path) => watcher.emit(EventType::Change, &path, is_file), + Event::Rename(path) => watcher.emit(WatchEventKind::Rename, &path, is_file), + Event::Change(path) => watcher.emit(WatchEventKind::Change, &path, is_file), Event::Error(err) => watcher.emit_error(&err), _ => {} } @@ -1363,25 +1330,25 @@ impl PathWatcherManager { #[cfg(target_os = "freebsd")] impl Kqueue { - fn init(manager: &mut PathWatcherManager) -> sys::Result<()> { - let kq = match sys::kqueue() { - Ok(f) => f, - Err(e) => return Err(e), - }; + fn init() -> sys::Result<&'static PathWatcherManager> { + let kq = sys::kqueue()?; + // Owning raw pointer first, shared view second: the spawn error arm reclaims + // through `manager_ptr`, which must not be derived from a shared reference. + let manager_ptr = bun_core::heap::into_raw(Box::new(PathWatcherManager::default())); + // SAFETY: just allocated and exclusively owned; published only on Ok. + let manager: &'static PathWatcherManager = unsafe { &*manager_ptr }; manager.platform_fd.set(kq); // Daemon reader — the manager is process-global and never torn down. - let mgr_ptr = manager as *mut PathWatcherManager as usize; - match std::thread::Builder::new().spawn(move || { - // SAFETY: manager is process-global (&'static), never freed. - Kqueue::thread_main(unsafe { &*(mgr_ptr as *const PathWatcherManager) }) - }) { + match std::thread::Builder::new().spawn(move || Kqueue::thread_main(manager)) { Ok(handle) => drop(handle), // detach Err(_) => { manager.platform_fd.get().close(); + // SAFETY: the thread never started and the manager was never published. + drop(unsafe { bun_core::heap::take(manager_ptr) }); return Err(sys::Error::from_code(E::ENOMEM, Tag::watch)); } } - Ok(()) + Ok(manager) } /// Caller holds `manager.mutex`. @@ -1560,13 +1527,13 @@ impl Kqueue { unsafe { &*((*entry.watcher).path.as_bytes() as *const [u8]) }; let watcher = unsafe { &mut *entry.watcher }; - let event_type: EventType = if kev.fflags + let event_type: WatchEventKind = if kev.fflags & (NOTE::DELETE | NOTE::RENAME | NOTE::REVOKE | NOTE::LINK) != 0 { - EventType::Rename + WatchEventKind::Rename } else { - EventType::Change + WatchEventKind::Change }; // kqueue has no filenames. For a file watch, report the basename; for a @@ -1593,19 +1560,3 @@ impl Kqueue { // ──────────────────────────────────────────────────────────────────────────────── // Windows stub // ──────────────────────────────────────────────────────────────────────────────── - -#[cfg(windows)] -#[derive(Default)] -pub(crate) struct WindowsStub {} - -#[cfg(windows)] -impl WindowsStub { - fn init(_: &mut PathWatcherManager) -> sys::Result<()> { - Err(sys::Error::from_code(E::ENOTSUP, Tag::watch)) - } - fn add_watch(_: &'static PathWatcherManager, _: &mut PathWatcher) -> sys::Result<()> { - Err(sys::Error::from_code(E::ENOTSUP, Tag::watch)) - } - #[allow(dead_code)] - fn remove_watch(_: &'static PathWatcherManager, _: &mut PathWatcher) {} -} diff --git a/src/runtime/node/win_watcher.rs b/src/runtime/node/win_watcher.rs index dfcb03b560a9..8aeb2ad997fb 100644 --- a/src/runtime/node/win_watcher.rs +++ b/src/runtime/node/win_watcher.rs @@ -15,9 +15,7 @@ use bun_sys::windows::libuv as uv; use bun_sys::windows::libuv::UvHandle as _; use bun_threading::Mutex; -// `pub(crate)`: node_fs_watcher's `path_watcher` alias resolves to this module -// on Windows, so `path_watcher::EventType` must re-export the real one. -pub(crate) use super::path_watcher::EventType; +use super::node_fs_watcher::WatchEventKind; // The callbacks are *associated functions* on `FSWatcher`, not free fns. use crate::node::node_fs_watcher::{Event, FSWatcher, StringOrBytesToDecode}; #[allow(non_upper_case_globals)] @@ -155,7 +153,7 @@ pub struct PathWatcher { #[derive(Clone, Copy)] pub(crate) struct ChangeEvent { hash: bun_watcher::HashType, - event_type: EventType, + event_type: WatchEventKind, timestamp: u64, } @@ -163,7 +161,7 @@ impl Default for ChangeEvent { fn default() -> Self { Self { hash: 0, - event_type: EventType::Change, + event_type: WatchEventKind::Change, timestamp: 0, } } @@ -174,7 +172,7 @@ impl ChangeEvent { &mut self, hash: bun_watcher::HashType, timestamp: u64, - event_type: EventType, + event_type: WatchEventKind, ) -> bool { let time_diff = timestamp.saturating_sub(self.timestamp); // skip consecutive exact duplicates (same path and event type) only @@ -237,9 +235,9 @@ impl PathWatcher { } let event_type = if events & uv::UV_RENAME != 0 { - EventType::Rename + WatchEventKind::Rename } else { - EventType::Change + WatchEventKind::Change }; if filename.is_null() { @@ -270,7 +268,7 @@ impl PathWatcher { hash: bun_watcher::HashType, timestamp: u64, is_file: bool, - event_type: EventType, + event_type: WatchEventKind, ) { self.emit_in_progress = true; #[cfg(debug_assertions)] diff --git a/test/internal/dead-code-escape-limits.json b/test/internal/dead-code-escape-limits.json index af4671f8bfe2..ca28f91a5105 100644 --- a/test/internal/dead-code-escape-limits.json +++ b/test/internal/dead-code-escape-limits.json @@ -21,7 +21,7 @@ "src/runtime/dns_jsc/dns.rs": 5, "src/runtime/image/backend_coregraphics.rs": 14, "src/runtime/image/codecs.rs": 1, - "src/runtime/node/path_watcher.rs": 2, + "src/runtime/node/fs_events.rs": 3, "src/runtime/server/NodeHTTPResponse.rs": 1, "src/runtime/shell/IOWriter.rs": 1, "src/runtime/test_runner/expect.rs": 1, diff --git a/test/js/node/watch/fs.watch.close-exit.test.ts b/test/js/node/watch/fs.watch.close-exit.test.ts new file mode 100644 index 000000000000..117bafe69784 --- /dev/null +++ b/test/js/node/watch/fs.watch.close-exit.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// Regression test for pointer-provenance UB in the fs.watch backends +// introduced by the Rust port. +// +// `FSEventsLoop::init()` spawned the CoreFoundation thread by laundering +// `*mut FSEventsLoop` through `usize` (`this as usize` → `addr as *mut _`) to +// satisfy `Send` on the closure. That strips provenance: the CF thread's +// writes to `self.loop_` become disconnected from the JS thread's reads. +// Compounding this, `cf_thread_loop` took `&mut self` and held it across +// `CFRunLoopRun()`, so the JS thread's `&mut FSEventsLoop` in +// `register_watcher`/`unregister_watcher`/`Drop` aliased it — two live +// `&mut` to one allocation is UB regardless of synchronization. Under +// `noalias` LLVM is free to treat the CF thread's +// `self.loop_ = CFRunLoopGetCurrent()` as invisible, so the JS thread's +// `enqueue_task_concurrent` reads a stale `NULL` and calls +// `CFRunLoopWakeUp(NULL)`, faulting inside CoreFoundation at +0xC. +// +// The same `usize` round-trip existed in the Linux inotify and FreeBSD +// kqueue reader-thread spawns; fixed together. +// +// Field report: test/js/node/async_hooks/async-context/async-context-fs-watch.js +// crashed on macOS aarch64 release with "Segmentation fault at address 0xC". +// +// This test hammers the exact sequence from that report — watch → trigger → +// close-in-callback → process.exit — across many subprocesses so the optimizer +// has plenty of chances to exploit the UB. +test.concurrent( + "fs.watch: close() + process.exit() inside the watch callback does not crash", + async () => { + using dir = tempDir("fs-watch-close-exit", { + ".keep": "", + }); + + const script = /* js */ ` + const fs = require("fs"); + const path = require("path"); + // Each subprocess gets its own file so concurrent runs don't race on + // unlink/watch of a shared path. Under 'bun -e' there is no script + // slot, so the first extra CLI arg is argv[1]. + const file = path.join(process.argv[1], "target-" + process.pid + ".txt"); + fs.writeFileSync(file, "initial"); + + const watcher = fs.watch(file, () => { + // Inside the callback: drop the watcher (→ unregister_watcher → + // enqueue_task_concurrent, which reads self.loop_), then exit + // (→ close_and_wait → shutdown → enqueue_task_concurrent again while + // the CF thread is still inside cf_thread_loop). + watcher.close(); + try { fs.unlinkSync(file); } catch {} + process.exit(0); + }); + + // Trigger the watch — repeat on an interval so every platform + // (FSEvents has a 50ms latency floor) gets a chance to deliver. + let n = 0; + const trigger = setInterval(() => fs.writeFileSync(file, "m" + n++), 20); + + // Fallback: if the event never fires, still exercise the crash site + // (close() → unregister_watcher → enqueue_task_concurrent → reads + // self.loop_; process.exit → close_and_wait → shutdown → same). We're + // asserting "does not crash", and that code path is identical whether + // close() is called from the watch callback or a timer — only the CF + // thread's concurrent position differs. Failing here instead would + // make the test flaky under load for no extra coverage. + setTimeout(() => { + clearInterval(trigger); + watcher.close(); + process.exit(0); + }, 4000); + `; + + // Run the sequence many times. On an unpatched macOS aarch64 release build + // this reproduces the 0xC segfault within a handful of iterations; on other + // platforms it still exercises the reader-thread spawn + shutdown path. + // Batch them so a failure surfaces quickly without serializing 40 spawns. + const iterations = 40; + const width = 8; + for (let base = 0; base < iterations; base += width) { + const batch = Array.from({ length: Math.min(width, iterations - base) }, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script, String(dir)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Debug/ASAN builds may write benign warnings to stderr; a crash surfaces + // through the signal and exit code (stderr is included for diagnostics). + expect({ + stdout, + signalCode: proc.signalCode, + exitCode, + crash: stderr.includes("panic") || stderr.includes("Segmentation fault"), + }).toEqual({ + stdout: "", + signalCode: null, + exitCode: 0, + crash: false, + }); + }); + await Promise.all(batch); + } + }, + 60_000, +);