node:http: keep delivering the request body after a synchronous res.end() - #35489
node:http: keep delivering the request body after a synchronous res.end()#35489robobun wants to merge 13 commits into
Conversation
|
Warning Review limit reached
Next review available in: 4 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 (4)
WalkthroughChangesHTTP request body completion
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Beyond the inline nits, two lifetime/ref concerns in NodeHTTPResponse.rs were examined and ruled out: (1) the abort/timeout handlers do not need re-arming alongside the post-end() on_data re-arm — they're registered on the socket/connection, not on inStream, so markDone() nulling inStream doesn't clear them; (2) maybe_stop_reading_body on the UPGRADED/SOCKET_CLOSED branches still releases correctly — with ondata set the body stays Pending and body_read_ref is later dropped via on_data_or_aborted(last=true) → mark_request_as_done, so no ref leaks.
Extended reasoning...
This PR changes request/response lifecycle in the native HTTP server — re-registering a uws on_data callback on raw_response after end()/end_stream() has run, gated on body_read_state == Pending with IS_REQUEST_PENDING as the keep-alive. That's exactly the kind of lifetime-sensitive change that should get human eyes even though the reasoning in the PR description is sound and test coverage is good. The two inline findings are nits (test hygiene; _dump() now preceding a throw-capable native call). Deferring rather than approving; the note above records the additional concerns the finder/verifier pass already checked so they aren't re-explored.
|
@robobun this needs to match Node.js semantics identically. |
|
Reworked so the |
|
Updated 8:43 PM PT - Jul 31st, 2026
✅ @robobun, your commit 8e926f027d31f9b0768c708fb7f38b3c5023122b passed in 🧪 To try this PR locally: bunx bun-pr 35489That installs a local version of the PR into your bun-35489 --bun |
|
Rebased onto main at df49a6e and re-verified on 8e926f0. The
Ready for review. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/runtime/server/NodeHTTPResponse.rs (2)
680-731: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRef-release on teardown looks correct.
Releasing
body_read_refinmark_request_as_done()fixes the leaked-ref/vm.active_tasksimbalance for the "socket closed mid-upload" case (SOCKET_CLOSEDcan makeshould_request_be_pending()returnfalsewhilebody_read_stateis stillPending, e.g. via theset_closed()-only path). One nit:body_read_stateitself is left atPendinghere rather than transitioned toDone/None, soget_has_body()could report a stale "pending" bit indefinitely after teardown. Given the object is already torn down at this point, this is likely inconsequential, but worth a quick check that nothing downstream re-readsbody_read_stateafter this point in a way that matters.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/server/NodeHTTPResponse.rs` around lines 680 - 731, Review teardown handling in mark_request_as_done and downstream reads of body_read_state, especially get_has_body. Ensure the body-read state is transitioned from Pending to its appropriate terminal state during teardown so stale pending status cannot be observed, while preserving the existing body_read_ref.unref behavior and avoiding duplicate release.
2032-2073: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick winUpdate the ordering comment and relax the end-time callback gate
req._dump()now runs on the'finish'nextTick, so the comment sayingServerResponse.prototype.endclearsondatabefore reaching this path is stale.set_on_datastill rejectsFlags::ENDEDeven whenbody_read_stateisPending; that can drop a deferredreq.on("data")/req.pipe()attached after synchronousres.end()and stop post-end body delivery.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/server/NodeHTTPResponse.rs` around lines 2032 - 2073, Update the ordering comment in the IS_END body-read handling to reflect that req._dump() runs on the 'finish' nextTick, rather than claiming ServerResponse.prototype.end clears ondata first. Adjust set_on_data so Flags::ENDED does not reject registration while body_read_state is Pending, allowing deferred req.on("data") and req.pipe() callbacks to receive post-end body data. Preserve the ended rejection for non-pending body states.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/runtime/server/NodeHTTPResponse.rs`:
- Around line 680-731: Review teardown handling in mark_request_as_done and
downstream reads of body_read_state, especially get_has_body. Ensure the
body-read state is transitioned from Pending to its appropriate terminal state
during teardown so stale pending status cannot be observed, while preserving the
existing body_read_ref.unref behavior and avoiding duplicate release.
- Around line 2032-2073: Update the ordering comment in the IS_END body-read
handling to reflect that req._dump() runs on the 'finish' nextTick, rather than
claiming ServerResponse.prototype.end clears ondata first. Adjust set_on_data so
Flags::ENDED does not reject registration while body_read_state is Pending,
allowing deferred req.on("data") and req.pipe() callbacks to receive post-end
body data. Preserve the ended rejection for non-pending body states.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 30cce976-e54d-4082-9564-a46a4d1237ac
📒 Files selected for processing (3)
src/js/node/_http_server.tssrc/runtime/server/NodeHTTPResponse.rstest/js/node/http/node-http.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/server/NodeHTTPResponse.rs:1632-1645— d444b8f's buffer-non-empty guard only covers the self-call: line 1632 still setsbody_read_state = Doneimmediately, so in the reverse ordering — fin lands on the paused shim before a deferredres.end()—write_or_end::<true>→on_request_complete()(:2117 → :1476) seesENDED && !Pending, runsmark_request_as_done(), andclear_and_free()s the undelivered pause-buffer tail (:712-713).do_resumethen early-returns at :1445-1448 and'end'still fires viaIS_DATA_BUFFERED_DURING_PAUSE_LAST— silent body truncation. Haveshould_request_be_pending()also returntruewhilebuffered_request_body_data_during_pauseis non-empty (or defer :1632 until the drain hands the tail to JS) so allmark_request_as_done_if_necessary()callers are covered atomically.Extended reasoning...
What the bug is
Commit 0d9c2f2 added
self.body_read_state.set(BodyReadState::Done)at :1632 inon_buffer_request_body_while_paused(last=true). Commit d444b8f then guarded the localmark_request_as_done_if_necessary()call at :1639-1645 (skip while the pause buffer is non-empty) and added a drain-time call at :1419-1425. But :1632 flips global state that every other caller ofmark_request_as_done_if_necessary()observes viashould_request_be_pending()(:651-652:if ENDED { return body_read_state == Pending }). One such caller —on_request_complete()at :1476, reached fromwrite_or_end::<true>at :2117 — has no buffer-emptiness guard. So when the body's fin arrives on the paused shim before a deferredres.end()runs,res.end()frees the buffered tail before the consumer can drain it.Step-by-step trace
- Async/nextTick handler:
req.pipe(slowSink); await X; res.end('ok'). A body chunk arrives →onDataIncomingMessage→push()returnsfalse→do_pausesetsIS_DATA_BUFFERED_DURING_PAUSEand armson_buffer_paused_shim. - The body's fin (plus any tail bytes) arrives on the paused shim — always reachable on Windows (
pause_socket()iscfg-skipped at :1388-1391) and on POSIX when coalesced in the same recv() batch.on_buffer_request_body_while_paused(chunk, last=true)appendschunktobuffered_request_body_data_during_pause(:1624-1625), setsIS_DATA_BUFFERED_DURING_PAUSE_LAST(:1628), setsbody_read_state = Done(:1632), unrefsbody_read_ref(:1633-1635), and — buffer non-empty — skips the self-call at :1639-1645.ENDEDis not yet set,REQUEST_HAS_COMPLETEDis not yet set. - The deferred
res.end('ok')fires →write_or_end::<true>. :2083 setsFlags::ENDED. :2101 checksbody_read_state == Pending(it'sDone) so the re-arm block is skipped. :2117 callson_request_complete(). on_request_complete()(:1468-1477):REQUEST_HAS_COMPLETEDnot set → :1473 sets it, :1476 callsmark_request_as_done_if_necessary(). At :727,IS_REQUEST_PENDINGis set andshould_request_be_pending()at :651-652 evaluatesENDED → (body_read_state == Pending)= false →mark_request_as_done()runs.mark_request_as_done(): :703clear_on_data_callbacknulls the JS ondata slot; :712-713buffered_request_body_data_during_pause.clear_and_free()frees the tail chunk appended in step 2; :723 derefs.- The slow consumer catches up →
_read()→do_resume. :1445-1448(REQUEST_HAS_COMPLETED || ENDED) && body_read_state != Pending→ returnFALSEbefore :1462's drain (and the buffer is empty anyway).get_has_bodystill ORs in the done bit fromIS_DATA_BUFFERED_DURING_PAUSE_LAST, so JS runsemitEOFIncomingMessage→reqemits'end'with the tail silently missing.
Why nothing existing prevents it
should_request_be_pending()(:633-655) has no clause forbuffered_request_body_data_during_pauseorIS_DATA_BUFFERED_DURING_PAUSE_LAST; d444b8f's guard is local to :1639-1645 and cannot influence :1476. The re-arm block at :2101-2116 is gated onPending, so it neither re-arms nor drains here. Pre-PR this was safe becauseon_buffer_request_body_while_pauseddid not touchbody_read_state(the diff shows :1632 is a pure addition), so at step 4should_request_be_pending()sawPendingand returnedtrue, leaving the buffer intact fordo_resumeto drain.Relationship to the open comment at :1644
That comment describes the fin-after-
res.end()ordering (ENDEDalready set when the fin lands, self-call at the old :1625 frees the buffer). d444b8f's :1639-1645 guard addresses that self-call site. This report is the fin-before-deferred-res.end()ordering, reachingmark_request_as_done()via a different call site (on_request_complete()at :2117) that has no such guard. Same root cause (:1632 flipsbody_read_statebefore the drain), different unguarded caller — d444b8f patched only one of them.Impact
Silent request-body truncation with a clean
'end'event on the exact backpressure path this PR opens (async handler + slow consumer where the upload finishes before the deferred response). Areq.pipe(fs.createWriteStream(...))upload handler that awaits before responding writes a truncated file with no error surfaced. This is a regression introduced by :1632 on a path this PR made reachable.Fix
Either:
- have
should_request_be_pending()also returntruewhenbuffered_request_body_data_during_pauseis non-empty (orIS_DATA_BUFFERED_DURING_PAUSE_LASTis set with an undrained buffer) — this covers everymark_request_as_done_if_necessary()caller atomically and lets the local :1639-1645 guard be dropped; or - defer
self.body_read_state.set(BodyReadState::Done)from :1632 todrain_buffered_request_body_from_pause(set it just before :1424'smark_request_as_done_if_necessary(), once the tail has been handed to JS).
The first is preferable per REVIEW.md's "fix bugs at the layer that owns the violated invariant": the invariant is "request is not done while undelivered body bytes exist", and
should_request_be_pending()is the single predicate every caller consults. - Async/nextTick handler:
-
🔴
src/runtime/server/NodeHTTPResponse.rs:1439-1449— The relaxeddo_resumegate now drains the pause buffer and returns it toNodeHTTPServerSocket.#resumeSocket()even afterENDED— but a synchronousres.end()has already rundetachSocket(socket)(dispatcher :1084), sothis._httpMessageis null and#resumeSocket()'sif (req) req.push(resumed)at :1872 is skipped; the drained bytes go only tothis.push()on the socket duplex (no HTTP consumer, discarded bysuper.resume()). Back inIncomingMessage._read(),hasBufferedDataDuringPauseis now clear (do_resume already.replace(Vec::new())'d the buffer at :1412 and cleared the flag at :1459), so the :400-404drainRequestBody()recovery is skipped — the mid-body chunks are silently lost while the later fin still fires'end'. Simplest fix: whenENDED|REQUEST_HAS_COMPLETEDis set, havedo_resumere-arm the shim but skip :1459/:1462 and returnTRUE, so_read()'s ownhandle.drainRequestBody()(which pushes to theIncomingMessagedirectly) does the drain.Extended reasoning...
What the bug is
When backpressure from
req.pipe(slowSink)buffers non-fin body chunks in the native pause buffer after a synchronousres.end(), the consumer's next_read()drains that buffer viado_resume— but the drained bytes are routed throughsocket._httpMessage?.req, which the dispatcher already nulled viadetachSocket(). The bytes go only to the socket duplex's readable side (which the HTTP path never reads), so they are discarded. TheIncomingMessagelater receives the tail via the re-armedon_data_shimand emits'end', so the consumer sees a complete stream with a hole in the middle — silent request-body corruption.This is distinct from the open thread on
on_buffer_request_body_while_pausedat :1644 (that one is the fin case wheremark_request_as_done()freed the buffer, now addressed by the.is_empty()gate in d444b8f). This is the non-fin case:body_read_stateis stillPending, sodo_resumeproceeds past the new gate at :1445-1448 and drains — to the wrong JS target.Step-by-step proof
- Handler:
req.pipe(slowSink); res.end('ok')on a keep-alive request with a large body. write_or_end::<true>setsENDED, keeps the bodyPending(ondata set, notConnection: close), re-armson_data_shimat :2110, andon_request_complete()setsREQUEST_HAS_COMPLETED.handle.finished→ true.- The dispatcher returns from the
'request'emit, seeshandle.finishedat_http_server.ts:1077, and runshttp_res.detachSocket(socket)at :1084 →socket._httpMessage = null(:3457) andsocket[kRequest] = null(:3462).req.socketis not touched, soIncomingMessage._read()can still reach the socket. - A body chunk arrives on
on_data_shim→onDataIncomingMessage→req.push(chunk)returnsfalse(slowSink is full) →socket.pause()(internal/http.ts:261-266, gated on!socket.writableEndedwhich is false on keep-alive) →do_pause. The relaxed gate at :1362-1365 letsENDED+Pendingthrough; :1373-1374 armson_buffer_paused_shim. - Further non-fin chunks land in
buffered_request_body_data_during_pause— always on Windows (pause_socket()iscfg-skipped at :1378-1381), and on POSIX for chunks in the samerecv()batch that uws keeps parsing after the shim swap. The fin has not yet arrived, sobody_read_statestaysPending. slowSinkdrains →IncomingMessage._read()fires →_http_incoming.ts:376callssocket.resume()→NodeHTTPServerSocket.resume()(:2037-2039) →#resumeSocket()(:1843-1878).#resumeSocket():handle.responseis still the liveNodeHTTPResponse(currentResponseObjectonJSNodeHTTPServerSocketis only cleared byonClose/detach()or reassigned by a new request, not byon_request_complete).response.resume()→do_resume.body_read_state == Pending, so the new gate at :1445-1448 does not early-return. :1457 re-armson_data_shim. :1459 clearsIS_DATA_BUFFERED_DURING_PAUSE. :1462 →drain_buffered_request_body_from_pause()→ :1412.replace(Vec::new())→ returns the buffer.- Back in
#resumeSocket()(:1866-1875):const message = this._httpMessage→ null (step 3).const req = message?.req→ undefined. :1872if (req) req.push(resumed)→ skipped. Only :1875this.push(resumed)runs, pushing to the socket duplex. resume():2039 callssuper.resume(), putting the socket duplex into flowing mode with no'data'listener — the pushed bytes are discarded.- Back in
_read()at :390,handle.hasBodyis re-read:get_has_body(:780) testsbuffered_request_body_data_during_pause.len() > 0for thehasBufferedDataDuringPausebit — now 0 (drained in step 7). The recovery at :400-404 (handle.drainRequestBody()→this.push(drained)) is skipped. - Later chunks + fin arrive on the re-armed
on_data_shim→onDataIncomingMessage→req.push(...)→emitEOFIncomingMessage. The consumer receives the tail and'end'— with the step-5 bytes missing from the middle.
Why nothing existing prevents it
#resumeSocket()'s only route to theIncomingMessageisthis._httpMessage?.req; there is no fallback tothis[kRequest](also nulled) or any other back-pointer._read()'s recovery at :400-404 keys onhasBufferedDataDuringPause, whichdo_resumeclears at :1459 in the same call that hands the buffer to the wrong target.- The PR's tests use 12-byte bodies against a 16 KB HWM, so
push()never returnsfalseand this path is uncovered.
Why this is newly reachable
Pre-PR,
do_pausereturnedFALSEunconditionally onENDED(old :1350-1355), so the pause buffer was never populated afterres.end(); anddo_resumereturnedFALSEunconditionally onENDED(old :1419-1425), so#resumeSocket()never received a drained buffer post-end (and_read()'s :400-404 fallback, which pushes tothisdirectly, was the only drain path). This PR opens both — correctly, for the feature — but the drain-return path was designed for the pre-detach case where_httpMessageis still set.Impact
Silent request-body truncation whenever, after a synchronous
res.end()on keep-alive, a body chunk triggerspush()→falseand further non-fin chunks buffer before the consumer's next_read().req.pipe(fs.createWriteStream(...))— the PR's own headline repro — with a body larger than the Readable HWM hits this on Windows deterministically and on POSIX under coalesced recvs. The consumer sees'end'and treats the upload as complete; there is no error. Per REVIEW.md this is data loss on the exact path the PR opens.How to fix
Two options; the first is the smallest change to this PR:
- In
do_resume, whenENDED|REQUEST_HAS_COMPLETEDis set andbody_read_state == Pending, re-armon_data_shim(as now) but skip :1459 and :1462 — returnTRUEwithout draining.IncomingMessage._read()then seeshasBufferedDataDuringPauseat :390 and drains viahandle.drainRequestBody()at :401, which pushes tothis(theIncomingMessage) directly. This mirrors how the pre-PR code delivered the pause buffer. - Alternatively, have
#resumeSocket()route the drained buffer to theIncomingMessagethat owns the handle when_httpMessageis null — but both_httpMessageandkRequestare cleared bydetachSocket(), so this needs a new back-pointer (e.g. stash theIncomingMessageon the native handle) and is a larger change.
- Handler:
d9edf8a to
68a53e2
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/server/NodeHTTPResponse.rs:1405-1413— The deferredmark_request_as_done_if_necessary()here runs from JS-side_read()after the slow consumer catches up — potentially after request N+1 on the same keep-alive connection has already dispatched and written its owninStream/userDataonto the shared per-connectionHttpResponseData.mark_request_as_done()→clear_on_data_callback(:703 → :2260-2264) then callsraw_response.clear_on_data()on N's still-liveraw_response, gated only onSOCKET_CLOSED/UPGRADED— not onREQUEST_HAS_COMPLETEDlike the siblingset_on_dataclear branch at :2293 — souws_res_on_data(NULL, NULL)writesdata->userData = nullptr; data->inStream = nullptrand silently drops N+1's request body (and leavesonTimeout/onAbortedto fire with a null ctx). Fix: add&& !flags.contains(Flags::REQUEST_HAS_COMPLETED)to the gate at :2260, mirroring :2293.Extended reasoning...
What the bug is
The fix for the buffered-tail truncation (previous review round) deferred
mark_request_as_done_if_necessary()fromon_buffer_request_body_while_paused(last=true)(:1623-1629) intodrain_buffered_request_body_from_pause(:1406-1412), so it runs when JS eventually callshandle.drainRequestBody()fromIncomingMessage._read(). But by the time the slow consumer catches up and_read()fires, a later request on the same keep-alive connection may already own the shared per-connectionHttpResponseData.mark_request_as_done()→clear_on_data_callback()(:703 → :2260-2264) callsraw_response.clear_on_data()on N's still-liveraw_response, andHttpResponse::onData(nullptr, nullptr)(packages/bun-uws/src/HttpResponse.h:940-943) writesdata->userData = nullptr; data->inStream = nullptr— silently dropping every subsequent body chunk for N+1 (HttpContext.h:511 gates delivery oninStream) and leaving a lateronAborted/onTimeouton N+1 to fire with a null ctx pointer (userDatais shared with those callbacks per HttpResponseData.h:172-173).The specific code path
Newly reachable via the relaxed
do_pausegate (:1354-1357) plus the :1411 deferral. Handler doesreq.pipe(slowSink); res.end('ok'):write_or_end::<true>setsENDED, re-armson_data_shimat :2079, callson_request_complete()which setsREQUEST_HAS_COMPLETED(body_read_state is stillPending, soIS_REQUEST_PENDINGstays set).- A body chunk arrives →
onDataIncomingMessage→push()returns false (slowSink full) → pipe callsreq.pause()→do_pause. The relaxed gate at :1354-1357 now permitsENDED && body_read_state == Pending, so it armson_buffer_paused_shim. - N's body fin arrives on the paused shim — always on Windows (
pause_socket()is skipped per#[cfg(not(windows))]at :1370-1373), or on POSIX when coalesced into the same recv() batch (uws keeps parsing the recv buffer through the newly-armed shim).on_buffer_request_body_while_paused(chunk, last=true)appends the chunk, setsIS_DATA_BUFFERED_DURING_PAUSE_LAST, setsbody_read_state = Done, unrefsbody_read_ref, and — because the buffer is non-empty — defersmark_request_as_done_if_necessary(). - uws's body fin path nulls
inStream(HttpContext.h:543) and continues: N+1's headers arrive on the same keep-alive connection (Windows: any later recv before slowSink drains; POSIX: same recv buffer if pipelined). The dispatcher creates M, andhandle.ondata = onDataIncomingMessage→set_on_datareaches :2321 (M'sENDED=false,body_read_state=Pending) and writesinStream = on_data_shim,userData = M.ctxon the sharedHttpResponseData. - Later, slowSink drains → N's readable resumes →
_read()→handle.drainRequestBody()on N (get_has_body()at :776-778 sets bit 3 whilebuffered_request_body_data_during_pauseis non-empty).drain_buffered_request_body_from_pausehands the buffered tail to JS, then :1411 callsmark_request_as_done_if_necessary(). N hasIS_REQUEST_PENDINGset andshould_request_be_pending()(ENDED && body_read_state==Done) is false →mark_request_as_done()runs. - :703 calls
clear_on_data_callback. At :2260, N's flags have neitherSOCKET_CLOSEDnorUPGRADED(andbody_read_state == Done != Nonepasses the outer gate at :2255), so :2262-2264 dereferencesself.raw_response— which was never nulled on this path (only :572 (upgrade), :1259 and :1311 (both abort-only) null it) — and callsclear_on_data()on the live per-connectionHttpResponseDatathat N+1 now owns.
Why nothing existing prevents it
clear_on_data_callbackgates only on N's ownSOCKET_CLOSED/UPGRADEDflags. It has no guard equivalent to the "don't touch a pipelined request's userData" comments indo_pause/do_resume(:1359-1361, :1437-1439), and — critically — is asymmetric withset_on_data's clear branch at :2293-2295, which does gate itsraw_response.clear_on_data()on!REQUEST_HAS_COMPLETEDfor exactly this hazard. N hasREQUEST_HAS_COMPLETEDset (from step 1), so a matching gate would have skipped the clear.Pre-PR this was unreachable: (a)
do_pauserefused whileENDED, so the paused shim never armed afterres.end(); (b)drain_buffered_request_body_from_pausedid not callmark_request_as_done_if_necessary(). Everymark_request_as_done()on N therefore ran synchronously inside a uwsinStreamcallback (or insideres.end()), before uws could dispatch N+1. The :1411 deferral to a later JS turn is what breaks this invariant.Impact
Silent request-body loss for the next keep-alive request on the same connection (HttpContext.h:511 gates body delivery on
inStream), plus a potential null-deref crash if N+1 later times out or aborts (on_timeout_shimat :300-303 /on_aborted_shimdereference the ctx pointer with no null check). The trigger is narrow — backpressure after a synchronousres.end(), fin landing on the paused shim, and N+1 dispatched before the slow sink drains — but on Windows (wherepause_socket()is a no-op) the fin-on-paused-shim leg is not a race, andreq.pipe(slowSink); res.end()is precisely the pattern this PR exists to support.Fix
Mirror
set_on_data's clear branch: add&& !flags.contains(Flags::REQUEST_HAS_COMPLETED)to the gate at :2260 inclear_on_data_callback. OnceREQUEST_HAS_COMPLETEDis set, uws'smarkDone()has already nulled N'sinStreamon the connection and any laterinStream/userDatathere belongs to a subsequent request; N has no business touching it. Alternatively, nullself.raw_responseonce N's body fin has been observed withREQUEST_HAS_COMPLETEDset, so N can no longer reach the shared connection at all.
013cef7 to
8d895bb
Compare
There was a problem hiding this comment.
No new findings on f1ae3c9, but this reworks the node:http server request-lifecycle state machine (ref-counting, per-socket vs per-request inStream/currentResponseObject, buffered-pause shim across keep-alive) and has needed several rounds of subtle fixes — worth a human pass before merge.
What was reviewed:
should_request_be_pendingnow hoists the_LAST && !buffered.is_empty()check above every terminal flag (SOCKET_CLOSED/UPGRADED/TUNNELED/ENDED), closing the last parallel-arm truncation.set_on_data(undefined)allowsclear_on_data()while pre-clear state isPending, andclear_on_data_callbackgates both the cached-slot write and the native clear on!REQUEST_HAS_COMPLETED— the N-clobbers-N+1 keep-alive hazard stays covered on both sides.write_or_end::<true>'s paused-shim re-arm re-pause_socket()s on POSIX soend()'sresume_socket()cannot leave the buffering shim on an un-paused fd.mark_request_as_donenow unrefsbody_read_ref; traced the added unref against every existing unref site — no double-unref path found.
Extended reasoning...
Overview
The PR changes src/runtime/server/NodeHTTPResponse.rs (request-body lifecycle after a synchronous res.end(): should_request_be_pending, do_pause/do_resume, write_or_end::<true>'s post-markDone() inStream re-arm, set_on_data's clear branch, clear_on_data_callback, on_buffer_request_body_while_paused, drain_buffered_request_body_from_pause, mark_request_as_done) and src/js/node/_http_server.ts (moves the req._dump() gate from ServerResponse.prototype.end to emitResponseFinish, matching Node's resOnFinish). Twelve new tests in node-http.test.ts and a one-line hostname fix in node-http-proxy.js.
Security risks
No auth/crypto surface. The class of concern here is resource exhaustion: an earlier revision could accumulate an entire request body unbounded in buffered_request_body_data_during_pause on a resumed fd; that was closed by re-pause_socket() after the paused-shim re-arm and by letting the clear branch disarm the shim while body_read_state == Pending. The other hazard was per-socket state bleed on keep-alive (request N clearing N+1's inStream/cached ondata), addressed by the !REQUEST_HAS_COMPLETED guard in clear_on_data_callback.
Level of scrutiny
High. This is the production node:http server hot path, with cross-cutting changes to a refcounted state machine (body_read_ref, IS_REQUEST_PENDING, deref()), per-socket uws state shared across pipelined requests, and platform-divergent behavior (pause_socket() is a no-op on Windows). The PR has taken eight review rounds to converge, each surfacing a distinct ref-leak / truncation / unbounded-buffer / N-vs-N+1 interaction — that history alone argues for a maintainer read of the final shape rather than bot approval.
Other factors
- f1ae3c9 addresses my last finding exactly as suggested (hoisting the
_LAST && !buffered.is_empty()check to the top ofshould_request_be_pending), and the bug-hunting pass on this revision found nothing new. - One unresolved comment-cop nag remains on the new comment block at
should_request_be_pending:637 — cosmetic, not a blocker. - Test coverage is good (12 new cases including keep-alive reuse, mid-upload socket destroy, chunked body split across segments, Connection: close), but the Windows-specific paused-shim paths and the slow-consumer + client-close-after-fin race that motivated f1ae3c9 are not directly exercised by an automated test.
…d in the handler
When a handler attached a body consumer (req.pipe / req.on('data')) and
then called res.end() synchronously, the request body was silently
dropped: the native end path treated the dispatcher-installed ondata
callback as discardable (!HAS_CUSTOM_ON_DATA) and released the body-read
ref, and uws's markDone() nulled inStream regardless. Node.js keeps the
body flowing independently of the response.
- ServerResponse.prototype.end now runs req._dump() before the native
end so ondata is only cleared when the IncomingMessage truly has no
consumer.
- The native discard now checks whether ondata is actually set rather
than HAS_CUSTOM_ON_DATA.
- After end/end_stream, re-arm the connection's inStream while the body
is still Pending (markDone() cleared it).
Fixes #4733.
…our for Connection: close, allow pause/resume while body is Pending after end - mark_request_as_done now unrefs body_read_ref so an incomplete body (socket closed mid-upload after the response ended) cannot strand vm.active_tasks or trip the deinit assertion. - IS_END discards the body when the connection is closing (Connection: close / HTTP/1.0): the 'finish' nextTick shuts the socket down before any later segment could reach the parser, so keeping the body Pending there only hung the process. - do_pause/do_resume no longer early-return on ENDED|REQUEST_HAS_COMPLETED while the body is still Pending, so push()->false backpressure can still reach the socket after a synchronous res.end(). - New tests for the mid-upload abort and the Connection: close no-hang guard.
…side end() Moving the dump to the 'finish' listener (nextTick after end()) lets a body consumer attached between res.end() and the next tick receive the body, like Node. - set_on_data(undefined) now calls mark_request_as_done_if_necessary so the request is marked done once the dump transitions the body out of Pending. - maybe_stop_reading_body guards on_data_get_cached against an empty this_value (get_this_value() returns empty once the socket's currentResponseObject has been cleared). - New test for the end()-then-attach ordering; test hygiene and timeout tweaks for the spawn-based case.
…true) Mirrors on_data_or_aborted so should_request_be_pending() observes Done once the body's fin arrives on the buffered-pause shim (reachable now that do_pause can run after ENDED while the body is Pending).
- write_or_end<true>: the is_none() arm of the discard is dead (ondata is always set now that _dump() runs on 'finish'); drop it so the block only discards for is_http_connection_close(), and rewrite the comment to match. - Guard the inStream re-arm on !SOCKET_CLOSED (end() with close=true can destruct the HttpResponseData synchronously) and pick the buffered-pause shim when a pre-end push()->false already installed it. - on_data_or_aborted(last=true): call mark_request_as_done_if_necessary() unconditionally; run_callback above can drain nextTicks and reach _destroy -> set_on_data(undefined), which unrefs body_read_ref before the tail re-reads it. - on_buffer_request_body_while_paused(last=true): defer mark_request_as_done until the buffered tail has been drained so it is not freed before the consumer sees it; drain_buffered_request_body_from_pause now does the release once it has handed the tail to JS. - Tests: rewrite the run() helper to assert _dumped in addition to body + events (verified identical to Node v26 for every ordering), add the end()-then-attach, pipe-after-end, resume-after-end, no-consumer dumpedAtFinish, and keep-alive reuse (server.close resolves) cases. Wire every error event to reject.
… do_resume's drain after end - should_request_be_pending(): also true while buffered_request_body_data_during_pause is non-empty, so every mark_request_as_done_if_necessary() caller (on_request_complete, on_data_or_aborted, set_on_data) observes the undelivered tail and does not free it before the consumer drains. - do_resume: after ENDED/REQUEST_HAS_COMPLETED, re-arm the data shim but return without draining; detachSocket() has already cleared socket._httpMessage so #resumeSocket() cannot route the drain to the IncomingMessage. _read()'s handle.drainRequestBody() pushes to the IncomingMessage directly. - Connection: close test: swallow expected client-side EPIPE/ECONNRESET and wire server req error to reject.
…T_HAS_COMPLETED After mark_request_as_done is deferred to the drain path, it can run after the next keep-alive request has written its own inStream/userData onto the shared HttpResponseData. uws's markDone() already nulled this request's inStream when the response ended, so there is nothing to clear, and doing so would clobber the next request's handler. Mirrors the existing REQUEST_HAS_COMPLETED gate in set_on_data's clear branch.
…e-arm, guard per-socket writes in clear_on_data_callback, clear buffer in set_on_data(undefined) - should_request_be_pending: TUNNELED shares the ENDED arm's buffered-pause-non-empty check so an upgrade/CONNECT-with-body whose fin lands on the paused shim is not marked done before drain. - write_or_end<true>: when re-arming the buffered-pause shim, also pause_socket() (POSIX) so a pre-end push()->false pause stays in effect; the unconditional resume_socket() before end() had re-armed READABLE and the shim only appends, so the buffer grew unbounded. - clear_on_data_callback: move on_data_set_cached(UNDEFINED) inside the !REQUEST_HAS_COMPLETED gate. get_this_value() resolves via the per-socket currentResponseObject, which is the next request's wrapper once it has dispatched. - set_on_data's clear branch: clear buffered_request_body_data_during_pause before mark_request_as_done_if_necessary() so a pipeline()/destroy teardown (which nulls req[kHandle], defeating the drain path) does not strand IS_REQUEST_PENDING on a non-empty buffer.
…'localhost'
In an IPv6-first environment listen(0, 'localhost') binds ::1 while
http.request({hostname:'localhost'}) connects to 127.0.0.1 (Node v26 behaves
the same way), so the test ECONNREFUSED regardless of the code under test.
…ending N's fin has not been parsed while body_read_state is Pending, so N+1 cannot have written its inStream onto the shared HttpResponseData yet; clearing is safe and disarms a still-installed paused shim on the req.pause()+res.end() no-consumer path so _dump()'s resume() cannot leave it appending on a resumed fd.
…y terminal flag The fin was parsed onto the paused shim, so the buffered tail is the complete body and belongs to JS; mark_request_as_done() would free it before drain. This covers SOCKET_CLOSED (client closes after the response but before the slow consumer drains) the same way the ENDED/TUNNELED arms already do.
f1ae3c9 to
8e926f0
Compare
There was a problem hiding this comment.
All prior findings from earlier rounds are now addressed (the last one — SOCKET_CLOSED bypassing the buffered-tail guard — is fixed by the new _LAST && !buffered.is_empty() check ahead of every terminal flag in should_request_be_pending). This run only surfaced a nit on per-test timeouts. That said, this touches the native node:http body-read lifecycle across enough interacting axes (uws inStream re-arm after markDone(), per-socket currentResponseObject vs per-request state on keep-alive, paused-shim buffering with Windows/POSIX pause_socket divergence, body_read_ref/IS_REQUEST_PENDING balancing) that a human maintainer should sign off on the final shape.
Extended reasoning...
Overview
Changes span src/runtime/server/NodeHTTPResponse.rs (body-read lifecycle after res.end(): should_request_be_pending, do_pause/do_resume gates, write_or_end::<true> re-arming inStream, set_on_data clear branch, clear_on_data_callback, mark_request_as_done, paused-shim buffering), src/js/node/_http_server.ts (moving the req._dump() gate from ServerResponse#end to emitResponseFinish, matching Node's resOnFinish), 12 new tests in node-http.test.ts, and a hostname fix in node-http-proxy.js.
Security risks
An earlier revision introduced a client-controllable unbounded buffer (paused shim armed on a resumed fd); that and the req.pause(); res.end() no-consumer variant were both fixed mid-review. The final diff re-pause_socket()s when re-arming the paused shim and allows clear_on_data() while pre-clear body_read_state == Pending, so the dump path disarms the shim. I did not find a remaining unbounded-growth path this round, but the interaction surface is large enough that I would not certify it.
Level of scrutiny
High. This is production-critical native code on the node:http server request path. Over five review rounds each surfaced a distinct real bug (N clobbering N+1's inStream/cached ondata on keep-alive, buffered fin tail freed before drain on ENDED/TUNNELED/SOCKET_CLOSED, unbounded paused-shim accumulation, stranded IS_REQUEST_PENDING on pipeline destroyer). All are now addressed, but the density of cross-layer invariants argues for a maintainer pass.
Other factors
Test coverage is solid for the primary fix (12 new tests, verified against Node v26 per the description; keep-alive reuse and mid-upload socket-destroy run in subprocesses and assert clean exit). The edge cases that drove the later fix commits — backpressure-triggered paused shim after res.end(), N+1 dispatch racing N's deferred mark_request_as_done, socket close with a buffered fin tail — are not directly exercised by the new tests (they need slow sinks / coalesced recv batches / Windows). The one inline finding this run is a style nit on 20_000 per-test timeouts; the file already has several, so it is non-blocking.
…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.
|
Closing in favour of #38196, which fixes the same The tests from this PR were carried over to #38196 (11 of the 12 cases, in node-http.test.ts); the |
Fixes #4733.
Fixes #18613.
Repro
Node writes
testingto/tmp/outputand logsrequest ended. Bun before this change emitsend/closebut never firesdata, so/tmp/outputis empty.Cause
Two independent native discards dropped the body as soon as
res.end()ran:write_or_end::<true>(andmaybe_stop_reading_body) released the body-read ref whenever!HAS_CUSTOM_ON_DATA, which the dispatcher clears for every request; the check fired regardless of whether theIncomingMessagewas being consumed.markDone()(reached fromend()/endStream()) nulls the connection'sinStream, so even without step 1 no further body chunks would be delivered.Node's
resOnFinishonly dumps the body when nothing is consuming it, and it runs on the'finish'event (nextTick afterend()), so a consumer attached afterres.end()in the same tick is still honoured.Fix
emitResponseFinish(the'finish'listener, Bun'sresOnFinish) now runs thereq._dump()gate, exactly where Node does.ServerResponse.prototype.endno longer touches_dump().write_or_end::<true>no longer discards the body on keep-alive; it only discards forConnection: close/HTTP 1.0, where uws closes the socket before any later segment could reach the parser. Afterraw_response.end(),inStreamis re-armed while the body is stillPendingso chunks keep flowing into theIncomingMessage. The re-arm skipsSOCKET_CLOSED(end() can destruct theHttpResponseDatasynchronously) and preserves an existing buffered-pause shim.do_pause/do_resumeno longer refuse whileENDEDand the body is stillPending, sopush()->falsebackpressure reaches the socket after a synchronousres.end().on_buffer_request_body_while_paused(last=true)now setsbody_read_state = Doneand defersmark_request_as_doneuntildrain_buffered_request_body_from_pausehas handed the buffered tail to JS, so the tail is not freed before the consumer sees it.on_data_or_aborted(last=true)re-evaluatesIS_REQUEST_PENDINGunconditionally;run_callbackabove can drain nextTicks and unrefbody_read_refvia_destroy -> set_on_data(undefined)before the tail re-reads it.set_on_data's clear branch andmark_request_as_donereleasebody_read_refand callmark_request_as_done_if_necessary()so a mid-upload socket close after the response ended cannot strandvm.active_tasks.maybe_stop_reading_bodyguardson_data_get_cachedagainst an emptythis_value.Node semantics
req._dumpednow matches Node v26 for every ordering: consumer beforeres.end(),pipe/on('data')afterres.end()in the same tick,resume()afterres.end(), no consumer (dumped on'finish'), and pause-then-end (test-http-pause-no-dump). Verified by running each scenario side-by-side against Node.Verification
New
describe("request body still flows after res.end() was called in the handler")intest/js/node/http/node-http.test.tscovers all of the above plus a keep-alive reuse case (three POSTs on one socket,server.close()resolves), a mid-upload socket-destroy (process reachesbeforeExit),Connection: close, and a chunked body split across TCP segments. 10 of the 12 fail onmainand all pass with this change; Node v26 passes them identically.Also ran
node-http.test.ts,node-http-backpressure.test.ts,node-http-server-*.test.ts,express.json.test.ts, and the upstreamtest-http-{pause,pause-no-dump,no-read-no-dump,pause-resume-one-end,pipe-fs,req-res-close,request-end,request-large-payload,incoming-pipelined-socket-destroy,keep-alive,1.0-keep-alive,keep-alive-pipeline-max-requests,client-keep-alive-release-before-finish,server-keepalive-end,pipeline-requests-connection-leak,outgoing-end-cork}.js. No new failures.