Skip to content

node:http: pause the socket on Windows too when the request body is paused - #37977

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/531e28db/node-http-pause-socket-windows
Aug 13, 2026
Merged

node:http: pause the socket on Windows too when the request body is paused#37977
Jarred-Sumner merged 1 commit into
mainfrom
farm/531e28db/node-http-pause-socket-windows

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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 fetch() with ReadableStream request body ignores backpressure on Windows #26332, which was filed from Windows: node:http: emit 'pause' on req.socket once an unread body fills the IncomingMessage buffer #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 node:http: emit 'pause' on req.socket once an unread body fills the IncomingMessage buffer #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 fix(node:http) fix post regression #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. node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #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 node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #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 (Bun.serve: apply TCP backpressure to a request body the handler reads slowly #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 node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #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: node:http: deliver a pipelined POST's body when the previous response is still in flight #34761 (pipelined POST bodies) has context lines in this hunk but keeps the guard; it is a different bug.
Repro script and measurements
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.

…aused

do_pause re-armed onData to buffer body chunks natively but skipped
pause_socket() under cfg(not(windows)), so on Windows a paused request
kept reading from the kernel at full speed and the body accumulated in
buffered_request_body_data_during_pause until resume. The guard dates
from when the libuv backend could not report a peer FIN on a socket
polling without READABLE; the backend now arms UV_DISCONNECT on every
poll and probes paused sockets, and every terminal path here resumes the
socket first, so the pause is safe on every platform.

Adds request-body backpressure tests (http and https) that upload 32 MiB
into a paused request and require the client to stall, then drain.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 22 minutes

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

How can I continue?

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

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f76395e-bead-4581-aa29-bbc861cb3570

📥 Commits

Reviewing files that changed from the base of the PR and between 8a1cd8d and c1f8c45.

📒 Files selected for processing (2)
  • src/runtime/server/NodeHTTPResponse.rs
  • test/js/node/http/node-http-backpressure.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Aug 12th, 2026

@robobun, your commit c1f8c45 is building: #93789

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: merged as a28fcc4.

Reproduced on Windows x64 with the shipped canary (1.4.0-canary.1+9a543cc18): with the handler holding req.pause(), the client had pushed 2 GB of body in 2.5 s and server RSS reached 2.1 GB; Linux on the same script stays flat at 12 chunks. With this change a Windows debug build stalls the upload at about 3 MB and still delivers the full body on resume.

The stall tests added to test/js/node/http/node-http-backpressure.test.ts fail on a Windows build without the fix and pass with it; on Linux and macOS the compiled code is unchanged by this PR.

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

LGTM — removes a stale #[cfg(not(windows))] guard so do_pause reaches TCP on Windows, matching the behavior Linux/macOS already have.

What was reviewed:

  • Verified the guard's original rationale is obsolete: packages/bun-usockets/src/eventing/libuv.c now arms UV_DISCONNECT unconditionally and poll_cb MSG_PEEK-probes paused sockets, exactly as the PR describes.
  • Confirmed pause_socket_reads (pipelining flood guard) already calls raw.pause() on every platform with no Windows exclusion, and do_resume calls resume_socket() before any flag checks — so paused sockets are always re-armed.
  • Tests use bounded polling (12×25ms) to detect the stall, cover http+https and both explicit/implicit pause paths, and clean up via await using/try-finally.
Extended reasoning...

Overview

The Rust change is two deletions in src/runtime/server/NodeHTTPResponse.rs: the #[cfg(not(windows))] guard around self.pause_socket() in do_pause, and the #[allow(dead_code)] on pause_socket that existed only because the guard made it dead on Windows. Compiled output on Linux/macOS is byte-identical; only Windows changes. The test change adds a request body describe group to test/js/node/http/node-http-backpressure.test.ts with 5 tests (2 stall scenarios × http/https, plus a FIN-while-paused deferred-EOF test).

Security risks

None. This adjusts read-side flow control on the server socket; no auth, crypto, parsing, or trust-boundary changes.

Level of scrutiny

The native change is a stale-TODO removal whose justification is directly verifiable in-tree: I confirmed libuv.c now unconditionally arms UV_DISCONNECT in us_poll_start/us_poll_change and probes paused sockets with MSG_PEEK to distinguish FIN from RST — the exact machinery the TODO was waiting on. I also confirmed the same raw.pause() primitive is already live on Windows via pause_socket_reads (no cfg guard there), and that do_resume unconditionally calls resume_socket() before its flag checks, so a paused socket is always re-armed on resume/end/abort. The risk surface is Windows-only and the PR reports 497/498 upstream test-http* files pass identically before and after.

Other factors

The PR description is unusually thorough: it traces the guard's history, cites the PR that made it obsolete, provides before/after RSS measurements on Windows, and reports fail-before/pass-after on the 4 stall tests. The new tests follow the file's existing conventions (raw net/tls sockets, once for readiness, await using servers, port 0, try/finally socket cleanup), use bounded polling rather than blind sleeps to detect the stall, wire aborted to reject and pre-attach .catch(() => {}) to avoid unhandled rejections, and the 32 MiB payload gives ample margin over the ~3 MB measured stall point. No CODEOWNERS cover these paths and there are no outstanding reviewer comments.

@Jarred-Sumner
Jarred-Sumner merged commit a28fcc4 into main Aug 13, 2026
9 of 24 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/531e28db/node-http-pause-socket-windows branch August 13, 2026 01:37
alii added a commit that referenced this pull request Aug 13, 2026
…37994)

#37977 removed the last #[allow(dead_code)] in NodeHTTPResponse.rs but
left the inventory at 1, so the source-lints job has been failing on
main since a28fcc4. Regenerated with bun
./test/internal/source-lints/dead-code-escapes.test.ts.
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.

2 participants