Skip to content

node:fs: copyFile and cp fail and remove the destination when close(2) of the destination fails - #44331

Draft
robobun wants to merge 7 commits into
robobun/8d1b9fe1/copyfile-unlink-partial-destfrom
robobun/a27cb684/copyfile-close-error
Draft

robobun wants to merge 7 commits into
robobun/8d1b9fe1/copyfile-unlink-partial-destfrom
robobun/a27cb684/copyfile-close-error

Conversation

@robobun

@robobun robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fs.copyFile* and fs.cp* report success, and keep the file, when close(2) of the destination fails. NFS, SMB and FUSE can report a write error first at close. Node throws ENOSPC: no space left on device, copyfile and removes the destination.
  • The cause: close_copy_dest in src/runtime/node/node_fs.rs (node:fs: remove the destination when copyFile/cp fails partway #41798) ends with dest_fd.close(), which drops the result.
  • No user reported this. The case is node's behaviour.

Fix

  • A failed close of the destination now fails the copy, with the close errno and syscall copyfile. The destination is then unlinked by the rule of node:fs: remove the destination when copyFile/cp fails partway #41798: a regular file that is not the source.
  • Verified: the two copy tests in test/js/node/fs/fs-close-error.test.ts fail without the node_fs.rs change.
  • Self-reviewed: 6 concerns raised, 5 addressed (Notes). Open: one function for the copy tail.

Background

Downsides

  • A copy onto an existing file whose close fails removes that file. Node's copyFile does too, and its cpSync keeps it. A maintainer must choose.
  • A copy that succeeds runs 21 more instructions (copyFileSync) or 18 (cpSync): no new syscall, branch or allocation. text grows by 1,024 B.
Notes

Stack. The base of this PR is #41798, which adds close_copy_dest. The commits of #44329 and #44330 are in this branch (0f2f8c7, 219635a, 38478b6, 38a9a64, 618c91e and d27e7ba). The change of this PR is 6772f97: node_fs.rs, the copy tests, and the FICLONE switch of the test shim. The order to land: #44329, then #44330, and #41798. Then this PR, as one commit on main.

Self-review. It ran on this PR as the head of the stack. Its verdict: keep the fix and its layer, and change how it is cut.

  1. A merge of this PR goes into the branch of node:fs: remove the destination when copyFile/cp fails partway #41798. Done: this PR is a draft until its base and sys, node:fs: close reports every errno except EINTR and EINPROGRESS #44330 are on main.
  2. writeFile and appendFile by path still returned success where node throws ENOSPC close. Done in sys, node:fs: close reports every errno except EINTR and EINPROGRESS #44330.
  3. The copy tail gets a third helper: close_copied_dest here, beside close_copy_dest of node:fs: remove the destination when copyFile/cp fails partway #41798 and finish_copied_dest of fs.writeFile, Bun.write, fs.copyFile: fail when the final ftruncate fails on a regular file #42618. Open. close_copy_dest already takes the result of the copy and returns it here. The FICLONE exits call close_copied_dest because node:fs: remove the destination when copyFile/cp fails partway #41798 leaves them without a trim. One function for every exit needs the signature that node:fs: remove the destination when copyFile/cp fails partway #41798 lands with, so that change comes with the rebase.
  4. The body called two differences from node "not fixed". Done: they are deliberate, see below.
  5. A maintainer must rule on a destination that existed before the copy. Done: the question is in Downsides.
  6. Remove libuv on Windows #42819 edits the same lines of src/sys/fd.rs as sys: one single-shot POSIX close behind close_allowing_standard_io and bun_sys::close #44329. Done: sys: one single-shot POSIX close behind close_allowing_standard_io and bun_sys::close #44329 says so.

Reproduction. No NFS, SMB or FUSE mount exists in my environment, so the close failure is simulated. The shim test/js/node/fs/fs-close-error-shim.c does the real close of each file named *.<errno>.close-fault that is open for writing, and then returns that errno.

cc -shared -fPIC -o shim.so test/js/node/fs/fs-close-error-shim.c -ldl
head -c 20000 /dev/urandom > src.bin
LD_PRELOAD=$PWD/shim.so bun -e 'require("fs").copyFileSync("src.bin", "copy.28.close-fault")'   # 28 is ENOSPC
ls -l copy.28.close-fault
call, close of the destination returns ENOSPC bun main (9f70da0) this PR node v26.3.0
copyFileSync, copyFile, promises.copyFile success, 20,000-byte file stays ENOSPC, syscall copyfile, file removed ENOSPC, syscall copyfile, file removed
cpSync, cp, promises.cp of one file success, file stays ENOSPC, syscall copyfile, file removed ENOSPC, syscall copyfile, file removed
copyFileSync, close returns EINTR or EINPROGRESS success success success

Where this PR differs from node. Each row except the last node column is a test.

destination this PR node v26.3.0
a file that existed before removed removed
a symlink link removed, target keeps the copy the same
the source itself (copyFileSync(p, p)) error, file kept error, file removed
a fifo ENOSPC, fifo kept node fails every copy into a fifo with EINVAL (its ftruncate), and removes the fifo
cpSync onto a file that existed, and cpSync of a directory ENOSPC, syscall copyfile, file removed ENOSPC, syscall cp, file kept

The rule "only a regular file that is not the source" is the rule of #41798 for a copy that fails partway. In the last row node's cpSync takes another path in C++, which closes through libc close and does not unlink. I saw that row with a shim that also interposes close.

Cost of a copy that succeeds. Release builds of this branch with and without the last commit, same toolchain:

  • gdb, every syscall of the JS thread during one call: copyFileSync 8 before and after, cpSync of one file 15 before and after. The lists are identical.
  • gdb, single-step through the native function: copyFileSync 1,857 to 1,878 instructions, cpSync (copy_single_file_sync) 281 to 299. Conditional jumps (270 and 28), calls (36 and 6) and allocator calls (0) do not change. The added instructions pass the result of the copy into close_copy_dest and back.
  • size: text 80,661,782 B to 80,662,806 B. nm -S: copy_dest_close_failed is 408 B and cold, close_copied_dest is 192 B, close_copy_dest grows by 245 B.
  • When the close fails with ENOSPC, the copy issues 2 more syscalls: newfstatat and unlink. With EINTR it issues none. The destination is closed once in both cases.

What ran, and what did not.

  • Linux x64, debug build with ASAN of d27e7ba: fs-close-error.test.ts (4 pass), fs-promises-writeFile-async-iterator.test.ts and wasi.test.js. Each of the 4 tests runs one bun process under the shim and compares its report with one toEqual.
  • Before the review follow-up, on a debug build with ASAN of 6772f97: fs-copyfile-write-error.test.ts, cp.test.ts, cp-symlink-target.test.ts, the copyFile tests of fs.test.ts, and node's 79 test-fs-copyfile* and test-fs-cp-* files. node_fs.rs is the same in both.
  • On a build without 6772f97, 12 of the 14 copy cases failed when each case was its own test. The 2 others check that EINTR is not an error and that each destination is closed once. The two copy tests now assert all 14 cases, so both fail.
  • The tests pass a timeout for debug builds only. On a loaded machine a debug build with ASAN needed up to 6.1 s for one fixture, and a test has 5 s there. Other builds keep the default.
  • No file system here has reflinks. For the three FICLONE exits, the shim makes the FICLONE ioctl report success and copy nothing. An empty destination after a close that returns EINTR shows that the copy took that exit.
  • bun run rust:check-all passes for all 12 targets. The macOS arm (sources up to 128 KB) and the FreeBSD arms are compiled, not run.

Deliberate differences from node.

  • The close of the source descriptor: bun ignores its result. The descriptor was only read, so the copy is whole. libuv reports that error and removes the destination (fs.c#L1403-L1408).
  • fchmod of the destination: bun drops its result, as before this PR. The data is written and the close reported no error. libuv fails the copy, except for EPERM on CIFS (fs.c#L1338-L1355).
  • A destination that is the source, or a fifo: bun keeps it (the rule of node:fs: remove the destination when copyFile/cp fails partway #41798). Node removes it.

Not fixed here.

…d bun_sys::close

Both entry points had their own copy of the close call and of the rule
for its result, one per platform: five copies in two files. They now
share close_once (the call) and close_error (the rule). The rule does
not change: only EBADF surfaces.
The POSIX close kept EBADF and dropped every other errno. A file system
can report a write error (ENOSPC, EDQUOT, EIO) first at close(2): NFS,
SMB and FUSE do. fs.close, fs.closeSync and FileHandle.close returned
success for it. They now report the error, as node does.

EINTR and EINPROGRESS stay a success, as in libuv. Fd::close() still
drops the result, and its debug assert fires for EBADF only.

node:wasi removes its entry for a descriptor before the close, and
http2 respondWithFile ignores the result of its close.
…) of the destination fails

A file system can report a write error (ENOSPC, EDQUOT, EIO) first at
close(2): NFS, SMB and FUSE do. The copy paths dropped the result of the
close of the destination, so copyFile and cp returned success for an
incomplete copy. libuv's uv_fs_copyfile keeps the error and unlinks the
destination.

close_copy_dest now returns the result of the copy. After a good copy it
closes through close_copied_dest: when that close fails, the copy fails
with the close error as a copyfile error, and the destination is
unlinked by the rule of a failed copy (only a regular file that is not
the source). The FICLONE exits and the FreeBSD copyFile arm close
through the same helper.
@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Linux x64 with a simulated close failure. My environment cannot mount NFS, SMB or FUSE, so a real mount is not tested.

The shim in this PR (test/js/node/fs/fs-close-error-shim.c) does the real close of each file named *.<errno>.close-fault that is open for writing, and then returns that errno. 28 is ENOSPC.

cc -shared -fPIC -o shim.so test/js/node/fs/fs-close-error-shim.c -ldl
head -c 20000 /dev/urandom > src.bin
LD_PRELOAD=$PWD/shim.so bun -e 'require("fs").copyFileSync("src.bin", "copy.28.close-fault")'
ls -l copy.28.close-fault
call bun main (9f70da0) this branch (d27e7ba) node v26.3.0
copyFileSync, copyFile, promises.copyFile success, the 20,000-byte file stays ENOSPC, syscall copyfile, file removed ENOSPC, syscall copyfile, file removed
cpSync, cp, promises.cp of one file success, the file stays ENOSPC, syscall copyfile, file removed ENOSPC, syscall copyfile, file removed

The stack: #44329 (one close in bun_sys, no behaviour change), then #44330 (close reports the errno), then this PR on top of #41798.

This PR is a draft on purpose. Its base is the branch of #41798, so it must not merge before #41798 and #44330 are on main. Then it becomes one commit on main.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the two ScopeGuard::into_inner disarms in the Linux copy_file_range/sendfile arms of copy_file — each runs before close_copied_dest, so dest is closed exactly once on those exits — and the http2.ts tryClose change, where passing a noop callback to fs.close routes a close error to the callback instead of an unhandled promise rejection.

Extended reasoning...

The change unifies POSIX close into close_once/close_error in bun_sys, propagates close(2) errors through Fd::close_allowing_* and node:fs copyFile/cp (with cfg-gated copies for Linux, macOS and FreeBSD), and reorders FD_MAP updates in wasi.ts. It touches no auth, crypto or injection surface, but it does add an unlink of the destination path on close failure and alters syscall-wrapper semantics used across the runtime. Inline findings were posted, the diff is cross-platform and largely untestable on this host for macOS/FreeBSD arms, so a human look is still warranted; the note only records what else was ruled out.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/node/node_fs.rs — pre-existing: users of writeFile and appendFile on NFS, SMB or FUSE still get success and a short or missing file when close(2) reports ENOSPC, EDQUOT or EIO, while Node throws or rejects. This is the same class this PR fixes for copyFile: write_file_with_path_buffer closes its path-opened fd through a scopeguard fd.close() at src/runtime/node/node_fs.rs:7432-7438, which drops the close result; append_file does the same at node_fs.rs:4718. Fix: after a successful write, close through close_allowing_bad_file_descriptor and return the close error (syscall close) instead of Ok, for both sites, which serve writeFileSync, writeFile, promises.writeFile, appendFileSync, appendFile and promises.appendFile. [also at: src/runtime/node/node_fs.rs:4942 - pre-existing: users of fs.writeFileSync, fs.writeFile and fs.appendFile on NFS, SMB or FUSE still get success when close(2) of the file fails with ENOSPC, EDQUOT or EIO, while this PR makes copyFile and cp report it.]

    Why this was flagged

    A user calls fs.writeFileSync(path, buffer) or fs.promises.writeFile(path, buffer) on an NFS, SMB or FUSE mount where the write is accepted and the error is reported first at close(2). write_file_with_path_buffer at src/runtime/node/node_fs.rs:7415-7431 opens the fd, and the scopeguard at node_fs.rs:7432-7438 closes it with fd.close(), whose result Fd::close at src/sys/fd.rs:69-75 discards. The function returns Ok(()) at node_fs.rs:7543, so the user sees success and a file that may be short or missing. append_file at node_fs.rs:4718 has the same shape and returns Ok(()) at node_fs.rs:4723. Node's writeFileSync runs fs.closeSync(fd) in its finally block and throws the close error, and promises.writeFile rejects through handleFdClose. The base branch behaves the same way, so this is pre-existing; the PR series makes close errors observable in sys::close_error (src/sys/lib.rs:1956) but only rewires copyFile and cp to consume them.

    Verification: pre-existing (the base branch drops these close results by the same route; this PR fixes only the copyFile/cp destination close). The write error is reported first at close(2) (NFS, SMB, FUSE returning ENOSPC/EDQUOT/EIO). write_file_with_path_buffer at src/runtime/node/node_fs.rs:7432-7439 closes the fd via fd.close() and returns Ok(()) at line 7543 regardless of the close result.

Comment thread test/js/node/fs/fs-close-error.test.ts Outdated
dest_fd: FD,
src_stat: &sys::Stat,
) -> Maybe<ret::CopyFile> {
let close_err = dest_fd.close_allowing_bad_file_descriptor(None);

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.

🟣 pre-existing, not blocking: Processes started with a closed stdio slot (e.g. bun script.js <&-) still get success from copyFile and cp when close(2) of the destination fails, and leak the descriptor, despite this PR's promise to report that error. node_fs.rs:4948 closes through close_allowing_bad_file_descriptor, and src/sys/fd.rs:81-83 returns None without calling close for any fd whose stdio_tag() is set, so a dest_fd of 0, 1 or 2 is never closed and never reports. Fix: close the copy destination through close_allowing_standard_io (the fd is owned by this function and was opened here), so a stdio-numbered descriptor is both released and reported like every other.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user runs bun with stdin closed (<&-, or from a supervisor that closes fd 0), so the next open(2) returns fd 0. In copy_file the Syscall::open(dest, ...) at node_fs.rs:5240 (Linux), 5134 (FreeBSD) or 5065 (macOS) yields dest_fd = 0. After the copy, node_fs.rs:4948 calls dest_fd.close_allowing_bad_file_descriptor(None); src/sys/fd.rs:81-83 checks self.stdio_tag().is_some() and returns None with a log line, never calling close(2). The match at node_fs.rs:4950 sees None and returns Ok, so a close that the file system would have failed with ENOSPC or EIO is never issued, the copy reports success, and fd 0 stays open for the life of the process. The base branch leaked the same fd through dest_fd.close() (the same skip), but the base did not claim to report close errors. Remedy: use close_allowing_standard_io for the dest descriptor that this function opened, as node_fs::close at node_fs.rs:4732 already does.

Verification: The corrected trigger is a script that closes fd 0/1/2 at runtime (e.g. fs.closeSync(0)) before calling copyFile/cp; bun script.js <&- does NOT produce this (c-bindings.cpp:735-741). node_fs.rs:4948 and src/sys/fd.rs:81-84 never pass a dest_fd numbered 0-2 to close(2); close_copied_dest returns Ok(()) (line 4951). Base dest_fd.close() (node_fs.rs:4927) has the same stdio skip, so merging makes nothing worse.

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.

This is #43844. Fd::close() skips fd 0, 1 and 2, so each descriptor that bun opens while one of them is closed stays open. #43845 moved the first group of descriptors off those numbers where bun_sys creates them. The issue lists each internal open and close pair as what is left.

A different close in this one caller does not fix that class, so this PR keeps the rule of Fd::close(). The Notes now list the case under "Not fixed here".

Comment thread src/js/node/wasi.ts
After the last byte of the file, the only error that the read stream can
report is the close of the descriptor. The descriptor was only read, so
the response is whole. Before this change that error reset the stream
with NGHTTP2_INTERNAL_ERROR when the stream was still open.

The wasi comment names what uvwasi does. Each test fixture now runs in
its own test. The fd_renumber test does not depend on the direction of
the move.
@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up in 38478b6. It is the commit that #44330 got for its review (the http2 file response), with the copy tests of this PR in the new shape.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/js/node/fs.promises.ts — Callers of fs.promises.writeFile with an async iterable on NFS, SMB or FUSE can now lose the write error or the AbortError when close(2) also fails. At src/js/node/fs.promises.ts:1663 await fs.close(fdOrPath) is unguarded, so a close error thrown there skips the abort check and the throw error below it; the same at src/js/node/fs.promises.ts:1641 on the abort path. Fix: catch the close error on both sites and rethrow the earlier error or AbortError when one exists (Node aggregates the two via aggregateTwoErrors, keeping the write error's code), falling back to the close error only when the write succeeded.

    Why this was flagged

    A user calls fs.promises.writeFile(path, asyncIterable) on a mount that reports write errors at close. A write fails or the signal aborts, then the close reports ENOSPC. writeFileAsyncIterator at src/js/node/fs.promises.ts:1649-1653 stores the write error in error, then line 1663 awaits fs.close, which after this PR rejects with the close errno (src/sys/lib.rs:1961 now surfaces every errno except EINTR and EINPROGRESS, routed through node_fs.rs:4732). That rejection propagates out of writeFileAsyncIterator before the abort shadowing at line 1667 and the throw error at line 1672, so the caller sees only the close error. On the base branch, fs.close could only reject with EBADF, so this line never threw and the write error or AbortError always reached the caller. Node's handleFdClose rejects with aggregateTwoErrors(closeError, opError), which keeps the operation error's code. No guard exists: the try/catch at 1649 covers only the write, and 1663 is outside it.

    Verification: Trigger: fs.promises.writeFile(path, asyncIterable) where the write loop throws, or the signal aborts, and the close(2) fails. src/js/node/fs.promises.ts:1663 await fs.close(fdOrPath); is unguarded, so a rejecting close propagates out before throw error at 1672. The original error is replaced by the close errno; on base the close error was swallowed. Not covered by the PR's tests.

@robobun

robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:38 PM PT - Oct 1st, 2026

❌ @robobun, your commit d27e7ba has 1 failures in Build #122599 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44331

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

bun-44331 --bun

…ose fails too

fs.promises.writeFile(path, iterable) closes the file itself. A close
that failed replaced the error of the iterable, or the AbortError. The
error that came first now keeps its place, in an AggregateError as in
node's handleFdClose. An abort still shadows every other error.
@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed. The code is from #44330, so the change is there (c921d2b) and in this branch as 38a9a64.

writeFileAsyncIterator now guards both closes. When the iterable and the close both fail, the call rejects with an AggregateError that has the code of the first error, as node's handleFdClose does (promises.js#L1075-L1086). An abort still shadows every other error, as before. When only the close fails, the call rejects with the close error.

The node:fs test in fs-close-error.test.ts covers the three cases. Without the change, two of them report ENOSPC in place of the first error.

@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 the latest push (through the writeFile-iterable commit) and found no bugs, but since this widens what every unix close(2) reports and reshapes the copy exits on three platforms, a human look is still worthwhile.

What was reviewed:

  • Every caller of bun_sys::close / close_allowing_bad_file_descriptor outside node_fs (watcher, resolver, glob, ipc, shell, Blob, install) discards the result, so the new non-EBADF errnos only surface through node:fs close and the copy paths.
  • Each copy exit (FICLONE, copy_file_range, FreeBSD, the cp twin) closes dest exactly once; the two ScopeGuard::into_inner disarms happen only on the success exits, and the error exits still close through the guard.
  • The writeFile-iterable change matches Node's handleFdClose argument order (aggregateTwoErrors(closeError, opError)), and the abort path still shadows a close error.
  • The macOS and FreeBSD arms of node_fs.rs could not be compiled in this environment; they were checked by reading only.
Extended reasoning...

The change makes posix_impl::close_once/close_error the single close path for unix in src/sys, so any errno except EINTR/EINPROGRESS now surfaces from bun_sys::close and close_allowing_bad_file_descriptor, and node_fs.rs routes every POSIX copy_file and cp destination close through close_copied_dest, which unlinks a regular non-source dest and fails the copy on a close error. JS changes cover fs.promises.writeFile with an iterable, http2 tryClose, and WASI fd_close/fd_renumber ordering; no auth, crypto, or injection surface is touched. Tests are an LD_PRELOAD shim plus four concurrent subprocess fixtures, gated on glibc and a C compiler. Deferring rather than approving because the diff spans cfg-gated code for macOS and FreeBSD that cannot be compiled here, changes the meaning of a core syscall wrapper used across the codebase, and makes a user-visible decision to unlink an existing destination on close failure.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

… AbortError

Node's handleFdClose aggregates the error of the close with the error of
the operation, also when that error is the abort. An abort dropped the
close error. It is now in the AggregateError, and the code stays
ABORT_ERR.

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun
robobun marked this pull request as draft October 1, 2026 17:38
…lose

Both open a file, write it and close it. The close went through
Fd::close(), which drops the result, so a write error that the file
system reports first at close was lost. The error of the close is now
the result of the call, as in node: syscall close, no path, and the file
stays.
@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

This PR is now a draft. I ran a review of the whole stack. Its verdict: keep the fix and its layer, and change how it lands.

The PR body has the list and the details.

This branch has not been deployed

No deployments
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