From 24af187e0eeeebedb09cdfe3cbc7ee3418b31709 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:56:29 +0000 Subject: [PATCH 01/15] resolver: build DirInfo without listing the directory The DirInfo walk opened every directory from the filesystem root down to the target and read its full listing, then looked up five fixed names in it. Startup time and memory grew with the number of entries in the cwd and in each ancestor. The walk now keeps the open fd and answers the marker questions (node_modules, package.json, tsconfig.json, jsconfig.json, .bin) with one stat each. The directory-entry cache gets an Unlisted placeholder for a directory the walk opened but did not list. The first reader that needs the names (extension probing, the test scanner, the routers, completions) replaces it with a listing through entries_at_locked or read_directory_with_iterator. A directory inside node_modules is still listed from the fd the walk holds, because the resolve that follows probes its entries. The entry point is resolved with one stat of its path (Resolver::resolve_entry_point) instead of a listing of its directory. The .env loader opens the default file names directly and treats ENOENT as absent, instead of receiving a copy of the cwd listing. The install code keeps the project directory path instead of a listing and probes bun.lock, bun.lockb, node_modules and package.json by name. The test scanner reads its root without the scanner as the iterator and always visits it in name order. --- src/bundler/transpiler.rs | 28 +- src/dotenv/env_loader.rs | 83 +-- src/dotenv/lib.rs | 5 +- src/install/PackageManager.rs | 54 +- .../PackageManager/security_scanner.rs | 9 +- src/install/auto_installer.rs | 2 +- src/install/update_transitive.rs | 4 +- src/jsc/VirtualMachine.rs | 29 +- src/resolver/dir_info.rs | 52 +- src/resolver/lib.rs | 155 +++- src/resolver/resolver.rs | 704 ++++++++---------- src/router/lib.rs | 2 +- src/runtime/api/filesystem_router.rs | 2 +- src/runtime/bake/router/mod.rs | 2 +- src/runtime/cli/pm_view_command.rs | 39 +- src/runtime/cli/run_command.rs | 28 +- src/runtime/cli/test/Scanner.rs | 57 +- .../bun/resolve/resolver-dir-listing.test.ts | 197 +++++ 18 files changed, 849 insertions(+), 603 deletions(-) create mode 100644 test/js/bun/resolve/resolver-dir-listing.test.ts diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 1f4678fa6c28..9b2b2e498cdb 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -632,10 +632,10 @@ impl<'a> Transpiler<'a> { self.resolver.opts.set_production(true); } - // Load the project root for .env file discovery. If the cwd - // (or a parent) is unreadable, readDirInfo may return null; - // bail out of .env file loading in that case, but process - // env vars were already loaded above. + // The project root's tsconfig decides the JSX pragma. If the + // cwd (or a parent) is unreadable, readDirInfo returns null; + // skip .env file loading in that case, but process env vars + // were already loaded above. let top_level_dir = Fs::FileSystem::get().top_level_dir(); let dir_info = match self.resolver.read_dir_info(top_level_dir) { Ok(Some(d)) => d, @@ -646,24 +646,6 @@ impl<'a> Transpiler<'a> { merge_tsconfig_jsx_into(&tsconfig, &mut self.options.jsx); } - // Copy the listing's basenames out under `entries_mutex`, - // refreshing it at our generation in the same critical - // section: concurrent resolvers rewrite the `DirEntry` map in - // place under that lock, and `dot_env::Loader::load` does - // file I/O between probes. - let dir = { - let _entries_lock = bun_resolver::fs::FileSystem::get() - .fs - .entries_mutex - .lock_guard(); - match dir_info.get_entries_ref_locked(self.resolver.generation) { - Some(entries) => dot_env::DirEntryKeys( - entries.data.iter().map(|(k, _)| Box::from(&**k)).collect(), - ), - None => return Ok(()), - } - }; - // `Env.files: Box<[Box<[u8]>]>` but `Loader::load` // wants `&[&[u8]]`. Re-borrow into a small Vec; the explicit // `--env-file` list is bounded (CLI args), not hot-path. @@ -676,7 +658,7 @@ impl<'a> Transpiler<'a> { } else { dot_env::DotEnvFileSuffix::Development }; - env.load(&dir, &env_files, suffix, skip_default_env)?; + env.load(&env_files, suffix, skip_default_env)?; } DotEnvBehavior::disable => { env.load_process()?; diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 6fa7e6f4f401..9ae4801d899c 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -50,27 +50,6 @@ impl DefaultEnvFile { } } -/// Directory-entry probe used by `Loader::load`. `bun_dotenv` sits below -/// `bun_resolver` in the crate graph, so the directory listing is taken -/// generically; the only operation `load_default_files` performs is a lookup -/// of a known-at-compile-time filename. Callers snapshot the resolver's -/// listing into a [`DirEntryKeys`] (the live `DirEntry` map may be rewritten -/// in place by a concurrent resolver, so `load` must not probe it directly). -pub trait DirEntryProbe { - /// The argument MUST already be ASCII-lowercase. - fn has_comptime_query(&self, query_lower: &'static [u8]) -> bool; -} - -/// Directory-listing basenames copied out under the resolver's -/// `entries_mutex`, probed between the `.env` file reads in `Loader::load`. -pub struct DirEntryKeys(pub Vec>); - -impl DirEntryProbe for DirEntryKeys { - fn has_comptime_query(&self, query_lower: &'static [u8]) -> bool { - self.0.iter().any(|k| **k == *query_lower) - } -} - /// Canonical definition; re-exported as /// `bun_options_types::schema::api::DotEnvBehavior` for higher tiers. #[repr(u32)] @@ -624,9 +603,11 @@ impl Loader { Parser::parse_bytes::(str, &mut self.map, &mut value_buffer) } - pub fn load( + /// Load the `--env-file` list, or else the default `.env*` files of the + /// working directory. A default file that does not exist is one failed + /// `open`, not an error. + pub fn load( &mut self, - dir: &D, env_files: &[&[u8]], suffix: DotEnvFileSuffix, skip_default_env: bool, @@ -649,7 +630,7 @@ impl Loader { // See https://github.com/oven-sh/bun/issues/9635#issuecomment-2021350123 // for more details on how this edge case works. if !skip_default_env { - self.load_default_files(suffix, dir, &mut value_buffer)?; + self.load_default_files(suffix, &mut value_buffer)?; } } @@ -690,61 +671,52 @@ impl Loader { // Load .env.development if development // Load .env.production if !development // .env goes last - fn load_default_files( + fn load_default_files( &mut self, suffix: DotEnvFileSuffix, - dir: &D, value_buffer: &mut Vec, ) -> crate::Result<()> { let dir_handle = bun_sys::Fd::cwd(); match suffix { - DotEnvFileSuffix::Development => self.try_load_default( - dir, - dir_handle, - DefaultEnvFile::DevelopmentLocal, - value_buffer, - )?, - DotEnvFileSuffix::Production => self.try_load_default( - dir, - dir_handle, - DefaultEnvFile::ProductionLocal, - value_buffer, - )?, + DotEnvFileSuffix::Development => { + self.try_load_default(dir_handle, DefaultEnvFile::DevelopmentLocal, value_buffer)? + } + DotEnvFileSuffix::Production => { + self.try_load_default(dir_handle, DefaultEnvFile::ProductionLocal, value_buffer)? + } DotEnvFileSuffix::Test => { - self.try_load_default(dir, dir_handle, DefaultEnvFile::TestLocal, value_buffer)? + self.try_load_default(dir_handle, DefaultEnvFile::TestLocal, value_buffer)? } } if suffix != DotEnvFileSuffix::Test { - self.try_load_default(dir, dir_handle, DefaultEnvFile::Local, value_buffer)?; + self.try_load_default(dir_handle, DefaultEnvFile::Local, value_buffer)?; } match suffix { DotEnvFileSuffix::Development => { - self.try_load_default(dir, dir_handle, DefaultEnvFile::Development, value_buffer)? + self.try_load_default(dir_handle, DefaultEnvFile::Development, value_buffer)? } DotEnvFileSuffix::Production => { - self.try_load_default(dir, dir_handle, DefaultEnvFile::Production, value_buffer)? + self.try_load_default(dir_handle, DefaultEnvFile::Production, value_buffer)? } DotEnvFileSuffix::Test => { - self.try_load_default(dir, dir_handle, DefaultEnvFile::Test, value_buffer)? + self.try_load_default(dir_handle, DefaultEnvFile::Test, value_buffer)? } } - self.try_load_default(dir, dir_handle, DefaultEnvFile::Env, value_buffer) + self.try_load_default(dir_handle, DefaultEnvFile::Env, value_buffer) } #[inline] - fn try_load_default( + fn try_load_default( &mut self, - dir: &D, dir_handle: bun_sys::Fd, env_file: DefaultEnvFile, value_buffer: &mut Vec, ) -> crate::Result<()> { - if dir.has_comptime_query(env_file.name()) { - self.load_env_file::(dir_handle, env_file, value_buffer)?; + if self.load_env_file::(dir_handle, env_file, value_buffer)? { analytics::Features::dotenv_inc(); } Ok(()) @@ -784,14 +756,16 @@ impl Loader { Output::flush(); } + /// Returns whether a file of that name exists. Only a file that exists + /// counts as loaded, even when it cannot be read. pub(crate) fn load_env_file( &mut self, dir: bun_sys::Fd, env_file: DefaultEnvFile, value_buffer: &mut Vec, - ) -> crate::Result<()> { + ) -> crate::Result { if self.default_files_loaded.contains(env_file) { - return Ok(()); + return Ok(true); } let base = env_file.name(); @@ -802,11 +776,12 @@ impl Loader { Err(err) => { use bun_sys::E; match err.get_errno() { + E::ENOENT | E::ENOTDIR => return Ok(false), // A unix socket: ENXIO on Linux, EOPNOTSUPP on macOS and FreeBSD. - E::EISDIR | E::ENOENT | E::ENXIO | E::EOPNOTSUPP => { + E::EISDIR | E::ENXIO | E::EOPNOTSUPP => { // prevent retrying self.default_files_loaded.insert(env_file); - return Ok(()); + return Ok(true); } E::EBUSY | E::EACCES => { if !self.quiet { @@ -818,7 +793,7 @@ impl Loader { } // prevent retrying self.default_files_loaded.insert(env_file); - return Ok(()); + return Ok(true); } _ => return Err(err.into()), } @@ -842,7 +817,7 @@ impl Loader { } self.default_files_loaded.insert(env_file); - Ok(()) + Ok(true) } pub(crate) fn load_env_file_dynamic( diff --git a/src/dotenv/lib.rs b/src/dotenv/lib.rs index b99816e76d72..b63c4cf03bd2 100644 --- a/src/dotenv/lib.rs +++ b/src/dotenv/lib.rs @@ -6,9 +6,8 @@ pub mod no_proxy; pub use error::{Error, Result}; pub use env_loader::{ - DirEntryKeys, DotEnvBehavior, DotEnvFileSuffix, HAS_NO_CLEAR_SCREEN_CLI_FLAG, HashTable, - HashTableValue, INSTANCE, Loader, Map, NullDelimitedEnvMap, S3Credentials, instance, - set_instance, + DotEnvBehavior, DotEnvFileSuffix, HAS_NO_CLEAR_SCREEN_CLI_FLAG, HashTable, HashTableValue, + INSTANCE, Loader, Map, NullDelimitedEnvMap, S3Credentials, instance, set_instance, }; /// `dotenv::map::{HashTable, Entry}` namespace expected by `install_jsc::ini_jsc` et al. diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e636d7e80f8f..c486703c4efd 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -457,7 +457,8 @@ bun_output::declare_scope!(PackageManager, hidden); pub struct PackageManager { pub(crate) cache_directory: Option, pub(crate) cache_directory_path: ZBox, // owned; process lifetime via the leaked singleton - pub root_dir: &'static fs::DirEntry, + /// The project directory, interned in `DirnameStore`, with a trailing separator. + pub root_dir: &'static [u8], // allocator dropped per §Allocators (was `bun.default_allocator`). For the // handful of sites that allocated AST nodes via `Expr.allocate(manager.allocator, …)` // — i.e. nodes that must outlive `Expr.Data.Store.reset()` across workspace @@ -1558,7 +1559,7 @@ fn http_thread_on_init_error(err: http::InitError, opts: &http::http_thread::Ini fn allocate_package_manager( options: Options, log: *mut bun_ast::Log, - root_dir: &'static fs::DirEntry, + root_dir: &'static [u8], env: &mut dot_env::Loader, // NUL-terminated original_package_json_path: Vec, @@ -2370,12 +2371,7 @@ pub fn init( if !cli.env_files.is_empty() { // Load these before the cwd changes, because their paths are relative to it. env.load_process()?; - env.load( - &dot_env::DirEntryKeys(Vec::new()), - cli.env_files, - dot_env::DotEnvFileSuffix::Production, - true, - )?; + env.load(cli.env_files, dot_env::DotEnvFileSuffix::Production, true)?; } const CONFIG_TAG: bun_options_types::command_tag::Tag = @@ -2614,34 +2610,18 @@ pub fn init( let _ = root_package_json_file.close(); let top_level_dir = fs.top_level_dir(); - // Returns the resolver's BSSMap-owned - // `*EntriesOption` slot. - let entries_option = FileSystem::instance() - .read_directory(top_level_dir, 0, true)? - .try_entries()?; env.load_process()?; // This is read before the project's `.env` files are loaded, so they cannot set it. let env_ignores_scripts = env_ignores_scripts(env); if cli.env_files.is_empty() && !cli.no_env_file && !ctx.args.disable_default_env_files { - // `.data` must only be probed while `entries_mutex` is held. - let env_probe_keys = { - let _entries_lock = FileSystem::get().fs.entries_mutex.lock_guard(); - dot_env::DirEntryKeys( - entries_option - .data - .iter() - .map(|(k, _)| Box::from(&**k)) - .collect(), - ) - }; // Follows `BUN_ENV` / `NODE_ENV` like `bun run`, but defaults to the production set. let suffix = match env.get_node_env() { Some(b"development") => dot_env::DotEnvFileSuffix::Development, Some(b"test") => dot_env::DotEnvFileSuffix::Test, _ => dot_env::DotEnvFileSuffix::Production, }; - env.load(&env_probe_keys, &[], suffix, false)?; + env.load(&[], suffix, false)?; } // `bunx` sets these for this command alone. They are removed because lifecycle scripts and the // security scanner get a copy of this map. @@ -2709,7 +2689,7 @@ pub fn init( let manager_ptr = allocate_package_manager( options, ctx.log, - entries_option, + top_level_dir, env, original_package_json_path_buf, root_package_json_path, @@ -2965,15 +2945,14 @@ fn init_with_runtime_once( PackageManager::set_verbose_install(true); } - // Read the root directory BEFORE allocating the singleton. This is + // Open the root directory BEFORE allocating the singleton. This is // user-reachable failure (the cwd may have been deleted out from under the // process, or be unreadable: ENOENT/EACCES/ENOTDIR), and bailing out here // leaves `holder::RAW_PTR` null rather than pointing at an uninitialized - // manager. Returns the resolver's BSSMap-owned `*EntriesOption` slot. + // manager. let top_level_dir = FileSystem::get().top_level_dir(); - let root_dir = FileSystem::instance() - .read_directory(top_level_dir, 0, true)? - .try_entries()?; + let _ = bun_sys::close(bun_sys::open_dir_for_iteration(Fd::cwd(), top_level_dir)?); + let root_dir = top_level_dir; // A file inside `node_modules` belongs to the project that installed the package. let importer_dir = match strings::index_of(importer_dir, bun_paths::NODE_MODULES_NEEDLE) { @@ -3138,14 +3117,9 @@ fn init_with_runtime_once( // temporarily moving the boxed lockfile out so the `&mut PackageManager` // passed in does not alias the `&mut Lockfile` receiver. manager.lockfile.init_empty(); - if let Ok(fs::EntriesOption::Entries(entries)) = - FileSystem::instance().read_directory(project_dir, 0, false) - && { - // `.data` probes must hold `entries_mutex`. - let _entries_lock = FileSystem::get().fs.entries_mutex.lock_guard(); - entries.has_comptime_query(b"bun.lock") || entries.has_comptime_query(b"bun.lockb") - } - && let Ok(dir) = bun_sys::Dir::open(project_dir) + if let Ok(dir) = bun_sys::Dir::open(project_dir) + && (bun_sys::lstatat(dir.fd(), bun_core::zstr!("bun.lock")).is_ok() + || bun_sys::lstatat(dir.fd(), bun_core::zstr!("bun.lockb")).is_ok()) { let mut lockfile = core::mem::replace(&mut manager.lockfile, Box::new(Lockfile::default())); match lockfile.load_from_dir::(dir.fd(), Some(&mut *manager), log) { @@ -3156,7 +3130,7 @@ fn init_with_runtime_once( // Workspace paths are relative to `root_dir`. A lockfile records them relative to its own // directory, and `parse_with_json` makes them relative to the working directory. if manager.lockfile.packages.len() > 0 { - manager.root_dir = entries; + manager.root_dir = FileSystem::get().dirname_store().append(project_dir)?; // Auto-install ignores an override that points away from the registry, so it also drops // what such an override resolved to in the lockfile. use crate::lockfile_real::package::PackageColumns as _; diff --git a/src/install/PackageManager/security_scanner.rs b/src/install/PackageManager/security_scanner.rs index d312a80ff5e1..6e07ebe973ac 100644 --- a/src/install/PackageManager/security_scanner.rs +++ b/src/install/PackageManager/security_scanner.rs @@ -215,10 +215,11 @@ pub(crate) fn perform_security_scan_after_resolution( return Ok(None); } - let has_node_modules = { - let _entries_lock = FileSystem::get().fs.entries_mutex.lock_guard(); - manager.root_dir.has_comptime_query(b"node_modules") - }; + let has_node_modules = bun_sys::lstatat( + bun_sys::Fd::cwd(), + &bun_core::ZBox::from_bytes(&[manager.root_dir, b"node_modules".as_slice()].concat()), + ) + .is_ok(); // A command that names the packages it adds or updates scans only those packages and `seeds`. // The install that wrote the lockfile and `node_modules` already scanned everything else. let scan_all = manager.update_requests.is_empty() diff --git a/src/install/auto_installer.rs b/src/install/auto_installer.rs index aa6af65c6bc7..b0075e131248 100644 --- a/src/install/auto_installer.rs +++ b/src/install/auto_installer.rs @@ -252,7 +252,7 @@ impl hooks::AutoInstaller for PackageManager { _ => None, }; let (base, path): (&[u8], &[u8]) = if let Some(member) = &member { - (self.root_dir.dir, self.lockfile.str(member)) + (self.root_dir, self.lockfile.str(member)) } else { let package_json_dir = package_json_dir?; match version.tag { diff --git a/src/install/update_transitive.rs b/src/install/update_transitive.rs index 6e46c1a03596..a3799afd80c7 100644 --- a/src/install/update_transitive.rs +++ b/src/install/update_transitive.rs @@ -771,7 +771,7 @@ fn root_patched_dependencies<'a>( pub(crate) fn patched_dependency_keys(manager: &mut PackageManager) -> Vec> { let log = manager.log_mut(); let path = manager.root_package_json_path.as_bytes(); - let root_dir = manager.root_dir.dir; + let root_dir = manager.root_dir; let Some((_, patched)) = root_patched_dependencies( &mut manager.workspace_package_json_cache, log, @@ -833,7 +833,7 @@ pub(crate) fn warn_orphaned_patches(manager: &mut PackageManager) { } let log = manager.log_mut(); let path = manager.root_package_json_path.as_bytes(); - let root_dir = manager.root_dir.dir; + let root_dir = manager.root_dir; let Some((source, patched)) = root_patched_dependencies( &mut manager.workspace_package_json_cache, log, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 1162fd886537..5e5219f09126 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5551,16 +5551,35 @@ impl VirtualMachine { top_level_dir }; + let import_kind = if is_esm { + bun_ast::ImportKind::Stmt + } else { + bun_ast::ImportKind::Require + }; + + // The entry point is a file the CLI already found: one `stat` resolves + // it. Anything else goes through the full resolver below. + let mut entry_point = None; + if source == MAIN_FILE_NAME && bun_paths::is_absolute(normalized_specifier) { + entry_point = match self + .transpiler + .resolver + .resolve_entry_point(normalized_specifier, import_kind) + { + ResultUnion::Success(r) => Some(r), + ResultUnion::Failure(e) => return Err(e.into()), + ResultUnion::Pending(_) | ResultUnion::NotFound => None, + }; + } + // A `loop` // returning the resolver result; `retry_on_not_found` is consumed on // the first miss. let mut retry_on_not_found = bun_paths::is_absolute(source_to_use); let result: bun_resolver::Result = loop { - let import_kind = if is_esm { - bun_ast::ImportKind::Stmt - } else { - bun_ast::ImportKind::Require - }; + if let Some(entry_point) = entry_point.take() { + break entry_point; + } match self.transpiler.resolver.resolve_and_auto_install( source_to_use, normalized_specifier, diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index 3ba53d61c249..6f5b05a039dc 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -225,12 +225,26 @@ impl DirInfo { pub fn get_file_descriptor(&self) -> Fd { if FeatureFlags::STORE_FILE_DESCRIPTORS { - // Scalar field read; see `get_entries_const` for the contract. - return self.get_entries_const().map_or(Fd::INVALID, |e| e.fd); + // Scalar field read, no lock needed. + return fs::FileSystem::instance() + .fs + .entries + .at_index(self.entries) + .map_or(Fd::INVALID, |e| e.fd()); } Fd::INVALID } + /// Is this directory's slot a listing (or a read error), as opposed to the + /// placeholder the `DirInfo` walk leaves for a directory it only opened? + /// Caller holds `entries_mutex`. + pub(crate) fn is_listed_locked(&self) -> bool { + !matches!( + fs::FileSystem::instance().fs.entries.at_index(self.entries), + Some(fs::EntriesOption::Unlisted(_)) + ) + } + /// Generation-checked listing accessor for call sites that already hold /// `entries_mutex` (the mutex is non-recursive); see /// [`RealFS::entries_at_locked`](fs::RealFS::entries_at_locked). The @@ -268,16 +282,30 @@ impl DirInfo { .get(query) } - /// As-cached listing access with no generation check and no locking. - /// Scalar fields (`fd`, `dir`) may be read through this unlocked; the - /// `.data` map must only be probed/iterated while `entries_mutex` is held - /// (a concurrent stale-generation re-read rewrites it in place). - pub fn get_entries_const(&self) -> Option<&fs::DirEntry> { - fs::FileSystem::instance() - .fs - .entries - .at_index(self.entries)? - .as_entries() + /// [`get_entry`](Self::get_entry) that does not list a directory the + /// walk only opened: the outer `None` is that directory, the inner one a + /// name that is not in the listing. + pub(crate) fn get_entry_if_listed( + &self, + generation: Generation, + query: &[u8], + ) -> Option> { + let rfs = &mut fs::FileSystem::instance().fs; + let _lock = rfs.entries_mutex.lock_guard(); + if !self.is_listed_locked() { + return None; + } + Some( + rfs.entries_at_locked(self.entries, generation) + .and_then(|slot| slot.as_entries()) + .and_then(|entries| entries.get(query)), + ) + } + + /// The listing as cached, with no generation check, read now when the + /// `DirInfo` walk only opened this directory. Caller holds `entries_mutex`. + pub fn get_entries_listed_locked(&self) -> Option<&'static fs::DirEntry> { + self.get_entries_ref_locked(0) } #[inline] diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 43db5aacd8d9..870692f6c96b 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -740,24 +740,49 @@ pub mod fs { pub enum EntriesOption { Entries(&'static mut DirEntry), Err(dir_entry::Err), + /// A directory the resolver opened for its `DirInfo` markers and did + /// not list. `entries_at_locked` and `read_directory_with_iterator` + /// replace it with `Entries` the first time a caller needs the names. + Unlisted(Unlisted), + } + + #[derive(Clone, Copy)] + pub struct Unlisted { + /// Interned in `DirnameStore`, with a trailing separator. + pub dir: &'static [u8], + /// The open directory when the resolver keeps fds (`store_fd`). + pub fd: Fd, } 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); - /// The listing, or the error that reading the directory gave. + /// The listing, or the error that reading the directory gave. Only + /// valid on a slot that `read_directory` or `entries_at_locked` returned. pub fn try_entries(&self) -> crate::CrateResult<&DirEntry> { match self { EntriesOption::Entries(entries) => Ok(&**entries), EntriesOption::Err(err) => Err(err.canonical_error), + EntriesOption::Unlisted(_) => { + Err(crate::Error::Sys(bun_errno::SystemErrno::ENOENT)) + } } } pub(crate) fn as_entries(&self) -> Option<&DirEntry> { match self { EntriesOption::Entries(entries) => Some(&**entries), - EntriesOption::Err(_) => None, + EntriesOption::Err(_) | EntriesOption::Unlisted(_) => None, + } + } + + /// The directory fd this slot keeps open, if any. + pub(crate) fn fd(&self) -> Fd { + match self { + EntriesOption::Entries(entries) => entries.fd, + EntriesOption::Unlisted(unlisted) => unlisted.fd, + EntriesOption::Err(_) => Fd::INVALID, } } @@ -772,7 +797,7 @@ pub mod fs { let _lock = FileSystem::get().fs.entries_mutex.lock_guard(); match self { EntriesOption::Entries(entries) => (entries.get(query), entries.fd), - EntriesOption::Err(_) => (None, Fd::INVALID), + EntriesOption::Err(_) | EntriesOption::Unlisted(_) => (None, Fd::INVALID), } } @@ -782,6 +807,7 @@ pub mod fs { let _lock = FileSystem::get().fs.entries_mutex.lock_guard(); match self { EntriesOption::Entries(entries) => Some((entries.dir, entries.fd)), + EntriesOption::Unlisted(unlisted) => Some((unlisted.dir, unlisted.fd)), EntriesOption::Err(_) => None, } } @@ -1011,7 +1037,13 @@ pub mod fs { } } - // debug("readdir({}, {}) = {}", handle, dir_, dir.data.count()); + bun_core::scoped_log!( + crate::fs_full::Fs, + "readdir({}, {}) = {}", + handle, + bstr::BStr::new(dir_), + dir.data.count(), + ); Ok(dir) } @@ -1071,12 +1103,37 @@ pub mod fs { store_fd: bool, iterator: &mut I, ) -> crate::CrateResult<&'static EntriesOption> { + let _unlock_guard = self.entries_mutex.lock_guard(); + self.read_directory_locked( + dir_maybe_trail_slash, + maybe_handle, + generation, + store_fd, + iterator, + ) + } + + /// [`read_directory_with_iterator`](Self::read_directory_with_iterator) + /// for a caller that already holds `entries_mutex`. Never returns an + /// `Unlisted` slot. + pub(crate) fn read_directory_locked( + &mut self, + dir_maybe_trail_slash: &[u8], + maybe_handle: Option, + generation: Generation, + store_fd: bool, + iterator: &mut I, + ) -> crate::CrateResult<&'static EntriesOption> { + debug_assert!( + self.entries_mutex.is_held_by_current_thread(), + "read_directory_locked: caller must hold entries_mutex", + ); let dir = strings::paths::without_trailing_slash_windows_path(dir_maybe_trail_slash); crate::Resolver::assert_valid_cache_key(dir); - let _unlock_guard = self.entries_mutex.lock_guard(); let mut in_place: Option<*mut DirEntry> = None; + let mut unlisted: Option = None; let mut cache_result = self.entries.get_or_put(dir)?; if cache_result.has_checked_if_exists() { @@ -1092,6 +1149,7 @@ pub mod fs { EntriesOption::Entries(e) if e.generation < generation => { in_place = Some(std::ptr::from_mut::(*e)); } + EntriesOption::Unlisted(u) => unlisted = Some(*u), cached => return Ok(cached), } } else if cache_result.status == bun_alloc::ItemStatus::NotFound && generation == 0 @@ -1116,7 +1174,11 @@ pub mod fs { // Close the handle on every exit path. Use // scopeguard so close happens even if `readdir`/`put` early-return with `?`. - let should_close_handle = !had_handle && (!store_fd || self.need_to_close_files()); + // A placeholder that already keeps the directory open keeps that + // fd; the handle opened here for the read is closed. + let keeps_unlisted_fd = unlisted.is_some_and(|u| u.fd.is_valid()); + let should_close_handle = + !had_handle && (!store_fd || keeps_unlisted_fd || self.need_to_close_files()); let _close_guard = scopeguard::guard(handle, move |h| { if should_close_handle { let _ = bun_sys::close(h); @@ -1131,6 +1193,8 @@ pub mod fs { // SAFETY: `in_place` points to a `DirEntry` inside the BSSMap singleton; // its `dir` field is DirnameStore-interned (&'static). unsafe { (*existing).dir } + } else if let Some(unlisted) = unlisted { + unlisted.dir } else if !had_handle { DirnameStore::instance().append_slice(dir_maybe_trail_slash)? } else { @@ -1170,6 +1234,11 @@ pub mod fs { if store_fd && !entries.fd.is_valid() { entries.fd = handle; } + if let Some(unlisted) = unlisted + && unlisted.fd.is_valid() + { + entries.fd = unlisted.fd; + } // SAFETY: `entries_ptr` is either a live BSSMap slot (`in_place`) or a fresh // leaked Box; exclusively owned here under `entries_mutex`. @@ -1460,8 +1529,40 @@ pub mod fs { // `entries_mutex` held by the caller; sole `&mut` to this slot. let result_ptr = std::ptr::from_mut::(self.entries.at_index(index)?); // SAFETY: BSSMap-owned slot; uniquely held under `entries_mutex`. - if let EntriesOption::Entries(existing) = unsafe { &mut *result_ptr } { - if existing.generation < generation { + match unsafe { &mut *result_ptr } { + EntriesOption::Unlisted(unlisted) => { + let unlisted = *unlisted; + let handle = match bun_sys::open_dir_for_iteration(Fd::cwd(), unlisted.dir) { + Ok(h) => h, + Err(err) => { + return self.read_directory_error(unlisted.dir, err.into()).ok(); + } + }; + let _close_guard = scopeguard::guard(handle, |h| { + let _ = bun_sys::close(h); + }); + let mut new_entry = match self.readdir( + false, + None, + unlisted.dir, + generation, + handle, + &mut (), + ) { + Ok(e) => e, + Err(err) => return self.read_directory_error(unlisted.dir, err).ok(), + }; + new_entry.fd = unlisted.fd; + // SAFETY: slot is exclusively owned under `entries_mutex`; the + // `DirEntry` is leaked for the life of the process like every + // other listing in this map. + unsafe { + *result_ptr = EntriesOption::Entries(&mut *bun_core::heap::into_raw( + Box::new(new_entry), + )) + }; + } + EntriesOption::Entries(existing) if existing.generation < generation => { let e_ptr: *mut DirEntry = std::ptr::from_mut::(*existing); // SAFETY: BSSMap-owned `DirEntry` (boxed/leaked into `EntriesOption`); `entries_mutex` held. let dir = unsafe { (*e_ptr).dir }; @@ -1494,11 +1595,42 @@ pub mod fs { } } } + EntriesOption::Entries(_) | EntriesOption::Err(_) => {} } // SAFETY: BSSMap-owned slot; outlives caller (process-static singleton). Some(unsafe { &*result_ptr }) } + /// Kind of `name` inside the directory at `dir` (open as `fd`), following + /// symlinks. `None` for a missing or dangling entry, or when the + /// directory could not be opened. + pub(crate) fn kind_at(&self, dir: &[u8], fd: Fd, name: &'static ZStr) -> Option { + if !fd.is_valid() { + return None; + } + #[cfg(windows)] + { + match self + .kind(dir, name.as_bytes(), Fd::INVALID, false) + .ok()? + .kind + { + EntryKind::Dangling => None, + kind => Some(kind), + } + } + #[cfg(not(windows))] + { + let _ = dir; + let stat = bun_sys::fstatat(fd, name).ok()?; + match bun_core::kind_from_mode(stat.st_mode as bun_sys::Mode) { + bun_sys::FileKind::Directory => Some(EntryKind::Dir), + bun_sys::FileKind::File => Some(EntryKind::File), + _ => None, + } + } + } + fn platform_temp_dir_compute() -> &'static [u8] { use bun_core::env_var; // Try TMPDIR, TMP, and TEMP in that order, matching Node.js. @@ -1647,7 +1779,9 @@ pub mod fs { } pub mod file_system { - pub use super::{DirEntry, DirnameStore, Entry, EntryKind, FilenameStore, RealFS}; + pub use super::{ + DirEntry, DirnameStore, Entry, EntryKind, FilenameStore, RealFS, Unlisted, + }; pub mod real_fs { pub use crate::fs::EntriesOption; } @@ -1889,6 +2023,9 @@ pub mod dir_entry_accessor { value: Some(&**entry), })), EntriesOption::Err(err) => Err(err.original_err.into_core()), + EntriesOption::Unlisted(_) => { + Err(crate::Error::Sys(bun_errno::SystemErrno::ENOENT).into_core()) + } } } diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 8dcc38e33140..07b171ee8a31 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -255,7 +255,6 @@ use bun_sys::Fd as FD; use bun_threading::Mutex; use crate::fs as Fs; -use crate::fs::FilenameStoreAppender; use crate::node_fallbacks as NodeFallbackModules; use crate::package_json::{BrowserMap, ESModule, PackageJSON}; use crate::tsconfig_json::TSConfigJSON; @@ -1847,6 +1846,62 @@ impl<'a> Resolver<'a> { } } + /// Resolve the program's entry point, an absolute path to a file, with one + /// `stat` of that path (node's `tryFile`) instead of a listing of its + /// directory. `NotFound` when `path` is not a regular file, so the caller + /// falls back to [`resolve_and_auto_install`](Self::resolve_and_auto_install). + pub fn resolve_entry_point( + &mut self, + path: &[u8], + kind: ast::ImportKind, + ) -> ResultUnion<'static> { + if self.standalone_module_graph.is_some() + || !bun_paths::is_absolute(path) + || strings::index_of(path, b"..").is_some() + || strings::index_of_char(path, 0).is_some() + { + return ResultUnion::NotFound; + } + let mut normalizer = ResolvePath::PosixToWinNormalizer::default(); + let abs_path = normalizer.resolve(self.fs_ref().top_level_dir(), path); + let name = Fs::PathName::init(abs_path); + if name.dir.is_empty() || name.filename.is_empty() { + return ResultUnion::NotFound; + } + let Ok(cache) = self + .rfs_ref() + .kind(name.dir, name.filename, FD::INVALID, false) + else { + return ResultUnion::NotFound; + }; + if cache.kind != Fs::file_system::EntryKind::File { + return ResultUnion::NotFound; + } + let Ok(abs_path) = self.fs_ref().dirname_store.append_slice(abs_path) else { + return ResultUnion::NotFound; + }; + + let mut result = Result { + path_pair: PathPair { + primary: Path::init(abs_path), + secondary: None, + }, + jsx: self.opts.jsx.clone(), + import_kind: kind, + ..Default::default() + }; + if !self.opts.preserve_symlinks && !cache.symlink.is_empty() { + result + .path_pair + .primary + .set_realpath(cache.symlink.as_bytes()); + } + match self.finalize_result(&mut result, kind) { + Ok(()) => ResultUnion::Success(result), + Err(err) => ResultUnion::Failure(err), + } + } + /// Runs a resolution but also checking if a Bun Bake framework has an /// override. This is used in one place in the bundler. pub fn resolve_with_framework<'r>( @@ -1967,8 +2022,27 @@ impl<'a> Resolver<'a> { // Probe the listing in one `entries_mutex` critical section: a // concurrent resolver at a newer generation rewrites this `DirEntry`'s // map in place under that lock. The entry pointer stays valid after - // unlock (EntryStore-owned). - if let Some(query) = dir.get_entry(self.generation, name.filename) { + // unlock (EntryStore-owned). A directory the walk only opened (the + // entry point's) has no listing; `resolve_entry_point` already + // resolved the file's own symlink, so only a symlinked directory is + // left to apply. + let query = dir.get_entry_if_listed(self.generation, name.filename); + if query.is_none() { + if !path.is_symlink + && !dir.abs_real_path.is_empty() + && let mut buf = bun_paths::path_buffer_pool::get() + && let Some(joined) = ::bun_paths::resolve_path::join_abs_string_buf_z_checked::< + ::bun_paths::platform::Loose, + >( + self.fs_ref().top_level_dir(), + &mut buf[..], + &[dir.abs_real_path, name.filename], + ) + { + let symlink = Fs::FilenameStore::instance().append_slice(joined)?; + path.set_realpath(symlink); + } + } else if let Some(Some(query)) = query { let symlink_path = query.entry().symlink(self.rfs_ref(), self.store_fd); if !symlink_path.is_empty() { path.set_realpath(symlink_path); @@ -3774,22 +3848,9 @@ impl<'a> Resolver<'a> { unsafe { &mut *rfs } }; } - // Hold `entries_mutex` across the in-place `DirEntry` rewrite below and - // the `dir_info_uncached` call, mirroring `dir_info_cached_miss`: the - // route loaders iterate the `DirEntry.data` map under this lock. - let _entries_unlock = rfs!().entries_mutex.lock_guard(); - let mut cached_dir_entry_result = rfs!().entries.get_or_put(dir_path)?; - - // NOTE: always assigned by either the cached-hit arm or the - // `needs_iter` block below; null-init so rustc accepts the proof. - let mut dir_entries_option: *mut Fs::file_system::real_fs::EntriesOption = - core::ptr::null_mut(); - let mut needs_iter = true; - let mut in_place: Option<*mut Fs::file_system::DirEntry> = None; let open_dir = match bun_sys::open_dir_for_iteration(FD::cwd(), dir_path) { Ok(d) => d, Err(err) => { - // TODO: handle this error better let _ = self.log_mut().add_error_fmt( None, bun_ast::Loc::EMPTY, @@ -3799,95 +3860,26 @@ impl<'a> Resolver<'a> { } }; - if let Some(cached_entry) = rfs!().entries.at_index(cached_dir_entry_result.index) { - if let Fs::file_system::real_fs::EntriesOption::Entries(entries) = cached_entry { - if entries.generation >= self.generation { - dir_entries_option = cached_entry; - needs_iter = false; - } else { - in_place = Some(std::ptr::from_mut(*entries)); - } - } - } - - if needs_iter { - // SAFETY: (block-wide) `in_place`/`dir_entries_ptr`/`dir_entries_option` point to slots - // in `rfs.entries` (BSSMap singleton) or a fresh leaked Box; both outlive this fn and - // are accessed under `rfs.entries_mutex` (see LIFETIMES.tsv). - let mut new_entry = Fs::file_system::DirEntry::init( - if let Some(existing) = in_place { - // SAFETY: see block-wide note above. - unsafe { &*existing }.dir - } else { - Fs::file_system::DirnameStore::instance() - .append_slice(dir_path) - .expect("unreachable") - }, - self.generation, - ); - - // Pre-size `data` so the per-entry inserts below skip the - // 1→2→4→…→N hashbrown rehash cascade from an empty table. 64 - // covers a typical node_modules package dir; larger dirs still - // rehash from there (cheap relative to starting at 0). - new_entry.data.reserve(64); - - let mut dir_iterator = bun_sys::iterate_dir(open_dir); - // Hoist the `FilenameStore` singleton resolve out of the per-entry loop - // (see `DirEntry::add_entry` doc-comment) and reuse the appender state. - let mut filename_store = FilenameStoreAppender::new(); - while let Ok(Some(_value)) = dir_iterator.next() { - new_entry - .add_entry_with_store( - // SAFETY: see block-wide note above. - in_place.map(|existing| unsafe { &mut (*existing).data }), - &_value, - &mut filename_store, - ) - .expect("unreachable"); - } - if let Some(existing) = in_place { - // SAFETY: see block-wide note above. - // NOTE: `StringHashMap` (std::HashMap newtype) - // has no separate `clear_and_free`; `clear()` drops all entries. - unsafe { &mut *existing }.data.clear(); - } - - if self.store_fd { - new_entry.fd = open_dir; + // A package directory is listed: `load_as_file_or_directory` probes + // its entries right after this. + let dir_entries_option: *const Fs::file_system::real_fs::EntriesOption = + rfs!().read_directory(dir_path, Some(open_dir), self.generation, self.store_fd)?; + // SAFETY: `read_directory` returned a live slot in `rfs.entries` (or the + // threadlocal scratch slot for a missing directory, read before the next call). + let dir_entries = match unsafe { &*dir_entries_option }.try_entries() { + Ok(entries) => entries, + Err(err) => { + let _ = self.log_mut().add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!("Unable to open directory: {}", bstr::BStr::new(err.name())), + ); + return Err(err); } - // NOTE: see `dir_info_cached_maybe_log` — `DirEntry.data` holds a `NonNull`, - // so a zeroed slot is UB; box `new_entry` directly for the fresh case. - let dir_entries_ptr = match in_place { - Some(p) => { - // SAFETY: dir_entries_ptr is a live BSSMap slot (`in_place`). - unsafe { *p = new_entry }; - p - } - None => bun_core::heap::into_raw(Box::new(new_entry)), - }; - - bun_core::scoped_log!( - crate::fs_full::Fs, - "readdir({}, {}) = {}", - open_dir, - bstr::BStr::new(dir_path), - // SAFETY: `dir_entries_ptr` is a live BSSMap slot (`in_place`) or a freshly - // boxed entry (see block-wide note above). - unsafe { (*dir_entries_ptr).data.count() }, - ); - - dir_entries_option = rfs!() - .entries - .put( - &mut cached_dir_entry_result, - Fs::file_system::real_fs::EntriesOption::Entries( - // SAFETY: `dir_entries_ptr` is a live BSSMap slot (`in_place`) or a freshly boxed entry. - unsafe { &mut *dir_entries_ptr }, - ), - ) - .expect("unreachable"); - } + }; + // The route loaders iterate the `DirEntry.data` map under this lock. + let _entries_unlock = rfs!().entries_mutex.lock_guard(); + let cached_dir_entry_result = rfs!().entries.get_or_put(dir_path)?; // We must initialize it as empty so that the result index is correct. // This is important so that browser_scope has a valid index. @@ -3905,16 +3897,11 @@ impl<'a> Resolver<'a> { // which gets overwritten on the next auto-install resolution. `dir_info_uncached` stores // its `path` argument directly as `DirInfo.abs_path` in the permanent `dir_cache`, so // pass the interned copy from `DirEntry.dir` (always backed by `DirnameStore`) instead. - // SAFETY: ARENA — `dir_entries_option` is a slot in `rfs.entries` (BSSMap) and - // 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 = dir_entries.dir; self.dir_info_uncached( dir_info_ptr, dir_entries_dir, - // already `*mut EntriesOption` — pass raw, no intermediate `&mut` retag - dir_entries_option, + Some(std::ptr::from_ref(dir_entries)), dir_cache_info_result, cached_dir_entry_result.index, // Packages in the global disk cache are top-level, we shouldn't try @@ -4716,6 +4703,10 @@ impl<'a> Resolver<'a> { slot.safe_path = entries.dir; slot.fd = entries.fd; } + Fs::file_system::real_fs::EntriesOption::Unlisted(unlisted) => { + slot.safe_path = unlisted.dir; + slot.fd = unlisted.fd; + } Fs::file_system::real_fs::EntriesOption::Err(err) => { debuglog!( "Failed to load DirEntry {} {} - {}", @@ -4751,6 +4742,10 @@ impl<'a> Resolver<'a> { slot.safe_path = entries.dir; slot.fd = entries.fd; } + Fs::file_system::real_fs::EntriesOption::Unlisted(unlisted) => { + slot.safe_path = unlisted.dir; + slot.fd = unlisted.fd; + } Fs::file_system::real_fs::EntriesOption::Err(err) => { debuglog!( "Failed to load DirEntry {} {} - {}", @@ -4980,106 +4975,60 @@ impl<'a> Resolver<'a> { let mut cached_dir_entry_result = rfs!().entries.get_or_put(dir_path).expect("unreachable"); - let mut dir_entries_option: *mut Fs::file_system::real_fs::EntriesOption = - core::ptr::null_mut(); - let mut needs_iter = true; - let mut in_place: Option<*mut Fs::file_system::DirEntry> = None; + // SAFETY: `top_parent.index` is a sentinel or a slot assigned by + // `put`; resolver mutex held. + let parent_dir_ptr = unsafe { DirInfo::slot_ptr_at(top_parent.index) } + .map(|p| unsafe { DirInfoRef::from_raw(p) }); + + // The markers `dir_info_uncached` asks for are fixed names, so a + // directory is not listed here. A package directory is the + // exception: the resolve that follows probes its entries anyway, + // and the open fd makes that listing free of extra syscalls. + let in_node_modules = open_dir.is_valid() + && (strings::without_trailing_slash(bun_paths::basename(dir_path)) + == b"node_modules" + || parent_dir_ptr.is_some_and(|parent| { + parent.is_node_modules() || parent.is_inside_node_modules() + })); + let mut listing: Option<*const Fs::file_system::DirEntry> = None; + let mut has_stale_listing = false; if let Some(cached_entry) = rfs!().entries.at_index(cached_dir_entry_result.index) { if let Fs::file_system::real_fs::EntriesOption::Entries(entries) = cached_entry { if entries.generation >= self.generation { - dir_entries_option = cached_entry; - needs_iter = false; + listing = Some(std::ptr::from_ref(&**entries)); } else { - in_place = Some(std::ptr::from_mut(*entries)); + has_stale_listing = true; } } } - if needs_iter { - // SAFETY: (block-wide) `in_place`/`dir_entries_ptr`/`dir_entries_option` point to - // slots in `rfs.entries` (BSSMap singleton) or a fresh leaked Box; both outlive this - // fn and are accessed under `rfs.entries_mutex` (see LIFETIMES.tsv). - let mut new_entry = Fs::file_system::DirEntry::init( - if let Some(existing) = in_place { - // SAFETY: see block-wide note above. - unsafe { &*existing }.dir - } else { - Fs::file_system::DirnameStore::instance() - .append_slice(dir_path) - .expect("unreachable") - }, - self.generation, - ); - - // Pre-size `data` so the per-entry inserts below skip the - // 1→2→4→…→N hashbrown rehash cascade from an empty table. 64 - // covers a typical node_modules package dir; larger dirs - // still rehash from there (cheap relative to starting at 0). - new_entry.data.reserve(64); - - // A permission-denied ancestor has no fd to enumerate; its - // entry set stays empty. - if open_dir.is_valid() { - let mut dir_iterator = bun_sys::iterate_dir(open_dir); - // NOTE: `WrappedIterator::next` returns - // `Result>`, so use `?`-style break-on-error. - // Hoist the `FilenameStore` singleton resolve out of the per-entry loop - // (see `DirEntry::add_entry` doc-comment) and reuse the appender state. - let mut filename_store = FilenameStoreAppender::new(); - loop { - let _value = match dir_iterator.next() { - Ok(Some(v)) => v, - Ok(None) => break, - Err(_) => break, - }; - new_entry - .add_entry_with_store( - // SAFETY: see block-wide note above. - in_place.map(|existing| unsafe { &mut (*existing).data }), - &_value, - &mut filename_store, - ) - .expect("unreachable"); + if listing.is_none() { + if in_node_modules { + let read = rfs!().read_directory_locked( + dir_path, + Some(open_dir), + self.generation, + self.store_fd, + &mut (), + )?; + if let Fs::file_system::real_fs::EntriesOption::Entries(entries) = read { + listing = Some(std::ptr::from_ref(&**entries)); } + cached_dir_entry_result = + rfs!().entries.get_or_put(dir_path).expect("unreachable"); + } else if !has_stale_listing { + // A stale listing stays until a reader asks for it again. + rfs!().entries.put( + &mut cached_dir_entry_result, + Fs::file_system::real_fs::EntriesOption::Unlisted( + Fs::file_system::Unlisted { + dir: dir_path, + fd: if self.store_fd { open_dir } else { FD::INVALID }, + }, + ), + )?; } - if let Some(existing) = in_place { - // SAFETY: see block-wide note above. - // NOTE: bun_collections::StringHashMap exposes `clear`, which drops all entries. - unsafe { &mut *existing }.data.clear(); - } - new_entry.fd = if self.store_fd { open_dir } else { FD::INVALID }; - // NOTE: `DirEntry.data` is a `HashMap` - // (`NonNull` inside), so a zeroed slot is UB and `*ptr = new_entry` would drop it. - // Box `new_entry` directly for the fresh case; assign-into only for `in_place`. - let dir_entries_ptr = match in_place { - Some(p) => { - // SAFETY: dir_entries_ptr is a live BSSMap slot (`in_place`). - unsafe { *p = new_entry }; - p - } - None => bun_core::heap::into_raw(Box::new(new_entry)), - }; - // NOTE (Stacked Borrows): log BEFORE `entries.put` stores the - // `&'static mut DirEntry` — a later read through the parent raw - // pointer would pop that reference's Unique tag (the ordering - // is unobservable). - bun_core::scoped_log!( - crate::fs_full::Fs, - "readdir({}, {}) = {}", - open_dir, - bstr::BStr::new(dir_path), - // SAFETY: `dir_entries_ptr` is a live BSSMap slot (`in_place`) or a - // freshly boxed entry (see block-wide note above). - unsafe { (*dir_entries_ptr).data.count() }, - ); - dir_entries_option = rfs!().entries.put( - &mut cached_dir_entry_result, - Fs::file_system::real_fs::EntriesOption::Entries( - // SAFETY: `dir_entries_ptr` is a live BSSMap slot (`in_place`) or a freshly boxed entry. - unsafe { &mut *dir_entries_ptr }, - ), - )?; } // We must initialize it as empty so that the result index is correct. @@ -5094,16 +5043,11 @@ impl<'a> Resolver<'a> { DirInfo::DirInfo::default(), ) }?; - // SAFETY: `top_parent.index` is a sentinel or a slot assigned by - // `put`; resolver mutex held. - let parent_dir_ptr = unsafe { DirInfo::slot_ptr_at(top_parent.index) } - .map(|p| unsafe { DirInfoRef::from_raw(p) }); self.dir_info_uncached( dir_info_ptr, dir_path, - // SAFETY: ARENA — `dir_entries_option` is a slot in `rfs.entries` (BSSMap) and outlives the resolver. - dir_entries_option, + listing, queue_top.result, cached_dir_entry_result.index, parent_dir_ptr, @@ -6410,7 +6354,7 @@ impl<'a> Resolver<'a> { &mut self, info: *mut DirInfo::DirInfo, path: &'static [u8], - _entries: *mut Fs::file_system::real_fs::EntriesOption, + listing: Option<*const Fs::file_system::DirEntry>, _result: allocators::Result, dir_entry_index: allocators::IndexType, parent: Option, @@ -6420,14 +6364,21 @@ impl<'a> Resolver<'a> { ) -> crate::CrateResult<()> { let result = _result; - // SAFETY: `_entries` is a live slot (caller contract); the payload - // `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()); - macro_rules! entries { - () => { - dir_entries.get() + // SAFETY: `listing` is the payload of a live `rfs.entries` slot (caller + // contract), a separate process-lifetime allocation, read under + // `entries_mutex`. + let listing = listing.map(|p| bun_ptr::BackRef::new(unsafe { &*p })); + // Kind of a marker file in this directory: from the cached listing + // when there is one, else one `stat` on the open directory. + macro_rules! marker_kind { + ($name:literal) => { + match listing { + Some(entries) => entries + .get() + .get_comptime_query($name.as_bytes()) + .map(|lookup| lookup.entry().kind(self.rfs_ref(), self.store_fd)), + None => self.rfs_ref().kind_at(path, fd, ::bun_core::zstr!($name)), + } }; } @@ -6463,55 +6414,46 @@ impl<'a> Resolver<'a> { info.flags .set_present(DirInfo::Flag::IsNodeModules, base == b"node_modules"); - // if (entries != null) { if !info.is_node_modules() { - if let Some(entry) = entries!().get_comptime_query(b"node_modules") { - info.flags.set_present( - DirInfo::Flag::HasNodeModules, - entry.entry().kind(self.rfs_ref(), self.store_fd) - == Fs::file_system::EntryKind::Dir, - ); - } + info.flags.set_present( + DirInfo::Flag::HasNodeModules, + marker_kind!("node_modules") == Some(Fs::file_system::EntryKind::Dir), + ); } if self.care_about_bin_folder { 'append_bin_dir: { if info.has_node_modules() { - if entries!().has_comptime_query(b"node_modules") { - let Ok(file) = bun_sys::open_dir_z( - fd, - bun_paths::path_literal!(b"node_modules/.bin"), - Default::default(), - ) else { - break 'append_bin_dir; - }; - let _close = bun_sys::CloseOnDrop::new(file); - let Ok(bin_path) = file.get_fd_path(bufs!(node_bin_path)) else { - break 'append_bin_dir; - }; - let _unlock = BIN_FOLDERS_LOCK.lock_guard(); - - // SAFETY: BIN_FOLDERS guarded by BIN_FOLDERS_LOCK acquired above. - unsafe { - for existing_folder in (*BIN_FOLDERS.get()).const_slice() { - if *existing_folder == bin_path { - break 'append_bin_dir; - } - } + let Ok(file) = bun_sys::open_dir_z( + fd, + bun_paths::path_literal!(b"node_modules/.bin"), + Default::default(), + ) else { + break 'append_bin_dir; + }; + let _close = bun_sys::CloseOnDrop::new(file); + let Ok(bin_path) = file.get_fd_path(bufs!(node_bin_path)) else { + break 'append_bin_dir; + }; + let _unlock = BIN_FOLDERS_LOCK.lock_guard(); - let Ok(stored) = self.fs_ref().dirname_store.append_slice(bin_path) - else { + // SAFETY: BIN_FOLDERS guarded by BIN_FOLDERS_LOCK acquired above. + unsafe { + for existing_folder in (*BIN_FOLDERS.get()).const_slice() { + if *existing_folder == bin_path { break 'append_bin_dir; - }; - let _ = (*BIN_FOLDERS.get()).append(stored); + } } + + let Ok(stored) = self.fs_ref().dirname_store.append_slice(bin_path) else { + break 'append_bin_dir; + }; + let _ = (*BIN_FOLDERS.get()).append(stored); } } if info.is_node_modules() { - if let Some(q) = entries!().get_comptime_query(b".bin") { - if q.entry().kind(self.rfs_ref(), self.store_fd) - == Fs::file_system::EntryKind::Dir + if marker_kind!(".bin") == Some(Fs::file_system::EntryKind::Dir) { { let Ok(file) = bun_sys::open_dir_z(fd, b".bin\0", Default::default()) else { @@ -6577,63 +6519,77 @@ impl<'a> Resolver<'a> { // The only caller that reaches this with `parent` set // (`dir_info_cached_miss`) already holds `entries_mutex`, and that // mutex is non-recursive, so go through the `_locked` accessor. - if let Some(parent_entries) = parent_.get_entries_ref_locked(self.generation) { - if let Some(lookup) = parent_entries.get(base) { - let entries_fd = entries!().fd; - if entries_fd.is_valid() - && !lookup.entry().cache().fd.is_valid() - && self.store_fd - { - // Every cached-`Entry` rewrite takes the per-entry mutex. - let _entry_guard = lookup.entry().mutex.lock_guard(); - lookup.entry().set_cache_fd(entries_fd); + // A parent the walk only opened is asked with one `lstat` + // instead of a listing. + let found: Option<(&'static [u8], Option)> = + if parent_.is_listed_locked() { + parent_ + .get_entries_ref_locked(self.generation) + .and_then(|parent_entries| parent_entries.get(base)) + .map(|lookup| { + if fd.is_valid() + && !lookup.entry().cache().fd.is_valid() + && self.store_fd + { + // Every cached-`Entry` rewrite takes the per-entry mutex. + let _entry_guard = lookup.entry().mutex.lock_guard(); + lookup.entry().set_cache_fd(fd); + } + ( + lookup.entry().symlink(self.rfs_ref(), self.store_fd), + Some(lookup), + ) + }) + } else { + self.rfs_ref() + .kind(parent_.abs_path, base, FD::INVALID, false) + .ok() + .map(|cache| (cache.symlink.as_bytes(), None)) + }; + if let Some((mut symlink, lookup)) = found { + if !symlink.is_empty() { + if let Some(logs) = self.debug_logs.as_mut() { + let mut buf = Vec::new(); + let _ = write!( + &mut buf, + "Resolved symlink \"{}\" to \"{}\"", + bstr::BStr::new(path), + bstr::BStr::new(symlink) + ); + logs.add_note(buf); } + info.abs_real_path = symlink; + } else if !parent_.abs_real_path.is_empty() + && let Some(joined) = self.fs_ref().abs_buf_checked( + &[parent_.abs_real_path, base], + bufs!(dir_info_uncached_filename), + ) + { + // this might leak a little i'm not sure + symlink = self + .fs_ref() + .dirname_store + .append_slice(joined) + .expect("unreachable"); - let mut symlink = lookup.entry().symlink(self.rfs_ref(), self.store_fd); - if !symlink.is_empty() { - if let Some(logs) = self.debug_logs.as_mut() { - let mut buf = Vec::new(); - let _ = write!( - &mut buf, - "Resolved symlink \"{}\" to \"{}\"", - bstr::BStr::new(path), - bstr::BStr::new(symlink) - ); - logs.add_note(buf); - } - info.abs_real_path = symlink; - } else if !parent_.abs_real_path.is_empty() - && let Some(joined) = self.fs_ref().abs_buf_checked( - &[parent_.abs_real_path, base], - bufs!(dir_info_uncached_filename), - ) - { - // this might leak a little i'm not sure - symlink = self - .fs_ref() - .dirname_store - .append_slice(joined) - .expect("unreachable"); - - if let Some(logs) = self.debug_logs.as_mut() { - let mut buf = Vec::new(); - let _ = write!( - &mut buf, - "Resolved symlink \"{}\" to \"{}\"", - bstr::BStr::new(path), - bstr::BStr::new(symlink) - ); - logs.add_note(buf); - } - { - // Every cached-`Entry` rewrite takes the per-entry mutex. - let _entry_guard = lookup.entry().mutex.lock_guard(); - lookup - .entry() - .set_cache_symlink(Interned::from_static(symlink)); - } - info.abs_real_path = symlink; + if let Some(logs) = self.debug_logs.as_mut() { + let mut buf = Vec::new(); + let _ = write!( + &mut buf, + "Resolved symlink \"{}\" to \"{}\"", + bstr::BStr::new(path), + bstr::BStr::new(symlink) + ); + logs.add_note(buf); + } + if let Some(lookup) = lookup { + // Every cached-`Entry` rewrite takes the per-entry mutex. + let _entry_guard = lookup.entry().mutex.lock_guard(); + lookup + .entry() + .set_cache_symlink(Interned::from_static(symlink)); } + info.abs_real_path = symlink; } } } @@ -6645,66 +6601,62 @@ impl<'a> Resolver<'a> { } // Record if this directory has a package.json file - if self.opts.load_package_json { - if let Some(lookup) = entries!().get_comptime_query(b"package.json") { - if lookup.entry().kind(self.rfs_ref(), self.store_fd) - == Fs::file_system::EntryKind::File - { - // A package in the global cache has a `package_id`, and keeps it even if its - // tarball shipped a `node_modules` folder. - info.package_json = if self.use_package_manager() - && (package_id.is_some() || !info.has_node_modules()) - && !info.is_node_modules() - { - self.parse_package_json::( - path, - if FeatureFlags::STORE_FILE_DESCRIPTORS { - fd - } else { - FD::INVALID - }, - package_id, - ) - .ok() - .flatten() + if self.opts.load_package_json + && marker_kind!("package.json") == Some(Fs::file_system::EntryKind::File) + { + // A package in the global cache has a `package_id`, and keeps it even if its + // tarball shipped a `node_modules` folder. + info.package_json = if self.use_package_manager() + && (package_id.is_some() || !info.has_node_modules()) + && !info.is_node_modules() + { + self.parse_package_json::( + path, + if FeatureFlags::STORE_FILE_DESCRIPTORS { + fd } else { - self.parse_package_json::( - path, - if FeatureFlags::STORE_FILE_DESCRIPTORS { - fd - } else { - FD::INVALID - }, - None, - ) - .ok() - .flatten() - }; + FD::INVALID + }, + package_id, + ) + .ok() + .flatten() + } else { + self.parse_package_json::( + path, + if FeatureFlags::STORE_FILE_DESCRIPTORS { + fd + } else { + FD::INVALID + }, + None, + ) + .ok() + .flatten() + }; - if let Some(pkg) = info.package_json() { - if pkg.browser_map.count() > 0 { - info.enclosing_browser_scope = result.index; - info.package_json_for_browser_field = Some(pkg); - } + if let Some(pkg) = info.package_json() { + if pkg.browser_map.count() > 0 { + info.enclosing_browser_scope = result.index; + info.package_json_for_browser_field = Some(pkg); + } - if !pkg.name.is_empty() || self.care_about_bin_folder { - info.enclosing_package_json = Some(pkg); - } + if !pkg.name.is_empty() || self.care_about_bin_folder { + info.enclosing_package_json = Some(pkg); + } - if pkg.lists_dependencies() { - // NOTE: store the raw `NonNull` field (not the - // `&'static` accessor result) so mut-provenance flows - // through to `enqueue_dependency_to_resolve`. - info.package_json_for_dependencies = info.package_json; - } + if pkg.lists_dependencies() { + // NOTE: store the raw `NonNull` field (not the + // `&'static` accessor result) so mut-provenance flows + // through to `enqueue_dependency_to_resolve`. + info.package_json_for_dependencies = info.package_json; + } - if let Some(logs) = self.debug_logs.as_mut() { - logs.add_note_fmt(format_args!( - "Resolved package.json in \"{}\"", - bstr::BStr::new(path) - )); - } - } + if let Some(logs) = self.debug_logs.as_mut() { + logs.add_note_fmt(format_args!( + "Resolved package.json in \"{}\"", + bstr::BStr::new(path) + )); } } } @@ -6716,27 +6668,19 @@ impl<'a> Resolver<'a> { // Record if this directory has a tsconfig.json or jsconfig.json file if self.opts.load_tsconfig_json { let mut tsconfig_path: Option<&[u8]> = None; - if let Some(lookup) = entries!().get_comptime_query(b"tsconfig.json") { - if lookup.entry().kind(self.rfs_ref(), self.store_fd) - == Fs::file_system::EntryKind::File - { - let parts = [path, b"tsconfig.json".as_slice()]; - tsconfig_path = self - .fs_ref() - .abs_buf_checked(&parts, bufs!(dir_info_uncached_filename)); - } + if marker_kind!("tsconfig.json") == Some(Fs::file_system::EntryKind::File) { + let parts = [path, b"tsconfig.json".as_slice()]; + tsconfig_path = self + .fs_ref() + .abs_buf_checked(&parts, bufs!(dir_info_uncached_filename)); } - if tsconfig_path.is_none() { - if let Some(lookup) = entries!().get_comptime_query(b"jsconfig.json") { - if lookup.entry().kind(self.rfs_ref(), self.store_fd) - == Fs::file_system::EntryKind::File - { - let parts = [path, b"jsconfig.json".as_slice()]; - tsconfig_path = self - .fs_ref() - .abs_buf_checked(&parts, bufs!(dir_info_uncached_filename)); - } - } + if tsconfig_path.is_none() + && marker_kind!("jsconfig.json") == Some(Fs::file_system::EntryKind::File) + { + let parts = [path, b"jsconfig.json".as_slice()]; + tsconfig_path = self + .fs_ref() + .abs_buf_checked(&parts, bufs!(dir_info_uncached_filename)); } if let Some(tsconfigpath) = tsconfig_path { diff --git a/src/router/lib.rs b/src/router/lib.rs index a6bc7d0a7c15..70b13490df73 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -550,7 +550,7 @@ impl<'a> RouteLoader<'a> { // under that lock, so iterating it live would walk freed buckets. let entry_ptrs: Vec<*mut Fs::Entry> = { let _entries_lock = fs.fs.entries_mutex.lock_guard(); - match root_dir_info.get_entries_const() { + match root_dir_info.get_entries_listed_locked() { Some(entries) => entries.data.values().copied().collect(), None => return, } diff --git a/src/runtime/api/filesystem_router.rs b/src/runtime/api/filesystem_router.rs index 4789d81c50cf..fb1a56647d27 100644 --- a/src/runtime/api/filesystem_router.rs +++ b/src/runtime/api/filesystem_router.rs @@ -165,7 +165,7 @@ impl FileSystemRouter { // the guard: `bust_dir_cache` / the recursion below re-acquire it. let entry_ptrs: Vec<*mut Fs::Entry> = { let _entries_lock = Fs::FileSystem::get().fs.entries_mutex.lock_guard(); - match dir_ref.get_entries_const() { + match dir_ref.get_entries_listed_locked() { Some(entries) => entries.data.values().copied().collect(), None => Vec::new(), } diff --git a/src/runtime/bake/router/mod.rs b/src/runtime/bake/router/mod.rs index c0f7babc6227..b7472d12b4da 100644 --- a/src/runtime/bake/router/mod.rs +++ b/src/runtime/bake/router/mod.rs @@ -587,7 +587,7 @@ impl FrameworkRouter { // `DirEntry` map in place under that lock. Dropped before the // walk so the `read_dir_info_ignore_error` recursion can re-lock. let _entries_lock = fs_ref.fs.entries_mutex.lock_guard(); - if let Some(entries) = dir_info.get_entries_const() { + if let Some(entries) = dir_info.get_entries_listed_locked() { for (k, &v) in entries.data.iter() { let _ = zig_order.put(Box::from(&**k), v); } diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index 8e11c5d42d8b..f800adab1e73 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -70,30 +70,23 @@ pub(crate) fn view( // Try our best to get the package.json name they meant 'from_package_json: { - // `root_dir` is in the resolver's directory cache, so lookups must hold - // `entries_mutex`. - let has_package_json = { - let _entries_lock = bun_resolver::fs::FileSystem::get() - .fs - .entries_mutex - .lock_guard(); - manager.root_dir.has_comptime_query(b"package.json") - }; - if !has_package_json { - const NO_NAME: &str = "no package name given and no package.json found"; - if json_output { - print_json_error(NO_NAME, spec_, None); - } else { - Output::err_generic(NO_NAME, ()); + let package_json_path = bun_core::ZBox::from_bytes( + &[manager.root_dir, b"package.json".as_slice()].concat(), + ); + let str = match bun_sys::File::open(&package_json_path, bun_sys::O::RDONLY, 0) + .and_then(|file| file.read_to_end()) + { + Ok(str) => str, + Err(err) if err.get_errno() == bun_sys::E::ENOENT => { + const NO_NAME: &str = "no package name given and no package.json found"; + if json_output { + print_json_error(NO_NAME, spec_, None); + } else { + Output::err_generic(NO_NAME, ()); + } + Global::exit(1); } - Global::exit(1); - } - let fd = manager.root_dir.fd; - if !fd.is_valid() { - break 'from_package_json; - } - let Ok(str) = bun_sys::File::read_from(fd, b"package.json") else { - break 'from_package_json; + Err(_) => break 'from_package_json, }; let str: &[u8] = bump.alloc_slice_copy(&str); let source = &bun_ast::Source::init_path_string(b"package.json", str); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 9b669e8bc996..79fcc3310abb 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -3782,13 +3782,13 @@ impl RunCommand { .ok() .flatten() { - if let Some(entries) = bin_dir.get_entries_const() { - // `.data` iteration must hold `entries_mutex` - // (uncontended on this single-threaded CLI path). - let _entries_lock = bun_resolver::fs::FileSystem::get() - .fs - .entries_mutex - .lock_guard(); + // `.data` iteration must hold `entries_mutex` + // (uncontended on this single-threaded CLI path). + let _entries_lock = bun_resolver::fs::FileSystem::get() + .fs + .entries_mutex + .lock_guard(); + if let Some(entries) = bin_dir.get_entries_listed_locked() { let mut path_buf = bun_paths::path_buffer_pool::get(); let mut iter = entries.data.iter(); let mut has_copied = false; @@ -3840,13 +3840,13 @@ impl RunCommand { .ok() .flatten() { - if let Some(entries) = dir_info.get_entries_const() { - // `.data` iteration must hold `entries_mutex` - // (uncontended on this single-threaded CLI path). - let _entries_lock = bun_resolver::fs::FileSystem::get() - .fs - .entries_mutex - .lock_guard(); + // `.data` iteration must hold `entries_mutex` + // (uncontended on this single-threaded CLI path). + let _entries_lock = bun_resolver::fs::FileSystem::get() + .fs + .entries_mutex + .lock_guard(); + if let Some(entries) = dir_info.get_entries_listed_locked() { let mut iter = entries.data.iter(); while let Some(entry) = iter.next() { diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 3bd081a66fcc..c869748477af 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -32,7 +32,6 @@ pub(crate) struct Scanner<'a> { pub(crate) fs: *mut FileSystem, pub(crate) open_dir_buf: PathBuffer, pub(crate) options: &'a BundleOptions<'a>, - pub(crate) has_iterated: bool, pub(crate) search_count: usize, /// The directory being iterated; its fd closes once every child `ScanEntry` has been opened. current_dir: Option>, @@ -81,7 +80,6 @@ impl<'a> Scanner<'a> { fs: transpiler.resolver.fs(), test_files: results, open_dir_buf: PathBuffer::ZEROED, - has_iterated: false, search_count: 0, current_dir: None, }) @@ -127,8 +125,13 @@ impl<'a> Scanner<'a> { return Err(ScanError::DoesNotExist); }; - let root = self - .read_dir_with_name(path, None) + // The root is read without the scanner as its iterator, then visited + // in name order below: hash-map iteration order is not stable, and + // `readdir` order is not either. + let fs_ptr = self.fs; + // SAFETY: borrows only the `fs` field; re-entrant access is serialised by `RealFS.entries_mutex`. + let root = unsafe { &mut (*fs_ptr).fs } + .read_directory_with_iterator(path, None, 0, false, &mut ()) .map_err(|_| ScanError::OutOfMemory)?; if let EntriesOption::Err(root_err) = root { @@ -154,31 +157,26 @@ impl<'a> Scanner<'a> { } } - // you typed "." and we already scanned it - if !self.has_iterated { - if let EntriesOption::Entries(entries) = root { - // Collect first so `self.next(…)` doesn't overlap the - // `entries.data` borrow. - // this branch is taken when the resolver already has - // `path` cached (e.g. `run_env_loader`/`read_dir_info` read the - // cwd before the scanner runs), so `read_directory_with_iterator` - // returned the cached `EntryMap` without invoking `iterator.next`. - // Hash-map iteration order is not stable. Sort by (lowercased) - // base name so test-file discovery order is deterministic — - // regression/issue/26851 relies on `a_*.test` running before - // `b_*.test` under `--bail`. - let mut entry_ptrs: Vec<*mut fs::Entry> = entries.data.values().copied().collect(); - index_sort::sort_slice_by(&mut entry_ptrs, |a, b| { - // SAFETY: `EntryMap` stores `*mut Entry` into the - // process-static `EntryStore`; valid for `'static`. - let (an, bn) = unsafe { ((**a).base_lowercase(), (**b).base_lowercase()) }; - an.cmp(bn) - }); - for entry_ptr in entry_ptrs { - // SAFETY: `EntryMap` stores `*mut Entry` into the - // process-static `EntryStore`; valid for `'static`. - self.next(&self.fs().fs, unsafe { &*entry_ptr }); - } + if let EntriesOption::Entries(entries) = root { + // Collect first so `self.next(…)` doesn't overlap the + // `entries.data` borrow. Sort by (lowercased) base name so + // test-file discovery order is deterministic — + // regression/issue/26851 relies on `a_*.test` running before + // `b_*.test` under `--bail`. + let mut entry_ptrs: Vec<*mut fs::Entry> = { + let _entries_lock = self.fs().fs.entries_mutex.lock_guard(); + entries.data.values().copied().collect() + }; + index_sort::sort_slice_by(&mut entry_ptrs, |a, b| { + // SAFETY: `EntryMap` stores `*mut Entry` into the + // process-static `EntryStore`; valid for `'static`. + let (an, bn) = unsafe { ((**a).base_lowercase(), (**b).base_lowercase()) }; + an.cmp(bn) + }); + for entry_ptr in entry_ptrs { + // SAFETY: `EntryMap` stores `*mut Entry` into the + // process-static `EntryStore`; valid for `'static`. + self.next(&self.fs().fs, unsafe { &*entry_ptr }); } } @@ -324,7 +322,6 @@ impl<'a> Scanner<'a> { fn next(&mut self, rfs: &R, entry: &fs::Entry) { let name = entry.base_lowercase(); - self.has_iterated = true; match entry.kind(rfs, false) { fs::EntryKind::Dir => { if (!name.is_empty() && name[0] == b'.') || name == b"node_modules" { diff --git a/test/js/bun/resolve/resolver-dir-listing.test.ts b/test/js/bun/resolve/resolver-dir-listing.test.ts new file mode 100644 index 000000000000..5e274a8c080a --- /dev/null +++ b/test/js/bun/resolve/resolver-dir-listing.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isDebug, isLinux, isRoot, isWindows, tempDir } from "harness"; +import { chmodSync, readFileSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; + +// The resolver walks from the filesystem root down to a directory to build its +// `DirInfo` (package.json, tsconfig.json, node_modules). That walk must not +// read the listing of any directory on the way: startup would then scale with +// the number of entries in the cwd and in every ancestor (200k files in +// $TMPDIR cost ~150 ms per spawn). A listing is read only when something +// needs the names in a directory: a relative import probes extensions, +// `bun test` scans for test files. + +async function run(cmd: string[], cwd: string, env: Record = {}) { + await using proc = Bun.spawn({ + cmd, + cwd, + env: { ...bunEnv, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +/** The program's output and the directories the debug build listed, from its `BUN_DEBUG_Fs` log on stdout. */ +function splitFsLog(stdout: string): { output: string; listed: string[] } { + const lines = stdout.split("\n"); + return { + output: lines.filter(line => !line.startsWith("[fs] ")).join("\n"), + listed: lines + .filter(line => line.startsWith("[fs] readdir(")) + .map(line => line.slice(line.indexOf(", ") + 2, line.indexOf(") = "))), + }; +} + +const fsLog = { BUN_DEBUG_Fs: "1", BUN_DEBUG_QUIET_LOGS: "1" }; + +describe.skipIf(!isDebug)("startup does not list directories", () => { + test.concurrent("bun -e reads no directory listing", async () => { + using dir = tempDir("no-listing-eval", { + "a/b/c/.keep": "", + "a/package.json": JSON.stringify({ name: "a" }), + "a/b/tsconfig.json": "{}", + }); + const { stdout, exitCode } = await run([bunExe(), "-e", "console.log('ok')"], join(dir, "a/b/c"), fsLog); + expect({ ...splitFsLog(stdout), exitCode }).toEqual({ output: "ok\n", listed: [], exitCode: 0 }); + }); + + test.concurrent("running a script reads no directory listing", async () => { + using dir = tempDir("no-listing-run", { + "a/b/c/script.js": `console.log(import.meta.path === process.argv[1] ? "same" : import.meta.path);`, + "a/package.json": JSON.stringify({ name: "a" }), + }); + const script = join(dir, "a/b/c/script.js"); + const { stdout, exitCode } = await run([bunExe(), script], join(dir, "a/b"), fsLog); + expect({ ...splitFsLog(stdout), exitCode }).toEqual({ output: "same\n", listed: [], exitCode: 0 }); + }); + + test.concurrent("a relative import lists only the importing directory", async () => { + using dir = tempDir("one-listing-import", { + "a/b/c/index.js": `import { dep } from "./dep"; console.log(dep);`, + "a/b/c/dep.js": `export const dep = "dep";`, + }); + const cwd = join(dir, "a/b/c"); + const { stdout, exitCode } = await run([bunExe(), "index.js"], cwd, fsLog); + expect({ ...splitFsLog(stdout), exitCode }).toEqual({ output: "dep\n", listed: [cwd + "/"], exitCode: 0 }); + }); +}); + +describe("DirInfo markers without a listing", () => { + test.concurrent("a bare import finds node_modules in the cwd", async () => { + using dir = tempDir("markers-node-modules", { + "index.js": `import { v } from "pkg"; console.log(v);`, + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", main: "main.js" }), + "node_modules/pkg/main.js": `export const v = "from pkg";`, + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.js"], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "from pkg\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("tsconfig paths in the cwd apply", async () => { + using dir = tempDir("markers-tsconfig", { + "index.ts": `import { v } from "@app/dep"; console.log(v);`, + "src/dep.ts": `export const v = "via paths";`, + "tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@app/*": ["./src/*"] } } }), + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.ts"], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "via paths\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("package.json imports in a parent apply to the entry point", async () => { + using dir = tempDir("markers-package-imports", { + "package.json": JSON.stringify({ name: "p", imports: { "#dep": "./src/dep.js" } }), + "src/dep.js": `export const v = "hash import";`, + "src/index.js": `import { v } from "#dep"; console.log(v);`, + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "src/index.js"], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "hash import\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("tsconfig jsx settings in a parent apply to the entry point", async () => { + using dir = tempDir("markers-tsconfig-jsx", { + "tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react", jsxFactory: "h" } }), + "src/app.jsx": `globalThis.h = (tag) => "h:" + tag;\nconsole.log(
);`, + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "src/app.jsx"], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "h:div\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("the default .env files of the cwd load", async () => { + using dir = tempDir("markers-dotenv", { + ".env": "FROM_ENV=1\n", + ".env.local": "FROM_LOCAL=2\n", + ".env.production": "FROM_PROD=3\n", + }); + const { stdout, stderr, exitCode } = await run( + [bunExe(), "-e", "console.log(process.env.FROM_ENV, process.env.FROM_LOCAL, process.env.FROM_PROD)"], + String(dir), + ); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1 2 undefined\n", stderr: "", exitCode: 0 }); + }); +}); + +describe("entry point resolved by stat", () => { + test.concurrent("a symlinked entry file runs as its real path", async () => { + using dir = tempDir("entry-symlink-file", { + "real/script.js": `console.log(import.meta.path);`, + }); + symlinkSync(join(dir, "real/script.js"), join(dir, "link.js")); + const { stdout, stderr, exitCode } = await run([bunExe(), join(dir, "link.js")], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: join(dir, "real/script.js") + "\n", + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("an entry file in a symlinked directory runs as its real path", async () => { + using dir = tempDir("entry-symlink-dir", { + "real/script.js": `console.log(import.meta.path);`, + }); + symlinkSync(join(dir, "real"), join(dir, "link"), "dir"); + const { stdout, stderr, exitCode } = await run([bunExe(), join(dir, "link/script.js")], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: join(dir, "real/script.js") + "\n", + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("the entry point can import its own directory", async () => { + using dir = tempDir("entry-own-dir", { + "app/main.js": `const m = await import("./"); console.log(m.v);`, + "app/index.js": `export const v = "index";`, + }); + const { stdout, stderr, exitCode } = await run([bunExe(), join(dir, "app/main.js")], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "index\n", stderr: "", exitCode: 0 }); + }); + + // Root bypasses permission checks. On Linux we drop to `nobody` with runuser. + const nobody = (() => { + if (isWindows) return null; + try { + const line = readFileSync("/etc/passwd", "utf8") + .split("\n") + .find(l => l.startsWith("nobody:")); + return line ? line.split(":")[2] : null; + } catch { + return null; + } + })(); + const canUseRunuser = isLinux && isRoot && !!Bun.which("runuser") && nobody !== null; + const canTriggerEACCES = !isWindows && (!isRoot || canUseRunuser); + + test.skipIf(!canTriggerEACCES)("an entry file in a directory that cannot be listed runs", async () => { + using dir = tempDir("entry-unlistable-dir", { + "locked/script.js": `console.log("RAN", import.meta.path);`, + "work/.keep": "", + }); + const root = String(dir); + const script = join(root, "locked/script.js"); + let cmd = [bunExe(), script]; + if (canUseRunuser) { + for (const p of [root, join(root, "locked"), script, join(root, "work")]) chmodSync(p, 0o777); + cmd = ["runuser", "-u", "nobody", "--", ...cmd]; + } + // Searchable, not listable: `stat` of a file inside works, `readdir` fails. + chmodSync(join(root, "locked"), 0o711); + try { + const { stdout, stderr, exitCode } = await run(cmd, join(root, "work"), { HOME: join(root, "work") }); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: `RAN ${script}\n`, stderr: "", exitCode: 0 }); + } finally { + chmodSync(join(root, "locked"), 0o755); + } + }); +}); From 4f1515d31984acea43fb592f27491fb44f1792b7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:04:42 +0000 Subject: [PATCH 02/15] install: probe root_dir markers by joined path, node_modules at init root_dir has no trailing separator, so a concatenation pointed next to the project directory instead of inside it. The security scanner asked whether node_modules existed after the install had created it; the answer is taken once when the manager is created, as the listing was. --- src/install/PackageManager.rs | 18 ++++++++++++++++++ src/install/PackageManager/security_scanner.rs | 6 +----- src/runtime/cli/pm_view_command.rs | 17 +++++++++++++---- test/cli/install/bun-pack.test.ts | 3 +-- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index c486703c4efd..1066299b83bb 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -459,6 +459,9 @@ pub struct PackageManager { pub(crate) cache_directory_path: ZBox, // owned; process lifetime via the leaked singleton /// The project directory, interned in `DirnameStore`, with a trailing separator. pub root_dir: &'static [u8], + /// Whether `root_dir` had a `node_modules` entry when the manager was created, before + /// any install created one. + pub root_dir_has_node_modules: bool, // allocator dropped per §Allocators (was `bun.default_allocator`). For the // handful of sites that allocated AST nodes via `Expr.allocate(manager.allocator, …)` // — i.e. nodes that must outlive `Expr.Data.Store.reset()` across workspace @@ -1556,6 +1559,16 @@ fn http_thread_on_init_error(err: http::InitError, opts: &http::http_thread::Ini // allocate / get singleton // ────────────────────────────────────────────────────────────────────────── +fn root_dir_has_node_modules(root_dir: &[u8]) -> bool { + let mut buf = bun_paths::path_buffer_pool::get(); + resolve_path::join_abs_string_buf_z_checked::( + root_dir, + &mut buf[..], + &[b"node_modules".as_slice()], + ) + .is_some_and(|path| bun_sys::lstat(path).is_ok()) +} + fn allocate_package_manager( options: Options, log: *mut bun_ast::Log, @@ -1617,6 +1630,10 @@ fn allocate_package_manager( wr!(patch_task_fifo, PatchTaskFifo::init()); wr!(log, log); wr!(root_dir, root_dir); + wr!( + root_dir_has_node_modules, + root_dir_has_node_modules(root_dir) + ); wr!(ast_arena, bun_alloc::Arena::new()); wr!(env, Some(bun_ptr::BackRef::new_mut(env))); wr!( @@ -3131,6 +3148,7 @@ fn init_with_runtime_once( // directory, and `parse_with_json` makes them relative to the working directory. if manager.lockfile.packages.len() > 0 { manager.root_dir = FileSystem::get().dirname_store().append(project_dir)?; + manager.root_dir_has_node_modules = root_dir_has_node_modules(project_dir); // Auto-install ignores an override that points away from the registry, so it also drops // what such an override resolved to in the lockfile. use crate::lockfile_real::package::PackageColumns as _; diff --git a/src/install/PackageManager/security_scanner.rs b/src/install/PackageManager/security_scanner.rs index 6e07ebe973ac..e307005fbfd6 100644 --- a/src/install/PackageManager/security_scanner.rs +++ b/src/install/PackageManager/security_scanner.rs @@ -215,11 +215,7 @@ pub(crate) fn perform_security_scan_after_resolution( return Ok(None); } - let has_node_modules = bun_sys::lstatat( - bun_sys::Fd::cwd(), - &bun_core::ZBox::from_bytes(&[manager.root_dir, b"node_modules".as_slice()].concat()), - ) - .is_ok(); + let has_node_modules = manager.root_dir_has_node_modules; // A command that names the packages it adds or updates scans only those packages and `seeds`. // The install that wrote the lockfile and `node_modules` already scanned everything else. let scan_all = manager.update_requests.is_empty() diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index f800adab1e73..54a9c5c918b5 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -70,10 +70,19 @@ pub(crate) fn view( // Try our best to get the package.json name they meant 'from_package_json: { - let package_json_path = bun_core::ZBox::from_bytes( - &[manager.root_dir, b"package.json".as_slice()].concat(), - ); - let str = match bun_sys::File::open(&package_json_path, bun_sys::O::RDONLY, 0) + let mut path_buf = bun_paths::path_buffer_pool::get(); + let Some(package_json_path) = + bun_paths::resolve_path::join_abs_string_buf_z_checked::< + bun_paths::platform::Auto, + >( + manager.root_dir, + &mut path_buf[..], + &[b"package.json".as_slice()], + ) + else { + break 'from_package_json; + }; + let str = match bun_sys::File::open(package_json_path, bun_sys::O::RDONLY, 0) .and_then(|file| file.read_to_end()) { Ok(str) => str, diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index d5d02e006ba0..bc868cfdde8b 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -5759,8 +5759,7 @@ long syscall(long nr, ...) { test.concurrent.each([ // One row for each loop that reads a directory. `dir` is the directory whose first read fails. { walk: "the default walk, a subdirectory", packageJson: {}, files: { "lib/index.js": index }, dir: "lib" }, - // The resolver reads the package root before pack does: pack is the second reader. - { walk: "the default walk, the package root", packageJson: {}, files: { "index.js": index }, dir: "", reader: 2 }, + { walk: "the default walk, the package root", packageJson: {}, files: { "index.js": index }, dir: "" }, { walk: '"files" with a directory', packageJson: { files: ["lib"] }, From 69f45aae80a58b45f209aa7b7871091f291af499 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:09:33 +0000 Subject: [PATCH 03/15] resolver: shorter comments --- src/bundler/transpiler.rs | 6 ++---- src/dotenv/env_loader.rs | 7 ++----- src/install/PackageManager.rs | 5 ++--- src/jsc/VirtualMachine.rs | 2 -- src/resolver/dir_info.rs | 13 ++++--------- src/resolver/lib.rs | 22 ++++++---------------- src/resolver/resolver.rs | 21 ++++----------------- src/runtime/cli/test/Scanner.rs | 10 ++-------- 8 files changed, 22 insertions(+), 64 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 9b2b2e498cdb..15ea73ace381 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -632,10 +632,8 @@ impl<'a> Transpiler<'a> { self.resolver.opts.set_production(true); } - // The project root's tsconfig decides the JSX pragma. If the - // cwd (or a parent) is unreadable, readDirInfo returns null; - // skip .env file loading in that case, but process env vars - // were already loaded above. + // If the cwd (or a parent) is unreadable, readDirInfo returns + // null; skip .env file loading, process env vars are loaded. let top_level_dir = Fs::FileSystem::get().top_level_dir(); let dir_info = match self.resolver.read_dir_info(top_level_dir) { Ok(Some(d)) => d, diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 9ae4801d899c..5512d0fe7d03 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -603,9 +603,7 @@ impl Loader { Parser::parse_bytes::(str, &mut self.map, &mut value_buffer) } - /// Load the `--env-file` list, or else the default `.env*` files of the - /// working directory. A default file that does not exist is one failed - /// `open`, not an error. + /// Load the `--env-file` list, or else the default `.env*` files of the cwd. pub fn load( &mut self, env_files: &[&[u8]], @@ -756,8 +754,7 @@ impl Loader { Output::flush(); } - /// Returns whether a file of that name exists. Only a file that exists - /// counts as loaded, even when it cannot be read. + /// Returns whether a file of that name exists. pub(crate) fn load_env_file( &mut self, dir: bun_sys::Fd, diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 1066299b83bb..032a6c7c7c83 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -457,10 +457,9 @@ bun_output::declare_scope!(PackageManager, hidden); pub struct PackageManager { pub(crate) cache_directory: Option, pub(crate) cache_directory_path: ZBox, // owned; process lifetime via the leaked singleton - /// The project directory, interned in `DirnameStore`, with a trailing separator. + /// The project directory, interned in `DirnameStore`. pub root_dir: &'static [u8], - /// Whether `root_dir` had a `node_modules` entry when the manager was created, before - /// any install created one. + /// `root_dir` had a `node_modules` entry when the manager was created. pub root_dir_has_node_modules: bool, // allocator dropped per §Allocators (was `bun.default_allocator`). For the // handful of sites that allocated AST nodes via `Expr.allocate(manager.allocator, …)` diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5e5219f09126..eb06e5423336 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5557,8 +5557,6 @@ impl VirtualMachine { bun_ast::ImportKind::Require }; - // The entry point is a file the CLI already found: one `stat` resolves - // it. Anything else goes through the full resolver below. let mut entry_point = None; if source == MAIN_FILE_NAME && bun_paths::is_absolute(normalized_specifier) { entry_point = match self diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index 6f5b05a039dc..c84a53d70111 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -225,7 +225,6 @@ impl DirInfo { pub fn get_file_descriptor(&self) -> Fd { if FeatureFlags::STORE_FILE_DESCRIPTORS { - // Scalar field read, no lock needed. return fs::FileSystem::instance() .fs .entries @@ -235,9 +234,7 @@ impl DirInfo { Fd::INVALID } - /// Is this directory's slot a listing (or a read error), as opposed to the - /// placeholder the `DirInfo` walk leaves for a directory it only opened? - /// Caller holds `entries_mutex`. + /// False for the `Unlisted` placeholder. Caller holds `entries_mutex`. pub(crate) fn is_listed_locked(&self) -> bool { !matches!( fs::FileSystem::instance().fs.entries.at_index(self.entries), @@ -282,9 +279,8 @@ impl DirInfo { .get(query) } - /// [`get_entry`](Self::get_entry) that does not list a directory the - /// walk only opened: the outer `None` is that directory, the inner one a - /// name that is not in the listing. + /// [`get_entry`](Self::get_entry) without listing an `Unlisted` directory: + /// the outer `None` is that directory, the inner one a missing name. pub(crate) fn get_entry_if_listed( &self, generation: Generation, @@ -302,8 +298,7 @@ impl DirInfo { ) } - /// The listing as cached, with no generation check, read now when the - /// `DirInfo` walk only opened this directory. Caller holds `entries_mutex`. + /// The listing as cached, read now if `Unlisted`. Caller holds `entries_mutex`. pub fn get_entries_listed_locked(&self) -> Option<&'static fs::DirEntry> { self.get_entries_ref_locked(0) } diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 870692f6c96b..a1c8ee7ac7c9 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -740,9 +740,8 @@ pub mod fs { pub enum EntriesOption { Entries(&'static mut DirEntry), Err(dir_entry::Err), - /// A directory the resolver opened for its `DirInfo` markers and did - /// not list. `entries_at_locked` and `read_directory_with_iterator` - /// replace it with `Entries` the first time a caller needs the names. + /// Opened for its `DirInfo` markers, not listed. `entries_at_locked` and + /// `read_directory_with_iterator` list it on the first request for names. Unlisted(Unlisted), } @@ -758,8 +757,7 @@ pub mod fs { // Payload is `&'static mut DirEntry`; auto-deref coerces to `&DirEntry` / `&mut DirEntry`. bun_core::enum_unwrap!(pub EntriesOption, Entries => fn entries / entries_mut -> DirEntry); - /// The listing, or the error that reading the directory gave. Only - /// valid on a slot that `read_directory` or `entries_at_locked` returned. + /// The listing, or the error that reading the directory gave. pub fn try_entries(&self) -> crate::CrateResult<&DirEntry> { match self { EntriesOption::Entries(entries) => Ok(&**entries), @@ -1113,9 +1111,7 @@ pub mod fs { ) } - /// [`read_directory_with_iterator`](Self::read_directory_with_iterator) - /// for a caller that already holds `entries_mutex`. Never returns an - /// `Unlisted` slot. + /// Caller holds `entries_mutex`. Never returns an `Unlisted` slot. pub(crate) fn read_directory_locked( &mut self, dir_maybe_trail_slash: &[u8], @@ -1174,8 +1170,6 @@ pub mod fs { // Close the handle on every exit path. Use // scopeguard so close happens even if `readdir`/`put` early-return with `?`. - // A placeholder that already keeps the directory open keeps that - // fd; the handle opened here for the read is closed. let keeps_unlisted_fd = unlisted.is_some_and(|u| u.fd.is_valid()); let should_close_handle = !had_handle && (!store_fd || keeps_unlisted_fd || self.need_to_close_files()); @@ -1553,9 +1547,7 @@ pub mod fs { Err(err) => return self.read_directory_error(unlisted.dir, err).ok(), }; new_entry.fd = unlisted.fd; - // SAFETY: slot is exclusively owned under `entries_mutex`; the - // `DirEntry` is leaked for the life of the process like every - // other listing in this map. + // SAFETY: slot is exclusively owned under `entries_mutex`. unsafe { *result_ptr = EntriesOption::Entries(&mut *bun_core::heap::into_raw( Box::new(new_entry), @@ -1601,9 +1593,7 @@ pub mod fs { Some(unsafe { &*result_ptr }) } - /// Kind of `name` inside the directory at `dir` (open as `fd`), following - /// symlinks. `None` for a missing or dangling entry, or when the - /// directory could not be opened. + /// Kind of `name` in the directory open as `fd`, following symlinks. pub(crate) fn kind_at(&self, dir: &[u8], fd: Fd, name: &'static ZStr) -> Option { if !fd.is_valid() { return None; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 07b171ee8a31..b431b3aaf868 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2022,10 +2022,7 @@ impl<'a> Resolver<'a> { // Probe the listing in one `entries_mutex` critical section: a // concurrent resolver at a newer generation rewrites this `DirEntry`'s // map in place under that lock. The entry pointer stays valid after - // unlock (EntryStore-owned). A directory the walk only opened (the - // entry point's) has no listing; `resolve_entry_point` already - // resolved the file's own symlink, so only a symlinked directory is - // left to apply. + // unlock (EntryStore-owned). let query = dir.get_entry_if_listed(self.generation, name.filename); if query.is_none() { if !path.is_symlink @@ -3860,8 +3857,6 @@ impl<'a> Resolver<'a> { } }; - // A package directory is listed: `load_as_file_or_directory` probes - // its entries right after this. let dir_entries_option: *const Fs::file_system::real_fs::EntriesOption = rfs!().read_directory(dir_path, Some(open_dir), self.generation, self.store_fd)?; // SAFETY: `read_directory` returned a live slot in `rfs.entries` (or the @@ -4980,10 +4975,8 @@ impl<'a> Resolver<'a> { let parent_dir_ptr = unsafe { DirInfo::slot_ptr_at(top_parent.index) } .map(|p| unsafe { DirInfoRef::from_raw(p) }); - // The markers `dir_info_uncached` asks for are fixed names, so a - // directory is not listed here. A package directory is the - // exception: the resolve that follows probes its entries anyway, - // and the open fd makes that listing free of extra syscalls. + // Only a package directory is listed here: the resolve that follows + // probes its entries anyway. let in_node_modules = open_dir.is_valid() && (strings::without_trailing_slash(bun_paths::basename(dir_path)) == b"node_modules" @@ -5018,7 +5011,6 @@ impl<'a> Resolver<'a> { cached_dir_entry_result = rfs!().entries.get_or_put(dir_path).expect("unreachable"); } else if !has_stale_listing { - // A stale listing stays until a reader asks for it again. rfs!().entries.put( &mut cached_dir_entry_result, Fs::file_system::real_fs::EntriesOption::Unlisted( @@ -6365,11 +6357,8 @@ impl<'a> Resolver<'a> { let result = _result; // SAFETY: `listing` is the payload of a live `rfs.entries` slot (caller - // contract), a separate process-lifetime allocation, read under - // `entries_mutex`. + // contract), read under `entries_mutex`. let listing = listing.map(|p| bun_ptr::BackRef::new(unsafe { &*p })); - // Kind of a marker file in this directory: from the cached listing - // when there is one, else one `stat` on the open directory. macro_rules! marker_kind { ($name:literal) => { match listing { @@ -6519,8 +6508,6 @@ impl<'a> Resolver<'a> { // The only caller that reaches this with `parent` set // (`dir_info_cached_miss`) already holds `entries_mutex`, and that // mutex is non-recursive, so go through the `_locked` accessor. - // A parent the walk only opened is asked with one `lstat` - // instead of a listing. let found: Option<(&'static [u8], Option)> = if parent_.is_listed_locked() { parent_ diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index c869748477af..dbcc6771f93b 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -125,9 +125,6 @@ impl<'a> Scanner<'a> { return Err(ScanError::DoesNotExist); }; - // The root is read without the scanner as its iterator, then visited - // in name order below: hash-map iteration order is not stable, and - // `readdir` order is not either. let fs_ptr = self.fs; // SAFETY: borrows only the `fs` field; re-entrant access is serialised by `RealFS.entries_mutex`. let root = unsafe { &mut (*fs_ptr).fs } @@ -158,11 +155,8 @@ impl<'a> Scanner<'a> { } if let EntriesOption::Entries(entries) = root { - // Collect first so `self.next(…)` doesn't overlap the - // `entries.data` borrow. Sort by (lowercased) base name so - // test-file discovery order is deterministic — - // regression/issue/26851 relies on `a_*.test` running before - // `b_*.test` under `--bail`. + // Name order: regression/issue/26851 relies on `a_*.test` running + // before `b_*.test` under `--bail`. let mut entry_ptrs: Vec<*mut fs::Entry> = { let _entries_lock = self.fs().fs.entries_mutex.lock_guard(); entries.data.values().copied().collect() From 7d9fc27156b3a0a67de1ae0c274e51688de90de2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:37:31 +0000 Subject: [PATCH 04/15] resolver: list only package paths eagerly, normalize the entry path, count listings The walk lists a directory under node_modules only when the resolve that follows probes its entries (node_modules, a scope, a package root), not a dot directory such as .bin or .bun. A listing read with a caller's handle keeps the caller's spelling of the directory, and the bin completion writes the separator it reserved room for. resolve_entry_point normalizes the path instead of refusing any path that contains two dots. A compiled executable that loads no tsconfig skips the DirInfo walk before the .env files. getCounters().readdir counts the listings the process read, so the tests run on every build. --- src/bundler/transpiler.rs | 21 ++-- src/install/PackageManager.rs | 2 +- src/jsc/Counters.bind.ts | 2 +- src/jsc/Counters.rs | 1 + src/resolver/lib.rs | 17 ++- src/resolver/resolver.rs | 33 +++--- src/runtime/cli/run_command.rs | 3 +- test/cli/bun.test.ts | 15 +++ .../bun/resolve/resolver-dir-listing.test.ts | 105 ++++++++++++++---- 9 files changed, 148 insertions(+), 51 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 15ea73ace381..af116dc6f415 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -632,16 +632,19 @@ impl<'a> Transpiler<'a> { self.resolver.opts.set_production(true); } - // If the cwd (or a parent) is unreadable, readDirInfo returns - // null; skip .env file loading, process env vars are loaded. - let top_level_dir = Fs::FileSystem::get().top_level_dir(); - let dir_info = match self.resolver.read_dir_info(top_level_dir) { - Ok(Some(d)) => d, - _ => return Ok(()), - }; + // The walk serves the cwd's tsconfig. If the cwd (or a parent) + // is unreadable, readDirInfo returns null; skip .env file + // loading, process env vars are loaded. + if self.resolver.opts.load_tsconfig_json { + let top_level_dir = Fs::FileSystem::get().top_level_dir(); + let dir_info = match self.resolver.read_dir_info(top_level_dir) { + Ok(Some(d)) => d, + _ => return Ok(()), + }; - if let Some(tsconfig) = self.resolver.tsconfig_json(&dir_info) { - merge_tsconfig_jsx_into(&tsconfig, &mut self.options.jsx); + if let Some(tsconfig) = self.resolver.tsconfig_json(&dir_info) { + merge_tsconfig_jsx_into(&tsconfig, &mut self.options.jsx); + } } // `Env.files: Box<[Box<[u8]>]>` but `Loader::load` diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 032a6c7c7c83..dc2489f99a17 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -457,7 +457,7 @@ bun_output::declare_scope!(PackageManager, hidden); pub struct PackageManager { pub(crate) cache_directory: Option, pub(crate) cache_directory_path: ZBox, // owned; process lifetime via the leaked singleton - /// The project directory, interned in `DirnameStore`. + /// The project directory, interned in `DirnameStore`, without a trailing separator. pub root_dir: &'static [u8], /// `root_dir` had a `node_modules` entry when the manager was created. pub root_dir_has_node_modules: bool, diff --git a/src/jsc/Counters.bind.ts b/src/jsc/Counters.bind.ts index c7b29110b2bf..e113419ec16c 100644 --- a/src/jsc/Counters.bind.ts +++ b/src/jsc/Counters.bind.ts @@ -2,6 +2,6 @@ import * as b from "../codegen/bindgen-lib.ts"; export default b.module({ rustPath: "crate::counters" }); -export const CountersObject = b.dictionary({ spawnSync_blocking: b.i32, spawn_memfd: b.i32 }); +export const CountersObject = b.dictionary({ spawnSync_blocking: b.i32, spawn_memfd: b.i32, readdir: b.i32 }); export const createCountersObject = b.fn({ returns: CountersObject, capability: "heap" }); diff --git a/src/jsc/Counters.rs b/src/jsc/Counters.rs index e325bb352723..3a458df6e63a 100644 --- a/src/jsc/Counters.rs +++ b/src/jsc/Counters.rs @@ -24,6 +24,7 @@ pub(crate) fn create_counters_object(heap: &JsHeap<'_>) -> CountersObject { CountersObject { spawn_sync_blocking: counters.spawn_sync_blocking, spawn_memfd: counters.spawn_memfd, + readdir: bun_resolver::fs::readdir_count().min(i32::MAX as u32) as i32, } } diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index a1c8ee7ac7c9..346cb3c30a9a 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -733,6 +733,14 @@ pub mod fs { }) } + static READDIR_COUNT: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); + + /// How many directory listings this process has read into the cache + /// (`bun:internal-for-testing` `getCounters().readdir`). + pub fn readdir_count() -> u32 { + READDIR_COUNT.load(core::sync::atomic::Ordering::Relaxed) + } + // Modeled as // an unbounded `&mut DirEntry` so resolver match arms (`Entries(entries) => // entries.dir`) auto-deref. The backing storage is the BSSMap singleton; @@ -747,7 +755,7 @@ pub mod fs { #[derive(Clone, Copy)] pub struct Unlisted { - /// Interned in `DirnameStore`, with a trailing separator. + /// Interned in `DirnameStore`. pub dir: &'static [u8], /// The open directory when the resolver keeps fds (`store_fd`). pub fd: Fd, @@ -1016,6 +1024,7 @@ pub mod fs { handle: Fd, iterator: &mut I, ) -> crate::CrateResult { + READDIR_COUNT.fetch_add(1, core::sync::atomic::Ordering::Relaxed); let mut iter = bun_sys::iterate_dir(handle); let mut dir = DirEntry::init(dir_, generation); @@ -1189,12 +1198,8 @@ pub mod fs { unsafe { (*existing).dir } } else if let Some(unlisted) = unlisted { unlisted.dir - } else if !had_handle { - DirnameStore::instance().append_slice(dir_maybe_trail_slash)? } else { - // Intern into DirnameStore so the cache entry never dangles — - // `append_slice` is a bump-pointer copy, cost is bounded. - DirnameStore::instance().append_slice(dir)? + DirnameStore::instance().append_slice(dir_maybe_trail_slash)? }; // Cache miss: read the directory entries diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index b431b3aaf868..cd1e462ae742 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1848,8 +1848,9 @@ impl<'a> Resolver<'a> { /// Resolve the program's entry point, an absolute path to a file, with one /// `stat` of that path (node's `tryFile`) instead of a listing of its - /// directory. `NotFound` when `path` is not a regular file, so the caller - /// falls back to [`resolve_and_auto_install`](Self::resolve_and_auto_install). + /// directory. `NotFound` when `path` is a directory or does not exist, so + /// the caller falls back to + /// [`resolve_and_auto_install`](Self::resolve_and_auto_install). pub fn resolve_entry_point( &mut self, path: &[u8], @@ -1857,13 +1858,14 @@ impl<'a> Resolver<'a> { ) -> ResultUnion<'static> { if self.standalone_module_graph.is_some() || !bun_paths::is_absolute(path) - || strings::index_of(path, b"..").is_some() || strings::index_of_char(path, 0).is_some() { return ResultUnion::NotFound; } - let mut normalizer = ResolvePath::PosixToWinNormalizer::default(); - let abs_path = normalizer.resolve(self.fs_ref().top_level_dir(), path); + let mut buf = bun_paths::path_buffer_pool::get(); + let Some(abs_path) = self.fs_ref().abs_buf_checked(&[path], &mut buf[..]) else { + return ResultUnion::NotFound; + }; let name = Fs::PathName::init(abs_path); if name.dir.is_empty() || name.filename.is_empty() { return ResultUnion::NotFound; @@ -4975,14 +4977,17 @@ impl<'a> Resolver<'a> { let parent_dir_ptr = unsafe { DirInfo::slot_ptr_at(top_parent.index) } .map(|p| unsafe { DirInfoRef::from_raw(p) }); - // Only a package directory is listed here: the resolve that follows - // probes its entries anyway. - let in_node_modules = open_dir.is_valid() - && (strings::without_trailing_slash(bun_paths::basename(dir_path)) - == b"node_modules" - || parent_dir_ptr.is_some_and(|parent| { - parent.is_node_modules() || parent.is_inside_node_modules() - })); + // A package path is listed here: `load_as_file_or_directory` probes + // the entries of `node_modules`, a scope and a package root right + // after this. A dot directory (`.bin`, `.bun`, `.pnpm`) is not. + let is_package_path = open_dir.is_valid() && { + let base = strings::without_trailing_slash(bun_paths::basename(dir_path)); + !base.starts_with(b".") + && (base == b"node_modules" + || parent_dir_ptr.is_some_and(|parent| { + parent.is_node_modules() || parent.is_inside_node_modules() + })) + }; let mut listing: Option<*const Fs::file_system::DirEntry> = None; let mut has_stale_listing = false; @@ -4997,7 +5002,7 @@ impl<'a> Resolver<'a> { } if listing.is_none() { - if in_node_modules { + if is_package_path { let read = rfs!().read_directory_locked( dir_path, Some(open_dir), diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 79fcc3310abb..9c4ee0949659 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -3804,7 +3804,8 @@ impl RunCommand { path_buf[..value.dir.len()].copy_from_slice(value.dir); dir_slice_len = value.dir.len(); if !strings::ends_with_char_or_is_zero_length(value.dir, SEP) { - dir_slice_len = value.dir.len() + 1; + path_buf[dir_slice_len] = SEP; + dir_slice_len += 1; } has_copied = true; } diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index 52d7337143b2..e2e82962eb35 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -185,6 +185,21 @@ describe("bun", () => { expect(stdout.toString()).not.toBeEmpty(); }); + test.skipIf(isWindows)("getcompletes b lists the executables in node_modules/.bin", () => { + using dir = tempDir("getcompletes-bin", { + "package.json": JSON.stringify({ name: "test" }), + "node_modules/.bin/mybin": "#!/bin/sh\necho hi\n", + }); + fs.chmodSync(join(String(dir), "node_modules/.bin/mybin"), 0o755); + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), "getcompletes", "b"], + env: bunEnv, + cwd: String(dir), + }); + expect(stdout.toString().split("\n").filter(Boolean)).toContain("mybin"); + expect(exitCode).toBe(0); + }); + // https://github.com/oven-sh/bun/issues/30086 test("getcompletes keeps scripts whose names start with 'pre'/'post' when no sibling script exists", () => { using dir = tempDir("getcompletes-pre-post", { diff --git a/test/js/bun/resolve/resolver-dir-listing.test.ts b/test/js/bun/resolve/resolver-dir-listing.test.ts index 5e274a8c080a..ab0a4f874d71 100644 --- a/test/js/bun/resolve/resolver-dir-listing.test.ts +++ b/test/js/bun/resolve/resolver-dir-listing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isLinux, isRoot, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isRoot, isWindows, tempDir } from "harness"; import { chmodSync, readFileSync, symlinkSync } from "node:fs"; import { join } from "node:path"; @@ -23,49 +23,116 @@ async function run(cmd: string[], cwd: string, env: Record l.startsWith("listings ")); return { - output: lines.filter(line => !line.startsWith("[fs] ")).join("\n"), - listed: lines - .filter(line => line.startsWith("[fs] readdir(")) - .map(line => line.slice(line.indexOf(", ") + 2, line.indexOf(") = "))), + output: lines.filter(l => !l.startsWith("listings ")).join("\n"), + listings: line === undefined ? -1 : Number(line.slice("listings ".length)), }; } -const fsLog = { BUN_DEBUG_Fs: "1", BUN_DEBUG_QUIET_LOGS: "1" }; - -describe.skipIf(!isDebug)("startup does not list directories", () => { +describe("startup does not list directories", () => { test.concurrent("bun -e reads no directory listing", async () => { using dir = tempDir("no-listing-eval", { "a/b/c/.keep": "", "a/package.json": JSON.stringify({ name: "a" }), "a/b/tsconfig.json": "{}", }); - const { stdout, exitCode } = await run([bunExe(), "-e", "console.log('ok')"], join(dir, "a/b/c"), fsLog); - expect({ ...splitFsLog(stdout), exitCode }).toEqual({ output: "ok\n", listed: [], exitCode: 0 }); + const { stdout, stderr, exitCode } = await run([bunExe(), "-e", countListings], join(dir, "a/b/c")); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ output: "", listings: 0, stderr: "", exitCode: 0 }); }); test.concurrent("running a script reads no directory listing", async () => { using dir = tempDir("no-listing-run", { - "a/b/c/script.js": `console.log(import.meta.path === process.argv[1] ? "same" : import.meta.path);`, + "a/b/c/script.js": `console.log(import.meta.path === process.argv[1] ? "same" : import.meta.path);\n${countListings}`, "a/package.json": JSON.stringify({ name: "a" }), }); const script = join(dir, "a/b/c/script.js"); - const { stdout, exitCode } = await run([bunExe(), script], join(dir, "a/b"), fsLog); - expect({ ...splitFsLog(stdout), exitCode }).toEqual({ output: "same\n", listed: [], exitCode: 0 }); + const { stdout, stderr, exitCode } = await run([bunExe(), script], join(dir, "a/b")); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: "same\n", + listings: 0, + stderr: "", + exitCode: 0, + }); }); test.concurrent("a relative import lists only the importing directory", async () => { using dir = tempDir("one-listing-import", { - "a/b/c/index.js": `import { dep } from "./dep"; console.log(dep);`, + "a/b/c/index.js": `import { dep } from "./dep"; console.log(dep);\n${countListings}`, "a/b/c/dep.js": `export const dep = "dep";`, }); - const cwd = join(dir, "a/b/c"); - const { stdout, exitCode } = await run([bunExe(), "index.js"], cwd, fsLog); - expect({ ...splitFsLog(stdout), exitCode }).toEqual({ output: "dep\n", listed: [cwd + "/"], exitCode: 0 }); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.js"], join(dir, "a/b/c")); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: "dep\n", + listings: 1, + stderr: "", + exitCode: 0, + }); }); + + test.concurrent("a bare import lists node_modules and the package, not .bin", async () => { + using dir = tempDir("one-listing-bare-import", { + "index.js": `import { v } from "pkg"; console.log(v);\n${countListings}`, + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", main: "main.js" }), + "node_modules/pkg/main.js": `export const v = "from pkg";`, + "node_modules/.bin/.keep": "", + "node_modules/other/index.js": "", + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "index.js"], String(dir)); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: "from pkg\n", + listings: 2, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("a directory or file name that contains '..' still avoids the listing", async () => { + using dir = tempDir("no-listing-dotdot", { + "a..b/z..js": `console.log(import.meta.path);\n${countListings}`, + }); + const script = join(dir, "a..b/z..js"); + const { stdout, stderr, exitCode } = await run([bunExe(), script], String(dir)); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: script + "\n", + listings: 0, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("an entry path with a '..' segment runs as its normalized path", async () => { + using dir = tempDir("entry-dotdot-segment", { + "x/y.js": `console.log(import.meta.path);`, + "other/.keep": "", + }); + const { stdout, stderr, exitCode } = await run([bunExe(), join(dir, "other/../x//y.js")], String(dir)); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: join(dir, "x/y.js") + "\n", stderr: "", exitCode: 0 }); + }); + + // Compiling copies the whole executable, which a debug build makes slow. + test.concurrent("a compiled executable reads no directory listing", async () => { + using dir = tempDir("no-listing-compiled", { + "app.js": `console.log("compiled");\n${countListings}`, + "a/b/.keep": "", + }); + const outfile = join(dir, "app" + (isWindows ? ".exe" : "")); + const build = await run([bunExe(), "build", "--compile", "app.js", "--outfile", outfile], String(dir)); + expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const { stdout, stderr, exitCode } = await run([outfile], join(dir, "a/b")); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: "compiled\n", + listings: 0, + stderr: "", + exitCode: 0, + }); + }, 120_000); }); describe("DirInfo markers without a listing", () => { From f44b0c13080bbe13aa5417a97f088d92f5dd7824 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:46:32 +0000 Subject: [PATCH 05/15] [autofix.ci] apply automated fixes --- .../bun/resolve/resolver-dir-listing.test.ts | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/test/js/bun/resolve/resolver-dir-listing.test.ts b/test/js/bun/resolve/resolver-dir-listing.test.ts index ab0a4f874d71..8e265fe9cf9a 100644 --- a/test/js/bun/resolve/resolver-dir-listing.test.ts +++ b/test/js/bun/resolve/resolver-dir-listing.test.ts @@ -44,7 +44,12 @@ describe("startup does not list directories", () => { "a/b/tsconfig.json": "{}", }); const { stdout, stderr, exitCode } = await run([bunExe(), "-e", countListings], join(dir, "a/b/c")); - expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ output: "", listings: 0, stderr: "", exitCode: 0 }); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: "", + listings: 0, + stderr: "", + exitCode: 0, + }); }); test.concurrent("running a script reads no directory listing", async () => { @@ -117,22 +122,26 @@ describe("startup does not list directories", () => { }); // Compiling copies the whole executable, which a debug build makes slow. - test.concurrent("a compiled executable reads no directory listing", async () => { - using dir = tempDir("no-listing-compiled", { - "app.js": `console.log("compiled");\n${countListings}`, - "a/b/.keep": "", - }); - const outfile = join(dir, "app" + (isWindows ? ".exe" : "")); - const build = await run([bunExe(), "build", "--compile", "app.js", "--outfile", outfile], String(dir)); - expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); - const { stdout, stderr, exitCode } = await run([outfile], join(dir, "a/b")); - expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ - output: "compiled\n", - listings: 0, - stderr: "", - exitCode: 0, - }); - }, 120_000); + test.concurrent( + "a compiled executable reads no directory listing", + async () => { + using dir = tempDir("no-listing-compiled", { + "app.js": `console.log("compiled");\n${countListings}`, + "a/b/.keep": "", + }); + const outfile = join(dir, "app" + (isWindows ? ".exe" : "")); + const build = await run([bunExe(), "build", "--compile", "app.js", "--outfile", outfile], String(dir)); + expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const { stdout, stderr, exitCode } = await run([outfile], join(dir, "a/b")); + expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ + output: "compiled\n", + listings: 0, + stderr: "", + exitCode: 0, + }); + }, + 120_000, + ); }); describe("DirInfo markers without a listing", () => { From 122f17c8065ddd4c8d78617ba94628cdd2ab9c59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:57:39 +0000 Subject: [PATCH 06/15] transpiler: keep the startup DirInfo walk for every run mode A compiled executable's Bun.build shares the directory cache with the runtime. The startup walk records the cwd without its tsconfig when the executable does not autoload one, and a trimmed executable relies on that record. --- src/bundler/transpiler.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index af116dc6f415..15ea73ace381 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -632,19 +632,16 @@ impl<'a> Transpiler<'a> { self.resolver.opts.set_production(true); } - // The walk serves the cwd's tsconfig. If the cwd (or a parent) - // is unreadable, readDirInfo returns null; skip .env file - // loading, process env vars are loaded. - if self.resolver.opts.load_tsconfig_json { - let top_level_dir = Fs::FileSystem::get().top_level_dir(); - let dir_info = match self.resolver.read_dir_info(top_level_dir) { - Ok(Some(d)) => d, - _ => return Ok(()), - }; + // If the cwd (or a parent) is unreadable, readDirInfo returns + // null; skip .env file loading, process env vars are loaded. + let top_level_dir = Fs::FileSystem::get().top_level_dir(); + let dir_info = match self.resolver.read_dir_info(top_level_dir) { + Ok(Some(d)) => d, + _ => return Ok(()), + }; - if let Some(tsconfig) = self.resolver.tsconfig_json(&dir_info) { - merge_tsconfig_jsx_into(&tsconfig, &mut self.options.jsx); - } + if let Some(tsconfig) = self.resolver.tsconfig_json(&dir_info) { + merge_tsconfig_jsx_into(&tsconfig, &mut self.options.jsx); } // `Env.files: Box<[Box<[u8]>]>` but `Loader::load` From 4dcac78b0cbfafcbe6bc837051f8c102a19fb5a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:18:52 +0000 Subject: [PATCH 07/15] resolver: drop the listing accessors nothing uses --- src/resolver/fs.rs | 5 ----- src/resolver/lib.rs | 12 ------------ 2 files changed, 17 deletions(-) diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index be870b2393c0..e0e3e7abed8c 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -570,11 +570,6 @@ impl DirEntry { Some(EntryLookup { entry: result_ptr }) } - /// True if a cached entry exists for the given already-lowercase name. - pub fn has_comptime_query(&self, query_lower: &'static [u8]) -> bool { - Self::debug_assert_entries_mutex_held(); - self.data.contains_key(query_lower) - } } // `data` drops itself and `dir` is interned in DirnameStore (see the comment diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 346cb3c30a9a..65e0ee4de4fe 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -443,18 +443,6 @@ pub mod fs { RealFS::get_default_temp_dir() } - /// Returns the cached - /// `*EntriesOption` slot owned by the resolver's BSSMap singleton - /// (process-lifetime). - #[inline] - pub fn read_directory( - &mut self, - dir: &[u8], - generation: Generation, - store_fd: bool, - ) -> crate::CrateResult<&'static EntriesOption> { - self.fs.read_directory(dir, None, generation, store_fd) - } } // ── PathName / Path ────────────────────────────────────────────────── From 7328e65e4320e33db9cead31fb52335e5124fe48 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:20:22 +0000 Subject: [PATCH 08/15] [autofix.ci] apply automated fixes --- src/resolver/fs.rs | 1 - src/resolver/lib.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/resolver/fs.rs b/src/resolver/fs.rs index e0e3e7abed8c..53df5d5a5872 100644 --- a/src/resolver/fs.rs +++ b/src/resolver/fs.rs @@ -569,7 +569,6 @@ impl DirEntry { let &result_ptr = self.data.get(query_lower)?; Some(EntryLookup { entry: result_ptr }) } - } // `data` drops itself and `dir` is interned in DirnameStore (see the comment diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 65e0ee4de4fe..549dabaa1946 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -442,7 +442,6 @@ pub mod fs { pub fn get_default_temp_dir() -> &'static [u8] { RealFS::get_default_temp_dir() } - } // ── PathName / Path ────────────────────────────────────────────────── From 1a93fc4543fbce4f9c270a3d5135af1c5fc8f522 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:46:22 +0000 Subject: [PATCH 09/15] resolver: close the placeholder fd on a failed listing, lock the fd read, keep the on-disk case of the entry An opaque ancestor keeps the empty listing it had. A listing read with a caller's handle no longer honours a stale not-found mark. The entry point's module key is the on-disk spelling on a case-insensitive filesystem. The compiled-executable listing test moves to the bundler compile suite. --- src/resolver/dir_info.rs | 7 ++- src/resolver/lib.rs | 26 ++++++++-- src/resolver/resolver.rs | 49 +++++++++++++++++++ test/bundler/bundler_compile_autoload.test.ts | 22 +++++++++ test/cli/bun.test.ts | 1 + .../bun/resolve/resolver-dir-listing.test.ts | 22 --------- 6 files changed, 100 insertions(+), 27 deletions(-) diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index c84a53d70111..516053fde956 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -223,10 +223,13 @@ impl DirInfo { self.tsconfig_json.map(arena_ref) } + /// Caller must not hold `entries_mutex`: a placeholder slot is rewritten + /// in place under it. pub fn get_file_descriptor(&self) -> Fd { if FeatureFlags::STORE_FILE_DESCRIPTORS { - return fs::FileSystem::instance() - .fs + let rfs = &mut fs::FileSystem::instance().fs; + let _lock = rfs.entries_mutex.lock_guard(); + return rfs .entries .at_index(self.entries) .map_or(Fd::INVALID, |e| e.fd()); diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 549dabaa1946..48ffbda32b16 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -720,6 +720,7 @@ pub mod fs { }) } + // Relaxed: a statistic for tests, read after the fact. static READDIR_COUNT: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); /// How many directory listings this process has read into the cache @@ -1144,7 +1145,9 @@ pub mod fs { EntriesOption::Unlisted(u) => unlisted = Some(*u), cached => return Ok(cached), } - } else if cache_result.status == bun_alloc::ItemStatus::NotFound && generation == 0 + } else if cache_result.status == bun_alloc::ItemStatus::NotFound + && generation == 0 + && maybe_handle.is_none() { return Ok(temp_entries_option_write(EntriesOption::Err( dir_entry::Err { @@ -1160,7 +1163,10 @@ pub mod fs { Some(h) => h, None => match self.open_dir(dir) { Ok(h) => h, - Err(err) => return self.read_directory_error(dir, err), + Err(err) => { + Self::close_unlisted_fd(unlisted); + return self.read_directory_error(dir, err); + } }, }; @@ -1198,6 +1204,7 @@ pub mod fs { { Ok(e) => e, Err(err) => { + Self::close_unlisted_fd(unlisted); if let Some(existing) = in_place { // SAFETY: see above. unsafe { (*existing).data.clear() }; @@ -1521,6 +1528,7 @@ pub mod fs { let handle = match bun_sys::open_dir_for_iteration(Fd::cwd(), unlisted.dir) { Ok(h) => h, Err(err) => { + Self::close_unlisted_fd(Some(unlisted)); return self.read_directory_error(unlisted.dir, err.into()).ok(); } }; @@ -1536,7 +1544,10 @@ pub mod fs { &mut (), ) { Ok(e) => e, - Err(err) => return self.read_directory_error(unlisted.dir, err).ok(), + Err(err) => { + Self::close_unlisted_fd(Some(unlisted)); + return self.read_directory_error(unlisted.dir, err).ok(); + } }; new_entry.fd = unlisted.fd; // SAFETY: slot is exclusively owned under `entries_mutex`. @@ -1585,6 +1596,15 @@ pub mod fs { Some(unsafe { &*result_ptr }) } + /// The fd a placeholder kept open, when its slot becomes an error. + fn close_unlisted_fd(unlisted: Option) { + if let Some(unlisted) = unlisted + && unlisted.fd.is_valid() + { + let _ = bun_sys::close(unlisted.fd); + } + } + /// Kind of `name` in the directory open as `fd`, following symlinks. pub(crate) fn kind_at(&self, dir: &[u8], fd: Fd, name: &'static ZStr) -> Option { if !fd.is_valid() { diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index cd1e462ae742..f6f501753966 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1879,6 +1879,34 @@ impl<'a> Resolver<'a> { if cache.kind != Fs::file_system::EntryKind::File { return ResultUnion::NotFound; } + // A case-insensitive filesystem matched the path as typed. The module + // key is the spelling on disk, the one every other resolve gives. + let mut on_disk = bun_paths::path_buffer_pool::get(); + let abs_path: &[u8] = if cfg!(any(target_os = "macos", windows)) && cache.symlink.is_empty() + { + let mut z = bun_paths::path_buffer_pool::get(); + let len = abs_path.len(); + if len >= z.len() { + return ResultUnion::NotFound; + } + z[..len].copy_from_slice(abs_path); + z[len] = 0; + let Ok(file) = bun_sys::open( + bun_core::ZStr::from_buf(&z[..], len), + bun_sys::O::RDONLY | bun_sys::O::CLOEXEC, + 0, + ) else { + return ResultUnion::NotFound; + }; + let real = bun_sys::get_fd_path(file, &mut on_disk).map(|p| p.len()); + let _ = bun_sys::close(file); + match real { + Ok(n) => &on_disk[..n], + Err(_) => return ResultUnion::NotFound, + } + } else { + abs_path + }; let Ok(abs_path) = self.fs_ref().dirname_store.append_slice(abs_path) else { return ResultUnion::NotFound; }; @@ -5015,6 +5043,27 @@ impl<'a> Resolver<'a> { } cached_dir_entry_result = rfs!().entries.get_or_put(dir_path).expect("unreachable"); + } else if !open_dir.is_valid() { + // A permission-denied ancestor is an empty directory to + // every reader. + let read = rfs!().entries.put( + &mut cached_dir_entry_result, + Fs::file_system::real_fs::EntriesOption::Entries( + // SAFETY: leaked for the life of the process like every + // other listing in this map. + unsafe { + &mut *bun_core::heap::into_raw(Box::new( + Fs::file_system::DirEntry::init(dir_path, self.generation), + )) + }, + ), + )?; + // SAFETY: `put` returned the live slot. + if let Fs::file_system::real_fs::EntriesOption::Entries(entries) = + unsafe { &*read } + { + listing = Some(std::ptr::from_ref(&**entries)); + } } else if !has_stale_listing { rfs!().entries.put( &mut cached_dir_entry_result, diff --git a/test/bundler/bundler_compile_autoload.test.ts b/test/bundler/bundler_compile_autoload.test.ts index 6b4338c26985..6dd1737cdd46 100644 --- a/test/bundler/bundler_compile_autoload.test.ts +++ b/test/bundler/bundler_compile_autoload.test.ts @@ -6,6 +6,28 @@ import { itBundled } from "./expectBundled"; // those at once SIGTERMs on the linux lanes. expectBundled already forces // backend:"api" cases to it.serial, so only the CLI cases would overlap. describe("bundler", () => { + // The autoload of .env, package.json and tsconfig.json must not read the + // listing of the cwd or of any ancestor: startup would scale with the + // number of files next to the executable's working directory. + itBundled("compile/AutoloadReadsNoDirectoryListing", { + compile: true, + files: { + "/entry.ts": /* js */ ` + import { getCounters } from "bun:internal-for-testing"; + console.log("listings", getCounters().readdir); + `, + }, + runtimeFiles: { + "/.env": `TEST_VAR=from_dotenv`, + "/package.json": `{ "name": "app" }`, + "/tsconfig.json": `{}`, + }, + run: { + stdout: "listings 0", + setCwd: true, + }, + }); + // Test that .env files are loaded by default in standalone executables itBundled("compile/AutoloadDotenvDefault", { compile: true, diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index e2e82962eb35..c0b72c0104e0 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -185,6 +185,7 @@ describe("bun", () => { expect(stdout.toString()).not.toBeEmpty(); }); + // The fixture is a shell script with a mode bit; Windows has neither. test.skipIf(isWindows)("getcompletes b lists the executables in node_modules/.bin", () => { using dir = tempDir("getcompletes-bin", { "package.json": JSON.stringify({ name: "test" }), diff --git a/test/js/bun/resolve/resolver-dir-listing.test.ts b/test/js/bun/resolve/resolver-dir-listing.test.ts index 8e265fe9cf9a..f2370f398114 100644 --- a/test/js/bun/resolve/resolver-dir-listing.test.ts +++ b/test/js/bun/resolve/resolver-dir-listing.test.ts @@ -120,28 +120,6 @@ describe("startup does not list directories", () => { const { stdout, stderr, exitCode } = await run([bunExe(), join(dir, "other/../x//y.js")], String(dir)); expect({ stdout, stderr, exitCode }).toEqual({ stdout: join(dir, "x/y.js") + "\n", stderr: "", exitCode: 0 }); }); - - // Compiling copies the whole executable, which a debug build makes slow. - test.concurrent( - "a compiled executable reads no directory listing", - async () => { - using dir = tempDir("no-listing-compiled", { - "app.js": `console.log("compiled");\n${countListings}`, - "a/b/.keep": "", - }); - const outfile = join(dir, "app" + (isWindows ? ".exe" : "")); - const build = await run([bunExe(), "build", "--compile", "app.js", "--outfile", outfile], String(dir)); - expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); - const { stdout, stderr, exitCode } = await run([outfile], join(dir, "a/b")); - expect({ ...splitListings(stdout), stderr, exitCode }).toEqual({ - output: "compiled\n", - listings: 0, - stderr: "", - exitCode: 0, - }); - }, - 120_000, - ); }); describe("DirInfo markers without a listing", () => { From a5510133de933d584fda746e6aafca2b7b1be766 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:24:39 +0000 Subject: [PATCH 10/15] resolver: a failed placeholder listing becomes an error slot, the entry keeps its typed path unless only the case differs --- src/resolver/lib.rs | 40 ++++++++++++------- src/resolver/resolver.rs | 18 +++++++-- .../bun/resolve/resolver-dir-listing.test.ts | 6 ++- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 48ffbda32b16..a41b30914217 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1126,7 +1126,7 @@ pub mod fs { crate::Resolver::assert_valid_cache_key(dir); let mut in_place: Option<*mut DirEntry> = None; - let mut unlisted: Option = None; + let mut unlisted: Option<(*mut EntriesOption, Unlisted)> = None; let mut cache_result = self.entries.get_or_put(dir)?; if cache_result.has_checked_if_exists() { @@ -1142,7 +1142,7 @@ pub mod fs { EntriesOption::Entries(e) if e.generation < generation => { in_place = Some(std::ptr::from_mut::(*e)); } - EntriesOption::Unlisted(u) => unlisted = Some(*u), + EntriesOption::Unlisted(u) => unlisted = Some((cached_ptr, *u)), cached => return Ok(cached), } } else if cache_result.status == bun_alloc::ItemStatus::NotFound @@ -1164,7 +1164,7 @@ pub mod fs { None => match self.open_dir(dir) { Ok(h) => h, Err(err) => { - Self::close_unlisted_fd(unlisted); + Self::fail_unlisted(unlisted, err); return self.read_directory_error(dir, err); } }, @@ -1172,7 +1172,7 @@ pub mod fs { // Close the handle on every exit path. Use // scopeguard so close happens even if `readdir`/`put` early-return with `?`. - let keeps_unlisted_fd = unlisted.is_some_and(|u| u.fd.is_valid()); + let keeps_unlisted_fd = unlisted.is_some_and(|(_, u)| u.fd.is_valid()); let should_close_handle = !had_handle && (!store_fd || keeps_unlisted_fd || self.need_to_close_files()); let _close_guard = scopeguard::guard(handle, move |h| { @@ -1189,7 +1189,7 @@ pub mod fs { // SAFETY: `in_place` points to a `DirEntry` inside the BSSMap singleton; // its `dir` field is DirnameStore-interned (&'static). unsafe { (*existing).dir } - } else if let Some(unlisted) = unlisted { + } else if let Some((_, unlisted)) = unlisted { unlisted.dir } else { DirnameStore::instance().append_slice(dir_maybe_trail_slash)? @@ -1204,7 +1204,7 @@ pub mod fs { { Ok(e) => e, Err(err) => { - Self::close_unlisted_fd(unlisted); + Self::fail_unlisted(unlisted, err); if let Some(existing) = in_place { // SAFETY: see above. unsafe { (*existing).data.clear() }; @@ -1227,7 +1227,7 @@ pub mod fs { if store_fd && !entries.fd.is_valid() { entries.fd = handle; } - if let Some(unlisted) = unlisted + if let Some((_, unlisted)) = unlisted && unlisted.fd.is_valid() { entries.fd = unlisted.fd; @@ -1528,8 +1528,9 @@ pub mod fs { let handle = match bun_sys::open_dir_for_iteration(Fd::cwd(), unlisted.dir) { Ok(h) => h, Err(err) => { - Self::close_unlisted_fd(Some(unlisted)); - return self.read_directory_error(unlisted.dir, err.into()).ok(); + let err = err.into(); + Self::fail_unlisted(Some((result_ptr, unlisted)), err); + return self.read_directory_error(unlisted.dir, err).ok(); } }; let _close_guard = scopeguard::guard(handle, |h| { @@ -1545,7 +1546,7 @@ pub mod fs { ) { Ok(e) => e, Err(err) => { - Self::close_unlisted_fd(Some(unlisted)); + Self::fail_unlisted(Some((result_ptr, unlisted)), err); return self.read_directory_error(unlisted.dir, err).ok(); } }; @@ -1596,13 +1597,22 @@ pub mod fs { Some(unsafe { &*result_ptr }) } - /// The fd a placeholder kept open, when its slot becomes an error. - fn close_unlisted_fd(unlisted: Option) { - if let Some(unlisted) = unlisted - && unlisted.fd.is_valid() - { + /// A placeholder whose listing failed: its slot, still indexed by a + /// `DirInfo`, becomes the error, and the fd it kept open is closed once. + fn fail_unlisted(unlisted: Option<(*mut EntriesOption, Unlisted)>, err: crate::Error) { + let Some((slot, unlisted)) = unlisted else { + return; + }; + if unlisted.fd.is_valid() { let _ = bun_sys::close(unlisted.fd); } + // SAFETY: `slot` is a live BSSMap slot, exclusively held under `entries_mutex`. + unsafe { + *slot = EntriesOption::Err(dir_entry::Err { + original_err: err, + canonical_error: err, + }); + } } /// Kind of `name` in the directory open as `fd`, following symlinks. diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index f6f501753966..ec7cf4fe18f6 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1880,9 +1880,13 @@ impl<'a> Resolver<'a> { return ResultUnion::NotFound; } // A case-insensitive filesystem matched the path as typed. The module - // key is the spelling on disk, the one every other resolve gives. + // key is the spelling on disk, the one every other resolve gives. A + // path that differs in more than case (a symlinked directory, a + // mapped drive) stays as typed, like `load_as_file` keeps it. let mut on_disk = bun_paths::path_buffer_pool::get(); - let abs_path: &[u8] = if cfg!(any(target_os = "macos", windows)) && cache.symlink.is_empty() + let abs_path: &[u8] = if cfg!(any(target_os = "macos", windows)) + && cache.symlink.is_empty() + && !self.opts.preserve_symlinks { let mut z = bun_paths::path_buffer_pool::get(); let len = abs_path.len(); @@ -1901,7 +1905,15 @@ impl<'a> Resolver<'a> { let real = bun_sys::get_fd_path(file, &mut on_disk).map(|p| p.len()); let _ = bun_sys::close(file); match real { - Ok(n) => &on_disk[..n], + Ok(n) + if strings::eql_case_insensitive_ascii_check_length( + &on_disk[..n], + abs_path, + ) => + { + &on_disk[..n] + } + Ok(_) => abs_path, Err(_) => return ResultUnion::NotFound, } } else { diff --git a/test/js/bun/resolve/resolver-dir-listing.test.ts b/test/js/bun/resolve/resolver-dir-listing.test.ts index f2370f398114..02a07fd15a17 100644 --- a/test/js/bun/resolve/resolver-dir-listing.test.ts +++ b/test/js/bun/resolve/resolver-dir-listing.test.ts @@ -117,7 +117,8 @@ describe("startup does not list directories", () => { "x/y.js": `console.log(import.meta.path);`, "other/.keep": "", }); - const { stdout, stderr, exitCode } = await run([bunExe(), join(dir, "other/../x//y.js")], String(dir)); + // Not `join`: it would normalize the argument before bun sees it. + const { stdout, stderr, exitCode } = await run([bunExe(), `${dir}/other/../x//y.js`], String(dir)); expect({ stdout, stderr, exitCode }).toEqual({ stdout: join(dir, "x/y.js") + "\n", stderr: "", exitCode: 0 }); }); }); @@ -240,7 +241,8 @@ describe("entry point resolved by stat", () => { cmd = ["runuser", "-u", "nobody", "--", ...cmd]; } // Searchable, not listable: `stat` of a file inside works, `readdir` fails. - chmodSync(join(root, "locked"), 0o711); + // As the owner, the owner's read bit has to go too. + chmodSync(join(root, "locked"), canUseRunuser ? 0o711 : 0o111); try { const { stdout, stderr, exitCode } = await run(cmd, join(root, "work"), { HOME: join(root, "work") }); expect({ stdout, stderr, exitCode }).toEqual({ stdout: `RAN ${script}\n`, stderr: "", exitCode: 0 }); From e68f797f4a174b84175b5ecf479c88ac1afe7062 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:27:54 +0000 Subject: [PATCH 11/15] resolver: shorter comments --- src/resolver/lib.rs | 3 +-- src/resolver/resolver.rs | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index a41b30914217..8a4470b08739 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1597,8 +1597,7 @@ pub mod fs { Some(unsafe { &*result_ptr }) } - /// A placeholder whose listing failed: its slot, still indexed by a - /// `DirInfo`, becomes the error, and the fd it kept open is closed once. + /// The slot, still indexed by a `DirInfo`, becomes the error; its fd closes once. fn fail_unlisted(unlisted: Option<(*mut EntriesOption, Unlisted)>, err: crate::Error) { let Some((slot, unlisted)) = unlisted else { return; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index ec7cf4fe18f6..01a93ef7d34f 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1879,10 +1879,9 @@ impl<'a> Resolver<'a> { if cache.kind != Fs::file_system::EntryKind::File { return ResultUnion::NotFound; } - // A case-insensitive filesystem matched the path as typed. The module - // key is the spelling on disk, the one every other resolve gives. A - // path that differs in more than case (a symlinked directory, a - // mapped drive) stays as typed, like `load_as_file` keeps it. + // On a case-insensitive filesystem the module key is the spelling on + // disk, as `load_as_file` gives it; a path that differs in more than + // case stays as typed. let mut on_disk = bun_paths::path_buffer_pool::get(); let abs_path: &[u8] = if cfg!(any(target_os = "macos", windows)) && cache.symlink.is_empty() From 1ae208a7a7f131a72ab4f1f1bbab98733d32d2c4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:21:57 +0000 Subject: [PATCH 12/15] resolver: open the entry without blocking when it reads the on-disk case --- src/resolver/resolver.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 01a93ef7d34f..dff6821ffd2c 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1894,9 +1894,10 @@ impl<'a> Resolver<'a> { } z[..len].copy_from_slice(abs_path); z[len] = 0; + // NONBLOCK: a FIFO entry must not wait for a writer here. let Ok(file) = bun_sys::open( bun_core::ZStr::from_buf(&z[..], len), - bun_sys::O::RDONLY | bun_sys::O::CLOEXEC, + bun_sys::O::RDONLY | bun_sys::O::CLOEXEC | bun_sys::O::NONBLOCK, 0, ) else { return ResultUnion::NotFound; From cfc665cfc84f5c1c1320c5ec6deba494a6466786 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:36:52 +0000 Subject: [PATCH 13/15] resolver: a directory-spelled entry path takes the full resolver; close the package handle when its listing fails The compiled listing test sets the internal-testing flag itself, and the dotenv test pins the environment it reads. --- src/resolver/resolver.rs | 15 +++++++++++---- test/bundler/bundler_compile_autoload.test.ts | 1 + test/js/bun/resolve/resolver-dir-listing.test.ts | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index dff6821ffd2c..88671a78d4aa 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1859,6 +1859,7 @@ impl<'a> Resolver<'a> { if self.standalone_module_graph.is_some() || !bun_paths::is_absolute(path) || strings::index_of_char(path, 0).is_some() + || Self::import_path_names_directory(path) { return ResultUnion::NotFound; } @@ -3900,12 +3901,19 @@ impl<'a> Resolver<'a> { }; let dir_entries_option: *const Fs::file_system::real_fs::EntriesOption = - rfs!().read_directory(dir_path, Some(open_dir), self.generation, self.store_fd)?; + match rfs!().read_directory(dir_path, Some(open_dir), self.generation, self.store_fd) { + Ok(read) => read, + Err(err) => { + let _ = bun_sys::close(open_dir); + return Err(err); + } + }; // SAFETY: `read_directory` returned a live slot in `rfs.entries` (or the // threadlocal scratch slot for a missing directory, read before the next call). let dir_entries = match unsafe { &*dir_entries_option }.try_entries() { Ok(entries) => entries, Err(err) => { + let _ = bun_sys::close(open_dir); let _ = self.log_mut().add_error_fmt( None, bun_ast::Loc::EMPTY, @@ -5071,9 +5079,8 @@ impl<'a> Resolver<'a> { ), )?; // SAFETY: `put` returned the live slot. - if let Fs::file_system::real_fs::EntriesOption::Entries(entries) = - unsafe { &*read } - { + let read = unsafe { &*read }; + if let Fs::file_system::real_fs::EntriesOption::Entries(entries) = read { listing = Some(std::ptr::from_ref(&**entries)); } } else if !has_stale_listing { diff --git a/test/bundler/bundler_compile_autoload.test.ts b/test/bundler/bundler_compile_autoload.test.ts index 6dd1737cdd46..ddd1b09c98be 100644 --- a/test/bundler/bundler_compile_autoload.test.ts +++ b/test/bundler/bundler_compile_autoload.test.ts @@ -25,6 +25,7 @@ describe("bundler", () => { run: { stdout: "listings 0", setCwd: true, + env: { BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, }, }); diff --git a/test/js/bun/resolve/resolver-dir-listing.test.ts b/test/js/bun/resolve/resolver-dir-listing.test.ts index 02a07fd15a17..97cbcca0ce01 100644 --- a/test/js/bun/resolve/resolver-dir-listing.test.ts +++ b/test/js/bun/resolve/resolver-dir-listing.test.ts @@ -172,6 +172,7 @@ describe("DirInfo markers without a listing", () => { const { stdout, stderr, exitCode } = await run( [bunExe(), "-e", "console.log(process.env.FROM_ENV, process.env.FROM_LOCAL, process.env.FROM_PROD)"], String(dir), + { NODE_ENV: "development", BUN_ENV: undefined }, ); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1 2 undefined\n", stderr: "", exitCode: 0 }); }); From 9799f6602f5a2ba5e4f46f0203f231d8ee650e10 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:04:09 +0000 Subject: [PATCH 14/15] resolver: inspect the package listing under the entries lock and close the handle it did not adopt --- src/resolver/resolver.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 88671a78d4aa..05f588dade05 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -3908,6 +3908,8 @@ impl<'a> Resolver<'a> { return Err(err); } }; + // The route loaders iterate the `DirEntry.data` map under this lock. + let _entries_unlock = rfs!().entries_mutex.lock_guard(); // SAFETY: `read_directory` returned a live slot in `rfs.entries` (or the // threadlocal scratch slot for a missing directory, read before the next call). let dir_entries = match unsafe { &*dir_entries_option }.try_entries() { @@ -3922,8 +3924,6 @@ impl<'a> Resolver<'a> { return Err(err); } }; - // The route loaders iterate the `DirEntry.data` map under this lock. - let _entries_unlock = rfs!().entries_mutex.lock_guard(); let cached_dir_entry_result = rfs!().entries.get_or_put(dir_path)?; // We must initialize it as empty so that the result index is correct. @@ -3956,6 +3956,9 @@ impl<'a> Resolver<'a> { open_dir, Some(package_id), )?; + if dir_entries.fd != open_dir { + let _ = bun_sys::close(open_dir); + } // SAFETY: `dir_info_ptr` is the BSSMap slot just filled by `dir_info_uncached`. Ok(Some(unsafe { DirInfoRef::from_raw(dir_info_ptr) })) } From 44b10f4a9efbd36e9a19da1b2bf2d1147d89b6e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:39:15 +0000 Subject: [PATCH 15/15] resolver: close the package directory handle when dir_info_uncached fails A compiled program holds the file the plain run resolved from disk, so it lists the directory only after the file is deleted. Name that test in the list of programs that differ compiled. --- src/resolver/resolver.rs | 5 +++-- test/aot/runner-suites.programs.txt | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 05f588dade05..b9c0ce673add 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -3943,7 +3943,7 @@ impl<'a> Resolver<'a> { // its `path` argument directly as `DirInfo.abs_path` in the permanent `dir_cache`, so // pass the interned copy from `DirEntry.dir` (always backed by `DirnameStore`) instead. let dir_entries_dir = dir_entries.dir; - self.dir_info_uncached( + let result = self.dir_info_uncached( dir_info_ptr, dir_entries_dir, Some(std::ptr::from_ref(dir_entries)), @@ -3955,10 +3955,11 @@ impl<'a> Resolver<'a> { allocators::NOT_FOUND, open_dir, Some(package_id), - )?; + ); if dir_entries.fd != open_dir { let _ = bun_sys::close(open_dir); } + result?; // SAFETY: `dir_info_ptr` is the BSSMap slot just filled by `dir_info_uncached`. Ok(Some(unsafe { DirInfoRef::from_raw(dir_info_ptr) })) } diff --git a/test/aot/runner-suites.programs.txt b/test/aot/runner-suites.programs.txt index 85606509a241..27e82390b473 100644 --- a/test/aot/runner-suites.programs.txt +++ b/test/aot/runner-suites.programs.txt @@ -2,10 +2,12 @@ # A line goes out with the fix that makes its test pass. `~ ` before a line: that test fails in some runs and passes in others. # Most of it is what an executable refuses on purpose, as `bun build --compile` does too: # a query string in an import path (?raw ?url, ?worker in a require(), import.meta.glob's `query`) . code that a path computed at run time names . plugins of the run time . a fixture in TypeScript with a type error -# (one such fixture stands behind the 110 lines of "ES decorators lowering matrix > .ts") . the words of a syntax error or of a failed resolution, which are the checker's. +# (one such fixture stands behind the 110 lines of "ES decorators lowering matrix > .ts") . the words of a syntax error or of a failed resolution, which are the checker's +# . a file the plain run resolved from disk before it was deleted: the executable holds that file, lists the directory only for the second import, and finds it gone. # The faults among them: CSS modules . hoisted mocks and coverage with import.meta.glob . `.cjs` for `.cts` inside node_modules . import.meta.glob as a value (plain's --compile too). test/js/bun/css/css-loader.test.ts: css module exports > :local and :global test/js/bun/css/css-loader.test.ts: css module exports > CSS the bundler only warns about doesn't throw +test/js/bun/css/css-loader.test.ts: css module exports > a file that was deleted after it was resolved test/js/bun/css/css-loader.test.ts: css module exports > a module without classes or ids test/js/bun/css/css-loader.test.ts: css module exports > classes and ids, wherever they appear test/js/bun/css/css-loader.test.ts: css module exports > composes