Skip to content

fs.promises.cp: defer AsyncCpTask destruction until all subtasks finish - #30162

Merged
dylan-conway merged 2 commits into
mainfrom
farm/23cc9d07/fix-async-cp-uaf
May 3, 2026
Merged

dylan-conway merged 2 commits into
mainfrom
farm/23cc9d07/fix-async-cp-uaf

Conversation

@robobun

@robobun robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes a use-after-free in fs.promises.cp(src, dest, { recursive: true }) when one file copy fails while sibling SingleTasks are still running on the thread pool.

Repro

Recursive fs.promises.cp of a directory where copying one file fails (e.g. its destination path is already a directory → EISDIR) while ~100 sibling file copies are in flight. Under ASAN:

==4475==ERROR: AddressSanitizer: use-after-poison on address 0x79676fca08b0
WRITE of size 8 at 0x79676fca08b0 thread T22 (Bun Pool 11)
    #0 atomic.Value(usize).fetchSub
    #1 NewAsyncCpTask(false).SingleTask.workPoolCallback src/bun.js/node/node_fs.zig:528

Cause

finishConcurrently() used the has_result cmpxchg only to ensure the result was set once, then immediately enqueued runFromJSThread → deinit() → bun.destroy(this). It did not wait for subtask_count to reach zero, so:

  • A SingleTask that errored called finishConcurrently(err) and returned without decrementing subtask_count. The JS thread then freed the parent while other SingleTasks were still dereferencing cp_task->args / cp_task->subtask_count.
  • cpAsync decremented subtask_count after _cpAsyncDirectory returned an error, by which time runFromJSThread could already have freed this.

Fix

  • finishConcurrently(result) now only records the result (first caller wins).
  • New onSubtaskDone() decrements subtask_count with .acq_rel ordering; only the caller that drops it to zero enqueues runFromJSThread. If no one recorded a result, it defaults to .success.
  • cpAsync drops its initial reference via defer this.onSubtaskDone(), covering every early return (Windows, non-directory, EISDIR, and the recursive path).
  • SingleTask.workPoolCallback always ends with this.deinit(); parent.onSubtaskDone(); on both success and error paths.

This matches the pattern already used by AsyncReaddirRecursiveTask.

Verification

New test in test/js/node/fs/cp.test.ts creates a source dir with 128 files plus one whose destination is a pre-existing directory, and runs fs.promises.cp 50× in a subprocess.

  • Without fix (git stash -- src/ && bun bd test): subprocess aborts with the ASAN use-after-poison shown above → test fails.
  • With fix (bun bd test): subprocess rejects with EISDIR every iteration, exits 0 → test passes.
  • Full cp.test.ts suite: 38 pass, 3 skip (Windows-only), 0 fail.
  • zig:check-all passes on all targets.

When a SingleTask copy failed during a recursive fs.promises.cp, the
error path called finishConcurrently() which immediately enqueued
runFromJSThread -> deinit() -> bun.destroy(this). Other SingleTasks
still running on the thread pool then dereferenced the freed parent
(args, subtask_count, onCopy). The same UAF existed after
_cpAsyncDirectory returned an error while subtasks it had already
spawned were still in flight.

finishConcurrently() now only records the result (first caller wins).
A new onSubtaskDone() decrements subtask_count with acq_rel ordering
and only the last caller enqueues runFromJSThread. Every code path --
the main directory-scan task via defer, and every SingleTask on both
success and error -- drops exactly one reference, so the parent is
never destroyed while a subtask still holds a pointer to it.
@coderabbitai

coderabbitai Bot commented May 3, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 41 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f7d28b9-28be-4f80-94d7-cdbcf2d0c75f

📥 Commits

Reviewing files that changed from the base of the PR and between 1abdd62 and 1c4339d.

📒 Files selected for processing (2)
  • src/bun.js/node/node_fs.zig
  • test/js/node/fs/cp.test.ts

Review rate limit: 0/5 reviews remaining, refill in 7 minutes and 41 seconds.

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

@github-actions github-actions Bot added the claude label May 3, 2026
@robobun

robobun commented May 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:42 AM PT - May 3rd, 2026

✅ @robobun, your commit 1c4339de7d454a99bb88053c9ce64c6b6b2c57ec passed in Build #50452! 🎉


🧪   To try this PR locally:

bunx bun-pr 30162

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

bun-30162 --bun

@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Independently arrived at the same fix for this (reported via Slack as a UAF of AsyncCpTask.subtask_count at node_fs.zig:785 / :908 / :518). Pushed my variant to farm/4723563f/asynccptask-uaf for reference rather than opening a competing PR.

Minor diff in approach: I kept finishConcurrently as a storeResult + onSubtaskDone convenience wrapper so the seven early-return paths in cpAsync didn't need to change, and added a separate storeResult for the _cpAsyncDirectory error paths (which don't own a ref). Functionally equivalent to the defer onSubtaskDone() here.

The test in my branch uses errorOnExist: true collisions (24 files, all pre-existing in dest) instead of an EISDIR collision — also reliably triggers the ASAN use-after-poison at SingleTask.workPoolCallback. Feel free to cherry-pick if useful.

Comment thread test/js/node/fs/cp.test.ts Outdated
32 files x 20 iterations still triggers the ASAN use-after-poison
reliably (10/10 on the unpatched build) and completes in ~2-3s under
the default timeout with the fix applied.
@dylan-conway
dylan-conway merged commit a428072 into main May 3, 2026
43 of 53 checks passed
@dylan-conway
dylan-conway deleted the farm/23cc9d07/fix-async-cp-uaf branch May 3, 2026 03:02
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…sh (oven-sh#30162)

## What

Fixes a use-after-free in `fs.promises.cp(src, dest, { recursive: true
})` when one file copy fails while sibling `SingleTask`s are still
running on the thread pool.

## Repro

Recursive `fs.promises.cp` of a directory where copying one file fails
(e.g. its destination path is already a directory → `EISDIR`) while ~100
sibling file copies are in flight. Under ASAN:

```
==4475==ERROR: AddressSanitizer: use-after-poison on address 0x79676fca08b0
WRITE of size 8 at 0x79676fca08b0 thread T22 (Bun Pool 11)
    #0 atomic.Value(usize).fetchSub
    oven-sh#1 NewAsyncCpTask(false).SingleTask.workPoolCallback src/bun.js/node/node_fs.zig:528
```

## Cause

`finishConcurrently()` used the `has_result` cmpxchg only to ensure the
**result** was set once, then immediately enqueued `runFromJSThread` →
`deinit()` → `bun.destroy(this)`. It did not wait for `subtask_count` to
reach zero, so:

- A `SingleTask` that errored called `finishConcurrently(err)` and
returned **without** decrementing `subtask_count`. The JS thread then
freed the parent while other `SingleTask`s were still dereferencing
`cp_task->args` / `cp_task->subtask_count`.
- `cpAsync` decremented `subtask_count` after `_cpAsyncDirectory`
returned an error, by which time `runFromJSThread` could already have
freed `this`.

## Fix

- `finishConcurrently(result)` now only records the result (first caller
wins).
- New `onSubtaskDone()` decrements `subtask_count` with `.acq_rel`
ordering; only the caller that drops it to zero enqueues
`runFromJSThread`. If no one recorded a result, it defaults to
`.success`.
- `cpAsync` drops its initial reference via `defer
this.onSubtaskDone()`, covering every early return (Windows,
non-directory, EISDIR, and the recursive path).
- `SingleTask.workPoolCallback` always ends with `this.deinit();
parent.onSubtaskDone();` on both success and error paths.

This matches the pattern already used by `AsyncReaddirRecursiveTask`.

## Verification

New test in `test/js/node/fs/cp.test.ts` creates a source dir with 128
files plus one whose destination is a pre-existing directory, and runs
`fs.promises.cp` 50× in a subprocess.

- **Without fix** (`git stash -- src/ && bun bd test`): subprocess
aborts with the ASAN `use-after-poison` shown above → test fails.
- **With fix** (`bun bd test`): subprocess rejects with `EISDIR` every
iteration, exits 0 → test passes.
- Full `cp.test.ts` suite: 38 pass, 3 skip (Windows-only), 0 fail.
- `zig:check-all` passes on all targets.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
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