Skip to content

node:fs: recursive rm continues past a child that raced to ENOENT - #35800

Open
robobun wants to merge 6 commits into
mainfrom
farm/0559581d/rm-recursive-child-enoent
Open

robobun wants to merge 6 commits into
mainfrom
farm/0559581d/rm-recursive-child-enoent

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

fs.rm(path, { recursive: true }) aborts mid-walk when an operation on a child entry returns ENOENT, which happens when another process deletes the entry between readdir and the follow-up unlinkat/openat/getdents64. With force: true the returned ENOENT is mistaken for "root path missing" and swallowed, so rm returns success with the rest of the tree (including the root) still on disk. With force: false it reports a misleading top-level ENOENT.

Repro

Deterministic via an LD_PRELOAD shim that makes unlinkat on one child remove the file and then report ENOENT (simulating a concurrent unlink):

force:true force:false
bun before ok, root still exists ENOENT, root still exists
bun after ok, root gone ok, root gone
node promise/cb ok, root gone ok, root gone
node rmSync ok, root still exists ok, root still exists

Node's callback/promise rm (lib/internal/fs/rimraf.js) treats a child ENOENT as "already gone" and keeps walking. Node's native rmSync (the C++ binding.rmSync using std::filesystem) has the same silent-abort as Bun did; since Bun uses one walk for all three entry points, we match the rimraf semantics so rmSync / promises.rm / callback rm agree and don't leave a tree on disk after reporting success.

Cause

zig_delete_tree (src/runtime/node/node_fs.rs) had no ENOENT arm at any of the child-level sites in its main 16-slot stack loop: the directory iterator, dt_open_dir on a directory child, dt_delete_file on a file child, and the stack-full fallback into the min_stack variant. Any child ENOENT hit the catch-all return Err(...) and unwound the whole walk. The min_stack variant's inner loop already had Err(E::ENOENT) => continue 'dir_it at its dt_open_dir/dt_delete_file sites; its iterator had the same gap.

Fix

Treat a child-level ENOENT as "already gone" at every site in both variants, the same way the min_stack inner loop already did: move on to the next entry (or treat it as end-of-directory for the iterator), falling through to the existing parent rmdir which already tolerates ENOENT.

Verification

test/js/node/fs/rm-recursive-child-enoent.test.ts compiles the shim and runs rmSync / promises.rm / callback rm with and without force: 6 cases, all of which fail on main (root left on disk / spurious ENOENT) and pass with this change. test/js/node/fs/fs.test.ts -t '^rm' and the vendored test-fs-rm*.js pass. cargo check is green on linux/macos/windows.

Only the dt_delete_file arm is directly exercised by the LD_PRELOAD shim: unlinkat goes through libc (src/sys/lib.rs), but openat and getdents64 on Linux use rustix's raw-syscall backend with no libc import for LD_PRELOAD to intercept. The other arms are the same one-line treatment and mirror the min_stack inner loop's pre-existing handling.

Discovered during review of #35749; orthogonal to that change (which rewrites the error plumbing around these same matches).

Fixes #42062 (macOS exFAT: unlinking name also removes the AppleDouble ._name sibling, so the walk hits ENOENT on the sibling and stops).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/fs/rm-recursive-child-enoent.test.ts

A child entry that disappears between readdir() and unlinkat()/openat()
(concurrent delete) made the main stack loop in zig_delete_tree return
ENOENT for the whole tree. With force:true, rm() treated that as "root
path missing" and returned success with the tree still on disk; with
force:false it reported a misleading top-level ENOENT.

The min_stack variant of the same walk already skipped a child ENOENT
and kept going; the main 16-slot stack loop did not. Add the matching
ENOENT arms to its dt_delete_file and dt_open_dir child matches.

This matches Node's lib/internal/fs/rimraf.js, which callback/promise
fs.rm route through: a child unlink that reports ENOENT is treated as
"already gone" and the walk continues.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a8898437-abd5-4916-b198-28d76b7a8fe7

📥 Commits

Reviewing files that changed from the base of the PR and between 4ff9193 and 96d069a.

📒 Files selected for processing (2)
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/rm-recursive-child-enoent.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


Walkthrough

Changes

Recursive deletion race handling

Layer / File(s) Summary
Handle missing entries during deletion
src/runtime/node/node_fs.rs
zig_delete_tree and the minimal-stack fallback now treat ENOENT during iteration, directory opening, and file deletion as successful handling of a missing entry.
Validate disappearing child entries
test/js/node/fs/rm-recursive-child-enoent.test.ts
A Linux/compiler-gated test uses an unlinkat shim to trigger ENOENT and checks synchronous, promise-based, and callback-based removal with both force settings.

Suggested reviewers: jarred-sumner, dylan-conway

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 96d06

Recursive removal now continues when a child disappears during traversal, with regression coverage for sync, promise, and callback APIs. No merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes directly address issue #42062 by treating child-level ENOENT as already removed, continuing traversal, removing the root directory, and avoiding misleading errors.
Out of Scope Changes check ✅ Passed The Rust changes and Linux regression test are directly related to recursive fs.rm ENOENT handling and the linked exFAT issue. No unrelated code changes are evident.
Title check ✅ Passed The title clearly summarizes the main change: recursive removal continues when a child races to ENOENT.
Description check ✅ Passed The description explains the problem, cause, fix, affected APIs, linked issue, and verification. It uses different headings from the template, but it provides the required information and is complete.

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:21 PM PT - Sep 9th, 2026

⏳ @Jarred-Sumner, your commit 96d069a is still building in Build #113626, but has 2 failures so far (All Failures):

Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread src/runtime/node/node_fs.rs Outdated

@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: 3

🤖 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/js/node/fs/rm-recursive-child-enoent.test.ts`:
- Around line 1-9: Remove the multi-line narrative comment above the regression
test in rm-recursive-child-enoent.test.ts. Keep the test coverage unchanged; do
not replace it with non-URL commentary, and add an issue URL only if one is
available.
- Around line 102-105: Update the subprocess helper and its callers around the
fixture-result parsing to return both the parsed JSON result and exit code
without asserting exitCode there. In each test, assert the expected removal
behavior from the parsed result first, then assert exitCode equals 0 as the
final assertion.
- Around line 49-50: Extend the recursive-removal fixture around the existing
GHOST and keep-d.txt setup to include a child directory that disappears before
opening, causing the dt_open_dir ENOENT path in zig_delete_tree to execute. Run
this disappearing-directory scenario through the same API and force-option
matrix already used by the test, preserving the existing file-based unlinkat
ENOENT coverage.
🪄 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: 10f1172b-78df-455a-93e3-ffaa65bb7910

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 4404dd1.

📒 Files selected for processing (2)
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/rm-recursive-child-enoent.test.ts

Comment thread test/js/node/fs/rm-recursive-child-enoent.test.ts Outdated
Comment thread test/js/node/fs/rm-recursive-child-enoent.test.ts
Comment thread test/js/node/fs/rm-recursive-child-enoent.test.ts Outdated
Comment thread src/runtime/node/node_fs.rs Outdated
Comment thread test/js/node/fs/rm-recursive-child-enoent.test.ts
Also covers the stack-full fallback that delegates to the min_stack
variant: its open_initial_subpath call propagates ENOENT for the child
entry, which is now treated the same as the other two sites.

Test: drop narrative header, assert stdout/stderr/exitCode together via
toEqual, and add a depth-18 variant that reaches the min_stack fallback.
Comment thread test/js/node/fs/rm-recursive-child-enoent.test.ts Outdated
getdents64 on a directory that is concurrently removed can return ENOENT
(observed on tmpfs/overlayfs). Handle it at both iterator sites the same
way: treat as end-of-directory and fall through to the parent rmdir,
which already tolerates ENOENT.

The depth-18 test cases hit the pre-existing min_stack inner-loop ENOENT
arm and passed on main unchanged, so drop them.

@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 — the child-level ENOENT arms mirror the pre-existing min_stack inner-loop handling, and my earlier feedback (stack-full fallback, depth-18 cases that passed on main) has been addressed in a497009 / 99a61fb.

Reviewed: the five new ENOENT sites in zig_delete_tree / min_stack — each falls through to a parent rmdir that already tolerates ENOENT, so no case leaves an fd open or skips cleanup. Confirmed dt_err(ENOENT) → crate::Error::FileNotFound, so the fallback match at 9857 is correct. Test covers sync/promise/callback × force/noforce via LD_PRELOAD unlinkat; the un-shimmable arms (openat/getdents64 go through rustix raw syscalls) are one-line symmetry with lines 10086/10095 which have shipped since the function was written.

Extended reasoning...

Overview

Adds ENOENT handling at five child-level sites in zig_delete_tree (src/runtime/node/node_fs.rs): the main-loop iterator, dt_open_dir, the stack-full min_stack fallback, dt_delete_file, and the min_stack iterator. Each treats a child that vanished between readdir and the follow-up syscall as already gone and continues the walk. Ships a Linux-only LD_PRELOAD test covering rmSync/promises.rm/callback rm × force/no-force.

Security risks

None. This makes recursive removal more tolerant of a specific race (concurrent unlink of a child), never less strict. No new syscalls, no path handling changes, no user-controlled input reaching new code paths.

Level of scrutiny

Medium. fs.rm({recursive}) is core node:fs surface, but the change is ~15 net lines confined to one function's error-match arms, and each new arm is byte-for-byte symmetry with the pre-existing Err(E::ENOENT) => continue 'dir_it at min_stack's inner loop (lines 10086/10095). I traced control flow after each new break: the iterator arms fall through to the pop-and-rmdir which already has Err(E::ENOENT) => {} at line 9921; the 'handle_entry breaks proceed to the next entry. Verified dt_err(E::ENOENT) maps to crate::Error::FileNotFound (line 9646), so the stack-full fallback's Err(crate::Error::FileNotFound) pattern is the right comparand for what open_initial_subpath propagates.

Other factors

This PR has already been through three rounds of my review: I flagged the missing stack-full-fallback arm (fixed in a497009), the dt_open_dir coverage gap (explained as unshimmable — openat goes through rustix's raw-syscall backend with no libc import), and the depth-18 cases that passed on main (dropped in 99a61fb). All threads resolved. The remaining six depth-1 cases fail on main and pass with the fix. The deliberate divergence from Node's native rmSync (which has the same silent-abort bug) in favor of rimraf semantics is well-argued in the description — Bun uses one walk for all three entry points, and "reports success with the tree still on disk" is unambiguously wrong. Standalone test file placement matches existing precedent (rm-windows-ntstatus.test.ts).

@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.

No remaining concerns from me — the depth-18 cases were dropped in 99a61fb as suggested, and the five new ENOENT arms are each a one-line "entry already gone → continue" that mirrors the pre-existing min_stack inner-loop handling. Deferring for a maintainer to sign off on the Node-compat call (Bun's rmSync now matches rimraf rather than Node's native rmSync) and on sequencing with #35749, which rewrites error plumbing around these same matches.

What was reviewed: traced each new arm against its min_stack counterpart and the pop-site dt_delete_dir (which already tolerates ENOENT at :9921); confirmed the iterator-ENOENT → break falls through to that tolerant rmdir; verified only unlinkat is libc-routed on Linux so the dt_delete_file arm is the only one the LD_PRELOAD test can deterministically hit.

Extended reasoning...

Overview

Adds Err(E::ENOENT) handling at five child-level sites in zig_delete_tree / zig_delete_tree_min_stack_size_with_kind_hint (src/runtime/node/node_fs.rs) so a child that races to ENOENT between readdir and the follow-up syscall is treated as already gone rather than aborting the whole recursive walk. Adds a Linux-only LD_PRELOAD test (test/js/node/fs/rm-recursive-child-enoent.test.ts) that shims unlinkat to deterministically simulate the race across rmSync / promises.rm / callback rm × force on/off.

Review history

Three prior rounds of my review were all addressed: the stack-full fallback now catches FileNotFound (a497009); the depth-18 test dimension that didn't exercise the new arm was dropped (99a61fb); the dt_open_dir / iterator / fallback arms remain without direct test coverage because openat and getdents64 go through rustix's raw-syscall backend on Linux and are not LD_PRELOAD-interceptable — this is documented in the PR description and each arm is a one-line mirror of the pre-existing Err(E::ENOENT) => continue 'dir_it in the min_stack variant's inner loop. I also confirmed the pop-site dt_delete_dir at :9919-9921 already has an Err(E::ENOENT) => {} arm, so the new iterator break falls through to a tolerant parent rmdir as the description claims.

Security risks

None identified. The change only relaxes handling of ENOENT on a child entry during a recursive delete the caller already requested; it cannot delete anything outside the target tree and cannot mask a non-ENOENT error (each new arm matches E::ENOENT / FileNotFound specifically).

Level of scrutiny

Medium-high — recursive fs.rm is a destructive, widely-used operation. That said, the diff is ~20 lines of match-arm additions following an established in-file pattern, and the failure mode being fixed (silent success with the tree still on disk) is strictly worse than the new behavior.

Why defer rather than approve

Two things a maintainer should be aware of at merge time, neither a correctness concern with this diff:

  1. Node-compat semantics: the PR deliberately makes Bun's rmSync match Node's rimraf (callback/promise) behavior rather than Node's native rmSync (which has the same silent-abort bug). The argument is sound — Bun uses one walk for all three entry points and shouldn't leave trees on disk after reporting success — but it's a documented divergence from one Node entry point worth a human ack.
  2. #35749 overlap: that PR rewrites the error plumbing around these same match statements. The author flags them as orthogonal; a maintainer merging should decide sequencing.

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Rebased on main at 96d069a. Build #113626 has two red tests, neither in fs: test/bake/deinitialization.test.ts (Windows segfault at exit of the dev server fixture, see #39643 and #41191) and test/js/bun/http/serve-pending-promise-abort-leak.test.ts (GC weak ref flake on the ASAN lane, see #40539 and #41409). The earlier build #81858 on this diff was green on all 170 jobs.

Ready for review.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes #42062, a deterministic case of the same bug that needs no race. On a macOS exFAT volume (FSKit), unlinking f0.bin makes the kernel drop its AppleDouble sibling ._f0.bin. Bun's readdir lists the data file first, so the walk unlinks f0.bin, then gets ENOENT on ._f0.bin and stops. With force: true the call returns success with most of the tree still on disk. The Err(E::ENOENT) => break 'handle_entry arm at the dt_delete_file site in this PR is the path that case hits.

Reproduced on Linux with an unlinkat shim that removes ._name when name is unlinked: bun 1.4.3 leaves 2N-2 entries behind, as the issue reports.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

fs.rmSync(dir, { recursive: true }) on an exFAT volume deletes one entry and stops silently (macOS, FSKit exFAT)

2 participants