Skip to content

node:http2: tear the session down when a user-supplied transport reports EOF - #42211

Open
robobun wants to merge 1 commit into
mainfrom
robobun/24129096/http2-transport-eof
Open

robobun wants to merge 1 commit into
mainfrom
robobun/24129096/http2-transport-eof

Conversation

@robobun

@robobun robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • http2.connect(url, { createConnection: () => duplex }): the peer ends its side while a request is pending. Bun emits nothing, ever. The stream and the session never close, session.destroyed stays false, and the transport is not destroyed. Node closes the stream, closes the session, and destroys the transport.
  • A server session over an injected stream (server.emit('connection', duplex)) behaves the same way. The client's EOF leaves the server stream and session alive.
  • Cause: both session constructors subscribe to the transport's data, drain, close, error and timeout (src/js/node/http2.ts:4491 and :5721). There is no 'end' listener, so the EOF reaches nothing.

Fix

  • Watch 'end' on a transport that is not a net.Socket, then apply net's destroySoon to it: end the writable side, and destroy it once that side finishes. The existing 'close' path then tears the session down.
  • This is what node does. It wraps that class of transport in a JSStreamSocket, which is a net.Socket with allowHalfOpen off. A real net.Socket (a TLSSocket too) already applies its own allowHalfOpen, so the guard leaves those transports on their current path.
  • Found by a node-parity audit. Nobody reported it.
  • Verified: test/js/node/http2/node-http2.test.js, two new tests, both fail on stock bun. Also all 256 upstream test-http2-* tests, the 10 files in test/js/node/http2, serve-http2*, the http2 regression tests, and grpc-js.

Background

Notes

The repro

const http2 = require("node:http2");
const { duplexPair } = require("node:stream");

const [clientSide, serverSide] = duplexPair();
const server = http2.createServer();
server.on("stream", () => {}); // never respond
server.emit("connection", serverSide);

const client = http2.connect("http://x.test", { createConnection: () => clientSide });
const req = client.request({ ":path": "/" });
req.on("close", () => console.log("stream close"));
client.on("close", () => console.log("session close"));
req.end();
setTimeout(() => serverSide.end(), 50);

node v26.3.0 prints both lines. Stock bun prints nothing and exits 0 with the request unanswered.

Parity measured against node v26.3.0

Each shape was run on node v26.3.0, on stock bun 1.4.3, and on this build.

shape stock bun this build
client, request pending, peer ends nothing matches node
client, idle session, peer ends nothing matches node
client, mid response body, peer ends nothing matches node
client, peer ends in the same tick as the request nothing matches node
server over an injected Duplex, client ends nothing matches node
Duplex.from({ readable, writable }) transport nothing matches node
peer ends while a transport write is stalled nothing matches node, the destroy waits for the write
client half-closes, server responds late response delivered matches node, ERR_HTTP2_INVALID_STREAM on the late respond
stream.finished(req) after the peer's EOF never settles ERR_STREAM_PREMATURE_CLOSE, like node

Guard checks, which must not change:

  • A net.Socket transport with allowHalfOpen: true, peer FIN: the session stays alive and a ping still reaches the peer. Client and server. Same on node, stock bun, and this build.
  • net.Socket and TLSSocket transports with the default allowHalfOpen: false, client and server, plain TCP and TLS: unchanged.
  • A transport already at EOF before the session is built: unchanged. Node hangs there too.

Divergences that remain, all of them older than this change

Why the guard is instanceof net.Socket

Node's condition is !socket._handle || !socket._handle.isStreamBase. Bun's _handle is a native socket object with no isStreamBase, so a literal port of that test would wrap every socket. instanceof net.Socket selects the same population: in node a JSStreamSocket is itself a net.Socket, and tls.connect({ socket: duplex }) returns a TLSSocket, which is a net.Socket in both runtimes.

Reviews

Self-reviewed. Four concerns survived: three about how this body states the reach of the change, which are addressed above, and one about the execution, which the guard checks and the parity table answer.


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

fails on main (without fix)
ASAN without fix: 2 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 (5f554969b)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [921.37ms]
(pass) node none > Client Basics > should be able to send a POST request [626.06ms]
(pass) node none > Client Basics > constants [18.85ms]
(pass) node none > Client Basics > getDefaultSettings [7.85ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.78ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.67ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.21ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.11ms]
(pass) node none > Client Basics > should be able to send data using end [645.87ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [625.41ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 2 failed, 6 skipped
bun test v1.4.3-canary.1 (5f554969b)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.85ms]
(pass) node none > Client Basics > getDefaultSettings [0.14ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.30ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.13ms]
(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.09ms]
(pass) node none > Client Basics > is possible to abort request [1.69ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.74ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.67ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.81ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [29.86ms]
(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 (5f554969b)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [859.62ms]
(pass) node none > Client Basics > should be able to send a POST request [571.43ms]
(pass) node none > Client Basics > constants [18.52ms]
(pass) node none > Client Basics > getDefaultSettings [6.97ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.18ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.42ms]
(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.95ms]
(pass) node none > Client Basics > should be able to send data using end [595.39ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [583.86ms]
(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 614ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (7888ms)
Bundle modules (46ms)
Postprocesss modules (20ms)
Bundle Functions (540ms)
Generate Code (33ms)

[8.53s] Bundled "src/js" for production
  2595 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 32s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.3-canary.1+b3b42f48a
[build] done
bun test v1.4.3-canary.1 (b3b42f48a)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.82ms]
(pass) node none > Client Basics > getDefaultSettings [0.14ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.28ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.12ms]
(pass) node none > Client Basics > 
... (truncated)
diff hotspot
src/js/node/http2.ts                  | 26 +++++++++
 test/js/node/http2/node-http2.test.js | 99 +++++++++++++++++++++++++++++++++++
 2 files changed, 125 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                       9      5     13
test/js/node/http2/node-http2.test.js      4      4     13

…rts EOF

A transport that is not a net.Socket (an options.createConnection Duplex,
a stream handed to server.emit('connection')) reported EOF and nothing
happened: the session subscribed to data, drain, close, error and timeout
only. A pending request never settled and the session stayed alive.

Node wraps such a transport in a JSStreamSocket, a net.Socket with
allowHalfOpen off, so the EOF ends the transport's writable side and
destroys it, and that close tears the session down. Mirror net's
destroySoon on the transport itself.
@coderabbitai

coderabbitai Bot commented Sep 10, 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 1cce04a3-2225-4d45-bf4f-713ae2b5b6cd

📥 Commits

Reviewing files that changed from the base of the PR and between 4ff9193 and b3b42f4.

📒 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; 5 remain after this review.


Walkthrough

Changes

HTTP/2 generic transport EOF handling

Layer / File(s) Summary
Generic transport EOF handling
src/js/node/http2.ts
Server and client sessions register EOF handling for non-net.Socket transports. The transport ends on EOF and is destroyed immediately or after finish.
EOF teardown regression coverage
test/js/node/http2/node-http2.test.js
Tests cover client and server transport destruction, session and stream closure, event ordering, pending request abortion, and NGHTTP2_CANCEL reset codes.

Suggested reviewers: cirospaciari, jarred-sumner

Merge Risk: ⚪ Minimal · up to b3b42

Custom HTTP/2 transports now tear down cleanly after EOF while preserving pending writes, with client and server regression coverage. The change is ready to merge.

🚥 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 and concisely describes the primary change: tearing down HTTP/2 sessions when user-supplied transports report EOF.
Description check ✅ Passed The description explains the problem, root cause, fix, affected transport types, expected behavior, and verification results. It does not use the template headings exactly, but it provides the require…

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

@robobun

robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status:

  • Reproduced with the single-file repro in the Notes block (in-process Duplex pair, hand-written HTTP/2 peer) on bun 1.4.3-canary and main @4ff91937: no stream or session event after the transport's 'end'. node v26.3.0 closes both and destroys the transport.
  • The two new tests in test/js/node/http2/node-http2.test.js fail with USE_SYSTEM_BUN=1 and pass with the debug build.
  • Both automated reviews raised nothing actionable. The guard question one of them defers to a maintainer is answered below. Waiting on Buildkite #113862.

@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 this and didn't find any bugs. Because it changes http2 session teardown semantics for user-supplied Duplex transports and picks instanceof net.Socket as the guard (a deliberate divergence from Node's _handle.isStreamBase check), a human look from someone familiar with the http2 lifecycle would still be worthwhile.

What was reviewed:

  • watchGenericTransportEnd / destroyGenericTransportSoonNT against Node's onReadableStreamEnd/destroySoon sequence — the end→finish→destroy ordering and nextTick deferral match the cited upstream.
  • The guard leaves net.Socket/TLSSocket transports untouched, so the existing allowHalfOpen path is unaffected; net is captured at module load.
  • socket.once("finish", socket.destroy) — 'finish' emits with no args, so destroy() receives no spurious error; once binds this to the socket.
  • New tests: bounded setImmediate poll instead of sleeps, error events wired to the recorded sequence, per-object ordering asserted separately (sidesteps the known cross-object ordering divergence), describe.concurrent for independence.
Extended reasoning...

Overview

This PR adds an 'end' listener to non-net.Socket transports in both ClientHttp2Session and ServerHttp2Session constructors (src/js/node/http2.ts). When such a transport's readable side reaches EOF, the new destroyGenericTransportSoonNT helper ends the writable side and destroys the transport (immediately if writableFinished, else on 'finish'), which then flows into the existing 'close' handler to tear the session down. This mirrors what Node achieves by wrapping generic Duplex transports in a JSStreamSocket with allowHalfOpen: false. Two new tests in test/js/node/http2/node-http2.test.js cover the client and server sides over an in-memory duplexPair().

Security risks

None identified. The change adds no new user-controlled input parsing, no auth/crypto/permission logic, and no network-facing surface beyond what already exists. It only affects the (already niche) path where a caller supplies their own Duplex via createConnection or server.emit('connection', duplex). The instanceof net.Socket guard means the common TCP/TLS paths are untouched.

Level of scrutiny

Moderate. The production change is small (~24 lines across three named helpers plus two one-line call sites), well-cited against Node v26.3.0 source, and follows the file's existing conventions (process.nextTick, named functions rather than inline closures, net captured at module load). However, http2 session/stream lifecycle and event ordering is historically subtle in Bun — the PR description itself references three open divergences (#33802, #38124, and session.closed staying false). The instanceof net.Socket guard is a reasoned but non-literal port of Node's !socket._handle.isStreamBase check, and that design choice deserves a maintainer's eye. The tests deliberately assert per-object event order rather than the full interleaving to avoid depending on a known pre-existing divergence, which is the right call but also means the exact cross-object timing isn't locked down here.

Other factors

The tests follow the repo's conventions well: added to the existing node-http2.test.js, use a bounded setImmediate poll (settle) rather than sleeps, wire error events into the recorded sequence, use describe.concurrent, and assert a combined state object with .toEqual. No CODEOWNERS entry covers the changed paths. The bug hunt ran to dry_streak with no findings and no ruled-out candidates. Given the subtlety of http2 teardown timing and the guard being a deliberate architectural choice rather than a mechanical port, deferring to a human reviewer is the safer call over auto-approval.

@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

On the guard the review flags for a human (instanceof net.Socket where node tests !socket._handle || !socket._handle.isStreamBase):

  • Node's test asks "does this transport have a native StreamBase handle". Bun's net.Socket._handle is a native socket object with no isStreamBase property, so a literal port reads undefined on every socket and would wrap all of them.
  • The two tests pick the same population. Measured on node v26.3.0 and bun: a connecting net.Socket, a connected one, and tls.connect({ socket: duplex }) are all instanceof net.Socket (node: isStreamBase === true for all three), and a bare Duplex is neither. In node a JSStreamSocket is itself a net.Socket, so nothing that node leaves unwrapped gets the new listener here.
  • Guard pin: an http2 session over a real net.Socket with allowHalfOpen: true survives the peer's FIN and a ping still reaches the peer, client and server, on node, stock bun, and this build.

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