Skip to content

node:http2: end a paused server stream that holds no data when it closes - #43631

Open
robobun wants to merge 1 commit into
mainfrom
robobun/c5edf1ad/http2-paused-server-stream-end
Open

robobun wants to merge 1 commit into
mainfrom
robobun/c5edf1ad/http2-paused-server-stream-end

Conversation

@robobun

@robobun robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • When both halves have closed, the server handler calls stream.read(0) if it does not resume, like node's onStreamClose. The client handler already does.
  • Correct because read(0) only ends a readable that is ended and empty. Unread data still waits for its reader.
  • The close can arrive inside end(), with the last write in flight. An 'end' then destroys the stream before 'finish', so read(0) waits until the writable side has finished.
  • Verified: test/js/node/http2/node-http2.test.js (7 new tests, 6 fail without the fix, all pass on Node), test/js/node/http2/, the 261 vendored test-http2-* tests. Self-review and limits: Notes.

Background

  • Http2Stream is a Duplex with autoDestroy: false. Only destroy() emits 'close'.
  • The native parser reports a close through streamEnd. State 7 means both halves have closed. A peer reset takes another path.
  • pause() sets readableFlowing to false. A paused Readable emits 'end' only when something calls read().
Notes

Repro.

const http2 = require("node:http2");
const events = [];
const server = http2.createServer();
server.on("stream", s => {
  s.pause();
  s.on("end", () => events.push("end"));
  s.on("close", () => events.push("close"));
  s.respond({ ":status": 200 });
  s.end("hello");
});
server.listen(0, () => {
  const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
  const req = client.request({ ":path": "/" });
  req.resume();
  req.on("close", () => client.ping(() => { console.log(events); process.exit(0); }));
  req.end();
});

Versions. Official release binaries, the version string is the one each binary reports. "main" is canary 1.4.3-canary.1+367d939d9. No 1.4.3 release exists. The last column is the compat API: 200 requests on one connection, each handler calls req.pause() and res.end("ok").

                         repro above      streams undestroyed   res 'finish' / 'close'
1.3.13+bf2e2cecf         [ 'close' ]      0                     200 / 200
1.3.14+0d9b296af         [ 'close' ]      0                     200 / 200
1.4.0+34cbb9a40          []               200                   0 / 0
1.4.1+4661e494f          []               200                   0 / 0
1.4.2+744846f84          []               200                   0 / 0
main (canary)            []               200                   0 / 0
this PR                  [ 'end', 'close' ]   0                 200 / 200
node v26.3.0             [ 'end', 'close' ]   0                 200 / 200

Provenance. The parent of 4bbe075 (#32488) still calls stream.destroy() at once in the state 7 block of the server handler. 4bbe075 adds the wait for 'end', which is the first half of node's onStreamClose. This PR adds the second half. A revert is not the fix: with an unread body, 1.3.14 destroys the stream before the reader gets to it (finish, close, never 'end'), where node, main and this PR give finish, (the reader reads), end, close. The test "keeps unread data until the user reads it" guards that.

Reach. I found no issue that describes this. The shapes I measured as affected all pause and then respond without a read or a resume(), on a request with no unread data: the raw API, the compat API (req.pause()), and a 401 sent while the stream is paused for an async auth check (main: finish, stream not destroyed; this PR: finish, end, close). grpc-js 1.12.0 resumes the server streams it pauses (server-interceptors.js). The library code of fastify 5.2.2 never calls pause().

Server stream events, paused in the 'stream' listener. Measured. "later" means the response starts on a later turn, after the request ended.

                                              node v26.3.0                 main                     this PR
GET, respond at once                          end, finish, close           finish                   finish, end, close
GET, respond later                            end, finish, close           finish                   finish, end, close
POST end() with no data                       end, close                   finish                   finish, end, close
'data' listener pauses after the last chunk   finish, end, close           finish                   finish, end, close
GET, respond({ endStream: true })             finish, end, close           finish                   finish, end, close
GET, respondWithFile / respondWithFD          finish, end, close           finish                   finish, end, close
HEAD                                          end, close                   finish                   finish, end, close
GET, close() without a response               aborted, finish, end, close  aborted, finish, close   as node
POST end('hello'), body never read            finish (stream stays open)   as node                  as node
POST end('hello'), read() after the close     finish, end, close           as node                  as node
compat API, req.pause(), GET                  req close, res finish, res close   (nothing)          as node

Bun emits 'finish' before 'end' where node emits 'end' first. An untouched stream has the same order on main (finish, end, close against node's end, finish, close), so this PR keeps it. Where node prints no 'finish', node destroys the stream before its writable side finishes. Bun keeps 'finish' there on purpose (the existing mid-finish branch of the handler). Over a JS socket transport (duplexPair) I measured the two paused GET rows: main gives finish and leaves the stream undestroyed, this PR gives finish, end, close.

Scope. Before the rebase I compared main with this build over 43 scenarios (untouched, flowing, 'readable' listener, unread body, pipe under backpressure, client streams, the compat API). Every scenario that differs is a paused stream with no unread data. destroySelfOnEnd and the client handler are unchanged.

Why read(0) waits for 'finish'. With the request already complete, end(chunk) sends END_STREAM and the native parser reports state 7 inside that call. An immediate read(0) queues 'end' ahead of the deferred write callback. The existing 'end' listener then calls destroy(), 'finish' never fires, and the end(chunk, cb) callback gets ERR_STREAM_DESTROYED. The test "still emits 'finish' when it responds after the request ended" fails with an immediate read(0).

A design I built first and dropped. It called read(0) at once and made destroySelfOnEnd wait for 'finish', like node's kMaybeDestroy. All streams share that function. Measured with a write() after end() from an 'end' listener that user code adds after the close:

                      node v26.3.0                          main                   dropped design              this PR
paused, no body       end, finish, error, close             finish (stuck)         end, error (stuck)          finish, end, close
untouched, POST body  finish, end, close (no 'error')       end, finish, close     end, finish, error, close   as main

The dropped design gave a stream that user code never paused a new 'error' event, which is an uncaught exception without a listener. It also left the paused stream undestroyed where node destroys it.

Not covered. All measured, all the same on main and with this PR.

  • Half close. A paused stream whose handler responds from its 'end' listener hangs when the request ends on an empty DATA frame (POST, end() with no data). Node answers 200, because it also calls read(0) when END_STREAM arrives on a DATA frame (onStreamRead). 1.3.14 hangs too. With END_STREAM on the HEADERS frame (GET) node hangs as well. I left the half close alone: an 'end' there would be missed by a listener that user code adds after an await and before resume(), which works on Bun today.
  • A reset from the peer. After the client's req.close() or req.destroy() (RST_STREAM with NO_ERROR), node emits aborted, finish, end, close on the paused server stream. Bun emits aborted, finish, close. The stream is destroyed, only 'end' is missing. That path does not go through state 7.
  • A JS socket transport, a stream nobody paused. Over duplexPair, an untouched server stream with an unread POST body that later calls write() then end(cb) loses 'finish', and cb gets ERR_STREAM_DESTROYED. Node gives end, finish, close. Over TCP, Bun is correct. The cause is the destroy at 'end' in destroySelfOnEnd while a write callback is still deferred. A fix there has to handle a stream that errors while it waits, which node:http2: emit 'close' on a stream errored by a write() after end() #43346 works on.
  • close(code) on a paused stream. Bun's event sequence differs from node's in 6 of 16 measured rows (codes 0, 8, 2, 11, with and without respond(), in the listener or after a timer). For example close(8) without a response: node aborted, finish, close, Bun adds 'end'. main and this PR print the same sequence in all 16 rows.
  • A stream from pushStream() never reaches state 7 on main, paused or not (node:http2: release server push streams once their response ends #38082 is open for that).
  • node:http2: destroy a closed stream with unread data when its session is torn down #43433 and node:http2: emit 'close' on a stream errored by a write() after end() #43346 move the block below the changed lines into a helper. The PR that lands second may need a small rebase.

Self-review. An independent review of this diff ended with "merge after changes, keep the code shape". It asked for three changes to this text, all made: name the regressing change and the last good release, say that no user reported it and give the measured reach, and list the JS socket row under "Not covered". I checked each of its claims on my own runs first. Two did not hold up and are not repeated here: pipe() into a destination that errors is not affected on main, and 1.3.14 does not lose the unread bytes (it destroys the stream before the reader, see Provenance). The review kept one more concern about the implementation that I could not recover in full. So I re-ran its last probes (half close, reset from the peer, reach). They showed two claims of my earlier text as too broad, and the first two items of "Not covered" correct them.

Suites on the debug build, this commit. test/js/node/http2/ (11 files, node-http2.test.js 396 pass), test/js/node/test/{parallel,sequential}/test-http2-*.js (261 pass), fetch-http2-client.test.ts. Before the rebase also fetch-backpressure.test.ts, node-http2-ping-flood-staged.test.ts, the http2 regression tests (25589, 24924, 26915, 29073), and test/js/third_party/grpc-js/: 322 pass, 6 fail (five DNS lookups of public hosts with no network here, and test tonic server). I did not run grpc-js without the change. #43433 reports the same failures on a debug build of main.


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

fails on main (without fix)
ASAN without fix: 6 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 [745.44ms]
(pass) node none > Client Basics > should be able to send a POST request [497.97ms]
(pass) node none > Client Basics > constants [17.26ms]
(pass) node none > Client Basics > getDefaultSettings [6.77ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [15.62ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.02ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [2.91ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.51ms]
(pass) node none > Client Basics > should be able to send data using end [520.89ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [510.44ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 7 failed, 6 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.88ms]
(pass) node none > Client Basics > getDefaultSettings [0.14ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.27ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.14ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.06ms]
(pass) node none > Client Basics > is possible to abort request [1.64ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.70ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.70ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.69ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [29.90ms]
(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 [813.55ms]
(pass) node none > Client Basics > should be able to send a POST request [537.52ms]
(pass) node none > Client Basics > constants [17.29ms]
(pass) node none > Client Basics > getDefaultSettings [6.95ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [16.17ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.30ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.01ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.67ms]
(pass) node none > Client Basics > should be able to send data using end [557.59ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [547.51ms]
(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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     a8a4926313
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 674ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 61 packages (no changes) [7.00ms]
[2/1250] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1250] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939d9)

Checked 111 installs across 104 packages (no changes) [4.00ms]
[4/1250] gen ErrorCode+*.h
[5/1250] gen bindgenv2
[6/1250] gen node-fallbacks/react-refresh.js
Bundled 1 module in 7ms

  react-refresh.js  4.81 KB  (entry point)

[7/1250] gen .bind.ts → GeneratedBindings.cpp
[8/1250] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1223] fetch zlib
[zlib] up to date
[10/1223] fetch tinycc
[tinycc] up to date
[11/1222] esbuild bun-error

  ../../build/release/codegen/bun-error/i
... (truncated)
diff hotspot
src/js/node/http2.ts                  |   9 ++
 test/js/node/http2/node-http2.test.js | 163 ++++++++++++++++++++++++++++++++++
 2 files changed, 172 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                      11      6     30
test/js/node/http2/node-http2.test.js      4      4     30

A server stream that user code paused was never destroyed when its readable
side held no data: no 'end', no 'close'. Since #32488 the streamEnd handler
of the server session waits for 'end' before destroy(), but it only resumes
a stream nobody touched. Nothing ended the readable side of a paused stream.
With the compat API, req.pause() on a request with no body meant res never
emitted 'finish' or 'close'.

When both halves have closed, the handler now calls read(0), like node's
onStreamClose. read(0) ends a readable that is ended and empty. It does not
consume data, so a paused stream with unread data still waits for its reader.

The close can arrive inside end(), while the last write is in flight. An
'end' at that point destroys the stream before 'finish'. So the handler
calls read(0) once the writable side has finished.
@robobun

robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:25 AM PT - Sep 20th, 2026

✅ @robobun, your commit a8a4926313a935b78057c8e725e07d215c6c2bda passed in Build #118948! 🎉


🧪   To try this PR locally:

bunx bun-pr 43631

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

bun-43631 --bun

@robobun

robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Buildkite passes on a8a4926 and no review thread is open.

How I reproduced it. Run the repro script from the Notes of the PR description. A server 'stream' listener calls pause(), then respond() and end("hello"). The client sends a GET and waits for its PING ack.

  • Node v26.3.0 prints [ 'end', 'close' ].
  • Bun 1.4.0, 1.4.1, 1.4.2 and main print []. The server stream is never destroyed.
  • Bun 1.3.13 and 1.3.14 print [ 'close' ].
  • This branch prints [ 'end', 'close' ].

I ran the official release binaries for the version rows. The regression came with #32488, which first shipped in 1.4.0.

Proof in the test file. test/js/node/http2/node-http2.test.js, block "a paused server stream": 6 of the 7 tests fail without the change to src/, all 7 pass with it, and the same assertions pass on Node v26.3.0.

@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 97b2fe78-b616-4bff-a7dd-d398434bd305

📥 Commits

Reviewing files that changed from the base of the PR and between fc297d4 and a8a4926.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/http2/node-http2.test.js

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

The HTTP/2 implementation adds zero-byte reads for paused server streams during closure. Tests cover lifecycle events, response completion, reset handling, compatibility-layer closure, and unread buffered request data.

Changes

Paused HTTP/2 server streams

Layer / File(s) Summary
Paused stream completion handling
src/js/node/http2.ts
Adds readNothing, which invokes read(0). Paused server streams now read immediately after writable completion or defer the read until finish.
Paused stream lifecycle tests
test/js/node/http2/node-http2.test.js
Adds asynchronous tests for completion events, response ordering, reset handling, compatibility-layer closure, and retention of unread request data.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main change: ending a paused server stream that has no unread data when it closes.
Description check ✅ Passed The description explains the problem, fix, scope, background, limitations, and verification results. It does not use the template headings exactly, but it provides the required change summary and veri…

Comment @coderabbitai help to get the list of available commands.

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

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

  • 🟣 src/js/node/http2.ts — Client callers whose stream closes inside req.end(chunk) still lose 'finish' and get ERR_STREAM_DESTROYED in the end() callback; only the server side of this pair is fixed. The PR's own rationale for deferring read(0) until 'finish' (src/js/node/http2.ts:4064-4067) applies verbatim to the client handler at :5062, which still calls read(0) unconditionally before the once('end', destroySelfOnEnd) at :5075. Fix: apply the same writableFinished check and readNothing deferral in ClientHttp2Session.streamEnd, or state in the PR why the client cannot reach state 7 inside end(chunk); the class is one concern.

    Extended reasoning...

    Confirmed with git show on the base: the client handler is byte-identical at :5053 (base) and :5062 (head), so this is a sibling site the diff leaves untouched, not a new line. Trigger: server answers a request with HEADERS carrying END_STREAM (e.g. an early 4xx) while the client still owes body. Client 'response' handler calls req.end('last'). The native parser has the remote half closed; the write with END_STREAM moves the stream to state 7 inside that end() call, exactly as the PR describes for the server. If streamEnd(6) was dispatched just before 'response', read(0) at :5062 has already queued endReadableNT on nextTick but endEmitted is still false, so at state 7 stream.readable is true and :5075 registers once('end', destroySelfOnEnd). The write callback for the END_STREAM chunk is deferred by kDeferWriteCallback (:2864) and lands after that nextTick. 'end' fires first, destroy() runs, Writable's kOnFinished callbacks are flushed with ERR_STREAM_DESTROYED and 'finish' never emits. The test the PR added for this on the server ("still emits 'finish' when it responds after the…

    Verification: pre-existing (the client handler is untouched by this PR and the base behaves identically), but it is the sibling site of the exact hazard the PR's own new comment names, so the class is only half-fixed. Trigger: a client stream whose response arrives with END_STREAM while the request body is still open, and the user answers with req.end(chunk[, cb]) over a JS-side socket transport (duplexPair /…

Comment thread src/js/node/http2.ts
@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Thank you for the review. I measured both findings on Node v26.3.0, on main (canary 1.4.3-canary.1+367d939d9) and on this branch. Neither one reproduces, so I did not push a change. The scripts are below. If you have a sequence where this branch and main differ, send it and I will add it as a test.

1. close(code) on a paused stream (the inline comment at http2.ts:4066). 16 rows: codes 0, 8, 2 and 11, with and without respond(), close() called in the 'stream' listener or after a 20 ms timer. This branch and main print the same sequence in all 16 rows. That includes the scenario of the comment (pause, async check, close(NGHTTP2_INTERNAL_ERROR)): both print aborted, finish, end, error, close. So the 'end' in that row is not new. main already emits it. In the rows where close() runs in the listener, both print aborted, finish, error, close, with no 'end'.

I did not add a rstCode check. It would change no measured row, so no test could fail without it. When close(code) reaches state 7 inside its own end() call, rstCode is not set yet, so a check at that point could not see the code anyway.

2. The client handler. 12 rows: TCP and a JS socket transport (duplexPair), the client stream paused, untouched or flowing, req.end("x", cb) called in the 'response' listener or 30 ms later. The server answers 400 with END_STREAM on its HEADERS while the request body is still open. On main and on this branch every row keeps 'finish' and the callback gets no error. In 10 of the 12 rows 'end' fires at the half close, before the request ends, so at the full close the handler takes its existing branch that waits for 'finish'. In the other two (TCP, end() inside the 'response' listener) 'finish' fires before 'end'. The two builds are identical in all 12 rows, so this PR leaves the client handler alone.

The first matrix shows differences from Node in the close(code) sequences of a paused stream (6 of 16 rows, the same on main and here). They are older than this PR. I added them to "Not covered" in the description.

close(code) on a paused server stream: output and script

main and this branch (identical):

  close(0) no respond()   sync  [aborted, finish, end, close] destroyed=true
  close(0) no respond()   async [aborted, finish, end, close] destroyed=true
  close(0) after respond() sync  [aborted, finish, end, close] destroyed=true
  close(0) after respond() async [aborted, finish, end, close] destroyed=true
  close(8) no respond()   sync  [aborted, finish, end, close] destroyed=true
  close(8) no respond()   async [aborted, finish, end, close] destroyed=true
  close(8) after respond() sync  [aborted, finish, end, close] destroyed=true
  close(8) after respond() async [aborted, finish, end, close] destroyed=true
  close(2) no respond()   sync  [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(2) no respond()   async [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(2) after respond() sync  [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(2) after respond() async [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) no respond()   sync  [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) no respond()   async [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) after respond() sync  [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) after respond() async [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true

Node v26.3.0:

  close(0) no respond()   sync  [aborted, finish, end, close] destroyed=true
  close(0) no respond()   async [aborted, finish, end, close] destroyed=true
  close(0) after respond() sync  [aborted, finish, end, close] destroyed=true
  close(0) after respond() async [aborted, finish, end, close] destroyed=true
  close(8) no respond()   sync  [aborted, finish, close] destroyed=true
  close(8) no respond()   async [aborted, finish, close] destroyed=true
  close(8) after respond() sync  [aborted, finish, end, close] destroyed=true
  close(8) after respond() async [aborted, finish, end, close] destroyed=true
  close(2) no respond()   sync  [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(2) no respond()   async [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(2) after respond() sync  [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(2) after respond() async [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) no respond()   sync  [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) no respond()   async [aborted, finish, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) after respond() sync  [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
  close(11) after respond() async [aborted, finish, end, error:ERR_HTTP2_STREAM_ERROR, close] destroyed=true
// Paused server stream, request with no body (GET). The handler rejects it with close(code).
// Usage: <runtime> close-code.js
const http2 = require("node:http2");

async function run(code, respondFirst, when) {
  const events = [];
  let sstream;
  const server = http2.createServer();
  server.on("stream", s => {
    sstream = s;
    s.pause();
    for (const ev of ["aborted", "end", "finish", "close"]) s.on(ev, () => events.push(ev));
    s.on("error", e => events.push(`error:${e.code}`));
    const go = () => {
      if (respondFirst) s.respond({ ":status": 200 });
      s.close(code);
    };
    if (when === "sync") go();
    else setTimeout(go, 20);
  });
  await new Promise(r => server.listen(0, r));
  const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
  client.on("error", () => {});
  const req = client.request({ ":path": "/" });
  req.on("error", () => {});
  req.resume();
  await new Promise(r => req.on("close", r));
  await new Promise(r => client.ping(r));
  await new Promise(r => setTimeout(r, 150));
  const label = `close(${code}) ${respondFirst ? "after respond()" : "no respond()  "} ${when.padEnd(5)}`;
  console.log(`  ${label} [${events.join(", ")}] destroyed=${sstream.destroyed}`);
  client.destroy();
  server.close();
}

(async () => {
  for (const code of [0, 8, 2, 11]) {
    for (const respondFirst of [false, true]) {
      for (const when of ["sync", "async"]) await run(code, respondFirst, when);
    }
  }
  process.exit(0);
})();
client stream, early response: output and script

main and this branch (identical):

  tcp    response paused    [endcb ok, finish, end, close] destroyed=true
  tcp    response untouched [endcb ok, finish, end, close] destroyed=true
  tcp    response flowing   [end, endcb ok, finish, close] destroyed=true
  tcp    later    paused    [end, endcb ok, finish, close] destroyed=true
  tcp    later    untouched [end, endcb ok, finish, close] destroyed=true
  tcp    later    flowing   [end, endcb ok, finish, close] destroyed=true
  duplex response paused    [end, endcb ok, finish, close] destroyed=true
  duplex response untouched [end, endcb ok, finish, close] destroyed=true
  duplex response flowing   [end, endcb ok, finish, close] destroyed=true
  duplex later    paused    [end, endcb ok, finish, close] destroyed=true
  duplex later    untouched [end, endcb ok, finish, close] destroyed=true
  duplex later    flowing   [end, endcb ok, finish, close] destroyed=true
// CLIENT stream. The server answers with END_STREAM on its HEADERS before the client ends its
// request. The client then calls req.end("x", cb). Does the client keep 'finish'?
// Usage: <runtime> client-early.js
const http2 = require("node:http2");
const { duplexPair } = require("node:stream");

function run(transport, when, variant) {
  return new Promise(resolve => {
    const events = [];
    const server = http2.createServer();
    server.on("stream", s => {
      s.on("error", () => {});
      s.respond({ ":status": 400 }, { endStream: true });
    });
    const start = connect => {
      const client = connect();
      client.on("error", () => {});
      const req = client.request({ ":path": "/", ":method": "POST" });
      if (variant === "paused") req.pause();
      if (variant === "flowing") req.on("data", () => {});
      for (const n of ["end", "finish", "close"]) req.on(n, () => events.push(n));
      req.on("error", e => events.push("error:" + e.code));
      const finishIt = () => req.end("x", err => events.push(err ? "endcb:" + err.code : "endcb ok"));
      req.on("response", () => (when === "response" ? finishIt() : setTimeout(finishIt, 30)));
      setTimeout(() => {
        console.log(`  ${transport.padEnd(6)} ${when.padEnd(8)} ${variant.padEnd(9)} [${events.join(", ")}] destroyed=${req.destroyed}`);
        client.destroy();
        server.close();
        resolve();
      }, 600);
    };
    if (transport === "tcp") {
      server.listen(0, () => start(() => http2.connect(`http://127.0.0.1:${server.address().port}`)));
    } else {
      const [clientSide, serverSide] = duplexPair();
      server.emit("connection", serverSide);
      start(() => http2.connect("http://localhost:80", { createConnection: () => clientSide }));
    }
  });
}

(async () => {
  for (const transport of ["tcp", "duplex"])
    for (const when of ["response", "later"])
      for (const variant of ["paused", "untouched", "flowing"]) await run(transport, when, variant);
  process.exit(0);
})();

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

#46121 covers this case. It ends and closes a paused server stream at the full close as part of one rule for the readable side of a stream, and the 7 tests of this PR pass on that branch. If #46121 merges, this PR can be closed.

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