perf(cache): reuse the child path when its parent canonicalizes to itself#1288
Conversation
…self When no ancestor of a path is a symlink, canonicalizing the parent yields the parent's own cache entry, and rebuilding the child key via strip_prefix + normalize_with (scratch-buffer copy, full-path hash, shard probe) reproduces the child itself. Detect this with Arc::ptr_eq plus a byte-length/separator guard and reuse the child entry directly. Keys shaped other than <parent><MAIN_SEPARATOR><file_name> (.. or . tails, trailing/doubled separators, forward-slash joints on Windows) fail the guard and take the rebuild path, which folds them as before.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1288 +/- ##
==========================================
+ Coverage 94.05% 94.07% +0.01%
==========================================
Files 21 21
Lines 4275 4285 +10
==========================================
+ Hits 4021 4031 +10
Misses 254 254 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Merging this PR will improve performance by 7.74%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e201289ea1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…alize fast path A root parent already ends with the separator, so rebuilding the child adds no joint separator and the guard's + 1 counted a doubled separator after the root instead: //x (or C:\\x) reused the dirty key rather than folding it to /x the way strip_prefix + normalize_with does. Require the byte before the joint to be a non-separator, which sends root children down the rebuild path. Path's component-based PartialEq treats //x and /x as equal, so the dirty-key test now compares canonicalization results as OsStr bytes, and covers root-adjacent doubled separators on both Unix and Windows.
|
Note on the CodSpeed report for 7f00a33: the flagged |
Component normalization trims the trailing NULs uvwasi can leave in directory entries; reusing the key verbatim would keep them. Also reword the fast-path comment around the append-to-the-canonical-parent model.
## 🤖 New release * `oxc_resolver`: 11.23.0 -> 11.24.0 * `oxc_resolver_napi`: 11.23.0 -> 11.24.0 <details><summary><i><b>Changelog</b></i></summary><p> ## `oxc_resolver` <blockquote> ## [11.24.0](v11.23.0...v11.24.0) - 2026-07-12 ### <!-- 0 -->🚀 Features - expose tsconfig paths resolve method without file existence check ([#1282](#1282)) (by @sapphi-red) - add `ResolveError::TsconfigLoadFailed` for tsconfig read and parse failures ([#1287](#1287)) (by @shulaoda) ### <!-- 2 -->🚜 Refactor - replace cfg-if dependency with std cfg_select! macro ([#1290](#1290)) (by @Boshen) ### <!-- 3 -->📚 Documentation - normalize README sponsor section ([#1273](#1273)) (by @Boshen) ### <!-- 4 -->⚡ Performance - *(cache)* reuse the child path when its parent canonicalizes to itself ([#1288](#1288)) (by @Boshen) - *(napi)* shrink release binaries (path remap + build-std without backtrace) ([#1283](#1283)) (by @Boshen) ### Contributors * @sapphi-red * @Boshen * @renovate[bot] * @shulaoda </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). Co-authored-by: oxc-guard[bot] <276638029+oxc-guard[bot]@users.noreply.github.com>
## 🤖 New release * `oxc_resolver`: 11.24.0 -> 11.24.1 * `oxc_resolver_napi`: 11.24.0 -> 11.24.1 <details><summary><i><b>Changelog</b></i></summary><p> ## `oxc_resolver` <blockquote> ## [11.24.0](v11.23.0...v11.24.0) - 2026-07-12 ### <!-- 0 -->🚀 Features - expose tsconfig paths resolve method without file existence check ([#1282](#1282)) (by @sapphi-red) - add `ResolveError::TsconfigLoadFailed` for tsconfig read and parse failures ([#1287](#1287)) (by @shulaoda) ### <!-- 2 -->🚜 Refactor - replace cfg-if dependency with std cfg_select! macro ([#1290](#1290)) (by @Boshen) ### <!-- 3 -->📚 Documentation - normalize README sponsor section ([#1273](#1273)) (by @Boshen) ### <!-- 4 -->⚡ Performance - *(cache)* reuse the child path when its parent canonicalizes to itself ([#1288](#1288)) (by @Boshen) - *(napi)* shrink release binaries (path remap + build-std without backtrace) ([#1283](#1283)) (by @Boshen) ### Contributors * @sapphi-red * @Boshen * @renovate[bot] * @shulaoda </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). Co-authored-by: oxc-guard[bot] <276638029+oxc-guard[bot]@users.noreply.github.com>
Summary
Each level of
canonicalize_with_visiteddoes one job: append the path's last component to whatever its parent canonicalized into — today viastrip_prefix(O(len) component walk) +normalize_with(scratch-buffer copy of the whole path, full-path FxHash, dashmap shard probe).When no ancestor is a symlink — the common case — the parent canonicalizes to itself, so that append can only rebuild the child's own key, and the shard probe is guaranteed to hand back the entry we already hold: four O(len) operations computing the identity function. This PR detects that case and returns the entry directly, per non-symlink level of every first-time canonicalization.
Example
pnpm project,
node_modules/lodash → .pnpm/[email protected]/node_modules/lodash; canonicalize/app/node_modules/lodash/index.js(parent-first recursion):/app/node_modules: parent/appcanonicalized to itself → the rebuild could only reproduce/app/node_modules→ fast path returns the entry as-is. Most levels of most paths look like this./app/node_modules/lodash: fast path for the string, then the (unchanged) symlink check lstats it, follows the link, and its canonical becomes the.pnpmentry./app/node_modules/lodash/index.js: the parent's canonical is now a different entry →ptr_eqfails → the rebuild does real work, appendingindex.jsto the.pnpmparent. The rebuild runs exactly where its output differs from its input.Why the guard is sound
The cache interns paths — one
Arc<CachedPathImpl>per byte string — soArc::ptr_eq(&parent_canonical.0, &parent.0)⟺ canonicalizing the parent changed nothing ⟺ no ancestor was rewritten by a symlink. Under that precondition the rebuild returnspath's own Arc iff the rebuilt string is byte-identical topath's key.PathBuf::pushof one normal component appendsMAIN_SEPARATOR+ name, except it appends no separator when the base already ends with one (a root). So the fast path is restricted to keys shaped exactly<parent><one native separator><file_name>, checked in O(1):file_name().is_some()— excludes./..tails, where the rebuild folds (/a/b/..→/a);parent_len + 1 + name.len() == path_len— excludes trailing slashes and doubled separators mid-path;path_bytes[parent_len] == MAIN_SEPARATOR— excludes mixed joints on Windows (C:\proj/src, where push would emit\);path_bytes[parent_len − 1] != MAIN_SEPARATOR— excludes root-adjacent doubled separators (//x,C:\\x), where push appends no joint separator and the+ 1would otherwise count the doubled separator itself (caught in review; clean root children like/usralready failed the length check, so no fast-path coverage is lost);Keys failing the guard are exactly the spellings the rebuild would fold rather than reproduce — they take the rebuild path as before. The symlink check on the resulting entry is unchanged; only the string computation is skipped.
Correctness
canonicalize_matches_os_for_all_node_modules: 12,441 paths across 7 installed bench-pm combos (npm/pnpm/yarn/bun × flat/isolated/hoisted) matchstd::fs::canonicalizebyte-for-byte.canonicalize_dirty_cache_keystest covers every guard exclusion (./..tails and mid-chain, trailing, doubled, and root-adjacent doubled separators on Unix and Windows) against the OS oracle. It comparesOsStrbytes, becausePath's component-basedPartialEqtreats//xand/xas equal and would mask separator-level differences; the root-adjacent cases fail on the unfixed fast path and pass with the guard.--deny warnings, fmt, on all CI platforms including Windows and big-endian.Benchmarks
CodSpeed (instrumented instruction count, cold-cache pm workloads that exercise this path):
The flagged
resolver_real[multi-thread]−6.2% is measurement slop: that bench reuses one resolver across iterations, so its steady state never reaches the changed code (thecanonicalizedOnceLock returns before the guard), and the same bench was untouched on e201289, which contains the identical fast path. Local wall-clock A/B (interleaved fixed binaries on a quiet machine, change measured first) agreed in direction: −1% to −3% on 7/8 pm combos, yarn-pnp neutral (dominated by.pnp.cjsparsing).🤖 Generated with Claude Code