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

Draft
RO
18d ago
+5213-137

Supersedes #44877.

Problem

  • A node:http response emits 'drain' while its connection still holds body bytes by reference. A handler that refills its buffer there sends wrong bytes. Node.js and Bun 1.3.13 do not (script in Notes).
  • Also: a handler that closes at 'drain' cuts the body, and Writable.toWeb(res) can hang.
  • Cause: flushWriteAccountingNT (src/js/node/_http_server.ts:3852 on main, #31587) emits 'drain' without asking the handle, which holds the unsent end of a write by reference since #34511.

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 (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.016777216 bytes, intact
Bun 1.3.1316777216 bytes, intact
Bun 1.4.2, 1.4.3, main (963452075e)16777216 bytes, wrong bytes from offset 16711680
this branch16777216 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):

mainnowNode.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,21616,777,216
pipelined GET /b, answered with pipe(res), behind an 8 MiB GET /ano answeransweredanswered
Writable.toWeb(res) on a response whose connection holds bytes, after writes that all returned truewaits for a 'drain' that nothing oweswriteswrites

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.

mainnowNode.js v26.3.0
'drain' events for 16 MiB written in one turn, listen()2, the first with megabytes unsent1, at writableLength 01, at 0
the same on the tunnel socket of CONNECT or upgrade3, the first with megabytes unsent1, at 01, at 0
'drain' after a false write(), stream over its marknext tick, and again at the 'drain' of the socketat the 'drain' of the socketat the 'drain' of the socket
'drain' of a turn that passed the mark while the stream holds less than its marknext tickwhen the stream is empty, ahead of the callback of the chunk that emptied itthe same
a write() under the mark while that 'drain' is owedtrue, writableNeedDrain falsetrue, writableNeedDrain truethe same
socket.writableNeedDrain while a response waits for a stream under its markfalsetruetrue
callback of write(chunk, cb) on a streamnext tickwhen the stream has written the chunkwhen the stream has written the chunk
a write callback that throws, on a streamuncaught exception from the tick queuethrown where the stream completed the writethe same
write callbacks when the client resets the connection, streamfail 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, streamthat of the last write that waitedthat of the first write that the socket holdsthat 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 emptytrue
held write callbacks when a later write() is accepted, listen()all go to the next tickin order, when the handle is emptywhen their chunk is written
'drain' and the callback of one write() over the mark that the connection takescallback, then 'drain''drain', then the callback, which reads writableLength 0the 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 undefinedreturns what the handle answeredreturns what the socket answered
writableNeedDrainderived from the buffer of the connectiontrue from a false write() to its 'drain'the same flag
writableLength of a response that waits behind another onebytes of the connectionits own bytesits own bytes
'drain' of a queued response whose kept writes returned falseone tick after its turnat its turn, after its bytesat its turn, after its bytes
'drain' after a replay whose writes all returned true100
writableNeedDrain at the 'socket' event after a queued write() returned falsefalsetruetrue
'prefinish' for a queued, ended response of a subclass that overrides assignSocket211
write() to a HEAD or 204 response after the socket diedfalse, callback droppedtrue, callback runstrue, callback runs
highWaterMark of a response on a streamstream defaultthat of the socketthat of the socket
socket writes for a chunked write(chunk, cb) of up to 16 KiB on a stream314 under one cork
socket timeout set, listen(): a response that waits for 'drain'idle from the last request bytethe timer starts over at the first false write() and when the handle is emptyevery 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):

mainthe version of 2026-10-20now
2,000 res.write("", cb), one for each turn0 empty DATA framesthe peer ends the session (over 1,000)0
2,000 awaited writes, every second one empty01,0000
1,200 rounds of small writes to false, close-delimited body01,004, the peer ends the session0
1,200 keep-alive responses with an empty bodythe peer ends the sessionthe samethe 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.

  • The gate is nodejs/node#62936 (v26.1.0) and is the same in v26.12.0.
  • nodejs/node#64038 (v26.5.0) fixed a 'drain' that never came after res.cork() and res.uncork(). The seeded plans accept a missing 'drain' for a false that only the chunks under a cork caused, which is what v26.3.0 does. Bun emits it, and a test requires the 'drain' of a chunked write() over the mark under a cork on both paths.
  • nodejs/node#64991 (v26.8.0) changed _flush(): at the turn of a queued response it emits 'drain' when the socket is under its mark, also while the socket holds bytes. This branch waits until the handle is empty: the native handle can hold the end of a write by reference.
  • nodejs/node#64987 (v26.9.0) coalesces chunked writes. nodejs/node#66781 is an open regression from it (a 'drain' that never comes), with nodejs/node#66782 as the open fix.
  • v26.7.0 runs the end() callback before 'finish'. v26.9.0 returns true from a write() after destroy.
  • On v26.12.0, 58 of the 318 tests fail (36 of them are tests that main has). They pin the v26.3.0 side of those changes.

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, Mnow, MJITno JITNode.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 turn967 [957..973]956 [953..962]-1.1%-10.7%3053
listen(): write(1 KiB, cb) chain, 500,000 writes1853 [1851..1903]2044 [2040..2050]+10.3%-5.1%9030
listen(): write(1 KiB, cb) chain, 2,000,000 writes6523 [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 turn1934 [1932..1934]2147 [1934..2317]+11.0%-3.9%3049
stream: write(1 KiB, cb) chain2587 [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 turn1780 [1779..1834]1777 [1769..1814]-0.2%-3.0%2702
TLS stream: write(1 KiB, cb) chain2367 [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) chain1072 [1008..1212]1116 [1115..1118]+4.1%-10.5%1220
TLS stream: write(16 KiB, cb) chain1221 [1218..1365]1223 [1218..1226]+0.2%-22.5%2285
stream: write(60,000-byte, cb) chain818 [811..837]848 [846..853]+3.7%+11.1%756
TLS stream: write(60,000-byte, cb) chain997 [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 #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 #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 #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 (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 (#43250) and no list: #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 (#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. #44468 and #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 (#43874, #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 #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. #43736 and #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. #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.

  • #44877: merged into this branch with its tests, and closed.
  • #45380 fixes #44925 (a user report, res.download() of Express). It merges cleanly on main and touches 2 lines that this branch also changes. It does not need to wait for this one.
  • #46141 (async context of a write that waits) rewrites allowWritesToContinue() and the 'close' listener of the stand-in. Merged code: allowWritesToContinue() runs settleWhenEmpty in the frame of the first write that waited, write() keeps return refuseWrite(this), the 'close' listener fails the list of the connection and then the callbacks of the response, and the frame of the list goes.
  • #44871 (res._send() as the stored head plus _writeRaw()): its raw writes follow the rule that _send() has here (the stream takes the callback, or the response holds it until settleWhenEmpty).
  • #44347 removes the state that isOwedDrain() reads (kDispatcherDetached): the first half of that function goes with it. #44457 renames that symbol: isOwedDrain() sits next to its declaration so that the two changes conflict in the text.
  • #43250 and #43392 make node:net fail the callbacks of queued writes at a reset. Neither shares a line with this branch.
  • #37712 drops empty DATA frames without END_STREAM. #43753 counts response writes as activity for the socket timeout.
  • Also merge cleanly on main and conflict with this branch: #45047 (the head in writeHead()), #44338 (trailers).
  • Conflict with main already and touch the same functions: #44244, #44419, #35018, #35285, #43736, #45246, #44609, #41341.