Skip to content

paths: terminate recursive mkdir walk when a confirmed parent still yields ENOENT - #36162

Open
robobun wants to merge 13 commits into
mainfrom
farm/ea7dbce8/fix-makepath-spin
Open

paths: terminate recursive mkdir walk when a confirmed parent still yields ENOENT#36162
robobun wants to merge 13 commits into
mainfrom
farm/ea7dbce8/fix-makepath-spin

Conversation

@robobun

@robobun robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

bun install and the runtime auto-installer spin forever at ~50k mkdirat/s, burning a full core with no output, when the resolved install cache directory is inside a path whose parent exists but cannot host children (e.g. a dangling symlink to an unmounted disk, or a procfs path).

Repro

D=$(mktemp -d); cd "$D"
ln -s "$D/does-not-exist" dangling
mkdir proj && cd proj
printf '{"name":"x","version":"1.0.0","dependencies":{"any-pkg":"1.0.0"}}\n' > package.json
BUN_INSTALL_CACHE_DIR="$D/dangling/cache" BUN_CONFIG_REGISTRY=http://127.0.0.1:9/ \
  timeout 10 strace -f -c -e trace=mkdirat bun install
# rc=124; strace shows ~500k mkdirat calls, 100% ENOENT/EEXIST, still running at 10s

The raw loop:

mkdirat(".../dangling/cache") = -1 ENOENT
mkdirat(".../dangling")       = -1 EEXIST
mkdirat(".../dangling/cache") = -1 ENOENT
... forever

Cause

bun_paths::make_path_with drives the shared back-then-forward mkdir -p walk used by bun_sys::mkdir_recursive_at (POSIX and Windows) and the libarchive u16 variant. On ENOENT it steps back to the parent; on Created/EEXIST it steps forward. When the parent is a dangling symlink the walk oscillates forever. fs.mkdirSync(path, {recursive: true}) already throws ENOENT cleanly on the same path because node_fs::mkdir_recursive_os_path_impl runs a distinct forward pass where ENOENT is fatal.

Fix

  • make_path_with: once the walk has advanced forward (parent confirmed Created/Exists), a subsequent NotFound returns the error instead of stepping back. The normal "walk back to the first existing ancestor, then forward" path is unchanged.
  • ensure_cache_directory: when make_open_path on the resolved cache directory fails, surface the error instead of silently disabling the cache. CacheDir now carries is_explicit, true only when the path came from BUN_INSTALL_CACHE_DIR or bunfig's install.cache.dir:
    • explicit path: print error: cache directory "<path>" is not creatable: <errno> and exit.
    • implicit default ($BUN_INSTALL, $XDG_CACHE_HOME, $HOME, or the in-tree fallback): print warn: cache directory "<path>" is not creatable: <errno>, falling back to node_modules/.cache and continue. This preserves the existing fallback for unwritable $HOME/.bun (root-owned after sudo, read-only $HOME in containers).

After:

$ BUN_INSTALL_CACHE_DIR="$D/dangling/cache" bun install
bun install v1.4.0
error: cache directory "/tmp/.../dangling/cache" is not creatable: ENOENT
$ echo $?
1

$ HOME="$D/dangling" bun install    # no BUN_INSTALL_CACHE_DIR
bun install v1.4.0
warn: cache directory "/tmp/.../dangling/.bun/install/cache/" is not creatable: ENOENT, falling back to node_modules/.cache
Resolving dependencies
...

Verification

  • USE_SYSTEM_BUN=1 bun test test/cli/install/bun-install-cache-dir.test.ts: all three tests fail (processes spin, test-runner timeout).
  • bun bd test test/cli/install/bun-install-cache-dir.test.ts: all three pass in ~150-300ms, asserting signalCode === null and the exact error/warn text. The implicit-path test also asserts node_modules/.cache exists on disk and that resolution continued past cache init.
  • cargo test -p bun_paths component_iterator: new make_path_terminates_when_parent_exists_but_child_is_enoent unit test passes; positive-path walk test unchanged.
  • bun bd test test/js/node/fs/fs-mkdir.test.ts and test/cli/install/bun-install-retry.test.ts: still green.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install-cache-dir.test.ts

…ENOENT

The back-then-forward mkdir walk assumed ENOENT on a component always means
"parent missing". When the parent is a dangling symlink (or any path that
exists but cannot host children), the leaf returns ENOENT, the parent returns
EEXIST, and the walk oscillates between the two forever. bun install with
BUN_INSTALL_CACHE_DIR under a dangling symlink burned a full core issuing
~50k mkdirat/s with no output and no timeout.

Once the walk has advanced forward (parent confirmed Created/Exists), treat a
subsequent NotFound as terminal and return the error. This matches the
distinct forward pass in node_fs::mkdir_recursive_os_path_impl, where ENOENT
after a confirmed parent is already fatal.
@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; ready for review.

Reproduced with:

USE_SYSTEM_BUN=1 bun test test/cli/install/bun-install-cache-dir.test.ts   # all 4 spin → timeout
bun bd test test/cli/install/bun-install-cache-dir.test.ts                 # all 4 pass in ~200-300ms

Behavior on an uncreatable cache dir:

  • explicit (BUN_INSTALL_CACHE_DIR / --cache-dir / bunfig install.cache.dir): error: cache directory "<path>" is not creatable: ENOENT and exit 1
  • implicit ($HOME/$XDG_CACHE_HOME/$BUN_INSTALL): warn: ... falling back to node_modules/.cache (suppressed under --silent) and continue

CI (build 83770): the only failed job is darwin 14 x64 on test/js/bun/http/serve.test.ts ("request body backpressure > releases a paused request body when the handler responds without reading it"), which is marked [pre-existing] on main and unrelated to this diff. All other lanes passed (some after a retry). The new test/cli/install/bun-install-cache-dir.test.ts passed on every lane.

@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:18 PM PT - Jul 27th, 2026

@robobun, your commit 2c2c34b has 1 failures in Build #83770 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36162

That installs a local version of the PR into your bun-36162 executable, so you can run:

bun-36162 --bun

Comment thread src/paths/component_iterator.rs Outdated
Comment thread src/paths/component_iterator.rs Outdated
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 145b3da9-d529-4953-b753-4742b02372a4

📥 Commits

Reviewing files that changed from the base of the PR and between fbb5034 and 2c2c34b.

📒 Files selected for processing (2)
  • src/install/PackageManager/PackageManagerDirectories.rs
  • test/cli/install/bun-install-cache-dir.test.ts

Walkthrough

make_path_with now returns a NotFound error after forward traversal instead of repeatedly backtracking. Unit tests and a Bun install regression test verify termination for dangling-symlink-like cache paths.

Changes

Path walk termination

Layer / File(s) Summary
Iterator termination state machine
src/paths/component_iterator.rs
make_path_with documents and implements forward-progress tracking, terminates on post-advance NotFound, and tests dangling-symlink-like and back-then-forward traversal behavior.
CLI regression coverage
test/cli/install/bun-install-cache-dir.test.ts
Adds a non-Windows install test confirming a dangling-symlink cache path exits naturally with an error and non-zero status.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main fix: stopping recursive mkdir traversal when a confirmed parent still yields ENOENT.
Description check ✅ Passed The description covers the change and verification steps, even though it uses different headings than the template.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/install/bun-install-cache-dir.test.ts`:
- Line 15: Remove the per-test timeout configuration from the test declaration
for “bun install exits instead of spinning in mkdirat”. Keep the child-process
timeout guard unchanged and rely on the test runner’s default timeout.
- Around line 6-13: Update the regression-test comments around the
dangling-symlink coverage to contain only the confirmed issue URL; remove the
explanatory behavior, fallback, and Windows-skip comments while preserving the
test implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44680663-270b-4f10-9dad-7dfd5c494d9a

📥 Commits

Reviewing files that changed from the base of the PR and between 2a5855b and 3fcd2bc.

📒 Files selected for processing (2)
  • src/paths/component_iterator.rs
  • test/cli/install/bun-install-cache-dir.test.ts

Comment thread test/cli/install/bun-install-cache-dir.test.ts Outdated
Comment thread test/cli/install/bun-install-cache-dir.test.ts Outdated
Comment thread src/paths/component_iterator.rs Outdated
Comment thread src/paths/component_iterator.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — tight 3-line termination guard on the shared mkdir walk, with unit + integration coverage. The doc-comment nit inline is a comment-cop artifact, not a blocker.

Extended reasoning...

Overview

This PR fixes an infinite mkdirat loop in bun_paths::make_path_with, the shared back-then-forward walk driving bun_sys::mkdir_recursive_at (POSIX + Windows) and the libarchive u16 variant. When a path component exists (e.g. a dangling symlink) but its child returns ENOENT, the old walk oscillated forever between "parent exists → step forward" and "child ENOENT → step back". The fix adds a single advanced flag: once any step has moved forward, a subsequent NotFound returns the error instead of stepping back. Net logic change is 3 lines; the rest is two Rust unit tests and a spawned-install integration test.

Security risks

None. Pure path-walk state machine; no untrusted input parsing changed. The fix strictly reduces the state space — it converts an unbounded loop into an error return. It cannot make a previously-succeeding case loop.

Level of scrutiny

Medium — this is a shared low-level helper with several callers, so I traced them: mkdir_recursive_at_mode (posix at src/sys/lib.rs:2404, windows at :4025), Dir::make_path/make_open_path, File::open_mkdirs_at, and the libarchive Windows extractor. All map EEXIST → Exists and ENOENT → NotFound, so the new guard applies uniformly. The only behavioral delta beyond the bug case is a theoretical TOCTOU race (another process deletes a just-created intermediate dir mid-walk) — previously that would retry once, now it returns ENOENT. That matches Node's mkdir({recursive:true}) forward-pass semantics and is unfixable in general anyway; the infinite spin it replaces is strictly worse.

Other factors

  • The make_path_walks_back_then_forward unit test explicitly proves the normal deep-create path is unchanged, and make_path_terminates_... asserts exactly 3 calls for the dangling-symlink shape.
  • The integration test is a proper hang-guard: spawns with a 15s subprocess timeout, asserts signalCode === null first (so a regression shows as SIGTERM, not a generic timeout), drains both pipes concurrently, uses tempDir/bunEnv, and points at 127.0.0.1:1 so no external network. skipIf(isWindows) is reasonable since POSIX symlink semantics don't map cleanly and the Windows path is covered by the shared unit test.
  • All prior bot feedback (comment-cop, coderabbit) is resolved. The one remaining nit — stale doc comments on MakePathStep::NotFound / make_path_with — is the direct result of comment-cop rejecting three successive attempts to update them; the code is ~10 lines below and self-explanatory.

Comment thread src/paths/component_iterator.rs
Comment thread src/paths/component_iterator.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/cli/install/bun-install-cache-dir.test.ts (1)

18-36: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the regression assertion specific to the cache-path failure.

Because the registry is intentionally unreachable, any ordinary registry/network failure can satisfy the generic "error" and non-zero-exit assertions—even if BUN_INSTALL_CACHE_DIR is ignored or the dangling-symlink mkdir path is never exercised. Use a hermetic fixture that proves cache initialization was reached, or assert a cache-specific error/fallback filesystem effect.

As per coding guidelines, tests must prove they fail for the intended reason.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/install/bun-install-cache-dir.test.ts` around lines 18 - 36,
Strengthen the regression test around the Bun.spawn install flow so it verifies
the dangling BUN_INSTALL_CACHE_DIR path was actually initialized, rather than
relying on generic registry errors and a nonzero exit. Add a hermetic cache-path
assertion or fixture that distinguishes cache-directory handling from the
intentionally unreachable BUN_CONFIG_REGISTRY failure, while preserving the
existing process cleanup and outcome checks.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/cli/install/bun-install-cache-dir.test.ts`:
- Around line 18-36: Strengthen the regression test around the Bun.spawn install
flow so it verifies the dangling BUN_INSTALL_CACHE_DIR path was actually
initialized, rather than relying on generic registry errors and a nonzero exit.
Add a hermetic cache-path assertion or fixture that distinguishes
cache-directory handling from the intentionally unreachable BUN_CONFIG_REGISTRY
failure, while preserving the existing process cleanup and outcome checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f69d0010-aee2-417d-a79b-265fcb38116b

📥 Commits

Reviewing files that changed from the base of the PR and between 3fcd2bc and fbb5034.

📒 Files selected for processing (2)
  • src/paths/component_iterator.rs
  • test/cli/install/bun-install-cache-dir.test.ts

ensure_cache_directory previously swallowed the error and silently fell back
to node_modules/.cache. With the mkdir walk now returning ENOENT on a
dangling-symlink parent, surface that error to the user: print
'error: cache directory "<path>" is not creatable: <errno>' and exit. The
fallback loop is dropped; when Enable::CACHE is already off the
node_modules/.cache branch is unchanged.

Adds a runtime auto-install test variant alongside bun install.
Comment thread test/cli/install/bun-install-cache-dir.test.ts Outdated
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated
…lback for implicit defaults

An unwritable $HOME/.bun/install/cache (root-owned after a sudo install,
read-only $HOME in containers) previously fell back silently to
node_modules/.cache. Keep that working: CacheDir now carries is_explicit,
set only for BUN_INSTALL_CACHE_DIR and bunfig install.cache.dir. On open
failure: explicit path -> error + exit; implicit default -> warn naming the
path and errno, then fall through to node_modules/.cache.

Also switch to bun_fmt::quote for the path per local convention.
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated
Comment thread test/cli/install/bun-install-cache-dir.test.ts
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior review feedback has been addressed and the bug-hunting pass found nothing new. Deferring rather than approving because this touches a shared mkdir -p primitive (make_path_with, used by bun_sys::mkdir_recursive_at on POSIX/Windows and by libarchive extraction) and introduces a user-facing behavior change (explicit BUN_INSTALL_CACHE_DIR/--cache-dir/bunfig now hard-fails instead of silently falling back) — worth a human sign-off.

What was reviewed:

  • Traced the advanced guard through the normal back-then-forward walk, the dangling-symlink case, and a concurrent-delete race — the only behavior lost is retry-on-TOCTOU-delete, which is acceptable.
  • Checked all make_path_with callers (src/sys/lib.rs:2409, :4133, src/libarchive/lib.rs:1118) — all map ENOENT→NotFound/EEXIST→Exists, so the guard applies uniformly.
  • Verified CacheDir has no other construction sites and the second fetch_cache_directory_path caller (bun pm cache rm) doesn't read is_explicit.
Extended reasoning...

Overview

Three files: (1) src/paths/component_iterator.rs adds a 3-line advanced guard to make_path_with so a NotFound after the walk has confirmed a parent returns the error instead of oscillating forever, plus two unit tests; (2) src/install/PackageManager/PackageManagerDirectories.rs restructures ensure_cache_directory from a loop/continue to straight-line, adds CacheDir::is_explicit, and branches error handling on it (explicit → error: + Global::crash(); implicit → warn: gated on log_level != Silent + fall through to node_modules/.cache); (3) a new 4-test file exercising env-var, bunfig, runtime auto-install, and the implicit-$HOME fallback.

Security risks

None. No untrusted-input parsing was added; the path is already user-controlled and only flows into mkdirat as before. The change tightens termination and error reporting.

Level of scrutiny

Medium-high. make_path_with is the single back-then-forward walk used by every recursive-mkdir path in the runtime — POSIX mkdir_recursive_at, the Windows NtCreateFile variant, and libarchive tar extraction. The logic change is small and I traced it against the normal walk (unchanged), the dangling-symlink case (now terminates in 3 calls, matching the unit test), and the concurrent-delete race (now returns ENOENT instead of retrying — arguably correct, but a behavior change on a shared primitive). Separately, the ensure_cache_directory restructure is a deliberate user-facing behavior change: an explicitly-configured-but-uncreatable cache directory now aborts bun install where it previously fell back silently. That matches REVIEW.md's "operations the user explicitly requested fail" rule and I raised the implicit-path-regression concern earlier (now addressed via is_explicit), but a maintainer should confirm the explicit-path hard-fail is the desired product behavior.

Other factors

This PR has been through five rounds of review feedback, all addressed: implicit-path fallback preserved (d2a2740), bun_fmt::quote for path quoting (d2a2740), dead cache_directory_path = b"" write removed (38ee46c), bunfig-spelling test added (a23676c), --silent gate on the warn (5456aa1), and doc-comment enumeration completed (2c2c34b). Tests use test.concurrent, tempDir, timeout: 15_000 as a hang guard with signalCode === null assertions, and a dead registry so no network is contacted. The suite is skipIf(isWindows) (dangling POSIX symlinks); Windows coverage of the primitive change relies on the cargo test unit tests plus CI.

Comment thread src/install/PackageManager/PackageManagerDirectories.rs
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Two more ways into the same loop, for the record. Both go through make_path_with, so the component_iterator.rs change in this PR covers them; nothing extra is needed for either.

  1. Released 1.4.0 on Linux, no install involved:

    echo 'console.log(1)' > entry.js
    ln -s "$PWD/does-not-exist" dangling
    bun build ./entry.js --outdir dangling/a/b   # spins forever, ~90% of its time in the kernel
    bun build ./entry.js --outdir dangling       # one component: fails cleanly with ENOENT "dangling"

    make_open_path gets ENOENT from the open, then the walk bounces between dangling (EEXIST) and dangling/a (ENOENT).

  2. Windows: mkdir of a component longer than NTFS allows (255 chars) is reported as ENOENT rather than ENAMETOOLONG (fs.mkdirSync("x".repeat(300)) throws ENOENT there, same as Node), so the walk bounces between the existing parent and the uncreatable component. Seen as bun install --linker isolated hanging after "Resolved, downloaded and extracted" when a store entry name is a single ~320 char component (a package.json depending on "./" + "x/../".repeat(60) + "bar-0.0.2.tgz", with the tarball from test/cli/install). The same install on Linux exits 1 with ENAMETOOLONG. The hoisted installer, bin linking and libarchive extraction reach the same walk with per-entry directory names, so a package containing an over-long directory name triggers it as well. install: keep isolated store entry names within NAME_MAX #37470 caps store entry names, which removes that particular trigger but not the loop.

Unrelated to the above: the branch currently conflicts with main in src/install/PackageManager/PackageManagerDirectories.rs (CacheDir.is_node_modules was removed on main next to where is_explicit is added). component_iterator.rs still merges cleanly.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

One more trigger for the same make_path_with oscillation, observed in CI rather than via a symlink: on Windows a plain regular file in place of an intermediate output directory is enough. bun_sys::mkdirat there is NtCreateFile(FILE_CREATE | FILE_DIRECTORY_FILE), so mkdir("file\\sub") maps to ENOENT (STATUS_OBJECT_PATH_NOT_FOUND) and mkdir("file") to EEXIST, and the walk spins exactly as described above. POSIX only avoids that shape because mkdirat returns ENOTDIR for it; the dangling symlink case in this PR reproduces on both.

Seen on the windows-2019-x64 and windows-11-aarch64 lanes of #39295 (build 99109): a bake production test pre-created dist/_bun as a file and then ran bun build --app --debug-no-minify, whose outputs live under dist/_bun/bun-framework-react/, and the process sat at 100% CPU until the 90s test timeout. That test was changed to avoid the shape, so nothing else fixes this. Confirmed the bun build --outdir variant still hangs with 1.4.0 on Linux when the outdir's parent is a dangling symlink.

The advanced guard here terminates that case too (leaf ENOENT, parent EEXIST, leaf ENOENT, error), with ENOENT as the surfaced errno, which is what Win32 itself reports for it. The src/paths/component_iterator.rs hunk still applies cleanly to current main; the merge conflict is only in PackageManagerDirectories.rs, where main has since dropped CacheDir.is_node_modules.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants