diff --git a/src/cache/cache_impl.rs b/src/cache/cache_impl.rs index 4771188ef..fc2905334 100644 --- a/src/cache/cache_impl.rs +++ b/src/cache/cache_impl.rs @@ -359,7 +359,7 @@ impl Cache { let normalized = parent_canonical .normalize_with(path.path().strip_prefix(parent.path()).unwrap(), self); - if self.fs.symlink_metadata(path.path()).is_ok_and(|m| m.is_symlink) { + if path.link_metadata(&self.fs).is_some_and(|m| m.is_symlink) { let link = self.fs.read_link(normalized.path())?; if link.is_absolute() { return self.canonicalize_with_visited( diff --git a/src/cache/cached_meta.rs b/src/cache/cached_meta.rs index 66535b010..3e4db493c 100644 --- a/src/cache/cached_meta.rs +++ b/src/cache/cached_meta.rs @@ -1,102 +1,132 @@ -//! Single-byte cache slot for `Option<(is_file, is_dir)>` filesystem metadata. +//! Two single-byte cache slots for a path's filesystem metadata. //! -//! Replaces `OnceLock>` (16 bytes) with an `AtomicU8` (1 byte) — saving -//! 15 bytes per cached path entry. The four possible `(is_file, is_dir)` combinations, the -//! "not found" outcome, and the uninitialized state fit comfortably in a single byte. +//! A path is looked at two ways during resolution: //! -//! Filesystem metadata is idempotent, so the lack of `OnceLock`'s exactly-once semantics is -//! harmless: if two threads race to populate the slot they both re-`stat` and store the same -//! answer. +//! * `link` — the `lstat` ([`FileSystem::symlink_metadata`]) view of the path *itself*: file, +//! directory, or symlink? Canonicalization needs this to decide whether to follow a link. +//! * `followed` — the `stat` ([`FileSystem::metadata`]) view *after* following symlinks: does the +//! path ultimately resolve to a file or a directory? This is what `is_file`/`is_dir` need. +//! +//! For a non-symlink the two views are identical, so a single `lstat` answers both and the +//! follow-up `stat` is skipped. Sharing the cached `link` view lets `is_file`/`is_dir` and +//! canonicalization issue one `lstat` instead of a `stat` *and* an `lstat` for the same path. +//! +//! Each view is packed into one [`AtomicU8`] rather than a `OnceLock>` (which +//! would be 16 bytes), saving 30 bytes per cached path entry. Metadata is idempotent, so the lack +//! of `OnceLock`'s exactly-once guarantee is harmless: racing threads recompute the same answer. +//! +//! [`FileSystem::metadata`]: crate::FileSystem::metadata +//! [`FileSystem::symlink_metadata`]: crate::FileSystem::symlink_metadata use std::sync::atomic::{AtomicU8, Ordering}; -const UNINIT: u8 = 0; -const NONE: u8 = 1; -const FALSE_FALSE: u8 = 2; -const TRUE_FALSE: u8 = 3; -const FALSE_TRUE: u8 = 4; -const TRUE_TRUE: u8 = 5; +use crate::FileMetadata; -/// Lazily-populated `Option<(is_file, is_dir)>` packed into one byte. -pub struct CachedMeta(AtomicU8); +// Bit layout of a slot. An all-zero byte (`INITIALIZED` unset) means "not probed yet"; `EXISTS` +// distinguishes a cached `Some` from a cached `None`. +const INITIALIZED: u8 = 1 << 0; +const EXISTS: u8 = 1 << 1; +const IS_FILE: u8 = 1 << 2; +const IS_DIR: u8 = 1 << 3; +const IS_SYMLINK: u8 = 1 << 4; + +/// Lazily-populated `lstat` (`link`) and `stat` (`followed`) metadata, one byte each. +#[derive(Default)] +pub struct CachedMeta { + link: AtomicU8, + followed: AtomicU8, +} impl CachedMeta { pub const fn new() -> Self { - Self(AtomicU8::new(UNINIT)) + Self { link: AtomicU8::new(0), followed: AtomicU8::new(0) } } - /// Return the cached value if the slot has been populated, otherwise call `f`, store its - /// result, and return it. Multiple threads may race to populate; the last writer wins. - pub fn get_or_init(&self, f: F) -> Option<(bool, bool)> - where - F: FnOnce() -> Option<(bool, bool)>, - { - let state = self.0.load(Ordering::Relaxed); - if state != UNINIT { - return decode(state); - } - let computed = f(); - self.0.store(encode(computed), Ordering::Relaxed); - computed + /// Return the cached `lstat` view, or populate it from `f` (an `lstat`) and return that. + pub fn link_or_init Option>(&self, f: F) -> Option { + get_or_init(&self.link, f) } -} -impl Default for CachedMeta { - fn default() -> Self { - Self::new() + /// Return the cached `stat` (symlink-followed) view, or populate it from `f` and return that. + pub fn followed_or_init Option>( + &self, + f: F, + ) -> Option { + get_or_init(&self.followed, f) } } -#[inline] -fn encode(value: Option<(bool, bool)>) -> u8 { - match value { - None => NONE, - Some((false, false)) => FALSE_FALSE, - Some((true, false)) => TRUE_FALSE, - Some((false, true)) => FALSE_TRUE, - Some((true, true)) => TRUE_TRUE, +fn get_or_init Option>(slot: &AtomicU8, f: F) -> Option { + let bits = slot.load(Ordering::Relaxed); + if (bits & INITIALIZED) != 0 { + return decode(bits); } + let meta = f(); + slot.store(encode(meta), Ordering::Relaxed); + meta } -#[inline] -fn decode(state: u8) -> Option<(bool, bool)> { - match state { - NONE => None, - FALSE_FALSE => Some((false, false)), - TRUE_FALSE => Some((true, false)), - FALSE_TRUE => Some((false, true)), - TRUE_TRUE => Some((true, true)), - // UNINIT is filtered out by the caller; any other byte indicates corruption. - _ => unreachable!("invalid CachedMeta state"), +fn encode(meta: Option) -> u8 { + let Some(meta) = meta else { return INITIALIZED }; + let mut bits = INITIALIZED | EXISTS; + if meta.is_file() { + bits |= IS_FILE; + } + if meta.is_dir() { + bits |= IS_DIR; + } + if meta.is_symlink() { + bits |= IS_SYMLINK; } + bits +} + +/// Decode a slot known to be [`INITIALIZED`]. +fn decode(bits: u8) -> Option { + ((bits & EXISTS) != 0).then(|| { + FileMetadata::new((bits & IS_FILE) != 0, (bits & IS_DIR) != 0, (bits & IS_SYMLINK) != 0) + }) } #[cfg(test)] mod tests { use super::*; - use std::mem::size_of; - #[test] - fn cached_meta_is_one_byte() { - assert_eq!(size_of::(), 1); + fn parts(meta: FileMetadata) -> (bool, bool, bool) { + (meta.is_file(), meta.is_dir(), meta.is_symlink()) } #[test] - fn returns_none_when_initializer_returns_none() { - let meta = CachedMeta::new(); - assert!(meta.get_or_init(|| None).is_none()); - // And on subsequent calls the initializer is not invoked. - assert!(meta.get_or_init(|| panic!("must not be called")).is_none()); + fn is_two_bytes() { + assert_eq!(std::mem::size_of::(), 2); } #[test] - fn roundtrips_all_some_combinations() { - for (is_file, is_dir) in [(false, false), (true, false), (false, true), (true, true)] { + fn roundtrips_and_caches() { + let cases = [ + None, + Some(FileMetadata::new(false, false, false)), + Some(FileMetadata::new(true, false, false)), + Some(FileMetadata::new(false, true, false)), + Some(FileMetadata::new(false, false, true)), + ]; + for expected in cases { let meta = CachedMeta::new(); - let got = meta.get_or_init(|| Some((is_file, is_dir))); - assert_eq!(got, Some((is_file, is_dir))); - let cached = meta.get_or_init(|| panic!("must not be called")); - assert_eq!(cached, Some((is_file, is_dir))); + assert_eq!(meta.link_or_init(|| expected).map(parts), expected.map(parts)); + // Once populated, the initializer is never called again. + assert_eq!( + meta.link_or_init(|| panic!("must not be called")).map(parts), + expected.map(parts) + ); } } + + #[test] + fn link_and_followed_are_separate_slots() { + let meta = CachedMeta::new(); + meta.link_or_init(|| Some(FileMetadata::new(false, false, true))); + meta.followed_or_init(|| Some(FileMetadata::new(false, true, false))); + assert_eq!(meta.link_or_init(|| panic!()).map(parts), Some((false, false, true))); + assert_eq!(meta.followed_or_init(|| panic!()).map(parts), Some((false, true, false))); + } } diff --git a/src/cache/cached_path.rs b/src/cache/cached_path.rs index 7ab8a4094..90b054209 100644 --- a/src/cache/cached_path.rs +++ b/src/cache/cached_path.rs @@ -13,7 +13,7 @@ use once_cell::sync::OnceCell as OnceLock; use super::cache_impl::Cache; use super::cached_meta::CachedMeta; use super::thread_local::SCRATCH_PATH; -use crate::{FileSystem, PackageJson, TsConfig, context::ResolveContext as Ctx}; +use crate::{FileMetadata, FileSystem, PackageJson, TsConfig, context::ResolveContext as Ctx}; #[derive(Clone)] pub struct CachedPath(pub Arc); @@ -239,16 +239,32 @@ impl CachedPath { } impl CachedPath { - fn metadata(&self, fs: &Fs) -> Option<(bool, bool)> { - self.meta.get_or_init(|| fs.metadata(&self.path).ok().map(|r| (r.is_file, r.is_dir))) + /// `lstat` view of this path (the link itself), cached. + /// + /// Used both to answer `is_file`/`is_dir` for non-symlinks and by canonicalization to decide + /// whether to follow a symlink — so the two share a single `lstat` syscall per path. + pub(crate) fn link_metadata(&self, fs: &Fs) -> Option { + self.meta.link_or_init(|| fs.symlink_metadata(&self.path).ok()) + } + + /// `stat` view of this path (symlinks followed), cached. + /// + /// For a non-symlink this reuses the cached `lstat` result and issues no extra syscall; only + /// an actual symlink needs a follow-up `stat` to learn what it points at. + fn followed_metadata(&self, fs: &Fs) -> Option { + self.meta.followed_or_init(|| match self.link_metadata(fs) { + Some(meta) if meta.is_symlink() => fs.metadata(&self.path).ok(), + // A non-symlink's `lstat` already is its `stat`; `None` stays `None`. + other => other, + }) } pub(crate) fn is_file(&self, fs: &Fs) -> Option { - self.metadata(fs).map(|r| r.0) + self.followed_metadata(fs).map(FileMetadata::is_file) } pub(crate) fn is_dir(&self, fs: &Fs) -> Option { - self.metadata(fs).map(|r| r.1) + self.followed_metadata(fs).map(FileMetadata::is_dir) } }