node:http: gate 'drain' on the bytes of the handle, give write callbacks to the stream
Supersedes #44877.
Problem
- A
node:httpresponse 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, andWritable.toWeb(res)can hang. - Cause:
flushWriteAccountingNT(src/js/node/_http_server.ts:3852on main, #31587) emits'drain'without asking the handle, which holds the unsent end of a write by reference since #34511.
Fix
settleWhenEmptyalone emits'drain'and runs held write callbacks: after awrite()returned false, athandle.bufferedAmount0. Node.js v26.3.0 has this gate (nodejs/node#62936).- Sockets from
server.emit('connection')and http2allowHTTP1get 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.bufferedAmountis 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 outsidebufferedAmount: 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.
- 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 afterres.cork()andres.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 chunkedwrite()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 awrite()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, 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 readsbufferedAmountandflagsof 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:writableLengthreached 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,004sendto(main: 60,004). TLS stream, 5,000write(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 throughpipe(res): 1,543 (main: 1,541).listen()rows: equal, exceptpipe(res)with 512sendmsg(main: 515). - Allocations: an accepted native
write()no longer allocates an array (main: 1 for each write). A streamwrite(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, orend()and thendestroy()while the socket holds chunks, on anet.Socket, the tunnel socket of a CONNECT request, a socket of atls.Serverand http2allowHTTP1(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, onlisten(), httpslisten()and a stream (the'drain'cells fail on main). A write callback that throws when the client went away (a subprocess). A socket without awritableNeedDraingetter. 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.tstake 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 ofwrite(),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 atdestroy(), 45 onlisten()and 45 on anet.Socketgiven toemit('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 withres._send()behindwriteHead(), 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 onlisten()and 0 of 2,000 on anet.Socketgiven toemit('connection'). A write callback that reports no error must have its chunk on the wire, also after a teardown. Onlisten()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 emptywrite()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 afteruncork()). 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 itssocket.timeoutguard). - Debug build, after the merge of main (
963452075e): the four changed test files pass withbun 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 vendoredtest-http*,test-stream-pipe*andtest-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.tsand 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
writableNeedDraingetter), the answer ofsocket.write(), the timer, the symbol for the stand-in getter, the false comment insettleWhenEmpty. 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 onlisten(), and_flush()ofClientRequest(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 fourpipe(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 fromemit('connection').node:netfirst (#43250) and no list: #43250 conflicts with main in three files and changes the teardown order of every socket. A shared helper withws.tsfor held callbacks: a question for a maintainer. ATLSSocketover a JS stream and anHttp2Streamas entry points of the test matrix: both have defects of their own with their own PRs (#43877, and the destroy of anHttp2Stream). 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. Atls.Serversocket 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 http2allowHTTP1already 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
bufferedAmountdoes not count until a deferred flush. Onlisten()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 ofres.write(8 KiB, cb)loses the chunk. #44468 and #44871 send the cork only forwriteContinue(),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)andres.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.socketonlisten()reads as empty while megabytes are unsent, and never emits its own'drain'. Main has the same. A separate change is in preparation.- A
TLSSocketthat 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
Http2Streamthat 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 branchrobobun/7f2a07bf/http-queued-response-sync-throwmoves 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)andres.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
ServerResponsewithout a server readswritableNeedDrainfalse after a refusedwrite()(Node.js: true). ItswritableLengthis 0 while its socket holds bytes, so the'drain'of_flush()inassignSocket()can come early. Both are as on main, and keep the answers of main forlight-my-requestandfastify.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 fordetachSocket(). res.cork()withoutuncork(), thenend(), 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()runssettleWhenEmptyin the frame of the first write that waited,write()keepsreturn 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 untilsettleWhenEmpty). - #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:netfail 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.