Conversation
…st byte is written
When a Response body is an async iterable (or ReadableStream) that errors,
Bun.serve previously committed the user's status line before the body was
started, so error() could never replace the response. The three observable
outcomes were all wrong or inconsistent:
- throw before the first yield: a complete 200 OK with an empty chunked
body (0\r\n\r\n), indistinguishable from a successful empty response
- synchronous yields then throw: the connection was reset with zero bytes;
the already-yielded chunks and the status line were discarded
- awaited yields then throw: the chunked body was truncated (the only case
a client could tell apart from success)
and error() was never invoked in any of them.
do_render_stream now defers render_metadata() to on_first_write, which the
sink fires just before the first body byte reaches uWS (end_from_js' empty
body path now fires it too). handle_reject_stream routes to handle_reject
(the user's error() handler, or the default 500) when has_written_status is
still false; otherwise it reports the failure and force-closes without the
terminating chunk, uncorking first so body bytes written inside the cork are
not discarded. run_error_handler_with_status_code_dont_check_responded now
protects error()'s Response the same way process_on_error_promise and the
normal fetch() path already do, so the deferred render_metadata can still
read it.
Existing tests that locked in the previous behaviour are updated to assert
error() is invoked for pre-first-byte failures.
|
Updated 4:07 AM PT - Jul 23rd, 2026
✅ @robobun, your commit 8ffb18562d7713bd01ce33782e8f8d4e7c94fd38 passed in 🧪 To try this PR locally: bunx bun-pr 35229That installs a local version of the PR into your bun-35229 --bun |
|
Warning Review limit reached
Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
WalkthroughStreaming response handling now delays status commitment until the first body output, routes pre-byte failures through ChangesStreaming rejection handling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/server/RequestContext.rs (1)
936-947: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting the duplicated committed-status failure path into a shared helper. Both
handle_rejectandhandle_reject_streamencode the identical "status already committed → optionally run the VMerror()handler, thenuncork()+force_close()whenhttp_write_called && response_pending, elseend_stream()" contract. These must remain byte-for-byte in sync (they already differ slightly in guard/borrow shape), so a small helper taking the rejection value would reduce the divergence risk.
src/runtime/server/RequestContext.rs#L936-L947: replace the inline report-and-close block with the shared helper.src/runtime/server/RequestContext.rs#L3009-L3033: replace the inline report-and-close block with the same helper.🤖 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 936 - 947, Extract the duplicated committed-status rejection handling into a shared helper that accepts the rejection value and preserves the existing VM error-handler, uncork/force-close, and end_stream behavior. Replace the inline blocks in src/runtime/server/RequestContext.rs:936-947 and src/runtime/server/RequestContext.rs:3009-3033 with calls to this helper, keeping both paths behaviorally and byte-for-byte consistent.
🤖 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.
Outside diff comments:
In `@src/runtime/server/RequestContext.rs`:
- Around line 936-947: Extract the duplicated committed-status rejection
handling into a shared helper that accepts the rejection value and preserves the
existing VM error-handler, uncork/force-close, and end_stream behavior. Replace
the inline blocks in src/runtime/server/RequestContext.rs:936-947 and
src/runtime/server/RequestContext.rs:3009-3033 with calls to this helper,
keeping both paths behaviorally and byte-for-byte consistent.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 89b4d1ac-6f83-4561-a41d-f2da08385ce9
📒 Files selected for processing (9)
src/runtime/server/RequestContext.rssrc/runtime/webcore/streams.rstest/js/bun/http/async-iterator-stream.test.tstest/js/bun/http/async-iterator-throws.fixture.jstest/js/bun/http/serve-body-error-before-first-byte-fixture.tstest/js/bun/http/serve-direct-readable-stream.test.tstest/js/bun/http/serve-stream-body-error.test.tstest/js/bun/http/serve-stream-reject-flush-leak.test.tstest/js/bun/http/serve.test.ts
…ommitted_status()
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/server/RequestContext.rs:2147-2157— Removing the eagerrender_metadata()from the Pending branch regresses the direct-stream sibling of the empty-body case: an asyncpull()that resolves without writing or callingcontroller.end()reacheshandle_resolve_stream→sink.finalize()withon_first_writestill armed, andfinalize()'s!donebranch callsres.end_stream(false)— which triggers uWSsendTerminatingChunk()→writeStatus(HTTP_200_OK)— before the user's status/headers are ever written;handle_resolve_stream's subsequentrender_metadata()then appends the user's headers after the0\r\n\r\nterminator. The fix inend_from_js's empty-body branch (handle_first_write_if_necessary()beforeres.end()) needs mirroring infinalize()'s!donebranch beforeres.end_stream(false)(or fired inhandle_resolve_streambeforefinalize()). The newiter-empty-ok/rs-empty-oktests cover the async-iterable and default-ReadableStream siblings — both route throughend_from_js— but not the direct-stream sibling.Extended reasoning...
What the bug is
This PR defers writing the user's status line/headers from
do_render_streamto the sink'son_first_writehook, so a stream that fails before its first byte can be routed toerror(). To keep an empty successful body from losing the user's status, the PR addsself.handle_first_write_if_necessary()toend_from_js()'s empty-body branch instreams.rsbeforeres.end(b"", false).But there is a sibling site that ends the response with an empty body and was not patched:
HTTPServerWritable::finalize()'s!donebranch, whichhandle_resolve_streamreaches whenever the pump promise resolves withsink.donestill false. That branch callsflush_no_wait()(which returns 0 on an empty buffer without firingon_first_write) and thenres.end_stream(false), which drives uWS'ssendTerminatingChunk()→writeStatus(HTTP_200_OK)before Bun'srender_metadata()ever runs.Concrete trigger
Bun.serve({ fetch() { return new Response( new ReadableStream({ type: 'direct', async pull(c) { await Bun.sleep(5); /* no write, no end */ }, }), { status: 202, headers: { 'x-custom': 'yes' } }, ); }, });
Step-by-step trace
readDirectStream(BunStreamSource.cpp:939–942):pull()returns a Promise, so it's wrapped viaonReturnUndefinedinto a result promise and returned toassign_to_stream.do_render_stream:promise.unwrap()→Pending. Before this PR, this branch didon_first_write = None; render_metadata()(the block removed at diff lines 2141–2145), writing202+x-customimmediately. After this PR,on_first_writestays armed and nothing is written.pull()resolves →onReturnUndefinedresolves the result promise →ON_RESOLVE_STREAM→handle_resolve_stream.- Line 2807–2840:
sink.pending_flushisNone(nothing was written) → falls through. - Line 2845–2852:
sink.done == false,wrote == 0,ended_response == false→wrapper.sink.finalize(). finalize()!donebranch (streams.rs):flush_no_wait()seesreadable_slice().len() == 0and returns 0 — it does not callsend_readable, sohandle_first_write_if_necessary()never fires. Thenres.end_stream(false)→uws_res_end_stream→sendTerminatingChunk()→writeStatus(HTTP_200_OK). uWS'sHTTP_STATUS_CALLEDwas not yet set, so it writesHTTP/1.1 200 OK\r\n+Date+Transfer-Encoding: chunked\r\n\r\n0\r\n\r\nand setsHTTP_STATUS_CALLED+HTTP_WRITE_CALLED.- Back in
handle_resolve_stream, line 2891:ended_responseis false → skip. Line 2895:!req.flags.has_written_status()is true — Bun's own flag is only set bydo_write_status, which never ran — soreq.render_metadata()runs. do_write_status(202)→resp.write_status("202 Accepted")→ uWSwriteStatusseesHTTP_STATUS_CALLED(set in step 6) and no-ops. The user's 202 is silently dropped.do_write_headers()→ uWSwriteHeader("x-custom", "yes")has no guard onHTTP_WRITE_CALLEDand appendsx-custom: yes\r\nviaAsyncSocket::write— after the0\r\n\r\nbody terminator.
Wire before the PR:
HTTP/1.1 202 Accepted\r\nx-custom: yes\r\n…\r\n\r\n0\r\n\r\n
Wire after the PR:HTTP/1.1 200 OK\r\n…Transfer-Encoding: chunked\r\n\r\n0\r\n\r\nx-custom: yes\r\n…— the user's status is silently replaced with 200 and their headers are spliced after the body terminator (garbage before the next keep-alive response).Why existing code doesn't prevent it
The PR patched
end_from_js()'s empty-body branch (handle_first_write_if_necessary()beforeres.end(b"", false)). Async iterables and default ReadableStreams both route throughreadStreamIntoSink, which callssink.end()→end_from_js, so the newiter-empty-ok/rs-empty-oktests pass. Direct streams route throughreadDirectStream; when asyncpull()resolves withoutcontroller.end(),end_from_jsis never called and the parallelres.end_stream(false)infinalize()is reached instead. The existing direct-stream test "sync pull() that writes nothing and ends later still responds" uses a syncpull, which takes theclosePromisepath (BunStreamSource.cpp:944–947) and only resolves afterend()setssink.done = true, sofinalize()'s!donebranch is never entered there.Fix
Mirror the
end_from_jsfix infinalize()'s!donebranch: callself.handle_first_write_if_necessary()beforeres.end_stream(false)(or fire it inhandle_resolve_streambeforewrapper.sink.finalize()when!req.flags.has_written_status()). And add the direct-stream variant to the*-empty-oktest matrix, e.g.:if (p === '/direct-empty-ok') { return new Response( new ReadableStream({ type: 'direct', async pull(c) { await Bun.sleep(1); } }), { status: 202, headers: { 'x-custom': 'yes' } }, ); }
Per REVIEW.md — "fix the whole class in the same PR: grep for every sibling site sharing the pattern" and "cover the variant matrix, not just the repro" — this is the missed sibling of the exact fix pattern the PR introduces.
…e previous Response before error() Promise branch, keep error()'s own status when its body stream fails
Also makes the iter-yield-then-throw assertion tolerant of Windows'
SO_LINGER{1,0} reset discarding the uncorked bytes (the behavior there is
unchanged from before this PR), adds direct-empty-ok and async-error-handler
coverage, and tightens the two stderr filters per review.
…ver reached the client, so error() replaces the response
An async fetch() that goes Pending past the initial drain reaches
do_render_stream with is_async()=true, so its drain_microtasks is a no-op:
sub-highWaterMark yields stay in the sink buffer, and on_reject_stream can
fire in the same microtask drain as the yields, before the deferred
auto-flusher runs. The buffered bytes are discarded either way; routing to
error() is what the boundary ('until the first byte is written') means.
There was a problem hiding this comment.
No new issues found on this pass — the three earlier rounds of findings (async error() unprotect, finalize() empty-body path, Winsock RST assertions, and the !buffer.is_empty() disjunct) have all been addressed. Deferring to a human because this is a user-visible behavior change to Bun.serve's error contract in memory-sensitive native code, and the description notes an unresolved overlap with #33660 that whichever lands second must reconcile.
What was reviewed:
handle_reject_streamre-routing tohandle_rejectwhen!has_written_status(), including thehas_marked_pendingreset and thehas_called_error_handler()guard soerror()'s own failing stream keeps its status.- The unprotect hoisted before the
as_any_promise/as_responsesplit, and the new protect forerror()'s Response in the sync arm — checked both Fulfilled and Pending arms ofprocess_on_error_promiseno longer see a staleresponse_protected. - The deferred
render_metadataviaon_first_write: the two newhandle_first_write_if_necessary()calls inend_from_jsandfinalize()cover the empty-body paths; the removed eager write in the Pending arm keeps the sink'sctxpointer alive via the existingref_(). - The removed
DEBUG_MODEdev-server fallback-page block inhandle_reject_stream— its reporting is now covered byhandle_reject/terminate_body_after_committed_status, but the HTML fallback rendering for a rejecting stream underbun devis dropped; worth a human confirming that's intended.
Extended reasoning...
Overview
This PR changes Bun.serve's handling of Response bodies backed by streams/async-iterables that error: instead of eagerly writing the user's status line before the body starts (which committed a 200 even when the body immediately failed), the status write is deferred to the sink's on_first_write hook. A stream that rejects before any body byte reaches uWS is now routed through handle_reject → the user's error() handler; one that rejects after is reported to stderr and force-closed without the terminating chunk. Touches src/runtime/server/RequestContext.rs (~140 net lines: do_render_stream, handle_reject_stream, handle_reject, run_error_handler_with_status_code_dont_check_responded, new terminate_body_after_committed_status), src/runtime/webcore/streams.rs (two handle_first_write_if_necessary() calls in empty-body end paths), and seven test files including inverted assertions in serve.test.ts and serve-direct-readable-stream.test.ts.
Security risks
None identified. The change does not touch auth, TLS, or input parsing. The force_close path already existed; the new uncork() before it is defensive. The GC-protection changes were reviewed for balance across all branches (sync Response, async Fulfilled, async Pending) and look correct after the hoist in 37392c4.
Level of scrutiny
High. This is production-critical HTTP server code with hand-managed uWS response lifetimes, GC-root protect/unprotect pairing, and a raw ctx back-pointer from the sink into RequestContext that is now held longer (across the Pending branch instead of being disarmed there). It is also a user-visible behavior change: existing tests that asserted "throw on pull renders headers, does not call error handler" now assert the opposite. That's the right behavior, but it changes what users observe.
Other factors
- Three prior automated review rounds each found real issues (per-request GC-root leak on async
error(), missingon_first_writeinfinalize(), Windows-only test failure, and the!buffer.is_empty()disjunct that defeated the fix underasync fetch()); all four were addressed with commits and matching test coverage. - The PR description explicitly flags overlap with #33660 (SSE header flushing on Pending), which wants the opposite timing for the status write. A maintainer should decide the reconciliation strategy before either lands.
- The removed
DEBUG_MODEblock rendered a dev-server fallback HTML page for stream rejections; the reporting half of it is subsumed by the new paths, but the fallback page rendering is not, and the description doesn't mention it. - Test coverage for the new behavior is thorough (raw-socket wire assertions across sync/async fetch, sync/async error(), no-error-handler, empty-body direct/default streams, and the yield-then-throw platform-variant case).
|
On the removed |
|
CI on 8ffb185 (build 78497): the six red tests are all flaky-on-retry and unrelated to this diff (webview-chrome animation timing, bun-install-registry hoisting, bun-upgrade ETXTBSY, napi string snapshot, node test-fs-read-stream-pos, serve-protocols HTTP/3 echo). |
|
Closing in favor of #40596. The symptom this PR fixes, a complete What this PR adds beyond that, deferring the status line so that |
…#40596) ### Problem - A `Bun.serve` Response whose body stream fails before any body byte is written is delivered to the client as a complete message: the Response's status and headers, `Transfer-Encoding: chunked`, an empty body and a clean `0\r\n\r\n`. `curl` exits 0. A proxied upstream body that dies before its first chunk is forwarded the same way, and nothing is reported. Only a failure after the first body byte closed the connection without the terminator (#32842). - Cause: three sites in `src/runtime/server/RequestContext.rs` handle a body failure after the status line is committed (`handle_reject` for a direct stream's synchronous `pull()` throw, `handle_reject_stream` for a JS stream, `end_chunk` for a native byte stream). Each force-closed only when `is_http_write_called()` and otherwise called `end_stream()`, which writes the terminating chunk. ### Fix - The three sites share a new `close_incomplete_stream`: while the response is still pending, `force_close()` the connection, whether or not body bytes went out first. Once the sink has already ended the response, `end_stream()` only releases the context, as before. - Correct because a chunked message is complete only with its terminator (RFC 9112 section 7). An empty chunked body with a terminator is as complete as a truncated one. Closing without it is the only way HTTP/1.1 can signal an incomplete message. When the status is still in the cork buffer, the close discards it and the client sees an empty reply, the same as Node's `res.destroy()` before the headers flush. - `end_chunk` (HTMLRewriter output, a proxied `fetch()` body) no longer drops the producer's error: it reports it in both modes through the shared `report_committed_body_error`, which also gives native bodies the bake dev server error page that JS streams already had. This carries the remaining parts of #39442, closed in favor of this PR. - Verified: `test/js/bun/http/serve-stream-body-error.test.ts` (JS stream variants, pending error after the headers, HTMLRewriter and proxied fetch bodies in both modes, two dev server rows; 17 of 26 fail on stock bun). Also `serve.test.ts`, `serve-direct-readable-stream.test.ts`, `async-iterator-stream.test.ts`, `serve-http3.test.ts`, `serve-error-handler-stream.test.ts`, `serve-stream-reject-flush-leak.test.ts`, `text-encoder-stream.test.ts`, `html-rewriter.test.js`. ### Background - `RequestContext` is the per-request state of `Bun.serve`. `do_render_stream` writes the Response's status and headers into the corked uWS response before it attaches the body stream, so by the time a body error arrives `has_written_status()` is already true and `error()` cannot supply a replacement. That contract is unchanged here: `error()` is still not called once the status is committed. - uWS corks a socket while a request handler runs: writes go to a per-socket buffer that is flushed when the handler returns. `force_close()` closes the socket directly and drops that buffer, so a synchronous failure leaves nothing on the wire. An asynchronous failure arrives after the flush, so the client gets the headers and then a reset. - Behavior change: the tests that pinned the old contract ("throw on pull renders headers", "async generator ... continues to send the headers", the pre-first-byte variants in `serve-stream-body-error.test.ts`) now expect the connection to close without a complete response. <details><summary>Notes</summary> Wire before the fix, for `new Response(new ReadableStream({ start(c) { c.enqueue(chunk); c.error(new Error("boom")); } }))`: ``` HTTP/1.1 200 OK Content-Type: text/plain;charset=utf-8 Date: ... Transfer-Encoding: chunked 0 ``` After: the connection closes with 0 bytes sent (the status was still corked). For a stream whose first `pull()` is asynchronous, the headers were already flushed, so the client gets `HTTP/1.1 200 OK` plus headers and then a connection reset with no terminating chunk. Both are incomplete messages. The error reaches stderr through the existing reporters; the production-mode asynchronous JS path stays quiet, as today. The forced close sends a RST, and a RST discards data the peer has not read yet (always on Windows). The tests that expect the status line on the wire before the failure therefore trigger the failure from the client's data handler, once the status line has arrived. uWS terminates the header block only with the first body byte, so those tests wait for the status line, not for a blank line. Dev server exception: under a bake dev server (`development: true` plus an HTML route), a body that fails after the status is committed gets the dev error page appended and the response ends normally, so the browser shows the error after what was already streamed. JS streams always did this; native bodies now take the same branch. Probed sources, all now closed incomplete: `controller.error` in `start`, synchronous and asynchronous `pull()` throw, async generator throw, `Readable.toWeb` of an erroring node stream, a throwing `TransformStream`, `DecompressionStream` on bad bytes, a `type: "direct"` stream that throws, an HTMLRewriter handler that throws or rejects after the first byte, a proxied upstream fetch body reset before and after its first chunk. `Bun.file()` on a missing path already reaches `error()` and answers 500 (unchanged). A user `Content-Length` header on a stream Response is ignored in favor of chunked framing, so it does not change the outcome. Related PRs in this area: #35229 deferred the status line until the first body byte so that `error()` can answer a pre-first-byte failure with a 500 (a larger change to `do_render_stream` and the sink), closed in favor of this PR; that design remains a possible follow-up. #38003 touches the same sites for the stored-ByteStream-error case. This PR only enforces the framing invariant; it does not decide whether `error()` should be reachable for these failures. HTTP/3 is not fixed by this change: `uws_h3_res_force_close` closes the QUIC stream with a FIN, which is a complete message in HTTP/3. That is tracked separately in #40598. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts <!-- robobun:evidence:end -->
What does this PR do?
When a
Responsebody is driven by an async iterable (async generator or[Symbol.asyncIterator]object) and that iterable throws,Bun.serveproduced three inconsistent client-visible outcomes and never invoked the server'serror()callback:200 OK+ empty chunked body (0\r\n\r\n), cacheable as a successful response even though the failure happened before any byte was committedThe same applies to a default
ReadableStreamwhosepullrejects.Cause
do_render_streamwrote the user's status line and headers (render_metadata) beforeassign_to_streamstarted the body, sohas_written_status()was already true by the time the body failed and theerror()path was skipped. The sync-yield-then-throw case additionally reachedforce_close()while the status and yielded chunks were still in the uWS cork buffer, which discarded them.Fix
do_render_streamno longer writes the status line up front. The existingon_first_writehook on the sink firesrender_metadatajust before the first body byte reaches uWS;end_from_js's empty-body path now fires it too so an empty stream still sends the user's status/headers.handle_reject_streamroutes tohandle_reject(the user'serror()handler, or the default 500) whenhas_written_status()is still false; when true, it reports the failure and force-closes without the terminating chunk, uncorking first so bytes written inside the cork reach the socket.run_error_handler_with_status_code_dont_check_respondednow protectserror()'s Response the same wayprocess_on_error_promiseand the normalfetch()path already do, so the deferredrender_metadatacan still read it.After:
error()not callederror()called; its Response is senterror()not callederror()called; its Response is senterror()not calledHow did you verify your code works?
New fixture
serve-body-error-before-first-byte-fixture.tscovers each async-iterable case plus a defaultReadableStreampull rejection, observed over a raw socket so the exact wire framing is asserted; the existing stream-body-error tests are updated to expecterror()for pre-first-byte failures.All tests in
serve-stream-body-error.test.ts,serve-direct-readable-stream.test.ts,async-iterator-stream.test.ts,serve-error-handler-stream.test.ts,serve-stream-reject-flush-leak.test.ts,serve-http3.test.ts, and the streaming section ofserve.test.tspass; the new tests fail on the system bun and pass with this change.Note: this overlaps with #33660, which wants the header block flushed immediately when a stream body goes pending (for SSE
onopen). That change commits the status before the first body byte, which would close the window in whicherror()can replace the response for a stream that fails after going async but before yielding. Whichever lands second will need to reconcile the Pending branch.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/serve-stream-reject-flush-leak.test.ts test/js/bun/http/serve.test.ts