Skip to content

sys, node:fs: close reports every errno except EINTR and EINPROGRESS - #44330

Open
robobun wants to merge 5 commits into
robobun/a27cb684/sys-single-shot-closefrom
robobun/a27cb684/sys-close-reports-errno
Open

robobun wants to merge 5 commits into
robobun/a27cb684/sys-single-shot-closefrom
robobun/a27cb684/sys-close-reports-errno

Conversation

@robobun

@robobun robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fs.close*, FileHandle.close, write streams, fs.writeFile* and fs.appendFile* report success when close(2) fails. NFS, SMB and FUSE can report a write error first at close. Node throws ENOSPC: no space left on device, close.
  • The cause: close_error in src/sys/lib.rs returns EBADF and drops every other errno.

Fix

Background

Downsides

  • The calls above, and read streams, can now fail, as in node.
  • Size: +808 B of text, 552 B of it built-in JS. Instructions of a call that succeeds: closeSync 154 (no change), writeFileSync 237 to 235, appendFileSync 162 to 164.
  • Not fixed: Bun.write and FileSink drop the result of their close, and flush: true that of fsync (Notes).
Notes

Stack. PR 2 of 3. #44329 is the groundwork. #44331 uses this in fs.copyFile and fs.cp. The order to land: #44329, then this PR. #44331 is a draft until this PR and its base #41798 are on main.

Self-review. It ran on #44331 as the head of the stack, and its list is in the Notes there. Its concern for this PR: writeFile and appendFile by path still returned success where node throws ENOSPC close. c37bac7 adds them.

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. Bun and libuv both close through syscall(SYS_close, fd) on Linux, so one shim works for both runtimes.

cc -shared -fPIC -o shim.so test/js/node/fs/fs-close-error-shim.c -ldl
LD_PRELOAD=$PWD/shim.so bun -e '
  const fs = require("fs");
  const fd = fs.openSync("x.28.close-fault", "w");   // 28 is ENOSPC
  fs.writeSync(fd, "data");
  fs.closeSync(fd);
  console.log("closeSync returned");'
call bun main (9f70da0) this PR node v26.3.0
fs.closeSync returns throws ENOSPC, syscall close throws ENOSPC, syscall close
fs.close (callback) no error ENOSPC ENOSPC
FileHandle.close resolves rejects with ENOSPC rejects with ENOSPC
fs.createWriteStream close event, no error error event with ENOSPC error event with ENOSPC
fs.writeFileSync, fs.writeFile, fs.promises.writeFile by path no error ENOSPC, syscall close, file kept the same
fs.appendFileSync, fs.appendFile, fs.promises.appendFile by path no error ENOSPC, syscall close, file kept the same
fs.closeSync, errno EINTR or EINPROGRESS returns returns returns

fs.closeSync gives the same result for EDQUOT and EIO. In every row the descriptor is closed, and close is issued once.

Who reads a close result. Counted with grep at the head of this PR:

  • close_allowing_standard_io and close_allowing_bad_file_descriptor: 17 call sites outside src/sys/fd.rs. Two read the result: NodeFS::close and the new NodeFS::close_written, which writeFile and appendFile call (src/runtime/node/node_fs.rs). Three return it to callers that drop it: Entry::close_fd in the resolver, Accessor::close in the glob walker, and sys_uv::close, which is Windows only. The other 12 drop it.
  • NodeFS::write_file_with_path_buffer has one caller outside node:fs: Bun.build with compile writes its external sourcemap through it (js_bundle_completion_task.rs). A close that fails there now prints failed to write sourcemap file and drops that output, as any other write error does.
  • bun_sys::close: 37 qualified call sites, all let _ =. In src/sys, 51 more: 50 drop it and File::close returns it. No caller of File::close outside unit tests reads it.
  • Built-in JS that calls fs.close or fs.closeSync:
    • fs.promises FileHandle sets its fd to -1 before the close.
    • internal/fs/streams and Utf8Stream emit error, as node does.
    • worker_threads, net and the FileHandle finalizer call it inside try.
    • Dir from opendir holds no descriptor (its handle is the constant 1), so it closes nothing.
    • The FileHandle iterator APIs (pull, writer, readableWebStream) close in the same places as node's.
    • node:wasi, node:http2 and fs.promises.writeFile with an iterable change in this PR.
  • fs.close(fd) with no callback: a failed close is an unhandled rejection. In node it is an uncaught exception.

The users that change.

  • node:wasi fd_close and fd_renumber called closeSync before they updated FD_MAP. A close that throws left the entry in the map, and the guest's next fd_close closed that number again. The number can belong to another file by then. uvwasi keeps the entry when the close fails (uvwasi.c#L813-L816). Bun removes it, because the descriptor is released.
  • http2 tryClose calls fs.close(fd) with no callback. Its try does not catch the rejection, so a failed close ended the process. Node asserts here (fs.close(fd, assert.ifError)), which also ends the process. Bun now ignores the error: the descriptor was only read.
  • An http2 file response reads the file through fs.createReadStream with autoClose. A failed close made that stream emit error, and onFileStreamError reset the http2 stream with NGHTTP2_INTERNAL_ERROR when it was still open. With waitForTrailers that happens after the whole body. The handler now ignores an error that comes after the read stream's end: only the close can fail then. Node destroys the stream with that error (core.js#L2685-L2691). Bun ignores it for the same reason as in tryClose.
  • fs.promises.writeFile(path, iterable) opens and closes the file itself, and its await fs.close() had no guard. 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 (promises.js#L1075-L1086). For an abort the AggregateError has the code ABORT_ERR and both errors. An abort still shadows the error of the iterable, as before. When only the close fails, the call rejects with the close error.
  • writeFile and appendFile by path (sync, callback and promise) open the file, write it and close it in NodeFS::write_file_with_path_buffer and NodeFS::append_file. Both closed through Fd::close(). After a write that succeeded they now return the error of the close. As in node, the error has code, errno and syscall only, and the file stays. When the write fails too, bun reports the write error in every form, as before. Node's sync forms report the close error then, and its async forms an AggregateError with the code of the write error (seen with ulimit -f and the shim).

Machine code. Release builds of #44329 and of this PR (c37bac7), same toolchain:

  • size: text 80,666,326 B to 80,667,134 B. size counts the embedded built-in JS as text: that is 552 B. The writeFile and appendFile commit is 256 B: write_file_with_path_buffer grows by 109 B and append_file by 89 B (nm -S).
  • At the first commit of this PR, nm -S showed 6 functions that grew, by 5 to 7 B each. They are the ones that return the result: close_allowing_standard_io, close_allowing_bad_file_descriptor, bun_sys::close, File::close, the glob Accessor::close, and the closeSync host function.
  • objdump -d, addresses normalised: 279 of the 284 functions with an inlined syscall(SYS_close) are identical. Every fd.close() that drops its result compiles to the same code as before.
  • gdb, single-step through one call, base and head: the closeSync host function runs 154 instructions and 18 conditional jumps in both, with 1 syscall. write_file_with_path_buffer (writeFileSync of 4 KB by path) runs 237 and 235 instructions, 35 and 36 conditional jumps, 5 calls and 4 syscalls in both (openat, write, ftruncate, close). append_file runs 162 and 164 instructions, 25 conditional jumps, 4 calls and 3 syscalls in both.
  • Built-in JS, bundled for production (bundle-modules.ts --debug=OFF): node/http2.js 168,934 B to 169,010 B, node/fs.promises.js 43,556 B to 44,032 B. node/wasi.js does not change size. The new code runs only when a close fails.

Tests.

  • fs-close-error.test.ts has 2 tests. Each runs one bun process under the shim and compares its report with one toEqual. On a build of sys: one single-shot POSIX close behind close_allowing_standard_io and bun_sys::close #44329 the node:fs test fails: the calls return where they must throw.
  • The node:fs test has a row for each of the six writeFile and appendFile forms by path: the error is { code: "ENOSPC", syscall: "close" } with no path and no fd, and the file has its 4,096 bytes.
  • The node:fs test also covers writeFile with an iterable: only the close fails, the iterable and the close fail, an abort after the open and the close fail, an abort in the iterable and the close fail. Without the fs.promises.ts change the close error replaces the first error.
  • The http2 test makes two requests. /cancelled takes the tryClose path. /sent sends the file with waitForTrailers, so the http2 stream is still open when the close of the file fails. Without the http2.ts change the client gets rstCode 2 for /sent.
  • wasi.test.js has 2 new tests. Both fail without the wasi.ts change. The fd_renumber test asserts that no guest number maps to the descriptor that the call closed. It does not depend on the direction of the move.
  • 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 the http2 fixture, and a test has 5 s there. Other builds keep the default.
  • The tests are in a new file, next to fs-stat-seccomp-linux.test.ts: they need a C fixture and glibc. Say so if they belong in fs.test.ts.
  • Run on a debug build with ASAN of this branch (c37bac7): fs-close-error.test.ts, wasi.test.js, fs-promises-writeFile-async-iterator.test.ts, fs.test.ts (615 pass, 8 skip), test/bundler/bun-build-compile-sourcemap.test.ts (9 pass), and 15 of node's test-fs-write-file*, test-fs-append-file*, test-fs-promises-writefile*, test-fs-promises-file-handle-writeFile, test-fs-promises-file-handle-append-file and test-fs-promises files. On a113f11: 20 of node's test-http2-respond-*, test-http2-generic-streams-sendfile and test-http2-error-order files. tsc -p src/js/tsconfig.json and oxlint pass.
  • Run before the review follow-up, on a debug build with ASAN of 22c65c8: fs.test.ts, cp.test.ts, promises.test.js, dir.test.ts, fs-promises-writeFile-async-iterator.test.ts, fs-open-cloexec.test.ts, and 64 of node's test-fs-close*, test-fs-promises-file-handle-*, test-fs-write-stream*, test-fs-read-stream-* and test-http2-respond-* files. The host was under heavy load, and 6 tests exceeded their timeout in the full runs. 5 of them pass when run alone. The sixth, readdirSync ... x 100, has a 10 s limit and took 12.6 s.

Not covered.

  • A real NFS or FUSE mount.
  • fs.promises.writeFile(path, asyncIterable) closes the file twice: a FileSink on a duplicate of the descriptor, then fs.close. On NFS and SMB the first close takes the error and drops it. The test shim fails both closes, so the tests do not show this.
  • bun run rust:check-all passed for all 12 targets at the first commit of this PR. The macOS and FreeBSD arms are compiled, not run. On Windows uv_fs_close already returns its error, so writeFile and appendFile report it there too. CI builds and tests those targets.
  • fs.truncate by path has no close in bun: it calls truncate(2). Node opens the file for it, so node reports ENOSPC close there under the shim.

Found, not changed here.

  • writeFile with flush: true drops the result of fsync (FlushFileBuffers on Windows). On NFS and SMB that call takes the deferred error, so the close after it reports none. appendFile does not read flush at all. Node reports the error of fsync.
  • node:wasi fd_renumber(from, to) moves the wrong way: it closes from and gives its number to the file of to. uvwasi closes to and moves from into it (fd_table.c#L441-L456). This is older than this PR, which only moves the map update before the close.
  • wasi.ts keeps the fs of its bindings in one module-level variable, so two instances with different bindings share the last one.
  • fs.close(fd) with no callback reports a failed close as an unhandled rejection. Node throws it from defaultCloseCallback (fs.js#L488-L505). EBADF already takes this path.

Related. #38630 adds a debug ledger to both close entry points. After #44329 there is one place for it.

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

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:52 PM PT - Oct 1st, 2026

✅ @robobun, your commit c37bac708909b831c473442f8a023e9899503377 passed in Build #122583! 🎉


🧪   To try this PR locally:

bunx bun-pr 44330

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

bun-44330 --bun

@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
LD_PRELOAD=$PWD/shim.so bun -e '
  const fs = require("fs");
  const fd = fs.openSync("x.28.close-fault", "w");
  fs.writeSync(fd, "data");
  fs.closeSync(fd);
  console.log("closeSync returned");'
call bun main (9f70da0) this branch (c37bac7) node v26.3.0
fs.closeSync returns throws ENOSPC, syscall close throws ENOSPC, syscall close
fs.close (callback) no error ENOSPC ENOSPC
FileHandle.close resolves rejects with ENOSPC rejects with ENOSPC
fs.createWriteStream close event, no error error event with ENOSPC error event with ENOSPC
writeFile* and appendFile* by path no error ENOSPC, syscall close, file kept ENOSPC, syscall close, file kept

The stack: #44329 (one close in bun_sys, no behaviour change), then this PR, then #44331 (fs.copyFile and fs.cp).

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

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

  • 🔴 src/js/node/http2.ts — http2 clients downloading a file from a server whose file system reports an error at close of a read-only descriptor (FUSE flush) now receive RST_STREAM INTERNAL_ERROR after the whole body; on the base branch the response ended cleanly. On the success path the read stream has autoClose at src/js/node/http2.ts:3116, its close error goes to onFileStreamError at src/js/node/http2.ts:3143, and that calls this.close(NGHTTP2_INTERNAL_ERROR) whenever the stream is not yet closed. Fix: a close error on an fd that was only read must not reset the http2 stream once the body has been handed over; treat the error from the read stream's fd close like tryClose does (ignore or log), or skip onFileStreamError after fileStream 'end'. [also at: src/js/node/http2.ts:3001 - HTTP/2 clients served a whole file from a FUSE or NFS mount can now receive RST_STREAM INTERNAL_ERROR after the complete body, where the base finished the stream cleanly. respondWithFile's read stream is created with autoClose (src/js/node/http2.ts:3115) and its final close now surfaces EIO/ENOSPC via src/sys/lib.rs:1960, so the read stream emits 'error' and onFileStreamError at src/js/node/http2.ts:3143 calls this.close(NGHTTP2_INTERNAL_ERROR) on a stream that already sent END_STREAM.]

    Why this was flagged

    Trigger: stream.respondWithFile(path) succeeds and the read fd's close(2) returns a non-EINTR/EINPROGRESS errno. Mechanism: src/sys/lib.rs:1960 now surfaces the errno; fs.createReadStream(null, { fd, autoClose: true }) at src/js/node/http2.ts:3111-3120 closes the fd in _destroy after 'end' and emits 'error' when close fails; src/js/node/http2.ts:3143 routes that to onFileStreamError (3151-3153), which sends RST_STREAM INTERNAL_ERROR if this.closed is still false. Whether the stream is already closed when the async close callback lands is a race between nghttp2 processing END_STREAM and the fs callback, so some fraction of full transfers get a reset. Base behaviour: close_error returned None for every errno but EBADF, so no 'error' event and a clean end. Remedy: do not reset the http2 stream for a close error of an fd that was only read.

    Verification: src/sys/lib.rs:1958-1960 now returns every other errno from close_error. That is wired to onFileStreamError (http2.ts:3143), which does if (!this.destroyed && !this.closed) this.close(NGHTTP2_INTERNAL_ERROR) (http2.ts:3151-3153). On the base branch close_error surfaced only EBADF, so this fd close never errored and the response ended cleanly.

  • 🟣 src/js/node/wasi.ts — Programs holding two WASI instances with different bindings get the file operations of the instance constructed last applied to both, and the new test leaves a throwing closeSync installed module-wide. src/js/node/wasi.ts:413 declares a single module-level let fs, and src/js/node/wasi.ts:603 reassigns it in every constructor. The new wasi tests install a bindings.fs whose closeSync throws ENOSPC; after they run, any later fd_close/fd_renumber on an instance created earlier in the same process, or any WASI instance created before another one, uses the wrong fs. Fix: store the bindings' fs on the instance (this.fs) and read it in the wasiImport closures instead of a module global.

    Why this was flagged

    src/js/node/wasi.ts:413 let fs; is module scope; src/js/node/wasi.ts:603 fs = bindings.fs; runs in each constructor. All wasiImport closures (e.g. fd_close at src/js/node/wasi.ts:867, fd_renumber at 1173) read the module-level fs, not this.bindings.fs. Trigger: two WASI instances alive with different bindings (the second constructor rebinds fs for the first), or the new test in test/js/bun/wasm/wasi.test.js (bindingsWithFailingClose) followed by any use of an earlier instance in the same bun test process. The dismissing finders relied on each new WASI resetting fs before use; that holds only for the instance constructed last. The PR touches exactly the two functions (fd_close, fd_renumber) that now depend on closeSync throwing per-binding, and adds tests that make the module-level binding throw, so the shared global is now load-bearing for correctness. Base branch has the same global, so the root design is pre-existing, but the new tests are the first in-tree code to install a throwing fs there. Remedy: keep fs per instance.

    Verification: pre-existing. /home/claude/bun/src/js/node/wasi.ts:413 let fs; is module scope and :603 fs = bindings.fs; rebinds it in every constructor; fd_close (:867) and fd_renumber (:1173) read that module-level fs. So two live WASI instances with different bindings.fs share the last one's. Not introduced by the PR: b6666c4 has identical lines 413 and 603; the diff only reorders FD_MAP vs fs.closeSync.

Comment thread test/js/node/fs/fs-close-error.test.ts Outdated
Comment thread src/js/node/wasi.ts
Comment on lines 1169 to +1173
const fromEntry = CHECK_FD(from, BigInt(0));
const toEntry = CHECK_FD(to, BigInt(0));
fs.closeSync(fromEntry.real);
this.FD_MAP.set(from, toEntry);
this.FD_MAP.delete(to);
fs.closeSync(fromEntry.real);

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: pre-existing: WASI guests calling fd_renumber(from, to) get the two descriptors swapped the wrong way, so the file they meant to keep is closed and to becomes EBADF. The reordered body at src/js/node/wasi.ts:1171-1173 still maps from to the old to entry, deletes to, and closes fromEntry.real, which is the inverse of WASI/uvwasi semantics (close to's file, move from's entry to to, delete from). The new test at test/js/bun/wasm/wasi.test.js:305 asserts this inverted mapping and so certifies it. Fix: close toEntry.real, set FD_MAP.set(to, fromEntry), delete from (no-op when from === to), and update the test to expect to holding the former from entry.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A guest calls fd_renumber(5, 3) through wasiImport, expecting fd 3 to afterwards refer to the file that fd 5 held and fd 5 to be released (wasi_snapshot_preview1 fd_renumber; uvwasi_fd_table_renumber(dst=to, src=from) closes dst's file and copies src into dst). In src/js/node/wasi.ts:1169-1173 the code instead keeps entry 5 pointing at the old fd 3 file, deletes entry 3, and closes fromEntry.real, the file the guest wanted to keep. The guest's next read or write on fd 3 returns WASI_EBADF, and any further use of fd 5 hits the file that should have been discarded, while the real host descriptor of the kept file is already closed. The base branch behaves the same way (only the closeSync call moved), so this is pre-existing; the new test test/js/bun/wasm/wasi.test.js:298-306 pins the inverted result ({ from: secondEntry, to: false }), so it locks the bug in rather than guarding against it.

Verification: pre-existing — the base branch already has the inverted mapping; this PR reorders the body and adds a test that pins the inverted behavior. Triggering condition: any WASI guest calling fd_renumber(from, to). At src/js/node/wasi.ts:1168-1175 from now points at the old to resource, to is gone, and the real fd behind from is closed, so the next fd_read on to returns WASI_EBADF (8).

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.

Correct, and it is older than this PR. uvwasi closes to and moves from into it (fd_table.c#L441-L456). wasi.ts does the reverse.

This PR only moves the map update before the close, so I left the direction for a change of its own. The test no longer pins it: it now asserts that no guest number maps to the descriptor that the call closed. The Notes list the inversion under "Found, not changed here".

Comment thread src/runtime/node/node_fs.rs
Comment thread src/sys/lib.rs
Comment on lines +1959 to +1960
Ok(()) | Err(libc::EINTR | libc::EINPROGRESS) => None,
Err(errno) => Some(Error::from_code_int(errno, Tag::close).with_fd(fd)),

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: Apps that call fs.close(fd) without a callback now get an unhandled rejection instead of Node's uncaught exception when the close fails. src/sys/lib.rs:1960 makes ENOSPC/EDQUOT/EIO reach fs.close, and the no-callback arm at src/js/node/fs.ts:94 does fs.close(fd).then(() => {}), so the error surfaces only as an unhandled rejection. In Bun's default mode that prints, sets exit code 1 and keeps running; uncaughtException handlers never see it. Fix: route the no-callback case through fs.closeCb with a guarded default callback that rethrows the error, so every fs-reported close error takes the uncaughtException path like Node. Pre-existing for EBADF; this PR widens it to environment errors.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

User code opens a file on NFS/SMB/FUSE, writes, and calls fs.close(fd) with no callback, which is common fire-and-forget usage. After src/sys/lib.rs:1959-1960 the close's ENOSPC/EDQUOT/EIO is returned as an error where the base returned None. src/js/node/fs.ts:89-97 handles the missing callback with fs.close(fd).then(() => {}), so the rejection has no handler. src/jsc/VirtualMachine.rs:4392-4398 and 4450-4451 show the default Mode::Bun path: Bun__handleUnhandledRejection only consults unhandledRejection listeners, never uncaughtException, then the default handler prints the error and bumps unhandled_error_counter so the process later exits 1 while continuing to run. Node's close(fd, callback = defaultCloseCallback) throws the error from the FS callback, so it goes to process.on('uncaughtException') handlers or crashes. On the base branch only EBADF (a programming bug) could reach this arm; after the PR a file-system condition in production does.

Verification: src/sys/lib.rs:1957-1961 now returns Some(Error) for every errno except EINTR/EINPROGRESS (base returned it only for EBADF). src/js/node/fs.ts:89-97 (untouched by the PR) handles the missing-callback case with fs.close(fd).then(() => {}) — no rejection handler, so the error becomes an unhandled rejection. The process continues; uncaughtException listeners never see it.

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.

Correct. Node throws the error from defaultCloseCallback (fs.js#L488-L505). Bun's arm for no callback has been a promise since #10229, so EBADF already takes this path.

To move it to the callback arm changes what an unhandledRejection handler sees for EBADF today. That is a change of its own, so it is not in this PR. The Notes list it under "Found, not changed here".

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

  • http2 file response (the finding on http2.ts). Confirmed. With respondWithFile(path, headers, { waitForTrailers: true }) the http2 stream is still open when the read stream closes the file. A close that fails then reset the stream with NGHTTP2_INTERNAL_ERROR after the whole body. onFileStreamError now ignores an error that comes after the read stream's end: by then only the close can fail, and the file was only read. Node destroys the stream with that error (core.js#L2685-L2691). Bun ignores it, for the same reason as in tryClose. The http2 test now covers both closes. Without the change the client gets rstCode 2.
  • wasi.ts module-level fs. It is older than this PR, and this PR does not change it. Each WASI constructor sets it again, and each test in wasi.test.js constructs its own instance.
  • uvwasi. While I checked the wasi findings I read uvwasi: it keeps the entry of a descriptor when the close fails (uvwasi.c#L813-L816). This PR removes the entry, because the descriptor is released and its number can go to another file. The code comment and the Notes now say so.
  • The four inline threads have replies. The PR body is updated.

@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 (3):

  • Unresolved: 3 minor or pre-existing.

…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

c921d2b answers a finding that the review posted on #44331 (#44331 (comment)). The code is from this PR, so the change is here.

fs.promises.writeFile(path, iterable) closes the file itself. A close that failed replaced the error of the iterable, or the AbortError. The first error now keeps its place, as in node's handleFdClose. The Notes in the PR body have the details, and the node:fs test covers the three cases.

@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 (3):

  • Unresolved: 3 minor or pre-existing.

Comment thread src/js/node/fs.promises.ts
Comment on lines +1667 to +1668
try {
await fs.close(fdOrPath);

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: pre-existing: users writing to SMB (or a FUSE daemon that reports a flush error once) with fs.promises.writeFile(path, iterable) still get a resolved promise after a lost write, which Node rejects with ENOSPC. writeFileAsyncIteratorInner writes through Bun.file(fd).writer(), a dup of the fd (src/io/openForWriting.rs:34), and its close drops the errno; cifs clears the error on the first close, so fs.close at src/js/node/fs.promises.ts:1668 sees 0. Fix: carry the sink's close error (or write through the original fd so one close reports), covering FileHandle.writeFile(iterable) too. The shim faults every close, so the test cannot see this.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls fs.promises.writeFile(path, asyncIterable) on a cifs mount that is out of space; write(2) succeeds and the error is reported at close. writeFileAsyncIteratorInner (src/js/node/fs.promises.ts:1574) creates Bun.file(fd).writer(), which open_for_writing turns into dup_with_flags(fd, 0) (src/io/openForWriting.rs:34). await writer.end() (src/js/node/fs.promises.ts:1600) closes that dup on the work pool; Closer::run calls Fd::close() (src/io/lib.rs:2225), which discards the errno. cifs_flush test_and_clear_bit()s AS_ENOSPC/AS_EIO, so only the first close of the open file description gets ENOSPC. When the dup's close runs first the fs.close at src/js/node/fs.promises.ts:1668 returns 0 and the new catch at 1669-1672 never runs; writeFile resolves. The base branch also resolves here, so this is pre-existing, but the PR's test (test/js/node/fs/fs-close-error.test.ts:67, 202) asserts "ENOSPC close" for this path, which passes only because fs-close-error-shim.c:57-62 fails every close of a matching file, including the dup.

Verification: pre-existing. Trigger: fs.promises.writeFile(path, asyncIterable) on a cifs mount, when the write error surfaces at close. The sink writes to a dup (src/io/openForWriting.rs:34); Closer::run drops the errno (src/io/lib.rs:2224); cifs_flush clears the error, so fs.close at fs.promises.ts:1668 returns 0 and the promise resolves after a lost write. The shim fails every close, so the dup also fails.

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.

Yes. The writer of writeFileAsyncIteratorInner is a FileSink on a duplicate of the descriptor, and its close drops the result. On NFS and SMB that first close gets the error, so the fs.close after it returns 0.

This PR does not change that. Downsides lists FileSink as not fixed, and the Notes have this case under "Not covered". To carry the error of the sink changes FileSink for every user of Bun.file().writer(), so it needs a change of its own.

The comment of the fixture now says that the shim fails both closes, and that on NFS and SMB only the first one gets the error.

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

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 (4):

  • Unresolved: 4 minor or pre-existing.

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

  • 🟡 src/js/node/fs.promises.ts — nit: a FileHandle whose sync autoClose fails now never emits 'close', although its fd is already -1 and the async close() path emits it on every outcome. In [kCloseSync] at src/js/node/fs.promises.ts:1502-1503 closeSync throws before this.emit("close") runs, which this PR makes reachable for ENOSPC/EDQUOT/EIO instead of EBADF only. Fix: emit 'close' before rethrowing (try/finally around closeSync) so listeners such as streams.ts:174/418 and user handle.on('close') see the handle closed on the error path too, matching close() at line 743.

    Why this was flagged

    A user calls handle.writer({ autoClose: true }) or handle.pull({ autoClose: true }) on a mount whose close(2) reports ENOSPC (or EIO via FUSE flush for the read side). endSync() at src/js/node/fs.promises.ts:1453, fail() via teardown at 1466 ([Symbol.dispose] at 1484) or the pull cleanup at 1026 call handlekCloseSync. At 1500-1502 kFd is set to -1 and the registry entry is unregistered, then closeSync throws, so this.emit("close") at 1503 is skipped. The async close() at 731-743 emits 'close' unconditionally before returning the (possibly rejecting) promise, and Node's FileHandle.close emits it the same way. Listeners on the handle's 'close' event (user code, or the fd.on("close", ...) hooks at src/js/internal/fs/streams.ts:174 and 418) never fire although the descriptor is gone. On the base branch close_error only returned EBADF, so this path was a programming-error case; after src/sys/lib.rs:1959-1960 it is reached by a file-system condition in production.

    Verification: nit. Trigger: a FileHandle whose fd is auto-closed synchronously on a mount where close(2) returns ENOSPC/EDQUOT/EIO, which this PR newly surfaces (on the base only EBADF could throw here). [kCloseSync] at src/js/node/fs.promises.ts:1494-1504 calls closeSync(fd) (:1502) with no try/finally, so this.emit("close") at :1503 is skipped when closeSync throws.

@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

On the [kCloseSync] finding ('close' is not emitted when closeSync throws): node has the same order. Its [kCloseSync] sets the fd to -1, calls closeSync(), and then emits 'close', with no try (promises.js#L304-L312). A close that throws skips the event there too.

Bun's [kCloseSync] is a port of that code, so this PR keeps the order.

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

@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 (3):

  • Unresolved: 3 minor or pre-existing.

Comment on lines 4715 to +4724
PathOrFileDescriptor::Path(path_) => {
let path = path_.slice_z(&mut self.sync_error_buf);
let fd = Syscall::open(path, args.flag.as_int(), args.mode)?;
let _close = scopeguard::guard(fd, |fd| fd.close());
let close = scopeguard::guard(fd, |fd| fd.close());
while !data.is_empty() {
let written = Syscall::write(fd, data)?;
data = &data[written..];
}
Ok(())
Self::close_written(scopeguard::ScopeGuard::into_inner(close))
}

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: pre-existing: users passing flush: true to fs.appendFile / appendFileSync / promises.appendFile never get an fsync, so the data is not forced to disk and no fsync error can be reported. NodeFS::append_file at src/runtime/node/node_fs.rs:4700-4726 parses flush (args::WriteFile::from_js, src/runtime/node/node_fs.rs:4253 and 4281) but never reads args.flush. Fix: when args.flush is set, call fsync (FlushFileBuffers on Windows) after the write loop in both the Fd and Path arms and return its error with syscall fsync, matching Node where appendFileSync delegates to writeFileSync and writeAll runs fs.fsync; test/js/node/test/parallel/test-fs-append-file-flush.js:26 comments out the 'performs flush' cases on the claim that "fsync is called in native code", which is not true for appendFile.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls fs.appendFileSync(path, data, { flush: true }) (or the callback / promise forms) expecting the bytes to be on disk when the call returns. The option is parsed into args.flush by args::WriteFile::from_js at src/runtime/node/node_fs.rs:4253-4262 and 4281. NodeFS::append_file at src/runtime/node/node_fs.rs:4700-4726 writes in a loop and goes straight to close_written (4723) or Ok(()) (4713); nothing reads args.flush and no fsync is issued. A power loss or a file system that reports the write error only at fsync therefore loses the appended data with no error, where Node's appendFileSync calls writeFileSync which runs fs.fsyncSync(fd) and surfaces its error. The base branch behaves the same, so this is pre-existing, but it sits in the function this PR rewrites to surface deferred write errors. The vendored Node test test/js/node/test/parallel/test-fs-append-file-flush.js:26-38 disables the 'performs flush' assertions with a comment saying fsync is called in native code, so nothing in the suite catches the missing call.

Verification: Trigger: any caller passing { flush: true } to fs.appendFile / fs.appendFileSync. NodeFS::append_file (src/runtime/node/node_fs.rs:4700-4726) only writes in a loop and then returns Ok(()) or Self::close_written(...); args.flush is never read. The base branch has the identical omission. Data appended with flush:true is not forced to storage and no fsync error can be reported.

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.

Correct, and also older than this PR: append_file never reads args.flush.

It belongs with the fsync result of writeFile (the other thread): one change for flush: true, which can also turn on the cases that test-fs-append-file-flush.js has commented out. The Notes list it under "Found, not changed here".

Comment on lines 7513 to 7514
}
}

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: pre-existing: users passing flush: true to fs.writeFile / writeFileSync / promises.writeFile on NFS or SMB still get silent success after a lost write, which Node reports. The fsync result is discarded at src/runtime/node/node_fs.rs:7512 (and FlushFileBuffers at 7508), and on these file systems the deferred ENOSPC/EDQUOT/EIO is reported once, so fsync consumes it and the new close_written at 7518 then sees a clean close. Fix: propagate a failed fsync as the call's error (syscall fsync, as Node's writeFileSync does via fsyncSync and writeAll via the fsync callback) on both the posix and Windows arms, before the close; close_written still runs afterwards so a later close error is not lost either.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls fs.writeFileSync(path, data, { flush: true }) on an NFS or SMB mount that is out of space or quota; write(2) succeeds and the error is deferred. write_file_with_path_buffer at src/runtime/node/node_fs.rs:7505-7514 runs let _ = Syscall::fsync(fd); (7512) or let _ = FlushFileBuffers(...) (7508) and discards the ENOSPC it returns. NFS's nfs_file_fsync reports the error and advances the errseq, and nfs_file_flush at close only reports errors raised since its own sample, so the close at src/runtime/node/node_fs.rs:7518 (close_written) returns 0. The call returns Ok(()) and the user is told the data is durable although it is not on disk. Node's writeFileSync calls fs.fsyncSync(fd) inside the try and throws ENOSPC with syscall fsync. The base branch behaves the same, so this is pre-existing, but the PR's purpose of surfacing deferred write errors from writeFile leaves exactly the durability-opt-in path still silent. close_written cannot catch it because the file system has already reported and cleared the error at fsync.

Verification: pre-existing. Trigger: fs.writeFileSync / fs.writeFile / fs.promises.writeFile with { flush: true } on a file system that reports a deferred write error (NFS/SMB). src/runtime/node/node_fs.rs lines 7505-7514 run let _ = Syscall::fsync(fd); (7512) and discard the result; the fsync call consumes the deferred error, so close_written (7516-7518) returns 0 and the call returns Ok(()).

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.

Correct, and it is older than this PR. With flush: true the fsync takes the deferred error first, so the close after it sees none.

It needs a change of its own: the result of fsync on POSIX and of FlushFileBuffers on Windows, node's error shape for it (syscall fsync), and a way to make fsync fail in a test. The Notes list it under "Found, not changed here", with the appendFile case from the other thread.

@robobun

robobun commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

c37bac7: writeFile and appendFile by path now return the error of their close, as node does: syscall close, no path, and the file stays.

A review of the whole stack asked for this. Without it the stack fixed copyFile and left the more common write calls silent. The PR body has the node comparison, the cost of a call that succeeds (measured on release builds), and what is still not fixed: Bun.write, FileSink, and fsync with flush: true.

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