Skip to content

Report unhandled early rejections from Bun.write, server.fetch and Bun.resolve - #37474

Open
robobun wants to merge 3 commits into
mainfrom
farm/6a7db94c/track-early-rejected-promises
Open

Report unhandled early rejections from Bun.write, server.fetch and Bun.resolve#37474
robobun wants to merge 3 commits into
mainfrom
farm/6a7db94c/track-early-rejected-promises

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

When Bun.write(), server.fetch() or Bun.resolve() fail before doing any work, the rejected promise they return is invisible to the unhandled rejection machinery. Nothing is printed and the process exits 0:

$ echo x > plainfile
$ bun -e 'Bun.write("plainfile/x", "x")'; echo "exit=$?"
exit=0
$ bun -e 'Bun.resolve("./does-not-exist", process.cwd())'; echo "exit=$?"
exit=0
$ bun -e 'const s = Bun.serve({ port: 0, fetch() { return new Response("x") } }); s.fetch(); s.stop()'; echo "exit=$?"
exit=0

Adding .catch() to any of them shows the error is there (ENOTDIR, ERR_MODULE_NOT_FOUND, fetch() expects a string but received no arguments.), and process.on("unhandledRejection") listeners never see it either. Every other rejection in Bun is reported and exits 1, including the same Bun.write() failure once it takes a different internal route: Bun.write("plainfile/x", "x".repeat(300000)) and Bun.write("plainfile/x", new Blob(["x"])) both print the ENOTDIR error and exit 1 today, because only payloads under 256 KiB go through the synchronous fast path. On Windows, which has no fast path, all of them are reported. Whether a failed write is reported currently depends on the payload size and type.

Cause: these early exits build their promise with JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm (JSC__JSPromise__rejectedPromiseValue in bindings.cpp), which sets the rejected flag and the result slot directly and never calls promiseRejectionTracker. Its doc comment has said this since it was renamed in #18549. fetch() has the same problem and is fixed separately in #37434; this PR converts every other user:

file sites reachable from
src/runtime/webcore/Blob.rs 21 Bun.write(), BunFile.write(), S3File.write(), S3Client.write(): sync fast paths for strings and typed arrays, empty Blob sources, S3 option validation, S3 stream setup failures, errored Response/Request body sources, pipe_readable_stream_to_blob
src/runtime/server/server_body.rs 7 server.fetch(): no arguments, blank URL, wrong argument type, no fetch handler, body conversion failure, handler throwing, returning an Error, returning undefined
src/runtime/api/BunObject.rs 1 Bun.resolve()
src/runtime/webcore/ByteStream.rs 1 text()/json()/... on a native stream that already failed (the C++ buffered fast path chains a reaction onto this promise, so the change is not observable there)
src/runtime/node/node_fs_binding.rs 1 fs.promises.readFile/writeFile/appendFile with an already aborted signal (the JS wrappers adopt the promise, not observable either)
src/runtime/api/bun/subprocess.rs 1 subprocess.exited read after a waitpid failure; the promise created while the process was still running is already rejected through the tracked path, so this makes both reads behave the same
src/jsc/JSPromise.rs 1 wrap_value (only used by formData(), whose value is never an error)

Fix: build them with JSPromise::rejected_promise(global, err).to_js(), the helper Body.rs, streams.rs, s3/client.rs and ErrorCode::reject() already use. That goes through JSC::JSPromise::rejectedPromise, so the promise is registered with the tracker exactly like a Promise.reject() from JS. Two kinds of sites needed slightly more than the one-line swap:

  • Six sites reject with an exception caught from a call (S3 option validation in three Bun.write paths, Bun.resolve, the server.fetch() handler call and its body conversion). They used take_exception(), which returns the JSC::Exception cell, and rejected with that. await happens to unwrap it, but .catch() callbacks received the bare cell: no message, no code, instanceof TypeError false, and String(e) throws. JSC::JSPromise::rejectedPromise also asserts it is not given an Exception. These now go through a small new JSPromise::rejected_promise_with_caught_exception(global, err), which is create() + the existing reject(Err(err)): it takes the exception off the VM, rejects with the thrown value, and propagates a termination instead of turning it into a rejection reason. The body conversion site in server.fetch() additionally had a real bug: it discarded the Err without clearing the exception, so the generic "fetch() received invalid body" promise it built was never observed. Release builds threw the conversion error synchronously out of server.fetch() and debug builds hit ExceptionScope::releaseAssertNoException. It now rejects with that error, like fetch() does for its argument conversion errors.
  • pipe_readable_stream_to_blob forwards the rejection of the stream's own promise. It now reads it with unwrap(MarkHandled) (the pattern blob/write_file.rs and RequestContext.rs use) so that only the promise handed to the caller is reported, not the internal one as well.

Bun__resolve in BunObject.rs was another user of the helper. Nothing calls it on either side (the only reference was the extern declaration in ImportMetaObject.h), so it is removed instead of converted. The deprecated Rust helper and JSC__JSPromise__rejectedPromiseValue themselves stay until #37434 removes the fetch.rs uses; whichever of the two lands second can delete them.

Note on the diff: the large hunk in write_file_internal (Blob.rs) is cargo fmt re-indenting the body_dispatch closure. Its only over-long line was one of the converted calls, which had made rustfmt skip the whole closure until now; apart from the one converted call, git diff -w shows only re-indentation and re-wrapping in it.

Why this is the right behavior

The promise is returned straight to the caller, so the normal rules apply unchanged: a handler attached before the microtask checkpoint (.catch(), await, expect(p).rejects, adopting it from an async function) clears it from the pending list and nothing is reported; only a rejection nobody handles is. That is already how the async paths of the very same calls behave, so this removes the dependence on which internal route a call happened to take, and it matches Node, where every unhandled rejection is reported. I audited the native consumers of these promises: every one either returns the value to JS, resolves another promise with it (Image.rs), or reads it with unwrap(MarkHandled) (blob/write_file.rs), so none of them produces a new spurious report. I also checked the test suite for code that creates one of these rejections and handles it late; there is none (the one such case for fetch() is handled in #37434).

How did you verify your code works?

New tests, all spawning bun -e and asserting on stderr and the exit code, plus in-process checks of the rejection reason:

  • test/js/bun/io/bun-write.test.js: string and typed array to a directory (both fast-path open errors) and to a read-only fd (both fast-path write errors), empty Blob to a directory, BunFile.write(), S3 writes with an invalid storageClass for a string and an empty Blob source (the two S3 validation sites); that the S3 rejection reason is the TypeError itself; that the promise passed to unhandledRejection is the one returned; and that a handled rejection is still silent.
  • test/js/bun/http/bun-serve-fetch-invalid-args.test.ts: all eight server.fetch() exits as unhandled rejections, rejects.toBe(error) for a throwing handler (Error and non-Error values) and for a handler returning an Error, a rejection instead of a synchronous throw when the body cannot be converted, the unhandledRejection identity check and the handled case.
  • test/js/bun/resolve/resolve-error.test.ts: Bun.resolve() reported, delivered to unhandledRejection, and silent when handled.

On the current release 11 of 12 tests in the new bun-write block, 10 of 14 in the server.fetch file and 2 of 3 in the resolve block fail (empty stderr and exit 0, or the bare exception cell as the reason); all pass with this change. The remaining converted sites (S3 stream setup failures, errored body sources, pipe_readable_stream_to_blob, ByteStream, the aborted-signal fs path, subprocess.exited after a waitpid failure, wrap_value) are the same one-line change; the first group needs S3 or internal stream failures to reach and the rest are not observable from JS, as described in the table above.

Also ran with the debug build: the rest of bun-write.test.js, resolve.test.ts, bun-object/write.spec.ts, util/fuzzy-wuzzy.test.ts (calls every Bun.* function with no arguments), bun-serve-routes.test.ts, image.test.ts (resolves with a Bun.write promise), spawn.test.ts, node/fs/promises.test.js, web/fetch/body*.test.ts, body-stream, fetch-abort-stream-body, stream-fast-path, FormData.test.ts and util/bun-file.test.ts. All pass except bun-write.test.js "copyFileRange is not available > on large files", which fills a 256 MiB buffer in a JS loop and exceeds its 5 s timeout under the debug build in this environment regardless of this change (0.9 s on the release build). cargo clippy on bun_jsc and bun_runtime is clean.

…friends

The promises these APIs return when they fail before doing any work were
built with JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm,
which never registers the rejection with the promise rejection tracker.
An unhandled one was therefore silently dropped and the process exited 0.
Build them with JSPromise::rejected_promise instead, like the rest of the
runtime does.

Sites that rejected with a caught exception used to hand out the
JSC::Exception cell itself; they now reject with the thrown value via
JSPromise::rejected_promise_with_caught_exception. server.fetch() also
takes the exception thrown while converting the body off the VM instead
of returning a promise with it still pending.

Bun__resolve, another user of the helper, had no callers and is removed.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 30e49d45-c1a4-48d2-b111-b75b0aea2a18

📥 Commits

Reviewing files that changed from the base of the PR and between da3851e and eeedfe4.

📒 Files selected for processing (11)
  • src/jsc/JSPromise.rs
  • src/jsc/bindings/ImportMetaObject.h
  • src/runtime/api/BunObject.rs
  • src/runtime/api/bun/subprocess.rs
  • src/runtime/node/node_fs_binding.rs
  • src/runtime/server/server_body.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/ByteStream.rs
  • test/js/bun/http/bun-serve-fetch-invalid-args.test.ts
  • test/js/bun/io/bun-write.test.js
  • test/js/bun/resolve/resolve-error.test.ts

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the 1.4.0 release and on main (9fcdea80b1) with the three one-liners in the description (empty stderr, exit 0; .catch() shows the errors). Fixed in this PR; the new tests in test/js/bun/io/bun-write.test.js, test/js/bun/http/bun-serve-fetch-invalid-args.test.ts and test/js/bun/resolve/resolve-error.test.ts fail on the unfixed build and pass with it. The same change for fetch() is #37434.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Unwrap JSC::Exception in promise rejection bindings #30243 - Fixes the same raw JSC::Exception rejection defect (same Bun.write(Bun.s3.file(...), blob, { accessKeyId: 123 }) repro) in the C++ bindings instead of at the Rust call sites, making this PR's rejected_promise_with_caught_exception helper redundant.
  2. fetch: report early rejections as unhandled rejections #37434 - Same root cause and same fix (swapping dangerously_create_rejected_promise_value_without_notifying_vm for JSPromise::rejected_promise) applied to fetch.rs; both PRs claim ownership of deleting the deprecated helper.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Neither of the two is a duplicate of this PR:

  • fetch: report early rejections as unhandled rejections #37434 is the same fix for the 20 sites in fetch.rs; this PR covers every site outside fetch.rs. The file sets are disjoint, and neither PR deletes the deprecated helper or JSC__JSPromise__rejectedPromiseValue (both descriptions leave that to whichever lands second), so they do not conflict.
  • Unwrap JSC::Exception in promise rejection bindings #30243 only addresses the secondary symptom, the JSC::Exception cell being used as a rejection reason, by unwrapping it inside the C++ bindings (it also covers rejectAsHandled and the module loader, which this PR does not touch). It does not register the promise with the rejection tracker, which is the bug here: with Unwrap JSC::Exception in promise rejection bindings #30243 applied, Bun.write("plainfile/x", "x"), server.fetch() and Bun.resolve("./missing", dir) still print nothing and exit 0. rejected_promise_with_caught_exception is not made redundant by it either: its job is to take the pending exception off the VM (the server.fetch() body site returned with it still pending) and to propagate a termination; rejecting with the thrown value falls out of reusing JSPromise::reject. The two changes are compatible; if Unwrap JSC::Exception in promise rejection bindings #30243 lands, the bindings become defensive against callers that still pass a cell, and after this PR these callers no longer do.

Comment thread test/js/bun/io/bun-write.test.js
Comment thread src/jsc/JSPromise.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:18 AM PT - Aug 11th, 2026

@robobun, your commit eeedfe4373c3d0be10677d396ecbdcc71f707544 passed in Build #92307! 🎉


🧪   To try this PR locally:

bunx bun-pr 37474

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

bun-37474 --bun

Comment thread src/jsc/JSPromise.rs

@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 re-reviewed after bf55edb/eeedfe4 and didn't find any bugs. My earlier Windows EBADF concern was checked on a real Windows canary and a comment was added; the doc-comment length flags were addressed. Because this changes user-visible behavior (silent → reported unhandled rejections, exit 0 → 1) across ~30 rejection sites in JSC/runtime code and includes a real semantic fix in server.fetch()'s body-conversion path, a maintainer sign-off would still be worthwhile.

What was reviewed:

  • Confirmed Bun__resolve / bun_resolve have no remaining callers (C++ extern, HOST_EXPORT marker, and Rust fn all removed together).
  • The new rejected_promise_with_caught_exception composes existing create() + reject(Err(err)), so termination handling and Exception-cell unwrapping reuse the audited reject path.
  • pipe_readable_stream_to_blob's switch to unwrap(MarkHandled) matches the pattern in blob/write_file.rs and prevents double-reporting the internal stream promise.
  • Remaining users of the deprecated helper are only fetch.rs (covered by #37434) and the definition itself.
Extended reasoning...

Overview

The PR converts every remaining call site of JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm outside fetch.rs (~30 sites across Blob.rs, server_body.rs, BunObject.rs, ByteStream.rs, node_fs_binding.rs, subprocess.rs, and JSPromise.rs::wrap_value) to JSPromise::rejected_promise(...).to_js(), so early rejections from Bun.write, server.fetch, Bun.resolve, etc. are registered with the rejection tracker like every other rejection in Bun. Six sites that previously rejected with a raw JSC::Exception cell now go through a new 5-line helper rejected_promise_with_caught_exception that takes the pending exception off the VM and rejects with the thrown value (propagating termination). One site in server_body.rs additionally fixes a pre-existing bug where the body-conversion Err was discarded without clearing the pending exception. The dead Bun__resolve FFI export is removed. The large hunk in write_file_internal is rustfmt re-indentation of a closure whose only over-long line was one of the converted calls.

Security risks

None identified. No new input parsing, no auth/crypto/permission changes; the change routes existing error values through the tracked rejection path instead of the untracked one.

Level of scrutiny

Medium-high. Each individual site is a mechanical one-line swap to the helper the deprecated function's own doc comment says to use, and the new helper is a trivial composition of existing primitives. But collectively this is a user-visible behavioral change (processes that previously exited 0 with a silent rejection now print the error and exit 1) across many entry points, plus a real semantic fix in server.fetch() (pending-exception leak → proper rejection) and a MarkHandled adjustment in pipe_readable_stream_to_blob. That's the kind of intent change a maintainer should confirm, even though the PR description makes a thorough case that it aligns these paths with Node and with Bun's own async paths for the same calls.

Other factors

  • My previous inline finding (Windows EBADF vs EPERM in the read-only-fd test rows) was resolved: the author ran the exact test rows on a Windows canary, observed UV_EBADF (errno -4083), and added a comment explaining the substring match. The comment-cop long-comment flags were also addressed in eeedfe4.
  • I verified Bun__resolve has no remaining references anywhere (C++ header, HOST_EXPORT codegen marker, Rust body all removed), and that the only remaining users of the deprecated helper are fetch.rs (#37434) and the definition itself.
  • Test coverage is strong: subprocess-spawned checks of stderr + exit code for each converted early-exit, in-process rejects.toBe(error) identity checks for the Exception-cell fix, an unhandledRejection listener identity check, and negative tests that handled rejections stay silent. robobun confirmed fails-before/passes-after on the release build and on main.
  • No outstanding human reviewer comments.

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