Skip to content

node:http: gate 'drain' on the bytes of the handle, give write callbacks to the stream - #46014

Draft
robobun wants to merge 24 commits into
mainfrom
robobun/87e3357d/http-drain-when-handle-holds-no-bytes
Draft

robobun wants to merge 24 commits into
mainfrom
robobun/87e3357d/http-drain-when-handle-holds-no-bytes

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #44877.

Problem

Fix

  • settleWhenEmpty alone emits 'drain' and runs held write callbacks: after a write() returned false, at handle.bufferedAmount 0. Node.js v26.3.0 has this gate (http: emit 'drain' on OutgoingMessage only after buffers drain nodejs/node#62936).
  • Sockets from server.emit('connection') and http2 allowHTTP1 get each write callback with its chunk, as in Node.js. What a socket does not run fails at its 'close'.
  • Verified: test/js/node/http/node-http-backpressure.test.ts (235 new tests, 137 fail on main) and three more files.
  • Self-reviewed three times. The last review raised 34 concerns: each is addressed or answered (Notes).

Background

  • A response writes through a handle: native (uWS) on listen(), a JS stand-in on those sockets. bufferedAmount is its unsent bytes.
  • Considered: callbacks kept by the response (they starve on a busy socket), the gate without the stand-in half (wrong bytes stay).

Downsides

  • 'drain' and write callbacks now wait for the client. Code that relied on early ones slows down.
  • On listen(), uWS corks a write under 16 KiB outside bufferedAmount: its signals can still come early.
  • Instructions (no JIT): pipe(res) +1.2% to +3.8%, 60,000-byte stream callbacks up to +11.1%, other rows -2.2% to -28.3% (Notes).
Notes

Wrong bytes. No issue reports this. Bun 1.4.0 was the first release with both #31587 (the 'drain' of a turn) and #34511 (the unsent end of a write kept by reference).

const http = require("node:http");
const SIZE = 16 * 1024 * 1024;
const SLICE = 64 * 1024;
const server = http.createServer((req, res) => {
  const buf = Buffer.alloc(SIZE, "a");
  res.setHeader("Content-Length", SIZE);
  for (let offset = 0; offset < SIZE; offset += SLICE) res.write(buf.subarray(offset, offset + SLICE));
  // 'drain' says that the bytes are out, so the handler uses its buffer again.
  res.once("drain", () => {
    buf.fill("b");
    res.end();
  });
});
server.listen(0, "127.0.0.1", async () => {
  const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
  const body = Buffer.from(await response.arrayBuffer());
  const wrong = body.indexOf("b");
  console.log(`${body.length} bytes, ${wrong < 0 ? "intact" : `wrong bytes from offset ${wrong}`}`);
  server.close();
});
Output (Linux x64)
Node.js v26.3.0, v26.12.0 16777216 bytes, intact
Bun 1.3.13 16777216 bytes, intact
Bun 1.4.2, 1.4.3, main (963452075e) 16777216 bytes, wrong bytes from offset 16711680
this branch 16777216 bytes, intact

Behind net.createServer(socket => server.emit("connection", socket)), Bun 1.4.3 prints wrong bytes from offset 4718592: the socket holds every chunk that it has not written. This branch prints intact.

The other failures (release build with main's bundled JS, with this branch's, and Node.js v26.3.0):

main now Node.js
https listen(), 16 MiB in one turn, the handler destroys the socket at the first 'drain' the client gets 2,752,512 bytes (4,128,768 in another run) 16,777,216 16,777,216
pipelined GET /b, answered with pipe(res), behind an 8 MiB GET /a no answer answered answered
Writable.toWeb(res) on a response whose connection holds bytes, after writes that all returned true waits for a 'drain' that nothing owes writes writes

The last two come from the writableNeedDrain getter of main, which reads the buffer of the connection (_http_server.ts:3910 on main). They need a client that is slower than the source and writes that stay under the mark of a turn.

Repro of the report (server.emit('connection', socket) with a net.Socket, 16 MiB written in one turn to a client that reads after 50 ms):

Node.js v26.3.0 and this branch
  writes: 0 true, 256 false; socket.writableNeedDrain=true
  nextTick
  timer 50ms: client starts to read
  res 'drain' #1 inside the socket's 'drain' (socket.writableLength=0, socket.writableNeedDrain=false)
main
  writes: 0 true, 256 false; socket.writableNeedDrain=true
  res 'drain' #1 OUTSIDE the socket's 'drain' (socket.writableLength=12582912, socket.writableNeedDrain=true)
  nextTick
  timer 50ms: client starts to read
  res 'drain' #2 inside the socket's 'drain' (socket.writableLength=0, socket.writableNeedDrain=false)

Behaviour changes. listen() is a server that listens itself. "stream" is a socket from server.emit('connection') or http2 allowHTTP1.

main now Node.js v26.3.0
'drain' events for 16 MiB written in one turn, listen() 2, the first with megabytes unsent 1, at writableLength 0 1, at 0
the same on the tunnel socket of CONNECT or upgrade 3, the first with megabytes unsent 1, at 0 1, at 0
'drain' after a false write(), stream over its mark next tick, and again at the 'drain' of the socket at the 'drain' of the socket at the 'drain' of the socket
'drain' of a turn that passed the mark while the stream holds less than its mark next tick when the stream is empty, ahead of the callback of the chunk that emptied it the same
a write() under the mark while that 'drain' is owed true, writableNeedDrain false true, writableNeedDrain true the same
socket.writableNeedDrain while a response waits for a stream under its mark false true true
callback of write(chunk, cb) on a stream next tick when the stream has written the chunk when the stream has written the chunk
a write callback that throws, on a stream uncaught exception from the tick queue thrown where the stream completed the write the same
write callbacks when the client resets the connection, stream fail before 'close' fail before 'close' (the version of 2026-10-20 of this branch lost them) fail before 'close'
write callbacks that a destroyed stream forgot (readable-stream@3, a duplexPair() side) fail before 'close' fail before 'close' never run
async context of write callbacks behind a write that waits, stream that of the last write that waited that of the first write that the socket holds that of the first write that waits, or of an end() in the tick of that write
write(chunk, cb) that the kernel takes in part, listen() false, then 'drain' true, callback when the handle is empty true
held write callbacks when a later write() is accepted, listen() all go to the next tick in order, when the handle is empty when their chunk is written
'drain' and the callback of one write() over the mark that the connection takes callback, then 'drain' 'drain', then the callback, which reads writableLength 0 the same
res._send(data, enc, cb) after a false write() cb runs, the 'drain' never comes 'drain', then cb 'drain', then cb
OutgoingMessage.prototype.write.call(res, chunk) returns undefined returns what the handle answered returns what the socket answered
writableNeedDrain derived from the buffer of the connection true from a false write() to its 'drain' the same flag
writableLength of a response that waits behind another one bytes of the connection its own bytes its own bytes
'drain' of a queued response whose kept writes returned false one tick after its turn at its turn, after its bytes at its turn, after its bytes
'drain' after a replay whose writes all returned true 1 0 0
writableNeedDrain at the 'socket' event after a queued write() returned false false true true
'prefinish' for a queued, ended response of a subclass that overrides assignSocket 2 1 1
write() to a HEAD or 204 response after the socket died false, callback dropped true, callback runs true, callback runs
highWaterMark of a response on a stream stream default that of the socket that of the socket
socket writes for a chunked write(chunk, cb) of up to 16 KiB on a stream 3 1 4 under one cork
socket timeout set, listen(): a response that waits for 'drain' idle from the last request byte the timer starts over at the first false write() and when the handle is empty every write and every completion refresh the timer

The rule. Four places can find that something waits: the drain callback of the handle, the job at the end of a turn with writes, the turn of a queued response, and the moment a response gets its socket. All call settleWhenEmpty(res). It returns when nothing waits. While handle.bufferedAmount > 0 it arms the drain callback of a live handle. On a handle that is closed it does nothing: the close path of the connection fails the callbacks of the response that has the socket. Otherwise it runs the held write callbacks, clears kNeedDrain and emits 'drain' (Node's gate). ServerResponse#_send() returns what the handle answered, so the write() of OutgoingMessage stores the flag only for a false.

Write callbacks. On a stream, write() and res._send() pass the callback to the stand-in, which gives it to socket.write() with the chunk (Node). The response keeps a callback itself only behind one that it already holds, for a response that waits for its turn, and for a socket that has ended. On listen() the native handle takes no callback: the response holds it while the handle holds bytes. Otherwise the callback runs in the tick of the job of the turn, behind the 'drain'.

The record of the connection. Bun's node:net does not run the callbacks of its queued writes when the peer resets the connection (#43250 and #43392 fix that, src/js/thirdparty/ws.ts:1953 keeps its own list for the same reason). So the stand-in keeps each callback that it gave to the socket in a list of the connection: an entry runs once, and the 'close' listener of the socket fails what is left, ahead of the callbacks that the response holds and ahead of 'finish'. The list belongs to the connection, so a response that gave its socket away is covered. It is the third place that holds write callbacks (ws.ts, res[kPendingCallbacks], this list). With #43250 merged the list only matters for streams of user code that forget held writes at destroy(), where Node.js never runs the callbacks.

The entries run in the async context of the first of them, which is what Node.js does for writes behind a waiting write. Main ran held callbacks in the context of the last write that waited. #46141 puts the rule of Node.js into node:net itself. When it lands, the context of the list can go.

The stand-in handle. Its bufferedAmount is socket.writableLength, and 1 while the socket still owes its own 'drain'. write() reports the buffer of the socket against its mark, which is what socket.write() answers in Node.js, also for an empty chunk. A 'drain' that waits for a socket under its mark sets the need-drain state of the socket (_writableState.needDrain). That is the state of the socket of Node.js after the cork of a tick refused a write. The socket then emits its own 'drain' when it is empty, ahead of the callback of the chunk that emptied it, and that 'drain' settles the response. No empty chunk is written to find out when a socket is empty. An empty chunk goes to the socket only at end(), as on main, and for res.write("", cb) behind bytes that the socket holds, as in Node.js.

HTTP/1 on an Http2Stream given to server.emit('connection'), where Bun sends a DATA frame for an empty write (#37712 drops those frames):

main the version of 2026-10-20 now
2,000 res.write("", cb), one for each turn 0 empty DATA frames the peer ends the session (over 1,000) 0
2,000 awaited writes, every second one empty 0 1,000 0
1,200 rounds of small writes to false, close-delimited body 0 1,004, the peer ends the session 0
1,200 keep-alive responses with an empty body the peer ends the session the same the same

The last row is the empty write of end() on main. It needs #37712.

The socket timeout. On listen() no response write refreshes the timer of the socket, on main too (#43753). A response that now waits for its client would be cut at server.timeout where main, with its early 'drain', finished. So a response that starts to wait for its 'drain', and one whose bytes the peer took, refresh the timer of a socket that has a timeout. Steady writes that never return false are still cut, as on main: that is #43753. _onTimeout reads writableLength of the native response, which has no such property (bufferedAmount until #19099). Unchanged.

Node.js versions. The baseline is v26.3.0, which process.version reports.

Measurements. One release build (ThinLTO, as CI ships), of the commit before the last three merges of main (so before #46128, which moved the native handle to generated bindings). To fit the section of that binary, both variants carry the acorn source of the REPL as an empty function. No measured script loads it. Main's bundled JS and this branch's go into its .bun_builtins section, so the native code is the same for both. The unit is user instructions of the server process (perf_event_open): the median and [min..max] of 5 interleaved runs with the JIT, and the delta of 3 runs with BUN_JSC_useJIT=0, where a run repeats to 0.3%. "stream" is net.Server to emit('connection'). "TLS stream" is tls.Server to emit('connection'), the path of http2 allowHTTP1. Node.js v26.3.0 runs the same script.

main, M now, M JIT no JIT Node.js, M
listen(): 1 KiB writes to false, then 'drain' 2781 [2773..3056] 2803 [2801..2809] +0.8% -8.6% 11321
listen(): one 1 KiB write per turn 967 [957..973] 956 [953..962] -1.1% -10.7% 3053
listen(): write(1 KiB, cb) chain, 500,000 writes 1853 [1851..1903] 2044 [2040..2050] +10.3% -5.1% 9030
listen(): write(1 KiB, cb) chain, 2,000,000 writes 6523 [6453..6729] 6608 [6411..6608] +1.3% 33726
listen(): 64 KiB pipe(res) 753 [750..755] 772 [769..775] +2.4% +1.9% 1001
https listen(): 1 KiB writes to false, then 'drain' 1963 [1962..1964] 1965 [1963..1966] +0.1% -8.1% 7671
https listen(): 64 KiB pipe(res) 947 [946..950] 945 [937..946] -0.1% +1.2% 2571
stream: 1 KiB writes to false, then 'drain' 5323 [4973..5389] 5372 [4998..5477] +0.9% -2.2% 5895
stream: one 1 KiB write per turn 1934 [1932..1934] 2147 [1934..2317] +11.0% -3.9% 3049
stream: write(1 KiB, cb) chain 2587 [2585..3096] 2551 [2547..2559] -1.4% -22.8% 3913
stream: 64 KiB pipe(res) 1163 [1146..1254] 1230 [1211..1231] +5.8% +3.6% 980
TLS stream: 1 KiB writes to false, then 'drain' 3302 [3278..3318] 3388 [3375..3413] +2.6% -3.1% 4795
TLS stream: one 1 KiB write per turn 1780 [1779..1834] 1777 [1769..1814] -0.2% -3.0% 2702
TLS stream: write(1 KiB, cb) chain 2367 [2366..2370] 2007 [2006..2012] -15.2% -28.3% 4191
TLS stream: 64 KiB pipe(res) 1337 [1334..1338] 1289 [1287..1292] -3.6% +3.8% 2579
stream: write(16 KiB, cb) chain 1072 [1008..1212] 1116 [1115..1118] +4.1% -10.5% 1220
TLS stream: write(16 KiB, cb) chain 1221 [1218..1365] 1223 [1218..1226] +0.2% -22.5% 2285
stream: write(60,000-byte, cb) chain 818 [811..837] 848 [846..853] +3.7% +11.1% 756
TLS stream: write(60,000-byte, cb) chain 997 [997..999] 1021 [1017..1023] +2.3% +7.0% 2147
  • With the JIT, two rows change level from run to run on both binaries: "stream: one 1 KiB write per turn" (about 1,935 or about 2,250: an earlier series of the same main binary gave 1,940 to 2,255) and "stream: 1 KiB writes to false" (about 5,000 or about 5,400). Without the JIT they are -3.9% and -2.2%.
  • listen(), write(chunk, cb) chain: +191 M at 500,000 writes and +85 M at 2,000,000, so most of it is paid once, for the JIT. Two earlier series of the same main binary had medians of 1,901 and 2,146 at 500,000 writes. Without the JIT: -5.1%.
  • 64 KiB pipe(res): +1.2% to +3.8% without the JIT. Each chunk is one refused write and one 'drain', and the gate reads bufferedAmount and flags of the handle at each one. With the JIT: listen() +2.4%, stream +5.8% (the runs of main: 1,146 to 1,254), TLS stream -3.6%.
  • Rows that wait for a stream: the numbers of 'drain' events are equal (main 7,813, now 7,812). On main the early 'drain' let the handler write into a socket that still flushed: writableLength reached 86,599 (stream) and 131,073 (TLS stream) against a mark of 65,536. Now: 65,984 and 66,120.
  • Stream write(chunk, cb): a chunk of up to 16 KiB is one socket write, as in the cork of the native handle: -22.8% and -28.3% for 1 KiB, -10.5% and -22.5% for 16 KiB (no JIT). A chunk over 16 KiB stays three writes, and its callback now passes through the list of the connection: 60,000-byte chunks +11.1% (stream) and +7.0% (TLS stream) without the JIT, +3.7% and +2.3% with it.
  • System calls (ptrace). Stream, 20,000 write(1 KiB, cb): 20,004 sendto (main: 60,004). TLS stream, 5,000 write(1 KiB, cb): 5,006 on both. One write per turn, 2,000 writes: 6,004 (stream) and 4,005 (TLS stream) on both. Stream, 512 chunks of 64 KiB through pipe(res): 1,543 (main: 1,541). listen() rows: equal, except pipe(res) with 512 sendmsg (main: 515).
  • Allocations: an accepted native write() no longer allocates an array (main: 1 for each write). A stream write(chunk, cb) allocates one list entry and one closure, and for a chunk of up to 16 KiB one buffer for the chunk and its framing.
  • Bundled JS (release): node/_http_server.js +2,744 bytes, internal/http1_server_fallback.js +2,677 bytes, internal/http.js +76 bytes.

Tests.

  • node-http-backpressure.test.ts: 318 tests, 235 of them new (79 come from node:http: ServerResponse keeps Node's need-drain flag, and a queued response counts only its own bytes #44877). 137 fail with main's JS. On Node.js v26.3.0 (cd test && node node_modules/vitest/vitest.mjs run --globals js/node/http/node-http-backpressure.test.ts) 311 pass, and the 7 that fail are older tests with Bun-only matchers. Where Node.js and Bun differ on purpose, the test states both results.
  • New in the last rework: a client that goes away with destroy(), a TCP reset, or end() and then destroy() while the socket holds chunks, on a net.Socket, the tunnel socket of a CONNECT request, a socket of a tls.Server and http2 allowHTTP1 (the version of 2026-10-20 fails 9 of the 12). A handler that overwrites its buffer at 'drain' or in the callback of its last write, with the bytes compared, on listen(), https listen() and a stream (the 'drain' cells fail on main). A write callback that throws when the client went away (a subprocess). A socket without a writableNeedDrain getter. The bytes of the single write.
  • node-http-syscall-fault.test.ts (sanitizer builds, short sends in place of a slow client): 17 new tests, 15 fail with main's JS (16 in another run). node-http-server-timeouts.test.ts: 2 new tests on a unix socket. With main's JS the first fails, and the second fails when the client takes the block before the timeout. test/regression/issue/19111.test.ts: 2 new tests from node:http: ServerResponse keeps Node's need-drain flag, and a queued response counts only its own bytes #44877, 1 fails with main's JS.
  • Two older subprocess tests of node-http-syscall-fault.test.ts take 4.4 s to 6.4 s on a debug build here, with main's JS too, against a default limit of 5 s. They keep the workload of main and get a limit of 30 s.
  • Seeded plans (node-http-backpressure-invariants.ts): random sequences of write(), res._send(), end(), cork(), turns, socket releases, writes of another writer, the end or a reset of the peer, and teardowns, checked against six rules that Node.js keeps. The suite runs 100 plans on a stream, 50 on a stream that forgets held writes at destroy(), 45 on listen() and 45 on a net.Socket given to emit('connection') (short sends, with resets of the client). Sweeps: on release builds, this branch breaks a rule in 0 of 1,020,000 plans on a stream and in 0 of 1,020,000 on a stream that forgets held writes. Node.js v26.3.0: 1 of 600,000 and 0 of 600,000. The one plan (seed 22041780) sends a byte with res._send() behind writeHead(), and the client ends its side while the socket holds that write: Node.js then never finishes or closes the socket, and Bun does both. Main: 9,935 of 30,000. The version of 2026-10-20 of this branch, on the forgetful stream: 1,922 of 30,000. On a debug build with short sends: 0 of 2,000 plans on listen() and 0 of 2,000 on a net.Socket given to emit('connection'). A write callback that reports no error must have its chunk on the wire, also after a teardown. On listen() that rule and the wire check after a teardown are off (signalsAfterSend), because of the cork below. A sweep found one regression of this rework that the plans of the suite did not reach (an empty write() while the socket holds its mark returned true). It has a test now.
  • Single changes to the reworked code (remove a clause, a call, a store, swap two statements): 28 tried on this rework. 19 fail a test at once. 6 showed a missing test, and have one now (the mark of the socket, an ended socket, a socket with a mark of 0, a callback that throws at 'close', a response that gave its socket away, the 'drain' that the socket owes after uncork()). 2 showed code that nothing needed, which is removed (a second read of the need-drain state for sockets without the getter, and a method of the stand-in). 1 changes no behaviour (the timer refresh without its socket.timeout guard).
  • Debug build, after the merge of main (963452075e): the four changed test files pass with bun bd test. 78 more test files (test/js/node/http, http2, stream, ws, express, body-parser, hono, msw, st), 5 at a time: 73 pass. 3 pass alone (node-http-proxy-url, node-http-res-settimeout-unref, node-http-uaf). 2 fail the same tests with main's JS (express-memory-leak, express.test.ts). 975 vendored test-http*, test-stream-pipe* and test-tls-* scripts, 6 at a time: 967 exit 0. 4 fail or time out with main's JS too. 4 pass alone.
  • CI ran the files on Linux, macOS and Windows at the previous head. node-http-syscall-fault.test.ts and the two timeout tests skip on Windows.

Self-review. Three reviews.

  • The first one asked for one change with node:http: ServerResponse keeps Node's need-drain flag, and a queued response counts only its own bytes #44877 and raised 15 concerns. All are addressed (one PR, one gate function, the queued response, writableLength, the tunnel socket, seeded plans, release numbers).
  • The second one found hangs that the second version added, and asked for five changes and twelve fixes. All are done (_send() returns the answer of the handle, a response that wrote before it had its socket is settled when it gets the socket, the stream owns the write callbacks, the close path fails what the response holds, a closed handle is not armed).
  • The third one found that the stream half lost write callbacks at a client reset, where main ran them, and raised 34 concerns. Addressed in code: the record of the connection, the async context of Node.js for it, the wait without an empty write (it also removes the empty DATA frames, and the repeated empty writes to a socket without a writableNeedDrain getter), the answer of socket.write(), the timer, the symbol for the stand-in getter, the false comment in settleWhenEmpty. Addressed in tests: the client that goes away on real sockets, the bytes, the seeded plans on a real socket and on a socket that forgets. Addressed here: this text. Handed to separate changes: the native cork on listen(), and _flush() of ClientRequest (http: highWaterMark not respected when agent reuses socket with different HWM nodejs/node#64680).
  • Not taken, with the reason. A job of the turn at every false write(), so that no store of the flag can miss its wake-up: with the JIT it costs 3.5 to 8.1 points on the four pipe(res) rows (listen(): +0.5% without it, +8.6% with it). Each store has its wake-up beside it and a test. The gate without the stream half: a reused buffer still sends wrong bytes on a socket from emit('connection'). node:net first (node:net: cancel the in-flight write when the socket is destroyed #43250) and no list: node:net: cancel the in-flight write when the socket is destroyed #43250 conflicts with main in three files and changes the teardown order of every socket. A shared helper with ws.ts for held callbacks: a question for a maintainer. A TLSSocket over a JS stream and an Http2Stream as entry points of the test matrix: both have defects of their own with their own PRs (node:tls: complete write(cb) and end(cb) over a Duplex when the stream completes the ciphertext #43877, and the destroy of an Http2Stream). A seeded step for a write in the constructor of a response class: the wire rule cannot hold there (two heads on a stream, as on main). A smaller workload for the two older subprocess tests of the fault file: 256-byte sends and two clients moved one from 4.6 s to 4.0 s on a debug build, because about 3.5 s is the start of the subprocess, its TLS setup and its exit. Both keep their workload and get a limit of 30 s. A tls.Server socket as a fifth entry point of the whole matrix: 8 more tests and about 13 s on a debug build, for the socket type that http2 allowHTTP1 already runs. It has the three tests of a client that goes away.

Same class, left out.

  • uWS copies a write under 16 KiB into its cork, which bufferedAmount does not count until a deferred flush. On listen() the 'drain' and the callback of such a write can come before its bytes leave, as on main: with short sends, a handler that destroys the socket in the callback of res.write(8 KiB, cb) loses the chunk. node:http: send a 1xx response when it is written #44468 and node:http: make res._send() the stored head plus _writeRaw(), as in Node.js #44871 send the cork only for writeContinue(), writeInformational() and _writeRaw(). The fix is native (send the cork ahead of the gate, not only count it) and is a separate change in preparation. res._writeRaw(data, enc, cb) and res.writeContinue(cb) are the same.
  • On https listen(), the native drain callback checks the TLS tail only for an ended response. Main has the same. No probe lost a byte there. It is part of the same native change.
  • res.socket on listen() reads as empty while megabytes are unsent, and never emits its own 'drain'. Main has the same. A separate change is in preparation.
  • A TLSSocket that wraps a JS stream completes its writes before the wrapped stream has flushed them (TLSSocket.end(cb)/write(cb) fire before the wrapped stream's backlog is flushed #43874, node:tls: complete write(cb) and end(cb) over a Duplex when the stream completes the ciphertext #43877). A response on such a socket gets its 'drain' and callbacks with that backlog unsent.
  • An Http2Stream that is destroyed reports success to the callbacks of writes that it did not send. A response on such a stream passes that on. Main failed them itself, because the response held them.
  • A write to a socket that has ended (behind the end of the client) is dropped, and its callback still reports success when the socket has finished. Node.js never runs it. Unchanged.
  • res._send() on a queued response still writes ahead of the kept writes. The branch robobun/7f2a07bf/http-queued-response-sync-throw moves the kept calls into the handle.
  • write() return values stay with the count of one turn, like the per-tick cork of Node.js.
  • res.write(chunk, cb) and res.destroy() in one tick: Node.js fails the callback, because its socket still holds the chunk under the cork of the tick. Bun has given the chunk to the connection, and the callback reports no error. A test states both results. Unchanged.
  • A ServerResponse without a server reads writableNeedDrain false after a refused write() (Node.js: true). Its writableLength is 0 while its socket holds bytes, so the 'drain' of _flush() in assignSocket() can come early. Both are as on main, and keep the answers of main for light-my-request and fastify.inject() (from node:http: ServerResponse keeps Node's need-drain flag, and a queued response counts only its own bytes #44877, for a maintainer to confirm).
  • A response class that writes in its constructor: on a stream the wire gets two heads, as on main. The response finishes on both paths, as on main. node:http: run nextTicks and promise jobs where Node's parser does #43736 and node:http: adopt external sockets fed in via server.emit('connection') #35285 change that window.
  • A response that is no longer the message of its socket (detachSocket(), or detached by the dispatcher after a 'close' from its handler) has no close path: its held write callbacks never fail when the connection closes. Main has the same. node:http: remove the dispatch promise so that a 'close' emitted by user code acts as in Node.js #44347 removes the dispatcher case. A separate change is in preparation for detachSocket().
  • res.cork() without uncork(), then end(), on a stream: the response is never sent. Main has the same.

Other open PRs on these lines. Checked with git merge-tree against this branch. No order is proposed here: that is for a maintainer.

…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.
…f its bytes

A write() that returns false sets kNeedDrain. One emitter, emitDrainIfOwed,
sends each 'drain': it waits for the drain callback of the handle while the
handle still holds bytes of the response, and clears the flag when it emits.
The count of the turn, the drain callback and the turn of a queued response
all call it.

The handle of a connection from server.emit('connection') or http2's
allowHTTP1 reports the bytes of its socket from the head of the response on.
A socket under its high water mark emits no 'drain', so the last socket write
of each write() carries a completion callback that wakes the response.

Write callbacks wait for the same signal, and run before 'finish'.
…d write() callbacks in one function

Merges robobun/c5911590/queued-response-need-drain (#44877): the stored
kNeedDrain flag, a queued response that counts only its own bytes, and its
tests.

settleWhenEmpty() now runs what waits for the handle to hold none of the
response's bytes: the write() callbacks and the 'drain' that a write() owes.
It is the drain callback of the handle, the job at the end of a turn with
writes, and the turn of a queued response. write() and res._send() only hold
their callback and schedule that job.

Also in this commit:
- the stand-in handle settles on a socket 'drain' only when the socket is
  empty, and a response gets the high water mark of its socket
- writableLength counts once what the handle still holds of the turn's bytes
- assignSocket() flushes what a response stored before it had a handle, and
  leaves the 'drain' and 'prefinish' of a queued response to its replay
- a write() callback that throws at 'finish' is reported in place
- a tunnel socket no longer emits a second 'drain' from its transport
… holds bytes

A callback of res.write() or res._send() goes to the tick queue when the
handle holds none of the response's bytes and no earlier callback waits.
It joins the held callbacks otherwise, so the callbacks keep the order of
their writes.
…r a 'drain' waits

A write on a socket from server.emit('connection') carries no completion
callback again. When the drain callback of the handle is armed and the
socket is under its high water mark, one empty write calls back behind
the bytes that the socket holds. An ended response keeps only the async
context of the callback that end() arms. The byte gate stays in
settleWhenEmpty(), which arms the handle again while it holds bytes.
…ks that no test needs

The turn of a queued response calls settleWhenEmpty(), which already
returns for an ended response and arms the handle while it holds bytes.
The tunnel tests move next to the other entry points. The seeded plans
no longer check the order of callbacks when the transport completes a
held write inside the turn of the writes, where Node.js runs them out
of order.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 PM PT - Oct 21st, 2026

❌ @robobun, your commit b0f9b3f has some failures in Build #130210 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46014

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

bun-46014 --bun

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. The rework that the third self-review asked for is pushed. Four questions for a maintainer are in a comment below. A fourth self-review of this head runs now.

How it reproduces: run the script under "Wrong bytes" in the description. A handler writes 16 MiB from one buffer in one turn and fills the buffer again at 'drain'. Bun 1.4.2, 1.4.3 and main print wrong bytes from offset 16711680. Node.js v26.3.0, Bun 1.3.13 and this branch print intact.

The report that started this work: give a real net.Socket to server.emit('connection', socket) and write 16 MiB in one turn to a client that does not read yet. On main the response emits 'drain' on the next tick, while the socket still holds 12.5 MB, and a second 'drain' when the socket drains. Node.js v26.3.0 and this branch emit one, from the 'drain' of the socket.

Comment thread src/js/internal/http1_server_fallback.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
…en it gets its socket

- ServerResponse#_send() returns what the handle answered, so the write()
  of OutgoingMessage stores kNeedDrain only for a false.
- A response that wrote before it had its socket is settled when it gets
  the socket, in the dispatcher and in assignSocket().
- On a socket from server.emit('connection') the socket takes the callback
  of write() and res._send() with the chunk. A chunk of up to 16 KiB with a
  callback is one socket write.
- The close path of the stand-in fails the write callbacks that the response
  still holds, ahead of 'finish'.
- A closed handle is not armed. res._send() of a queued response arms
  nothing. A socket that owes its own 'drain' counts as not empty.
- On listen(), the callback of a write that the handle took runs in the
  tick of the job of the turn, behind the 'drain' of that turn.
- Seeded plans: cork(), the end and a reset of the peer, an exact order rule
  and the wire rule for write callbacks.
…e.js

- The test of a Uint16Array chunk with a callback moves to
  node-http-backpressure.test.ts, with the result of Node.js beside it.
- An older test in node-http-syscall-fault.test.ts takes about 5 s on a debug
  build, which is the default limit. It gets a limit of 30 s.
@robobun robobun changed the title node:http: emit 'drain' on a ServerResponse only when its handle holds none of its bytes node:http: gate 'drain' on the bytes of the handle, give write callbacks to the stream Oct 20, 2026
…olds, and the socket owes the 'drain' of a refused turn

A socket from server.emit('connection') or http2 allowHTTP1 gets each
write callback with its chunk. node:net drops the callbacks of its queued
writes when the peer resets the connection, so the connection keeps a
record of them and fails what is left at the 'close' of the socket.

A 'drain' that waits for a socket under its high water mark no longer
costs an empty write: the socket owes its own 'drain', as the socket of
Node.js does under the cork of a tick. write() reports what socket.write()
answered.

On a server that listens, a response that starts to wait for 'drain', and
one whose bytes the peer took, refresh the timer of a socket that has a
timeout.
… write() reports what socket.write() answered

The stand-in no longer counts completions or writes an empty chunk to learn
when a socket under its high water mark is empty: it sets the need-drain
state of the socket, which is where the socket of Node.js is after the cork
of a tick refused a write. The 'drain' of the socket settles the response,
ahead of the callback of the chunk that emptied the socket.

Tests: async context of the callbacks across two bursts, a write under the
mark while a 'drain' is owed, a socket that forgets its held writes after
it has one in flight.
…st its mark, also for an empty chunk

Tests: an empty write() while the socket holds its high water mark, and
the timer tests measure from the refresh to the 'timeout', so that a busy
machine cannot fail them. Two older subprocess tests of the fault file get
a limit of 30 s: on a debug build they sit at the default 5 s.
…nd tests for the mark, an ended socket and a detached response

The stand-in reads the high water mark of its socket once. Its
bufferedAmount reads the need-drain getter of the socket only. The
'close' listener fails the callbacks of the response directly.

The seeded plans wait for the bytes that a real socket still sends
after the end of its peer.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Four questions for a maintainer. They come from the third self-review of this PR, and the answers decide things that a self-review cannot.

  1. Carrier or input. The node:http server had a consolidation pass in node:http: the server follows Node (request body, framing, response finish, lifecycle) #43557. Is this PR the carrier of the 'drain' rule, or do you want it as input to another pass of that kind?
  2. The list of write callbacks. On a socket from server.emit('connection') or http2 allowHTTP1, the connection now keeps the callbacks that it gives to socket.write() and fails what is left at 'close'. The reason is that node:net drops them when the peer resets the connection (src/js/thirdparty/ws.ts:1953 keeps its own list for the same reason). This is the third place that holds write callbacks. Do you want one shared helper, or should the list go when node:net: cancel the in-flight write when the socket is destroyed #43250 or node:net: keep 'end' off a destroyed socket and report the read error behind a write in flight #43392 lands?
  3. The native half. On listen(), bufferedAmount does not count the uWS cork, so the 'drain' and the callback of a write under 16 KiB can still come before the bytes leave. The fix is native: send the cork ahead of the gate. A separate change for it is in preparation. Is that the shape that you want?
  4. Order. node:net: complete a write that waited for the socket in the async context of the write #46141, node:http: make res._send() the stored head plus _writeRaw(), as in Node.js #44871, node:http: headersSent and _header agree when the response ends on a closed socket #45380 and node:http: remove the dispatch promise so that a 'close' emitted by user code acts as in Node.js #44347 change the same lines. The PR body says what the merged code is for each. node:http: headersSent and _header agree when the response ends on a closed socket #45380 fixes a user report (node:http: res.end() after the client disconnects sets _header but leaves headersSent false (regression in 1.4.3, breaks Express res.download() / send) #44925) and does not need to wait. Which order do you want for the others?

This branch has not been deployed

No deployments
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.

node:http: res.end() after the client disconnects sets _header but leaves headersSent false (regression in 1.4.3, breaks Express res.download() / send)

1 participant