Skip to content

shell(rm): close the DirTask parent/child hand-off lost-wakeup#34032

Open
robobun wants to merge 5 commits into
mainfrom
farm/5b0f204b/shell-rm-handoff-race
Open

shell(rm): close the DirTask parent/child hand-off lost-wakeup#34032
robobun wants to merge 5 commits into
mainfrom
farm/5b0f204b/shell-rm-handoff-race

Conversation

@robobun

@robobun robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Closes the lost-wakeup race in the shell rm -rf DirTask parent/child join that was explicitly called out as out of scope in #32617 (85ef0c5):

There is a separate, pre-existing few-instruction window between subtask_count.load(SeqCst) > 1 and need_to_wait.store(true, SeqCst) where the last child can decrement and read need_to_wait == false. That race predates this change ... closing it cleanly requires reworking the post_run join (e.g. parent participates in the same fetch_sub instead of check-then-publish).

Mechanism

remove_entry_dir ended with

if dt.subtask_count.load(SeqCst) > 1 {
    *need_to_wait_out = true;
    dt.need_to_wait.store(true, SeqCst);
    return Ok(());
}

while a finishing child in post_run did

let tasks_left = p.subtask_count.fetch_sub(1, SeqCst);
let parent_still = !p.need_to_wait.load(SeqCst);
if !parent_still && tasks_left == 2 {
    Self::delete_after_waiting_for_children(me.parent_task);
}

When the last child slipped between the parent's load and store: the child decremented (tasks_left == 2), read need_to_wait == false, and skipped delete_after_waiting_for_children; the parent then stored true and returned waiting == true, so run_from_thread_pool_impl skipped post_run too. The parent DirTask was stranded with subtask_count == 1, need_to_wait == true, finish_concurrently never fired, and the shell promise never resolved.

A directory containing exactly one subdirectory (e.g. the foo/bar tree in test/js/bun/shell/leak.test.ts's fdleak_rm) is the minimal trigger.

Fix

The parent now publishes need_to_wait = true first, then releases its own slot on subtask_count with the same fetch_sub the children use: whoever takes the counter to 0 owns the rmdir. If the parent's fetch_sub returns 1, every child has already released (and each saw the counter > 1, so none took ownership); the parent restores subtask_count = 1 / need_to_wait = false and falls through to the inline delete so post_run runs as before. Otherwise a child still holds a count and the child taking it to 0 drives delete_after_waiting_for_children, which now also restores subtask_count = 1 so post_run's own decrement (and any re-enqueue from remove_entry_dir_after_children) starts from the expected value.

The child side drops the separate need_to_wait load: the SeqCst RMW chain on subtask_count already provides the release/acquire that makes the parent's deleted_entries writes visible to the winner.

Why this surfaced as a leak.test.ts red

fd leak > fdleak_rm runs 100 iterations of mkdir foo; touch ...; mkdir foo/bar; touch ...; rm -rfv foo/ in a subprocess with no per-iteration timeout; when one rm hit this race the subprocess hung, and the outer test timed out at 100 s. Recent hits on PR builds off current main:

build lane result
72115 darwin 14 aarch64 fdleak_rm 100 s timeout (went red because earlier fixtures' crash reports were attributed here and isAlwaysFailure blocked the per-file retry)
72143 windows 11 aarch64 fdleak_rm 100 s timeout
71942 windows 2019 x64 memleak_* iteration timed out at 5 s mid-run
71939 windows 11 aarch64 memleak_* iteration timed out
71915 windows 11 aarch64 fdleak_rm 100 s timeout

How did you verify your code works?

  • bun bd test test/js/bun/shell/commands/rm.test.ts (6 pass), bunshell.test.ts (399 pass), leak.test.ts rm/ls cases pass.
  • bun run rust:check-all clean across all targets.
  • Added a stress probe in rm.test.ts that runs 800 concurrent rm -rfv of the minimal one-subdir tree with a 10 s per-call watchdog.

Fail-before

This race is a few-instruction interleaving and I could not trigger it on the linux-x64 gate host: a 64 000-iteration probe (rm -rf of the minimal tree, 32-wide) against the unfixed release binary ran to completion without a hang. The CI hits above are the fail-before evidence; the new test is a probe that should catch any future regression on the arm64 lanes where scheduling makes the window observable.


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/bun/shell/leak.test.ts test/js/bun/shell/commands/rm.test.ts

The recursive rm hand-off in remove_entry_dir did

    if subtask_count.load() > 1 { need_to_wait.store(true); return waiting }

while a finishing child in post_run did

    tasks_left = parent.subtask_count.fetch_sub(1);
    if parent.need_to_wait.load() && tasks_left == 2 { take over parent }

If the last child slipped between the parent's load and store it would
decrement, read need_to_wait == false, and skip; the parent then stored
true and returned waiting == true, so run_from_thread_pool_impl skipped
post_run too. Nothing ever drove delete_after_waiting_for_children on
the parent, and the ShellRmTask (and so the shell promise) never
completed. This is the "pre-existing few-instruction window" called out
as out of scope in 85ef0c5.

Fix: the parent now publishes need_to_wait first, then releases its own
slot on subtask_count with the same fetch_sub the children use. Whoever
takes the counter to 0 owns the rmdir. If the parent wins it restores
the invariants post_run expects and falls through to the inline delete;
if a child wins it calls delete_after_waiting_for_children, which now
restores subtask_count = 1 before re-running post_run.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:37 AM PT - Jul 12th, 2026

@robobun, your commit 100760e has 1 failures in Build #72199 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34032

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

bun-34032 --bun

@coderabbitai

coderabbitai Bot commented Jul 12, 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: 2a0d6ac7-4759-4b03-a27b-b9f5a4d2b197

📥 Commits

Reviewing files that changed from the base of the PR and between 68579a9 and 100760e.

📒 Files selected for processing (2)
  • src/runtime/shell/builtin/rm.rs
  • test/js/bun/shell/commands/rm.test.ts

Walkthrough

Changes

The recursive rm task protocol now transfers child-wait ownership through atomic subtask_count updates, restores task state after waiting, and adds a concurrent stress test for recursive deletion hangs.

Recursive rm hand-off

Layer / File(s) Summary
Task ownership and deletion synchronization
src/runtime/shell/builtin/rm.rs
Updates hand-off, parent deletion, and post-wait state restoration logic, with documentation describing the revised synchronization ordering.
Concurrent recursive rm stress coverage
test/js/bun/shell/commands/rm.test.ts
Runs concurrent recursive removals in a spawned Bun process and verifies completion, output, stderr, and exit status.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: fixing the DirTask hand-off lost-wakeup in rm.
Description check ✅ Passed The description matches the required template and includes both the PR purpose and verification details with substantial implementation context.
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.

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: 1

🤖 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/bun/shell/commands/rm.test.ts`:
- Around line 161-163: Change the temporary-directory cleanup registered near
base in the rm handoff test from the beforeExit event to the exit event,
preserving the synchronous rmSync call and its recursive/force options so
cleanup runs on process.exit(1) paths as well.
🪄 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: 048e63e5-4a5b-4f69-a07d-eedd589b5c4f

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2230a and 8236685.

📒 Files selected for processing (2)
  • src/runtime/shell/builtin/rm.rs
  • test/js/bun/shell/commands/rm.test.ts

Comment thread test/js/bun/shell/commands/rm.test.ts Outdated
beforeExit does not fire on the process.exit(1) failure paths.

@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 (2)
test/js/bun/shell/commands/rm.test.ts (2)

9-9: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use the harness-managed tempDir for the fixture root.

The test creates a temporary directory with fs.mkdtempSync() inside the spawned fixture, bypassing the repository’s harness cleanup contract. Create it with using base = tempDir("rm-handoff", {}) in the outer test and inject the path into the fixture.

Also applies to: 157-161

🤖 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/js/bun/shell/commands/rm.test.ts` at line 9, Update the rm handoff test
to import and use the harness-managed tempDir, creating the fixture root in the
outer test with using base = tempDir("rm-handoff", {}). Inject base’s path into
the spawned fixture and remove the inner fs.mkdtempSync() creation, preserving
the existing fixture behavior and cleanup contract.

Sources: Coding guidelines, Learnings


178-181: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Remove the wall-clock watchdog and custom test timeout.

Bun.sleep(10_000) and 120_000 make the test depend on elapsed time and violate the repository’s no-timeout test guidance. Await the rm operations directly and rely on Bun’s test-runner timeout, or use an established cancellable watchdog without overriding the test timeout.

Also applies to: 209-209

🤖 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/js/bun/shell/commands/rm.test.ts` around lines 178 - 181, Remove the
Promise.race wall-clock watchdogs using Bun.sleep from the rm tests around the
rm command invocations, and await the rm operations directly. Remove the custom
120_000 test timeout as well, relying on Bun’s test-runner timeout while
preserving the existing assertions and coverage.

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/js/bun/shell/commands/rm.test.ts`:
- Line 9: Update the rm handoff test to import and use the harness-managed
tempDir, creating the fixture root in the outer test with using base =
tempDir("rm-handoff", {}). Inject base’s path into the spawned fixture and
remove the inner fs.mkdtempSync() creation, preserving the existing fixture
behavior and cleanup contract.
- Around line 178-181: Remove the Promise.race wall-clock watchdogs using
Bun.sleep from the rm tests around the rm command invocations, and await the rm
operations directly. Remove the custom 120_000 test timeout as well, relying on
Bun’s test-runner timeout while preserving the existing assertions and coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a0a8efe9-b166-42f0-8c44-064f6a677a53

📥 Commits

Reviewing files that changed from the base of the PR and between 8236685 and 68579a9.

📒 Files selected for processing (1)
  • test/js/bun/shell/commands/rm.test.ts

The outer test now owns the temporary directory and injects its path
into the spawned fixture, so cleanup goes through the harness instead
of an exit handler in the subprocess.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the two out-of-diff review notes:

  • tempDir from harness: done in 7dc9191. The outer test now owns the temp dir via using base = tempDir("rm-handoff", {}) and injects the path into the fixture, so cleanup goes through the harness instead of an exit handler in the subprocess.
  • Bun.sleep(10_000) watchdog / 120 s timeout: kept. The property under test is "recursive rm -rf completes", and the failure mode is that the shell promise never resolves. Without the per-call race the fixture would wait on a stranded promise until the file-level timeout (~5 min here) instead of reporting which iteration hung. The 10 s bound is ~500x a normal debug-build call. The explicit test timeout exists because 800 calls under debug+ASAN run ~15 s, which is above the 5 s default.

Comment thread test/js/bun/shell/commands/rm.test.ts
Comment thread src/runtime/shell/builtin/rm.rs
…mment

- Replace per-rm Bun.sleep() with one clearable setTimeout per
  iteration so the subprocess exits immediately instead of waiting out
  the last batch of ref'd timers (~10 s shaved off every run).
- Add .nothrow() so a nonzero rm exit reaches the diagnostic branch
  instead of rejecting with ShellError.
- Drop the parenthetical from the delete_after_waiting_for_children
  comment; the restore services post_run's own decrement, it does not
  make the (pre-existing, double-TOCTOU-only) re-enqueue path
  participate in the hand-off.

@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 issues found after the fixture fixes in 100760e, but this reworks a lock-free cross-thread ownership hand-off over raw *mut DirTask pointers, so it's worth a human pass on the atomic protocol.

Checked: the whoever-hits-0 protocol — parent fetch_sub==1 restore path (all children saw ≥2 so none claimed ownership; no late decrements possible), the !=1 path treats dir_task as freed (only the stack-local fd scopeguard runs after). The store(1) in delete_after_waiting_for_children correctly re-arms post_run's own decrement. Zero-children / error_signal-aborted-enqueue cases fall through to the inline delete as before. Test fixture: watchdog is now clearable, .nothrow() makes the diagnostic branch live, tempDir owns cleanup.

Extended reasoning...

Overview

Reworks the parent/child DirTask join in src/runtime/shell/builtin/rm.rs from check-then-publish (load > 1store(true)) to a unified SeqCst fetch_sub on subtask_count where whoever takes the counter to 0 owns the parent's rmdir. Touches remove_entry_dir (parent side), post_run (child side), delete_after_waiting_for_children (restore subtask_count=1), and updates the three SAFETY/doc comments describing the release/acquire hand-off. Adds an 800-iteration stress probe to rm.test.ts.

Security risks

None identified. This is internal task-tree lifecycle synchronization for the shell rm builtin; no user-controlled input reaches the changed lines beyond what already flowed through the pre-existing code.

Level of scrutiny

High. This is unsafe Rust manipulating raw *mut DirTask across worker threads, where the correctness of every &*dir_task dereference after the hand-off depends on the atomic protocol. The pre-existing race it closes was a few-instruction window that took CI-scale repetition on arm64 to surface; a newly-introduced window of similar width would be equally hard to catch by test. The PR description's interleaving analysis is thorough and I traced the three cases (parent wins, child wins, no children) without finding a hole, but lock-free protocol changes of this shape are exactly where a second human reading is most valuable.

Other factors

  • Prior inline nits (uncancelled Bun.sleep watchdog adding ~10s dead time, unreachable r !== 0 branch, beforeExit vs exit cleanup, misleading comment parenthetical about the re-enqueue path) were all addressed in 406e504/7dc9191d/100760e0.
  • The author explicitly could not reproduce the original race on the linux-x64 gate host (64k iterations clean), so the new test is a probe rather than a fail-before; the CI build links in the description are the actual evidence.
  • A pre-existing theoretical strand on the non-Linux remove_entry_dir_after_children re-enqueue path (double-TOCTOU) was noted and intentionally left out of scope; the comment was tightened so it no longer implies that path participates in the new protocol.

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build 72199 (100760e):

  • 283/286 lanes passed. test/js/bun/shell/commands/rm.test.ts (including the new stress probe) and test/js/bun/shell/leak.test.ts are green on every completed lane.
  • 1 red: test/integration/next-pages/test/next-build.test.ts core-dumped on alpine 3.23 x64-baseline in napi_release_threadsafe_functionenqueue_task_concurrent during a next-swc worker. The same crash is red on unrelated PR build 72185 and is being handled separately; nothing in this diff reaches NAPI or the event-loop wakeup path.
  • Flaky (passed on retry): bake/dev-and-prod.test.ts (HMR), napi/napi.test.ts (napi_wrap GC timing), and bunshell.test.ts on Windows 2019 x64-baseline (panic: expected Node::Cmd at Node#2, got Free). That last one is a shell interpreter Node-arena access on a freed slot; this diff touches only DirTask threadpool join in rm.rs, not the interpreter's node lifecycle, and the crash was around the Latin-1 echo/escape tests rather than rm.
  • 2 darwin 14 aarch64 shards are still queued.

This diff is ready for a look at the atomic protocol per the review summary. The hand-off race itself is not fail-before-provable on the x64 gate host (64 000-iteration probe ran clean; see the PR body), so the gate cannot mechanically demonstrate it.

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.

1 participant