Repository navigation
Conversation
…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.
|
Updated 3:05 PM PT - Oct 21st, 2026
❌ @robobun, your commit b0f9b3f has some failures in 🧪 To try this PR locally: bunx bun-pr 46014That installs a local version of the PR into your bun-46014 --bun |
|
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 The report that started this work: give a real |
…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.
…-drain-when-handle-holds-no-bytes
…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.
…-drain-when-handle-holds-no-bytes
…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.
…-drain-when-handle-holds-no-bytes
|
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.
|
Supersedes #44877.
Problem
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).'drain'cuts the body, andWritable.toWeb(res)can hang.flushWriteAccountingNT(src/js/node/_http_server.ts:3852on main, node:http: rewrite the client on net/tls + llhttp (Node http suite: ~55% → 82.3%, +182 vendored tests, client proxy support) #31587) emits'drain'without asking the handle, which holds the unsent end of a write by reference since node:http: hold large res.write() payloads by reference instead of copying into uWS backpressure #34511.Fix
settleWhenEmptyalone emits'drain'and runs held write callbacks: after awrite()returned false, athandle.bufferedAmount0. Node.js v26.3.0 has this gate (http: emit 'drain' on OutgoingMessage only after buffers drain nodejs/node#62936).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'.test/js/node/http/node-http-backpressure.test.ts(235 new tests, 137 fail on main) and three more files.Background
listen(), a JS stand-in on those sockets.bufferedAmountis its unsent bytes.Downsides
'drain'and write callbacks now wait for the client. Code that relied on early ones slows down.listen(), uWS corks a write under 16 KiB outsidebufferedAmount: its signals can still come early.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).16777216 bytes, intact16777216 bytes, intact963452075e)16777216 bytes, wrong bytes from offset 1671168016777216 bytes, intactBehind
net.createServer(socket => server.emit("connection", socket)), Bun 1.4.3 printswrong bytes from offset 4718592: the socket holds every chunk that it has not written. This branch printsintact.The other failures (release build with main's bundled JS, with this branch's, and Node.js v26.3.0):
listen(), 16 MiB in one turn, the handler destroys the socket at the first'drain'GET /b, answered withpipe(res), behind an 8 MiBGET /aWritable.toWeb(res)on a response whose connection holds bytes, after writes that all returned true'drain'that nothing owesThe last two come from the
writableNeedDraingetter of main, which reads the buffer of the connection (_http_server.ts:3910on 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 anet.Socket, 16 MiB written in one turn to a client that reads after 50 ms):Behaviour changes.
listen()is a server that listens itself. "stream" is a socket fromserver.emit('connection')or http2allowHTTP1.'drain'events for 16 MiB written in one turn,listen()writableLength0'drain'after a falsewrite(), stream over its mark'drain'of the socket'drain'of the socket'drain'of the socket'drain'of a turn that passed the mark while the stream holds less than its markwrite()under the mark while that'drain'is owedwritableNeedDrainfalsewritableNeedDraintruesocket.writableNeedDrainwhile a response waits for a stream under its markwrite(chunk, cb)on a stream'close''close'(the version of 2026-10-20 of this branch lost them)'close'duplexPair()side)'close''close'end()in the tick of that writewrite(chunk, cb)that the kernel takes in part,listen()'drain'write()is accepted,listen()'drain'and the callback of onewrite()over the mark that the connection takes'drain''drain', then the callback, which readswritableLength0res._send(data, enc, cb)after a falsewrite()cbruns, the'drain'never comes'drain', thencb'drain', thencbOutgoingMessage.prototype.write.call(res, chunk)undefinedwritableNeedDrainwrite()to its'drain'writableLengthof a response that waits behind another one'drain'of a queued response whose kept writes returned false'drain'after a replay whose writes all returned truewritableNeedDrainat the'socket'event after a queuedwrite()returned false'prefinish'for a queued, ended response of a subclass that overridesassignSocketwrite()to a HEAD or 204 response after the socket diedhighWaterMarkof a response on a streamwrite(chunk, cb)of up to 16 KiB on a streamlisten(): a response that waits for'drain'write()and when the handle is emptyThe 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. Whilehandle.bufferedAmount > 0it 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, clearskNeedDrainand emits'drain'(Node's gate).ServerResponse#_send()returns what the handle answered, so thewrite()ofOutgoingMessagestores the flag only for a false.Write callbacks. On a stream,
write()andres._send()pass the callback to the stand-in, which gives it tosocket.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. Onlisten()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:netdoes not run the callbacks of its queued writes when the peer resets the connection (#43250 and #43392 fix that,src/js/thirdparty/ws.ts:1953keeps 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 atdestroy(), 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:netitself. When it lands, the context of the list can go.The stand-in handle. Its
bufferedAmountissocket.writableLength, and 1 while the socket still owes its own'drain'.write()reports the buffer of the socket against its mark, which is whatsocket.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 atend(), as on main, and forres.write("", cb)behind bytes that the socket holds, as in Node.js.HTTP/1 on an
Http2Streamgiven toserver.emit('connection'), where Bun sends a DATA frame for an empty write (#37712 drops those frames):res.write("", cb), one for each turnThe 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 atserver.timeoutwhere 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._onTimeoutreadswritableLengthof the native response, which has no such property (bufferedAmountuntil #19099). Unchanged.Node.js versions. The baseline is v26.3.0, which
process.versionreports.'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._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.'drain'that never comes), with http: emit 'drain' after flushing coalesced chunks nodejs/node#66782 as the open fix.end()callback before'finish'. v26.9.0 returns true from awrite()after destroy.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_builtinssection, 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 withBUN_JSC_useJIT=0, where a run repeats to 0.3%. "stream" isnet.Servertoemit('connection'). "TLS stream" istls.Servertoemit('connection'), the path of http2allowHTTP1. Node.js v26.3.0 runs the same script.listen(): 1 KiB writes to false, then'drain'listen(): one 1 KiB write per turnlisten():write(1 KiB, cb)chain, 500,000 writeslisten():write(1 KiB, cb)chain, 2,000,000 writeslisten(): 64 KiBpipe(res)listen(): 1 KiB writes to false, then'drain'listen(): 64 KiBpipe(res)'drain'write(1 KiB, cb)chainpipe(res)'drain'write(1 KiB, cb)chainpipe(res)write(16 KiB, cb)chainwrite(16 KiB, cb)chainwrite(60,000-byte, cb)chainwrite(60,000-byte, cb)chainlisten(),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%.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%.'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.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.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).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.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.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 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.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.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.'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).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.node-http-syscall-fault.test.tsand the two timeout tests skip on Windows.Self-review. Three reviews.
writableLength, the tunnel socket, seeded plans, release numbers)._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).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(http: highWaterMark not respected when agent reuses socket with different HWM nodejs/node#64680).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 (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 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 (node:tls: complete write(cb) and end(cb) over a Duplex when the stream completes the ciphertext #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.
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. 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 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.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.TLSSocketthat 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.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.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.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 node:http: ServerResponse keeps Node's need-drain flag, and a queued response counts only its own bytes #44877, for a maintainer to confirm).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 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-treeagainst this branch. No order is proposed here: that is for a maintainer.res.end()after the client disconnects sets_headerbut leavesheadersSentfalse (regression in 1.4.3, breaks Expressres.download()/send) #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.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.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).isOwedDrain()reads (kDispatcherDetached): the first half of that function goes with it. node:http: run a response's 'finish' connection step on the socket the dispatcher detached it from #44457 renames that symbol:isOwedDrain()sits next to its declaration so that the two changes conflict in the text.node:netfail the callbacks of queued writes at a reset. Neither shares a line with this branch.writeHead()), node:http: response trailers travel with the end call of the response #44338 (trailers).