Bun.serve: send the buffered tail when a direct stream controller is close()d - #37696
Bun.serve: send the buffered tail when a direct stream controller is close()d#37696robobun wants to merge 2 commits into
Conversation
…close()d HTTPServerWritable::end() (the controller close() path) left bytes buffered below the high-water mark for the auto-flusher. A direct stream whose pull() closes synchronously is torn down by do_render_stream as soon as pull() returns, before any deferred task runs, so the tail was dropped and the client received Content-Length: 0, or a chunked body missing its tail after a mid-stream flush(). Over HTTP/3 the same happened from an async pull(): the auto-flusher's try_end hit backpressure with no pending flush parked for the request to wait on. end() now sends the tail itself exactly like end_from_js(): try_end / end through uWS, or park a pending_flush promise on backpressure so do_render_stream and handle_resolve_stream wait for on_writable to drain it. The shared pieces (parking the flush promise, the post-end bookkeeping) are factored into park_pending_flush() and handle_ended_response(). The requested_end branch of on_auto_flush() only served the deferred end and is removed; a debug assertion documents that the flusher never sees an ended sink.
|
Warning Review limit reached
Next review available in: 18 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 (2)
Comment |
|
Updated 2:45 AM PT - Aug 13th, 2026
✅ @robobun, your commit 34631f2e4d0ee6ea07def9394bc8ca345cb56a83 passed in 🧪 To try this PR locally: bunx bun-pr 37696That installs a local version of the PR into your bun-37696 --bun |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks control flow in HTTPServerWritable::end() — a lifetime-sensitive teardown path in the HTTP server that already has several ASAN regression guards in this file — a human look would still be worthwhile.
What was reviewed:
global_thisis set at sink construction indo_render_stream(RequestContext.rs:2021), soself.global_this()in the newend()backpressure branch cannot panic.send_readable()unconditionally unregisters the auto-flusher, so the newdebug_assert!(!self.requested_end)inon_auto_flushholds on both the drained and parked branches ofend()/end_from_js().- The
protect()inpark_pending_flush()is balanced byunprotect()inflush_promise(); discarding the return value inend()is fine becausedo_render_stream/handle_resolve_streampick the promise up fromsink.pending_flush. handle_ended_responseis a byte-for-byte extraction of the block it replaces inend_from_js, plus threadingerrthrough tosource.close(matching the empty-buffer paths inend()).
Extended reasoning...
Overview
This PR fixes data loss in Bun.serve when a direct ReadableStream controller's close() is called while bytes are still buffered in the sink. The fix makes HTTPServerWritable::end() (in src/runtime/webcore/streams.rs) send its buffered tail immediately via send_readable(0) — mirroring what end_from_js() already does — and park a pending_flush promise on transport backpressure so do_render_stream / handle_resolve_stream wait on it. Two shared blocks are extracted into park_pending_flush() and handle_ended_response(), and the now-unreachable requested_end branch in on_auto_flush() is replaced with a debug assertion. New tests cover sync/async close() with and without a mid-stream flush(), plus HTTP/3 backpressure siblings.
Security risks
None identified. The change is confined to server-side response body draining; no parsing of untrusted input, no auth/permission surface, no new allocation shapes. The protect()/unprotect() pairing on the parked promise is unchanged from the pre-existing flush_from_js/end_from_js pattern.
Level of scrutiny
High. HTTPServerWritable teardown is production-critical and has a documented history of UAF bugs (three ASAN-gated tests in this very file guard sink/socket lifetime around end()). This change alters when mark_done()/finalize() run relative to uWS try_end, removes a code path based on a reachability argument, and introduces a new backpressure branch in end() that leaves the sink alive with a parked promise. The reasoning in the PR description is sound and I traced each claim (auto-flusher unregistered on every end path via send_readable → unregister_auto_flusher; global_this installed at construction; do_render_stream picks up pending_flush when assignToStream returns undefined), but a maintainer familiar with the h3 markDone/onAborted interaction and the #36940 double-terminator history should confirm the removed clear_on_writable() and the new eager-send in end() don't reintroduce either.
Other factors
Test coverage is thorough (7-case matrix over sync/async × close/end × flushed/unflushed asserting body + framing headers, plus two h3 backpressure siblings), and the PR description confirms the wider serve* / streams suites still pass. The refactor is behavior-preserving for end_from_js and flush_from_js (byte-identical extraction). One candidate issue — the debug_assert firing when end() parks under backpressure — was raised by a finder and refuted: send_readable() unregisters the flusher before returning false. Given the sensitivity of this code path and the removal of a defensive branch, deferring to human review.
|
For whoever takes the human look, the two points above are covered like this:
|
|
CI status: build 92696 is green on 189 of 190 jobs. The one red job is Windows 2019 x64, where |
… with nothing buffered
| // JSC_BORROW: process-lifetime VM global, installed by | ||
| // `RequestContext::do_render_stream` at construction. Safe `Deref` via `BackRef`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Controller `close()`. The buffered tail is sent here rather than left | ||
| /// to the auto-flusher: a `pull()` that closes synchronously has its sink | ||
| /// torn down by `do_render_stream` before any deferred task runs. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// uWS could not drain what was handed to it: `on_writable` settles this | ||
| /// promise via `flush_promise` once it has (resending the `try_end` tail | ||
| /// still in `buffer` first). For an ended sink the request waits on it | ||
| /// instead of tearing the sink down (`do_render_stream`, | ||
| /// `handle_resolve_stream`). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `end()`/`end_from_js()` fully ended the response through uWS, which | ||
| /// `markDone()`s it and drops its `onAborted` (see `ended_response`). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `end()`/`end_from_js()` send their own tail (or park it for | ||
| // `on_writable`) and unregister this flusher on the way. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
|
34631f2 ports the coverage #34334 had that this branch was missing: the |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the teardown ordering of HTTPServerWritable in the Bun.serve streaming hot path (eager send + finalize in end(), removal of the requested_end auto-flusher branch), a human look would still be worthwhile — plus the comment-cop bot has five open inline notes that a maintainer should rule on.
What was reviewed:
end()'s new backpressure path:send_readable(0)unregisters the auto-flusher first, so the newdebug_assert!(!self.requested_end)inon_auto_flush()holds on both the drained and parked branches.self.global_this()on the parked branch cannot panic —do_render_streamsetsglobal_thisat sink construction (RequestContext.rs:2021), andhandle_resolve_stream/handle_reject_streamalready.expect()on the same field.do_render_stream's existingpending_flushpickup (the sync-assignToStreamfallthrough) is what surfaces the parked promise from a syncclose(), so the request waits instead of destroying the sink.- The
park_pending_flush/handle_ended_responseextractions are byte-for-byte moves of theflush_from_js/end_from_jsblocks, excepthandle_ended_response(err)now forwardserrtosource.close(), which matches whatend()'s empty-buffer path already did.
Extended reasoning...
Overview
The PR touches src/runtime/webcore/streams.rs (HTTPServerWritable::end, end_from_js, flush_from_js, on_auto_flush) and adds coverage in test/js/bun/http/serve-direct-readable-stream.test.ts. The fix makes controller.close() on a direct ReadableStream send its buffered tail immediately (as controller.end() already did) rather than deferring to an auto-flush task that a synchronous pull() never reaches. Two shared code blocks are factored into park_pending_flush() and handle_ended_response(); the now-unreachable requested_end branch of on_auto_flush() is deleted and replaced with a debug assertion.
Security risks
None identified. No user-controlled input reaches new parsing or allocation logic; the change reorders existing send/finalize calls on paths already reachable from user code.
Level of scrutiny
High. This is native code in the Bun.serve response path with GC-adjacent lifetime rules (BackRef<JSGlobalObject>, JSPromise::protect, sink finalize ordering). REVIEW.md calls out native memory safety as the most-blocked category, and the change removes a defensive clear_on_writable() and a whole auto-flusher branch on the strength of a reachability argument. The reasoning checks out against the surrounding code (send_readable unregisters the flusher; uWS markDone() drops onWritable; do_render_stream sets global_this at construction and already picks up a parked pending_flush), but a maintainer who owns this file should confirm the invariant that no other path can re-register the flusher after requested_end is set.
Other factors
CI is green (189/190, one unrelated Windows bake flake). Test coverage is thorough — sync/async close()/end(), mid-stream flush, HTTP/HTTPS, HTTP/3 backpressure, and the empty-buffer / high-water-mark paths that the fix leaves alone — and the existing #36940 single-terminator test now exercises the new eager path. There are five open github-actions comment-cop notes on the new doc comments; they read as legitimate invariant documentation rather than workaround justifications, but whether they need trimming is a repo-style call for a human.
Problem
Bun.serveresponse from a directReadableStreamwhosepull()writes and then callscontroller.close()synchronously loses whatever is still buffered: the client gets a well formed200withContent-Length: 0, or after a mid-streamflush()a chunked body missing its tail.controller.end()in the same spot sends everything. Bun 1.4.0 and main.async pull()too (c.write("hey"); c.close()returns an empty body).close()with buffered bytes only marked the sink as ending and left the send to a deferred auto-flush task, but a synchronous close has its sink torn down as soon aspull()returns, before that task runs.pull().Fix
close()with buffered bytes now sends the tail at once, the wayend()already did: hand it to uWS, and if uWS cannot drain it, park the pending-flush promise the request already waits on.close()andend()now finish through one path, and that path already survives a synchronouspull()and backpressure.close()is unchanged;close(error)for a body that errors mid-stream also takes the new path and is observably the same on HTTP/1 as before.close()andend(), with and without a mid-streamflush(), run over both plain HTTP and TLS (the TLS sink is a separate instantiation of the same code), plus two HTTP/3 backpressure cases. Eight fail on the unfixed build (three syncclose()cases on each of HTTP and HTTPS, both HTTP/3 cases); all pass with the fix, and the existing serve and stream suites still pass.close()shapes that have nothing buffered (no write at all, and a single write at the high-water mark that went straight to the socket), which this change leaves alone; they pass before and after. These and the TLS runs are the cases Bun.serve: flush buffered bytes when a direct stream's sync pull() calls controller.close() #34334 covered that were missing here.Background
new ReadableStream({ type: "direct", pull })): a Bun extension wherepull()writes bytes straight into a sink viacontroller.write()/flush()/close()/end()instead of enqueueing chunks. As aResponsebody, that sink is the HTTP response itself.Content-Length; once aflush()has gone out, the response is chunked and the rest follows as more chunks.pull()returns nothing to wait on and no flush promise is parked, the request treats the body as done and finalizes the sink immediately; a parked promise makes it wait.try_end: the HTTP layer's finish call reports whether it managed to write the tail. If not (backpressure), the sink must retry from its writable callback, so something has to keep the sink alive until then.Original description
Repro
Bun 1.4.0 and current main:
Every byte still buffered in the sink when a synchronous
pull()callscontroller.close()is dropped, and the response is a well formed200withContent-Length: 0(or a chunked body missing its tail).controller.end()in the same position sends everything. This is what you get by combining the syncpull()from the direct stream docs withcontroller.close().Over HTTP/3 the same thing happens from an
async pull()too (c.write("hey"); c.close()returns an empty body).Cause
controller.close()reachesHTTPServerWritable::end()insrc/runtime/webcore/streams.rs. With bytes buffered it only setrequested_end/end_lenand returned, leaving the send to the auto-flusher (a deferred task), andon_auto_flush()had arequested_endbranch to finish the response afterwards.That never runs for a synchronous close:
readDirectStreamreturnsundefinedbecause the stream is already closed, anddo_render_streamtakes its no-promise path, which callsmark_done()(unregistering the flusher) beforefinalize(), destroys the sink with the bytes still in its buffer, and ends the response throughrender_missing().The deferred design also had no answer for transport backpressure: when the flusher's
try_endcould not drain (common on QUIC right after HEADERS), nothing was parked forhandle_resolve_streamto wait on, so the sink was torn down and the body truncated even from an asyncpull().end_from_js()(theend()controller method) has neither problem: it sends the tail immediately and parks apending_flushpromise on backpressure, whichdo_render_streamandhandle_resolve_streamalready wait for.Fix
HTTPServerWritable::end()now sends the tail the same wayend_from_js()does:send_readable(0)(uWStry_end/end), then the same post-end bookkeeping, orpark_pending_flush()when uWS could not drain it, soon_writableresends the tail and settles the promise the request is waiting on. The shared pieces are factored intopark_pending_flush()(also used byflush_from_js(), which had the same block) andhandle_ended_response().With that, the
requested_endbranch inon_auto_flush()is unreachable (end()/end_from_js()unregister the flusher on every path and writes afterrequested_endare rejected), so it is deleted and replaced by a debug assertion. uWSmarkDone()already dropsonWritableon both HTTP/1 and HTTP/3, which is whatend_from_js()was relying on, so theclear_on_writable()from that branch is not needed.The empty-buffer
close()path is unchanged.close(error)fromreadStreamIntoSink(a defaultReadableStreambody that errors while bytes are still buffered in the sink) also goes throughend(); on HTTP/1 those bytes were already being delivered by the auto-flusher before this change, so the observable behaviour there is the same, just no longer dependent on a deferred task running first. The existing mid-stream error tests inserve-stream-body-error.test.tsstill pass.Supersedes #34334, which fixed the same bug by routing
close()toendWithSinkin the generated controller code based on the argument count; it has conflicted with main since #36006 / #36943.Verification
New cases in
test/js/bun/http/serve-direct-readable-stream.test.ts:buffered bytes are sent when the controller finishes: syncclose(), syncend(), both with and without a mid-streamflush(), asyncclose()siblings, and a single-writeclose(). Each asserts the body plus the framing (Content-Length: 10for the unflushed cases,Transfer-Encoding: chunkedfor the flushed ones).close()siblings in the existing h3 backpressure block (sync and asyncpull()).On the unfixed build 5 of them fail (3 HTTP/1 sync
close()cases, both h3 cases); with the fix all 23 tests in the file pass, including the #36940 single-terminator test, whoseclose()now goes through the new path.serve.test.ts,bun-server.test.ts,serve-http3.test.ts, the otherserve-*stream*/*-leakfiles andtest/js/web/streamspass as well (the remaining failures inserve.test.ts/bun-server.test.tsare thelocalhost/ IPv6 / root-port ones that fail identically with the stock binary in this environment).