Skip to content

node:http2: emit 'close' and end the stream after a write() that follows end() or close() - #43424

Open
robobun wants to merge 38 commits into
mainfrom
robobun/d1b8559a/http2-end-stream-after-late-write
Open

robobun wants to merge 38 commits into
mainfrom
robobun/d1b8559a/http2-end-stream-after-late-write

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • destroyClosedStream() destroys an errored stream when the native side reports the close. An events.errorMonitor listener destroys one whose close came first.
  • The listener sends the END_STREAM that _final owed, or the RST_STREAM that close() left to 'finish'.
  • Native (h2_frame_parser.rs): an empty END_STREAM write with no callback keeps the queued frame's callback. rstStream(id, code, true) sends the queued DATA that flow control allows ahead of the reset.
  • Verified: 53 tests in describe("write() after end()"), test/js/node/http2/node-http2.test.js (36 fail on main). Also test/js/node/http2/, 283 vendored tests.

Background

  • src/js/ahead-of-time/node/http2.ts, the module's second text for bun build --compile --aot, gets the same change.
  • Considered a completion function per write (+3.8 to +4.0 % instructions per 16-byte write) and an end check one tick after end(). Both cost a healthy stream.

Downsides

  • As on main, unlike node: a throwing write callback leaves the stream open. A reset from the late write's callback, or a CANCEL during a read, replaces END_STREAM (Notes).
  • One more listener per stream: +3 to +7 ns (noise floor 12 to 33 ns), +0 heap bytes. Builtin JS: +1,874 bytes.
Notes

This pull is the union of two changes. #43346 held the 'close' half and is closed. This pull targets main and carries both.

Merged with main 98cda0d8b1 (November). main changed this code after the October push (#44833, #46270). What the merge needed:

  • Both sides added bits to enum StreamState. The four bits of main keep 9 to 12. NativeErrored, RequestWaitsForTrailers and WriteHeldUntilReady move to 13 to 15, and the new ResetAtFinish is 16.
  • main removed onEndStreamSettled(), which the error path called. The call is gone: nothing reads the bit it set on an errored stream.
  • src/js/ahead-of-time/node/http2.ts is new on main. It is the second text of the module: the compiler of bun build --compile --aot reads it in place of src/js/node/http2.ts, and test/internal/second-texts-of-modules.test.ts requires the SHA-256 of the first text at its head. It gets every change of this pull. A program compiled with --aot from a script that makes a GET request and then calls req.write() prints error ERR_STREAM_WRITE_AFTER_END, response 200, close with this pull, and no close in 2 s with the two texts of main.
  • The callback of writeStream() is now optional in src/js/private.d.ts. The second text is checked against that declaration (bun src/codegen/check-types-of-modules.ts), and the END_STREAM of an errored stream has no callback (see "The native change").
  • main 89452d2ac1 is merged in too (1532ec4911). It touches none of the files of this pull.
  • close() and _final have new rules on main. They meet the error path in two places: see "close() before the late write" and "NativeErrored and ClosedByNative".

What is a regression. Measured with the release binaries (the events of the stream, or what a plain client gets):

shape bun 1.3.13 and 1.3.14 bun 1.4.4 this PR
server end("done"); write("late") finish error close error error close
server write("done"); end(); write("late") 4 bytes, end hang 4 bytes, end
server write("done"); close(); write("late") 4 bytes, end hang 4 bytes, end
server write(300KB); end(); write("late") hang hang 307200 bytes, end
client req.end("body"); req.write("late") error, no response error response error response close

The first three rows worked before 1.4.0. The last two did not.

Repro, no 'close' (server under test, the client half runs under node): st.respond(); st.end("done"); st.write("late", cb).

node v26.3.0: write-cb:ERR_STREAM_WRITE_AFTER_END error:ERR_STREAM_WRITE_AFTER_END close | destroyed true closed true
bun 1.3.13:   finish write-cb:ERR_STREAM_WRITE_AFTER_END error:ERR_STREAM_WRITE_AFTER_END close | destroyed true closed true
main:         write-cb:ERR_STREAM_WRITE_AFTER_END error:ERR_STREAM_WRITE_AFTER_END | destroyed false closed true
this PR:      write-cb:ERR_STREAM_WRITE_AFTER_END error:ERR_STREAM_WRITE_AFTER_END close | destroyed true closed true

Repro, no END_STREAM (bun file.mjs, compare with node file.mjs):

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", st => { st.on("error", () => {}); st.respond({ ":status": 200 }); st.write("done"); st.end(); st.write("late", () => {}); });
srv.listen(0, "127.0.0.1", () => {
  const c = http2.connect("http://127.0.0.1:" + srv.address().port); const r = c.request({ ":path": "/" });
  let n = 0, ended = false; r.on("data", d => n += d.length); r.on("end", () => ended = true);
  const t = setTimeout(() => { console.log("HANG body", n, "ended", ended); process.exit(1); }, 2000);
  r.on("close", () => { clearTimeout(t); console.log("ok body", n, "ended", ended, "rst", r.rstCode); c.close(); srv.close(); }); r.end();
});

node: ok body 4 ended true rst 0. main: HANG body 4 ended false. This PR: ok body 4 ended true rst 0.

Stream events, END_STREAM already sent (end(chunk) carries the flag). Node v26.3.0 gives the this PR column in every row except the last, where it never emits 'close'.

shape main this PR
server end("done"); write("late") in 'stream' error error close
same, after the request 'end' error error close
client req.end("body"); req.write("late") error response error response close
client request() for a GET, then req.write() (the request ends itself) error response error response close
client, response unread, late write after the response error, slot held error close, slot freed

With maxConcurrentStreams: 2, two late-write requests make a third request pend forever on main. This PR answers it with 200.

Where END_STREAM comes from. The last DATA frame carries END_STREAM when end() comes before its dispatch. If not, the Writable calls _final after the last write completes, and _final sends an empty DATA frame with the flag.

What the client sees, last write in flight at end() (server under test, plain client). late is write("late").

server does node v26.12.1 main this PR
write("done"); end(); late 4 bytes, end hang 4 bytes, end
write(300KB); end(); late 307200, end hang 307200, end
write(300KB); end(); then late on a later turn 307200, end hang 307200, end
write(300KB), later end(); late hang hang 307200, end
write(300KB); write("tail"); end(); late 307204, end 307204, end, no 'close' 307204, end
cork(); write; write; uncork(); end(); late (_writev) 4 bytes, end hang 4 bytes, end
write(""); end(); late 0 bytes, end hang 0 bytes, end
write(300KB, () => destroy()); end(); late 307200, end 307200, end 307200, end
write("done"); end(); late; close(2) rst 2 rst 2 rst 2
write("done"); end(); late; close() 4 bytes, end hang 4 bytes, end
close(2) or destroy(err) from an 'error' listener 4 bytes, end rst 2 4 bytes, end
same, with a 300KB write that is still blocked 65535, rst 2 65535, rst 2 65535, rst 2
close(2) or destroy(err) from the late write's own callback 4 bytes, end rst 2 rst 2
respond({ waitForTrailers: true }), then the first row hang hang hang
respondWithFile(), late write at 'finish' whole file, end whole file, end whole file, end
write("a", () => { throw }), end(), with an uncaughtException handler 1 byte, end hang hang

A client stream has the same bug and the same fix (req.write("body"); req.end(); req.write("late")). The server stream events are write callback error, 'error', 'close' in every row that ends, as in node.

close() before the late write. On main, close() ends a stream that end() did not end and marks it (EndedByClose). _final then sends no END_STREAM for that stream: like node, RST_STREAM ends it, unless the code is CANCEL. The RST_STREAM of a close() with NO_ERROR waits for 'finish'. An errored stream never emits 'finish', so on main the peer gets nothing more and the stream never closes. Node submits the RST_STREAM inside close(). close() now marks that it left the reset to 'finish' (ResetAtFinish), and the error listener sends the reset for a marked stream. The listener sends no END_STREAM for a stream that close() ended or that has a reset code. Frames that the server sends on the stream after HEADERS (raw client, the request has ended, and late is write("late")):

server does node v26.12.1 main this PR
write("done"); close(); late DATA, RST_STREAM(0) DATA, then nothing DATA, RST_STREAM(0)
write(300KB); close(); late, window never reopened 65535 bytes, RST_STREAM(0) 65535 bytes, then nothing 65535 bytes, RST_STREAM(0)
respond({ waitForTrailers: true }), then the first row DATA, RST_STREAM(0) DATA, then nothing DATA, RST_STREAM(0)
the first row, while another stream of the session is blocked DATA, RST_STREAM(0) nothing DATA, RST_STREAM(0)
write("done"); close(2); late DATA, RST_STREAM(2) DATA, RST_STREAM(2) DATA, RST_STREAM(2)
write(300KB); close(8); late 65535 bytes, RST_STREAM(8) 65535 bytes, RST_STREAM(8) 65535 bytes, RST_STREAM(8)
write("done"); close(8); late see "close(NGHTTP2_CANCEL)" DATA, RST_STREAM(8) DATA, RST_STREAM(8)
write("done"); end(); late; close(), the request is still open DATA with END_STREAM, no 'close' DATA, then nothing DATA, END_STREAM, no 'close'
an error on a stream that has not ended (unshift() after 'end'), then close() RST_STREAM(0) nothing, no 'close' RST_STREAM(0)

In the rows that end with RST_STREAM(0) the stream emits 'error' and then 'close', as in node. The rows with NO_ERROR and with code 2 give the same frames from a timer, and for a client request.

  • The row before the last is a gap of node itself. With the writable already ended, its close() waits for 'finish' too, and the stream closes only when the peer ends its side. This pull does the same.
  • The last row is the other order: the error comes first, so its listener has nothing to send, and close() then waits for a 'finish' that cannot come. close() now treats that stream like a close with an error code and sends the reset on the next turn.
  • The reset of the listener goes out when 'error' is emitted, one tick after the late write. Like node, the queued DATA frames of the stream that flow control lets through go out ahead of it: a chunk that waits for the socket, or behind the frames of another stream (rstStream(id, code, true)). A write that flow control still blocks loses its queued part, as in node, which sends only what fits the window.
  • Socket backpressure, measured with a raw client that grants the largest window and reads nothing until close() has run. The server writes 4 MB chunks until the native queue holds frames, then close(); late. 8081725d96 delivered 4,194,304 of 8,388,608 bytes and then RST_STREAM(0): the reset dropped every queued frame, and a node client reads that as a clean end. e105ce1287 delivers all 8,388,608 bytes and then RST_STREAM(0), as main does for the same close() with no late write. Node v26.12.1 delivers its 4,194,304 of 4,194,304. A review comment on 8081725d96 found this.
  • The listener sets ResetScheduled when it sends the reset. If not, a destroy(err) from an 'error' listener sends RST_STREAM(INTERNAL_ERROR) after it, once the native side has forgotten the stream.
  • A request that has no id at close() sends its RST_STREAM at 'ready', as on main, after the write that it holds. The listener leaves that request alone, and the native close that follows now destroys it.
  • Node also emits 'finish' in the trailers row. Bun does not.
  • Node main (v28.0.0-nightly20261109) no longer ends the writable in close(). There the late write of the first row goes out as a second DATA frame ahead of the RST_STREAM, and the stream emits no 'error'.

close(NGHTTP2_CANCEL). After a late write, a stream with a reset code gets its RST_STREAM and no END_STREAM, as on main. e0d81cf734 sent END_STREAM ahead of a CANCEL, because _final does that on main and node did it in the rows that I had measured. Those rows all ran close() in a 'stream' listener. Node v26.12.1 does it only if close() runs while the session reads: Http2Stream::SubmitRstStream (src/node_http2.cc) then puts a CANCEL on a list of pending resets, and END_STREAM goes out first. 8081725d96 takes it out. Frames after HEADERS for write("done"); close(8); late, and for write("done"); end(); late; close(8):

close(8) runs in node v26.12.1 main and this PR e0d81cf734
a 'stream', 'data', 'response' or 'remoteSettings' listener DATA with END_STREAM, then RST_STREAM(8) if the peer's side is open DATA, RST_STREAM(8) DATA, END_STREAM, then RST_STREAM(8) if the peer's side is open
a timer or a 'connect' listener DATA, RST_STREAM(8) DATA, RST_STREAM(8) DATA, END_STREAM, then RST_STREAM(8) if the peer's side is open
  • END_STREAM tells the peer that the body is complete. A cancelled upload must not look complete, so the error path does not add that signal where main does not send it.
  • With no late write, main sends END_STREAM ahead of the CANCEL at every call site (_final), and node only in the first row. This pull does not change _final.
  • Matching node in both rows needs to know in close() whether the session is reading. JS has no such signal today, and _final would need the same rule.

Why the error sends END_STREAM. Three designs were built and run against the rows of the table of the client's view.

  • A completion function on each write (the first version of this pull). It sent END_STREAM when the write completed on an ending, errored stream. Measured on the merge of main and that version: +3.8 to +4.0 % user instructions for a 16-byte write, +2.5 % for a 1 KB write. Every stream paid for a case that few streams hit.
  • Node's end check (kWriteGeneric, lib/internal/http2/core.js), moved from each write to end(): one process.nextTick for each stream that calls end() while a write is in flight. In a run on an earlier base it matched node in every row above, including the last one and the late write's own callback row. It costs a tick on a healthy stream, and it changes when END_STREAM goes out for a blocked last write and on a JS transport.
  • The error sends it (this pull). The errorMonitor listener runs when 'error' is emitted, before the 'error' listeners. A stream that never errors does no new work: end() and _final are the same as on main, and so are _write and _writev for a stream that has its id.

The two rows that the third design leaves as on main:

  • A reset from the late write's own callback. That callback runs one tick before 'error', so its close(code) or destroy(err) comes before the END_STREAM. Node already submitted END_STREAM one tick after the write.
  • A write callback that throws. The Writable then never reaches finishMaybe, so _final never runs, and no 'error' is emitted on the stream. A plain Writable with an asynchronous _write has the same hang in node. Node's http2 stream ends only because of its end check. Covering this case needs that check, which is work on each healthy stream.

The native change. send_data queues an empty END_STREAM write when the stream still has queued frames (a write that flow control blocks). queue_frame then merges the flag into the last queued frame. Before this pull the merge also replaced the callback of that frame and ran the old callback at once, before its bytes were flushed. With the END_STREAM now sent from the error path, a destroy() in that callback cut the body short. The empty write from the error path has no callback, and queue_frame now leaves the callback of the queued frame in place for such a write. The test "destroy() in the callback of the blocked write does not truncate the body" covers it. On a build of this head (e0d81cf734) without the queue_frame change it is the one test of the block that fails (65535 bytes received, 131072 expected). The other 49 pass. _final passes a callback and runs only after the last write completed, so it never reaches the merge with a frame queued.

The native reset. end_stream drops the DATA frames that a stream still has in its queue. For the reset of the listener that lost data under socket backpressure. rstStream() has a third parameter now (sendQueued, declared in h2_frame_parser.bind.ts and src/js/private.d.ts). With it, send_queued_frames first writes the queued frames that the flow control windows allow, with socket backpressure too, and the RST_STREAM follows them. It looks the stream up again for each frame, because a write callback runs JS in between. Only the listener passes the argument. Every other reset is as on main.

What the error path leaves alone. It sends END_STREAM only when the Writable would have called _final: the stream is ending, _final did not run, and nothing is buffered behind the write in flight. Each exception has a test.

  • _final already ran, or a file response took its place (respondWithFile clears _final and the file sends END_STREAM when it is done).
  • A chunk is buffered behind the write in flight. It carries END_STREAM itself when it is dispatched. The check counts chunks (bufferedRequestCount), not bytes: end("") leaves a chunk of length 0 behind a blocked write. With a byte comparison the error path sent END_STREAM too, the peer got two frames with the flag, and a node peer closed the session with a protocol error. The test reads the frames with a raw client.
  • A write is held until 'ready'. A request gets its id one tick before 'ready' dispatches the write it holds (a session over a socket that is already connected, or a request queued behind maxConcurrentStreams). The 'error' of a late write can fall in between. END_STREAM then went out ahead of the body and the native side dropped the body. The held write now sets a status bit (WriteHeldUntilReady), and it carries END_STREAM itself.
  • Trailers are pending, a reset code is set, close() ended the stream (above), or the native side already dropped the stream (below).

The END_STREAM is flushed at once. With frames of another stream queued on the session, send_data queues the empty END_STREAM frame too. A peer that sends nothing more then never gets it. So the error path calls native.flush(), as _final does. The test uses a raw peer that is silent after its second request.

Two orderings of error and native close. The native close is dispatched when END_STREAM is written and the peer has already ended its side.

  • Error first, close second. destroyClosedStream() destroys on the next tick. 'error' is then still queued ahead of that tick, in the middle of its emit (the END_STREAM from the listener closed the stream), or done. In each case the late write callback and the 'error' listeners get a live stream with its session.
  • Close first, error second: the destroy already waits for 'finish' (mid-finish writable) or 'end' (unread data), and neither fires after the error. The errorMonitor listener destroys.
  • A stream that the peer reset with NO_ERROR stays readable on main, like node, and its destroy waits for 'end'. It is the close-first ordering again: NativeErrored marks the stream, and the listener destroys it one tick after 'error'.

Divergence from node v26, on purpose. In the close-first ordering node v26 never destroys the stream, because onStreamClose also waits for 'end'. In node that ordering needs the late write to come at least one event loop turn after the response. In Bun it also happens in the same tick as end(chunk), and the client slot depends on 'close'. So this PR destroys, one tick after 'error'. read() still returns the buffered data, also after the destroy. A for await that starts inside the 'error' listener yields the buffer and then throws the stream's error, as in node. One that starts on a later turn throws at once, where node v26 and main yield the buffer first. A 'data' listener or a pipe() added after the error gets nothing on node, main and this PR.

Node main (v27). nodejs/node#63249 changed how a stream closes. Node v27.0.0-nightly20261018 destroys the stream in the close-first ordering too, and its for await on a later turn then behaves as in this PR. Measured rows (v26.3.0 and v26.12.0 agree with each other):

server does node v26 node v27 nightly this PR
close-first ordering, late write after the response no 'close' 'close' 'close'
write(300KB), later end(); late hang end end
write("done"); end(); late; close(2) rst 2 end rst 2
write("done"); end(); close(2), no late write rst 2 end end
reset from the late write's own callback end end rst 2
write callback that throws end end hang

Nothing new on the wire from the destroy. Both destroy paths run after the native close, so _destroy sends no RST_STREAM. Measured with a TCP proxy that logs every frame: in the four late write flows where end(chunk) carries END_STREAM (client and server, both orders) each direction carries HEADERS, DATA END_STREAM on the stream, the same as the flow without the late write, on node v26.3.0, main and this PR.

A stream that the native side already dropped. A peer RST_STREAM makes the native side drop the stream on the next read. JS destroys the stream one tick later, or at 'end' after a NO_ERROR reset. If the late write's 'error' is emitted in between, writeStream would get an id the native side no longer knows (Invalid stream id). So the streamError and aborted handlers set a status bit in the same dispatch (NativeErrored), and the listener treats that stream like one that is natively closed (NativeClosed, set by onStreamEnd(7)). The tests "peer reset (INTERNAL_ERROR) that drops the native stream before the write completes", its CANCEL twin, and the two server twins drive this. Without the bit their runs report error: Invalid stream id.

NativeErrored and ClosedByNative. main has a bit with a similar name. emitStreamErrorNT sets ClosedByNative immediately before its own destroy(), and _final reads it to tell that destroy from a destroy() of user code. NativeErrored is set one tick earlier, in the native dispatch, and it stays set when emitStreamErrorNT does not destroy (the NO_ERROR reset). One bit cannot do both jobs. Set in the dispatch, ClosedByNative would make _final take a destroy() that user code calls in that tick for the native one, and it would change what _final does when the NO_ERROR stream is destroyed at 'end'.

Costs for a stream with no late write.

  • Per write: none for a stream that has its id. A write on a request that has no id yet sets one status bit and clears it when 'ready' dispatches the write.
  • Per stream: the constructor calls this.on(kErrorMonitor, streamOnErrored). kErrorMonitor is a module constant, the registered symbol events.errorMonitor. Measured on a release build (1.4.3, 367d939d9a): new Duplex() plus the two on() calls the constructor already makes, with and without the third on(), 200,000 streams per round, 21 interleaved rounds, 3 runs: +2.8, +4.8 and +6.9 ns per stream (127 to 136 ns without). The spread between rounds of one variant (IQR) is 12 to 33 ns, so the delta is below the noise floor. 100,000 retained streams use 664 bytes per stream in both variants (heapStats().heapSize).
  • Per client request: one check of options.waitForTrailers. The version with the completion function had the same line and measured no change per request.
  • Per close(): a close() with NO_ERROR on a stream that has an id and has not finished sets one status bit (ResetAtFinish). If the stream was not ending, it reads errored first.
  • Size: the builtin module blob that a release binary embeds (bundle-modules.ts --debug=OFF, InternalModuleRegistryConstants.bin) is 2,787,498 bytes at the merge base (98cda0d8b1) and 2,789,372 bytes with this pull (+1,874). The second text grows by 3,247 bytes of source. The native change is one branch in the merge path for an empty write, and one function that only the reset of the listener calls. I did not build a pair of release binaries to measure it.
  • perf and valgrind are not installed where this pull was built, so there are no instruction counts from me.

Not in this PR: a reset in the same read as the response. A review comment here found that Bun and node differ when HEADERS and RST_STREAM(INTERNAL_ERROR) arrive in one read over a JS Duplex. Node handles the reset inside the read and emits 'response' one tick later:

events of the request
node v26.3.0 aborted, response on a destroyed stream, finish, error:ERR_HTTP2_STREAM_ERROR, close
main and this PR response on a live stream, aborted, error:ERR_HTTP2_STREAM_ERROR, close

Two pushes of this PR tried to match node (7df43d0, 778f206). 9091109 reverts both. Review of them, and of a third version that was never pushed, found that the change reaches much more than this case. A design for it has to meet all of these:

  1. Only on_stream_reset may change its timing. streamError and aborted serve 12 native dispatch sites (teardown sweeps, local resets, six rejections inside native request()). With all of them handled inside the call, session.destroy(err, NO_ERROR) no longer gave a request in flight its 'error', request(headers, { parent: 0 }) returned a destroyed stream, and a server stream got ERR_HTTP2_STREAM_ERROR in place of ERR_HTTP2_SESSION_ERROR after a peer GOAWAY.
  2. Nested reads. Over a synchronous transport (duplexPair, a peer that answers inside the transport's write) a reset is read inside request(), end(), write() or pushStream(). A teardown there destroys the stream before the caller has it: no 'error', rstCode 0, end(cb) gets an error. Node never reads inside a write on the same session. A listener that 'response' attaches must still get the error.
  3. A stream that the peer resets with NO_ERROR has to stay readable, as in node. It is then alive with its native side gone, outside the session's bookkeeping: it keeps its maxConcurrentStreams slot until it is read, no session teardown reaches it, respond() on it writes HEADERS for a reset stream, state and endAfterHeaders throw Invalid stream id, and a write that completes after the next read reaches the native side with a dropped id.
  4. Header events one tick later change the event order on every transport that Bun reads from JS. Over a TLS socket on top of another TLS socket (an HTTPS proxy tunnel) node reads natively and keeps 'response' before 'data', and Bun would not.
  5. A reset stream that waits for trailers must not emit 'wantTrailers'. main emits it for NO_ERROR and CANCEL resets. Node never does.

The third version, with tests for the first, third and fifth point, is on the branch robobun/d1b8559a/backup-scoped-reset. Nothing else tracks this work yet.

Since then main keeps a stream readable after a NO_ERROR reset (#44833), which is the start of point 3. The other points stand, and this pull does not touch them.

Related: #44881. That pull changes how the native side reports a completed write (no callback is kept in a queued frame). Its queue_frame never runs a callback early, so the queue_frame change here is not needed once it lands. The two pulls edit the same lines there.

Also not in this PR.

  • A pushed stream emits 'close' only when the session closes, with or without a late write. node:http2: release server push streams once their response ends #38082 covers that.
  • A late write that runs after 'finish' was already scheduled emits finish error close, before and after this PR. Node emits error close.
  • write("done"); end(); close(2) with no late write is a clean end on main and with this PR. Node v26 sends RST_STREAM(2), node v27 nightly ends cleanly too. node:http2: send RST_STREAM when a server stream is reset #33380 covers that.
  • END_STREAM ahead of a CANCEL: see "close(NGHTTP2_CANCEL)". After a late write Bun never sends it. With no late write Bun always sends it. Node sends it in both cases, and only if close() runs while the session reads.
  • Chunks that the Writable still buffers at close(), behind a write in flight: Bun sends them, and the last one carries END_STREAM, also for close(2) and close(8). Node drops them and sends the RST_STREAM after the first chunk. main and this pull send the same frames here, with a late write (measured for codes 0, 2 and 8) and with none (measured for close()).
  • A stream that waits for trailers (respond({ waitForTrailers: true }), or a request made with waitForTrailers) and a late write: the stream never ends, in node and in Bun.
  • A server stream whose request is still open and is not read: after write(); end(); late the stream stays open until the peer ends or resets it, in node v26.12.1 and in Bun. With no late write both close it with RST_STREAM(NO_ERROR) at 'finish'.
  • close(code) on a request that has no id yet puts its RST_STREAM on the wire twice, and the write that the request holds carries END_STREAM. This is so on main and with this PR, with or without a late write (measured with a raw server).
  • removeAllListeners() with no event name removes the listener of this pull, as it removes the 'pause' and 'resume' listeners that main registers. The stream then behaves as on main.
  • Over a JS transport (duplexPair) the callback of the write in flight can run after 'close'. Node has both orders: after 'close' on a server stream, before it on a client stream.
  • stream.eventNames() now includes the events.errorMonitor symbol, and listenerCount(events.errorMonitor) is 1.

Tests. Removing any one of these makes at least one test of the block fail: the ending check, the finalCalled check, the check for chunks buffered behind the write in flight (also when it compares bytes), the mark for a write held until 'ready' (its check, its set in _write and in _writev, and its clear in both), either trailers clause, the record of waitForTrailers on a request, the reset check (also when it lets a CANCEL pass), the EndedByClose branch, ResetAtFinish (its set in close() and its check), the RST_STREAM of the listener, the sendQueued argument of that reset (two tests), its set of ResetScheduled, the errored clause of close() (also without its !ending part), NativeErrored in the listener, its set in the four handlers, the deferred destroy in the listener and in destroyClosedStream(), the errored check of the listener, the flush of the END_STREAM frame, and the queue_frame change. On main 36 of the 53 tests fail and 17 pass (guards for a reset with an error code or with CANCEL, the trailers, the file response, the blocked write's callback, an error on a stream that has not ended, an 'error' that user code emits, and the dropped native stream).

Review of the merge. A review of the merge, and of the first version of the close() change, raised 26 concerns. 15 led to a change.

  • Three defects in that first version, each with a wrong frame on the wire, are fixed and have a test each. The reset went out for a request that had no id at close(), ahead of the write that the request holds, and then a second time at 'ready'. The reset overtook a chunk that waited behind the frames of another stream. A destroy(err) from an 'error' listener added RST_STREAM(INTERNAL_ERROR) after the RST_STREAM(NO_ERROR).
  • One difference from node that main has too is fixed, because it is the same debt in the other order: an error first, then close().
  • Gaps in the tests are closed: the server twin of the dropped native stream (both codes), the frames of a request with no id, a live stream in the 'error' listener after a NO_ERROR reset, and a second RST_STREAM that the raw helpers could not see.
  • Not changed: the other differences from node that main has without this pull, and the ones that this description lists. The review measured two of them again: a reset from the late write's own callback (Downsides), and a stream that ends where node v26 leaves it open (end() on a later tick than the write in flight).
  • Not changed, and new in this description: removeAllListeners() removes the listener.
  • The review also measured that the reset of close() dropped a queued chunk under socket backpressure. I listed that as a downside at first. A review comment on 8081725d96 rated it as a defect, and e105ce1287 fixes it (see "The native reset").
  • A second review, after the push of e0d81cf734, ran close(NGHTTP2_CANCEL) at call sites that the first one did not cover. It found the END_STREAM ahead of a CANCEL where node and main send none. 8081725d96 removes that END_STREAM (see "close(NGHTTP2_CANCEL)"), and six guard tests hold the frames of main for codes 2 and 8.

Suites on the debug+ASAN build (c32f9d8581, linux x64, with main 89452d2ac1 merged in). test/js/node/http2/, 11 files: 756 pass, 7 skip, 0 fail. The 283 vendored tests pass (test-http2-*, sequential, test-diagnostics-channel-http2-*, test-stream-pipeline-http2, test-worker*-http2-*). serve-http2*, node-http2-ping-flood-staged, fetch-http2-client, fetch-http2-adversarial and six regression files: 473 pass, 0 fail. grpc-js: 323 pass, 5 fail, and the 5 need public DNS. test/internal/second-texts-of-modules.test.ts, test/internal/arms-of-modules-for-compiled-programs.test.ts, bun lint and bun fmt --check pass. rustfmt --check and cargo clippy -p bun_runtime passed on e105ce1287, which has the same sources for this pull. bun src/codegen/check-types-of-modules.ts reports nothing for the files of this pull. It reports 3 errors about engine sources that are not on my machine. The http2 directory was run with a longer per-test timeout. With the default 5 s, 22 runs of node-http2.test.js alone (on the last four heads, with little else on the machine) had 0 to 9 failures each. Three runs had none. All are in tests of main: timeouts in tests that spawn a debug build ("http2 DATA payload survives its ArrayBuffer ...", "should receive goaway"), and "a session whose client is gone ends without an error on its streams, without TLS". Run alone, that test fails in half of its runs with main's http2.ts too (15 of 30, and 16 of 30 with this pull, in alternating runs). With other work on the machine, one test of this pull passed 5 s ("late write during a file response"). It and the socket backpressure test now run serially, and the file is 128 KB.

Not tested by me. macOS, Windows, and the suites under test/aot (CI runs them). The second text was run in one program compiled with --aot (see the merge above).


no test proof · iteration 6 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http2/node-http2.test.js

A write() after end() errors the Duplex without destroying it, because
autoDestroy is off like in node. An errored Duplex never emits 'finish' or
'end'. The streamEnd handlers deferred destroy() to one of those events, so
the stream never emitted 'close'. A client stream also kept its
maxConcurrentStreams slot.

The streamEnd handlers now destroy an errored stream at once. An
events.errorMonitor listener destroys a stream whose native close came before
the error.
… in flight

`stream.write(chunk); stream.end(); stream.write(late)` never sent
END_STREAM, so the peer waited forever. A chunk dispatched before end()
goes out without END_STREAM, and the flag then comes from _final. The
late write errors the stream without destroying it, and the Writable
never calls _final on an errored stream.

The completion of such a write now sends the END_STREAM that _final
would have sent. It does not send it when trailers are pending or when
a reset is pending, so close(code) still reaches the peer as RST_STREAM.
…letion

A peer RST_STREAM makes the native side drop the stream on the next read,
and JS handles the reset a tick later. When both reads happen in one turn
(a TLS socket over a JS Duplex), the completion of the last write runs in
between, and writeStream throws "Invalid stream id". _final has the
Writable's try/catch for this. The completion path now has its own.

Tests: the _writev path, the peer reset, and a check that the write is
still blocked in the later-turn test.
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:17 PM PT - Nov 10th, 2026

❌ @robobun, your commit c32f9d8 has 1 failures in Build #139926 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43424

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

bun-43424 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The base is main, and main 89452d2ac1 is merged in (head c32f9d8581). This pull carries both halves: the 'close' that #43346 held, and the END_STREAM. It also resets an errored stream that close() ended.

Reproduction, no END_STREAM (bun file.mjs, compare with node file.mjs):

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", st => { st.on("error", () => {}); st.respond({ ":status": 200 }); st.write("done"); st.end(); st.write("late", () => {}); });
srv.listen(0, "127.0.0.1", () => {
  const c = http2.connect("http://127.0.0.1:" + srv.address().port); const r = c.request({ ":path": "/" });
  let n = 0, ended = false; r.on("data", d => n += d.length); r.on("end", () => ended = true);
  const t = setTimeout(() => { console.log("HANG body", n, "ended", ended); process.exit(1); }, 2000);
  r.on("close", () => { clearTimeout(t); console.log("ok body", n, "ended", ended, "rst", r.rstCode); c.close(); srv.close(); }); r.end();
});
  • node v26.12.1 and this PR: ok body 4 ended true rst 0
  • bun 1.4.4 and main: HANG body 4 ended false

Reproduction, no 'close': in the server above use st.respond(); st.end("done"); st.write("late", () => {}) and log the events of the server stream. Node v26.12.1 and this PR emit error, then close. bun 1.4.4 and main emit error only.

Reproduction, no RST_STREAM after close(): in the first server use st.write("done"); st.close(); st.write("late", () => {}). Node v26.12.1, bun 1.3.14 and this PR print ok body 4 ended true rst 0. bun 1.4.4 and main print HANG body 4 ended false.

Tests: bun bd test test/js/node/http2/node-http2.test.js -t "write\(\) after end\(\)". On main 36 of the 53 tests fail. The other 17 are guards that pass both ways.

Not covered, as on main (details in the description): a write callback that throws leaves the stream without END_STREAM, and a reset with a code sends no END_STREAM first (from the late write's own callback, or a close(NGHTTP2_CANCEL)).

CI on c32f9d8581 (build 139926): 187 of 188 jobs passed, on every platform. The one red job is test/aot/parallel/allocations-near-a-full-heap/instances-below-a-class-whose-constructor-is-called-are-made-while-the-heap-is-full.ts, an ahead-of-time compiled program that did not exit in 90 s. This pull does not touch it. node-http2.test.js passed on every lane.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline findings, I also checked the _final -> sendEndStream extraction (same writeStream(..., true, callback), flush(), and settled-code-5 bookkeeping as before, so behavior-preserving) and the onStreamWriteDone.bind(this, ...) wrapping, which matches the file's existing .bind(this usage rather than needing a $-intrinsic. The waitForTrailers early return is a deliberate exclusion that matches Node, not a missed sibling site.

Extended reasoning...

Findings were already reported inline, so this body only records what else was examined. The refactor moving the END_STREAM empty-DATA-frame path out of _final into sendEndStream was diffed line by line against the removed block (writeStream with endStream=true, native.flush(), and the settled === 5 handling now in onEndStreamSettled) and is equivalent. The .bind(this, callback) pattern was checked against tamper-resistance conventions: the file already uses .bind(this at 27 sites and there is no $bind intrinsic used anywhere in src/js/, so it follows local convention. The bunHTTP2WaitForTrailers return in the wrapper is documented in the PR as intentional and matches Node, so it is not an unhandled sibling of the fix.

Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
When the request has already ended, the END_STREAM sent at write completion
closes the native stream at once, before the 'error' of the late write is
emitted. destroyClosedStream destroyed the stream right there, so the late
write callback and the 'error' listeners got a destroyed stream with no
session. It now leaves that destroy to streamOnErrored, which runs after
'error', as it already does when the close comes before the error.
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved
Comment thread src/js/node/http2.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-reviewed after the latest push and found no new bugs; the follow-up commit resolves the destroyed-stream-under-listener issue I raised earlier, and because this touches Http2Stream teardown ordering for every natively closed errored stream, a human look is still worthwhile.

What was reviewed:

  • destroyClosedStream now gating on _writableState.errorEmitted: traced that NativeClosed is set before the call on both the server (http2.ts:4105) and client (http2.ts:5106) paths and that streamOnErrored is an always-registered errorMonitor listener, so the deferred destroy cannot be skipped once the queued 'error' fires.
  • onStreamWriteDone guards: finalCalled/length === 0/EndStreamSent|NativeClosed/waitForTrailers/rstCode each map to a test in the new describe block; the bind per write matches this file's existing rstNextTick.bind/abortRequestStream.bind pattern.
  • The bare catch {} around sendEndStream: mirrors the Writable's own try/catch on the _final path for the same "Invalid stream id" throw after a peer RST_STREAM, and is exercised by the raw-peer TLS test.
Extended reasoning...

Overview

The diff touches src/js/node/http2.ts (two new helpers sendEndStream and onStreamWriteDone, _final refactored to share sendEndStream, _write/_writev wrapping their completion callback when the chunk carries no END_STREAM, and a narrowed immediate-destroy in destroyClosedStream) plus ~240 lines of new tests in test/js/node/http2/node-http2.test.js. Since my previous review, one commit (525458a) landed that changes destroyClosedStream to skip the synchronous destroy when the 'error' is still queued, and adds the "server stream, request already ended" test asserting the late write callback and 'error' listener see destroyed === false and a defined session.

Security risks

None specific to this change. The new code path sends an empty DATA frame with END_STREAM on the existing native writeStream API; it does not parse peer data or widen any input surface. The raw-peer test builds HTTP/2 frames by hand but only on the test side.

Level of scrutiny

High. This is stream lifecycle code in a Node-compat module where event ordering relative to state mutation is the contract, and the destroyClosedStream change applies to every errored stream that closes natively, not only the new END_STREAM-at-write-completion path. I traced the deferred-destroy chain: NativeClosed is OR'd into the status before destroyClosedStream on both server and client close handlers; errorOrDestroy with autoDestroy off always schedules or runs emitErrorNT, which sets errorEmitted on both halves before emitting; the errorMonitor listener streamOnErrored is attached unconditionally in the constructor and schedules destroyIfNotDestroyedNT. I did not find a path where errored is set, errorEmitted stays false, and no 'error' emission is pending. The _final refactor into sendEndStream preserves the old behavior (flush, status 5 handling, push-stream close marking). I could not run the test suite in this environment (no debug build present), so the verification is by reading only.

Other factors

The PR carries a substantial test matrix (server/client, _write/_writev, flow-control-blocked, same-tick close(code)/close(), resets from the 'error' listener, peer RST that drops the native stream), and the author's description lists the remaining divergences from Node (JS-transport deferred completion ordering, client waitForTrailers now ending via 'wantTrailers'). The two ruled-out candidates this run were both the bare catch {}; that block mirrors the Writable's own handling of the identical throw on the _final path and is covered by a test, so I did not re-raise it. My earlier non-blocking note about a throwing user write callback preventing the END_STREAM from going out remains as-is and is pre-existing behavior on the _final path too.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

The completion path now sends the empty END_STREAM frame inline, so _final
and onEndStreamSettled are unchanged again. Each added comment is one line.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Beyond the inline test nit, I also checked the new errorEmitted gate in destroyClosedStream (src/js/node/http2.ts:4917) for a stream that never gets destroyed: emitErrorNT sets kErrorEmitted on both the readable and writable state, and streamOnErrored is attached via errorMonitor in the constructor, so a queued 'error' on a NativeClosed stream (writable- or readable-side errored) still ends in the deferred destroy.

Extended reasoning...

The latest push (1aa955c) changed destroyClosedStream to skip the immediate destroy while 'error' is still queued and rely on streamOnErrored instead. The concern to rule out was a stream where errored is set but errorEmitted never becomes true, leaving nothing to destroy it. Traced: errorOrDestroy with autoDestroy: false always schedules emitErrorNT, which sets kErrorEmitted on both _readableState and _writableState before emitting, and streamOnErrored (registered on EventEmitter.errorMonitor at http2.ts:2337, so it fires even with no user 'error' listener) checks NativeClosed, which both state === 7 handlers set before calling destroyClosedStream. The readable-side-errored case is covered by the same emitErrorNT flag write. The onStreamWriteDone path is unchanged from the prior review apart from the guard set already discussed; the remaining inline finding is a test-only nit, so a human look is still worthwhile given this is HTTP/2 stream lifecycle code.

Comment thread test/js/node/http2/node-http2.test.js Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the latest pushes and found no new bugs; the errorEmitted gate in destroyClosedStream and the reject wiring in the peer-reset test cover the points raised earlier. A human look is still worthwhile: END_STREAM now goes out at write completion where Node decides on the tick after dispatch, and the PR lists behavioral divergences (JS-transport ordering, close(2) without a late write) that a maintainer should weigh.

What was reviewed:

  • onStreamWriteDone guards against a second END_STREAM: prefinish sets finalCalled synchronously before _final, and a chunk queued behind the completing one keeps state.length non-zero while isFinalWrite gives it the flag.
  • destroyClosedStream: errored && !errorEmitted only arises with emitErrorNT already queued, and streamOnErrored (errorMonitor, registered in the constructor) destroys on the next tick since NativeClosed is set before the call, so no stream is left undestroyed.
  • Native writeStream with no callback: call_write_callback no-ops on a non-callable value. The bare catch {} swallows any throw from that call, not only "Invalid stream id"; with constant arguments that is the only reachable one.
  • Tests were read, not executed (no debug build in this checkout).
Extended reasoning...

Overview

The diff touches src/js/node/http2.ts (26 lines) and adds ~250 lines of tests to test/js/node/http2/node-http2.test.js. The source change is (1) a new onStreamWriteDone wrapper bound around the Writable callback in _write and _writev for chunks that do not carry END_STREAM, which sends an empty END_STREAM DATA frame when the stream is ending, errored, not destroyed, _final was never called, nothing is queued, no trailers are pending, and no reset code is set; and (2) a change to destroyClosedStream so an errored stream is destroyed immediately only when its 'error' has already been emitted, leaving the queued emission plus streamOnErrored to destroy it afterwards. The four commits since my last review address the two inline findings I posted (destroyed stream observed inside the late write callback / 'error' listener; failure events not wired to reject in the peer-reset test).

Security risks

None identified. The change does not parse peer-supplied data; it only decides when to emit an empty DATA frame with END_STREAM on the local side. The catch {} around native.writeStream swallows a throw that a peer RST_STREAM can trigger (the native stream is dropped before JS observes the reset); the alternative is an uncaught exception reachable by a remote peer, so swallowing here is the safer direction. The arguments to that call are constant, so "Invalid stream id" is the only realistically reachable throw.

Level of scrutiny

Moderate to high. The source change is small but sits in the http2 stream lifecycle state machine, where event ordering (write callback, 'error', 'close'), rstCode, trailers, and native settle states interact. I traced: state.length is decremented synchronously inside onwrite before the wrapper's checks, and clearBuffer dispatches the next queued chunk synchronously so length stays non-zero while a chunk that will carry END_STREAM (via isFinalWrite, since ending is already set) is in flight; prefinish sets kFinalCalled synchronously before invoking _final, so a non-errored stream never reaches the new send; the EndStreamSent | NativeClosed bits and rstCode gate cover the close(code) path; native.writeStream with an undefined callback is safe because call_write_callback returns early on non-callable values. For destroyClosedStream, errorOrDestroy always queues emitErrorNT when it sets errored on a non-autoDestroy stream, and destroy(err) sets destroyed first, so the errored && !errorEmitted window always has a pending emission; streamOnErrored runs on errorMonitor and checks NativeClosed, which is set at the state-7 handler before destroyClosedStream is called. The PR itself documents divergences from Node (decision at write completion versus Node's nextTick-after-dispatch check, JS-transport ordering, close(2) with no late write) that are judgment calls for a maintainer, and the author is a bot account, so a human should confirm those trade-offs.

Other factors

The new tests are added inside the existing write() after end() describe with the shared serve/request/record helpers, wire client errors to reject, and pin the flow-control precondition (blockedLength === big.length) rather than assuming it. The two guard tests are labelled as passing without the fix. The peer-reset test builds raw HTTP/2 frames over a TLS socket through a Duplex proxy and now rejects on error/close from every emitter. I could not execute the tests in this checkout (no debug build present), so the "8 of 11 fail without the fix" claim in the description is unverified here. There are no CODEOWNERS entries covering these paths that I found, and no outstanding third-party objections in the timeline; the github-actions inline threads were resolved by the author after commit 1aa955c, which I read as the response.

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Changes since the first review:

  • 525458a: when the END_STREAM closes the native stream before 'error' is emitted, the stream is destroyed after 'error'. The late write callback and the 'error' listeners get a live stream with its session.
  • 1aa955c: _final is as it was. The completion path sends the frame inline, and each added comment is one line.
  • ab619d8: the peer reset test rejects on every failure event.

All review threads are resolved. One item stays open on purpose: a write callback that throws skips the END_STREAM here, as it skips _final on a stream that is not errored. That is the same on main, and a fix for it has to cover both paths.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun When HEADERS(200) and RST_STREAM(INTERNAL_ERROR) arrive in one read through a JS Duplex transport, Node v26.3.0 queues 'response' with process.nextTick but handles the RST_STREAM synchronously (lib/internal/http2/core.js:459 and :607), so the stream is already destroyed when 'response' runs. The test "peer reset that drops the native stream before the write completes" expects write:ERR_STREAM_WRITE_AFTER_END, error:ERR_STREAM_WRITE_AFTER_END, close and gets finish, error:ERR_HTTP2_STREAM_ERROR, close on Node; please match Node so it passes on both.
Repro: http2.connect(url,{createConnection:()=>duplex}) fed by a raw peer that writes HEADERS(:status 200)+RST_STREAM(2) in one chunk; in 'response' log req.destroyed: Node prints true.

…tick later

When HEADERS and RST_STREAM arrive in one read, node handles the reset
synchronously (onStreamClose) and emits 'response' on the next tick, so the
'response' listeners get a stream that is already destroyed. Bun emitted
'response' inside the read and deferred the reset by a tick.

- The client emits 'response', 'headers', 'push' and 'trailers' with
  process.nextTick, scheduled before the inbound half ends, like node.
- A peer reset tears the stream down inside the dispatch, on client and
  server. For an error code the writable side ends first (node's
  closeStream), so 'finish' comes before 'error' and a later write gets
  ERR_STREAM_WRITE_AFTER_END.
- JS now knows about the reset before the native side can drop the stream,
  so the completion path needs no try/catch for "Invalid stream id".
@robobun robobun changed the title node:http2: send END_STREAM when a write() after end() follows a write in flight node:http2: send END_STREAM after a late write(), handle a peer reset at once like node Sep 22, 2026
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Done in 7df43d0. Bun now matches node v26.3.0 for that read, and the test asserts node's sequence.

What changed:

  • The client emits 'response', 'headers', 'push' and 'trailers' with process.nextTick, scheduled before the inbound half ends, as onSessionHeaders does.
  • A peer reset tears the stream down inside the dispatch, on client and server, as onStreamClose does. For an error code the writable side ends first (closeStream). That gives 'finish' before 'error', and a later write gets ERR_STREAM_WRITE_AFTER_END.

Your repro (a raw peer over a JS Duplex writes HEADERS(200) + RST_STREAM(2) in one chunk, and 'response' does write(); end(); write(late)):

node v26.3.0: aborted, response(destroyed=true), finish, error:ERR_HTTP2_STREAM_ERROR, close, write:ERR_STREAM_WRITE_AFTER_END
main:         response(destroyed=false), write:ERR_STREAM_WRITE_AFTER_END, error:ERR_STREAM_WRITE_AFTER_END, close
this PR:      aborted, response(destroyed=true), finish, error:ERR_HTTP2_STREAM_ERROR, close, write:ERR_STREAM_WRITE_AFTER_END

The test is now "peer reset in the same read as the response headers". It uses that transport and expects finish, error:ERR_HTTP2_STREAM_ERROR, close, req.destroyed === true inside 'response', and ERR_STREAM_WRITE_AFTER_END for the late write.

The reset is the root cause of the race that the old test drove. JS now knows about the reset before the native side can drop the stream, so the try/catch around the END_STREAM write is gone.

One existing expectation changed. The UAF fixture "re-entrant read() from a trailer-value toString" in h2-conformance.test.ts feeds RST_STREAM(NO_ERROR) and then calls client.destroy() in the same turn. The reset closes the stream first now, so its snapshot no longer has req error ERR_HTTP2_STREAM_CANCEL. Node gives an open stream no error on session.destroy() either. The crash assertions of that test are unchanged.

Suites on the debug build: test/js/node/http2/ 592 pass and 0 fail, all 283 vendored node http2 tests pass, grpc-js has only the 6 known failures (5 DNS, tonic).

… the held write

destroyClosedStream queues the destroy of an errored stream in every
case: the 'error' event is then queued, in the middle of its emit, or
done, and the tick comes after it.

Tests: a held _writev, the mark of a held write that 'ready' clears,
and an 'error' that user code emits on a stream that is not errored.
@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

The base is main now, and this pull carries both halves (#43346 is closed). The description is rewritten for the pull as it stands.

The per-write cost is gone. The completion function on each write is removed. The error itself now sends the END_STREAM: the errorMonitor listener that the 'close' half already registers sends the empty END_STREAM frame when the stream ended before the error and _final did not run. A stream that has its id writes through the same _write and _writev code as on main, so the +2.5 to +4 % for small writes does not apply to this version.

What that needed:

  • One native change. An empty END_STREAM write with no callback now keeps the callback of the queued frame that it merges into (queue_frame). Before, the callback of a blocked write ran before its bytes were flushed.
  • Three things the error path has to leave alone. The self-review found the first two as defects on the wire, and each has a test: a chunk buffered behind the write in flight (also an empty one, end("")), a write held until 'ready', and a stream whose _final already ran (file responses included).

Two cases stay as on main and differ from node:

  • A write callback that throws (with an uncaughtException handler) leaves the stream without END_STREAM.
  • A close(code) or destroy(err) from the late write's own callback wins over END_STREAM.

Both have one cause: node submits END_STREAM one tick after the write, whether or not the stream errors later. An end check one tick after end() covers both and matched node in every row that I measured. It costs one process.nextTick for each stream that ends with a write in flight (about 70 to 105 ns on a release build). I did not add that cost to streams that never hit the bug. If you prefer that design, say so and I will switch to it.

#44881 edits the same lines of queue_frame (it removes the callback from queued frames). The pull that lands second needs a small merge there, and the native change here is not needed after it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the redesigned version of this PR (errorMonitor-driven endErroredStream replacing the per-write completion wrapper) and found no bugs; because it changes stream close/END_STREAM ordering across the JS and native layers of node:http2, a human look is still worthwhile.

What was reviewed:

  • endErroredStream guards: traced NativeErrored, RequestWaitsForTrailers and WriteHeldUntilReady to every setter (both sessions' streamError, setupRequestEndAndSignal, the _write/_writev 'ready' wrappers) and to this one consumer; the no-callback empty writeStream is safe on the Rust side (non-callable callback is a no-op in dispatch, and the new early return keeps the queued frame's callback instead of concluding it before its bytes flush).
  • Ordering: errorMonitor runs before 'error' listeners, and a synchronous streamEnd(7) from the END_STREAM write lands in destroyClosedStream's new errored branch, which defers the destroy a tick, so listeners see a live stream; the other three branches are byte-identical to the two blocks they replace.
  • Tests: every wait rejects on 'error'/'close', cleanup is in finally, port: 0, Buffer.alloc for the large body, and the waitForTrailers client/server guards and the peer-reset-drops-native-stream case are now covered.
Extended reasoning...

The change touches src/js/node/http2.ts (new StreamState bits, an errorMonitor listener per stream, endErroredStream, the destroyClosedStream extraction shared by ServerHttp2Session and ClientHttp2Session), a four-line early return in Stream::queue_frame in src/runtime/api/bun/h2_frame_parser.rs, and about 640 lines of new tests. It touches no security-sensitive surface (no auth, crypto, or untrusted-input parsing beyond existing frame handling). It is not a simple or mechanical change: it alters when 'close' fires and when END_STREAM goes on the wire for errored streams, deliberately diverges from node in the native-close-first ordering, and the review history shows several rounds of ordering bugs found and fixed, with the earlier threads resolved by the author. The bug hunt ran dry with no findings and my own trace found nothing, which is why this is a defer rather than a finding, but the subtlety of the lifecycle semantics is why it is not an approve.

@robobun
robobun requested a review from cirospaciari October 18, 2026 12:14
…behind another stream's queue

With frames of another stream queued on the session, send_data queues
the empty END_STREAM frame too. The peer in the test sends nothing more,
so only the flush in the error path gets the frame out.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread src/js/node/http2.ts Resolved
The Http2Stream constructor read EventEmitter.errorMonitor for each
stream. That property of the events export is writable, so code that
replaced it turned off the listener for every later stream. The
constant is the registered symbol that events.ts uses.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

…2-end-stream-after-late-write

# Conflicts:
#	src/js/node/http2.ts
…the second text for --aot

endErroredStream() called onEndStreamSettled(), which main no longer has.
It now calls markWritableDone(), as _final does for a stream that settles
to HALF_CLOSED_LOCAL.

src/js/ahead-of-time/node/http2.ts gets the same change as
src/js/node/http2.ts, and the hash of the new first text.

The callback of writeStream() is optional in $Node.H2FrameParser: the
END_STREAM write of an errored stream passes none, so that a queued frame
keeps its own.
@robobun
robobun requested a review from alii as a code owner November 10, 2026 14:14
@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

I merged main (98cda0d8b1) into this branch. It merges cleanly again, also with main as of 89452d2ac1. The head is 1549ce6126.

What the merge needed

  • enum StreamState: main took bits 9 to 12. NativeErrored, RequestWaitsForTrailers and WriteHeldUntilReady of this branch are bits 13 to 15 now. This was the only text conflict.
  • endErroredStream() called onEndStreamSettled(), which Hardening follow-up: fix regressions from #46021 and more crashes #46270 removed from main. The text merge did not show it. Each late write with a write in flight threw ReferenceError: onEndStreamSettled is not defined, and 8 of the 29 tests of the write() after end() block failed. It calls markWritableDone() now, as _final does on main.
  • Main has a second text of this module for bun build --compile --aot: src/js/ahead-of-time/node/http2.ts. It has the same change now, and the hash of the new first text, which test/internal/second-texts-of-modules.test.ts checks. The typed text needs the callback of writeStream() to be optional in src/js/private.d.ts.

Verified on a debug build of 1549ce6126

  • The write() after end() block of node-http2.test.js: 29 of 29 pass. With main's http2.ts in place, 21 fail and 8 pass.
  • test/js/node/http2/: 728 pass, 7 skip, 4 fail. The 4 are 5 s timeouts in "http2 DATA payload survives its ArrayBuffer ...". Those tests spawn a debug build. That block alone times out 1 to 6 times per run here with main's http2.ts too, so I do not count them against this change.
  • The 283 vendored test-http2-* and related node tests pass.
  • second-texts-of-modules.test.ts, arms-of-modules-for-compiled-programs.test.ts, the type check of the modules (bun run build:types, then bun src/codegen/check-types-of-modules.ts), oxlint and bun fmt --check pass.
  • A client write() on a GET request right after connect(): error ERR_STREAM_WRITE_AFTER_END, response 200, close, 6 of 6 runs, like node v26.3.0. An executable built with --compile --aot prints the same.
  • Server streams with a late write after end(chunk), after write(); end(), and after a 300 KB write blocked on flow control: the same output as node, as a script and as an --aot executable.
  • write(); close(); write(late), write(); close(2); write(late) and write(); end(); write(late); close(): the peer and the server stream see the same as with node.

Not verified here: macOS, Windows, and the tests under test/aot.

One point for review. On main, _final sends no END_STREAM for a stream that close() ended (EndedByClose), because the RST_STREAM ends it. endErroredStream() does not read that bit. It only stops on a set rstCode. The three close() shapes above match node, so I did not change it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread src/js/node/http2.ts Resolved
close() leaves the RST_STREAM of a NO_ERROR close to 'finish'. A write
after close() errors the stream, and an errored stream never emits
'finish'. The peer got nothing more and the stream never closed. Node
sends the RST_STREAM inside close().

close() now marks that it left the reset to 'finish' (ResetAtFinish).
The 'error' listener of the stream sends the reset for a marked stream.
It flushes first, so that a chunk that waits behind the frames of
another stream goes out ahead of the reset. It sets ResetScheduled, so
that _destroy() sends no second RST_STREAM. A request that had no id at
close() still sends its reset at 'ready'.

A stream that is already errored when close() ends it gets its reset on
the next turn, like a close() with an error code.

Like _final, the listener sends END_STREAM for a stream that close()
ended only ahead of a CANCEL. A reset with another error code still
takes the place of END_STREAM.

A stream that the peer reset with NO_ERROR stays readable and waits for
'end' before it destroys itself. The listener now destroys that stream
too. NativeErrored marks it, and the aborted handlers set the mark as
the streamError handlers do.

endErroredStream() no longer calls markWritableDone(). Nothing reads
that bit on an errored stream.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the current push (through e0d81cf) and found no bugs; given how many interacting lifecycle paths this touches, a maintainer look is still worthwhile.

What was reviewed: the new ResetAtFinish path — close() sets it only on a non-pending stream, streamOnErrored sets ResetScheduled before rstStream so _destroy sends no second RST; endErroredStream's guards against the Writable (end() on an already-errored stream never sets ending, so the !state.ending early return is correct) and against _final's EndedByClose/CANCEL rule; the h2_frame_parser early return, whose only no-callback caller is the new empty END_STREAM write; the ahead-of-time mirror, whose hunks match and whose declared SHA-256 equals the current src/js/node/http2.ts. Candidate ruled out: close() with no code after the error has already been emitted waits on 'finish' — pre-existing on main, and END_STREAM already went out by then. I did not run the test suite.

Extended reasoning...

The change touches node:http2 stream lifecycle in src/js/node/http2.ts and its --aot mirror (four new StreamState bits, an errorMonitor listener that sends END_STREAM or the deferred RST_STREAM for an errored stream, a shared destroyClosedStream), a one-line early return in h2_frame_parser.rs's queue_frame, an optional callback in private.d.ts, and about 1250 lines of tests that wire failures to reject and clean up in finally. It touches no auth, crypto or input-parsing surface. It is not approved because the logic spans many interleaved paths (native close before or after the error, pending streams, close() with and without a code, client and server sessions) across 14 pushes, a maintainer already engaged on the thread, and the tests were not executed in this run.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun robobun changed the title node:http2: emit 'close' and send END_STREAM after a write() that follows end() node:http2: emit 'close' and end the stream after a write() that follows end() or close() Nov 10, 2026
@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

The point that the merge comment left for review is decided. e0d81cf734 makes the error path follow main's rule for a stream that close() ended. The head merges cleanly with main 89452d2ac1.

Why it needed a change. The three close() probes in that comment looked at the events and at a plain client. A plain client reports END_STREAM and RST_STREAM(NO_ERROR) in the same way, so they could not show the difference. A raw client shows the frames. Server write("done"); close(); write("late"): the frames on the stream after HEADERS, and whether the server stream emits 'close'.

the request has ended the request is still open
node v26.12.1 DATA, RST_STREAM(0), 'close' DATA, RST_STREAM(0), 'close'
main and bun 1.4.4 DATA, then nothing, no 'close' DATA, then nothing, no 'close'
1549ce6126 DATA, END_STREAM, 'close' DATA, END_STREAM, no 'close'
e0d81cf734 DATA, RST_STREAM(0), 'close' DATA, RST_STREAM(0), 'close'

END_STREAM tells the peer that the body is complete, and close() means that it is not. With the request still open, neither side closed the stream. bun 1.3.13 and 1.3.14 send END_STREAM and RST_STREAM(0) here and emit 'close', so the hang is a regression of 1.4.0 too.

What changed in e0d81cf734.

  • close() marks that it left its RST_STREAM(NO_ERROR) to 'finish'. The 'error' listener sends that reset. Like _final, it sends END_STREAM for such a stream only ahead of a CANCEL.
  • close() on a stream that is already errored no longer waits for 'finish'.
  • On main, a stream that the peer reset with NO_ERROR waits for 'end'. The listener destroys it when it errors.
  • Both texts of the module have the change, and the second text has the new hash.

Verified on a debug build of e0d81cf734. The write() after end() block has 50 tests. All pass, and 38 fail with main's http2.ts. test/js/node/http2/: 753 pass, 7 skip, 0 fail (with a longer per-test timeout). The 283 vendored node http2 tests pass. A review found three defects in a first version of this change, before the push. They are fixed, and each has a test. The description has the measured rows, what is left as on main, and one new difference from node (a reset under socket backpressure).

…STREAM

The error path sent END_STREAM ahead of a CANCEL reset, as _final does.
Node does that only if close() runs while the session reads. From a
timer, a 'connect' listener or right after request(), node sends the
RST_STREAM alone, and so does main after a late write at every call
site. END_STREAM also tells the peer that the body is complete, which a
cancelled stream does not mean.

The error path now sends END_STREAM only when no reset code is set, and
a stream that close() ended gets only its RST_STREAM. After a late
write, the frames of a stream with a reset code are the same as on main.
@robobun

robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my comment above. 8081725d96 takes the END_STREAM ahead of a CANCEL out of the error path. The head merges cleanly with main 89452d2ac1.

What was wrong. After a late write, e0d81cf734 sent END_STREAM ahead of the RST_STREAM(8) of a close(NGHTTP2_CANCEL). _final does that on main, and node did it in the rows that I had measured. Those rows all ran close() in a 'stream' listener. Node v26.12.1 sends that END_STREAM only if close() runs while the session reads. From a timer or a 'connect' listener it sends DATA, RST_STREAM(8). main sends DATA, RST_STREAM(8) after a late write at every call site. So e0d81cf734 changed frames that were not part of the hang, and it moved them away from node at those call sites. END_STREAM also tells the peer that the body is complete, which a cancelled stream does not mean.

Now. After a late write, a stream with a reset code gets its RST_STREAM and no END_STREAM, as on main. The RST_STREAM(0) for a close() with no code is unchanged. It matches node in listeners and in timers, for a server stream and for a client request.

Verified on a debug build of 8081725d96. The block has 52 tests. All pass, and 35 fail with main's http2.ts. Six of the guards hold the frames of main for codes 2 and 8. test/js/node/http2/: 755 pass, 7 skip, 0 fail (with a longer per-test timeout). The 283 vendored node http2 tests pass. The description has the table of call sites ("close(NGHTTP2_CANCEL)").

Comment thread src/js/node/http2.ts Resolved
…follows a late write

After close() and a late write, the 'error' listener sends the
RST_STREAM(NO_ERROR) that close() left to 'finish'. Under socket
backpressure the native side still held DATA frames of the stream in
its queue, and the reset dropped them. The peer got a short body and a
clean close. Node sends its pending data ahead of an RST_STREAM, and
the same close() with no late write delivers the data first.

rstStream() takes a third argument. With it, the frames that flow
control lets through go out ahead of the RST_STREAM, with socket
backpressure too. The listener passes it and no longer calls flush().

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-reviewed the latest push (e105ce1) and found no new bugs; the native sendQueued path it adds addresses the backpressure truncation raised earlier. Given the native unsafe code and *.d.ts ownership, a human look is still worthwhile.

What was reviewed:

  • send_queued_frames in h2_frame_parser.rs: the stream is re-looked up after every flush_queue (its write callback runs JS), and the loop terminates on flow-control NoAction or when nothing was written and the queue did not shrink; frees stay deferred via pending_engine_stream_closes, so the &mut *stream reborrow matches the existing flush_stream_queue pattern.
  • The two-argument rstStream callers (rstNextTick, sendRstOnReady) still pass through unchanged (undefined coerces to false); .bind.ts, private.d.ts and the AOT second text carry the same change as the main text.
  • Not run here: the new close() under socket backpressure test, the USE_SYSTEM_BUN=1 failure check, and test/internal/second-texts-of-modules.test.ts for the updated SHA-256 header.
Extended reasoning...

The latest commit adds a third sendQueued argument to the native rstStream (h2_frame_parser.rs, .bind.ts, private.d.ts) and a send_queued_frames loop that flushes a stream's queued DATA frames before end_stream, used from streamOnErrored in both http2.ts texts, plus a new backpressure test. It touches no auth, crypto or injection surface; the sensitive surface is unsafe Rust reborrowing *mut Stream across JS callbacks, which follows the existing flush-loop pattern and re-looks up the stream after each callback. The change is large (stream lifecycle across JS and native, ~1280 test lines), src/js/private.d.ts falls under the *.d.ts CODEOWNERS entry, and the test and SHA checks were not run, so approve is not warranted; the new commit substantively responds to a prior inline finding, which justifies a short note instead of silence.

…t serially

Inside the concurrent block each of them shares the event loop with
about 50 other tests. On a loaded machine the file response test took
more than 5 s. The file is now two windows long (128 KB). The
backpressure test writes 1 MB chunks, and its loop has a limit for a
socket that never pushes back.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the current head (the November pushes on top of the merge with main) and found no bugs, but a human look is still worthwhile: the change rewires Http2Stream completion for errored streams on both server and client and adds a re-entrant native loop, which is beyond what an automated pass should sign off alone.

What was reviewed:

  • send_queued_frames in h2_frame_parser.rs: the loop re-looks up the stream after each flush_queue (write callbacks run JS), breaks on NoAction and on a zero-progress Backpressure return, and a re-entrant rstStream from a callback leaves the stream CLOSED so the outer end_stream returns early.
  • destroyClosedStream against the two inline blocks it replaced: the readable-drain, 'finish' and plain-destroy branches are unchanged; only the new errored branch is added ahead of them.
  • src/js/ahead-of-time/node/http2.ts: its diff matches src/js/node/http2.ts apart from type annotations, and its header SHA-256 matches the current first text.
  • Not run here: the new describe("write() after end()") cases, test/internal/second-texts-of-modules.test.ts, and bun src/codegen/check-types-of-modules.ts.
Extended reasoning...

The change touches src/js/node/http2.ts and its ahead-of-time mirror (new StreamState bits, an errorMonitor listener per stream, endErroredStream, close()/ResetAtFinish handling, the destroyClosedStream extraction), the native h2_frame_parser.rs (optional callback on an empty END_STREAM write, a sendQueued argument to rstStream with a new send_queued_frames loop), the .bind.ts and private.d.ts declarations, and about 1,300 lines of new tests. It touches no auth, crypto or injection surface; the security-relevant exposure is stream lifecycle and native re-entrancy under peer-controlled timing. Approve was ruled out by size and complexity (stream completion semantics across server and client plus unsafe native pointer re-lookups) and by the long history of findings on this PR, all of which were resolved by the author rather than an independent reviewer; a maintainer comment exists in the thread but no review state. The bug hunt exited on dry_streak with no findings, and my own reading found the native loop termination, the refactor's behavior preservation and the mirror hash all in order.

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.

2 participants