Skip to content

fetch: report early rejections as unhandled rejections - #37434

Open
robobun wants to merge 4 commits into
mainfrom
farm/ac18be49/fetch-rejections-notify-vm
Open

fetch: report early rejections as unhandled rejections#37434
robobun wants to merge 4 commits into
mainfrom
farm/ac18be49/fetch-rejections-notify-vm

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

When fetch() rejects before it ever touches the network and nothing handles that rejection, Bun prints nothing and exits 0:

$ bun -e 'fetch("gopher://example.com/")'; echo "exit=$?"
exit=0
$ bun -e 'const u = URL.createObjectURL(new Blob(["x"])); URL.revokeObjectURL(u); fetch(u)'; echo "exit=$?"
exit=0

The error exists (.catch() receives TypeError: protocol must be http:, https: or s3: and Failed to resolve blob:...), it is just never reported. process.on("unhandledRejection") listeners do not run either. Node reports both as unhandled rejections and exits 1, and so does Bun for every other rejected promise, including the ones fetch() produces later on from the network path.

The handled side is off too: because the tracker never saw the rejection, attaching a handler (fetch("gopher://example.com/").catch(...)) makes it look like a late handler for an already reported rejection, so process emits a spurious rejectionHandled event, and --unhandled-rejections=warn prints PromiseRejectionHandledWarning for a rejection that was handled synchronously.

Cause: every early exit in fetch_impl (src/runtime/webcore/fetch.rs) builds its promise with JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm. That helper (JSC__JSPromise__rejectedPromiseValue) sets the promise's rejected flag and result slot directly and never calls promiseRejectionTracker, which is exactly what its doc comment in src/jsc/JSPromise.rs warns about. The affected exits are: no arguments, blank URL, unparsable URL, data: URLs that fail to parse or decode, invalid proxy (string and object form), signal that is not an AbortSignal (init and input object form), proxy combined with unix, unresolvable blob: URL, unsupported scheme, GET/HEAD with a body, already aborted signal, a Bun.file() body that fails to open or read, s3 signing errors, a stream body with a non-upload method on s3, and reject_on_exception, which turns any exception thrown while converting the arguments into a rejection.

Fix: build them with JSPromise::rejected_promise(global, err).to_js() instead. That goes through JSC::JSPromise::rejectedPromise, so the rejection is registered with the tracker like a Promise.reject() from JS. It is the helper Body.rs, streams.rs, s3/client.rs and ErrorCode::reject() already use for the same purpose. The change is the same one line at each of the 20 call sites in fetch.rs; no messages or error types change.

Why this is the right behavior

The promise is handed straight back to the caller, so the usual rules apply: attaching a handler synchronously (.catch(), await, expect(...).rejects) removes it from the pending list before anything is reported, and only a rejection nobody handles by the end of the tick is reported. That is what the network-path rejections from the same fetch() call already do, so callers no longer get different reporting depending on how far fetch() got before failing. It also matches Node for these inputs. bun:test's expect(fn).toThrow() captures rejections produced during fn through its own scope, so the existing fetch-args.test.ts tests that assert on these errors via toThrow still pass unchanged.

One existing test needed updating: test/js/bun/http/fetch-file-upload.test.ts ("missing file throws the expected error") creates 1000 rejected fetch() promises and then asserts on each with expect(async () => await resp).toThrow(...). toThrow() drains the rejections that are still pending when it is called, so with the promises now registered the first assertion reported the rest as unhandled (the same thing happens today if resp is a plain Promise.reject()). The test now uses expect(resp).rejects.toThrow(...), which marks the promise handled as part of the assertion. That was the only failure in the full CI run of the first commit. Review then pointed out that the tracker keeps the 1000 promises alive until the end of the tick, which would have turned the trailing Bun.gc(true) into a no-op; verified with heapStats() (1000 promises and 1000 errors still live at that point, and still live after a bare await, since the list is drained after the microtask queue), so the test now yields one event loop turn with await Bun.sleep(0) before the forced collection, after which the counts are back to zero.

The other users of the deprecated helper (Bun.write, server.fetch(), Bun.resolve(), ...) have the same problem and are being handled separately; #37425 adds a new file: rejection that already uses rejected_promise.

How did you verify your code works?

New describe block in test/js/web/fetch/fetch-args.test.ts. It spawns bun -e for 19 of the 20 exits above (the remaining one, "Failed to start s3 stream", needs an internal stream creation failure that I could not trigger from JS) and checks that the error reaches stderr and the process exits 1. Two more tests check that a process.on("unhandledRejection") listener receives the rejection with the returned promise, and that a rejection handled with .catch() is neither reported nor followed by a rejectionHandled event. All 21 fail on the current release (empty stderr and exit 0 for the first twenty, the spurious rejectionHandled for the last one); all pass with this change, together with the rest of fetch-args.test.ts.

Also ran fetch.test.ts, fetch.stream.test.ts, fetch-redirect.test.ts, fetch.unix.test.ts, client-fetch.test.ts, blob.test.ts, the node-fetch and undici shim tests with the debug build. The only failures are the same ones the unmodified build has in this environment (localhost connections refused, (with gc) tests and a few compression tests exceeding their timeout under the debug build when the whole file runs; they pass in isolation).


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/http/fetch-file-upload.test.ts

Every early exit in fetch() (missing or blank URL, unsupported scheme,
unresolvable blob: URL, GET with a body, pre-aborted signal, unreadable
Bun.file() body, s3 signing errors, exceptions thrown while converting the
arguments, ...) built its rejected promise with
JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm,
which never tells the promise rejection tracker about the rejection. When
the caller attached no handler, nothing was printed, unhandledRejection
listeners never ran, and the process exited 0.

Build those promises with JSPromise::rejected_promise instead, the same
helper Body.rs, streams.rs and the s3 client already use, so an unhandled
early rejection is reported like any other rejected promise.
@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: 26 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: 2dc3a062-4c6e-41e1-beb8-732ebdc00a32

📥 Commits

Reviewing files that changed from the base of the PR and between cd15fba and 3643b50.

📒 Files selected for processing (3)
  • src/runtime/webcore/fetch.rs
  • test/js/bun/http/fetch-file-upload.test.ts
  • test/js/web/fetch/fetch-args.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 current release and on main: bun -e 'fetch("gopher://example.com/")' (or a revoked blob: URL, fetch(), fetch(""), GET with a body, a pre-aborted signal, ...) prints nothing and exits 0, while .catch() shows the error exists. Node reports the same inputs as unhandled rejections and exits 1. Handling one of these rejections also emitted a spurious rejectionHandled event.

Fix: the 20 early-exit sites in src/runtime/webcore/fetch.rs now build their promise with JSPromise::rejected_promise, so the rejection reaches the promise rejection tracker. Tests: new block in test/js/web/fetch/fetch-args.test.ts (all 21 fail without the change, all pass with it). test/js/bun/http/fetch-file-upload.test.ts needed its assertion and its final GC adjusted; both changes are explained in the description and the review thread is resolved.

CI was green on 5a48030 (build 91997, 190/190); the two pushes since only touch tests. Ready for review.

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

LGTM — mechanical swap of a deprecated helper for its documented replacement, with a subprocess test per call site.

Checked that JSPromise::rejected_promise is the exact replacement the deprecation comment on both the Rust helper (src/jsc/JSPromise.rs:330) and the C++ binding (bindings.cpp:5237) points to, and that Body.rs/streams.rs/s3/client.rs/Image.rs already use it in the same .to_js() shape.
Confirmed GlobalObject::promiseRejectionTracker for Reject only appends the promise to m_aboutToBeNotifiedRejectedPromises (ZigGlobalObject.cpp:1101) — no user JS runs synchronously, so no new reentrancy at these mid-fetch_impl call sites.
Tests follow harness conventions (bunEnv/bunExe/tempDir, concurrent pipe drain, describe.concurrent); the 127.0.0.1:1 and /tmp/fetch-args.sock inputs are never opened because every case rejects before the network path. The "Bun.file() body that is a directory" test's Windows behavior was examined and ruled out — the read-file fallback surfaces EISDIR there too.

Extended reasoning...

Overview

Replaces 20 uses of JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm in src/runtime/webcore/fetch.rs with JSPromise::rejected_promise(global, err).to_js(), so early fetch() rejections are registered with the promise rejection tracker and reported via unhandledRejection when nothing catches them. Adds a describe.concurrent block to test/js/web/fetch/fetch-args.test.ts with 21 subprocess tests covering 19 of the 20 call sites plus the unhandledRejection listener contract and the handled-rejection-is-not-reported case.

Security risks

None. No parsing, allocation, or trust boundary changes; error values and messages are unchanged. The rejection tracker path for Reject (ZigGlobalObject.cpp:1099-1102) only appends to a WriteBarrier vector, so switching to it introduces no synchronous re-entry into user JS while fetch_impl still holds RAII guards (SignalRef, FetchHeadersRef, the sendfile fd guard, etc.).

Level of scrutiny

Low-to-medium. The Rust change is a purely mechanical substitution at every early-exit site — same arguments, same return type via .to_js() — of a helper whose own doc comment says "DEPRECATED, use rejected_promise instead", and the pattern is already in production use in four other webcore/image files. It is a user-visible behavior change (scripts that let an early fetch rejection fall on the floor now exit 1), but that is the stated fix, matches Node, and matches what Bun already does for the same fetch() call once it reaches the network path.

Other factors

The tests are unusually thorough for this kind of change: one spawned process per changed exit, exact stderr substring + exit code, plus a positive check that process.on('unhandledRejection') receives the returned promise and a negative check that a synchronously handled rejection is not reported. They use bunEnv, bunExe, tempDir, drain stdout/stderr/exited concurrently, and JSON.stringify the temp-dir path so Windows backslashes survive the -e string. None of the http://127.0.0.1:1/ inputs reach the socket layer since every case rejects before FetchTasklet::queue. The one call site not covered ("Failed to start s3 stream") is noted in the description as untriggerable from JS. No prior reviews to reconcile; no CODEOWNERS on these files.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:08 AM PT - Aug 11th, 2026

@robobun, your commit 3643b50bb63c6e39b61c44b7cd51152c9e533111 passed in Build #92214! 🎉


🧪   To try this PR locally:

bunx bun-pr 37434

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

bun-37434 --bun

expect(fn).toThrow() reports every rejection that is still pending when it
is called. Now that fetch() registers its early rejections with the VM, the
1000 promises this test creates before asserting on them were reported as
unhandled. expect(promise).rejects marks the promise handled when the
assertion runs, which is what the test meant.

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

LGTM — mechanical swap from the deprecated dangerously_create_rejected_promise_value_without_notifying_vm to JSPromise::rejected_promise(...).to_js(), which is exactly what the doc comments on both the Rust helper (src/jsc/JSPromise.rs:330) and the C++ binding (bindings.cpp:5237) prescribe.

What was reviewed

  • All 20 call sites: same one-line substitution, no error values or control flow changed; reject_on_exception clears the pending exception before building the promise, so calling into the tracker is safe.
  • Confirmed rejected_promise().to_js() is the established pattern already used in Body.rs, streams.rs, s3/client.rs, and Image.rs.
  • fetch-file-upload.test.ts: .rejects.toThrow() attaches a handler synchronously so the 1000-iteration GC loop doesn't emit unhandled-rejection reports; the un-awaited assertion is pre-existing and Bun.peek.status(resp) remains the load-bearing check.
  • New tests spawn a subprocess per exit path and assert stderr + exit 1, plus a handled-rejection negative test — none contact the network.
Extended reasoning...

Overview

The PR replaces JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm(global, err) with JSPromise::rejected_promise(global, err).to_js() at all 20 early-exit sites in src/runtime/webcore/fetch.rs (plus data_url_response and reject_on_exception). It adds a 21-test describe.concurrent block in test/js/web/fetch/fetch-args.test.ts that spawns bun -e for each exit path and asserts the rejection reaches stderr with exit 1, plus two tests confirming unhandledRejection listeners fire and that handled rejections stay silent. test/js/bun/http/fetch-file-upload.test.ts swaps expect(async () => await resp).toThrow(...) for expect(resp).rejects.toThrow(...) so the now-tracked rejections are handled synchronously inside the 1000-iteration GC loop.

Security risks

None. No input parsing, validation, or trust boundary changes — only which JSC helper constructs the already-rejected promise. JSC::JSPromise::rejectedPromise registers the promise with the rejection tracker (a bookkeeping list) and does not run user JS synchronously.

Level of scrutiny

Medium: fetch() is a high-traffic user-facing API, but the change is a mechanical one-line substitution repeated 20 times. Both the Rust helper's doc comment ("DEPRECATED use rejected_promise instead") and the C++ binding's comment explicitly direct callers to the replacement used here, and four sibling files in src/runtime/webcore/ already use it. The user-visible behavior change (silent exit 0 → unhandled-rejection report + exit 1) aligns fetch's argument-validation exits with its own network-path rejections and with Node.js.

Other factors

  • Test coverage is thorough: 19 of 20 exit paths get a subprocess test (the one omission, "Failed to start s3 stream", is an internal-failure branch the author could not trigger from JS and says so), plus positive (unhandledRejection listener receives the returned promise) and negative (.catch() suppresses the report) checks. Tests use bunEnv/bunExe/tempDir, drain stdout/stderr/exited concurrently, and hit no external network (127.0.0.1:1 and gopher:// reject before any socket is opened).
  • The fetch-file-upload.test.ts adjustment is required by the fix (otherwise 1000 unhandled-rejection reports) and does not weaken the assertion — Bun.peek.status(resp) === "rejected" is still asserted synchronously each iteration, and .rejects attaches a handler before the tick ends.
  • One candidate issue ("un-awaited .rejects.toThrow() in the loop") was raised and refuted: the un-awaited state is pre-existing, and the primary synchronous assertion remains.

With the untracked promise, attaching a handler still reached the tracker's
handled path, so process emitted rejectionHandled (and
PromiseRejectionHandledWarning under --unhandled-rejections=warn) for a
rejection that was never reported as unhandled.
Comment thread test/js/bun/http/fetch-file-upload.test.ts
The tracker holds the 1000 rejected promises until the end of the tick, so
the trailing Bun.gc(true) was no longer collecting anything. Yield one turn
of the event loop first; a microtask is not enough because the list is
drained after the microtask queue.

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

LGTM — the earlier note on fetch-file-upload.test.ts is addressed (3643b50 adds await Bun.sleep(0) before the final Bun.gc(true) so the rejection tracker drains first).

What was reviewed:

  • All 20 fetch.rs sites: identical swap from the deprecated dangerously_create_rejected_promise_value_without_notifying_vm to rejected_promise(...).to_js(), which is exactly what JSPromise.rs:324 documents.
  • New tests cover 19 of the 20 exits via subprocess spawns plus the unhandledRejection/rejectionHandled contract; the one uncovered exit ("Failed to start s3 stream") is noted in the description.
  • Checked that rejected_promise returns &mut JSPromise (not a JSValue), so the added .to_js() is the intended shape and no Strong ref is introduced.
Extended reasoning...

Overview

Swaps 20 early-rejection sites in src/runtime/webcore/fetch.rs from JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm(global, err) to JSPromise::rejected_promise(global, err).to_js(), so those rejections reach promiseRejectionTracker and behave like every other rejected promise (reported when unhandled, no spurious rejectionHandled when handled). Adds a 21-test describe.concurrent block in test/js/web/fetch/fetch-args.test.ts covering each reachable exit via bun -e subprocesses, and updates one existing GC-stress test in test/js/bun/http/fetch-file-upload.test.ts to use .rejects.toThrow() plus a tick before the final forced GC.

Security risks

None. No parsing, no auth, no input validation changes — only the promise-construction helper is swapped. Error values and messages are unchanged.

Level of scrutiny

Low-to-medium. The Rust change is purely mechanical: the deprecated helper's own doc comment (src/jsc/JSPromise.rs:330) says "use rejected_promise instead", and rejected_promise's doc (line 324) says "use .to_js()" for a JSValue result — this PR does exactly that at every site. It's the same helper Body.rs, streams.rs, s3/client.rs and ErrorCode::reject() already use. The user-visible behavior change (unhandled early fetch() rejections now report and exit 1) is a bug fix that aligns with Node and with Bun's own network-path rejections from the same call.

Other factors

  • My previous inline comment (rejection tracker pinning the 1000 promises past the forced GC) is addressed in commit 3643b50 with await Bun.sleep(0) and an explanatory comment; the author correctly noted a microtask alone would not drain handleRejectedPromises().
  • CI was green on build 91997 (190/190) before the last two test-only commits.
  • Tests follow harness conventions: bunEnv/bunExe, tempDir, concurrent subprocess drain via Promise.all([stdout, stderr, exited]), describe.concurrent for the independent spawns, and specific error-message assertions.
  • No memory-safety surface: rejected_promise returns a GC-managed &mut JSPromise and .to_js() is a plain JSValue conversion; no new refs or roots at the call sites.

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