test: move the recursive cp error race test to the shell cp builtin - #38155
test: move the recursive cp error race test to the shell cp builtin#38155robobun wants to merge 1 commit into
Conversation
fs.promises.cp with an existing destination has gone through the JS directory walker on every platform since #31830, so the 20-iteration subprocess loop in cp.test.ts no longer exercised the concurrent native copy it was written for; on debug/ASAN builds the loop alone exceeded the 5s test budget. Replace it with an in-process check of the error it was observing and cover the native per-file fan-out, which the shell builtin's cp -R still uses, from the shell cp tests.
|
Warning Review limit reached
Next review available in: 23 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: reproduced and fixed.
|
There was a problem hiding this comment.
LGTM — test-only relocation of a UAF regression guard to the code path that still reaches it.
What was reviewed:
- Confirmed
fsCpNonDirToDirErrorin bothcp.tsandcp-sync.tssetserr.pathto the joined destination, so the new in-loopfs.cptest'se.pathassertion holds for both impls and on Windows (copyDirusespath.join, which normalizes separators). - Checked
tempDirreturns a realpath'd base, so the shell test's absolute-path stderr assertion won't mismatch on macOS's/var→/private/varsymlink. - Verified harness conventions:
bunEnvspread, concurrent stdout/stderr/exited drain,using tempDir, subprocess isolation for the ASAN-abort case, and theoutcomesSet makes any per-iteration variance visible in the diff rather than masked.
Extended reasoning...
Overview
Test-only PR touching two files. It removes a subprocess test from test/js/node/fs/cp.test.ts that was timing out on every debug/ASAN run (~5s child vs 5s default timeout) and, per the PR's tracing of tryNativeFastPath after #31830, no longer reached the native NewAsyncCpTask fan-out it was written to guard — fs.promises.cp into an existing directory now walks entries one at a time in JS. The guard is re-homed to test/js/bun/shell/commands/cp.test.ts against the shell cp -R builtin, which is the remaining caller of the concurrent native path on Linux (where ASAN runs). A simpler in-process case is added to the fs.cp/cpSync per-implementation loop covering what the removed test was actually observing: ERR_FS_CP_NON_DIR_TO_DIR with the entry's path, and the destination directory left intact.
Security risks
None. Test code only; no changes to runtime, parsing, or any user-reachable surface.
Level of scrutiny
Moderate — the review guidance flags "never silently weaken, skip, or delete an existing test or safety net" as merge-blocking. This PR clears that bar explicitly: the removed test's stated invariant (parent task survives a failing subtask while siblings run) is preserved by the new shell test, and the author verified it by temporarily reintroducing the #30162 bug and observing the new test fail with heap-use-after-free in 10/10 runs while the removed test passed on that same broken build. That is exactly the fail-before proof the review rules ask for.
Other factors
- I traced
copyDirinsrc/js/internal/fs/cp.ts:337-345and thefsCpNonDirToDirErrorthrow sites in bothcp.ts:60andcp-sync.ts:190; both setpath: destwheredestis thepath.join-built entry path, so the new test'se.path === join(basename, "result", "b.txt")assertion is correct for both implementations and normalizes separators on Windows. tempDirWithFiles(harness.ts:458) realpathsos.tmpdir()beforemkdtempSync, so the shell test's absolute-path stderr comparison is not at risk from the macOS/varsymlink.- Iteration-order independence: only
b.txt(fs test) /000-bad.txt(shell test) has a conflicting destination, so the error is deterministic regardless ofopendir/readdir order. - The shell test correctly sits outside
describe.if(!builtinDisabled("cp"))(skipped on POSIX in CI) and forces the builtin viaBUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1in a child, matching how the env var is read once at startup. TheoutcomesSet +toEqual([{...}])pattern means any variance across the 20 iterations produces a readable diff rather than a silent pass. ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "symbolize=0"].filter(Boolean).join(":")correctly composes with any harness-set options and degrades to"symbolize=0"when none are set.- Author ran
--rerun-each=10on debug/ASAN and the Windows canary; both files pass. No prior human or bot review comments to address.
|
Updated 5:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit 76abe1c has some failures in 🧪 To try this PR locally: bunx bun-pr 38155That installs a local version of the PR into your bun-38155 --bun |
Problem
test/js/node/fs/cp.test.ts> "fs.promises.cp recursive does not free parent task while subtasks are in flight after an error" fails on every debug/ASAN run, alone or with the file:^ this test timed out after 5000ms.(shell(cp): copy the file a symlink operand points at unless -R is given #37954 ran into the same timeout.)fs.promises.cpof 33 files at ~180ms each. On a release build the same child takes well under a second.fs.promises.cpof a directory whose destination already exists goes through the JS walker on every platform (src/js/node/fs.promises.ts:187-192->tryNativeFastPathinsrc/js/internal/fs/cp.ts, which only takes the native path for a directory on macOS when the destination is missing). The walker'scopyDir(src/js/internal/fs/cp.ts:337) awaits one entry at a time, so there are no concurrent tasks for the loop to race, and every iteration just re-observes the sameERR_FS_CP_NON_DIR_TO_DIRrejection.cp -Rbuiltin drives the sameNewAsyncCpTask/CpSingleTask(src/runtime/node/node_fs.rs:1359-1502) on every platform, and nothing exercised its "one file fails while its siblings are still copying" path.Fix
src/diff and no fail-before againstsrc/.test/js/node/fs/cp.test.ts: the subprocess test is replaced by a case in the existing per-implementation loop (so it coverscpSyncandpromises.cp, on all platforms): a recursive copy where one entry's destination is a directory throwsERR_FS_CP_NON_DIR_TO_DIRwith the entry's path and leaves the directory in place. This is what the removed test was actually checking; it now takes ~30ms on a debug build and runs in-process.test/js/bun/shell/commands/cp.test.ts: new test that runscp -R src dst20 times in a child bun withBUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1, against 32 regular files plus one whose destination is a directory. Every run has to exit 1 with the builtin's own message (the systemcp, which the shell falls back to without the env var, words it differently), the child has to exit 0 with an empty stderr, and all 32 siblings have to be indst/srcafterwards, which is what leaves them touching the shared parent task after the error was recorded. The child getsASAN_OPTIONS=symbolize=0, as the other tests of this kind in the repo do, so an ASAN abort fails the assertion instead of timing the test out while llvm-symbolizer runs.describe.if(!builtinDisabled("cp"))block because that block is skipped on POSIX (CI does not set the env var), matching how the other recent shellcptests are written.bun bd test test/js/node/fs/cp.test.ts: 48 pass, 5 skip (before: 1 fail by timeout, every run).bun bd test test/js/bun/shell/commands/cp.test.ts --rerun-each=10: 10/10 pass, 0.5s to 0.9s each on the debug/ASAN build.node_fs.rs(finish_concurrentlyposting the completion as soon as a result is recorded, and the failingCpSingleTaskkeeping its reference). The new shell test then fails in under a second withERROR: AddressSanitizer: heap-use-after-freein 10 out of 10 runs, after 1 to 9 of the 20 iterations (report below). The removedfs.promises.cptest passes on that same broken build, since it never reaches this code.USE_SYSTEM_BUN=1):shell/commands/cp.test.ts31 pass,fs/cp.test.ts46 pass, 7 skip. The builtin is always on there and reports the failed file asOperation not permitted, which the test expects on that platform.Background
fs.cp/fs.promises.cp(src/js/node/fs.promises.ts) validate in JS and then either hand the copy to the nativefs.cpbinding or walk the tree with the node-ported walker insrc/js/internal/fs/cp.ts. After fs: port Node.js v26.3.0 fs tests and fix the gaps they surface — cp error semantics, watcher event delivery, watch ignore+AbortSignal, FileHandle pull/writer, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive end-of-life, mock.fn (+119 tests) #31830 the native path is taken for single regular files and, on macOS only, for a directory tree with no existing destination (a singleclonefile()); everything else, including any copy into an existing directory, is the JS walker.NewAsyncCpTask(src/runtime/node/node_fs.rs) is the native recursive copy: a work-pool task scans the directory and spawns oneCpSingleTaskper file, all holding a pointer to the parent.subtask_countis a refcount; the parent is destroyed on the JS thread once it reaches zero. fs.promises.cp: defer AsyncCpTask destruction until all subtasks finish #30162 fixed the version of this where a failing file copy destroyed the parent immediately, while sibling copies were still using it.cpbuiltin (src/runtime/shell/builtin/cp.rs) resolves its operands and then starts the sameNewAsyncCpTask(asShellAsyncCpTask). On Linux and macOS the builtin is off by default and the shell runs the systemcpinstead;BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1turns it on, and it is read once at startup, which is why the copies run in a child process. On Windows it is always on.ASAN_OPTIONS=symbolize=0: when ASAN aborts a debugbunit symbolizes the report first, which takes several seconds on this binary; with symbolization off the report header still lands in stderr and the abort is immediate.What the new shell test reports with the #30162 bug put back (debug/ASAN build, symbolized run)
10 runs of the child loop on that build: ASAN fired in all 10, after 1, 3, 1, 2, 2, 1, 3, 9, 6 and 5 completed iterations.
Timing of the removed test's child on this machine