Skip to content

node:http: ServerResponse keeps Node's need-drain flag, and a queued response counts only its own bytes - #44877

Closed
robobun wants to merge 3 commits into
mainfrom
robobun/c5911590/queued-response-need-drain
Closed

robobun wants to merge 3 commits into
mainfrom
robobun/c5911590/queued-response-need-drain

Conversation

@robobun

@robobun robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http server never answers a pipelined request that streams with pipe() while the response ahead has unsent bytes. Node.js v26.3.0 answers.
  • ServerResponse#writableNeedDrain (src/js/node/_http_server.ts:3832) read the buffer of the connection. A waiting response saw the bytes ahead of it, and pipe() waited for a 'drain' that never came.

Fix

  • write() stores kNeedDrain where it returns false. The getter reads it.
  • One emitter sends each 'drain'. It tests the flag, returns while the drain callback of the handle is armed, then clears the flag and emits.
  • writableLength counts the bytes of the connection only for the response that has the socket.
  • Verified: node-http-backpressure.test.ts (79 new tests, 48 fail on main), all of test/js/node/http, 533 vendored scripts. Self-reviewed: 19 concerns raised, 18 addressed (Notes).

Background

  • pipe() reads writableNeedDrain before its first write. If true, it waits for 'drain'.
  • Node.js keeps one flag per message: a write() that returns false sets it, its 'drain' clears it.
  • A pipelined response is queued until the response ahead is written out.
  • Considered: drop the bufferedAmount term, or answer 0 natively for a queued response. Both keep a derived getter, which reads true after a replay.

Downsides

  • A ServerResponse without a server still reads false after a refused write(). Node.js reads true. Main's light-my-request flows keep their answers.
  • One backlog gets one 'drain' (main: two).
  • Each 'drain' costs one more native getter call. node/_http_server.js grows by 700 bytes.
Notes

Reach. No issue reports this. It needs a client that pipelines (undici with pipelining: 2, a proxy), or megabytes in front of the body that no write() put there (a large head, a raw res.socket.write()), or a server with highWaterMark: 0. The pipelined hang is on 1.3.14, 1.4.0, 1.4.2 and main.

Repro (exit 1 on main, exit 0 on Node.js v26.3.0 and on this branch. The same over TLS: 1, 0, 0):

const net = require("node:net"), http = require("node:http");
const { Readable } = require("node:stream");
const server = http.createServer((req, res) => {
  if (req.url === "/a") return res.end(Buffer.alloc(8 << 20, 1));
  Readable.from([Buffer.alloc(100, 2)]).pipe(res);
});
server.listen(0, "127.0.0.1", () => {
  const socket = net.connect(server.address().port, "127.0.0.1");
  let b = 0;
  socket.on("connect", () => socket.write("GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n"));
  socket.on("data", chunk => {
    for (const byte of chunk) if (byte === 2) b++;
    if (b === 100) process.exit(0);
  });
  setTimeout(() => process.exit(1), 4000);
});

The fresh response to /b, before it writes anything:

writableNeedDrain writableLength
Node.js v26.3.0 false 0
main true above 0 (the bytes of /a)
this branch false 0

The same getter, other entry points. Each one hangs or loses bytes on main, and has a test:

  • a response that has its socket, behind a large head from res.flushHeaders() or a large res.socket.write(), then pipe()
  • a server created with { highWaterMark: 0 }: every fresh response read true
  • a ServerResponse without a server on a socket with highWaterMark: 0: it read true before any write, and pipe() into it never started (test/regression/issue/19111.test.ts)
  • a write guarded on writableNeedDrain at the 'socket' event of a response whose queued write returned false (main read false there)
  • Writable.toWeb(res) after a replay that met native backpressure: main read true with no write() that returned false, and the adapter did not write the chunk

The emit rule.

  • Node.js emits 'drain' on a message when the flag is set and the message has nothing left to send (socketOnDrain).
  • Here three places can find a 'drain' owed: the drain callback of the handle, the end-of-turn count, and the turn of a queued response. All three call emitDrainIfOwed.
  • While the drain callback of the handle is armed, the socket still holds bytes. The emitter returns, and that callback emits later. Without this check a handler that waits with while (res.writableLength > 0) await once(res, "drain") gets its only 'drain' too early and hangs.
  • At the turn of a queued response the 'drain' comes at once when nothing is left to send, like in Node's _flush(). One tick later was too late: a write through Writable.toWeb(res) in that tick saw the flag and lost its chunk.
  • At that turn the cork buffer can take a kept write whole, so the handle reports no backpressure, and the socket can then take only part of it. Main emitted the 'drain' over those bytes, with nothing left to emit a second one. The turn now arms the drain callback of the handle when the handle still holds bytes.
  • res._send() gave its callback to the handle as the drain callback. That replaced the armed callback of an earlier write(), and the 'drain' of that write never came. It now passes the same drain callback as write(), and its own callback waits for the socket. A raw write to a socket that is gone drops its callback, as in Node.js.

Why _flush() is gated. OutgoingMessage#_flush emits 'drain' when kNeedDrain is set and writableLength is 0. assignSocket() ran it before the replay of the kept writes. With the stored flag, a 'drain' listener that ends the response would run ahead of those writes. A response with a handle keeps nothing in outputData, so _flush() has nothing else to do there. The same gate gives a subclass that overrides assignSocket() one 'prefinish' for a response that ended while queued (main: two, Node.js: one).

A response without a server. This is a deliberate difference from Node.js, for a maintainer to confirm.

  • light-my-request and fastify.inject() build a ServerResponse themselves and give it a socket that no server watches. Nothing brings the 'drain' of that socket to the response.
  • Node.js sets the flag there after a refused write(). A later pipe() into the response then never starts.
  • With Node's getter, 5 of 18 light-my-request flows and 7 of 19 fastify.inject() flows that answer on main stop answering, as on Node.js. With the gate on the handle, all 37 flows give the result of main.

Existing test rewritten. but not on a HEAD response and but not on a 204 response computed their precondition as res.writableLength + req.socket.writableLength > 0, with the comment "Bun counts the unsent bytes of the earlier response on this response". That expression pinned the defect. The precondition now reads the writableLength of the earlier response, which is above 0 on main, on this branch and on Node.js.

Measurements. Release-mode builds of main and of this branch, Linux x64. The bytecode table and the bundle size are from the current merge base (5866b5f13d). The other rows are from release binaries of main at 5749c3129 and of this branch before the rebase.

Native getter calls on the response handle (counting accessors):

per main this branch
read of res.writableNeedDrain 2 0
pipe() setup 2 0
write(chunk) 1 1
write(chunk, cb) 2 2
end(chunk) 1 1
read of writableLength on a queued response 1 0
emitted 'drain' 1 2

Bytecode instructions for each edited function (BUN_JSC_dumpGeneratedBytecodes):

function main this branch
ServerResponse.prototype.write 247 272
flushWriteAccountingNT 27 23
allowWritesToContinue 10 10
emitPipelinedDrainNT 10 removed
emitDrainIfOwed 24
bufferPipelinedWrite 90 93
advanceResponsePipeline 253 277
queuePipelinedResponse 28 27
writableLength getter 27 33
writableNeedDrain getter 40 17
ServerResponse.prototype._send 53 83
ServerResponse.prototype.assignSocket 27 31
ServerResponse.prototype.flushHeaders 87 93

Instructions that run, read from the two listings:

  • a write() that returns true: 1 fewer
  • a write() that returns false at the mark: 3 more
  • a queued response with kept writes, at its turn: 9 more
  • end("hello"): +0 per request. No edited function runs.
  • four 16 KB writes and end(): +10 per request, all in the call at the end-of-turn count. The four writes net 0.
  • pipelined depth 8 with end("hello"): +0 per request. No edited function runs.
  • These are static counts. The machine has no hardware counters (perf_event_open returns ENOENT), and perf and valgrind are not installed.
  • Wall-clock, 5 interleaved runs of 20 000 requests: runs of one build differ by 30% or more, so it shows no delta.

Events and values:

main this branch Node.js v26.3.0
'drain' events for one backlog 2 1 1
'drain' events after a replay whose writes all returned true 1 0 0
'prefinish' events for a queued, ended response of a subclass that overrides assignSocket 2 1 1
writableNeedDrain at the 'socket' event after a queued write() returned false false true true
own keys per ServerResponse before writes, after four 16 KB writes, at finish 48, 50, 52 48, 50, 52
own keys added by one read of writableNeedDrain on a response without a server 1 0 0
answered, of a fixed set of 27 pipelining scenarios 4 23 27
outgoingData added by flushHeaders() on a queued response, bytes 0 108 103
light-my-request 6.6.0 own suite, of 147 147 147
bundled node/_http_server.js, bytes 98493 99193

Of the 23 answered scenarios, 18 are equal to Node.js in every recorded fact. Main answers no scenario that this branch does not. The 4 that stay unanswered are in the kept calls of a queued response (see below). test-http-pipeline-flood.js and test-http-pipeline-socket-parser-typeerror.js exit 0.

Tests.

  • node-http-backpressure.test.ts: 79 new tests. 48 fail on main. All 79 pass on Node.js v26.3.0 (cd test && node node_modules/vitest/vitest.mjs run --globals js/node/http/node-http-backpressure.test.ts -t "the response's own|waits for the socket|every byte of a response arrives|but not on a").
  • node-http-syscall-fault.test.ts: 3 new tests under short sends (ASAN builds). Two fail on main. The third one is the only test that reaches the store in write(chunk, callback) for a write that the cork buffer took whole and the socket took in part.
  • test/regression/issue/19111.test.ts: 2 new tests. One fails on main. The other one pins the difference from Node.js for a response without a server.
  • 34 single changes to the new code (remove a store, a test of the flag, the clear, the armed check, a gate, an emit): 32 fail a test. The 2 that do not are !queued.ended and !hitBackpressure at the turn of a queued response. Main has both, and each only saves a tick.
  • One test is bound to a version: after req.socket.destroy(), write() returns false on Node.js 26.3 and true from 26.9 on. The test accepts both.
  • A debug build needs about 2.5 s to start a child process that loads node:http. Up to seven older tests in the two node-http files spawn one and need --timeout above the default 5 s on a debug build, on main too.

Suites. Release builds of this branch and of main, before the rebase onto 5866b5f13d: the 38 files of test/js/node/http (only node-http-backpressure.test.ts differs), 57 more files (test/js/node/http2, test/js/node/stream, test/js/web/streams, express, fastify, body-parser, hono, socket.io, msw, st, four regression tests, of which only 19111.test.ts differs), and 533 vendored test-http-*, test-https-*, test-stream-pipe*, test-stream-pipeline*, test-http2-compat* scripts (all exit 0 on both). Debug build at the current head: the three changed test files, 175 pass with --timeout 30000.

Self-review. 19 concerns, 18 addressed. Not taken: replace five Bun-only matcher calls in older tests of the file, so that the whole file runs on Node.js. Those tests are outside this change, and the command above runs the new ones.

Same class, left out.

Other open PRs on these lines.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http/node-http-syscall-fault.test.ts, test/js/node/http/node-http-backpressure.test.ts

@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status

@github-actions github-actions Bot added the claude label Oct 9, 2026
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 0cc5fa14-4801-413d-a427-0a25596c0181
📥 Commits

Reviewing files that changed from the base of the PR and between 5d5ba6b and b33689e.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http-backpressure.test.ts
  • test/js/node/http/node-http-syscall-fault.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Walkthrough

HTTP response backpressure now uses response-local drain state. Writable properties account for response lifecycle, socket attachment, and buffered output. Pipeline replay preserves owed drain state. Tests cover writes, streams, pipelining, and socket behavior.

Changes

HTTP response backpressure

Layer / File(s) Summary
Response write and drain accounting
src/js/node/_http_server.ts, test/js/node/http/node-http-backpressure.test.ts, test/js/node/http/node-http-syscall-fault.test.ts, test/regression/issue/19111.test.ts
Response writes record when a drain event is owed. writableNeedDrain and writableLength use response and socket state. Tests cover write callbacks, stream APIs, socket backpressure, and writable state.
Queued response replay and socket assignment
src/js/node/_http_server.ts, test/js/node/http/node-http-backpressure.test.ts, test/js/node/http/node-http-syscall-fault.test.ts
Pipeline replay saves and restores response drain state. Queued responses account for header bytes and use replay when assigned a native-backed socket. Tests check queued writes and response ordering.

Suggested reviewers: jarred-sumner, cirospaciari

Priority: ⬆️ High

Merge Risk: ⚪ Minimal · up to b3368

No actionable issue remains from this review; the change is mergeable after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and specifically summarizes the main changes to ServerResponse drain tracking and queued-response byte accounting.
Description check Passed The description explains the problem, fix, scope, known differences, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sec…
Full details: Description check

Explanation

The description explains the problem, fix, scope, known differences, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/_http_server.ts — pre-existing: a pipelined response that buffered writes while queued sends them out of order, or drops them, when a 'socket' listener writes or ends it. advanceResponsePipeline clears kPipelinedQueuedState at src/js/node/_http_server.ts:2893 and emits 'socket' before the ops replay at line 2943. A res.write() from that listener takes the direct handle path at line 3646-3722, so its bytes precede the buffered chunks; a res.end() there makes every replayed write a write-after-end and the chunks are lost. Fix: flush the not-yet-replayed ops before any direct write()/end()/_writeRaw on the response, so order holds from the 'socket' event on. The new test at test/js/node/http/node-http-backpressure.test.ts:1572 asserts only the body length, so it passes with the wrong order. [also at: src/js/node/_http_server.ts:2922 - pre-existing: a pipelined client gets the body bytes of a queued response out of order when the response's 'socket' listener calls res.write(). advanceResponsePipeline clears kPipelinedQueuedState at src/js/node/_http_server.ts:2893 and emits 'socket' (assignSocketInternal / res.assignSocket at…]

    Why this was flagged

    Trigger: a second request pipelined behind an unfinished response; its handler calls res.write(chunk) several times while queued (bufferPipelinedWrite, src/js/node/_http_server.ts:3031) and has res.on('socket', () => res.write('x')) or ends the response in that listener. When the first response finishes, advanceResponsePipeline runs queue.shift() and res[kPipelinedQueuedState] = undefined (lines 2892-2893), then assignSocketInternal emits 'socket' (line 164) before the replay loop at line 2943. Inside the listener, ServerResponse.prototype.write finds kPipelinedQueuedState undefined at line 3646 and writes through handle.cork/handle.write at line 3703-3722, so the wire carries head, 'x', then the buffered chunks, then the tail. With res.end('tail') in the listener instead, finished is set at line 3563 and each replayed write hits hasServerResponseFinished at line 3638 (write-after-end), so the buffered chunks never reach the client. The base branch has the same dequeue-assign-replay order, so this is pre-existing; the added test at line 1572 compares only body.length, not the byte order, so it does not catch it.

    Verification: advanceResponsePipeline runs queue.shift(); res[kPipelinedQueuedState] = undefined; (src/js/node/_http_server.ts:2892-2893), then assignSocketInternal (2913) emits 'socket', and only afterwards replays queued.ops (2943-2956). A res.write("x") from the 'socket' listener sees this[kPipelinedQueuedState] === undefined (3646) and goes straight to handle.write (3702-3726), so its bytes reach the wire before the replayed buffered chunks.

Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the finding about a write() or end() in the 'socket' listener of a queued response: main has the same order, and this PR does not change it. The cause is that a queued response keeps calls where Node.js keeps bytes. The branch robobun/7f2a07bf/http-queued-response-sync-throw moves the kept calls into the handle. The Notes list it under "Same class, left out".

The test at node-http-backpressure.test.ts:1572 asserts the body length on purpose. It pins when the 'drain' comes, and it has to pass before and after the order is fixed.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked whether the new onwritable !== undefined gate in emitDrainIfOwed could swallow the 'drain' that the native drain callback itself emits via allowWritesToContinue — it cannot: on_drain_corked in src/runtime/server/NodeHTTPResponse.rs clears the cached onwritable slot before invoking the callback, so get_on_writable reads undefined at that point and the emit goes through.

Extended reasoning...

The change reworks ServerResponse 'drain'/need-drain bookkeeping in src/js/node/_http_server.ts and adds ~1,200 lines of tests; it touches no security-sensitive surface. Three confirmed findings are posted inline, and this note only records one additional concern that was examined and ruled out from the native handle's onwritable implementation.

Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
…response counts only its own bytes

writableNeedDrain was derived from the buffer of the connection, the armed
drain callback and the per-turn count. A response that waits behind another one
read the bytes of the response ahead, so pipe() waited for a 'drain' that never
came.

write() now stores kNeedDrain where it returns false. One emitter tests the
flag, waits while the drain callback of the handle is armed, clears the flag
and emits 'drain'. The getter reads the flag for a response with a handle.
writableLength counts the unsent bytes of the connection only for the response
that has the socket. _send() no longer replaces the armed drain callback.
… replayed write

At the turn of a queued response, a kept write that the cork buffer took whole
reports no backpressure. When the socket then takes only part of it, the owed
'drain' now waits for the drain callback of the handle.

The destroyed-socket tests pin what write() returns by the Node.js version.
The native write takes nothing for a closed socket and returns 0, so the
callback ran as if the bytes were sent. Node's _writeRaw() drops it.

The destroyed-socket tests state the value of Bun, and apply the Node.js
version switch only under Node.js.
@robobun
robobun force-pushed the robobun/c5911590/queued-response-need-drain branch from b33689e to fdbc86d Compare October 16, 2026 10:56
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:23 AM PT - Oct 16th, 2026

✅ @robobun, your commit fdbc86dc9179b3bf43c6ba6dcb31565125540a1f passed in Build #126613! 🎉


🧪   To try this PR locally:

bunx bun-pr 44877

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

bun-44877 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-reviewed the three new commits and found no bugs; the earlier inline points (the _send callback on a closed handle, the process.versions.node gate in the test, and arming onwritable after a replay that left bytes in the socket) now appear addressed in the code. Because this reworks drain/backpressure ordering across the pipelined, foreign-duplex and bodyless paths of node:http and documents a deliberate divergence from Node in writableNeedDrain, a maintainer look would still be worthwhile.

What was reviewed:

  • Every return false in ServerResponse.prototype.write and bufferPipelinedWrite now sets kNeedDrain; the lone exception (write-after-end via hasServerResponseFinished) matches Node, which also leaves the flag untouched there.
  • The finally restore of kNeedDrain in advanceResponsePipeline: replayed ops go through the handle directly, so the overwrite cannot discard a flag set during the replay.
  • _send now routes the user callback through kPendingCallbacks on backpressure and drops it when the handle is closed; the nextTick callback on a synchronous handler whose bytes are still in the cork buffer was considered and ruled out as a regression (base never called it at all).
  • Removing _flush() from assignSocket when a native handle exists: outputData is empty on that path, and the queued 'drain'/'prefinish' are handled after the replay.
Extended reasoning...

The diff touches src/js/node/_http_server.ts (about 90 lines) plus roughly 1,250 lines of new tests in node-http-backpressure, node-http-syscall-fault and the 19111 regression test. It changes how ServerResponse tracks and emits 'drain' (a per-response kNeedDrain flag, a single emitDrainIfOwed emitter, writableLength scoped to the socket owner, _send no longer clobbering the armed drain callback), with no security-sensitive surface. The bug hunt ran dry with no findings and the inline comments from the two prior runs were addressed by the new commits, but the change is a non-trivial rework of a hot-path node:http module with a documented intentional divergence from Node for serverless responses, which a maintainer should sign off on rather than an automated approval.

@robobun

robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: superseded by #46014. That PR has this branch merged in, with its tests, and adds the byte gate for 'drain' and write callbacks.

@robobun robobun closed this Oct 20, 2026
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