Skip to content

node:http: emit 'pause' on req.socket once an unread body fills the IncomingMessage buffer - #34740

Merged
Jarred-Sumner merged 12 commits into
mainfrom
farm/7605e109/http-server-req-socket-pause
Jul 20, 2026
Merged

Jarred-Sumner merged 12 commits into
mainfrom
farm/7605e109/http-server-req-socket-pause

Conversation

@robobun

@robobun robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

A node:http server handler that never reads a POST body never sees 'pause' on req.connection. Node emits it once the body bytes pushed into the IncomingMessage reach its highWaterMark; code that keys its slow-reader flow on that event (including Node's own test-http-no-read-no-dump) wedges forever on Bun.

Repro

const http = require('http');
const server = http.createServer((req, res) => {
  req.connection.on('pause', () => {         // node: fires; bun: never
    res.end('ok'); server.close(); process.exit(0);
  });
  res.writeHead(200); res.flushHeaders();
}).listen(0, () => {
  const post = http.request({ method: 'POST', port: server.address().port });
  post.flushHeaders();
  post.write(Buffer.alloc(64 * 1024).fill('X'));
});
setTimeout(() => { console.log('WEDGED: no pause event'); process.exit(86); }, 5000);

Node v26.3.0: pause emitted, exit 0. Bun 1.4.0 / main: WEDGED: no pause event, exit 86.

Cause

The native server dispatcher (onNodeHTTPRequest) called handle.pause() on the body handle at dispatch and only installed handle.ondata from IncomingMessage.prototype._read(). So a handler that never read the body never had onDataIncomingMessage run at all: no bytes ever reached the Readable buffer, push() was never called, and nothing ever called socket.pause() on the connection.

Node's connectionListenerInternal keeps the connection socket flowing through socketOnData -> parser -> parserOnBody, which pushes body bytes into the IncomingMessage unconditionally and calls readStop(this.socket) (i.e. socket.pause(), which emits 'pause') the moment push() returns false. IncomingMessage._read() later readStart()s the socket to resume.

Bun's llhttp path (_http_common.ts parserOnBody) already does this; only the native server path diverged.

Fix

  • onNodeHTTPRequest: install handle.ondata = onDataIncomingMessage.bind(http_req) at dispatch instead of handle.pause(), so body bytes flow into the IncomingMessage as they arrive (Node's eager-push model).
  • onDataIncomingMessage: when push() returns false, call readStop(this.socket). NodeHTTPServerSocket.pause() forwards to response.pause() (so kernel reads still stop) and Duplex.pause() emits 'pause' on the socket.
  • NodeHTTPResponse::do_resume: once this request's body_read_state has left Pending, skip re-registering onData/onTimeout with uWS. Every uWS callback registration writes the per-socket HttpResponseData::userData, so a socket.resume() routed to the in-flight request's handle (via socketHandle.response) after its body had completed was stealing a pipelined request's onData ctx and dispatching that request's body fin (and trailers) to the previous request. The eager install above made that reachable on every pipelined chunked request; on main it was latent behind _read()'s late set_on_data re-registering last. resume_socket() still runs so a deferred FIN on a paused fd is delivered.

_read() already calls socket.resume(), which restarts the native flow and drains anything buffered during the pause, so the existing resume path closes the cycle.

As a consequence this also bounds req.readableLength when the handler reads slowly: the onDataIncomingMessage path previously ignored push()'s return value entirely, so the Readable buffer grew unbounded (#26332). With the readStop in place it stabilises at the highWaterMark, matching Node:

pulls @ stable req.readableLength
Node v26.3.0 13 ~128 KB
Bun main 536+ (unbounded) ~140 MB (unbounded)
Bun (this PR) 12 ~127 KB

This supersedes #30600, which backpressures via handle.pause() directly. That bounds the buffer but still never emits 'pause' on req.socket (and never reaches its own check when the handler never reads, since ondata was only installed from _read()). Routing through readStop(socket) matches Node's parserOnBody exactly and fixes both symptoms.

Verification

Two new tests in node-http.test.ts:

  • req.socket emits 'pause' once an unread request body fills the IncomingMessage buffer (the repro above)
  • body reading from 'pause' still delivers every byte and 'end' (pause -> attach 'data' -> drain 256 KB -> 'end')

Both time out on main and pass with this change, with output identical to Node v26.3.0. Node's upstream test/parallel/test-http-no-read-no-dump.js is added and now passes (hung forever before).

The 376-file test/js/node/test/parallel/test-http-*.js sweep has no new failures; node-http.test.ts, node-http-transfer-encoding.test.ts, node-http-backpressure.test.ts, node-http-connect.test.ts (11/12 vs 9/12 on main), express and body-parser integration tests are unchanged or improved.

Fixes #26332.


[review] gate passed · iteration 10 · 7 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ 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-req-socket-pause.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
ninja: Entering directory `/workspace/bun/build/debug'
[1/162] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[2/162] gen cpp.rs (cppbind)
[3/162] gen JS modules (bundle-modules)
Preprocess modules (16242ms)
Bundle modules (257ms)
Postprocesss modules (32ms)
Bundle Functions (1703ms)
Generate Code (13ms)

[18.27s] Bundled "src/js" for development
  2208 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/162] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

[4/162] pch pch/root-pch.h.hxx
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (637dd2c53)

test/js/node/http/node-http-req-socket-pause.test.ts:
(pass) req.socket emits 'pause' once an unread request body fills the IncomingMessage buffer [26.23ms]
(pass) body reading from 'pause' still delivers every byte and 'end' [9.84ms]
(pass) req.socket emits 'pause' on every body-bearing keep-alive request, not just the first [9.05ms]

 3 pass
 0 fail
 6 expect() calls
Ran 3 tests across 1 file. [467.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ 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-req-socket-pause.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f996a404b)

test/js/node/http/node-http-req-socket-pause.test.ts:
(pass) req.socket emits 'pause' once an unread request body fills the IncomingMessage buffer [1618.55ms]
(pass) body reading from 'pause' still delivers every byte and 'end' [468.86ms]
(pass) req.socket emits 'pause' on every body-bearing keep-alive request, not just the first [591.07ms]

 3 pass
 0 fail
 6 expect() calls
Ran 3 tests across 1 file. [8.61s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 2062ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/121] gen cpp.rs (cppbind)
[2/121] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/121] gen JS modules (bundle-modules)
Preprocess modules (14072ms)
Bundle modules (235ms)
Postprocesss modules (195ms)
Bundle Functions (1128ms)
Generate Code (422ms)

[16.11s] Bundled "src/js" for production
  2039 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/121] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: com
... (truncated)
diff hotspot
src/js/internal/http.ts                            |  31 ++++++
 src/js/node/_http_incoming.ts                      |  25 +----
 src/js/node/_http_server.ts                        |   8 +-
 .../node/JSNodeHTTPServerSocketPrototype.cpp       |  13 ++-
 src/runtime/server/NodeHTTPResponse.rs             |  22 +++-
 .../node/http/node-http-req-socket-pause.test.ts   | 114 +++++++++++++++++++++
 .../test/parallel/test-http-no-read-no-dump.js     |  56 ++++++++++
 7 files changed, 236 insertions(+), 33 deletions(-)

gate history · 7 passed · 1 rejected · iteration 10

evidence per changed file
file                                                      reads  edits  tests
src/js/internal/http.ts                                       4      6      0
src/js/node/_http_incoming.ts                                10     13      0
src/js/node/_http_server.ts                                   7      6      0
…c/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp      2      3      0
src/runtime/server/NodeHTTPResponse.rs                        9     12      0
test/js/node/http/node-http-req-socket-pause.test.ts          3      8      0
test/js/node/test/parallel/test-http-no-read-no-dump.js       0      0      0

…ressure

The native server dispatcher paused the body handle on arrival and only
installed ondata from IncomingMessage._read(), so a handler that never
read the body never saw body bytes reach the IncomingMessage and
req.socket never had pause() called on it. Node's connectionListener
feeds body bytes through parserOnBody unconditionally and calls
readStop(socket) when push() returns false, which is what emits 'pause'
on the connection.

Install ondata at dispatch (replacing the unconditional handle.pause())
and have the push callback readStop(this.socket) once the Readable
buffer fills. NodeHTTPServerSocket.pause() forwards to response.pause()
as before, so kernel reads still stop, and Duplex.pause() emits the
'pause' event code like test-http-no-read-no-dump keys on.

This also bounds IncomingMessage.readableLength when the handler reads
slowly (was growing unbounded), since the native ondata path now
backpressures instead of ignoring push()'s return.
@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:51 AM PT - Jul 20th, 2026

❌ @robobun, your commit f996a40 has 1 failures in Build #76168 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34740

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

bun-34740 --bun

@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

HTTP request body delivery now shares a centralized data handler that applies backpressure. Response callback re-arming and socket shutdown pauses are state-gated, with regression tests covering unread bodies, pause events, and keep-alive sequencing.

HTTP body backpressure

Layer / File(s) Summary
Incoming body flow
src/js/internal/http.ts, src/js/node/_http_incoming.ts, src/js/node/_http_server.ts
Request body chunks use the shared onDataIncomingMessage handler, which pauses sockets when buffering applies backpressure and resumes them after completion.
Resume state and socket shutdown
src/runtime/server/NodeHTTPResponse.rs, src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Pause/resume callbacks are limited to pending body states, and socket shutdown pauses around the buffered final write.
Backpressure regression coverage
test/js/node/http/node-http-req-socket-pause.test.ts, test/js/node/test/parallel/test-http-no-read-no-dump.js
Tests cover unread request data, socket pause events, continued body delivery, and keep-alive request ordering.

Possibly related PRs

  • oven-sh/bun#34356: Adjusts Node HTTP socket pause/resume behavior during response shutdown.
  • oven-sh/bun#34761: Changes NodeHTTPResponse data-handler lifecycle for pipelined request handling.
  • oven-sh/bun#32488: Includes related Node HTTP pause-ordering compatibility coverage.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes and tests directly address #26332 by pausing socket reads at the IncomingMessage highWaterMark and matching Node behavior.
Out of Scope Changes check ✅ Passed The transport and shutdown adjustments support the same backpressure fix, with no clear unrelated changes introduced.
Title check ✅ Passed The title precisely summarizes the main behavior change: emitting 'pause' on req.socket when an unread body fills IncomingMessage.
Description check ✅ Passed The description is detailed and covers the change plus verification, though it uses custom sections instead of the template headings.

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

robobun added 2 commits July 20, 2026 02:24
…e the body is delivered

The eager onData install exposed a pipelining hazard: a pipelined
request's _read() reaches socket.resume() -> #resumeSocket() ->
currentResponseObject.resume(), which is the in-flight request's
handle. do_resume() unconditionally re-registered onData and
onTimeout, both of which overwrite the per-socket HttpResponseData
userData, so the pipelined request's body fin was dispatched to the
previous request's handle and picked up its trailers.

Once body_read_state has left Pending there is nothing to re-arm, so
gate the re-registration on it. resume_socket() still runs so a
deferred FIN on a paused fd is delivered.
…heir own file

For an accepted Upgrade with a body, the upgrade listener owns the
socket's flow state for the tunnel bytes that follow the body, so
readStop(socket) from the request's push callback (mirroring
parserOnBody) must not touch it. _read() already special-cases upgrade
for the same reason. Without this the tunnel bytes stalled behind a
paused socket on Windows, where do_pause() does not stop kernel reads
and the body fills the Readable buffer in-process.

Move the two new 'pause' tests to their own file so the gate's
with-fix run is not tripped by the pre-existing 'request via http
proxy' failure in node-http.test.ts.
@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. node:http IncomingMessage stream data cannot be read, events are not emitted #4733 - The PR's eager-push model for body bytes into IncomingMessage directly fixes body data/events never reaching req.pipe() or req.on('data') in node:http servers
  2. Unable to read raw data from http/https requests, using either "node:http" or "node:https" #11924 - Same root cause: body data events were never emitted on the server socket/request because ondata was only installed from _read(); the eager install fixes this
  3. node:http keep-alive server drops the next reused request after a Content-Length response finalized by a deferred end() (graceful FIN, no Connection: close) #31889 - The do_resume guard that prevents overwriting per-socket userData once body_read_state leaves Pending directly fixes the pipelining hazard that causes dropped requests on keep-alive connections

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #4733
Fixes #11924
Fixes #31889

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:http: apply backpressure to request body when IncomingMessage buffer is full #30600 - Both fix fetch() with ReadableStream request body ignores backpressure on Windows #26332 by applying backpressure to the request body when the IncomingMessage buffer is full, checking the return value of push() in onDataIncomingMessage and pausing socket reads

🤖 Generated with Claude Code

Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
readStop(socket) from a pipelined request's body push reaches
socket.pause() -> currentResponseObject.pause() where
currentResponseObject is still the previous in-flight request's handle.
Without this guard do_pause on that handle re-arms
on_buffer_paused_shim with its ctx and steals the pipelined request's
body bytes. Symmetric with the do_resume guard.
@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Ready for review at f996a40. node:_http_incoming keeps its original public surface; the backpressure-aware onDataIncomingMessage lives in internal/http.ts and is the single copy used by both _http_server.ts and _read().

The darwin-26 node-http-halfclose-midupload.test.ts hang was root-caused via dtrace on a macOS 26.4 CI host: jsFunctionNodeHTTPServerSocketEnd's us_socket_resume() was written to undo the dispatch-time handle.pause() that this PR removed. With is_paused=0 it early-returned, so the kqueue EVFILT_READ delete/re-add cycle around bsd_shutdown_socket never happened (main's fd went kq[1->2->0->1], this PR's stayed at kq[0->1]) and the peer's close was never delivered. 235d1e8 adds us_socket_pause() before the buffered write so the pause/shutdown/resume sequence is preserved regardless of dispatch state; verified 8/8 CLOSED on that host, and both darwin-26 shards pass on build #76070.

CI red on #76168 is unrelated: test-net-connect-memleak.js is a FinalizationRegistry GC-timing assertion on alpine-x64 only (passes locally on this branch and on system bun; this diff does not touch net.ts); the rest are [flaky]-tagged retries on complex-workspace migration, es-module-lexer, 30205 napi leak, test-http-client-leaky-with-double-response. The files this PR touches are green on every lane that ran them. All bot review threads resolved (claude review approved: "No new findings").

Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
…Pending

pausePipelineReads (_http_server.ts) calls response.pause() on the head
in-flight request, whose body_read_state is necessarily None or Done by
the time a pipelined request is dispatched. The body_read_state guard
added in aafc38d returned before pause_socket(), so the write-side read
gate set socket._paused=true in JS but never stopped kernel reads.

Gate only the onData re-arm on body_read_state; pause_socket() runs
regardless (mirroring do_resume, which resume_socket()s before its
identical check).

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

No new issues found; my three earlier rounds of feedback are all addressed in a315c2d (the body_read_state guard now wraps only the on_data re-arm, so pause_socket() still runs for pausePipelineReads, and do_pause/do_resume are symmetric). This is a substantive change to the server's body-flow model — eager push at dispatch instead of lazy install from _read(), plus native guards around shared HttpResponseData state under pipelining — so it's worth a human look, but I have nothing further.

What was reviewed

  • onDataIncomingMessage → readStop(socket) routing under pipelined dispatch (currentResponseObject points at the head request) — the new body_read_state guards on both do_pause and do_resume prevent stale-ctx re-arm.
  • pausePipelineReads still reaches pause_socket() after a315c2d; resume_socket() remains unconditional.
  • Upgrade-with-body path skips readStop, matching the existing _read() carve-out.
Extended reasoning...

Overview

This PR changes the native node:http server so request-body bytes are pushed into the IncomingMessage eagerly at dispatch (via handle.ondata = onDataIncomingMessage.bind(http_req)) instead of only after _read() runs. onDataIncomingMessage now calls readStop(this.socket) when push() returns false, which routes through NodeHTTPServerSocket.pause() → native do_pause and emits 'pause' on the socket — matching Node's parserOnBody behavior. On the Rust side, do_pause and do_resume gain a body_read_state == Pending gate around the raw.on_data(...) re-arm so a pause/resume routed to a request whose body is already delivered can't overwrite a pipelined successor's ctx on the shared uWS HttpResponseData.

Prior review rounds

I raised three issues on earlier revisions, all now resolved:

  1. readStop(socket) on a pipelined request reaches the previous request's handle via currentResponseObject, whose do_pause re-armed on_buffer_paused_shim with the wrong ctx → fixed by the body_read_state guard in do_pause (aafc38d).
  2. do_pause/do_resume asymmetry (same rationale) → same commit.
  3. That guard's early-return skipped pause_socket(), defeating pausePipelineReads → fixed in a315c2d by scoping the guard to only the on_data re-arm.

I re-verified the final shape at NodeHTTPResponse.rs:1339-1439: the four-flag early-return is unchanged from main; body_read_state == Pending wraps only the IS_DATA_BUFFERED_DURING_PAUSE + raw.on_data block; pause_socket() runs regardless; do_resume calls resume_socket() unconditionally before its guard. Symmetric.

Security risks

None identified. This is flow-control on request-body reads; no auth, crypto, or trust-boundary parsing is touched. The change adds backpressure (bounds req.readableLength at the highWaterMark instead of unbounded growth), which is a hardening.

Level of scrutiny

High. This changes when body bytes reach the IncomingMessage for every native-server request with a body, and the pipelining interaction with uWS's shared per-socket userData slot is subtle enough that it took three review iterations to land correctly. The author also notes a pre-existing pipelined-POST-body bug on main that this PR doesn't fix (deferred to a follow-up). A maintainer familiar with the NodeHTTPResponse / uWS callback-registration model should confirm the eager-install doesn't regress any path the existing test sweep doesn't cover.

Other factors

Two new targeted tests plus Node's upstream test-http-no-read-no-dump.js are added; the PR description reports the 376-file test-http-* sweep and the express/body-parser integration tests are unchanged or improved. The robobun evidence gate's "without fix" runs appear to have used in-PR commits (e2b534f / aafc38d) rather than main, so its fail-on-main proof is not clean, but the mechanism is well-explained and the debug-build timeout on main is consistent with the described root cause.

socketHandle.end() (res.socket.end() -> _final()) calls
us_socket_resume() after the shutdown specifically so kqueue's one-shot
EVFILT_WRITE delivers EV_EOF and the unread body drains. With the eager
body push, a readStop(socket) from the first body chunk that arrives
after that end() re-paused the poll, re-deferring the EOF on kqueue so
'close' never fired (macOS 26 only in CI; darwin-14 passes).

writableEnded is the Duplex flag end() sets before _final(), so gating
readStop on it leaves the poll armed exactly when socketHandle.end()
needs it.
Comment thread src/runtime/server/NodeHTTPResponse.rs
…dy-done predicate

on_buffer_request_body_while_paused sets that flag when the last chunk
arrives via the pause buffer but leaves body_read_state at Pending;
set_on_data and get_has_body already treat the flag as part of the
body-done invariant. do_pause/do_resume now match: the onData re-arm is
gated on Pending && !LAST, and do_resume's drain runs regardless so a
body buffered-while-paused still reaches its caller.
Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/js/node/_http_incoming.ts Outdated
…rt on body completion; route upgrade backpressure through the request's own handle

Three follow-ups from review and the darwin-26 regression:

socketHandle.end()'s us_socket_resume() was written to undo
onNodeHTTPRequest's dispatch-time handle.pause(), which this PR removed.
With the socket never paused, us_socket_resume() early-returns and the
kqueue EVFILT_READ delete/re-add cycle that us_internal_socket_raw_
shutdown relied on never happens, so on macOS 26 a res.socket.end()
mid-upload left the server in FIN_WAIT_2 with the peer's close never
delivered (node-http-halfclose-midupload.test.ts). us_socket_pause()
before the buffered write restores the cycle regardless of dispatch
state; verified 8/8 CLOSED on a macOS 26.4 CI host via dtrace.

onDataIncomingMessage's isLast branch now calls readStart(socket) after
emitEOFIncomingMessage, mirroring Node's parserOnMessageComplete and
Bun's own llhttp path, so a readStop from the last body chunk does not
leave the shared socket's flowing=false for the next keep-alive request.
New test covers two body-bearing POSTs on one connection.

Upgrade-with-body routes backpressure through this[kHandle].pause()
instead of skipping it entirely, restoring the fd-level bound the
dispatch-time handle.pause() used to provide; _read()'s
onIncomingMessageResumeNodeHTTPResponse already balances it.
Comment thread test/js/node/http/node-http-req-socket-pause.test.ts Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Comment thread test/js/node/http/node-http-req-socket-pause.test.ts Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do not add user-facing non-public exports or properties. Put it in an internal/ module if needed.

… tighten keep-alive test

onDataIncomingMessage was exported from node:_http_incoming which is a
public module; move the backpressure-aware version to internal/http.ts
(which already has the symbols it needs) and revert _http_incoming.ts's
copy to its previous shape. _http_server.ts imports it from internal/http.

Add extern "C" void us_socket_pause(us_socket_t*) alongside the existing
us_socket_resume declaration so the file compiles without unified-source
bundling pulling libusockets.h in first.

Keep-alive test: use .once('pause') so the per-request listener does not
leak onto the shared socket, and assert the exact [/a, /b] sequence;
import Agent at module scope instead of inline require().
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Moved onDataIncomingMessage to internal/http.ts in 637dd2c so node:_http_incoming keeps its original public surface. Also applied the three review nits (extern declaration for us_socket_pause, .once + toEqual in the keep-alive test, module-scope Agent import).

Comment thread src/js/internal/http.ts
Comment thread src/js/internal/http.ts
…ad of keeping a divergent copy

_read()'s !handle.ondata fallback now binds the same backpressure-aware
implementation the dispatcher installs, removing the duplicate that had
diverged to the pre-PR shape.

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

Actionable comments posted: 2

🤖 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.

Inline comments:
In `@test/js/node/http/node-http-req-socket-pause.test.ts`:
- Around line 78-80: Remove the explanatory regression rationale comment near
the request socket pause test, leaving only the permitted issue URL comment if
one exists. Preserve the test behavior and move no additional rationale into
code comments.
- Around line 83-108: Update the test around the server callback to capture each
request’s req.socket while recording pauses, then assert the sockets for "/a"
and "/b" are the same object. Keep the existing pause-order assertion and
cleanup, ensuring the test specifically verifies keep-alive agent reuse rather
than allowing a reconnect to satisfy it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9fd3e714-dd3f-45de-bf3a-6d7578395c01

📥 Commits

Reviewing files that changed from the base of the PR and between a315c2d and c34fb04.

📒 Files selected for processing (6)
  • src/js/internal/http.ts
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_server.ts
  • src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
  • src/runtime/server/NodeHTTPResponse.rs
  • test/js/node/http/node-http-req-socket-pause.test.ts

Comment thread test/js/node/http/node-http-req-socket-pause.test.ts Outdated
Comment thread test/js/node/http/node-http-req-socket-pause.test.ts
Without the socket-identity check, a reconnect would let /b emit 'pause'
even if the shared-socket flowing state regressed. Drop the explanatory
comment since the test name already describes the scenario.

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

No new findings after c34fb04 and f996a40 — the duplicate onDataIncomingMessage is gone and the keep-alive test now asserts socket identity. This is still a behavioral change to the native node:http server's request-body flow (lazy pause-at-dispatch → eager push) across JS builtins, Rust uWS callback re-arm guards, and a macOS-specific kqueue pause/resume cycle in C++, so it's worth a human look before merge.

What was reviewed:

  • do_pause/do_resume now gate only the on_data re-arm on body_read_state == Pending && !IS_DATA_BUFFERED_DURING_PAUSE_LAST, matching set_on_data; pause_socket()/buffer drain still run unconditionally.
  • _http_incoming.ts now imports the shared onDataIncomingMessage; _read()'s !handle.ondata fallback binds the same implementation.
  • The us_socket_pause extern-C declaration is present; the keep-alive test uses .once + toEqual and asserts sockets[0] === sockets[1].
  • The pause-buffer completion route (on_buffer_paused_shim delivering last=true) is acknowledged for the pipelining follow-up.
Extended reasoning...

Overview

The PR switches the native node:http server from pausing the body handle at dispatch to installing handle.ondata eagerly, so body bytes flow into the IncomingMessage unconditionally and push() returning false triggers socket.pause() on the connection — matching Node's parserOnBody → readStop model. It touches src/js/internal/http.ts (new shared onDataIncomingMessage), _http_incoming.ts (deletes the divergent copy), _http_server.ts (dispatch change), NodeHTTPResponse.rs (do_pause/do_resume guards so a completed request's resume cannot overwrite a pipelined request's uWS userData), and JSNodeHTTPServerSocketPrototype.cpp (an explicit us_socket_pause/us_socket_resume cycle around the shutdown to keep macOS 26 kqueue delivering the peer close now that dispatch no longer pauses).

Security risks

None identified. This is flow-control/backpressure semantics on the request body Readable; no auth, crypto, or input validation surface changes. The readStop addition bounds req.readableLength (previously unbounded on this path), which is a resource-consumption improvement.

Level of scrutiny

High. This is production-critical code — every node:http server request with a body goes through the changed dispatch path — and the fix required nine review iterations with substantive corrections at each (the body_read_state guard scope, the IS_DATA_BUFFERED_DURING_PAUSE_LAST predicate match, the upgrade-with-body branch, the readStart on isLast, the missing extern declaration, the duplicate helper). The C++ change encodes a macOS-26-specific kqueue EVFILT_READ behavior in a comment; that platform interaction is not directly testable in CI on other platforms.

Other factors

All prior inline findings (mine and CodeRabbit's) are resolved or explicitly deferred. The one open item — the on_buffer_paused_shim completion route not running socket.resume() — was filed as a nit and the author confirmed it overlaps a pre-existing pipelined-POST-body bug already handed off for a separate follow-up; the sequential keep-alive case is covered by the _dump() → _read() fallback and the new keep-alive test. Test coverage is good (three new tests plus Node's upstream test-http-no-read-no-dump.js, and the PR body reports the 376-file test-http-* sweep clean). Given the breadth of interacting subsystems and the iteration history, a maintainer sign-off is appropriate.

@Jarred-Sumner
Jarred-Sumner merged commit 2720297 into main Jul 20, 2026
79 of 80 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/7605e109/http-server-req-socket-pause branch July 20, 2026 21:11
Jarred-Sumner pushed a commit that referenced this pull request Aug 13, 2026
…aused (#37977)

### Problem
- On Windows, a `node:http` server whose handler stops reading the
request body (`req.pause()`, or simply not consuming `req`) keeps
accepting the upload at full speed; the bytes pile up in native memory
until the request is resumed. Same scenario as #26332, which was filed
from Windows: #34740 bounded the JS-side `IncomingMessage` buffer on
every platform, but on Windows that only moved the growth into the
native pause buffer.
- Repro below, 2.5 s after `req.pause()` (loopback upload of 256 KiB
chunks): Windows x64 canary `9a543cc18` has pulled 8195 chunks (2 GB)
and RSS is 2.1 GB and climbing; Linux stays at 12 chunks and 37 MB; Node
v26 on Windows stays at 15 chunks.
- A client that finishes its upload and half-closes while the request is
paused also gets its request aborted on Windows (the body was parked
natively, so `'end'` never fired before the FIN arrived); Node and Bun
on Linux deliver the body and the response.
- Cause: `NodeHTTPResponse::do_pause`
(`src/runtime/server/NodeHTTPResponse.rs`) re-arms uWS `onData` with
`on_buffer_paused_shim`, which appends every chunk to
`buffered_request_body_data_during_pause` with no bound, but the
`self.pause_socket()` call that stops the kernel reads was under
`#[cfg(not(windows))]` (`// TODO: figure out why windows is not emitting
EOF with UV_DISCONNECT`). Every pause path (`req.pause()`, the `push()
=== false` -> `readStop(socket)` path from #34740, `req.socket.pause()`)
ends in `do_pause`, so none of them reached TCP on Windows.

### Fix
- Remove the cfg guard: `do_pause` calls `pause_socket()` on every
platform (and `pause_socket` loses the `#[allow(dead_code)]` that
existed only because it was dead on Windows). No other code changes.
- Why the guard is obsolete: it was added in #18599 (March 2025), which
taught the epoll and kqueue backends to still see a peer FIN/RST on a
socket that is polling for nothing (`EPOLLRDHUP|EPOLLHUP|EPOLLERR`, a
kept `EVFILT_WRITE`) but had no equivalent for the libuv backend. #32488
added that equivalent: `us_poll_start`/`us_poll_change` in
`packages/bun-usockets/src/eventing/libuv.c` always arm `UV_DISCONNECT`,
`poll_cb` probes a paused socket with `MSG_PEEK` to tell a graceful FIN
(deferred until resume) from a reset (closed immediately), and the
shared dispatch in `loop.c` defers EOF for a paused socket until it
resumes. The symptom the TODO names is exactly what #32488 fixed.
- Why a paused `node:http` socket always gets resumed: `do_resume` calls
`resume_socket()` before any flag checks, and `end()`, `writeHeadAndEnd`
and `abort()` resume the socket first as well, so a response ending with
an unread body (`req._dump()` after `res.end()`) or a teardown re-arms
the poll and any deferred FIN is delivered. This is the behavior Linux
and macOS have had all along; this change gives Windows the same one.
- Same primitive, already live on Windows: `Bun.serve` request-body
backpressure (#36006, `RequestContext::pause_request_body_socket`) and
the node:http pipelining flood guard (`pause_socket_reads`) call the
same `uws_res_pause` -> `us_socket_pause` on every platform.
- Verification: `test/js/node/http/node-http-backpressure.test.ts`, new
`request body` group. Each stall test uploads a 32 MiB body into a
request that is paused (explicitly, or implicitly by never being read)
and requires the client's upload to stall short of the total, then
resumes and requires all 32 MiB plus a 200 response; run over both http
and https. A fifth test sends a small body plus FIN while the request is
paused and requires them to be delivered on resume.
- Windows x64, debug build without the fix: the 4 stall tests fail
(`Expected: < 33554432, Received: 33554432`); with the fix the whole
file passes (19/19). The FIN test passes on both and is coverage for the
newly enabled deferred-EOF path, not the fail-before proof.
- Linux, debug build: the file passes before and after (the compiled
code is unchanged there), so the fail-before half of this proof exists
only on Windows.
- The standalone version of the stall scenario, same script under Node
v26.3.0 and Bun on Linux: stalls at 2.75 MB; Bun on Windows with the
fix: 3.25 MB (http), 3 MB (https); without the fix: all 32 MB sent, no
stall.
- Windows x64, all 498 upstream `test-http-*` / `test-https-*` files
from `test/js/node/test/parallel` with the debug build: 497 pass both
before and after. The one failure (`test-http-set-timeout-server.js`, a
1 ms `server.setTimeout` firing twice) is identical before and after and
passes on the release canary.
- Windows x64, `test/js/node/http` directory with the fix: 756 pass, 23
skip, 4 todo, 0 fail.

### Background
- `us_socket_pause` drops the socket's readable interest in the event
backend (epoll/kqueue on POSIX, libuv `uv_poll` on Windows); the kernel
receive buffer then fills, the peer's send window closes, and its writes
block. That is how read-side backpressure reaches a TCP peer.
`us_socket_resume` re-adds the interest.
- The pause contract in usockets: a FIN that arrives while a socket is
paused is not acted on; it is re-discovered and delivered as `on_end`
after the socket resumes. A reset closes the socket right away. The
libuv backend needs extra machinery for this because Windows AFD only
reports a FIN to a poll without read interest through the one-shot
`UV_DISCONNECT` event; that machinery is what #32488 added.
- `on_buffer_paused_shim` / `buffered_request_body_data_during_pause`:
while a node:http request is paused, body chunks that uWS has already
read are parked in this `Vec` and handed to JS as one `Buffer` on
resume. With the socket actually paused it holds at most what was
already in flight (one recv buffer); without the pause it held the rest
of the upload.
- Adjacent: #34761 (pipelined POST bodies) has context lines in this
hunk but keeps the guard; it is a different bug.

<details>
<summary>Repro script and measurements</summary>

```js
import http from "node:http"; import { once } from "node:events";
const got = Promise.withResolvers();
const server = http.createServer(req => { req.on("data", () => {}); req.pause(); got.resolve(req); });
await once(server.listen(0, "127.0.0.1"), "listening");
let pulls = 0; const CHUNK = 256 * 1024;
const body = new ReadableStream({ pull(c) { pulls++; c.enqueue(new Uint8Array(CHUNK)); } }, { highWaterMark: 1 });
const ac = new AbortController();
fetch(`http://127.0.0.1:${server.address().port}/`, { method: "POST", body, duplex: "half", signal: ac.signal }).catch(() => {});
const req = await got.promise;
for (let t = 500; t <= 2500; t += 500) { await new Promise(r => setTimeout(r, 500)); console.log({ ms: t, pulls, readableLength: req.readableLength, rssMB: Math.round(process.memoryUsage().rss / 1048576) }); }
ac.abort(); req.destroy(); server.closeAllConnections(); server.close(); process.exit(0);
```

Windows x64, release canary `1.4.0-canary.1+9a543cc18` (unfixed):

```
{"ms":500,"pulls":1242,"readableLength":262144,"rssMB":368}
{"ms":1000,"pulls":2527,"readableLength":262144,"rssMB":679}
{"ms":1500,"pulls":4099,"readableLength":262144,"rssMB":2098}
{"ms":2000,"pulls":6553,"readableLength":262144,"rssMB":1694}
{"ms":2500,"pulls":8195,"readableLength":262144,"rssMB":2104}
```

Windows x64, debug build of this branch's parent (unfixed):

```
{"ms":500,"pulls":131,"readableLength":262144,"rssMB":215}
{"ms":2500,"pulls":2051,"readableLength":262144,"rssMB":1652}
```

Windows x64, debug build with this change:

```
{"ms":500,"pulls":14,"readableLength":262144,"rssMB":101}
{"ms":2500,"pulls":15,"readableLength":262144,"rssMB":101}
```

Linux, canary `da3851e57` (unchanged by this PR): `pulls` stays at 12,
RSS 37 MB.

</details>
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.

fetch() with ReadableStream request body ignores backpressure on Windows

2 participants