Skip to content

node:http2: destroy the transport when a transport error destroys a client session - #42175

Open
robobun wants to merge 4 commits into
mainfrom
robobun/7cd5e6a2/http2-client-transport-error-leak
Open

robobun wants to merge 4 commits into
mainfrom
robobun/7cd5e6a2/http2-client-transport-error-leak

Conversation

@robobun

@robobun robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 client session that takes a transport error destroys itself and leaves the transport open. The peer never sees a close. 100 sessions leak 100 connections and 200 fds. Node destroys the transport and leaks nothing.
  • Cause: ClientHttp2Session#onError (src/js/node/http2.ts:5422 on main) sets this[bunHTTP2Socket] = null before this.destroy(error). The socket step inside destroy() (GOAWAY, end(), then destroy()) finds no socket and does nothing.
  • Reached by any transport that reports an error without destroying itself: emit('error'), autoDestroy:false, a wrapper that forwards an inner error. A net.Socket that errors natively destroys itself, which hides the leak.

Fix

  • Remove the early detach. The session keeps the socket through destroy() and drops it after the teardown, as #onClose and the server session already do.
  • Correct because destroy() owns the socket teardown, as in Node: socketOnError calls session.destroy(error) with the socket attached, and finishSessionClose ends and destroys it. An already destroyed socket is unaffected: end() is a no-op and the delayed destroy checks socket.destroyed.
  • Self-reviewed: 2 concerns, 0 blocking. Reach is narrow (custom transports only). A Node-style destroyed flag is a follow-up (see Notes).
  • Verified: test/js/node/http2/node-http2.test.js (new test, stock bun fails it), all of test/js/node/http2/, and the 278 vendored test-http2-* files.

Background

  • An Http2Session runs over a transport: a socket it dials, or whatever options.createConnection returns. The session holds it under bunHTTP2Socket, and session.destroyed reads that reference.
  • #onError handles the transport's 'error' and calls destroy(), which sends the GOAWAY, ends the socket, destroys it a turn later, then drops the reference.
Notes

Repro (loopback, /proc for the fd count): 100 client sessions over createConnection: () => net.connect(), each transport emits one 'error' after the session connects. The far end is a raw TCP server that speaks an empty SETTINGS frame plus an ACK.

bun 1.4.2 and main @4ff91937:
  {"sessionsClosed":100,"last":{"sessionDestroyed":true,"transportDestroyed":false},
   "serverSideConnectionsStillOpen":100,"fdsBefore":10,"fdsAfter":210}
node v26.3.0:
  {"sessionsClosed":100,"last":{"sessionDestroyed":true,"transportDestroyed":true},
   "serverSideConnectionsStillOpen":0,"fdsBefore":23,"fdsAfter":23}
with this change:
  {"sessionsClosed":100,"last":{"sessionDestroyed":true,"transportDestroyed":false},
   "serverSideConnectionsStillOpen":0,"fdsBefore":10,"fdsAfter":10}

last.transportDestroyed is read when the session emits 'close'. It is still false there because bun emits the session's 'close' on the next tick, while node emits it from the socket's own 'close' listener. The transport is destroyed a few turns later, which is what the new test waits for. #38195 moves the session's 'close' behind the socket's.

The error path now also puts the final GOAWAY on the wire before the FIN, as Node's best-effort nghttp2_session_terminate_session does.

The early detach came in with the 2024 "H2 fixes" batch (#14606). No commit argues for it, and the only text that referenced it was the destroy() latch comment, now corrected. The #destroying latch itself is unchanged: destroy() is re-entered from inside its own teardown (stream 'error' listeners, the 'goaway' emit) before the socket detaches, so it cannot key off the destroyed getter.

Follow-up, not in this PR: session.destroyed means "socket detached" in bun and "destroy() ran" in node (a state flag). A flag would make the getter total during the teardown window. It would not close this leak on its own, so it is a separate change.

Related, and not covered by this change:

The server session's #onError does not detach, and a synthetic error on an accepted socket already destroys it (checked on main), so no server-side change was needed.

Test hardening after the first CI run: the macOS lanes saw the client's 'connect' before the server's accept callback, so the test now waits for both ends before it counts connections. The connect wait also rejects on an early session 'error' or 'close', and finally destroys the transport and session so a failing run releases the connection it just proved open.


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

fails on main (without fix)
ASAN without fix: 1 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 [852.31ms]
(pass) node none > Client Basics > should be able to send a POST request [578.14ms]
(pass) node none > Client Basics > constants [18.55ms]
(pass) node none > Client Basics > getDefaultSettings [6.86ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.85ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.36ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.05ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.90ms]
(pass) node none > Client Basics > should be able to send data using end [600.74ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [589.52ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 1 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.84ms]
(pass) node none > Client Basics > getDefaultSettings [0.14ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.29ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.12ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.03ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.07ms]
(pass) node none > Client Basics > is possible to abort request [1.72ms]
(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.67ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.74ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [29.29ms]
(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 [866.40ms]
(pass) node none > Client Basics > should be able to send a POST request [582.68ms]
(pass) node none > Client Basics > constants [19.02ms]
(pass) node none > Client Basics > getDefaultSettings [7.25ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [18.17ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.83ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.27ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.09ms]
(pass) node none > Client Basics > should be able to send data using end [609.01ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [598.26ms]
(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     e6018304f6
  features     baseline

23 deps, 131 codegen, 1172 objects in 881ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (5f554969b)

Checked 22 installs across 61 packages (no changes) [6.00ms]
[2/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (5f554969b)

Checked 1 install across 2 packages (no changes) [8.00ms]
[3/1244] fetch zlib
[zlib] up to date
[4/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1217] gen ErrorCode+*.h
[6/1217] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (5f554969b)

Checked 111 installs across 104 packages (no changes) [17.00ms]
[7/1217] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[8/1217] gen .bind.ts → GeneratedBindings.cpp
[9/1217] fetch tinycc
[tinycc] up to date
[10/1216] gen bindgenv2
[11/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server
... (truncated)
diff hotspot
src/js/node/http2.ts                  | 22 ++--------
 test/js/node/http2/node-http2.test.js | 75 +++++++++++++++++++++++++++++++++++
 2 files changed, 79 insertions(+), 18 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                      12      7     20
test/js/node/http2/node-http2.test.js      4      4     20

root cause · written by the author bot

The client HTTP/2 session's transport 'error' handler set its internal socket reference to null before calling destroy(), and because destroy() only performs its GOAWAY, end, and socket-destroy step when that reference is still set, the teardown was skipped and any transport that reported an error without destroying itself stayed open, leaking the connection and its file descriptor. The fix removes that premature detach so destroy() runs with the socket still attached and ends and destroys it, matching the server session's error path and Node's behavior. A regression test confirms the sessi…

…lient session

ClientHttp2Session#onError detached the socket from the session before it
called destroy(error). The socket teardown inside destroy() then had no
socket to end or destroy, so a transport that reports an error without
destroying itself stayed open and leaked the connection and its fd.

Keep the socket attached and let destroy() release it after it has ended
and destroyed it.
@robobun

robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI is green on e601830 (build #113732, 181/181).

How I reproduced it: 100 client sessions over createConnection: () => net.connect() against a raw TCP server that speaks an empty SETTINGS frame plus an ACK. Each transport emits one 'error' after its session connects. On main the sessions all report destroyed, the transports do not, the server still counts 100 open connections, and the process fd count goes from 10 to 210. Node v26.3.0 closes every connection and its fd count does not move. With this change the fd count does not move either.

Verified with bun bd test test/js/node/http2/node-http2.test.js (the new case fails with the released bun), all of test/js/node/http2/ (499 tests), and the 278 test-http2-* files in test/js/node/test/parallel/.

Reviewed: this PR should stay open because it is a one-line Node-parity fix for a measured fd leak, and it composes with #38195 in either merge order.

@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: 3b9d1d04-bf4c-4c35-ad75-973374b29eda

📥 Commits

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

📒 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 transport error teardown

Layer / File(s) Summary
Preserve socket during transport-error destruction
src/js/node/http2.ts, test/js/node/http2/node-http2.test.js
Client session error handling keeps the socket attached during destroy(). The regression test verifies session errors, session and socket destruction, peer closure, and connection cleanup.

Suggested reviewers: cirospaciari, jarred-sumner

Merge Risk: ⚪ Minimal · up to af39c

HTTP/2 client transport errors now complete session and connection teardown rather than leaving sockets open. The targeted regression coverage confirms the expected cleanup behavior, with no current merge-blocking risk identified.

🚥 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 specifically summarizes the main change: destroying the transport when a client HTTP/2 session encounters a transport error.
Description check ✅ Passed The description explains the problem, root cause, fix, scope, regression test, and verification results. It does not use the exact template headings, but it provides the required information and is co…

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 — nit: the guard comment in ClientHttp2Session#destroy() still says the destroyed getter "reads 'socket detached', which #onError sets before calling in here" — this PR removes that detach, so the comment now describes behavior that no longer exists and misleads the next reader about why the #destroying latch is used instead of this.destroyed. Fix: update or delete the stale clause so the comment matches the new #onError.

    Extended reasoning...

    src/js/node/http2.ts:5784-5786 (inside ClientHttp2Session#destroy) explains the #destroying latch by pointing at #onError nulling this[bunHTTP2Socket] before calling destroy(). This diff deletes exactly that null-out (the whole point of the fix), leaving the comment factually wrong. A future maintainer reading it may reintroduce the early detach or misjudge the guard's purpose. Pre-existing text made stale by this change; comment-only, no runtime impact.

    Verification: nit — the clause is now factually wrong after this diff. src/js/node/http2.ts:5784-5786 reads: "Guard on a latch, not the destroyed getter: that getter reads "socket detached", which #onError sets before calling in here, and the error-carrying destroy must still run once." The destroyed getter does read socket-detached (line 5470: return this[bunHTTP2Socket] === null;), but this PR's…

Comment thread src/js/node/http2.ts Outdated
Comment thread test/js/node/http2/node-http2.test.js
Comment thread test/js/node/http2/node-http2.test.js
…, fail the connect wait on an early session error, and release the client end in finally; trim the #onError comment and fix the stale destroy() latch comments
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated

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

LGTM — thanks for addressing the earlier feedback; all three points are resolved in the current diff.

What was reviewed:

  • The one-line behavioral change in ClientHttp2Session.#onError: dropping the early this[bunHTTP2Socket] = null so destroy() still holds the socket — matches Node's socketOnError → session.destroy(error) flow, and #onClose already does the detach after teardown.
  • Sibling ServerHttp2Session.#onError (line 4331) checked for the same pattern — it never detached, so no server-side change is needed; the two condensed idempotence comments are behavior-neutral (the #destroying latch is unchanged).
  • The new test: hermetic loopback net.Server on port: 0, 'connect' await now rejects on session 'error'/'close', bounded setImmediate poll for transport.destroyed, and finally now tears down transport/session before server.close().
Extended reasoning...

Overview

The PR removes a single line — this[bunHTTP2Socket] = null; — from ClientHttp2Session.#onError in src/js/node/http2.ts, so that when a transport error triggers session.destroy(error), the socket is still attached and the teardown can end() and destroy() it. It also corrects a comment typo (the cross-reference now points at the server session's #onError), condenses two long idempotence comments on destroy() to one line each with the Node source citation retained, and adds one regression test to test/js/node/http2/node-http2.test.js.

Since the previous review, four commits landed that address all three earlier inline comments: the five-line change-narration comment is now a one-line invariant ("The socket stays attached so destroy() can end and destroy it."), the test's 'connect' await now wires sessionError/sessionClosed to reject, and the finally block now destroys transport and session before server.close().

Security risks

None. This is a resource-leak fix in the node:http2 client teardown path; no auth, crypto, permission, or input-parsing surface is touched. The change makes cleanup strictly more thorough (the socket is now closed on the error path where it previously leaked).

Level of scrutiny

Low-to-moderate. The behavioral delta is one deleted line whose effect is well-understood: destroy() reads this[bunHTTP2Socket] to run its socket step, and nulling it beforehand skipped that step. The PR description cites Node's socketOnError and finishSessionClose, and the retained comment links Http2Session#destroy at v26.3.0. I confirmed the sibling ServerHttp2Session.#onError never had this detach, so the "fix the whole class" check is satisfied. The comment condensations touch no code — the #destroying latch guard is byte-identical.

Other factors

The new test is hermetic (local net.createServer speaking a minimal SETTINGS preface, port: 0), awaits real events with failure paths wired to reject, polls transport.destroyed with a bounded 200-tick setImmediate loop rather than sleeping, asserts the peer observes the close via peerClosed, and releases resources in finally in the right order. The PR reports the full test/js/node/http2/ suite and the test-http2-* parallel tests pass. No outstanding CHANGES_REQUESTED from other reviewers; the bot-resolved threads were verified addressed by reading the current diff. Exit reason was dry_streak.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun please run the tests this PR adds or changes on Node.js v26.3.0 and report whether each one passes or fails.
They must pass on both Bun and Node v26.3.0, so fix any case that only passes on Bun.

@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Ran it. This PR adds one test and changes no others: destroys the transport when a transport 'error' tears the client session down in test/js/node/http2/node-http2.test.js.

That file goes through node-harness (Bun.jest), so it cannot load under Node as is. I ported the test body line for line onto node:test + node:assert (script below, same server, same waits, same assertions) and ran that one file under each runtime:

Runtime Result
Node.js v26.3.0 (node --test) pass (13.9 ms)
Bun, this PR (e601830, debug build) pass
Bun 1.4.2 release (no fix) fail: AssertionError: transport.destroyed (expected true, got false)

So the test passes on both Node v26.3.0 and Bun with this change, and fails on Bun without it at the assertion the fix is about. Nothing needed changing for Node.

Ported script
// node --test h2-transport-error.node.test.mjs   |   bun test ./h2-transport-error.node.test.mjs
import assert from "node:assert";
import http2 from "node:http2";
import net from "node:net";
import { test } from "node:test";

test("destroys the transport when a transport 'error' tears the client session down", async () => {
  function frame(type, flags) {
    const header = Buffer.alloc(9);
    header[3] = type;
    header[4] = flags;
    return header;
  }
  let connectionsOpen = 0;
  const { promise: peerConnected, resolve: resolvePeerConnected } = Promise.withResolvers();
  const { promise: peerClosed, resolve: resolvePeerClosed } = Promise.withResolvers();
  const server = net.createServer(connection => {
    connectionsOpen++;
    connection.on("close", () => {
      connectionsOpen--;
      resolvePeerClosed();
    });
    connection.on("error", () => {});
    connection.write(frame(4, 0));
    connection.once("data", () => connection.write(frame(4, 1)));
    resolvePeerConnected();
  });
  let transport;
  let session;
  try {
    const port = await new Promise((resolve, reject) => {
      server.once("error", reject);
      server.listen(0, "127.0.0.1", () => resolve(server.address().port));
    });
    session = http2.connect(`http://127.0.0.1:${port}`, {
      createConnection: () => (transport = net.connect(port, "127.0.0.1")),
    });
    const injected = Object.assign(new Error("transport failed"), { code: "EFAIL" });
    const sessionError = new Promise(resolve => session.once("error", resolve));
    const sessionClosed = new Promise(resolve => session.once("close", resolve));
    const connected = new Promise((resolve, reject) => {
      session.once("connect", resolve);
      sessionError.then(reject);
      sessionClosed.then(() => reject(new Error("session closed before 'connect'")));
    });
    await Promise.all([connected, peerConnected]);
    assert.strictEqual(connectionsOpen, 1);

    transport.emit("error", injected);

    assert.strictEqual(await sessionError, injected);
    await sessionClosed;
    assert.strictEqual(session.destroyed, true);

    for (let tick = 0; tick < 200 && !transport.destroyed; tick++) {
      await new Promise(resolve => setImmediate(resolve));
    }
    assert.strictEqual(transport.destroyed, true, "transport.destroyed");
    await peerClosed;
    assert.strictEqual(connectionsOpen, 0);
  } finally {
    transport?.destroy();
    session?.destroy();
    server.close();
  }
});

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