node:http: keep receiving the request body after the response has ended - #38196
node:http: keep receiving the request body after the response has ended#38196robobun wants to merge 8 commits into
Conversation
A request whose response is ended before its body has been received used to be completed immediately: the native side dropped the body read state in res.end() (and in the dispatch tail), and IncomingMessage._dump()/_read() then fabricated EOF, so req.complete, readableEnded and destroyed flipped to true and 'end'/'close' fired while most of the body was still in flight. A consumer attached before (or in the same tick as) a synchronous res.end() never saw the body either. Like Node, the body now keeps flowing into the IncomingMessage after the response: native re-arms the connection's body data handler after uws's end() dropped it (while the request's reader is still armed and the body is still arriving), pause/resume and arming keep working in that window, and the request is released when the body's fin arrives or the connection goes away. JS stops clearing the native callback in _dump() and stops treating a dumped request as ended in _read(); the dump decision itself moves to the response's 'finish' like Node's resOnFinish. The never-set hasCustomOnData flag, which only existed to gate those discards, is removed.
|
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 (15)
Comment |
|
Status: reproduced and fixed; self-review in progress.
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #35489, although the two overlap on the consumer half (#4733 / #18613), as noted in the description. The bug this PR was opened for is the request with no consumer: on main (and with #35489, which does not touch |
There was a problem hiding this comment.
I reviewed this PR and the automated bug-hunt found no issues. Because it reworks native request-lifecycle state in the node:http server hot path — body_read_ref balance across several new release points, re-arming the shared per-connection uws data handler after end(), and the pause/resume gating once the response is over — a human look is still warranted.
Checked: no stale HAS_CUSTOM_ON_DATA / hasCustomOnData references remain anywhere; the new body_read_ref.unref in mark_request_as_done is idempotent and can't double-decrement; body_still_arriving() correctly excludes a parked fin so the re-arm in write_or_end and the clear_on_data in set_on_data never touch a pipelined successor's handler slot; the unconditional mark_request_as_done_if_necessary() at the fin is guarded by should_request_be_pending() so it is a no-op while the response is still in flight.
Extended reasoning...
Overview
This PR changes how the native node:http server handles a request body that is still arriving after res.end() has run. On the native side (NodeHTTPResponse.rs) it: keeps the body read state alive when a JS ondata reader is armed, re-arms the connection's uws inStream handler after end()'s markDone() dropped it, extends pause()/resume()/set_on_data to keep working while body_still_arriving(), adds body_read_ref releases in mark_request_as_done and in set_on_data's clear branch, and re-evaluates the pending state unconditionally at the fin. It removes the never-set HAS_CUSTOM_ON_DATA flag and its .classes.ts accessor. On the JS side, _dump() no longer clears the native callback, _read() no longer treats _dumped as EOF, and the dump decision moves from res.end() to the response's 'finish' listener (matching Node's resOnFinish). Eight new tests cover completion timing, body delivery with a synchronous res.end(), pause/resume after the response, peer-drop mid-body, and process exit.
Security risks
None identified. This is request-body flow control and lifecycle bookkeeping; no auth, crypto, or untrusted-length parsing is touched. The change re-arms a per-connection callback slot, but only while body_still_arriving() (state Pending and no parked fin), which is exactly the window in which the parser is inside this request's body and the slot cannot yet belong to a pipelined successor.
Level of scrutiny
High. This is the node:http server hot path, and the change threads through several ref-count / keep-alive balances (body_read_ref, IS_REQUEST_PENDING, the self-ref()/deref() at the fin) whose imbalance is either a leak (server.close() never resolves, process never exits) or a premature release. The PR description is unusually rigorous — it names every release point and why each is needed, and the new tests include two subprocess exit tests that would hang on a stranded ref — but the interaction with the shared HttpResponseData slot on keep-alive connections and the acknowledged pre-existing edge case (a fin parked while paused is still never released after res.end()) mean a maintainer familiar with the uws layer should confirm the re-arm is safe across every end() variant.
Other factors
The tests are strong: they await observable conditions (response bytes on the wire, server.close() resolving, subprocess exit), assert the full state object, and cover both the fix and its release paths. The author ran the broader http suite and the vendored Node test-http-* corpus. I verified the removed flag has no remaining references, that KeepAlive::unref is idempotent so the added body_read_ref.unref in mark_request_as_done cannot double-decrement when the fin path already released it, and that mark_request_as_done_if_necessary() is gated by should_request_be_pending() so the now-unconditional call at the fin does nothing while the response is still pending. Given the subtlety and the hot path, I'm deferring rather than approving.
…end() Carried over from #35489: pipe()/on('data') before and after res.end(), res.write()+res.end(), res.end() on nextTick, the _dump() decision being made on 'finish', keep-alive reuse across three consumed bodies, a chunked body split across segments and a mid-upload close reaching beforeExit.
…quest's connection For an HTTP/1.0 or Connection: close request the response 'finish' listener ends the server socket. When the handler responded before the body was read, that end() runs while the read carrying the request is still being parsed; the shutdown made the parser stop after the request head, so the body bytes already in the buffer were dropped and the request never completed. Hand the close to uWS's post-parse gate in that case (as is already done when response bytes are still buffered): the body is delivered first and the connection is closed right after, which is what Node does.
|
Updated 7:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 3710396 has some failures in 🧪 To try this PR locally: bunx bun-pr 38196That installs a local version of the PR into your bun-38196 --bun |
…s; bind the proxy test to 127.0.0.1
The abort-events file's scenarios are verified against Node.js, so keep it free
of Bun-only helpers. The proxy test's listen("localhost") can bind ::1 while
the client connects to 127.0.0.1; bind the IPv4 loopback explicitly.
There was a problem hiding this comment.
I reviewed this PR and didn't find bugs; the earlier nit about the harness import in the Node-parity test file was addressed in 781f3d1. Because this is a large change touching native request-lifecycle state (body_read_ref / IS_REQUEST_PENDING release paths in NodeHTTPResponse.rs, the shared per-connection inStream slot re-arm after end(), and the new deferred-shutdown / stop-dispatching coordination across JSNodeHTTPServerSocket.cpp and uws), a human look would still be worthwhile.
What was reviewed:
body_still_arriving()gating onset_on_data/do_pause/do_resumeand the re-arm afterraw_response.end()— checked that the sharedinStream/userDataslot is only touched while this request's body is still being parsed, so a pipelined successor's handler isn't overwritten.- The three added
mark_request_as_done_if_necessary()/body_read_ref.unref()sites (fin tail,set_on_dataclear,mark_request_as_done) against the paths that previously released them — no double-release found; each is idempotent on its guard. deferShutdownUntilResponseDrains+nodeHttpStopDispatchingAfterCurrentMessage+ the new error-pathcloseIfDoneAndMarked— the gate only acts on already-marked, fully-responded, non-shut-down sockets, so other'clientError'cases still defer to the listener.kEndAfterResponseplumbing fromonResponseFinishHandleSocket→_final→ nativeend(afterResponseFinished)— a user-issuedsocket.end()still shuts down immediately.
Extended reasoning...
Overview
This PR changes the node:http server so a request body keeps flowing to the IncomingMessage after res.end() has been called, matching Node.js. It spans four layers: uws C++ (HttpContext.h, HttpParser.h, HttpResponse.h — a new isDeliveringBodyAfterResponse() check, nodeHttpStopDispatchingAfterCurrentMessage(), and running the connection-close gate on the parse-error exit), the C++ bindings (JSNodeHTTPServerSocket* — shutdownAfterResponseDrains gains an afterResponseFinished flag and now also stops further dispatching), the Rust native handle (NodeHTTPResponse.rs — new body_still_arriving()/has_body_reader() predicates, re-arming on_data after uws's end() drops it, releasing body_read_ref on additional exit paths, removing the dead HAS_CUSTOM_ON_DATA flag, and relaxing ENDED/REQUEST_HAS_COMPLETED guards on pause/resume/setOnData), and built-in JS (_http_incoming.ts — _dump() no longer clears ondata, _read() no longer treats _dumped as EOF; _http_server.ts — the dump decision moves to the response 'finish' listener and a kEndAfterResponse symbol distinguishes the finish-driven socket.end() from a user-issued one). The hasCustomOnData accessor is dropped from server.classes.ts. ~26 new tests are added across two files, plus a fixture update and a test hostname fix.
Security risks
None identified. The change is behavioral (when body bytes are delivered and when the connection is closed) and does not touch auth, crypto, or input validation. The new stop-dispatching flag reuses the existing nodeHttpSawConnectionClose mechanism, so a request pipelined behind a close is rejected the same way as one behind Connection: close. The error-path close gate (closeIfDoneAndMarked) only shuts down connections already marked to close whose response is complete, so it doesn't force-close connections a 'clientError' listener owns.
Level of scrutiny
High. This is production-critical native code coordinating refcounts (body_read_ref, IS_REQUEST_PENDING), a per-connection callback slot shared across pipelined requests, and deferred socket shutdown across three languages. The failure modes are subtle: a stranded ref hangs server.close() and leaks the process; touching the shared inStream slot outside the body-arriving window corrupts a pipelined request; a missed close gate leaves an ended connection open. The PR description enumerates each release path and each test that guards it, and the test coverage is unusually thorough (state at the moment of socket close, keep-alive reuse, mid-body abort, pipelined-behind-close, HTTP/1.0, subprocess exit tests), but the interaction surface is large enough that a maintainer familiar with NodeHTTPResponse's lifecycle should confirm the invariants — particularly that body_still_arriving() is a sufficient gate for owning the shared HttpResponseData handler slot after REQUEST_HAS_COMPLETED, and that the unconditional mark_request_as_done_if_necessary() at the fin tail is safe now that the fin callback can synchronously release body_read_ref.
Other factors
My prior inline nit (the harness import breaking the Node-parity test file's stated convention) was addressed in 781f3d1 by moving the subprocess exit tests to node-http.test.ts. All comment-cop bot flags are resolved. The bug hunting system found nothing. The change supersedes #35489 and carries its test cases. Test coverage is strong and each scenario was verified against Node v26.3.0.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs; the earlier nit about node-http-server-abort-events.test.ts's Node-parity header was addressed in 781f3d1. Because this reworks request-body lifecycle across four layers (uWS parse/close gating, NodeHTTPResponse ref accounting and shared inStream slot ownership, the C++ socket-end deferral, and the JS _dump/'finish' timing), a human look is still worthwhile.
What was reviewed:
body_still_arriving()as the gate for touching the per-connectioninStream/userDataslot afterres.end()(pause/resume/set_on_data/re-arm inwrite_or_end) — the fin-parked check keeps it from stepping on a pipelined successor.- Ref balance:
body_read_refnow also released frommark_request_as_doneand fromset_on_data's clear branch;on_data_or_aborted's fin tail callsmark_request_as_done_if_necessaryunconditionally — traced against the exit tests that guardserver.close()completing. deferShutdownUntilResponseDrainsnow also callsnodeHttpStopDispatchingAfterCurrentMessage()on the pre-existing buffered-bytes deferral, andHttpContext::onData's node:http parse-error exit now runscloseIfDoneAndMarked— both gated on already-marked-to-close + response complete, so ordinary'clientError'handling is unchanged.
Extended reasoning...
Overview
This PR fixes #4733/#18613: a node:http handler that calls res.end() before the request body has arrived should keep receiving that body (Node's behaviour) instead of fabricating req.complete/'end' immediately and dropping the bytes. The change spans:
- Rust (
src/runtime/server/NodeHTTPResponse.rs, ~180 lines net): removes the never-setHAS_CUSTOM_ON_DATAflag; addshas_body_reader()andbody_still_arriving();write_or_end::<true>keeps the body read state and re-arms the uWSinStreamafterend()dropped it;do_pause/do_resume/set_on_datakeep working after the response ended while the body is still arriving;mark_request_as_donenow also releasesbody_read_ref; the fin tail inon_data_or_abortedre-evaluates pending state unconditionally. - C++ / uWS (
JSNodeHTTPServerSocket*.cpp/.h,HttpResponse.h,HttpContext.h,HttpParser.h):shutdownAfterResponseDrains(bool afterResponseFinished)gains a second deferral case (isDeliveringBodyAfterResponse()) and now setsnodeHttpStopDispatchingAfterCurrentMessage()so a pipelined successor cannot resetHTTP_CONNECTION_CLOSE; the node:http parse-error exit ofonDatarunscloseIfDoneAndMarkedso a deferred close still happens when a'clientError'listener does not destroy the socket. - JS builtins (
_http_incoming.ts,_http_server.ts):_dump()leaves the nativeondataarmed;_read()no longer treats_dumpedas EOF; the dump decision moves fromServerResponse.end()toemitResponseFinish(Node'sresOnFinish);kEndAfterResponsedistinguishes the response-finishsocket.end()from a user-issued one and is threaded through_final→ nativeend(). - Tests: 15 new cases in
node-http-server-abort-events.test.tsand 12 innode-http.test.ts, all verified against Node v26.3.0; one fixture updated for the removedhasCustomOnData;node-http-proxy.jsbinds127.0.0.1explicitly.
Security risks
None identified. This is server-side request-lifecycle plumbing; no auth, crypto, or input parsing is added. The new uWS close gate (closeIfDoneAndMarked from the parse-error exit) is guarded on shouldCloseConnection() && !HTTP_RESPONSE_PENDING && hasFullyDrained(), so it cannot close a connection a 'clientError' listener is still allowed to write to.
Level of scrutiny
High. This is production-critical request-handling code with hand-managed reference counts (body_read_ref, IS_REQUEST_PENDING) and a per-connection shared callback slot (inStream/userData) that multiple pipelined requests contend for. The invariant the whole change rests on — "while body_still_arriving(), the shared slot belongs to this request" — is argued convincingly in the description and enforced at every new touch point I checked, but the number of interacting release paths (fin, abort, socket-close-after-response, set_on_data(undefined), maybe_stop_reading_body) is large enough that a maintainer familiar with NodeHTTPResponse's ref model should sign off.
Other factors
- The bug hunting system found nothing; my one prior nit (Bun-only imports in a "must also pass in Node.js" file) was addressed by moving the subprocess test.
- Test coverage is thorough and each case names the Node behaviour it pins; the description documents which tests fail on main vs. with partial fixes, and CI evidence shows 23 failures on main → 0 with the fix on both ASAN and release.
nodeHttpStopDispatchingAfterCurrentMessage()is now also called on the pre-existing buffered-bytes deferral (not just the new body-still-parsing one). That is a behaviour widening — a request pipelined behind a response that overflowed the send buffer will now surface asHPE_CLOSED_CONNECTIONinstead of being dispatched — which the description justifies (dispatching it would clearHTTP_CONNECTION_CLOSE), but it is worth a maintainer's eye.- One pre-existing edge (
IS_DATA_BUFFERED_DURING_PAUSE_LASTafterres.end()) is explicitly left unfixed and filed as #38207.
|
CI status: the two red builds (95306, 95764) fail only on unrelated flaky tests (napi string test, S3 InternalError, install registry on Windows aarch64, two parallel-batch flakes), a different set each run. The PR's own test files pass on every lane in both builds, and both gate test files pass locally on debug and release. Ready for review. |
Fixes #4733.
Fixes #18613.
Problem
node:httphandler that answers before the request body has arrived (res.end()right away on a POST, the usual early 413/401/redirect) immediately getsreq.complete === true,req.readableEnded === true,req.destroyed === true, andreqemits'end'and'close', while most of the body is still in flight. Node leaves all threefalseand emits'end'/'close'only once the body has actually been received; if the peer drops the connection mid-body instead, Node emits nothing onreqand leaves it incomplete.req.on('data', ...)followed by a synchronousres.end()receives nothing and'end'fires with an empty body (node:httpIncomingMessage stream data cannot be read, events are not emitted #4733, http body won't be received if res.end is called too early #18613), whether the body was in the same packet as the headers (curl -d) or still in flight.NodeHTTPResponse::write_or_end::<true>(src/runtime/server/NodeHTTPResponse.rs) released the body read state atres.end()unlessHAS_CUSTOM_ON_DATAwas set, and that flag was never set: the dispatcher resethandle.hasCustomOnData = falseright after arming the IncomingMessage's callback. uws'send()(markDone()) also nulls the connection's body data handler, so no byte afterres.end()reached JS.maybe_stop_reading_bodydid the same from the dispatch tail for handlers that end synchronously.IncomingMessage._dump()clearedhandle.ondata, and_read()treated_dumpedas EOF, so a dumped request fabricated its own end right afterres.end().Connection: closerequest or HTTP/1.0, i.e.curl --http1.0 -d, ab, many proxies): the response's'finish'listener ends the server socket (kMustCloseConnection, Node'sdestroySoon()), and for a handler that responds synchronously that runs while uws is still parsing the read that carried the request.jsFunctionNodeHTTPServerSocketEndshut the socket down on the spot, andHttpContext's request hook stops parsing a shut-down socket right after the request head, so a body that arrived together with the headers was dropped and the request never completed. With the two fixes above alone this path still lost the body (body: "", no'end'); it was the same on main and with node:http: keep delivering the request body after a synchronous res.end() #35489, which discarded the body explicitly for this case.Fix
res.end()keeps the body read state while the IncomingMessage'sondatais still armed and the body is still arriving, and re-arms the connection's data handler after uws'send()dropped it (in buffering mode when the reader is paused).maybe_stop_reading_bodyonly discards when no reader is armed or the transport is gone.pause(),resume()and armingondatakeep working after the response has ended, gated onbody_still_arriving()(statePendingand no parked fin), which is exactly the window in which the per-connection handler slot belongs to this request.on_data_or_abortednow re-evaluates the pending state unconditionally (the fin callback's nextTick drain runs'end'-> autoDestroy ->ondata = undefined, which releasesbody_read_refbefore the tail looked at it, so the gated version stranded the request andserver.close()never completed once the connection served another request);set_on_data's clear branch stops the native delivery and re-evaluates when a reader is torn down mid-body;mark_request_as_donealso dropsbody_read_ref, which is still held when the connection closes after the response.HAS_CUSTOM_ON_DATA/handle.hasCustomOnDataare removed: the flag was never set and only existed to gate those discards (one test fixture located the handle through it and now usesondata)._dump()leaves the native callback armed (onDataIncomingMessagealready drops the chunks of a dumped request and reports the fin),_read()no longer emits EOF for a dumped request, and the dump decision moves fromres.end()to the response's'finish'listener, where Node'sresOnFinishmakes it, so a consumer attached in the same tick asres.end()still counts.onResponseFinishHandleSocketmarks the connection (kEndAfterResponse) before callingsocket.end(), and_finalpasses that to the nativeend(). For that end() only, when the response is complete, a body handler is still armed and the socket is the one uws is parsing right now (isDeliveringBodyAfterResponse()),shutdownAfterResponseDrains()setsHTTP_CONNECTION_CLOSEand returns instead of shutting down, exactly as it already does while response bytes are still buffered. uws finishes the buffer (the body reaches the IncomingMessage, the request completes) and its post-parse gate shuts down and closes the connection, which is Node's order too: the whole read is parsed, thendestroySoon(). Asocket.end()issued by user code, an end() outside a parse (res.end()from a timer), tunnels (isConnectRequest) and responses still in flight are all unaffected and shut down immediately as before.nodeHttpStopDispatchingAfterCurrentMessage(), packages/bun-uws/src/HttpParser.h, the flag aConnection: closerequest already sets): a request pipelined behind it in the same read would otherwise start a new response, and starting one clearsHTTP_CONNECTION_CLOSE, leaving the ended connection open and serving (for a close-delimited response, appending the next response to its body). Such a request is reported asHPE_CLOSED_CONNECTION, as it is behind aConnection: closerequest, and the parse-error exit ofHttpContext::onDatanow runs the same close gate (packages/bun-uws/src/HttpContext.h), so the connection is closed even when a'clientError'listener does not destroy it; the gate only acts on connections already marked to close whose response is complete, so every other parse error is still left to the listener.socketOnCloseonly aborts requests whose response has not finished, whichNodeHTTPServerSocket#onClosealready mirrors) andserver.close()completing in each.should_request_be_pending()already described "response ended, body pending" as pending; this change makes that state reachable and makes sure every way out of it releases the request.res.end(), empty bodies,pause()/resume()never completing, the abort variant of the exit test; the eighth, exit after the body arrives and the connection is reused, guards the fin-time release). Under "on a connection the response closes":Connection: closeand HTTP/1.0 with a consumer, an unread body and a body that never completes all failed on this branch before the JSNodeHTTPServerSocket change (empty body, no events); 3 of them fail on main as well, while the unread-body one passes there only because main's fabricated end atres.end()happens to produce the same final state. The 3 pipelined cases fail on main (body lost); with the deferral but without the no-dispatch flag and the error-path gate, the response-driven one and the'clientError'-listener one fail (the connection is never closed), which is what they guard. Each asserts what Node v26.3.0 shows: the request state at the moment the server closes the socket, and for the pipelined cases exactly one response followed by the close (Node still runs the handler for a request pipelined behind a response-driven close but never answers it either; the handler count is deliberately not asserted)._dump()happening on'finish', keep-alive reuse across three consumed bodies, a chunked body split across segments, a mid-upload close reaching beforeExit); 10 fail on main, all pass here. ItsConnection: closecase expected the fabricated'end'and was replaced by the tests above.test-http-*files (414 pass). The failures are the same with the release binary or without this diff: the env-proxy tests (this container setsHTTP_PROXY/NO_PROXY), test-http-agent-keepalive's 1 ms close window and a few 500 ms / 5 s budgets on the ASAN build, and node-http.test.ts's proxy test (localhostresolution here).clear_on_data_callback) is independent and untouched; the code added here only touches the shared slot whilebody_still_arriving(). A body whose fin was parked while the stream was paused is still never released afterres.end(); that is pre-existing (reproduces on 1.4.0) and is node:http: release a request whose body fin was buffered while paused once the response ends #38207.Background
handle.ondata = onDataIncomingMessageon the request's native handle; native calls it per chunk and once more withisLastat the body's fin.isLastis what setsreq.completeand pushes EOF ('end', then autoDestroy's 'close').req._dump()is Node's "nobody will read this body": it removes the'data'listeners and resumes the stream so the bytes are discarded as they come in.HttpResponseDataper connection, reused by every request on a keep-alive connection, with a single body data handler (inStream) and context pointer.end()callsmarkDone(), which nulls that handler. One request's body fin always precedes the next request's head in the byte stream, so while a body is being parsed the handler slot can only belong to that request; once its fin has been seen (delivered, or parked while paused) the slot may already be the next request's.Connection: closerequest the JS layer marks the responsekMustCloseConnectionand its'finish'listener callssocket.end()(Node:res._lastandresOnFinish->socket.destroySoon()). In Bun'finish'for a synchronously ended response is emitted before the dispatcher returns to uws, i.e. while uws is still insideHttpContext::onDatafor the read that carried the request (HttpContextData::parsingSocketis that socket); the body bytes in the same read are only parsed after the dispatcher returns. uws already has two places that close a connection markedHTTP_CONNECTION_CLOSEonce the response is complete and flushed: the gate at the end ofonDataand the one inonWritable; the existing buffered-response deferral inshutdownAfterResponseDrains()relies on the second, the new case on the first (plus, for a parse that ends in an error, the same gate run from the error exit). Dispatching a request resets the per-connection response state, including that mark, which is why a pending close also has to stop the parser from dispatching; the parser'snodeHttpSawConnectionClosealready does that for requests that carriedConnection: close(Node's parser raisesHPE_CLOSED_CONNECTIONfor anything after such a message).body_read_refis an event-loop keep-alive held while a body is pending.IS_REQUEST_PENDINGis a self-reference plus the server's in-flight request count, which is whatserver.close()waits on;mark_request_as_donereleases both, andshould_request_be_pending()decides when a response that has ended may be released.Repro (Node v26 vs Bun)
Node v26.3.0 (and this branch):
{ complete: false, readableEnded: false, destroyed: false }, thenreq end,req closeoncedefarrives.Bun before this change:
{ complete: true, readableEnded: true, destroyed: true }right afterres.end(), and neither event fires later.[review] gate passed · iteration 2 · 15 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 1 rejected · iteration 2
evidence per changed file
root cause · written by the author bot
The bug was that Bun's node:http server cut off request body delivery as soon as res.end() was called, unlike Node.js, which keeps streaming the remaining body to the IncomingMessage after the response finishes. The root cause was that several layers treated response completion as request completion: uws dropped the on_data callback when end() was sent, the native handle's ENDED and REQUEST_HAS_COMPLETED guards blocked pause, resume, and setOnData afterward, and the JS layer dumped the body and forced an early socket end from the response finish path. The fix introduces a body_still_arrivin…