Skip to content

node:http: keep receiving the request body after the response has ended - #38196

Open
robobun wants to merge 8 commits into
mainfrom
farm/be122dd5/http-req-complete-after-early-response
Open

node:http: keep receiving the request body after the response has ended#38196
robobun wants to merge 8 commits into
mainfrom
farm/be122dd5/http-req-complete-after-early-response

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4733.
Fixes #18613.

Problem

  • A node:http handler that answers before the request body has arrived (res.end() right away on a POST, the usual early 413/401/redirect) immediately gets req.complete === true, req.readableEnded === true, req.destroyed === true, and req emits 'end' and 'close', while most of the body is still in flight. Node leaves all three false and emits 'end'/'close' only once the body has actually been received; if the peer drops the connection mid-body instead, Node emits nothing on req and leaves it incomplete.
  • The same mechanism loses the body for a consumer: req.on('data', ...) followed by a synchronous res.end() receives nothing and 'end' fires with an empty body (node:http IncomingMessage 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.
  • Native cause: NodeHTTPResponse::write_or_end::<true> (src/runtime/server/NodeHTTPResponse.rs) released the body read state at res.end() unless HAS_CUSTOM_ON_DATA was set, and that flag was never set: the dispatcher reset handle.hasCustomOnData = false right after arming the IncomingMessage's callback. uws's end() (markDone()) also nulls the connection's body data handler, so no byte after res.end() reached JS. maybe_stop_reading_body did the same from the dispatch tail for handlers that end synchronously.
  • JS cause: IncomingMessage._dump() cleared handle.ondata, and _read() treated _dumped as EOF, so a dumped request fabricated its own end right after res.end().
  • Non-keep-alive cause (Connection: close request or HTTP/1.0, i.e. curl --http1.0 -d, ab, many proxies): the response's 'finish' listener ends the server socket (kMustCloseConnection, Node's destroySoon()), and for a handler that responds synchronously that runs while uws is still parsing the read that carried the request. jsFunctionNodeHTTPServerSocketEnd shut the socket down on the spot, and HttpContext'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

  • Native: res.end() keeps the body read state while the IncomingMessage's ondata is still armed and the body is still arriving, and re-arms the connection's data handler after uws's end() dropped it (in buffering mode when the reader is paused). maybe_stop_reading_body only discards when no reader is armed or the transport is gone. pause(), resume() and arming ondata keep working after the response has ended, gated on body_still_arriving() (state Pending and no parked fin), which is exactly the window in which the per-connection handler slot belongs to this request.
  • Native, release of the request: at the fin, on_data_or_aborted now re-evaluates the pending state unconditionally (the fin callback's nextTick drain runs 'end' -> autoDestroy -> ondata = undefined, which releases body_read_ref before the tail looked at it, so the gated version stranded the request and server.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_done also drops body_read_ref, which is still held when the connection closes after the response.
  • HAS_CUSTOM_ON_DATA / handle.hasCustomOnData are removed: the flag was never set and only existed to gate those discards (one test fixture located the handle through it and now uses ondata).
  • JS: _dump() leaves the native callback armed (onDataIncomingMessage already 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 from res.end() to the response's 'finish' listener, where Node's resOnFinish makes it, so a consumer attached in the same tick as res.end() still counts.
  • Non-keep-alive connections (src/js/node/_http_server.ts, src/jsc/bindings/node/JSNodeHTTPServerSocket*.cpp, packages/bun-uws/src/HttpResponse.h): onResponseFinishHandleSocket marks the connection (kEndAfterResponse) before calling socket.end(), and _final passes that to the native end(). 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() sets HTTP_CONNECTION_CLOSE and 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, then destroySoon(). A socket.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.
  • While such a close is pending (this one or the pre-existing buffered-bytes one), the parser is told to dispatch nothing after the current message (nodeHttpStopDispatchingAfterCurrentMessage(), packages/bun-uws/src/HttpParser.h, the flag a Connection: close request already sets): a request pipelined behind it in the same read would otherwise start a new response, and starting one clears HTTP_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 as HPE_CLOSED_CONNECTION, as it is behind a Connection: close request, and the parse-error exit of HttpContext::onData now 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.
  • Why this is the right behaviour: it matches Node. Every scenario in the new tests was run under Node v26.3.0 and under this build with identical output, including the peer-drop case (Node's socketOnClose only aborts requests whose response has not finished, which NodeHTTPServerSocket#onClose already mirrors) and server.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.
  • Verified with test/js/node/http/node-http-server-abort-events.test.ts: 15 new tests. 7 of the first 8 fail on main (state flipping at 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: close and 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 at res.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).
  • test/js/node/http/node-http.test.ts, "request body still flows after res.end() was called in the handler": the 11 consumer-side cases from node:http: keep delivering the request body after a synchronous res.end() #35489 (pipe()/on('data') before and after res.end(), write()+end(), end() on nextTick, _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. Its Connection: close case expected the fabricated 'end' and was replaced by the tests above.
  • Also run: node-http.test.ts, node-http-req-socket-pause, node-http-backpressure, node-http-transfer-encoding, node-http-server-socket-end-drain, node-http-ondata-reregister-leak, node-http-connect, node-http-with-ws, express and body-parser suites, and the 422 vendored test-http-* files (414 pass). The failures are the same with the release binary or without this diff: the env-proxy tests (this container sets HTTP_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 (localhost resolution here).
  • Related: node:http: keep delivering the request body after a synchronous res.end() #35489 fixed the consumer half of the same mechanism with a different native approach and is closed in favour of this PR; its tests were carried over as described above. node:http: deliver a pipelined POST's body when the previous response is still in flight #34761 (a pipelined successor's handler slot cleared from clear_on_data_callback) is independent and untouched; the code added here only touches the shared slot while body_still_arriving(). A body whose fin was parked while the stream was paused is still never released after res.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

  • Body delivery: when a request has a body, the dispatcher arms handle.ondata = onDataIncomingMessage on the request's native handle; native calls it per chunk and once more with isLast at the body's fin. isLast is what sets req.complete and 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.
  • uws keeps one HttpResponseData per connection, reused by every request on a keep-alive connection, with a single body data handler (inStream) and context pointer. end() calls markDone(), 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.
  • Closing non-keep-alive connections: for an HTTP/1.0 or Connection: close request the JS layer marks the response kMustCloseConnection and its 'finish' listener calls socket.end() (Node: res._last and resOnFinish -> socket.destroySoon()). In Bun 'finish' for a synchronously ended response is emitted before the dispatcher returns to uws, i.e. while uws is still inside HttpContext::onData for the read that carried the request (HttpContextData::parsingSocket is 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 marked HTTP_CONNECTION_CLOSE once the response is complete and flushed: the gate at the end of onData and the one in onWritable; the existing buffered-response deferral in shutdownAfterResponseDrains() 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's nodeHttpSawConnectionClose already does that for requests that carried Connection: close (Node's parser raises HPE_CLOSED_CONNECTION for anything after such a message).
  • body_read_ref is an event-loop keep-alive held while a body is pending. IS_REQUEST_PENDING is a self-reference plus the server's in-flight request count, which is what server.close() waits on; mark_request_as_done releases both, and should_request_be_pending() decides when a response that has ended may be released.
Repro (Node v26 vs Bun)
import { once } from "node:events";
import { createServer } from "node:http";
import { connect } from "node:net";
let gotReq; const request = new Promise(r => (gotReq = r));
const server = createServer((req, res) => { res.end("ok"); gotReq(req); });
server.listen(0, "127.0.0.1"); await once(server, "listening");
const client = connect(server.address().port, "127.0.0.1");
let data = ""; client.on("data", d => (data += d));
await once(client, "connect");
client.write("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 6\r\n\r\nabc"); // half of the body
const req = await request;
while (!data.includes("ok")) await new Promise(r => setTimeout(r, 5));
console.log({ complete: req.complete, readableEnded: req.readableEnded, destroyed: req.destroyed });
req.on("end", () => console.log("req end")); req.on("close", () => console.log("req close"));
client.write("def");

Node v26.3.0 (and this branch): { complete: false, readableEnded: false, destroyed: false }, then req end, req close once def arrives.
Bun before this change: { complete: true, readableEnded: true, destroyed: true } right after res.end(), and neither event fires later.


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

fails on main (without fix)
ASAN without fix: 23 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http-server-abort-events.test.ts test/js/node/http/node-http.test.ts
bun test v1.4.0 (371039612)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [433.54ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [66.16ms]
(pass) node:http > createServer > request & response body streaming (large) [116.13ms]
(pass) node:http > createServer > request & response body streaming (small) [73.76ms]
(pass) node:http > createServer > listen should return server [24.30ms]
(pass) node:http > createServer > listen callback should be bound to server [25.78ms]
(pass) node:http > createServer > should use the provided port [38.12ms]
(pass) node:http > createServer > should assign a random port when undefined [31.05ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [50.65ms]
(pass) node:http > response > set-cookie works with getHeader [3.66ms]
(pass) node:http > response > set-cookie works with getHeaders [6.35ms]
(pass) node:http > request > should not 
... (truncated)

release without fix: 23 failed, 1 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [15.31ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [4.03ms]
(pass) node:http > createServer > request & response body streaming (large) [6.28ms]
(pass) node:http > createServer > request & response body streaming (small) [2.88ms]
(pass) node:http > createServer > listen should return server [1.34ms]
(pass) node:http > createServer > listen callback should be bound to server [2.16ms]
(pass) node:http > createServer > should use the provided port [1.65ms]
(pass) node:http > createServer > should assign a random port when undefined [1.34ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [2.32ms]
(pass) node:http > response > set-cookie works with getHeader [0.12ms]
(pass) node:http > response > set-cookie works with getHeaders [0.16ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [3.50ms]
(pass) node:http > request > multiple Set-Cookie headers works #6810 [15.65ms]
(pass) node:http > request > should make a standard GET request when passed string as first arg [
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http-server-abort-events.test.ts test/js/node/http/node-http.test.ts
bun test v1.4.0 (371039612)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [423.62ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [68.61ms]
(pass) node:http > createServer > request & response body streaming (large) [109.77ms]
(pass) node:http > createServer > request & response body streaming (small) [66.93ms]
(pass) node:http > createServer > listen should return server [36.70ms]
(pass) node:http > createServer > listen callback should be bound to server [24.15ms]
(pass) node:http > createServer > should use the provided port [35.14ms]
(pass) node:http > createServer > should assign a random port when undefined [28.28ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [45.13ms]
(pass) node:http > response > set-cookie works with getHeader [3.42ms]
(pass) node:http > response > set-cookie works with getHeaders [5.75ms]
(pass) node:http > request > should not 
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     3710396125
  features     baseline

22 deps, 123 codegen, 1176 objects in 734ms

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

Checked 107 installs across 153 packages (no changes) [10.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1238] gen bindgenv2
[5/1238] fetch tinycc
[tinycc] up to date
[6/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [11.00ms]
[8/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1237] fetch zlib
[zlib] up to date
[10/1237] gen JSBuffer.lut.h
Generating /workspa
... (truncated)
diff hotspot
packages/bun-uws/src/HttpContext.h                 |  10 +
 packages/bun-uws/src/HttpParser.h                  |  14 +-
 packages/bun-uws/src/HttpResponse.h                |  16 +
 src/js/node/_http_incoming.ts                      |  15 +-
 src/js/node/_http_server.ts                        |  29 +-
 src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp   |  21 +-
 src/jsc/bindings/node/JSNodeHTTPServerSocket.h     |   8 +-
 .../node/JSNodeHTTPServerSocketPrototype.cpp       |   8 +-
 src/runtime/server/NodeHTTPResponse.rs             | 182 +++++----
 src/runtime/server/mod.rs                          |   3 +-
 src/runtime/server/server.classes.ts               |   4 -
 .../node-http-ondata-reregister-leak.fixture.js    |   2 +-
 test/js/node/http/node-http-proxy.js               |   4 +-
 .../http/node-http-server-abort-events.test.ts     | 415 ++++++++++++++++++++-
 test/js/node/http/node-http.test.ts                | 316 ++++++++++++++++
 15 files changed, 936 insertions(+), 111 deletions(-)

gate history · 2 passed · 1 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
packages/bun-uws/src/HttpContext.h                            0      0      0
packages/bun-uws/src/HttpParser.h                             0      0      0
packages/bun-uws/src/HttpResponse.h                           0      0      0
src/js/node/_http_incoming.ts                                 2      3      0
src/js/node/_http_server.ts                                   2      2      0
src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp              2      2      0
src/jsc/bindings/node/JSNodeHTTPServerSocket.h                2      2      0
…c/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp      1      1      0
src/runtime/server/NodeHTTPResponse.rs                        5      2      0
src/runtime/server/mod.rs                                     0      0      0
src/runtime/server/server.classes.ts                          0      0      0
…s/node/http/node-http-ondata-reregister-leak.fixture.js      0      0      0
test/js/node/http/node-http-proxy.js                          1      1      0
test/js/node/http/node-http-server-abort-events.test.ts       3      3      0
test/js/node/http/node-http.test.ts                           2      1      0

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…

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

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: acff2c0d-1e54-49bb-b1e0-0f8203b2aa54

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 3710396.

📒 Files selected for processing (15)
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpParser.h
  • packages/bun-uws/src/HttpResponse.h
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_server.ts
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.h
  • src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
  • src/runtime/server/NodeHTTPResponse.rs
  • src/runtime/server/mod.rs
  • src/runtime/server/server.classes.ts
  • test/js/node/http/node-http-ondata-reregister-leak.fixture.js
  • test/js/node/http/node-http-proxy.js
  • test/js/node/http/node-http-server-abort-events.test.ts
  • test/js/node/http/node-http.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; self-review in progress.

  • Reproduced with a raw net client sending half of a Content-Length: 6 POST body to a handler that calls res.end() immediately: on main req.complete / readableEnded / destroyed are true and 'end' + 'close' have fired by the time the response reaches the client; Node v26.3.0 reports all three false and emits the events once the rest of the body arrives. The consumer shapes from node:http IncomingMessage stream data cannot be read, events are not emitted #4733 / http body won't be received if res.end is called too early #18613 ('data' listener + synchronous res.end()) deliver an empty body on main.
  • Tests: test/js/node/http/node-http-server-abort-events.test.ts (new describe block). 7 of the 8 new tests fail on a build of main without the src/ changes and all pass with them; every scenario was also run under Node v26.3.0 with identical results.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:http: keep delivering the request body after a synchronous res.end() #35489 - Fixes the same two issues (node:http IncomingMessage stream data cannot be read, events are not emitted #4733, http body won't be received if res.end is called too early #18613) with a near-identical fix: moves the req._dump() decision out of ServerResponse.end() into emitResponseFinish, drops the HAS_CUSTOM_ON_DATA gate in maybe_stop_reading_body/write_or_end, re-arms uWS on_data after end(), and defers mark_request_as_done to the body's fin.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

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 _http_incoming.ts) req._dump() still clears handle.ondata and _read() still treats _dumped as EOF, so req.complete / readableEnded / destroyed flip to true and 'end' + 'close' fire at res.end() while the body is still in flight, and a connection dropped mid-body still reports a completed request. The first two tests and the abort exit test here cover that and would fail with #35489 applied. On the native side this PR also keeps pause() / resume() / arming and the release of the request consistent for that window (body_still_arriving()), and it removes the never-set hasCustomOnData flag instead of leaving it in place. #35489 has been conflicting since the beginning of the month; this supersedes it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

@robobun, your commit 3710396 has some failures in Build #95764 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38196

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

bun-38196 --bun

Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.h Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
…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.
Comment thread src/js/node/_http_incoming.ts
Comment thread src/js/node/_http_incoming.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.h
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs

@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 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 on set_on_data / do_pause / do_resume and the re-arm after raw_response.end() — checked that the shared inStream/userData slot 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_data clear, 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-path closeIfDoneAndMarked — the gate only acts on already-marked, fully-responded, non-shut-down sockets, so other 'clientError' cases still defer to the listener.
  • kEndAfterResponse plumbing from onResponseFinishHandleSocket_final → native end(afterResponseFinished) — a user-issued socket.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.

@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 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-connection inStream/userData slot after res.end() (pause/resume/set_on_data/re-arm in write_or_end) — the fin-parked check keeps it from stepping on a pipelined successor.
  • Ref balance: body_read_ref now also released from mark_request_as_done and from set_on_data's clear branch; on_data_or_aborted's fin tail calls mark_request_as_done_if_necessary unconditionally — traced against the exit tests that guard server.close() completing.
  • deferShutdownUntilResponseDrains now also calls nodeHttpStopDispatchingAfterCurrentMessage() on the pre-existing buffered-bytes deferral, and HttpContext::onData's node:http parse-error exit now runs closeIfDoneAndMarked — 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-set HAS_CUSTOM_ON_DATA flag; adds has_body_reader() and body_still_arriving(); write_or_end::<true> keeps the body read state and re-arms the uWS inStream after end() dropped it; do_pause/do_resume/set_on_data keep working after the response ended while the body is still arriving; mark_request_as_done now also releases body_read_ref; the fin tail in on_data_or_aborted re-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 sets nodeHttpStopDispatchingAfterCurrentMessage() so a pipelined successor cannot reset HTTP_CONNECTION_CLOSE; the node:http parse-error exit of onData runs closeIfDoneAndMarked so 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 native ondata armed; _read() no longer treats _dumped as EOF; the dump decision moves from ServerResponse.end() to emitResponseFinish (Node's resOnFinish); kEndAfterResponse distinguishes the response-finish socket.end() from a user-issued one and is threaded through _final → native end().
  • Tests: 15 new cases in node-http-server-abort-events.test.ts and 12 in node-http.test.ts, all verified against Node v26.3.0; one fixture updated for the removed hasCustomOnData; node-http-proxy.js binds 127.0.0.1 explicitly.

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 as HPE_CLOSED_CONNECTION instead of being dispatched — which the description justifies (dispatching it would clear HTTP_CONNECTION_CLOSE), but it is worth a maintainer's eye.
  • One pre-existing edge (IS_DATA_BUFFERED_DURING_PAUSE_LAST after res.end()) is explicitly left unfixed and filed as #38207.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

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.

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.

node:http IncomingMessage stream data cannot be read, events are not emitted http body won't be received if res.end is called too early

1 participant