Skip to content

Fix Bun.serve error() returning a deferred Promise<undefined> responding 204 - #33140

Open
robobun wants to merge 5 commits into
mainfrom
farm/480da73b/serve-deferred-error-204
Open

robobun wants to merge 5 commits into
mainfrom
farm/480da73b/serve-deferred-error-204

Conversation

@robobun

@robobun robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

A Bun.serve error() handler that returns a Promise which resolves to a non-Response after the server's microtask drain (the common shape: an async error() that awaits something and forgets to return a Response) responded 204 with an empty body, while the synchronous and already-settled twins responded 500.

using server = Bun.serve({
  port: 0,
  development: false,
  fetch() { throw new Error("boom"); },
  async error(e) {
    await new Promise(r => setImmediate(r));
    // forgot to return a Response
  },
});
const res = await fetch(server.url); // was 204, now 500

Cause

RequestContext::process_on_error_promise's Pending arm registers fetch's resolve callback. When the error() promise later fulfills with a non-Response, handle_resolve routes it to render_missing_invalid_response, which renders 204 (the fetch path's "handler returned nothing" behavior). The Fulfilled arm (already-settled) and the synchronous path both fall back to finish_running_error_handler(..., 500).

Fix

render_missing_invalid_response now checks the existing is_error_promise_pending flag (already set in the Pending arm) and renders 500 when it is set, matching the status of the sync and already-settled twins. No new state is retained. The accompanying "Expected a Response object, but received 'undefined'" diagnostic is accurate for a deferred error() that returned a non-Response.

Verification

New subprocess tests in serve.test.ts:

  • sync / already-settled / deferred resolutions to undefined and null, plus a rejected fetch() promise routed through error(), all respond 500;
  • a deferred error() that does return a Response still renders it (503);
  • a deferred fetch() (not error()) resolving to a non-Response still responds 204, pinning the is_error_promise_pending gate so it cannot hijack fetch's own path.

Fail-before: the deferred case responds 204 with an empty body on the released binary; it passes at 500 with the fix.

Fixes #33137.

…ing 204

When error() returned a promise that settled after the server's microtask
drain, the pending branch of process_on_error_promise registered fetch's
resolve/reject callbacks and dropped the original error and status. A later
fulfillment with a non-Response (the common case: an async error() that
awaits and forgets to return a Response) reached render_missing_invalid_response,
which responded 204 and printed fetch's invalid-return diagnostic instead of
reporting the original error.

Keep the original error rooted across the await and, when the deferred error()
promise fulfills with a non-Response, route it through finish_running_error_handler
at 500, matching the synchronous and already-settled twins.
@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:12 PM PT - Jun 30th, 2026

❌ @robobun, your commit 6228d6f has 1 failures in Build #67323 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33140

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

bun-33140 --bun

@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

RequestContext now treats a pending deferred error() promise as a 500 production error when it resolves to a non-Response. Bun.serve regression tests cover deferred error() outcomes, stderr diagnostics, and deferred fetch() resolutions to undefined or null.

Changes

Deferred server return-value handling

Layer / File(s) Summary
Deferred error fallback
src/runtime/server/RequestContext.rs
render_missing_invalid_response checks for a pending deferred error() promise, clears that pending flag, and renders the production 500 error path instead of render_missing().
Deferred error() regression coverage
test/js/bun/http/serve.test.ts
The subprocess Bun.serve test covers synchronous, already-settled, and deferred non-Response error(cause) outcomes, plus a deferred Response case and stderr diagnostics.
Deferred fetch() regression coverage
test/js/bun/http/serve.test.ts
The subprocess Bun.serve test verifies deferred fetch() resolutions to undefined and null still return HTTP 204 with an empty body.

Possibly related PRs

  • oven-sh/bun#33120: Also changes RequestContext invalid/missing Response handling and adds Bun.serve regression coverage for non-Response handler outcomes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately names the main bug fix: deferred error() promises resolving to undefined no longer return 204.
Description check ✅ Passed It includes the problem, cause, fix, and verification details, covering the required PR description content despite different headings.
Linked Issues check ✅ Passed The code and tests address the deferred error() regression by restoring 500 handling for non-Response results and keeping valid deferred Responses working.
Out of Scope Changes check ✅ Passed The added deferred fetch regression test is directly tied to the fix and helps ensure the pending-error flag does not affect the normal fetch path.

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

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/server/RequestContext.rs`:
- Around line 746-769: The deferred error in RequestContext::handle_resolve is
being unprotected too early via take_error_promise_value(), then passed unrooted
into finish_running_error_handler(). Update the error-handling flow so the
original_error stays protected for the entire reporting/rendering call, and only
unprotect or clear it after finish_running_error_handler() returns; use the
existing RequestContext::take_error_promise_value, handle_resolve, and
finish_running_error_handler symbols to locate the fix.

In `@test/js/bun/http/serve.test.ts`:
- Around line 1967-1968: The deferred-error check is ambiguous because the
generic "/deferred" substring also matches "/deferred-null", so it may not
verify the intended invariant. Update the assertion in the serve test to target
the exact deferred diagnostic line or split the checks so the plain deferred
undefined path and the deferred-null path are asserted separately using a
delimiter or more specific match in the same test block.
- Around line 1934-1946: The readiness handshake in the Bun spawn test can hang
if server.js exits before sending the IPC message. Update the test around
Promise.withResolvers and Bun.spawn so the readiness promise is rejected when
proc.exited resolves/rejects before ipc() calls resolve, instead of waiting for
the outer timeout. Also start draining stderr immediately when spawning the
process so failure output is consumed concurrently, and keep the await promise
path tied to the proc lifecycle in serve.test.ts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e4061ba5-3af2-4923-bb2d-8c702bd823ec

📥 Commits

Reviewing files that changed from the base of the PR and between ffea69a and 41ccd98.

📒 Files selected for processing (2)
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/serve.test.ts

Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread test/js/bun/http/serve.test.ts Outdated
Comment thread test/js/bun/http/serve.test.ts Outdated
- Hold the protect() on the deferred error across finish_running_error_handler
  (which re-enters JS and can run GC), releasing it only after the report.
- Fail the spawn test fast if the server dies before the URL handshake, and
  drain stderr concurrently.
- Assert each deferred variant's error at a line boundary so /deferred does
  not also satisfy /deferred-null.

@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 didn't find any issues, but this touches core RequestContext lifecycle and JSValue protect/unprotect semantics in the HTTP server hot path, so it warrants a human look before merging.

Extended reasoning...

Overview

This PR fixes #33137: a Bun.serve error() handler returning a pending Promise that resolves to a non-Response was responding 204 and printing fetch's invalid-return diagnostic instead of responding 500 and reporting the original error (as the sync and already-settled twins do). The fix adds an error_promise_value: JSValue field to RequestContext, protects it when process_on_error_promise hits the Pending arm, and routes deferred non-Response resolutions in handle_resolve through finish_running_error_handler(original_error, 500). Cleanup is added in finalize_without_deinit for the abort-while-pending path. A thorough subprocess regression test in serve.test.ts covers sync / already-settled / deferred × undefined / null, plus a deferred Response (which still renders).

The earlier coderabbit feedback (keep the error rooted across the JS-reentering report, fail-fast on subprocess exit before IPC, disambiguate the /deferred vs /deferred-null stderr assertion) was addressed in 7595fb9 — finish_running_error_handler now runs before clear_error_promise_value, and the helper is idempotent.

Security risks

None identified. This is error-path control flow and GC rooting; no new input parsing, auth, or data-exposure surface.

Level of scrutiny

High. RequestContext.rs is the per-request state machine for Bun's HTTP server, and the change adds a new GC-protected JSValue field with protect/unprotect bookkeeping across async boundaries (resolve, reject, abort, finalize). The reject path (handle_reject) does not explicitly clear the new field and relies on finalize_without_deinit for cleanup, which looks correct given has_called_error_handler prevents re-entry into process_on_error_promise, but a maintainer familiar with the full lifecycle should confirm.

Other factors

  • CI shows musl build failures and Windows aarch64 agent-creation failures on this PR; they look like infrastructure rather than this change, but worth confirming.
  • The fix is well-scoped, mirrors the existing Fulfilled arm's behavior, and the test is comprehensive. I traced the protect/unprotect pairing and it appears balanced and idempotent. My deferral is purely about the criticality of the code path, not a suspected defect.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

On the one lifecycle point worth calling out (the reject path): it is intentionally unchanged. When the deferred error() promise rejects, handle_reject -> run_error_handler(rejection) short-circuits on has_called_error_handler into finish_running_error_handler(rejection, 500), which is the existing behavior the async-rejected-pending case in "should support promise returned from error" already covers (500).

On the protect/unprotect balance: process_on_error_promise runs at most once per request (the has_called_error_handler guard is set before the handler is invoked), so the single protect() in the Pending arm is matched by exactly one release. The resolve path releases it via clear_error_promise_value() after reporting; the reject and abort-while-pending paths release it in finalize_without_deinit. clear_error_promise_value() is idempotent (guards on error_promise_value.is_empty()), so the belt-and-suspenders call in finalize_without_deinit is a no-op once the resolve path has already cleared it.

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

No issues found, but this adds a GC-protected JSValue field with protect/unprotect balanced across several lifecycle paths (resolve/reject/abort/finalize) in the core Bun.serve request context — worth a human pass on the rooting invariants.

Extended reasoning...

Overview

This PR fixes #33137: a Bun.serve error() handler returning a pending Promise<undefined> was responding 204 and printing fetch's invalid-return diagnostic instead of responding 500 and reporting the original error (as the sync and already-settled paths do). The fix adds an error_promise_value: JSValue field to RequestContext, protects the original error when the error() promise is pending, and in handle_resolve routes a non-Response fulfillment through finish_running_error_handler(original_error, 500) before releasing the root. clear_error_promise_value() is idempotent and is also called from finalize_without_deinit to cover the reject and abort-while-pending paths. A thorough subprocess regression test in serve.test.ts covers sync / already-settled / deferred × undefined/null, plus a deferred Response.

Security risks

None apparent. This is error-handling control flow inside the HTTP server; no auth, crypto, or untrusted-input parsing is touched. The user-observable change is 204→500 for a misbehaving error() handler, which is strictly less information disclosed than before.

Level of scrutiny

High. RequestContext is the per-request state machine at the heart of Bun.serve, and the change introduces manual JSValue protect/unprotect across async boundaries. The protect in process_on_error_promise's Pending arm must be matched by exactly one unprotect across resolve (explicit clear_error_promise_value after finish_running_error_handler), reject (deferred to finalize_without_deinit), early-return-on-abort/upgrade (deferred to finalize_without_deinit), and the deferred-Response fall-through (explicit clear). The robobun lifecycle note and the addressed CodeRabbit comment both speak to this; the reasoning looks sound, but a leak or premature unprotect here would be a memory bug in production-critical code, so it warrants human confirmation rather than bot auto-approval.

Other factors

CodeRabbit raised three points (GC root ordering, test readiness hang, ambiguous assertion) and all were addressed in commit 7595fb9 — the current diff keeps the value protected through the JS-reentering finish_running_error_handler call, drains stderr concurrently with a fail-fast on early child exit, and matches /deferred at a line boundary. The bug-hunting system found no issues. The change is focused and well-tested; my only reason to defer is the GC-lifecycle sensitivity of the touched code path.

@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: green on every lane that exercises this change. serve.test.ts does not appear in any failure annotation across the builds below; each red lane has been an unrelated flake or infra hiccup, none touching Bun.serve / RequestContext:

  • Build 67226: test/js/node/test/parallel/test-net-connect-memleak.js on Alpine (musl) (a net.connect GC-collection timing assertion).
  • Build 67235: test/js/bun/spawn/spawn-pipe-leak.test.ts on darwin aarch64 (an RSS growth-ratio assertion).
  • Build 67247: a spread of unrelated flakes (the same test-net-connect-memleak.js on musl, test/js/bun/terminal/terminal.test.ts, plus retried napi, solc, bun-install, dev-and-prod, update_interactive_install), and darwin 26 aarch64 failed before running any test (buildkite-agent artifact download timed out after 120s, pure infra).

These leak/RSS/terminal tests are timing-sensitive and pass on recent main builds. The added serve.test.ts regression tests passed on every lane that ran them. Ready for a maintainer to merge or re-run the flaky/infra lanes.

render_production_error wrote "Something went wrong!" for every method, so a
HEAD request reaching the error path emitted a 21-byte body and desynced the
keep-alive connection. This is pre-existing (the sync error() path did it too),
but the deferred error() fix now routes HEAD through here as well. Route HEAD
through end_without_body, mirroring the 404 arm, so no body bytes are written.

Tests: add a rejected fetch() promise that routes through handle_reject into
error(); a deferred fetch() non-Response that must still respond 204 (pins the
is_error_promise_pending gate); and a keep-alive HEAD check proving the 500
writes no body and the next response on the socket is not desynced.
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 038956d0bc from a self-review pass:

  • HEAD error responses no longer emit a body. render_production_error wrote Something went wrong! for every method, so a HEAD request reaching the error path sent a 21-byte body and desynced the keep-alive connection. This is pre-existing (the sync error() path did it too), but since this PR now routes deferred HEAD errors through the same place, I fixed it here: HEAD goes through end_without_body, mirroring the existing 404 arm. Verified over a raw socket that a keep-alive GET after a HEAD error still gets a clean response.
  • Wider coverage. Added a rejected fetch() promise that routes through handle_reject into error(); a deferred fetch() non-Response that must still respond 204 (pins the is_error_promise_pending gate, so deleting that clause now breaks a test); and the raw-socket HEAD check above.

The normal HEAD framing path (do_render_head_response) is untouched; its 18 framing tests still pass.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the "out of scope" pre-merge warning on the HEAD change.

The HEAD guard is not incidental cleanup; it keeps this fix from introducing a regression. Before this PR, a HEAD request whose deferred error() resolves to a non-Response returned a clean 204 with no body. The core fix reroutes that case through finish_running_error_handler -> render_production_error, which writes Something went wrong! for every method, so without the guard the same request would now return a 500 with a 21-byte body on a HEAD response, desyncing the keep-alive connection. So the guard is what prevents the deferred fix from regressing HEAD framing.

I put it in render_production_error (the layer that writes the body) rather than special-casing one caller, so it also covers the sync/already-settled error() HEAD paths that already had this. If you would rather keep this PR strictly scoped to the deferred case, I can move the sync/settled portion to a follow-up, but I would keep the HEAD guard itself here so the deferred fix does not regress HEAD.

@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 didn't find any bugs, but this touches the core Bun.serve request lifecycle (GC rooting of a stashed JSValue, a new state flag gating the shared fetch/error resolve callback, and HEAD framing in render_production_error), so it's worth a human pass on the protect/unprotect balance and the finalize path.

Extended reasoning...

Overview

This PR fixes #33137: a Bun.serve error() handler that returns a pending Promise<non-Response> was responding 204 and emitting fetch's invalid-return diagnostic instead of reporting the original error at 500 like its sync/already-settled twins. The fix is in src/runtime/server/RequestContext.rs:

  • Adds an error_promise_value: JSValue field on RequestContext, protect()ed in process_on_error_promise's Pending arm and released via a new idempotent clear_error_promise_value() in handle_resolve (after reporting) and in finalize_without_deinit (abort-while-pending).
  • Gates handle_resolve on is_error_promise_pending: a non-Response fulfillment routes to finish_running_error_handler(original_error, 500); a Response fulfillment clears the stash and falls through to render normally.
  • Separately suppresses the body on HEAD in render_production_error (a pre-existing keep-alive desync the deferred path now reaches more often).

Tests in serve.test.ts cover the full matrix (sync / already-settled / deferred × undefined / null / Response / fetch-rejects), a raw-socket HEAD-then-GET keep-alive check, and a negative test pinning that fetch()'s own deferred non-Response still gets 204.

Security risks

None identified. This is error-path response selection and GC rooting; no auth, crypto, input parsing, or privilege boundaries are touched. The HEAD fix tightens RFC 9110 conformance (prevents a stray body desyncing a keep-alive connection), which is if anything a hardening.

Level of scrutiny

Moderate-to-high. RequestContext is the per-request state machine for every Bun.serve request, and the change introduces:

  • A new protect()/unprotect() pair whose balance depends on has_called_error_handler guaranteeing the Pending arm runs at most once, and on every exit path (resolve, reject→finalize, abort→finalize) reaching exactly one clear_error_promise_value(). The author's analysis in the thread is convincing and the helper is idempotent, but GC-root lifecycle in a shared resolve callback is exactly the kind of thing a maintainer should eyeball.
  • A behavioral branch in handle_resolve keyed on a flag, where the same callback serves both fetch() and error() promises.
  • An HTTP framing change for HEAD on the production-error path.

None of this is a one-line config tweak; it's real logic in a hot, correctness-sensitive path.

Other factors

  • The bug-hunting system found nothing.
  • CodeRabbit's three findings (root-across-report, subprocess readiness reject, ambiguous /deferred substring match) are all marked addressed in 7595fb9 and the current diff reflects those fixes.
  • CI is reported green on every lane that exercises this change; the two red lanes are unrelated flaky leak tests per the author's analysis.
  • Test coverage for the fix is thorough, including a negative test that pins the is_error_promise_pending gate so a regression that hijacks fetch()'s own 204 path would fail.

Given the scope (core server lifecycle + manual GC rooting + framing change), I'm deferring rather than auto-approving.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not add another field. We don't need that.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Good call, dropped the field. Reworked to reuse the existing is_error_promise_pending flag: a deferred error() resolving to a non-Response already flows through render_missing_invalid_response, so that function now renders 500 (like the sync/already-settled twins) instead of 204 when the flag is set. No new state.

I also pulled the HEAD-framing change back out to keep this focused on the deferred error() fix. The body-on-HEAD for error 500s is pre-existing (the sync error() path hits it too) and can be a separate change.

Per review, remove the error_promise_value field. A deferred error() resolving
to a non-Response already flows through render_missing_invalid_response, so gate
that function's final render on the existing is_error_promise_pending flag:
render 500 (like the sync and already-settled twins) instead of the fetch
path's 204. No new state, no protect/unprotect.

Also drop the HEAD-body change to keep this focused on the deferred error() fix;
the body-on-HEAD for error 500s is pre-existing (the sync error() path hits it
too) and can be addressed separately.

Tests keep the deferred/sync/settled matrix (all 500), the rejected-fetch route
into error(), and the negative test pinning that fetch()'s own deferred
non-Response still responds 204.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/server/RequestContext.rs`:
- Around line 736-741: The deferred error branch in RequestContext’s error
handling still bypasses the shared error path, so the original `error()` value
is lost. Update the `is_error_promise_pending` branch to route through
`finish_running_error_handler(value, 500)` instead of calling
`render_production_error(500)` directly, and ensure the stored error value is
preserved so `vm.on_unhandled_rejection` and the `DEBUG_MODE` fallback page
still run.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d00956d-ba1b-4978-8956-7d7dd4b24e14

📥 Commits

Reviewing files that changed from the base of the PR and between 038956d and 6228d6f.

📒 Files selected for processing (2)
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/serve.test.ts

Comment on lines +736 to +741
// A deferred `error()` handler that produced no Response responds 500,
// like its sync and already-settled twins, instead of the fetch path's 204.
if self.flags.is_error_promise_pending() {
self.flags.set_is_error_promise_pending(false);
return self.render_production_error(500);
}

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant regions in RequestContext.rs and the matching test.
FILE="src/runtime/server/RequestContext.rs"
TEST="test/js/bun/http/serve.test.ts"

echo "== $FILE (around 3300-3485 and 720-760) =="
sed -n '3300,3485p' "$FILE"
echo
sed -n '720,760p' "$FILE"

echo
echo "== $TEST (around 1960-2010) =="
sed -n '1960,2010p' "$TEST"

Repository: oven-sh/bun

Length of output: 11407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all references to the error-promise flag and the helper that renders the fallback path.
FILE="src/runtime/server/RequestContext.rs"

rg -n "is_error_promise_pending|finish_running_error_handler|render_production_error|render_missing_invalid_response|on_unhandled_rejection|render_default_error" "$FILE"

Repository: oven-sh/bun

Length of output: 2159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact implementations for the helper functions mentioned in the review.
FILE="src/runtime/server/RequestContext.rs"

# Prefer precise line ranges if the file is large.
nl -ba "$FILE" | sed -n '3330,3475p'
echo
nl -ba "$FILE" | sed -n '730,745p'

Repository: oven-sh/bun

Length of output: 189


Deferred error path still skips the original error handling.
This returns 500, but it bypasses finish_running_error_handler(value, 500), so the original error() value never reaches vm.on_unhandled_rejection, and DEBUG_MODE loses the richer fallback page. Route this branch through the shared helper instead of calling render_production_error(500) directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/server/RequestContext.rs` around lines 736 - 741, The deferred
error branch in RequestContext’s error handling still bypasses the shared error
path, so the original `error()` value is lost. Update the
`is_error_promise_pending` branch to route through
`finish_running_error_handler(value, 500)` instead of calling
`render_production_error(500)` directly, and ensure the stored error value is
preserved so `vm.on_unhandled_rejection` and the `DEBUG_MODE` fallback page
still run.

Comment on lines +738 to +741
if self.flags.is_error_promise_pending() {
self.flags.set_is_error_promise_pending(false);
return self.render_production_error(500);
}

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.

🔴 After the rework that dropped error_promise_value, the deferred path no longer reports the original error — it prints the "Expected a Response object" fetch diagnostic and calls render_production_error(500) directly, while the sync and already-settled twins still route through finish_running_error_handler → vm.on_unhandled_rejection(original_error). So the 204→500 status inconsistency is fixed, but the error-reporting half of #33137 is not, and the PR description ("After: error: boom", "asserts the original error is reported while the bogus … diagnostic is not") now describes the opposite of what the test asserts. At minimum the description needs updating; if matching the twins' diagnostics matters, the original error has to be stashed across the await again.

Extended reasoning...

What changed in the rework

Commit 6228d6f dropped the error_promise_value field and replaced the handle_resolve interception with a single check inside render_missing_invalid_response (lines 738–741): when is_error_promise_pending is set, clear it and call render_production_error(500). That correctly turns the 204 into a 500, but it never calls vm.on_unhandled_rejection with the user's thrown error — and it can't, because the original error was discarded when the Pending arm of process_on_error_promise returned.

How the three twins now diverge

Compare the three entry points for "error() produced no Response":

Path Code Reports original error?
Sync error() returns undefined run_error_handler_… falls through to finish_running_error_handler(value, 500) at line 3425 → (vm.on_unhandled_rejection)(vm, global_this, value) at line 3379 ✅ error: boom
Already-settled Promise.resolve(undefined) process_on_error_promise Fulfilled arm, line 3464–3465 → finish_running_error_handler(value, status) ✅ error: boom
Deferred pending Promise<undefined> Pending arm (3441–3450) drops value; later handle_resolve → render_missing_invalid_response prints the fetch diagnostic at 709–712, then 738–741 → render_production_error(500) ❌ prints Expected a Response object, but received 'undefined'

So after this PR, sync/settled print the user's actual error to stderr; deferred prints the misleading "your fetch handler returned the wrong thing" diagnostic for a value that came from error(), and the original Error("boom") is never surfaced.

Step-by-step on the PR's own repro

fetch() { throw new Error("boom"); },
async error(e) { await new Promise(r => setImmediate(r)); /* no return */ },
  1. fetch throws → run_error_handler_with_status_code_dont_check_responded calls error(boom); result is a pending promise.
  2. process_on_error_promise → Pending arm sets is_error_promise_pending, registers ON_RESOLVE/ON_REJECT, and returns. value (the Error("boom")) is not stored anywhere.
  3. setImmediate fires, promise fulfills with undefined → on_resolve → handle_resolve(undefined).
  4. undefined is empty/null-ish → render_missing_invalid_response(undefined) prints Expected a Response object, but received 'undefined' (line 710).
  5. is_error_promise_pending is set → clear it, render_production_error(500). on_unhandled_rejection is never called.

stderr: Expected a Response object, but received 'undefined' — not error: boom.

The PR description and test are out of sync with this

The description's "What" section lists "never reports the original error" and "stderr instead prints fetch's invalid-return diagnostic" as the bug being fixed, the "After:" block shows error: boom, and the Verification section says the test "asserts the original error is reported while the bogus Expected a Response object diagnostic is not." All three describe the dropped field-based approach. The actual test now asserts expect(stderr).toContain("Expected a Response object") with the comment "The deferred 500s come with the invalid-return diagnostic" — the exact opposite.

Why existing code doesn't prevent it

finish_running_error_handler is the only place that calls (vm.on_unhandled_rejection)(…, original_error) for this flow (line 3379). The new branch bypasses it and goes straight to render_production_error, and there is no longer any field holding the original error to pass in even if it wanted to.

Impact and fix

The user-facing HTTP behavior (status 500, body Something went wrong!) is now consistent across all three shapes — that's the important part of #33137 and it's fixed. The remaining inconsistency is diagnostic-only: a developer whose async error() forgets to return a Response sees a confusing "Expected a Response object" instead of their real error, while the sync version of the same handler shows the real error.

If that trade-off is acceptable for the no-new-state constraint, the PR description and Verification section should be updated to say so (and drop the stale "After: error: boom" block). If the diagnostics should match the twins, the original error needs to be stashed across the await again — i.e. bring back something like error_promise_value (with the GC-rooting fix from 7595fb9) so handle_resolve can call finish_running_error_handler(original_error, 500) instead of falling through to the fetch diagnostic.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Description updated to match the field-free version.

On the divergence both bots flag: dropping the field means the deferred path can no longer re-report the original thrown error, so it surfaces the accurate Expected a Response object, but received 'undefined' diagnostic instead. Production status and body (500 / Something went wrong!) match the sync and already-settled twins; only the stderr message differs. Routing through finish_running_error_handler here would not recover the thrown error either, since the resolved value at this point is undefined.

@Jarred-Sumner fine to keep it this way, or if you want the deferred path to report the thrown error like the twins I will retain it across the await. Your call.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Stale PR review: keep open, rework.

The fix is wanted. The bug is live on a 1.4.3 canary build (367d939). An error() handler that returns a deferred promise of undefined responds 204 under development: false, and 200 with the "Welcome to Bun!" page under development: true. The synchronous and already-settled forms respond 500. render_missing_invalid_response on main still has no check for this case. The one human objection ("Do not add another field") was on commit 038956d. The current head 6228d6f adds no field: it reads the existing is_error_promise_pending flag.

The current diff still takes the wrong exit:

  • It returns through render_production_error(500), which always writes the plain-text "Something went wrong!" body (src/runtime/server/RequestContext.rs:3497). Under development: true, the sibling paths render the development error page through finish_running_error_handler (line 3565). This path would be the only one that does not.
  • Both new tests set development: false, so no test covers development mode.
  • The original error is still not reported on this path, so "Fixes Bun.serve: error() returning a deferred Promise<undefined> swallows the original error and responds 204 #33137" overstates the change.

The wanted shape: when is_error_promise_pending is set and the promise resolves to something that is not a sendable Response, clear the flag and leave through run_error_handler, as reject_unsendable_response already does (line 3536). Add development: true rows to the tests. To report the original error without a new RequestContext field, carry it in the held slot of NativePromiseContext (src/jsc/bindings/NativePromiseContext.h:56), or say in the body that the fix is partial.

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.

Bun.serve: error() returning a deferred Promise<undefined> swallows the original error and responds 204

2 participants