Skip to content

Bun.serve: do not re-render error() after the status line is committed - #33810

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/67aad12f/serve-direct-stream-error-after-status
Jul 9, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/67aad12f/serve-direct-stream-error-after-status

Conversation

@robobun

@robobun robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduction

const srv = Bun.serve({
  port: 0, development: false,
  error() { return new Response("FROM-ERROR-HANDLER", { status: 500, headers: { "x-err": "1" } }); },
  fetch() {
    return new Response(new ReadableStream({
      type: "direct",
      pull(c) { c.write("PARTIAL-BYTES"); c.flush(); throw new Error("boom"); },
    }));
  },
});

One request to this server on a debug build aborts the process:

panic: assertion failed: !self.flags.has_written_status()
  do_render_stream -> handle_reject -> run_error_handler -> render_metadata -> do_write_status

On a release build the error() Response's header block is written where a chunk-size line belongs, destroying the chunked framing:

HTTP/1.1 200 OK\r\n...Transfer-Encoding: chunked\r\n\r\n
d\r\nPARTIAL-BYTES\r\n
x-err: 1\r\ncontent-type: text/plain;charset=utf-8\r\n
12\r\nFROM-ERROR-HANDLER\r\n0\r\n\r\n

Without the preceding write()/flush(), the 500 status is silently dropped and the client receives a clean 200 OK carrying x-err: 1 and the error() body.

Cause

do_render_stream writes the status+headers (render_metadata) before invoking assign_to_stream, which runs the direct stream's pull(). A synchronous throw from pull() is routed to handle_reject, which gated the error() re-render only on resp.has_responded() (response ended), never on has_written_status(). The server's error() handler was therefore asked to produce a second Response for an exchange whose status line was already on the wire, and render_metadata wrote a second status/header block into the in-flight body.

The async sibling (handle_reject_stream) already handles this state correctly.

Fix

When has_written_status() is set, handle_reject now reports the failure via the VM error reporter and terminates the body (force_close if body bytes were written, end_stream otherwise) instead of re-rendering, matching handle_reject_stream.

Verification

Added two subprocess tests in test/js/bun/http/serve-direct-readable-stream.test.ts covering the with-flush and without-flush shapes. Both fail on the unfixed build (debug: !has_written_status assert; release: error() headers spliced into the wire) and pass with the fix.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/serve-direct-readable-stream.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ed033fa10)

test/js/bun/http/serve-direct-readable-stream.test.ts:
(pass) HTTPResponseSink displays correct message [60.98ms]
(pass) controller.end() after pull() resolved does not use the sink after free [556.86ms]
(pass) client disconnect after controller.end() with a parked pull() does not use the socket after free [1481.81ms]
(pass) client disconnect after controller.end() with a parked rejecting pull() does not use the socket after free [1527.34ms]
(pass) h3: controller.end() from a parked pull() disarms onAborted before the context is released [695.93ms]
killed 1 dangling process
(fail) sync pull() throw after status is written does not re-render error() > body bytes already flushed: connection is force-clo
... (truncated)

release without fix: 2 failed, 4 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/http/serve-direct-readable-stream.test.ts:
(pass) HTTPResponseSink displays correct message [13.12ms]
(skip) controller.end() after pull() resolved does not use the sink after free
(skip) client disconnect after controller.end() with a parked pull() does not use the socket after free
(skip) client disconnect after controller.end() with a parked rejecting pull() does not use the socket after free
(skip) h3: controller.end() from a parked pull() disarms onAborted before the context is released
414 |       env: bunEnv,
415 |       stdout: "pipe",
416 |       stderr: "pipe",
417 |     });
418 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
419 |     expect(stderr).toContain("error: boom");
                         ^
error: expect(received).toContain(expected)

Expected to contain: "error: boom"
Received: ""

      at <anonymous> (/workspace/bun/test/js/bun/http/serve-direct-readable-stream.test.ts:419:20)
(fail) sync pull() throw after status is written does not re-render error() > body bytes already flushed: connection is force-closed [35.72ms]
434 |    
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/serve-direct-readable-stream.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ed033fa10)

test/js/bun/http/serve-direct-readable-stream.test.ts:
(pass) HTTPResponseSink displays correct message [62.97ms]
(pass) controller.end() after pull() resolved does not use the sink after free [545.02ms]
(pass) client disconnect after controller.end() with a parked pull() does not use the socket after free [1447.99ms]
(pass) client disconnect after controller.end() with a parked rejecting pull() does not use the socket after free [1465.50ms]
(pass) h3: controller.end() from a parked pull() disarms onAborted before the context is released [673.69ms]
(pass) sync pull() throw after status is written does not re-render error() > body bytes already flushed: connection is force-closed [1393.06ms]
(pass) syn
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ed033fa106
  features     (none)

22 deps, 105 codegen, 1167 objects in 858ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1230] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [4.00ms]
[2/1230] gen ErrorCode+*.h
[3/1230] gen bindgenv2
[4/1230] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[5/1230] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [2.00ms]
[6/1230] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /wor
... (truncated)
diff hotspot
src/runtime/server/RequestContext.rs               | 26 +++++++
 .../bun/http/serve-direct-readable-stream.test.ts  | 81 ++++++++++++++++++++++
 2 files changed, 107 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                   reads  edits  tests
src/runtime/server/RequestContext.rs                       7      1      0
test/js/bun/http/serve-direct-readable-stream.test.ts      1      1      0

A direct ReadableStream whose pull() throws synchronously reaches
handle_reject after do_render_stream has already written the 200 status
and headers. handle_reject gated the error() re-render only on
has_responded() (response ended), never on has_written_status(), so the
server's error() handler was invoked and its Response rendered into the
in-flight exchange. Debug builds hit the !has_written_status assert in
do_write_status and aborted the process; release builds spliced the
error() header block into the chunked body, corrupting HTTP framing.

When the status is already committed handle_reject now reports the
failure via the VM error reporter and terminates the body (force_close
if body bytes were written, end_stream otherwise), matching what
handle_reject_stream already does for the async-reject path.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c3a7e28f-2112-4e36-aa14-ec86a5c6cf19

📥 Commits

Reviewing files that changed from the base of the PR and between b05b4fa and 224d78c.

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

Walkthrough

Modifies the handle_reject error path in RequestContext so that when a response's status has already been written but it hasn't fully responded, the server now optionally invokes the error handler then immediately terminates the response (force-close or end_stream), replacing the prior deferred-deinit flow. Adds tests validating this behavior for synchronous pull() throws.

Changes

Reject handling after status written

Layer / File(s) Summary
handle_reject early-return for committed responses
src/runtime/server/RequestContext.rs
Replaces deferred deinit and run_error_handler flow with an early-return path that optionally runs the server VM error handler and terminates the response via force-close or end_stream, avoiding a second header block.
Tests for pull() throw after status write
test/js/bun/http/serve-direct-readable-stream.test.ts
Adds a describe block with a fixture(pullBody) helper and two raw-wire tests verifying no re-render of error() and zero errorHandlerCalls when a direct stream's pull() throws synchronously after status/headers are flushed, with and without body bytes written.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: avoiding error() rerendering after the status line is committed.
Description check ✅ Passed The description covers the bug, fix, and verification, though it uses custom headings instead of the template's exact section labels.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:52 AM PT - Jul 9th, 2026

❌ @robobun, your commit 224d78c has 2 failures in Build #70852 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33810

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

bun-33810 --bun

@github-actions github-actions Bot added the claude label Jul 9, 2026

@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 RequestContext connection-lifecycle/refcount paths (force_close/end_stream both deref) and makes a small behavior choice (reporting the error via the VM handler unconditionally, whereas handle_reject_stream only does so under DEBUG_MODE) — worth a human look.

Extended reasoning...

Overview

This PR fixes a debug-build assertion crash / release-build HTTP framing corruption in Bun.serve when a direct ReadableStream's pull() throws synchronously after the status line has already been written. The fix adds an early-return branch in handle_reject (src/runtime/server/RequestContext.rs) that checks has_written_status(), reports the error via the VM's error reporter, and terminates the body via force_close() or end_stream() instead of re-rendering the user's error() Response. Two subprocess tests are added covering the with-flush and without-flush shapes.

Security risks

None identified. This is error-path handling for server-side response streaming; no auth, crypto, or untrusted-input parsing is touched. If anything, the fix removes a wire-corruption case where a second header block was spliced into a chunked body.

Level of scrutiny

High. RequestContext is the core per-request state machine for Bun.serve, and both force_close() and end_stream() call self.deref() — the ref balance on this new early-return path needs to match what the caller (do_render_stream at the handle_reject call sites around lines 2119/2240) expects. The new branch also introduces an unsafe const→mut cast on VirtualMachine (following the existing drain_microtasks() pattern) and runs user-observable JS (the VM error reporter) before terminating the connection.

Other factors

The termination logic (is_http_write_called() && is_response_pending() → force_close, else end_stream) exactly mirrors the tail of the async sibling handle_reject_stream (lines ~3088-3095), which is reassuring. However, the sibling only calls vm.run_error_handler under DEBUG_MODE, whereas this new branch reports unconditionally — the tests assert on stderr containing the error in development: false mode, so this is intentional, but it's a small behavior-policy choice a maintainer should confirm. The tests are well-constructed (raw TCP wire inspection, both variants covered, subprocess isolation).

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points raised:

Refcount balance: traced all four handle_reject callers. The old path through run_error_handler consumed exactly one base ref on every terminal outcome (render() → render_bytes deref, or render_production_error → end() deref, or fall-through render_missing() → end() deref). The new branch's force_close()/end_stream() also consume exactly one base ref, same as the sibling handle_reject_stream tail at the identical ref state. The promise ref (when present, from on_reject) is released independently by the caller's RequestContextRef guard in both old and new paths. Balanced.

Error reporting unconditionally vs DEBUG_MODE-only: handle_reject_stream itself only reports under DEBUG_MODE, but its caller in do_render_stream's Rejected arm reports in !DEBUG_MODE first, so a synchronously-settled stream rejection is reported in both modes today. The new branch matches that combined behavior. Reporting here also matters more than for the async sibling: error() is the documented way to observe handler failures, and on this path it cannot run (status is committed), so the VM reporter is the only signal the user gets.

@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 the RequestContext refcount/lifecycle path in Bun.serve with an unsafe VM backref and a new terminal branch — worth a human confirming the ref-balance reasoning.

Extended reasoning...

Overview

This PR adds a ~26-line guard in handle_reject (src/runtime/server/RequestContext.rs) so that when a direct ReadableStream's pull() throws synchronously after do_render_stream has already written the status line, the server reports the error via the VM reporter and terminates the body (force_close() or end_stream()) instead of invoking the user's error() handler and splicing a second header block into the in-flight chunked body. Two subprocess tests in serve-direct-readable-stream.test.ts cover the with-flush and without-flush shapes with wire-level assertions.

Security risks

None identified. The change narrows an existing error path to avoid protocol corruption; no new input parsing, auth, or trust boundaries are introduced. The unsafe const→mut VM cast follows the exact pattern already used in handle_reject_stream and elsewhere in this file.

Level of scrutiny

High. RequestContext is the core per-request lifecycle object for Bun.serve, and the new branch is a terminal path that must consume exactly one base ref (via force_close()/end_stream() → deref()) to match what the old run_error_handler path consumed. The author's follow-up comment traces all four handle_reject callers and the sibling handle_reject_stream tail to argue balance, which looks correct to me — the new tail mirrors lines ~3088-3095 of handle_reject_stream at the same ref state. But refcount reasoning in this file is subtle enough (see the surrounding defer_deinit_until_callback_completes machinery it now bypasses) that a maintainer familiar with the RequestContext lifecycle should confirm.

Other factors

The fix is small, mirrors an established sibling pattern, and comes with strong verification evidence (fails on main in both debug-ASAN and release, passes with fix). The tests are well-constructed subprocess tests with raw-socket wire inspection. No prior human review comments to address. My hesitation is purely about the criticality of the code path and the unsafe/refcount surface, not about any specific concern with the change itself.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. The new tests in serve-direct-readable-stream.test.ts passed on every lane.

The single failing lane across both #70840 and #70852 is alpine 3.23 x64 test-bun on test/regression/issue/26030.test.ts, where the MySQL docker container never becomes healthy (application not healthy after 1m0s). That test exercises the MySQL client and is unrelated to this change. The remaining retried tests (Windows bun-install-registry peer hoisting, Windows node-tls ECONNREFUSED, Windows postgres CONNECTION_REFUSED) are also unrelated.

Ready for review.

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.

2 participants