Skip to content

node:http2: emit ERR_STREAM_WRITE_AFTER_END for end(chunk) after end() - #43566

Open
robobun wants to merge 4 commits into
robobun/d1b8559a/http2-end-stream-after-late-writefrom
robobun/b660d27c/http2-end-chunk-after-end
Open

robobun wants to merge 4 commits into
robobun/d1b8559a/http2-end-stream-after-late-writefrom
robobun/b660d27c/http2-end-chunk-after-end

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #43424 (itself on #43346). The base is the #43424 branch. Only the last four commits are new.

Problem

  • Http2Stream#end(chunk, cb) on a stream whose writable side already ended drops the chunk and calls cb synchronously with no error. Node v26.3.0 emits 'error' (ERR_STREAM_WRITE_AFTER_END) and passes it to cb on a later tick.
  • respond() (204, 304, HEAD, endStream) and a body-less request() end the writable side themselves, so their first end(chunk) hits this.
  • Cause: end() (src/js/node/http2.ts:2794) returns early on a private EndedCalled flag and never reaches Writable#end. An end(123) that throws leaves the flag set, so no later end() can end the stream.

Fix

  • Remove the flag. A repeat end() goes to Writable#end, which rejects the chunk, validates the arguments, defers the callback and never runs _final again.
  • The flag hid a second bug. The implicit response to a HEAD request ends the writable side inside the first write, and that write completed synchronously, so Writable never finished a corked stream. That write now completes on a later turn.
  • Behaviour change: a late end(chunk) emits 'error'. With no 'error' listener, stream.respond(h); stream.end(body) ends the process on any HEAD request, as in node and as stream.write(body) already does on main.
  • Verified: test/js/node/http2/node-http2.test.js (19 new tests, 18 fail without the fix). Also test/js/node/http2/, vendored test-http2-*, grpc-js.

Background

Notes

Repro (st.respond({ ":status": 200 }); st.end("first"); st.end("second", cb) in a 'stream' handler):

node v26.3.0: error:ERR_STREAM_WRITE_AFTER_END, cb(ERR_STREAM_WRITE_AFTER_END) async, close | client body "first", rstCode 0
main:         cb() sync, finish, close                                                     | client body "first", rstCode 0
this PR:      error:ERR_STREAM_WRITE_AFTER_END, cb(ERR_STREAM_WRITE_AFTER_END) async, close | client body "first", rstCode 0

Decision for the reviewer. The behaviour change above is Node parity, and a remote peer can trigger it: one HEAD request ends a process whose 'stream' handler is stream.respond(h); stream.end(body) with no 'error' listener on the stream. Node v26.3.0 does exactly that, and node's own test-http2-head-request.js pins the same error for stream.write(), which Bun already emits on main. If Bun should stay lenient when respond() or request() ended the writable side (and not user code), say so. The change is then to pass the error to the callback only in that case. It needs a flag for who ended the stream, and end(chunk) would then differ from write(chunk) on the same stream.

Shapes compared with node v26.3.0. Each one gives the node result on this branch.

shape main node and this PR
server end("first"); end("second", cb) cb() sync 'error', cb(err) async, 'close'
server respond() for 204, endStream: true or HEAD, then end("body", cb) cb() sync 'error', cb(err) async, 'close'
server end("first"); end("", cb) cb() sync 'error', cb(err) async, 'close'
server end("first"), then end("x", cb) in 'finish' cb() sync 'error', cb(err) async, 'close'
server write("a"); end(); end("b", cb) (write in flight) cb() sync 'error', cb(err) async, 'close', the client receives "a" and the end
server destroy(); end("x", cb), or end("x", cb) in 'close' cb() sync cb(ERR_STREAM_WRITE_AFTER_END) async, no 'error'
server respondWithFD(fd); end("x", cb) cb() sync 'error', cb(err) async, 'close', file delivered in full
client GET request().end("body", cb) or end("", cb) cb() sync 'error', cb(err) async, 'close', the server receives no body
client POST end("a"); end("b", cb), also on a pending stream cb() sync 'error', cb(err) async, 'close', the server receives "a"
client GET end(123) no throw throws ERR_INVALID_ARG_TYPE
client POST end(123) throws, then end("ok") request hangs the server receives "ok"
end(cb) or end(null, cb) on an ended stream cb() sync cb() async, no error

The same results hold over a duplexPair transport. With maxConcurrentStreams: 1, three GET requests that each call end("body") all complete, so the errored stream frees its slot. The compat layer (Http2ServerResponse#end) does not pass a chunk to stream.end(), so res.end("a"); res.end("b", cb) is unchanged.

Who sees the new 'error'.

  • The handler stream.respond(headers); stream.end(body) on a HEAD request, as described above.
  • A handler that answers after the peer went away does not. Tried: req.close(CANCEL), req.destroy() and session.destroy() on the client, then stream.end("late", cb) on the server one tick, one microtask, one setImmediate and 50 ms later, with no 'error' listener. The stream is destroyed by then, so the error goes to the callback only. No run raised an uncaught exception.

The implicit response to a HEAD request. EndedCalled also made an end() inside end() a no-op, and one path relied on that. Take a HEAD request with no respond() call. The first _write sends the implicit response. respond() sees the HEAD request, puts END_STREAM on the HEADERS frame and calls this.end(), inside the write dispatch. The native layer completed a write to that half-closed stream synchronously. With a corked write, the finishMaybe in onwrite still sees kBuffered, and nothing calls it again. ServerHttp2Stream#_write and _writev now drop the body of that response themselves and complete the write on a later turn, so Writable finishes the stream.

HEAD request, no respond() main this PR node
end("body", cb), corked or not cb(), 'finish', 'close' same as main 'error', cb(err)
cork(); write("a"); end(cb) cb(), 'finish', 'close' same as main 'error', cb(err)
cork(); write("a"); uncork(); end(cb) cb() sync, no 'finish', no 'close' from the request's 'end' handler cb(), 'finish', 'close' 'error', cb(err)
write("a"); end("b", cb) cb(), 'finish', 'close' 'error', cb(err), 'close' 'error', cb(err)

Node ends the writable side of a HEAD stream when it creates the stream, so every chunk fails there. Bun cannot do that until an end() before respond() puts END_STREAM on the HEADERS frame (#38170). An earlier version of this PR kept an early return in end() for the nested call. The review asked for a fix at the source, and this is it.

Not in this PR.

  • On a HEAD request with no respond(), the first write() or end(chunk) drops its chunk without an error. Node fails it. See the paragraph above.
  • A HEAD response emits 'finish' before the 'error'. Node emits no 'finish' there. Also on main.
  • A pushed stream emits 'close' only when the session closes (node:http2: release server push streams once their response ends #38082). Also on main.
  • Found by the review, also on main and not touched here: _final, _write and _writev call native.writeStream with the id of a stream that a peer RST_STREAM already made the native layer drop. writeStream throws Invalid stream id for it. node:http2: send END_STREAM after a late write(), handle a peer reset at once like node #43424 guards only its own new call. A fix in write_stream (src/runtime/api/bun/h2_frame_parser.rs), which would call back and return like rst_stream does for an unknown id, covers all three sites.

The flag. EndedCalled once told _write that end() ran, to put END_STREAM on the last DATA frame. _write now reads _writableState.ending, so only the early return still used the flag.

Why the stack. The late end(chunk) errors the Duplex without destroying it, the same state a late write() produces. On main such a stream never emits 'close' (#43346 fixes that). When a write is in flight at the first end(), the END_STREAM has to come from _final, and Writable never calls _final on an errored stream (#43424 fixes that). With this PR on #43346 alone, write("a"); end(); end("b") never ends the response. On #43424 it does, and two of the new tests cover it.

Overlap with #33489. That PR changes the same early return to super.end(undefined, undefined, callback), which defers the callback and keeps the silent drop of the chunk. This PR removes that block, so it contains that source change. The 15 tests of #33489 pass on this branch. The PR that merges second needs a rebase of end().

Tests. 19 new tests in the end() after end() block. 18 fail on the base without the src/ change. The one that passes there is the corked end(chunk) on a HEAD request, which the removed flag covered. It fails when the asynchronous completion is removed. 17 of the 19 also pass under node v26.3.0 (run with a small describe/it/expect shim). The other two pin the Bun-only implicit response to a HEAD request.

Suites on the debug+ASAN build. test/js/node/http2/: 611 pass, 6 skip, 0 fail. All 256 vendored test-http2-* files and the 22 test-diagnostics-channel-http2-*, test-stream-pipeline-http2 and test-worker*-http2-* files pass. grpc-js: 322 pass, 6 fail, the same failures as without this change (5 need public DNS, test-tonic). The http2 regression tests, serve-http2*.test.ts and fetch-http2-client.test.ts: 425 pass, 0 fail.

Self-review. 7 concerns raised, 7 addressed: the nested end() above (found by the review, fixed and tested), and six about the tests (argument validation on an ended stream not pinned, no test after destroy, no check of what reached the wire, an unhandled rejection on the failure path of one test, two unclear comments, test names).


[human-review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 18 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1368.15ms]
(pass) node none > Client Basics > should be able to send a POST request [932.86ms]
(pass) node none > Client Basics > constants [30.33ms]
(pass) node none > Client Basics > getDefaultSettings [12.18ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [29.29ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [9.02ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [5.41ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [9.95ms]
(pass) node none > Client Basics > should be able to send data using end [973.69ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [946.20ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving
... (truncated)

release without fix: 12 failed, 6 skipped
bun test v1.4.3-canary.1 (bf76b7477)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.27ms]
(pass) node none > Client Basics > getDefaultSettings [0.21ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.38ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.17ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.07ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.09ms]
(pass) node none > Client Basics > is possible to abort request [2.18ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.98ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.87ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.95ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [41.38ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > headers cannot be bigge
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [839.28ms]
(pass) node none > Client Basics > should be able to send a POST request [551.99ms]
(pass) node none > Client Basics > constants [17.91ms]
(pass) node none > Client Basics > getDefaultSettings [6.86ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.33ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.40ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.15ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.76ms]
(pass) node none > Client Basics > should be able to send data using end [572.27ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [562.31ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 824ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen JS modules (bundle-modules)
Preprocess modules (9631ms)
Bundle modules (73ms)
Postprocesss modules (147ms)
Bundle Functions (537ms)
Generate Code (44ms)

[10.44s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/7] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�
... (truncated)
diff hotspot
src/js/node/http2.ts                  |  17 +-
 test/js/node/http2/node-http2.test.js | 304 ++++++++++++++++++++++++++++++++++
 2 files changed, 312 insertions(+), 9 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                       7      6     46
test/js/node/http2/node-http2.test.js      1      2     46

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:56 PM PT - Sep 19th, 2026

✅ @robobun, your commit 12a30c6fe3c898836d834bd29f103d7dd5ae5656 passed in Build #118656! 🎉


🧪   To try this PR locally:

bunx bun-pr 43566

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

bun-43566 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Stacked on #43424, which is stacked on #43346.

Reproduction (bun repro.mjs, compare with node repro.mjs):

import http2 from "node:http2";
const srv = http2.createServer();
const seen = [];
srv.on("stream", st => {
  st.on("error", e => seen.push("server stream error " + e.code));
  st.on("close", () => seen.push("server stream close"));
  st.respond({ ":status": 200 });
  st.end("first");
  let sync = true;
  st.end("second", err => seen.push("second end cb " + (err ? err.code : "ok") + (sync ? " (sync)" : " (async)")));
  sync = false;
});
await new Promise(r => srv.listen(0, "127.0.0.1", r));
const c = http2.connect("http://127.0.0.1:" + srv.address().port);
await new Promise(resolve => {
  const r = c.request({ ":path": "/" });
  let body = "";
  r.on("data", d => (body += d));
  r.on("close", () => { seen.push("client close body=" + JSON.stringify(body) + " rstCode=" + r.rstCode); resolve(); });
  r.end();
});
await new Promise(r => setImmediate(r));
console.log(seen.join("\n"));
c.close();
srv.close();
node v26.3.0 and this PR:
  server stream error ERR_STREAM_WRITE_AFTER_END
  second end cb ERR_STREAM_WRITE_AFTER_END (async)
  server stream close
  client close body="first" rstCode=0

bun 1.4.3-canary.1+367d939d9 (main):
  second end cb ok (sync)
  server stream close
  client close body="first" rstCode=0

The new tests are the end() after end() block in test/js/node/http2/node-http2.test.js. 18 of the 19 fail on the base branch without the src/ change. The other one is the corked end(chunk) on a HEAD request with no respond(), which the removed flag covered. It fails when the asynchronous completion of that write is removed. 17 of the 19 also pass under node v26.3.0. The other two pin the Bun-only implicit response to a HEAD request.

@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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Http2Stream#end() kept its own EndedCalled flag and returned early on a
repeat call. It dropped the chunk, called the callback synchronously with
no error, and skipped the argument validation. When end(chunk) threw, the
flag stayed set and no later end() could end the stream.

end() now delegates a repeat call to Writable#end, which reports the chunk
as ERR_STREAM_WRITE_AFTER_END, defers the callback, and tracks the ending
state itself. Only an end() nested inside another end() still returns
early: respond() makes that call when the final _write sends the implicit
response to a HEAD request.
@robobun
robobun force-pushed the robobun/b660d27c/http2-end-chunk-after-end branch from bf76b74 to 038f89c Compare September 19, 2026 21:57
Comment thread src/js/node/http2.ts Outdated
@robobun
robobun changed the base branch from robobun/677cb14e/http2-close-after-late-write to robobun/d1b8559a/http2-end-stream-after-late-write September 19, 2026 21:57

@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.

The new commits address two of the earlier threads: onStreamWriteDone now sends END_STREAM for the write(a); end(); end(b) shape, and kEndingWithChunk/kInsideEnd are declared as class fields. Beyond the inline findings, I also checked the bare catch {} in onStreamWriteDone — with its fixed ("", "ascii", true) arguments the only throw path left in native writeStream is the dropped-stream-id one it is meant to absorb — and the per-chunk onStreamWriteDone.bind in _write/_writev, which is one small closure per non-final chunk next to a native write and not worth a separate comment.

Extended reasoning...

Findings are being posted inline, so this body only records what else was examined on this pushed version. The source diff was re-read against the prior review: the missing END_STREAM after a late end(chunk) and the undeclared symbol fields are now handled; the HEAD-request uncaught error (Node parity) and the cork/write/uncork/end(cb) HEAD shape remain as the PR describes. The bare catch was checked against h2_frame_parser.rs writeStream: with a numeric id, empty string payload, literal "ascii" encoding and boolean close, the only remaining throw is the "Invalid stream id" lookup, so the catch cannot hide a different failure. The bind allocation is a real but minor cost on a path already dominated by the native write and callback deferral.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (2):

  • 🔴 src/js/node/http2.ts:2850 — Existing Bun http2 servers using the canonical stream.respond(headers); stream.end(body) handler now crash with an unca…
  • Also unresolved: 1 minor or pre-existing.

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/http2.ts — pre-existing: a client or server whose peer sends RST_STREAM while a write is in flight can crash with an uncaught plain Error: Invalid stream id, or get that error instead of ERR_HTTP2_STREAM_ERROR. The new onStreamWriteDone wraps its native call in try/catch for exactly this window, but the sibling sites do not. _final at src/js/node/http2.ts:2775 and the dispatch in _write/_writev call native.writeStream on an id the engine already evicted, and write_stream throws for an unknown id. Fix: handle the evicted-id case once for all callers, e.g. have write_stream (h2_frame_parser.rs:5881) dispatch the callback and return false like rst_stream does for unknown ids, which covers the 3 sites and lets the try/catch at :2086 go. …

    Extended reasoning...

    …Same pattern at 3 sites (http2.ts:2775, http2.ts:2943, http2.ts:2901).

    The window is the one the PR's own test builds: a peer sends HEADERS then RST_STREAM followed by enough bytes that the TLS socket delivers two reads in one turn. The first read processes the RST: Stream::free_resources (h2_frame_parser.rs:1932) pushes the id to pending_engine_stream_closes and JS is only told on nextTick (emitStreamErrorNT). The second read drains that list at dispatch depth 0 and calls this.streams.remove(&id) (h2_frame_parser.rs:3615-3618). After that write_stream (h2_frame_parser.rs:5881-5883) throws Invalid stream id for that id, unlike rst_stream (:5052-5075) which tolerates it. The write callback of the in-flight chunk was deferred by kDeferWriteCallback (nextTick or setImmediate) and runs after the turn, before emitStreamErrorNT destroys the stream. Take the test fixture minus the late write: req.write("body"); req.end() in 'response'. The deferred callback runs onwrite -> afterWrite -> finishMaybe -> prefinish -> _final. _final reaches native.writeStream(this.#id, "", "ascii",…

    Verification: pre-existing (the base branch has the identical _final/_write/_writev code; the diff adds the try/catch only inside the new onStreamWriteDone helper while leaving its siblings unguarded). Trigger: an Http2 client whose transport is a JS-fed TLS socket (tls.connect({ socket: <Duplex> }), i.e. proxy tunnels / upgradeDuplexToTLS — exactly the transport the PR's own test builds) has a DATA…

Comment thread test/js/node/http2/node-http2.test.js Outdated
…r turn

respond() ends the writable side of a HEAD response. When the first write
sends the implicit response, that end() runs inside the write dispatch, and
the native layer completed the write synchronously. Writable then never
finished the stream for a corked write followed by end(). The removed
EndedCalled flag hid this for a corked end(chunk) only.

The write now completes on a later turn, like every other write, so end()
needs no early return for a nested call. Tests cover both corked forms and
the remaining end() after end() forms.
Comment thread src/js/node/http2.ts Outdated
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c289010 and 12a30c6 for the review above.

  • The early return for a nested end() is gone. The cause is fixed at its source: the write of an implicit HEAD response now completes on a later turn. That also fixes cork(); write(); uncork(); end(cb) on a HEAD request, which never emitted 'finish' on main.
  • The PR is now stacked on node:http2: send END_STREAM after a late write(), handle a peer reset at once like node #43424, so write(a); end(); end(b) still ends the response. Two new tests cover it.
  • The missing test rows are added (19 tests in the block now).
  • The HEAD finding stays as Node parity. The description now states the remote trigger in plain words and leaves the callback-only variant as a decision for a maintainer.
  • The Invalid stream id finding is on main and in code this PR does not change (_final, _write, _writev). I added it to "Not in this PR" in the description with the suggested native fix.

@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 completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

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.

1 participant