node:http: release a request whose body fin was buffered while paused once the response ends - #38207
Conversation
… once the response ends When the last chunk of a request body arrives while the IncomingMessage is paused, on_buffer_request_body_while_paused() only sets IS_DATA_BUFFERED_DURING_PAUSE_LAST and leaves body_read_state at Pending so JS can still drain the buffered tail. should_request_be_pending() read that Pending as "body still arriving", so res.end() left IS_REQUEST_PENDING set, and since uws never delivers anything further for that body nothing ever re-evaluated it: the server's pending-request count never reached zero (server.close() never completed) and the response box leaked. The socket close path only reaches the connection's current response, so serving one more keep-alive request made the strand permanent. Treat a buffered fin as a complete body in should_request_be_pending(), so the request is released at res.end() (or, for an upgrade tunnel, at the fin) like every other body state. mark_request_as_done() no longer frees the buffered tail in that case, because the IncomingMessage still drains it through drainRequestBody; set_on_data()'s clear branch frees it once the reader detaches instead.
|
Warning Review limit reached
Next review available in: 30 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:19 PM PT - Aug 13th, 2026
✅ @robobun, your commit 01b1e255d1ddfc5ef5ea9aed346c9ccafa191188 passed in 🧪 To try this PR locally: bunx bun-pr 38207That installs a local version of the PR into your bun-38207 --bun |
|
Status: fix pushed, waiting on CI and review.
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes native HTTP server request-lifecycle accounting (when mark_request_as_done runs and what it frees, plus a new unconditional buffer free in set_on_data's refuse-to-arm branch), a human look would still be worthwhile.
What was reviewed:
should_request_be_pending(): traced thatIS_DATA_BUFFERED_DURING_PAUSE_LASTis only set alongsidebody_read_refunref, so treating it as body-complete for accounting matches every other consumer of the flag.- Buffer lifetime in
mark_request_as_done(): verified the retained tail is later reachable via_read()→drainRequestBody(), and is freed either there, inset_on_data's clear branch, or indeinit(). set_on_datanewclear_and_free(): confirmed_read()always callsdrainRequestBody()before assigninghandle.ondata, so the buffer is empty by the time this branch is reached with a callable;_dump/_destroyreach it withundefined, where freeing is intended.- Tests await observable events (server
'close', marker in response bytes, body promise) rather than sleeping; the four scenarios cover unread/read-late × keep-alive/pipelined/upgrade.
Extended reasoning...
Overview
The PR fixes a request-accounting leak in node:http server: when a request body's fin chunk arrives while the IncomingMessage is paused (native buffering path), should_request_be_pending() previously read body_read_state == Pending as "body still arriving" and kept IS_REQUEST_PENDING set forever after res.end(), stranding the server's in-flight count and the response object's self-reference. Two files change: src/runtime/server/NodeHTTPResponse.rs (three code sites, one comment) and four new tests in test/js/node/http/node-http-req-socket-pause.test.ts.
The Rust change has three parts: (1) should_request_be_pending() now treats Pending && IS_DATA_BUFFERED_DURING_PAUSE_LAST as body-complete, factored into a shared body_pending local for both the TUNNELED and ENDED arms; (2) mark_request_as_done() skips freeing buffered_request_body_data_during_pause when the fin was buffered and the socket is still open (so JS can still drainRequestBody()); (3) set_on_data's refuse-to-arm branch now frees that buffer, catching the _dump/_destroy/re-arm-after-drain paths so the retained buffer doesn't leak until deinit().
Security risks
None identified. This is server-side request accounting and buffer lifetime management with no auth, crypto, or user-controlled parsing changes. The buffer being retained longer is bounded (it holds only the tail of one request's body that arrived while paused), and every release path was traced to a free (drain, set_on_data clear, or deinit()).
Level of scrutiny
High. This is native HTTP server lifecycle code — refcount balancing (IS_REQUEST_PENDING is a self-reference plus a server counter unit), buffer ownership across the JS/native boundary, and interaction with the shared per-connection uws HttpResponseData handler slot. The correctness of retaining the buffer in mark_request_as_done() depends on the invariant that _read() drains before it re-arms ondata, and that clear_on_data_callback() (called unconditionally from mark_request_as_done() on the new path) is safe when uws has already nulled its handler. The PR description argues each of these convincingly and I verified them against the source, but the reasoning is subtle enough that a maintainer familiar with the pipelined-request / keep-alive edge cases in this file should confirm.
Other factors
The four new tests are well-constructed: they use a raw TCP socket to control segment framing, await response markers rather than sleeping, exercise a follow-on keep-alive request so socket-close cannot mask the leak, and each is documented as also passing on Node v26. The PR description lists an extensive set of related test files run on the build. The comment update in handle_abort_or_timeout correctly reflects that the paused-fin case is no longer the reason REQUEST_HAS_COMPLETED can co-exist with Pending (the custom-ondata case in write_or_end remains). No CODEOWNERS entry covers these paths.
|
@robobun please run the tests this PR adds or changes on Node.js v26.3.0 and report whether each one passes or fails. |
|
@cirospaciari I ran
The 3 tests that were already in the file ( On Bun the file is green on every lane of this PR's CI run for the head commit 01b1e25 (build #94785), including the ASAN lane. No test only passes on Bun, so there is nothing to change. A negative control (one How to rerun on Node// bun-test-node.mjs
import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, next) {
if (specifier === "bun:test") return { url: import.meta.url, shortCircuit: true };
return next(specifier, context);
},
});
import assert from "node:assert";
export { describe, it, test, before as beforeAll, after as afterAll, beforeEach, afterEach } from "node:test";
export function expect(value) {
return {
toBe: e => assert.strictEqual(value, e),
toEqual: e => assert.deepStrictEqual(value, e),
toContain: e => assert.ok(value.includes(e)),
toBeGreaterThan: e => assert.ok(value > e),
toBeGreaterThanOrEqual: e => assert.ok(value >= e),
toBeLessThan: e => assert.ok(value < e),
};
}cp test/js/node/http/node-http-req-socket-pause.test.ts /tmp/node-http-req-socket-pause.test.mts
node --import ./bun-test-node.mjs --test --test-reporter=spec /tmp/node-http-req-socket-pause.test.mtsThe |
Problem
node:httpserver request stays counted as in flight forever when the last chunk of its body arrived while theIncomingMessagewas paused and the response was ended afterwards.server.close()never calls back (the server's'close'event needs the in-flight count to reach zero), the process it keeps alive never exits, and the native response object leaks (IS_REQUEST_PENDINGis a self-reference). Node v26 closes cleanly; reproduces on bun 1.4.0 and main.pause(): a body chunk larger than theIncomingMessagehighWaterMarkthat nobody reads pauses the connection mid-segment (Node'sreadStopsemantics), so the rest of the same segment (remaining chunks + terminating chunk) is buffered natively while paused.src/runtime/server/NodeHTTPResponse.rs:on_buffer_request_body_while_paused(last = true)setsIS_DATA_BUFFERED_DURING_PAUSE_LAST, releasesbody_read_ref, and deliberately leavesbody_read_state == Pendingso JS can still drain the buffered tail (drainRequestBody).should_request_be_pending()read thatPendingas "body still arriving", sores.end()->on_request_complete()keptIS_REQUEST_PENDING.write_or_end's dump-equivalent does not apply either, because it keys onbody_read_refstill being held.end()), and the laterondata = undefinedfrom_dump()/_destroy()only flips the state toDone.handle_abort_or_timeoutonly reaches the connection's current response, so as soon as the keep-alive connection serves one more request the strand is permanent.Fix
should_request_be_pending()treats a body whose fin was buffered while paused as complete (Pendingand notIS_DATA_BUFFERED_DURING_PAUSE_LASTis the only "still arriving" state). The request is then released where every other body state is released: atres.end()viaon_request_complete(), or for an upgrade tunnel with a body at the fin itself (the existingmark_request_as_done_if_necessary()call inon_buffer_request_body_while_paused, matching what the unbuffered fin already does inon_data_or_aborted).mark_request_as_done()no longer freesbuffered_request_body_data_during_pausein that state while the connection is still open: theIncomingMessagestill owns that tail and drains it on its next_read()(possibly after the response ended). It is freed when drained, when the reader lets go (set_on_data's clear branch, reached from_dump()/_destroy()/ a re-arm attempt that follows_read()'s drain), or with the box. Closed/upgraded connections free it immediately as before.hasBodyreports done,pause()/resume()/ondatarefuse to re-arm), and the accounting release atres.end()is what Node does too (resOnFinishdrops the request from the server's queue regardless of whether its body was consumed). Releasing the accounting does not affect JS's ability to read the tail because the wrapper holds its own reference to the box; only the buffer's lifetime had to be decoupled.clear_on_data()reached frommark_request_as_done()on this new path is a no-op: uws already nulled the connection's handler when it delivered the fin (and again inend()), andondatais cleared on this request's own wrapper (armed_this_value).test/js/node/http/node-http-req-socket-pause.test.ts, four new tests, each also checked against Node v26 (all pass there):'close'fires: times out on main.'close'fires: times out on main, and fails (tail missing) if the tail is freed at release.res.end()): times out on main.server closed;node-http.test.ts,node-http-backpressure*.test.ts,node-http-transfer-encoding,node-http-server-abort-events,node-http-connect,node-http-with-ws,node-http-ondata-reregister-leak,node-http-server-socket-end-drain,node-http-uaf,node-http-server-timeouts, and 42 vendoredtest-http-{pause,no-read-no-dump,expect-continue,keep-alive,pipeline,upgrade-server-with-body,...}tests. The only failures are identical without this diff (the proxy test'slocalhostresolution in this container and a few subprocess tests exceeding 5 s on the ASAN build).cargo clippy -p bun_runtimeis clean.body_still_arriving()and leaves this strand to a separate fix; in its model a paused reader can also get the fin buffered afterres.end(), and this change is what releases that request. node:http: deliver a pipelined POST's body when the previous response is still in flight #34761 (pipelined successor's handler slot) is independent.Background
HttpResponseDataper connection with a single body-data handler slot.NodeHTTPResponsearms it withon_data_shim(deliver to JSondata) or, while theIncomingMessageis paused,on_buffer_paused_shim, which appends chunks tobuffered_request_body_data_during_pause; JS later fetches that buffer withdrainRequestBody()fromIncomingMessage._read(). uws clears the slot itself after the chunk flagged as the fin.body_read_stateisNone(no body),Pending(uws may still call back, or a buffered tail is still readable) orDone;IS_DATA_BUFFERED_DURING_PAUSE_LASTrecords that the fin was among the buffered chunks.body_read_refis the event-loop keep-alive held while chunks are still expected.IS_REQUEST_PENDINGis a self-reference on the response box plus one unit of the server's in-flight request count;mark_request_as_done()drops both, andshould_request_be_pending()decides whether an ended/tunneled response may drop them yet.server.close()innode:httpcompletes through the native server's all-requests-done promise, which is gated on that count.Repro from the report (hangs on bun 1.4.0, exits with "server closed" on Node and with this change)
BUN_DEBUG_NodeHTTPResponse=1before:onData(70000)->doPause->onBufferRequestBodyWhilePaused(3, false)->onBufferRequestBodyWhilePaused(0, true)->end('ok1')->onRequestComplete, and nomarkRequestAsDone()for the first request. After:markRequestAsDone()followsonRequestCompletedirectly, and the laterdrainBufferedRequestBodyFromPause 3shows the tail still being handed to JS.