Skip to content

Commit bc9c219

Browse files
committed
fix: use Weak references for CachedPath to enable proper drop (#727)
Related: * oxc-project/oxc#10627 The resolver was experiencing memory leaks due to circular strong references between `CachedPath` objects. Even after the resolver was dropped, `CachedPath` instances (and their associated `PackageJson` data) remained in memory due to reference cycles created by Arc (reference-counted) pointers. The memory leak was caused by circular references in three fields of `CachedPathImpl`: 1. `parent: Option<CachedPath>` - Child paths held strong references to parent paths 2. `canonicalized: OnceLock<Result<CachedPath, ResolveError>>` - Paths held strong references to their canonicalized versions 3. `node_modules: OnceLock<Option<CachedPath>>` - Parent directories held strong references to their node_modules subdirectories These created reference cycles preventing Arc reference counts from reaching zero. Converted all fields that could create circular references to use `Weak` references: - `parent: Option<Weak<CachedPathImpl>>` - `canonicalized: OnceLock<Result<Weak<CachedPathImpl>, ResolveError>>` - `node_modules: OnceLock<Option<Weak<CachedPathImpl>>>` Updated all accessor methods to upgrade Weak references when needed: - `parent()` - Upgrades weak parent reference - `cached_node_modules()` - Stores and retrieves weak references - `canonicalize_impl()` - Stores canonicalized paths as weak references Passes `test_memory_leak_arc_cycles` that failed in the previous stack.
1 parent 645164b commit bc9c219

File tree

4 files changed

+42
-27
lines changed

4 files changed

+42
-27
lines changed

src/cache/cache_impl.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ impl<Fs: FileSystem> Cache<Fs> {
5454
let is_node_modules = path.file_name().as_ref().is_some_and(|&name| name == "node_modules");
5555
let inside_node_modules =
5656
is_node_modules || parent.as_ref().is_some_and(|parent| parent.inside_node_modules);
57+
let parent_weak = parent.as_ref().map(|p| Arc::downgrade(&p.0));
5758
let cached_path = CachedPath(Arc::new(CachedPathImpl::new(
5859
hash,
5960
path.to_path_buf().into_boxed_path(),
6061
is_node_modules,
6162
inside_node_modules,
62-
parent,
63+
parent_weak,
6364
)));
6465
paths.insert(cached_path.clone());
6566
cached_path
@@ -240,7 +241,7 @@ impl<Fs: FileSystem> Cache<Fs> {
240241
let res = path.parent().map_or_else(
241242
|| Ok(path.normalize_root(self)),
242243
|parent| {
243-
self.canonicalize_impl(parent).and_then(|parent_canonical| {
244+
self.canonicalize_impl(&parent).and_then(|parent_canonical| {
244245
let normalized = parent_canonical.normalize_with(
245246
path.path().strip_prefix(parent.path()).unwrap(),
246247
self,
@@ -269,8 +270,15 @@ impl<Fs: FileSystem> Cache<Fs> {
269270
);
270271

271272
path.canonicalizing.store(0, Ordering::Release);
272-
res
273+
// Convert to Weak reference before storing
274+
res.map(|cp| Arc::downgrade(&cp.0))
275+
})
276+
.as_ref()
277+
.map_err(Clone::clone)
278+
.and_then(|weak| {
279+
weak.upgrade().map(CachedPath).ok_or_else(|| {
280+
ResolveError::from(io::Error::other("Canonicalized path was dropped"))
281+
})
273282
})
274-
.clone()
275283
}
276284
}

src/cache/cached_path.rs

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::{
44
hash::{Hash, Hasher},
55
ops::Deref,
66
path::{Component, Path, PathBuf},
7-
sync::{Arc, atomic::AtomicU64},
7+
sync::{Arc, Weak, atomic::AtomicU64},
88
};
99

1010
use cfg_if::cfg_if;
@@ -23,13 +23,13 @@ pub struct CachedPath(pub Arc<CachedPathImpl>);
2323
pub struct CachedPathImpl {
2424
pub hash: u64,
2525
pub path: Box<Path>,
26-
pub parent: Option<CachedPath>,
26+
pub parent: Option<Weak<CachedPathImpl>>,
2727
pub is_node_modules: bool,
2828
pub inside_node_modules: bool,
2929
pub meta: OnceLock<Option<FileMetadata>>,
30-
pub canonicalized: OnceLock<Result<CachedPath, ResolveError>>,
30+
pub canonicalized: OnceLock<Result<Weak<CachedPathImpl>, ResolveError>>,
3131
pub canonicalizing: AtomicU64,
32-
pub node_modules: OnceLock<Option<CachedPath>>,
32+
pub node_modules: OnceLock<Option<Weak<CachedPathImpl>>>,
3333
pub package_json: OnceLock<Option<Arc<PackageJson>>>,
3434
}
3535

@@ -39,7 +39,7 @@ impl CachedPathImpl {
3939
path: Box<Path>,
4040
is_node_modules: bool,
4141
inside_node_modules: bool,
42-
parent: Option<CachedPath>,
42+
parent: Option<Weak<Self>>,
4343
) -> Self {
4444
Self {
4545
hash,
@@ -73,8 +73,8 @@ impl CachedPath {
7373
self.path.to_path_buf()
7474
}
7575

76-
pub(crate) fn parent(&self) -> Option<&Self> {
77-
self.0.parent.as_ref()
76+
pub(crate) fn parent(&self) -> Option<Self> {
77+
self.0.parent.as_ref().and_then(|weak| weak.upgrade().map(CachedPath))
7878
}
7979

8080
pub(crate) fn is_node_modules(&self) -> bool {
@@ -100,7 +100,12 @@ impl CachedPath {
100100
cache: &Cache<Fs>,
101101
ctx: &mut Ctx,
102102
) -> Option<Self> {
103-
self.node_modules.get_or_init(|| self.module_directory("node_modules", cache, ctx)).clone()
103+
self.node_modules
104+
.get_or_init(|| {
105+
self.module_directory("node_modules", cache, ctx).map(|cp| Arc::downgrade(&cp.0))
106+
})
107+
.as_ref()
108+
.and_then(|weak| weak.upgrade().map(CachedPath))
104109
}
105110

106111
/// Find package.json of a path by traversing parent directories.
@@ -114,21 +119,21 @@ impl CachedPath {
114119
cache: &Cache<Fs>,
115120
ctx: &mut Ctx,
116121
) -> Result<Option<Arc<PackageJson>>, ResolveError> {
117-
let mut cache_value = self;
122+
let mut cache_value = self.clone();
118123
// Go up directories when the querying path is not a directory
119-
while !cache.is_dir(cache_value, ctx) {
120-
if let Some(cv) = &cache_value.parent {
124+
while !cache.is_dir(&cache_value, ctx) {
125+
if let Some(cv) = cache_value.parent() {
121126
cache_value = cv;
122127
} else {
123128
break;
124129
}
125130
}
126131
let mut cache_value = Some(cache_value);
127132
while let Some(cv) = cache_value {
128-
if let Some(package_json) = cache.get_package_json(cv, options, ctx)? {
133+
if let Some(package_json) = cache.get_package_json(&cv, options, ctx)? {
129134
return Ok(Some(package_json));
130135
}
131-
cache_value = cv.parent.as_ref();
136+
cache_value = cv.parent();
132137
}
133138
Ok(None)
134139
}

src/lib.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,13 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
304304
let inside_node_modules = cached_path.inside_node_modules();
305305
if inside_node_modules {
306306
let mut last = None;
307-
for cp in iter::successors(Some(cached_path), |cp| cp.parent()) {
307+
for cp in iter::successors(Some(cached_path.clone()), CachedPath::parent) {
308308
if cp.is_node_modules() {
309309
break;
310310
}
311-
if self.cache.is_dir(cp, ctx) {
311+
if self.cache.is_dir(&cp, ctx) {
312312
if let Some(package_json) =
313-
self.cache.get_package_json(cp, &self.options, ctx)?
313+
self.cache.get_package_json(&cp, &self.options, ctx)?
314314
{
315315
last = Some(package_json);
316316
}
@@ -852,13 +852,14 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
852852
// 1. let DIRS = NODE_MODULES_PATHS(START)
853853
// 2. for each DIR in DIRS:
854854
for module_name in &self.options.modules {
855-
for cached_path in std::iter::successors(Some(cached_path), |p| p.parent()) {
855+
for cached_path in std::iter::successors(Some(cached_path.clone()), CachedPath::parent)
856+
{
856857
// Skip if /path/to/node_modules does not exist
857-
if !self.cache.is_dir(cached_path, ctx) {
858+
if !self.cache.is_dir(&cached_path, ctx) {
858859
continue;
859860
}
860861

861-
let Some(cached_path) = self.get_module_directory(cached_path, module_name, ctx)
862+
let Some(cached_path) = self.get_module_directory(&cached_path, module_name, ctx)
862863
else {
863864
continue;
864865
};
@@ -884,7 +885,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
884885
// Skip if the directory lead to the scope package does not exist
885886
// i.e. `foo/node_modules/@scope` is not a directory for `foo/node_modules/@scope/package`
886887
if package_name.starts_with('@') {
887-
if let Some(path) = cached_path.parent() {
888+
if let Some(path) = cached_path.parent().as_ref() {
888889
if !self.cache.is_dir(path, ctx) {
889890
continue;
890891
}
@@ -1530,9 +1531,10 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
15301531

15311532
// 11. While parentURL is not the file system root,
15321533
for module_name in &self.options.modules {
1533-
for cached_path in std::iter::successors(Some(cached_path), |p| p.parent()) {
1534+
for cached_path in std::iter::successors(Some(cached_path.clone()), CachedPath::parent)
1535+
{
15341536
// 1. Let packageURL be the URL resolution of "node_modules/" concatenated with packageSpecifier, relative to parentURL.
1535-
let Some(cached_path) = self.get_module_directory(cached_path, module_name, ctx)
1537+
let Some(cached_path) = self.get_module_directory(&cached_path, module_name, ctx)
15361538
else {
15371539
continue;
15381540
};

src/tests/memory_leak.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ fn test_memory_leak_arc_cycles() {
1414
resolver.resolve(&f, "package-json-nested").unwrap();
1515

1616
// Populated cache - path is now owned in multiple places.
17-
assert_eq!(Arc::strong_count(&path.0), 4);
17+
assert_eq!(Arc::strong_count(&path.0), 2);
1818

1919
// Drop the resolver.
2020
drop(resolver);

0 commit comments

Comments
 (0)