Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cache/cache_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ impl<Fs: FileSystem> Cache<Fs> {
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(
Expand Down
162 changes: 96 additions & 66 deletions src/cache/cached_meta.rs
Original file line number Diff line number Diff line change
@@ -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<Option<(bool, bool)>>` (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<Option<FileMetadata>>` (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<F>(&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<F: FnOnce() -> Option<FileMetadata>>(&self, f: F) -> Option<FileMetadata> {
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<F: FnOnce() -> Option<FileMetadata>>(
&self,
f: F,
) -> Option<FileMetadata> {
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<F: FnOnce() -> Option<FileMetadata>>(slot: &AtomicU8, f: F) -> Option<FileMetadata> {
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<FileMetadata>) -> 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<FileMetadata> {
((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::<CachedMeta>(), 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::<CachedMeta>(), 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)));
}
}
26 changes: 21 additions & 5 deletions src/cache/cached_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CachedPathImpl>);
Expand Down Expand Up @@ -239,16 +239,32 @@ impl CachedPath {
}

impl CachedPath {
fn metadata<Fs: FileSystem>(&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<Fs: FileSystem>(&self, fs: &Fs) -> Option<FileMetadata> {
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<Fs: FileSystem>(&self, fs: &Fs) -> Option<FileMetadata> {
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<Fs: FileSystem>(&self, fs: &Fs) -> Option<bool> {
self.metadata(fs).map(|r| r.0)
self.followed_metadata(fs).map(FileMetadata::is_file)
}

pub(crate) fn is_dir<Fs: FileSystem>(&self, fs: &Fs) -> Option<bool> {
self.metadata(fs).map(|r| r.1)
self.followed_metadata(fs).map(FileMetadata::is_dir)
}
}

Expand Down
Loading