Skip to content

shell: free the cp builtin's task when its fs.cp completion is released unrun - #38502

Open
robobun wants to merge 4 commits into
mainfrom
farm/f287346a/shell-cp-release-unrun-leak
Open

shell: free the cp builtin's task when its fs.cp completion is released unrun#38502
robobun wants to merge 4 commits into
mainfrom
farm/f287346a/shell-cp-release-unrun-leak

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A cp -R run through the shell's cp builtin in a worker, with the copy still in flight when the worker exits, leaks the builtin's task. LeakSanitizer on the $ cp -R row of test/js/web/workers/worker-late-completion.test.ts: Direct leak of 440 byte(s) in 1 object(s) allocated at <bun_runtime::shell::builtins::cp::ShellCpTask>::create src/runtime/shell/builtin/cp.rs:422 (via Cp::next cp.rs:174, Builtin::start). Reproduces 6/6 on a debug build of main (032b8db).
  • On the success path the ShellCpTask hands the copy to a ShellAsyncCpTask (cp.rs:730, run_from_thread_pool_impl) and drops its own poster. The ShellAsyncCpTask's completion is then the only thing that reaches the ShellCpTask again: run_from_js_thread (node_fs.rs:1690) calls cp_on_finish, which continues the builtin and frees the task.
  • When that completion is released unrun instead (the worker is tearing down), <ShellAsyncCpTask as Taskable>::release_unrun (node_fs.rs:1506) only destroyed the fs task. The ShellCpTask it pointed at, and the keep-alive ShellCpTask::schedule took, were never released.
  • The builtin's error path is unaffected: there the ShellCpTask is posted itself and <ShellCpTask as Taskable>::release_unrun frees it. The other pool builtins (ls, rm, mv, mkdir, touch, glob) post their own task and their release_unrun impls mirror their run paths; cp is the one whose completion is carried by another type.

Fix

  • release_unrun of NewAsyncCpTask now also releases the shell task it carries the completion for (Some only in the IS_SHELL instantiation), through <ShellCpTask as Taskable>::release_unrun: unref the keep-alive, drop the box. The fs task is destroyed first because its args borrow the shell task's absolute paths.
  • Correct because the release happens at the same point the run path would have used the shell task, with nothing else left to touch it: on_subtask_done posts only after the last pool subtask is done with both tasks (the cp_on_copy callers), the builtin's own pool callback never touches the task after handing the copy over, and nothing else holds a pointer to it on POSIX (the Windows EBUSY list is only filled by the run path). release_unrun runs on the task's own JS thread with the loop alive, which is what ShellCpTask::release_unrun already assumes on the error path.
  • Test: test/js/web/workers/worker-late-completion.test.ts. Its rows now run the host under LeakSanitizer themselves on ASAN builds (detect_leaks=1, test/leaksan.supp, BUN_DESTRUCT_VM_ON_EXIT=1, the same environment the CI runner supplies on the ASAN lane), so a release path that leaks fails its row under a plain bun bd test too. Every test in the file starts a debug bun plus a worker and the last one waits out the 2s ticket report, so the file's default timeout is raised to 90s (passing rows take about 3s on a debug build here, and the failing row about 7s once LeakSanitizer has symbolized its report, past the 5s default; CI already ran the file with a 90s/270s timeout).
  • bun bd test test/js/web/workers/worker-late-completion.test.ts: with src/ stashed, only $ cp -R fails, with the report above; with the fix, 33/33 pass (twice).
  • bun bd test test/js/node/fs/cp.test.ts: passes; a manual cp -Rv/missing-source run with the builtin enabled under LeakSanitizer exits clean.

Background

  • Shell cp builtin: Cp::next heap-allocates one ShellCpTask per source operand and schedules it on the work pool. On the pool it resolves paths and hands the actual copying to node:fs's async cp implementation, ShellAsyncCpTask (NewAsyncCpTask<true> in node_fs.rs), which keeps a back-pointer (shelltask) to it. The fs task's subtasks report each copied file to the shell task (cp_on_copy), and its completion continues the builtin (cp_on_finish).
  • Completion / Taskable::release_unrun: pool work posts a completion task into its VM's queue to run on the JS thread. Since One door out of a VM's thread: tickets + a teardown that waits #38299 a VM being torn down (a worker exiting or being terminated) waits for everything it sent off-thread to come back, and completions arriving during that wait are not run; they are handed to their type's release_unrun, which has to free whatever running them would have freed.
  • Keep-alive: ShellCpTask::schedule refs the event loop's keep-alive so the loop stays alive while the task is out; the run path unrefs it in ShellTask::run_from_main_thread, the release path in ShellTask::unref_unrun.
  • BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE (debug-assertion builds): holds a cross-thread post until the worker's teardown is already waiting, so each row of the test exercises its release path deterministically. With detect_leaks=1, LeakSanitizer makes the host exit non-zero with a report if that path leaks; BUN_DESTRUCT_VM_ON_EXIT=1 destroys the main VM at exit so what it still owns is not reported as well.

…ed unrun

On the success path the shell's cp builtin hands the copy to a
ShellAsyncCpTask, and that task's completion is what continues (and
frees) the ShellCpTask via cp_on_finish. When a worker tears down while
the copy is in flight, the completion is released unrun instead, and
ShellAsyncCpTask::release_unrun only destroyed itself, leaking the
ShellCpTask and the keep-alive it took when it was scheduled.

Release the shell task from there as well, the same way a ShellCpTask
posted directly (the error path) is released.

The worker-late-completion rows now run their host under LeakSanitizer
on ASAN builds themselves instead of relying on the CI runner's
environment, so the "$ cp -R" row reports this; the file's per-test
timeout is raised since every test in it starts a debug bun and a worker.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:55 AM PT - Aug 14th, 2026

@robobun, your commit d2d31fa is building: #96439

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 50 seconds

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: ac707b56-7608-4c78-8d7b-5ca9f650dc2b

📥 Commits

Reviewing files that changed from the base of the PR and between 5a34f8d and 4e3db44.

📒 Files selected for processing (2)
  • src/runtime/node/node_fs.rs
  • test/js/web/workers/worker-late-completion.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; ready for review.

Reproduced on a debug (ASAN) build of main at 032b8db with bun bd test test/js/web/workers/worker-late-completion.test.ts once the rows run their host under LeakSanitizer (this PR's test change): the $ cp -R row fails 6/6 with Direct leak of 440 byte(s) allocated at ShellCpTask::create (src/runtime/shell/builtin/cp.rs:422); every other row is clean. With the fix in src/runtime/node/node_fs.rs the file passes 33/33.

CI (build 96022): every lane that has run is green (the only annotations are retried-and-passed flakes in unrelated files). The two darwin 14 aarch64 - test-bun jobs have not found an agent since the build started and keep being re-queued; on those lanes the release binary skips the gate rows anyway, so they exercise nothing this PR changes.

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

I reviewed this PR and didn't find any bugs. The change looks correct — ParentRef is Copy so the shell-task pointer is read out before Self::destroy(this), the non-shell instantiation keeps shelltask = None so its behaviour is unchanged, and the release order (fs task first, then shell task) matches the borrow direction of args. That said, this is unsafe cross-thread ownership/lifecycle code in the memory-safety category, so a human look at the ownership argument (particularly that nothing else — e.g. the Windows EBUSY list — can still hold the ShellCpTask when release_unrun runs) would be worthwhile.

What was reviewed

  • NewAsyncCpTask::release_unrun: confirmed shelltask is None when !IS_SHELL (set via ParentRef::from_nullable_mut in schedule_new), so the JS fs.cp path is untouched.
  • <ShellCpTask as Taskable>::release_unrun at cp.rs:754 already handles keep-alive unref + box drop; delegating to it mirrors the error-path release.
  • Test: leaksan.supp exists at the referenced path; setDefaultTimeout(90_000) is file-wide because every row spawns a debug/ASAN subprocess concurrently — matches what CI already applied.
Extended reasoning...

Overview

The PR fixes a leak of the shell cp builtin's ShellCpTask when a worker tears down while an fs.cp-backed copy is in flight. The native change is ~10 lines in src/runtime/node/node_fs.rs: <NewAsyncCpTask<IS_SHELL> as Taskable>::release_unrun now reads the shelltask back-pointer (an Option<ParentRef<ShellCpTask, Mut>>, Copy) before destroying the fs task, then — if Some — forwards to <ShellCpTask as Taskable>::release_unrun, which unrefs the event-loop keep-alive and drops the heap box. The test file gains a per-row LeakSanitizer environment on ASAN builds and a 90s file default timeout.

Security risks

None. This is internal task-lifecycle cleanup during worker teardown; no user-facing input handling, parsing, or protocol surface is touched.

Level of scrutiny

High. The repo's review guidance calls out native memory safety as the most-blocked category, and this change reasons about exactly-once release of a heap box across the pool→JS-thread completion boundary in unsafe Rust. The mechanism is well-argued in the description (posted only after the last subtask, run-path never touches the shell task after handoff, Windows EBUSY list only populated on the run path), and the code reads correctly to me: ParentRef is Copy (src/ptr/parent_ref.rs:169), so the pointer survives Self::destroy(this); the non-shell instantiation has shelltask = None so behaviour is byte-identical there; and the fs-task-first destroy order respects the args → shell-task path borrow. But confirming that no other holder of the ShellCpTask pointer exists at this point — the invariant that makes this a free rather than a UAF — is exactly the kind of ownership claim a maintainer familiar with the shell builtin lifecycle should sign off on.

Other factors

The test changes are sound: test/leaksan.supp exists, ASAN_OPTIONS is composed with any inherited value, and the 90s setDefaultTimeout is justified (every row spawns a debug/ASAN child concurrently; the description notes CI already ran this file at 90s/270s). The PR demonstrates the test fails on the unfixed build and passes with the fix, and test/js/node/fs/cp.test.ts still passes. No prior human review comments to address.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the ownership question (who else can hold the ShellCpTask when this release_unrun runs), for whoever takes the human look:

  • A ShellCpTask gets exactly one completion. work_pool_callback (cp.rs:525) either posts the task itself (every error return in run_from_thread_pool_impl happens before the fs task exists) or creates the ShellAsyncCpTask as its last step and posts nothing. So a released ShellAsyncCpTask is the only completion its shell task will ever get, and the shell task has not been seen by the JS thread since it was scheduled.
  • The Windows EBUSY list (exec.ebusy.tasks) is only ever filled by on_shell_cp_task_done (cp.rs:289), i.e. by a completion that ran; by then that task's ShellAsyncCpTask has already been destroyed in run_from_js_thread. A task is therefore either still waiting for its completion (the only state release_unrun sees) or on that list, never both. The same holds for output_queue, which holds OutputTasks created by the run path, not ShellCpTasks.
  • Off-thread, the only readers of the shell task are the copy's pool subtasks (cp_on_copy through the shelltask back-pointer), and on_subtask_done posts the completion only after the last of them has dropped its reference; the builtin's own pool callback does not touch the task after handing the copy over (it moved its poster out beforehand for exactly that reason, cp.rs:535).

A leak: suppression matches any frame of the recorded allocation stack,
and test/leaksan.supp suppresses Bun::evaluateCommonJSModuleOnce, which
on the release ASAN build is still within the 30 frames recorded for
what a row allocates from the worker's module body. The rows' leak
check therefore only bit on debug builds; starting the work from an
immediate keeps module evaluation out of every row's allocation stacks.
Comment thread src/runtime/node/node_fs.rs Outdated
Comment on lines +1505 to +1508
/// its promise handle, protected arguments and keep-alive. The shell's
/// variant is also the completion of the `ShellCpTask` that handed it
/// the copy (`run_from_js_thread` → `cp_on_finish`), so that task is
/// released unrun here too; nothing else frees it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Shortened in 5264b79: the doc comment now just notes that for the shell this completion is also what frees the ShellCpTask, and the SAFETY comment is down to the two facts the body relies on (the pool is done with both tasks; args borrows the shell task's paths, which fixes the order).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reworked again in the latest push: the doc comment is back to what main has, and the one fact this change adds (the shell task whose completion this task carries) now lives in the SAFETY comment, where it is part of the argument for freeing it.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Second commit (d2d31fa) changes only the test: each row's work is now started from a setImmediate callback instead of the worker's module body.

Reason: a leak: suppression applies when it matches any frame of an allocation's recorded stack, and test/leaksan.supp has leak:Bun::evaluateCommonJSModuleOnce. With the work started from the module body, that frame is within the 30 frames LeakSanitizer records for the ShellCpTask allocation on the release ASAN build (the profile CI's ASAN lane runs), so the unfixed leak was suppressed there; it was only reported on debug builds, where the un-inlined frames push module evaluation out of the recorded window. That also explains why the ASAN lane never flagged this row before. Starting the work from an immediate keeps module evaluation out of every row's allocation stacks.

Re-verified on the debug build with this host: with the node_fs.rs change reverted, $ cp -R is the only failing row (same 440-byte ShellCpTask::create report); with it, 33/33 pass.

Comment thread src/runtime/node/node_fs.rs Outdated
Comment on lines +1505 to +1507
/// its promise handle, protected arguments and keep-alive. For the
/// shell this completion is also what frees the `ShellCpTask`
/// (`cp_on_finish`), so that is released unrun here as well.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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

I reviewed this PR and the automated bug-hunting pass found no issues. Because the fix reasons about cross-thread ownership of ShellCpTask inside an unsafe release_unrun path — where a mistake is a double-free rather than a leak — a human look at the ownership argument would still be worthwhile.

What was reviewed:

  • Traced shelltask through create_for_shellParentRef::from_nullable_mutas_mut_ptr; the non-shell instantiation stays None, so fs.cp behavior is unchanged.
  • Confirmed ShellCpTask::release_unrun (cp.rs:754) does unref_unrun + heap::take, matching what the run path's cp_on_finish would have released; destroy order (fs task first) matches the args-borrows-paths constraint.
  • Checked the test's leakCheckEnv composes with bunEnv.ASAN_OPTIONS and that the setImmediate wrapper keeps the two-turn exit sequencing intact.
Extended reasoning...

Overview

The PR fixes a leak of the shell cp builtin's ShellCpTask when a worker tears down while a $ cp -R is in flight. The Rust change is ~12 lines in NewAsyncCpTask::release_unrun (src/runtime/node/node_fs.rs:1506): after destroying the fs task, it now also calls <ShellCpTask as Taskable>::release_unrun on the back-pointer the fs task carried, which unrefs the keep-alive and drops the box (cp.rs:754-759). The test change makes every row of worker-late-completion.test.ts run its host under LeakSanitizer on ASAN builds, wraps each row's work in setImmediate to escape the evaluateCommonJSModuleOnce suppression frame, and raises the file's default timeout to 90s.

Security risks

None. This is internal task-lifecycle cleanup on a debug-gated teardown path; no user-controlled input reaches the changed code, and no auth/crypto/permission surface is touched.

Level of scrutiny

High. Per the repo's review guidance, native memory safety is the most-blocked category. The correctness of this change rests on an ownership argument spanning two files and multiple threads: that when release_unrun fires, (a) the pool is done with both tasks (on_subtask_done posts only at count zero), (b) nothing else holds the ShellCpTask (the error path posts the shell task itself and never creates the fs task; the Windows EBUSY list is only populated by the run path), and (c) the destroy order respects the args-borrows-shell-task-paths dependency. The PR thread lays this out carefully and I did not find a hole in it, but a mistake here is a double-free/UAF rather than a leak, so it deserves a second set of eyes on the argument rather than an auto-approval.

Other factors

  • The fix mirrors the run path exactly: run_from_js_thread (node_fs.rs:1702) reads shelltask, hands it to cp_on_finish (which frees it), then destroys self. release_unrun now does the release-path equivalent in the safe order.
  • The non-shell AsyncCpTask instantiation has shelltask == None (created with null_mut() at node_fs.rs:1557), so plain fs.cp behavior is unchanged.
  • Test coverage is strong: LeakSanitizer is now enabled per-row, the $ cp -R row demonstrably fails without the fix and passes with it, and the setImmediate change is well-justified (avoids a suppression that hid the leak on the release-ASAN lane).
  • The file-wide setDefaultTimeout(90_000) is broader than the repo's usual per-test-outlier guidance, though the comment explains why (every test spawns a debug bun + worker; a failing row's LSAN symbolization alone exceeds 5s) and CI already ran this file at 90s/270s.
  • The comment-cop bot fired twice on earlier revisions; the final SAFETY comment is now concise and states the two facts the body relies on.

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