diff --git a/scripts/rust-miri.ts b/scripts/rust-miri.ts index 2512a509d286..2536776eaa9d 100644 --- a/scripts/rust-miri.ts +++ b/scripts/rust-miri.ts @@ -53,6 +53,7 @@ const MIRI_CRATES = [ "bun_shell_parser", "bun_threading", "bun_url", + "bun_watcher", "bun_wyhash", ]; diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 5650ac5b85ff..ebc864b3fa13 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -1,17 +1,11 @@ use core::marker::PhantomData; use core::sync::atomic::{AtomicU32, Ordering}; -#[cfg(not(windows))] -use bun_collections::StringHashMap; use bun_collections::StringSet; use bun_core::Output; use bun_core::ZStr; -#[cfg(not(windows))] -use bun_paths::SEP; use bun_paths::strings; -#[cfg(not(windows))] -use bun_resolver::fs::PathName; -use bun_resolver::fs::{self as Fs, FileSystem}; +use bun_resolver::fs::FileSystem; use bun_sys::{self, Fd}; use bun_watcher::WatchItemColumns as _; use bun_watcher::{ChangedFilePath, Op as WatchOp, Watcher}; @@ -361,9 +355,6 @@ pub struct NewHotReloader { pub(crate) main: MainFile, - #[cfg(not(windows))] - pub(crate) tombstones: StringHashMap<*mut Fs::EntriesOption>, - /// See [`HotReloaderCtx::reload_handle`]. pub(crate) reload_handle: Option, @@ -428,6 +419,13 @@ impl MainFile { main } + + /// Whether the watched folder at `folder_index` holds the entry point, under any of its spellings. + #[cfg(not(windows))] + fn is_in_folder(&self, watchlist: &bun_watcher::WatchList, folder_index: usize) -> bool { + let (folder, other_folders) = bun_watcher::folder_hashes(watchlist, folder_index); + folder == self.dir_hash || other_folders.contains(&self.dir_hash) + } } pub struct Task { @@ -471,7 +469,7 @@ where /// We deliberately do **not** expose a whole-struct `&NewHotReloader` /// accessor: [`Self::run`] executes on the JS event-loop thread while the /// watcher thread may be inside `on_file_update(&mut self)` writing - /// non-`UnsafeCell` fields (`main.is_waiting_for_dir_change`, `tombstones`). + /// non-`UnsafeCell` fields (`main.is_waiting_for_dir_change`). /// Materializing `&NewHotReloader` on the JS thread would assert those /// bytes are frozen, which is a data race / Stacked-Borrows violation even /// though this side never reads them. Instead, project to the single @@ -713,8 +711,6 @@ where verbose: unsafe { (*this).log_level_at_least_info() }, pending_count: AtomicU32::new(0), main: MainFile::init(entry_path.unwrap_or(b"")), - #[cfg(not(windows))] - tombstones: StringHashMap::default(), // SAFETY: see above. reload_handle: unsafe { (*this).reload_handle() }, _event_loop: PhantomData, @@ -754,14 +750,76 @@ where } } + /// Reloads the watched files that a directory event affects, as the watchlist alone tells. #[cfg(not(windows))] - fn put_tombstone(&mut self, key: &[u8], value: *mut Fs::EntriesOption) { - self.tombstones.put(key, value).expect("unreachable"); - } + fn reload_watched_files_in( + &self, + watchlist: &bun_watcher::WatchList, + event: &bun_watcher::WatchEvent, + changed_names: &[ChangedFilePath], + current_task: &mut Task, + ctx: *mut Watcher, + ) -> usize { + // inotify names the entries that changed. kqueue names none: a watched file that is gone changed. + let can_name_a_file = if IS_KQUEUE { + event.op.contains(WatchOp::DELETE) + } else { + !changed_names.is_empty() + }; + if !can_name_a_file { + return 0; + } - #[cfg(not(windows))] - fn get_tombstone(&mut self, key: &[u8]) -> Option<*mut Fs::EntriesOption> { - self.tombstones.get(key).copied() + let slice = watchlist.slice(); + let file_paths = slice.items_file_path(); + let kinds = slice.items_kind(); + let hashes = slice.items_hash(); + let parents = slice.items_parent_hash(); + let file_descriptors = slice.items_fd(); + + let (folder, other_folders) = bun_watcher::folder_hashes(watchlist, event.index as usize); + let mut reloaded: usize = 0; + for (entry_id, parent) in parents.iter().enumerate() { + if *parent != folder && (other_folders.is_empty() || !other_folders.contains(parent)) { + continue; + } + if kinds[entry_id] != bun_watcher::Kind::File + || (bun_watcher::REQUIRES_FILE_DESCRIPTORS + && !file_descriptors[entry_id].is_valid()) + { + continue; + } + let watched_path: &[u8] = &file_paths[entry_id]; + let changed = if IS_KQUEUE { + !bun_sys::exists(watched_path) + } else { + bun_watcher::names_file(changed_names, watched_path) + }; + if !changed { + continue; + } + reloaded += 1; + record_changed_path(watched_path); + current_task.append(hashes[entry_id]); + if self.verbose { + Self::debug(format_args!( + "Removing file: {}", + bstr::BStr::new(watched_path) + )); + Self::debug(format_args!( + "File change: {}", + bstr::BStr::new(bun_paths::resolve_path::relative( + FileSystem::get().top_level_dir, + watched_path, + )) + )); + } + // SAFETY: see the File-arm call in `on_file_update`. + unsafe { + (*ctx).remove_at_index::(bun_watcher::Kind::File, entry_id as u16, 0, &[]) + }; + } + reloaded } pub(crate) fn on_error(_: &mut Self, err: &bun_sys::Error) { @@ -808,10 +866,9 @@ where unsafe { bun_core::ffi::slice_mut(slice.items_raw::<"count", u32>(), slice.len()) }; let kinds = slice.items_kind(); let hashes = slice.items_hash(); - let parents = slice.items_parent_hash(); let file_descriptors = slice.items_fd(); // Note: reshaped for borrowck — `ctx` is held as a raw pointer so - // `self` can be reborrowed inside the loop body for tombstone access, + // `self` can be reborrowed inside the loop body, // and so the deferred `flush_evictions` doesn't hold `&mut Watcher` // across the loop. let ctx: *mut Watcher = std::ptr::from_mut(self.get_context()); @@ -837,11 +894,9 @@ where // SAFETY: the Watcher outlives this call (it owns the Reloader that calls us). unsafe { (*ctx).flush_evictions() }; }); - let fs: &mut FileSystem = FileSystem::instance(); - let rfs: &mut Fs::file_system::RealFS = &mut fs.fs; + let fs = FileSystem::get(); #[cfg(windows)] - let _ = (changed_files, parents, file_descriptors, rfs); - let mut _on_file_update_path_buf = bun_paths::path_buffer_pool::get(); + let _ = (changed_files, file_descriptors); for event in events.iter() { // Stale udata: kevent.udata can outlive a swapRemove in flushEvictions. @@ -881,9 +936,6 @@ where if self.verbose { Self::debug(format_args!( "File changed: {}", - // Note: `fs.relative_to(file_path)` would borrow `&*fs` - // while `rfs = &mut fs.fs` is live; inline the body so the - // split-borrow on `fs.top_level_dir` is visible to borrowck. bstr::BStr::new(bun_paths::resolve_path::relative( fs.top_level_dir, file_path @@ -931,34 +983,15 @@ where } #[cfg(not(windows))] { - let mut affected_buf: [&[u8]; 128] = [b"".as_slice(); 128]; - let mut entries_option: Option<*mut Fs::EntriesOption> = None; - - // Note: the affected-name element type differs by - // platform (kqueue vs inotify). Split into two locals; - // only one is populated per cfg. - let mut affected_kqueue: &[&[u8]] = &[]; - let mut affected_inotify: &[ChangedFilePath] = &[]; - let _ = (&mut affected_kqueue, &mut affected_inotify); - - let affected_len: usize = 'brk: { + let affected_inotify: &[ChangedFilePath] = 'brk: { if IS_KQUEUE { - // SAFETY: hot-reload runs single-threaded on the JS thread; - // no other live `&mut EntriesOption` for this key here. - if let Some(existing) = rfs.entries.get(file_path) { - self.put_tombstone(file_path, existing); - entries_option = Some(existing); - } else if let Some(existing) = self.get_tombstone(file_path) { - entries_option = Some(existing); - } - if event.op.contains(WatchOp::WRITE) { // Check if the entrypoint now exists after an atomic save. // If we previously got a NOTE_RENAME on the entrypoint (vim renamed // the file), this directory write event signals that the new // file has been re-created. Verify it exists and trigger reload. if self.main.is_waiting_for_dir_change - && self.main.dir_hash == current_hash + && self.main.is_in_folder(watchlist, event.index as usize) { // `.is_ok()` only checks faccessat didn't // error (it errs only on NAMETOOLONG), not @@ -988,62 +1021,12 @@ where } } - let mut affected_i: usize = 0; - - // if a file descriptor is stale, we need to close it - if event.op.contains(WatchOp::DELETE) && entries_option.is_some() { - for (entry_id, parent_hash) in parents.iter().enumerate() { - if *parent_hash == current_hash { - let affected_path: &[u8] = &file_paths[entry_id]; - // bun_sys::access takes a &ZStr; build one on the - // stack from the &[u8] watch-list slice. - let was_deleted = { - let mut zbuf = bun_paths::path_buffer_pool::get(); - if affected_path.len() >= zbuf.len() { - false - } else { - zbuf[..affected_path.len()] - .copy_from_slice(affected_path); - zbuf[affected_path.len()] = 0; - // SAFETY: zbuf is NUL-terminated at len. - let z = ZStr::from_buf( - &zbuf[..], - affected_path.len(), - ); - bun_sys::access(z, libc::F_OK).is_err() - } - }; - if !was_deleted { - continue; - } - - affected_buf[affected_i] = - &affected_path[file_path.len()..]; - affected_i += 1; - if affected_i >= affected_buf.len() { - break; - } - } - } - } - - affected_kqueue = &affected_buf[0..affected_i]; - break 'brk affected_i; + break 'brk &[]; } - affected_inotify = event.names(changed_files); - break 'brk affected_inotify.len(); + event.names(changed_files) }; - if affected_len > 0 && !IS_KQUEUE { - if let Some(existing) = rfs.entries.get(file_path) { - self.put_tombstone(file_path, existing); - entries_option = Some(existing); - } else if let Some(existing) = self.get_tombstone(file_path) { - entries_option = Some(existing); - } - } - let _ = self.ctx_mut().bust_dir_cache( strings::paths::without_trailing_slash_windows_path(file_path), ); @@ -1065,7 +1048,7 @@ where // of whether the per-file watchlist entry survived. The per-file // watch itself is re-armed on the JS thread by // `VirtualMachine::add_main_to_watcher_if_needed` after the reload. - if !IS_KQUEUE && self.main.hash != 0 && self.main.dir_hash == current_hash { + if !IS_KQUEUE && self.main.hash != 0 { let main_basename = bun_paths::basename(self.main.file); for changed_name_ in affected_inotify { let changed_name: &[u8] = match changed_name_ { @@ -1075,6 +1058,9 @@ where if changed_name != main_basename { continue; } + if !self.main.is_in_folder(watchlist, event.index as usize) { + break; + } let main_exists = { let mut zbuf = bun_paths::path_buffer_pool::get(); if self.main.file.len() >= zbuf.len() { @@ -1096,152 +1082,13 @@ where } } - if let Some(dir_ent) = entries_option { - // SAFETY: dir_ent points into rfs.entries (or a tombstoned copy); - // both outlive this loop iteration. Shared access only — - // `entries()` takes `&self` and per-entry mutation below goes - // through the entry's own mutex + cells. - let dir_ent = unsafe { &*dir_ent }; - let mut last_file_hash: bun_watcher::HashType = - bun_watcher::HashType::MAX; - - for i in 0..affected_len { - let changed_name: &[u8] = if IS_KQUEUE { - affected_kqueue[i] - } else { - affected_inotify[i].unwrap().as_bytes() - }; - if changed_name.is_empty() - || changed_name[0] == b'~' - || changed_name[0] == b'.' - { - continue; - } - - // `ctx` is a BACKREF that outlives the reloader. - let loader = self - .ctx - .get_loaders() - .get(PathName::find_extname(changed_name)) - .copied() - .unwrap_or(bun_ast::Loader::File); - // Note: the post-assignment `_ = prev_entry_id` - // below documents the intentional dead store. - let mut prev_entry_id: usize = usize::MAX; - if loader != bun_ast::Loader::File { - // Both arms of `'brk` assign these before - // any read. - let path_string: bun_ptr::Interned; - let file_hash: bun_watcher::HashType; - let abs_path: &[u8] = 'brk: { - // Probe `.data` under `entries_mutex`; a - // resolver at a newer generation rewrites - // the map in place under that lock. The - // entry pointer stays valid after unlock - // (EntryStore-owned). - let looked_up = { - let _entries_lock = rfs.entries_mutex.lock_guard(); - dir_ent.entries().get(changed_name) - }; - if let Some(file_ent) = looked_up { - // reset the file descriptor - let ent = file_ent.entry(); - { - // Every cached-`Entry` rewrite takes - // the per-entry mutex. - let _entry_guard = ent.mutex.lock_guard(); - ent.set_cache_fd(Fd::INVALID); - ent.need_stat.store( - true, - core::sync::atomic::Ordering::Release, - ); - } - path_string = ent.abs_path; - file_hash = Watcher::get_hash(path_string.as_bytes()); - for (entry_id, hash) in hashes.iter().enumerate() { - if *hash == file_hash { - if file_descriptors[entry_id].is_valid() { - if prev_entry_id != entry_id { - record_changed_path( - path_string.as_bytes(), - ); - current_task.append(hashes[entry_id]); - if self.verbose { - Self::debug(format_args!( - "Removing file: {}", - bstr::BStr::new( - path_string.as_bytes() - ) - )); - } - // SAFETY: see the - // File-arm call - // above. - unsafe { - (*ctx).remove_at_index::( - bun_watcher::Kind::File, - entry_id as u16, - 0, - &[], - ) - }; - } - } - - prev_entry_id = entry_id; - _ = prev_entry_id; - break; - } - } - - break 'brk path_string.as_bytes(); - } else { - let file_path_without_trailing_slash = - strings::trim_right(file_path, &[SEP]); - _on_file_update_path_buf - [0..file_path_without_trailing_slash.len()] - .copy_from_slice(file_path_without_trailing_slash); - _on_file_update_path_buf - [file_path_without_trailing_slash.len()] = SEP; - - // The separator written at index `len` is - // immediately overwritten by the - // `changed_name` copy, and the slice takes - // one stale byte past the copy. Deliberate: - // changing it would change the resulting - // path hash. - _on_file_update_path_buf - [file_path_without_trailing_slash.len() - ..file_path_without_trailing_slash.len() - + changed_name.len()] - .copy_from_slice(changed_name); - let path_slice = &_on_file_update_path_buf[0 - ..file_path_without_trailing_slash.len() - + changed_name.len() - + 1]; - file_hash = Watcher::get_hash(path_slice); - break 'brk path_slice; - } - }; - - // skip consecutive duplicates - if last_file_hash == file_hash { - continue; - } - last_file_hash = file_hash; - - if self.verbose { - Self::debug(format_args!( - "File change: {}", - bstr::BStr::new(bun_paths::resolve_path::relative( - fs.top_level_dir, - abs_path, - )) - )); - } - } - } - } + let reloaded = self.reload_watched_files_in( + watchlist, + event, + affected_inotify, + &mut current_task, + ctx, + ); if self.verbose { Self::debug(format_args!( @@ -1250,7 +1097,7 @@ where fs.top_level_dir, file_path )), - affected_len + reloaded )); } } diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 25986a502f8d..8d479c4651fa 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -903,9 +903,6 @@ pub mod fs { } impl EntriesOption { - // Payload is `&'static mut DirEntry`; auto-deref coerces to `&DirEntry` / `&mut DirEntry`. - bun_core::enum_unwrap!(pub EntriesOption, Entries => fn entries / entries_mut -> DirEntry); - pub(crate) fn as_entries(&self) -> Option<&DirEntry> { match self { EntriesOption::Entries(entries) => Some(&**entries), @@ -1005,7 +1002,7 @@ pub mod fs { // Cross-thread exclusion is provided by the mutex asserted above. unsafe { &mut *entries_option_map() } } - pub fn get(&mut self, key: &[u8]) -> Option<&mut EntriesOption> { + pub(crate) fn get(&mut self, key: &[u8]) -> Option<&mut EntriesOption> { self.inner().get(key) } pub(crate) fn get_or_put(&mut self, key: &[u8]) -> crate::CrateResult { @@ -1052,7 +1049,7 @@ pub mod fs { /// Port of `entries: *EntriesOption.Map`. The resolver body addresses /// this directly (`rfs.entries.get_or_put(..)`); modeled as the wrapper /// `EntriesMap` (bun_alloc has no BSSMap equivalent). - pub entries: EntriesMap, + pub(crate) entries: EntriesMap, pub(crate) cwd: &'static [u8], #[cfg(not(windows))] pub(crate) file_limit: usize, diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 75884b687f9f..5324ba28a611 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -3462,7 +3462,10 @@ impl<'a> Resolver<'a> { // outlives the resolver. Hoist the `&'static [u8] dir` read out so no `&EntriesOption` // temporary is live when the raw `*mut` is passed below (avoids a needless Unique // retag that would pop the shared tag mid-argument-list under Stacked Borrows). - let dir_entries_dir = unsafe { &*dir_entries_option }.entries().dir; + let dir_entries_dir = unsafe { &*dir_entries_option } + .as_entries() + .expect("unreachable") + .dir; self.dir_info_uncached( dir_info_ptr, dir_entries_dir, @@ -6084,7 +6087,8 @@ impl<'a> Resolver<'a> { // `DirEntry` is a separate process-lifetime allocation, so the shared // `BackRef` survives entries-map traffic. All uses below are `&self` // reads under `entries_mutex`. - let dir_entries = bun_ptr::BackRef::new(unsafe { &*_entries }.entries()); + let dir_entries = + bun_ptr::BackRef::new(unsafe { &*_entries }.as_entries().expect("unreachable")); macro_rules! entries { () => { dir_entries.get() diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 59cdd4020637..d8d75447dca0 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -45,7 +45,6 @@ pub type Event = WatchEvent; pub type WatchList = MultiArrayList; pub type HashType = u32; pub type WatchItemIndex = u16; -pub const MAX_EVICTION_COUNT: usize = 8096; const NO_WATCH_ITEM: WatchItemIndex = WatchItemIndex::MAX; @@ -122,8 +121,7 @@ pub struct Watcher { /// thread) after the loop exits. pub(crate) close_descriptors: bun_core::AtomicCell, - pub(crate) evict_list: [WatchItemIndex; MAX_EVICTION_COUNT], - pub(crate) evict_list_i: WatchItemIndex, + pub(crate) evict_list: Vec, /// Scratch snapshot of `watchlist.eventlist_index` used by /// `watch_loop_cycle`; owned by the watcher thread. @@ -202,8 +200,7 @@ impl Watcher { thread: None, running: bun_core::AtomicCell::new(true), close_descriptors: bun_core::AtomicCell::new(false), - evict_list: [0; MAX_EVICTION_COUNT], - evict_list_i: 0, + evict_list: Vec::new(), #[cfg(any(target_os = "linux", target_os = "android"))] eventlist_index_scratch: Vec::new(), thread_lock: ThreadLock::init_unlocked(), @@ -378,7 +375,7 @@ impl Watcher { } pub fn flush_evictions(&mut self) { - if self.evict_list_i == 0 { + if self.evict_list.is_empty() { return; } // The close+swap_remove below must be serialized against the JS @@ -397,12 +394,10 @@ impl Watcher { self.mutex.is_held_by_current_thread(), "flush_evictions: caller must hold self.mutex (platform watcher holds it around on_file_update)", ); - let evict_list_i = self.evict_list_i as usize; - // swapRemove messes up the order // But, it only messes up the order if any elements in the list appear after the item being removed // So if we just sort the list by the biggest index first, that should be fine - index_sort::sort_slice_by(&mut self.evict_list[0..evict_list_i], |a, b| b.cmp(a)); + index_sort::sort_slice_by(&mut self.evict_list, |a, b| b.cmp(a)); // reshaped for borrowck — capture fds.len() before loop let slice = self.watchlist.slice(); @@ -410,7 +405,7 @@ impl Watcher { let fds_len = fds.len(); let mut last_item = NO_WATCH_ITEM; - for &item in &self.evict_list[0..evict_list_i] { + for &item in &self.evict_list { // catch duplicates, since the list is sorted, duplicates will appear right after each other if item == last_item { continue; @@ -433,7 +428,7 @@ impl Watcher { last_item = NO_WATCH_ITEM; // This is split into two passes because reading the slice while modified is potentially unsafe. - for i in 0..evict_list_i { + for i in 0..self.evict_list.len() { let item = self.evict_list[i]; if item == last_item || self.watchlist.len() <= item as usize { continue; @@ -458,7 +453,7 @@ impl Watcher { last_item = item; } - self.evict_list_i = 0; + self.evict_list.clear(); } fn watch_loop(&mut self) -> sys::Result<()> { @@ -557,6 +552,9 @@ impl Watcher { // so we must copy into a NUL-terminated scratch buffer (mirrors the // directory branch below) instead of pointing at the caller's slice. let mut buf = bun_paths::path_buffer_pool::get(); + if file_path.len() >= buf.len() { + return Err(sys::Error::new(sys::E::ENAMETOOLONG, sys::Tag::watch)); + } let slice: &ZStr = if CLONE_FILE_PATH { buf[0..file_path.len()].copy_from_slice(file_path); buf[file_path.len()] = 0; @@ -631,6 +629,9 @@ impl Watcher { #[cfg(any(target_os = "linux", target_os = "android"))] let eventlist_index = { let mut buf = bun_paths::path_buffer_pool::get(); + if file_path.len() >= buf.len() { + return Err(sys::Error::new(sys::E::ENAMETOOLONG, sys::Tag::watch)); + } let path: &ZStr = if CLONE_FILE_PATH && !file_path.is_empty() && file_path[file_path.len() - 1] == 0 @@ -926,14 +927,12 @@ impl Watcher { debug_assert!(index != NO_WATCH_ITEM); - self.evict_list[self.evict_list_i as usize] = index; - self.evict_list_i += 1; + self.evict_list.push(index); if kind == WatchItemKind::Directory { for &parent in parents { if parent == hash { - self.evict_list[self.evict_list_i as usize] = parent as WatchItemIndex; - self.evict_list_i += 1; + self.evict_list.push(parent as WatchItemIndex); } } } @@ -965,6 +964,66 @@ impl Watcher { } } +// ─── directory events ───────────────────────────────────────────────────── + +/// The `parent_hash` of the files in `dir`: the hash of `dir` with a trailing separator. +fn parent_hash_of(dir: &[u8], dir_hash: HashType) -> HashType { + if dir.last() == Some(&bun_paths::SEP) { + return dir_hash; + } + let mut hasher = bun_wyhash::Wyhash::init(0); + hasher.update(dir); + hasher.update(&[bun_paths::SEP]); + hasher.final_() as HashType +} + +/// The `parent_hash` of the files in the folder at `folder_index`, then that of its other spellings. +pub fn folder_hashes(watchlist: &WatchList, folder_index: usize) -> (HashType, Vec) { + let file_paths = watchlist.items_file_path(); + let hashes = watchlist.items_hash(); + let folder = parent_hash_of(&file_paths[folder_index], hashes[folder_index]); + // One inotify watch per inode: the first spelling of a folder gets its events. + #[cfg(any(target_os = "linux", target_os = "android"))] + let other_folders = { + let kinds = watchlist.items_kind(); + let watch_descriptors = watchlist.items_eventlist_index(); + let mut other_folders: Vec = Vec::new(); + for (other_index, other) in watch_descriptors.iter().enumerate() { + if *other != watch_descriptors[folder_index] + || kinds[other_index] != WatchItemKind::Directory + { + continue; + } + let other_folder = parent_hash_of(&file_paths[other_index], hashes[other_index]); + if other_folder != folder && !other_folders.contains(&other_folder) { + other_folders.push(other_folder); + } + } + other_folders + }; + #[cfg(not(any(target_os = "linux", target_os = "android")))] + let other_folders = Vec::new(); + (folder, other_folders) +} + +/// Whether a directory event that names `changed_names` names the file at `path`, whatever the ASCII letter case. +#[inline] +pub fn names_file(changed_names: &[ChangedFilePath], path: &[u8]) -> bool { + for changed_name in changed_names.iter().flatten() { + let changed_name = changed_name.as_bytes(); + let Some(name_at) = path.len().checked_sub(changed_name.len()) else { + continue; + }; + if name_at > 0 + && path[name_at - 1] == bun_paths::SEP + && path[name_at..].eq_ignore_ascii_case(changed_name) + { + return true; + } + } + false +} + // ─── WatchEvent ─────────────────────────────────────────────────────────── #[derive(Clone, Copy, Default)] @@ -1139,3 +1198,80 @@ impl WatchItemColumns for bun_collections::multi_array_list::Slice { self.items::<"eventlist_index", platform::EventListIndex>() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn item(path: &'static [u8], kind: WatchItemKind, watch_descriptor: i32) -> WatchItem { + let _ = watch_descriptor; + WatchItem { + file_path: Cow::Borrowed(path), + hash: Watcher::get_hash(path), + fd: Fd::INVALID, + count: 0, + parent_hash: Watcher::get_hash( + bun_paths::fs::PathName::init(path).dir_with_trailing_slash(), + ), + kind, + package_json: None, + #[cfg(any(target_os = "linux", target_os = "android"))] + eventlist_index: watch_descriptor, + } + } + + fn watchlist(items: Vec) -> WatchList { + let mut list = WatchList::default(); + for item in items { + list.append(item).unwrap(); + } + list + } + + #[test] + fn folder_hashes_is_the_parent_hash_of_the_files_in_the_folder() { + use WatchItemKind::{Directory, File}; + let list = watchlist(vec![ + item(b"/p/lib", Directory, 1), + item(b"/p/lib/a.ts", File, 2), + item(b"/p/src/", Directory, 3), + item(b"/p/src/b.ts", File, 4), + ]); + let parents = list.items_parent_hash(); + assert_eq!(folder_hashes(&list, 0), (parents[1], vec![])); + assert_eq!(folder_hashes(&list, 2), (parents[3], vec![])); + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + #[test] + fn folder_hashes_adds_the_other_spellings_that_share_the_watch_descriptor() { + use WatchItemKind::{Directory, File}; + let list = watchlist(vec![ + item(b"/p/link/", Directory, 1), + item(b"/p/link", Directory, 1), + item(b"/p/real/", Directory, 1), + item(b"/p/real/a.ts", File, 1), + item(b"/p/other/", Directory, 5), + item(b"/p/other/b.ts", File, 6), + ]); + let parents = list.items_parent_hash(); + let link = Watcher::get_hash(b"/p/link/"); + assert_eq!(folder_hashes(&list, 0), (link, vec![parents[3]])); + assert_eq!(folder_hashes(&list, 1), (link, vec![parents[3]])); + assert_eq!(folder_hashes(&list, 2), (parents[3], vec![link])); + assert_eq!(folder_hashes(&list, 4), (parents[5], vec![])); + } + + #[test] + fn names_file_compares_the_last_component_of_the_path() { + let names: [ChangedFilePath; 3] = [None, Some(zstr!("b.ts")), Some(zstr!("Page.TSX"))]; + assert!(names_file(&names, b"/p/lib/b.ts")); + assert!(names_file(&names, b"/p/lib/page.tsx")); + assert!(!names_file(&names, b"/p/lib/ab.ts")); + assert!(!names_file(&names, b"/p/lib/b.tsx")); + assert!(!names_file(&names, b"/p/lib/b.ts/")); + assert!(!names_file(&names, b"b.ts")); + assert!(!names_file(&[], b"/p/lib/b.ts")); + assert!(!names_file(&[Some(zstr!(""))], b"/p/lib/b.ts")); + } +} diff --git a/src/watcher/lib.rs b/src/watcher/lib.rs index 07ffe348d7a0..acf1d1b38498 100644 --- a/src/watcher/lib.rs +++ b/src/watcher/lib.rs @@ -35,5 +35,5 @@ pub use WatchItemKind as Kind; pub use watcher_impl::{ AnyResolveWatcher, ChangedFilePath, Event, FdOwnership, HashType, MAX_COUNT, Op, PackageJSON, REQUIRES_FILE_DESCRIPTORS, WATCH_OPEN_FLAGS, WatchEvent, WatchItemColumns, WatchItemKind, - WatchList, Watcher, WatcherContext, + WatchList, Watcher, WatcherContext, folder_hashes, names_file, }; diff --git a/test/cli/hot/hot.test.ts b/test/cli/hot/hot.test.ts index 6252c8304a51..57b957d20d1f 100644 --- a/test/cli/hot/hot.test.ts +++ b/test/cli/hot/hot.test.ts @@ -1,7 +1,19 @@ -import { spawn } from "bun"; +import { spawn, spawnSync } from "bun"; import { beforeEach, expect, it } from "bun:test"; -import { copyFileSync, cpSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isDebug, isWindows, tempDir, tmpdirSync, waitForFileToExist } from "harness"; +import { + copyFileSync, + cpSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { bunEnv, bunExe, isDebug, isLinux, isWindows, tempDir, tmpdirSync, waitForFileToExist } from "harness"; import { join } from "path"; const timeout = isDebug ? Infinity : 10_000; @@ -928,3 +940,325 @@ it.each([ stderr: [], }); }); + +// Reads the standard output of a watched program. +function stdoutOf(proc: { stdout: ReadableStream }) { + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + return { + async until(needle: string) { + while (!output.includes(needle)) { + const { value, done } = await reader.read(); + if (done) throw new Error(`the program exited, output so far: ${JSON.stringify(output)}`); + output += decoder.decode(value, { stream: true }); + } + }, + async toEnd() { + for (;;) { + const { value, done } = await reader.read(); + if (done) return output; + output += decoder.decode(value, { stream: true }); + } + }, + }; +} + +// Returns once the watcher thread has handled every event made before the call. The watcher logs +// a batch of events, handles it, then reads the next batch. So once it logs a batch that was made +// after another one, it has handled the other one. `fence` is a watched folder. +async function watcherHandled(trace: string, fence: string) { + for (let i = 0; i < 2; i++) { + const from = statSync(trace).size; + const name = crypto.randomUUID(); + mkdirSync(join(fence, name)); + // kqueue does not log the changed name, only the watched path. + const logged = isLinux ? name : '/fence/"'; + while (!readFileSync(trace, "latin1").slice(from).includes(logged)) await Bun.sleep(1); + } +} + +// One save can reload the program more than once. +const withoutRepeats = (output: string) => output.split("\n").filter((line, i, lines) => line !== lines[i - 1]); + +// The resolver caches a failed read of a directory (any errno but ENOENT) in +// the place of its listing. The reloader used to read that cache for each +// directory event, and aborted on the failed read: +// "EntriesOption::entries on non-Entries variant". +// +// The fixture makes the read fail with no permission and no descriptor limit. +// It moves `lib` away and puts a plain file at its path, so a listing of `lib` +// is ENOTDIR. The watch stays on the folder that moved, so a change in it is a +// directory event for `lib`. +const readErrorFixture = { + "trace.log": "", + "src/lib/a.ts": `export const v = 1;`, + "src/fence/f.ts": `export {};`, + "src/main.ts": `import { v } from "./lib/a.ts"; +import "./fence/f.ts"; +import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +console.log("RUN", v); +if (v !== 1) process.exit(0); +const trace = process.env.BUN_WATCHER_TRACE; +// The same wait as watcherHandled() in the test file. +const handled = async () => { + for (let i = 0; i < 2; i++) { + const from = statSync(trace).size; + const name = crypto.randomUUID(); + mkdirSync("fence/" + name); + const logged = process.platform === "linux" ? name : '/fence/"'; + while (!readFileSync(trace, "latin1").slice(from).includes(logged)) await Bun.sleep(1); + } +}; +renameSync("lib", "lib.away"); +writeFileSync("lib", ""); +await handled(); +// The lookup fails, so the resolver lists lib again and caches ENOTDIR. +await import("./lib/b.ts").then( + () => console.log("IMPORT ok"), + () => console.log("IMPORT failed"), +); +if (process.env.THEN === "remove") { + rmSync("lib.away", { recursive: true }); + await handled(); + console.log("REMOVED"); +} else { + writeFileSync("lib.away/" + process.env.THEN, ""); + await handled(); + rmSync("lib"); + renameSync("lib.away", "lib"); + await handled(); + console.log("HEALED"); +} +setInterval(() => {}, 1e6); +`, +}; +for (const [flag, what, then] of [ + ["--hot", "a new file in the folder", "c.ts"], + ["--watch", "a new file in the folder", "c.ts"], + // The reloader did not look up a dot name, but it kept the cache entry for the next event. + ["--hot", "a dot file in the folder", ".env"], + ["--watch", "a dot file in the folder", ".env"], + // kqueue names no entry. It takes the watched files that are gone when the folder is deleted. + ["--hot", "the removal of the folder", "remove"], +]) { + // The Windows watcher reports each changed file itself. Its directory events only drop the cache. + it.skipIf(isWindows)( + `${flag} survives ${what} whose cached listing is a read error`, + async () => { + using dir = tempDir("hot-dir-read-error", readErrorFixture); + const cwd = join(String(dir), "src"); + await using proc = spawn({ + cmd: [bunExe(), flag, "--no-clear-screen", "main.ts"], + cwd, + // The trace file is outside the watched directories so that writes to it cause no events. + env: { ...bunEnv, BUN_WATCHER_TRACE: join(String(dir), "trace.log"), THEN: then }, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = stdoutOf(proc); + + if (then === "remove") { + await stdout.until("REMOVED\n"); + writeFileSync(join(cwd, "main.ts"), `console.log("RUN", 2);\nprocess.exit(0);\n`); + } else { + await stdout.until("HEALED\n"); + // A save that replaces the inode: the directory event is the only signal for it on Linux. + writeFileSync(join(cwd, "lib", "a.ts.tmp"), `export const v = 2;`); + renameSync(join(cwd, "lib", "a.ts.tmp"), join(cwd, "lib", "a.ts")); + } + expect(await stdout.toEnd()).toBe(`RUN 1\nIMPORT failed\n${then === "remove" ? "REMOVED" : "HEALED"}\nRUN 2\n`); + expect(await proc.exited).toBe(0); + }, + timeout, + ); +} + +// Both come from the length of a path in a PATH_MAX buffer of the watcher thread. Only inotify +// names the changed entry, and PATH_MAX is 1024 on macOS. +for (const [what, name] of [ + // A directory event joined the folder and the name with no bound: + // "range end index 4097 out of range for slice of length 4096". + ["a new file whose path is longer than PATH_MAX", Buffer.alloc(247, "n") + ".ts"], + // The watch of an imported file wrote a NUL behind its path: + // "index out of bounds: the len is 4096 but the index is 4096". + ["an import whose path is PATH_MAX long", Buffer.alloc(245, "n") + ".png"], +]) { + it.skipIf(!isLinux)( + `--hot survives ${what}`, + async () => { + using dir = tempDir("hot-long-path", {}); + // A folder of 3846 bytes is under PATH_MAX, so bun runs in it. Folder + separator + name is + // 4097 bytes with the name of 250 bytes and 4096 bytes with the name of 249 bytes. + const folderLength = 3846; + let cwd = realpathSync(String(dir)); + while (folderLength - cwd.length > 256) cwd = join(cwd, Buffer.alloc(200, "d").toString()); + cwd = join(cwd, Buffer.alloc(folderLength - cwd.length - 1, "e").toString()); + mkdirSync(cwd, { recursive: true }); + // The absolute path does not fit in PATH_MAX, so only a relative name reaches the file. + const create = `require("node:fs").writeFileSync("${name}", "");`; + if (name.endsWith(".png")) { + expect(spawnSync({ cmd: [bunExe(), "-e", create], cwd, env: bunEnv }).exitCode).toBe(0); + } + writeFileSync( + join(cwd, "main.ts"), + name.endsWith(".png") + ? `import asset from "./${name}"; +console.log("RUN", asset.length === 4096 ? 1 : asset); +console.log("READY"); +setInterval(() => {}, 1e6); +` + : `console.log("RUN", 1); +${create} +console.log("READY"); +setInterval(() => {}, 1e6); +`, + ); + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "main.ts"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = stdoutOf(proc); + + await stdout.until("READY\n"); + writeFileSync(join(cwd, "main.ts"), `console.log("RUN", 2);\nprocess.exit(0);\n`); + expect(await stdout.toEnd()).toBe("RUN 1\nREADY\nRUN 2\n"); + expect(await proc.exited).toBe(0); + }, + timeout, + ); +} + +// The entry point has a watch of its own, which a delete takes away. The reloader then takes a +// directory event that names the entry as its reload. It compared the folder of the event with +// the folder of the entry by the hash of one spelling. Only inotify names the entry: kqueue does +// not reload an entry point that is deleted and then created again. +for (const [spelling, lookup] of [ + ["without a trailing separator", "../app/missing.js"], + ["through a symlink", "../applink/missing.js"], +]) { + it.skipIf(!isLinux)( + `--hot reloads a recreated entry point whose folder was first watched ${spelling}`, + async () => { + using dir = tempDir("hot-entry-folder-spelling", { + "trace.log": "", + // A lookup that fails watches the folder under the spelling of the lookup. + "src/pre/setup.js": `try { require("${lookup}"); } catch {}\n`, + "src/fence/f.js": `export {};`, + "src/app/entry.js": `import "../fence/f.js";\nconsole.log("EVAL", 1);\nsetInterval(() => {}, 1e6);\n`, + }); + const cwd = join(String(dir), "src"); + symlinkSync("app", join(cwd, "applink"), "dir"); + const trace = join(String(dir), "trace.log"); + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "--preload", "./pre/setup.js", "app/entry.js"], + cwd, + env: { ...bunEnv, BUN_WATCHER_TRACE: trace }, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = stdoutOf(proc); + + await stdout.until("EVAL 1\n"); + rmSync(join(cwd, "app", "entry.js")); + // The delete is handled in a batch of its own: the entry is off the watchlist. + await watcherHandled(trace, join(cwd, "fence")); + writeFileSync(join(cwd, "app", "entry.js"), `console.log("EVAL", 2);\nprocess.exit(0);\n`); + expect(await stdout.toEnd()).toBe("EVAL 1\nEVAL 2\n"); + expect(await proc.exited).toBe(0); + }, + // A reload that does not come is a hang, so the limit is finite on a debug build too. + 30_000, + ); +} + +// The reloader took only a name that has a loader and no leading dot from a directory event, +// whatever the watchlist held. +it.skipIf(isWindows)( + "--hot reloads a dot-named module and a text import that are saved by rename", + async () => { + using dir = tempDir("hot-dir-event-any-name", { + "lib/.config.ts": `export const v = 1;`, + "lib/notes.md": `one`, + "main.ts": `import { v } from "./lib/.config.ts"; +import notes from "./lib/notes.md" with { type: "text" }; +console.log("RUN", v, notes); +if (v === 2 && notes === "two") process.exit(0); +setInterval(() => {}, 1e6); +`, + }); + const cwd = String(dir); + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "main.ts"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = stdoutOf(proc); + const renameOver = (name: string, contents: string) => { + writeFileSync(join(cwd, "lib", name + ".tmp"), contents); + renameSync(join(cwd, "lib", name + ".tmp"), join(cwd, "lib", name)); + }; + + await stdout.until("RUN 1 one\n"); + renameOver(".config.ts", `export const v = 2;`); + await stdout.until("RUN 2 one\n"); + renameOver("notes.md", `two`); + expect(withoutRepeats(await stdout.toEnd())).toEqual(["RUN 1 one", "RUN 2 one", "RUN 2 two", ""]); + expect(await proc.exited).toBe(0); + }, + 30_000, +); + +// https://github.com/oven-sh/bun/issues/30436, for an import: a Bun.build() in the entry lists +// the folder again, and the new listing does not know which of its files are loaded. +it.skipIf(isWindows)( + "--hot reloads an import that is saved by rename after a Bun.build() in the entry", + async () => { + using dir = tempDir("hot-dir-event-after-build", { + "a.ts": `export const v = 1;`, + "app.ts": `import { v } from "./a.ts"; +console.log("RUN", v); +if (v === 3) process.exit(0); +if (!globalThis.built) { + globalThis.built = true; + try { + await Bun.build({ entrypoints: ["nonexistent.ts"] }); + } catch {} + console.log("BUILT"); +} +setInterval(() => {}, 1e6); +`, + }); + const cwd = String(dir); + await using proc = spawn({ + cmd: [bunExe(), "--hot", "--no-clear-screen", "app.ts"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const stdout = stdoutOf(proc); + const renameOver = (contents: string) => { + writeFileSync(join(cwd, "a.ts.tmp"), contents); + renameSync(join(cwd, "a.ts.tmp"), join(cwd, "a.ts")); + }; + + await stdout.until("BUILT\n"); + renameOver(`export const v = 2;`); + await stdout.until("RUN 2\n"); + renameOver(`export const v = 3;`); + expect(withoutRepeats(await stdout.toEnd())).toEqual(["RUN 1", "BUILT", "RUN 2", "RUN 3", ""]); + expect(await proc.exited).toBe(0); + }, + 30_000, +); diff --git a/test/cli/watch/watch.test.ts b/test/cli/watch/watch.test.ts index d5957534669a..6ce9cb820bd9 100644 --- a/test/cli/watch/watch.test.ts +++ b/test/cli/watch/watch.test.ts @@ -2,7 +2,17 @@ import type { Subprocess } from "bun"; import { spawn } from "bun"; import { afterEach, expect, it } from "bun:test"; import { bunEnv, bunExe, isBroken, isLinux, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; -import { readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, +} from "node:fs"; import { join } from "node:path"; let watchee: Subprocess; @@ -650,3 +660,271 @@ it.skipIf(!isLinux)("watcher opens the watched directory and files with O_CLOEXE "/main.ts": [O_CLOEXEC], }); }); + +// The Linux watcher learns about a save that replaces the inode (write a temp +// file and rename it over the target, or delete and recreate) from the +// directory event only. The hot reloader used to map that event to the +// watchlist through the cached directory entry's `abs_path`, which the +// resolver fills in lazily. A lookup that resolves to nothing busts the +// directory cache, so the re-read entry had an empty `abs_path` and every later +// save of that kind in the directory was ignored. +const replaceFile = { + "rename over": async (path: string, contents: string) => { + const tmp = `${path}.tmp`; + await Bun.write(tmp, contents); + renameSync(tmp, path); + }, + "delete and recreate": async (path: string, contents: string) => { + rmSync(path); + await Bun.write(path, contents); + }, + // The old inode stays alive under another name, so its own watch reports no delete. + "move away and create": async (path: string, contents: string) => { + renameSync(path, `${path}.old`); + await Bun.write(path, contents); + }, +}; +for (const [how, replace] of Object.entries(replaceFile)) { + it.skipIf(isWindows)( + `--watch sees a ${how} save after a failed lookup in the same directory`, + async () => { + using dir = tempDir("watch-dir-event-after-miss", { + "a.js": `export const a = 1;`, + "entry.js": `import { a } from "./a.js"; +console.log("EVAL a =", a); +try { + require("./config.local.js"); +} catch {} +console.log("MISS done"); +setInterval(() => {}, 1e6); +`, + }); + const cwd = String(dir); + const proc = spawn({ + cwd, + cmd: [bunExe(), "--watch", "--no-clear-screen", "entry.js"], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + watchee = proc; + const { waitFor, release } = stdoutWaiter(proc); + + // "MISS done" means the failed lookup already busted the directory cache. + await waitFor("EVAL a = 1\nMISS done\n"); + + for (const v of [2, 3]) { + await replace(join(cwd, "a.js"), `export const a = ${v};`); + await waitFor(`EVAL a = ${v}\nMISS done\n`); + } + + release(); + proc.kill("SIGKILL"); + await proc.exited; + }, + 20_000, + ); +} + +// The same state needs no failed lookup. A program that creates an entry next +// to its sources (a pid file, a log, a cache directory) causes a directory +// event, and the watcher busts the directory cache for it. A module the +// program imports lazily after that re-reads the directory. +it.skipIf(isWindows)( + "--watch sees a rename over save after the program wrote into its own directory and then imported lazily", + async () => { + using dir = tempDir("watch-dir-event-after-own-write", { + "trace.log": "", + "src/a.js": `export const a = 1;`, + "src/lazy.js": `export const lazy = "lazy";`, + "src/fence/b.js": `export {};`, + "src/entry.js": `import { a } from "./a.js"; +import "./fence/b.js"; +import { mkdirSync, readFileSync, statSync } from "node:fs"; +const trace = process.env.BUN_WATCHER_TRACE; +const start = statSync(trace).size; +// The log keys each event by the watched path. kqueue does not log the changed name. +const logged = dir => readFileSync(trace, "latin1").slice(start).includes("/" + dir + '/"'); +// A new name on every run: kqueue reports a directory only when its entries change. +const id = crypto.randomUUID(); +mkdirSync("cache-" + id); +while (!logged("src")) await Bun.sleep(1); +// The watcher thread logs a batch of events, handles it, then takes the next batch. Once it +// logs an event for another directory, it has busted the cache for this one. +mkdirSync("fence/" + id); +while (!logged("fence")) await Bun.sleep(1); +const { lazy } = await import("./lazy.js"); +console.log("EVAL a =", a, lazy); +setInterval(() => {}, 1e6); +`, + }); + const cwd = join(String(dir), "src"); + const proc = spawn({ + cwd, + cmd: [bunExe(), "--watch", "--no-clear-screen", "entry.js"], + // The trace file is outside the watched directory so that writes to it cause no events. + env: { ...bunEnv, BUN_WATCHER_TRACE: join(String(dir), "trace.log") }, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + watchee = proc; + const { waitFor, release } = stdoutWaiter(proc); + + await waitFor("EVAL a = 1 lazy\n"); + await replaceFile["rename over"](join(cwd, "a.js"), `export const a = 2;`); + await waitFor("EVAL a = 2 lazy\n"); + + release(); + proc.kill("SIGKILL"); + await proc.exited; + }, + 20_000, +); + +// inotify has one watch per inode. A folder that is reached under two +// spellings, a symlink and its target, has one watch, and the watcher gives +// each event to the spelling it saw first. The files are in the watchlist +// under the spelling of their import. +it.skipIf(isWindows)( + "--watch sees a rename over save in a folder that is also watched through a symlink", + async () => { + using dir = tempDir("watch-dir-event-two-spellings", { + "real/pages/a.js": `export const a = 1;`, + "entry.js": `// A lookup that fails watches the folder under the spelling of the link. +try { + require("./link/pages/missing.js"); +} catch {} +const { a } = await import("./real/pages/a.js"); +console.log("EVAL a =", a); +setInterval(() => {}, 1e6); +`, + }); + const cwd = String(dir); + symlinkSync("real", join(cwd, "link"), "dir"); + const proc = spawn({ + cwd, + cmd: [bunExe(), "--watch", "--no-clear-screen", "entry.js"], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + watchee = proc; + const { waitFor, release } = stdoutWaiter(proc); + + await waitFor("EVAL a = 1\n"); + for (const v of [2, 3]) { + await replaceFile["rename over"](join(cwd, "real", "pages", "a.js"), `export const a = ${v};`); + await waitFor(`EVAL a = ${v}\n`); + } + + release(); + proc.kill("SIGKILL"); + await proc.exited; + }, + 20_000, +); + +// A workspace package is imported through its symlink in node_modules and loaded by its real +// path. The lookup of the package watches its folder under the spelling of the symlink. +it.skipIf(isWindows)( + "--watch sees a rename over save of a workspace package", + async () => { + using dir = tempDir("watch-dir-event-workspace", { + "package.json": JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"] }), + "packages/lib/package.json": JSON.stringify({ name: "lib", version: "1.0.0", main: "index.ts" }), + "packages/lib/index.ts": `export const v = 1;`, + "packages/app/package.json": JSON.stringify({ name: "app", dependencies: { lib: "workspace:*" } }), + "packages/app/entry.ts": `import { v } from "lib"; +console.log("EVAL v =", v); +setInterval(() => {}, 1e6); +`, + }); + const cwd = String(dir); + mkdirSync(join(cwd, "node_modules")); + symlinkSync("../packages/lib", join(cwd, "node_modules", "lib"), "dir"); + const proc = spawn({ + cwd, + cmd: [bunExe(), "--watch", "--no-clear-screen", "packages/app/entry.ts"], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + watchee = proc; + const { waitFor, release } = stdoutWaiter(proc); + + await waitFor("EVAL v = 1\n"); + for (const v of [2, 3]) { + await replaceFile["rename over"](join(cwd, "packages", "lib", "index.ts"), `export const v = ${v};`); + await waitFor(`EVAL v = ${v}\n`); + } + + release(); + proc.kill("SIGKILL"); + await proc.exited; + }, + 20_000, +); + +// A directory event names an entry of the folder. Only a watched file of that folder with that +// name is the module to reload. +it.skipIf(isWindows)( + "--watch does not restart for a folder, or for a file of another folder, with a watched name", + async () => { + using dir = tempDir("watch-dir-event-other-names", { + "trace.log": "", + "src/lib/a.js": `export const a = 1;`, + "src/lib/sub/x.js": `export {};`, + "src/other/b.js": `export {};`, + "src/fence/f.js": `export {};`, + "src/entry.js": `import { a } from "./lib/a.js"; +import "./other/b.js"; +import "./fence/f.js"; +// A lookup that fails watches the folder of the lookup. +try { + require("./lib/sub/missing.js"); +} catch {} +console.log("EVAL a =", a); +setInterval(() => {}, 1e6); +`, + }); + const cwd = join(String(dir), "src"); + const trace = join(String(dir), "trace.log"); + const proc = spawn({ + cwd, + cmd: [bunExe(), "--watch", "--no-clear-screen", "entry.js"], + env: { ...bunEnv, BUN_WATCHER_TRACE: trace }, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + watchee = proc; + const { waitFor, release, output } = stdoutWaiter(proc); + + await waitFor("EVAL a = 1\n"); + renameSync(join(cwd, "lib", "sub"), join(cwd, "lib", "sub.away")); + renameSync(join(cwd, "lib", "sub.away"), join(cwd, "lib", "sub")); + await Bun.write(join(cwd, "other", "a.js"), `export {};`); + // The watcher thread logs a batch of events, handles it, then reads the next batch. Once it + // logs a batch that was made after another one, it has handled the other one. + for (let i = 0; i < 2; i++) { + const from = statSync(trace).size; + const name = crypto.randomUUID(); + mkdirSync(join(cwd, "fence", name)); + // kqueue does not log the changed name, only the watched path. + const logged = isLinux ? name : '/fence/"'; + while (!readFileSync(trace, "latin1").slice(from).includes(logged)) await Bun.sleep(1); + } + await replaceFile["rename over"](join(cwd, "lib", "a.js"), `export const a = 2;`); + await waitFor("EVAL a = 2\n"); + expect(output().slice(0, output().indexOf("EVAL a = 2\n"))).toBe("EVAL a = 1\n"); + + release(); + proc.kill("SIGKILL"); + await proc.exited; + }, + 20_000, +);