Conversation
Both single-file arms of NewAsyncCpTask::cp_async called on_copy before looking at the result of copy_single_file_sync, so `cp -v` printed "src -> dest" on stdout for a copy that failed. The directory walk (CpSingleTask) already reported only successful copies. Route both arms through one finish_single_file helper that records the copy on Ok only; a tolerated EEXIST is now silent in the POSIX arm too, matching the Windows arm and the directory walk.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughAsync ChangesAsync cp reporting
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 3:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit 2d7bfb6 has some failures in 🧪 To try this PR locally: bunx bun-pr 37967That installs a local version of the PR into your bun-37967 --bun |
|
Status: ready for review (head 2d7bfb6). Reproduced on bun 1.4.0 with the builtin enabled ( The new tests in CI: every Linux (glibc, musl, ASAN) and Windows (x64, arm64) test job passed on both runs (builds 93747 and 94310); the only test failures were unrelated tests that passed on retry. The four macOS jobs did not run in either build (canceled before starting in the first, expired waiting for an agent in the second), so macOS has only been checked by reading the code: the helper is platform-independent, and the symlink test is written to hold whichever way |
There was a problem hiding this comment.
LGTM — clean fix that gates the -v line on copy success and unifies the two divergent single-file arms into one helper.
What was reviewed:
finish_single_filevs the removed inline blocks: Ok/Err/tolerated-EEXIST paths deliver the same result tofinish_concurrentlyas before; only the spuriouson_copyon failure is dropped.- The POSIX-arm change (no
on_copyon tolerated EEXIST) is unobservable: shell passesforce: true, error_on_exist: falseso EXCL is never set, andon_copyis a no-op fornode:fs. e.get_errno() == E::EEXISTis equivalent to the olde.errno == E::EEXIST as _on both platforms (Error::get_errno round-trips the stored discriminant).- New tests: hermetic tempdirs, pipes drained concurrently, child bun with the builtin env var so POSIX is covered; the mixed-outcome test's stdout is order-independent since only the succeeding copy emits a line.
Extended reasoning...
Overview
The PR fixes a cosmetic-but-wrong behaviour in Bun Shell's cp -v builtin: when a single-file copy fails (e.g. destination path is a directory, or read-only target), the src -> dest line was still printed to stdout even though stderr and the exit code correctly signalled failure. The root cause was two near-duplicate arms in NewAsyncCpTask::cp_async (one #[cfg(windows)], one POSIX) that called on_copy(src, dest) unconditionally after copy_single_file_sync returned, before checking whether the result was Ok.
The fix extracts a small finish_single_file(&self, src, dest, result) helper that both arms now call. It records on_copy only in the Ok arm, converts a tolerated EEXIST (!error_on_exist) to Ok(()) without recording a line, and passes any other error through. This mirrors what CpSingleTask::run_owned (the directory-walk per-file task) already does, and what cp_on_copy's doc comment says ("for every successfully-copied file"). Three new tests in cp.test.ts exercise the failing-copy, mixed-outcome, and read-only cases through a child bun with BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 so the builtin runs on all platforms.
Security risks
None. This is output-formatting logic in a shell builtin; no parsing of untrusted input, no auth/crypto/permissions surface. The change narrows behaviour (prints less), and does not alter what gets copied, what errors are surfaced, or the exit code.
Level of scrutiny
Low-to-medium. The diff is small (net −7 in the Rust file), mechanical, and the helper is a straight refactor of the two removed blocks with the one intended semantic change. I traced each of the three result cases (Ok, tolerated EEXIST, other Err) against both old arms and confirmed finish_concurrently receives the same value in every case; the only observable difference is the removed on_copy call on the error path, which is exactly the bug being fixed. The switch from e.errno == E::EEXIST as _ to e.get_errno() == E::EEXIST is equivalent — Error::get_errno round-trips the stored discriminant on both POSIX and Windows. self.args is the same struct the old code accessed via the local args = &this.args binding.
Other factors
- The one intentional divergence between old arms (POSIX called
on_copyon tolerated EEXIST, Windows did not) is resolved to the Windows/CpSingleTask behaviour. I verified this is unobservable: the shell hard-codesforce: true, error_on_exist: false(src/runtime/shell/builtin/cp.rs:712-716) soCOPYFILE_EXCLis never set on the shell path and EEXIST isn't returned there; fornode:fs,on_copyearly-returns when!IS_SHELL. - Tests follow repo conventions:
tempDir+using,bunEnvspread, concurrent stdout/stderr/exited drain,await usingon the spawn,skipIf(isRoot)for the permissions case with a try/finally chmod restore. The mixed-outcome test's stdout assertion is order-independent because only one source succeeds.join()produces the platform separator so the Windows path shape matches. - PR description reports the tests fail under
USE_SYSTEM_BUN=1and pass underbun bd, and thatcargo checkon the Windows target is clean.
…e single-file arms The directory walk (CpSingleTask) and the two single-file arms of cp_async now go through one record_copy_result: on_copy on Ok, nothing on a tolerated EEXIST, finish_concurrently on any other error. Success is implied, as it already was for the walk, by on_subtask_done resolving with Ok when no error was recorded. Tests: cp -R -v with one file in the tree failing lists everything else and not that file; a symlink source onto an existing destination (the one way the shell reaches the tolerated EEXIST today) is listed only if the destination actually became the link.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The record_copy_result refactor looks correct — the scopeguard at cp_async's entry guarantees on_subtask_done runs on every return, and that path already filled in Ok(()) when no result was recorded, so dropping the explicit finish_concurrently(Ok(())) from the single-file arms is safe and matches how CpSingleTask already worked. Because the helper is shared with fs.cp/fs.promises.cp and the comment-cop flag on the new doc comment is still outstanding, a human look would still be worthwhile.
What was reviewed:
- Traced all three call sites of
record_copy_resultand confirmedon_subtask_doneis reached on every path (via the scopeguard incp_asyncand the explicit call inCpSingleTask::run_owned). - Confirmed
on_copyis a no-op whenIS_SHELL = false, sofs.cpbehavior is unchanged (same result delivered on Ok / tolerated EEXIST / Err). - Checked
get_errno()vs the olderrno == E::EEXIST as _— the helper canonicalizes libuv codes on Windows, so the switch is correct. - New tests follow harness conventions (subprocess isolation for the once-per-process env flag, concurrent pipe drain,
tempDir, sorted comparison for the concurrent-Rwalk).
Extended reasoning...
Overview
Two files: src/runtime/node/node_fs.rs extracts a record_copy_result helper that all three per-file result sites (the Windows single-file arm, the POSIX single-file arm, and CpSingleTask::run_owned in the directory walk) now call; test/js/bun/shell/commands/cp.test.ts adds five subprocess-isolated tests exercising cp -v failure modes through the builtin.
The functional change is that on_copy (which appends the -v line for the shell) now fires only on Ok(()). The two single-file arms previously called it unconditionally (and disagreed with each other on the tolerated-EEXIST path); the walk already gated it on Ok. The helper also stops calling finish_concurrently(Ok(())) explicitly on success, relying instead on on_subtask_done (line 1670) to fill in Ok(()) when has_result is unset — exactly the mechanism CpSingleTask already depended on.
Security risks
None. This is cosmetic -v stdout output for the shell cp builtin. No parsing of untrusted input, no path handling changes, no permission logic.
Level of scrutiny
Medium. The logic change itself is small and well-justified against coreutils/BSD behavior, and the deduplication is exactly what REVIEW.md asks for ("the second time a multi-line block appears in your diff, extract a named helper"). But node_fs.rs backs both shell cp and fs.cp/fs.promises.cp, and the refactor removes an explicit finish_concurrently(Ok(())) in favor of an implicit fill-in — that's the kind of "refactor is guilty until proven behavior-preserving" change a human should sign off on. I traced it and it holds (the scopeguard::guard(this, Self::on_subtask_done) at line 1790 covers every return from cp_async, and run_owned calls on_subtask_done explicitly at line 1488), but it's subtle enough to warrant a second pair of eyes.
Other factors
- The comment-cop bot flagged the 6-line doc comment on
record_copy_result(line 1528) after cc178c8, and it hasn't been addressed. It reads to me as a false positive — it's a function doc comment stating a contract, not a workaround justification, and neighbouring helpers (finish_concurrently,on_subtask_done) carry similar-length docs — but it's an outstanding automated review item. - The
.errno == E::EEXIST as _→.get_errno() == E::EEXISTswitch is a quiet improvement:get_errnocanonicalizes libuv codes on Windows. - Tests look solid: subprocess isolation (the enable flag is read once per process),
describe.concurrent,await using+ concurrent pipe drain,sortedShellOutputfor the-Rcase where files copy in any order,skipIf(isRoot)on the read-only test with achmodrestore infinally. The symlink test relies onsymlinkSyncsucceeding, which on Windows CI needs Developer Mode — worth a glance but likely fine given other bun tests do the same.
There was a problem hiding this comment.
LGTM — focused fix that consolidates three duplicated result-handling arms into one record_copy_result helper and stops cp -v from printing lines for copies that failed or were skipped.
What was reviewed:
- Verified the completion path:
cp_asynchas a scopeguard callingon_subtask_done, which setsOk(())when no result was recorded — so dropping the explicitfinish_concurrently(Ok(()))on success is behavior-preserving for bothfs.cpand the shell. - Checked that
on_copyis a no-op when!IS_SHELL, sofs.cp/fs.promises.cpresolve/reject exactly as before. - Confirmed
e.get_errno() == E::EEXISTis the checked accessor and equivalent to (safer than) the previous rawerrno == E::EEXIST as _comparison. - Tests follow harness conventions (subprocess with
bunEnvspread,await using, drained pipes,skipIf(isRoot), chmod restored infinally,sortedShellOutputfor concurrent walk output).
Extended reasoning...
Overview
The PR fixes a bug in the Bun shell cp -v builtin where the src -> dest verbose line was printed even when the copy failed (e.g., destination is a directory, read-only, or a symlink onto an existing file where the EEXIST is tolerated). The root cause was that the two single-file arms of NewAsyncCpTask::cp_async (one #[cfg(windows)], one POSIX) called on_copy unconditionally after copy_single_file_sync, and had drifted from each other and from the directory-walk path (CpSingleTask::run_owned), which already got it right.
The fix introduces NewAsyncCpTask::record_copy_result(src, dest, result) that all three sites now call: on_copy on Ok, no-op for a tolerated EEXIST, finish_concurrently(Err) otherwise. Net native diff is ~-20 lines. The single-file arms no longer explicitly record Ok(()) — I traced that cp_async installs a scopeguard (_done) invoking on_subtask_done, which writes Ok(()) when has_result is still false, so the success path resolves identically. The macOS clonefile fast path already relied on this same mechanism.
Security risks
None. This touches only how the verbose-output callback and result are dispatched after a copy completes; no path parsing, no permission checks, no untrusted-input handling changed.
Level of scrutiny
Low-medium. This is a targeted bugfix in an experimental shell builtin (opt-in via BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 on POSIX, default only on Windows). The change is a deduplication that makes three call sites share the walk's already-correct logic. The only subtle bit — relying on on_subtask_done to record success — is exactly what the walk and clonefile paths already did, and I verified it by reading finish_concurrently and on_subtask_done in node_fs.rs. fs.cp is unaffected because on_copy early-returns when !IS_SHELL.
Other factors
Five new tests cover the failing single-file case, mixed success/failure with multiple sources, read-only destination (skipped as root), -R -v with one file failing (pins the walk now that it shares the helper), and the symlink-onto-existing-file case (asserts the -v line agrees with what actually happened to the destination). The tests spawn a child bun with the builtin enabled so they exercise the code on every platform, use test.concurrent, drain both pipes with Promise.all, and restore chmod in finally. The comment-cop bot's feedback about the long doc comment was addressed in 2d7bfb6 (comment removed). No CODEOWNERS for these paths.
Problem
cp -vbuiltin printssrc -> deston stdout for a single-file copy that did not happen. Withd/b.txtbeing a directory,cp -v b.txt dexits 1 and printscp: Is a directory: .../d/b.txton stderr, but stdout still says.../b.txt -> .../d/b.txt. Same for any other failing copy (a read-only destination as a non-root user, for example), and on Linux/FreeBSD also for a symlink source whose destination already exists, where the copy is skipped but the line was printed anyway.NewAsyncCpTask::cp_async(src/runtime/node/node_fs.rs, one#[cfg(windows)], one POSIX) calledthis.on_copy(src, dest)regardless of whatcopy_single_file_syncreturned.on_copyis what appends the verbose line (ShellCpTask::cp_on_copyinsrc/runtime/shell/builtin/cp.rs).CpSingleTask::run_owned) and got it right; the two single-file arms had also drifted from each other (only the POSIX one printed the line for a toleratedEEXIST).Fix
NewAsyncCpTask::record_copy_result(src, dest, result)now handles a file's result for all three sites (both single-file arms and the walk):on_copyonOk, nothing for anEEXISTthe flags tolerate,finish_concurrently(Err)otherwise. Success is not recorded explicitly;on_subtask_donealready resolves withOkwhen no error was recorded, which is how the walk (and the macOSclonefilepath) completed before.cp -vprinta -> bonly for copies they made,cp_on_copyis documented as being called per successfully copied file, and the walk already behaved this way. The error on stderr and the exit code were already correct; this only removes the line. Net effect for the single-file arms is the same as before except thaton_copyis no longer called onErr.fs.cp/fs.promises.cpare unaffected:on_copyis a no-op for them, and each result still resolves or rejects the same way (Okand a toleratedEEXISTresolve, anything else rejects with that error).EEXISTcase is reachable from the shell today only through a symlink source onto an existing destination (cp_symlinkcannot replace the destination and the shell passesforce: true, so theEEXISTis swallowed). This PR stops printing the line there; that the copy is skipped silently with exit 0 instead of replacing the destination likecp(1)is a separate, pre-existing bug and is tracked separately.test/js/bun/shell/commands/cp.test.ts. The new block runscp -vthrough the builtin in a child bun (BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1, so it is exercised on every platform): a failing copy,file file dirwhere one file fails (only the copied one is listed, and it was copied), a read-only destination (skipped as root),cp -R -vwith one file in the tree failing (the directories and the other file are listed, the failed file is not), and a symlink source onto an existing file (listed only if the destination actually became the link).USE_SYSTEM_BUN=1 bun test test/js/bun/shell/commands/cp.test.ts: 3 fail (failing copy, mixed, symlink); the read-only one also fails when run as an unprivileged user; the-Rone passes before and after, it pins the walk's behaviour now that it shares the helper.bun bd test test/js/bun/shell/commands/cp.test.ts: all pass (the read-only one verified as an unprivileged user as well).bun bd test test/js/node/fs/cp.test.ts,cp-symlink-target.test.ts, and the upstreamtest-fs-cp-async-*file-to-file / overwrite / nested-tree cases: pass.cargo check -p bun_runtime --target x86_64-pc-windows-msvcis clean (one of the arms is Windows-only); the Windows lanes of the first CI run were green as well.Background
cpbuiltin (always used on Windows, opt-in viaBUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1elsewhere) does not copy files itself. For each source operand it creates aShellCpTask, which resolves the operands and hands the copy to the native implementation behindfs.cp,NewAsyncCpTask, instantiated withIS_SHELL = true.NewAsyncCpTask::cp_asyncruns on a work-pool thread. A non-directory source is copied right there withcopy_single_file_sync; a directory is walked, and each file inside becomes aCpSingleTaskon the pool. The task holds a subtask count; when the last holder callson_subtask_done, the result is delivered to the JS thread, asOkunless some holder recorded an error withfinish_concurrently(first error wins, later files still get copied).on_copy(src, dest)feeds-v: forIS_SHELLit appendssrc -> destto a buffer the shell writes to stdout when the task completes; fornode:fsit does nothing. The walk also calls it once per directory after creating it.EEXIST:fs.cpwithforce: falsecopies withCOPYFILE_EXCLand treats an existing destination as "skip", unlesserrorOnExistwas requested. The shell never asks for that (force: true), so for it the case only comes up viacp_symlink, as described above.Earlier version of this PR
The first push changed only the two single-file arms (a
finish_single_filehelper that still calledfinish_concurrentlyitself) and left the walk's own copy of the logic in place. Review pointed out that the walk could share the helper, that nothing exercised-R -v, and that the tolerated-EEXISTarm is in fact reachable from the shell (symlink source onto an existing destination), so the helper was moved to cover all three sites and the-Rand symlink tests were added.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/commands/cp.test.ts