← 输入框|github.com|刷新
Skip to content

http2: client session leaks (no 'error', no 'close', socket never destroyed) when the server rejects the TLS handshake #67213

Open

Description

@dvergnes

Version

v22.22.2. Also reproduced on v18.20.4, v20.18.3, v22.14.0, v24.16.0 and v26.3.0. The code paths below are unchanged on main.

Platform

Darwin 25.6.0 arm64 (macOS). Also observed in production on Linux x64 (Node 22.22.2, OpenSSL 3.5.5).

Subsystem

http2, tls

What steps will reproduce the bug?

Save as repro.js and run node --expose-gc repro.js. It has no dependencies; the key and certificate are a throwaway self-signed pair for CN=localhost.

The server requires a client certificate (requestCert + rejectUnauthorized). The client sends none, so OpenSSL on the server side sends a fatal TLS alert (116 certificate required). With TLS 1.3 the client has already finished its side of the handshake when the alert arrives. In production we hit the same thing when a gRPC server rejected an expired client certificate (alert 45 certificate expired).

'use strict';
// Run: node --expose-gc repro.js [iterations]
const http2 = require('node:http2');

// Throwaway self-signed test certificate for CN=localhost (not a secret).
const key = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCUEnXOaW6XdF+Uli
xaXWH4jND4SgrxgWoCiPaseL04ihRANCAASmz8jUAlKXn9rtXvNEo/89lAIekPtG
Y03r5IK555fTgTPDm0kLbv9S43v9VXz/u1d/csVZcYYblXxETHYRkinf
-----END PRIVATE KEY-----
`;
const cert = `-----BEGIN CERTIFICATE-----
MIIBczCCARigAwIBAgIUC1XONRc6u557juM0koDJlMoldCkwCgYIKoZIzj0EAwIw
FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyOTE1NTIzNloXDTM2MTAyNjE1
NTIzNlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D
AQcDQgAEps/I1AJSl5/a7V7zRKP/PZQCHpD7RmNN6+SCueeX04Ezw5tJC27/UuN7
/VV8/7tXf3LFWXGGG5V8REx2EZIp36NIMEYwHQYDVR0OBBYEFIwoK1GT0vaH2Ty2
OEv2TwymxkviMA8GA1UdEwEB/wQFMAMBAf8wFAYDVR0RBA0wC4IJbG9jYWxob3N0
MAoGCCqGSM49BAMCA0kAMEYCIQChoXNku5ZKVpPGLBCCt2sMeLJi3eRvIFM/PmEa
xd3jQQIhAP6B0BXplq2wD0Hl179L7HRbCSBkUwbjJ3Fdg0HgvOpL
-----END CERTIFICATE-----
`;



// The server requires a client certificate. Because the client sends none,
// OpenSSL on the server side rejects it with a fatal TLS alert
// (116 "certificate required"). With TLS 1.3 the client has already finished
// its side of the handshake, so the alert arrives after 'connect'.
const server = http2.createSecureServer({
  key, cert, requestCert: true, rejectUnauthorized: true,
});
server.on('tlsClientError', () => {});

server.listen(0, async () => {
  const url = `https://localhost:${server.address().port}`;
  const iterations = Number(process.argv[2] || 20);
  const sessions = [];

  for (let i = 0; i < iterations; i++) {
    await new Promise((resolve) => {
      const session = http2.connect(url, { rejectUnauthorized: false });
      const rec = { ref: new WeakRef(session), error: false, close: false };
      sessions.push(rec);
      session.on('error', () => { rec.error = true; });
      session.on('close', () => { rec.close = true; resolve(); });
      setTimeout(resolve, 500); // give up waiting for 'close'
    });
  }

  server.close();
  for (let i = 0; i < 5; i++) {
    globalThis.gc();
    await new Promise((r) => setTimeout(r, 100));
  }

  const alive = sessions.map((s) => s.ref.deref()).filter(Boolean);
  console.log({
    node: process.version,
    sessions: iterations,
    errorEmitted: sessions.filter((s) => s.error).length,
    closeEmitted: sessions.filter((s) => s.close).length,
    aliveAfterGC: alive.length,
    aliveAndDestroyed: alive.filter((s) => s.destroyed).length,
    aliveWithSocketNotDestroyed: alive.filter((s) => s.socket && !s.socket.destroyed).length,
  });
  process.exit(0);
});

How often does it reproduce? Is there a required condition?

Always (20/20 sessions on every version listed above).

Required conditions:

  • an http2.connect() client over TLS 1.3, and
  • the server sends a fatal alert after the client's handshake is complete, so the client is already _secureEstablished. A server rejecting the client certificate is the common real-world case: expired, unknown CA, missing, or revoked certificate.
  • The server keeps the TCP connection open after sending the alert, or at least doesn't close it before the client reacts. OpenSSL-based servers do this, as does Node's own server in the repro.

If the TCP connection is reset or closed right away, the session closes normally.

What is the expected behavior? Why is that the expected behavior?

errorEmitted: 20, closeEmitted: 20, aliveAfterGC: 0

The TLS error should surface as an 'error' event on the session, followed by 'close', and the session and its socket should be released.

What do you see instead?

{ node: 'v22.22.2', sessions: 20, errorEmitted: 0, closeEmitted: 0, aliveAfterGC: 20, aliveAndDestroyed: 20, aliveWithSocketNotDestroyed: 20 }
  • session.destroyed === true, but neither 'error' nor 'close' is ever emitted.
  • The underlying TLSSocket is never destroyed. Its handle and file descriptor stay open forever.
  • The session, the socket, the native Http2Session (nghttp2 state), a pending WriteWrap, and everything reachable from the listeners are never garbage-collected.

Growth over 1000 failed connections (measure.js, attached):

before: { heapUsedMB: 4.5, rssMB: 48.4,  openFds: 23,   activeSocketHandles: 0 }
after:  { heapUsedMB: 10.7, rssMB: 96.3, openFds: 1026, activeSocketHandles: 1000 }

In production, a gRPC client (@grpc/grpc-js) kept reconnecting to a server that rejected its expired client certificate. A heap snapshot showed ~6,900 leaked ClientHttp2Session objects, each with a pending native SimpleWriteWrap and Http2Settings callback. Memory grew until the process was restarted, and file descriptors leaked along with it.

Additional information

Analysis

Native trace (NODE_DEBUG_NATIVE=TLS,HTTP2SESSION, client side, full log attached):

TLSWrap Got SSL error (1), calling onerror
Http2Session destroying session
Http2Session closing session
TLSWrap ReadStop()
Http2Session terminating session with code 2
Http2Session sending pending data
Http2Session nghttp2 has 17 bytes to send          <- GOAWAY
TLSWrap DoWrite()
TLSWrap Writing 17 bytes, written = -1
TLSWrap Saving data for later write                 <- write stays pending forever
TLSWrap Trying to write encrypted output
TLSWrap EncOut() write is scheduled
TLSWrap No pending encrypted output
                                                    <- nothing else, ever: no "make done session callback"

Step by step (links to v22.22.2):

  1. The alert arrives after the TLS handshake is established, so the TLS onerror handler takes the _emitTLSError(err) branch. It emits 'error' without destroying the socket (lib/internal/tls/wrap.js#L480-L496).
  2. http2's socketOnError calls session.destroy(error) (lib/internal/http2/core.js#L3229-L3239). closeSession then sets handle.ondone = finishSessionClose and calls handle.destroy(code, socket.destroyed) with socket.destroyed === false (core.js#L1219-L1248).
  3. Http2Session::Close(code, socket_closed=false) stops reading, terminates the nghttp2 session and calls SendPendingData() to send a GOAWAY (src/node_http2.cc#L816-L855).
  4. In TLSWrap::DoWrite, SSL_write() returns -1 on the dead TLS connection. SSL_get_error() returns something other than SSL_ERROR_SSL/SSL_ERROR_SYSCALL, so the data is kept "for later write" and current_write_ stays set (src/crypto/crypto_tls.cc#L1118-L1135). "Later" never comes: reading was stopped in step 3, and the TLS connection can't make progress after a fatal alert anyway.
  5. Back in Http2Session::Close, is_write_in_progress() is true, so ondone isn't called ("If we are writing we will get to make the callback in OnStreamAfterWrite", node_http2.cc#L845-L856). OnStreamAfterWrite never runs.
  6. ondone is finishSessionClose, which is the only code that ends/destroys the socket and emits 'error'/'close' (core.js#L1187-L1217). Because it never runs, nobody destroys the socket, and the session stays half-closed forever.

The result is a deadlock: the session waits for the write to finish before releasing the socket, and the write can only finish once something unblocks the socket.

Workaround

Own the socket via createConnection and destroy it on 'error' (workaround.js, attached; 20/20 sessions emit 'close' and are collected):

const session = http2.connect(url, {
  createConnection: () => {
    const socket = tls.connect({ host, port, ALPNProtocols: ['h2'] /* ... */ });
    socket.on('error', () => socket.destroy());
    return socket;
  },
});

This doesn't help users of http2.connect(url) with default options, because session.socket is a proxy that forbids destroy().

Possible fixes (suggestions only, not verified)

  • In socketOnError, also destroy the socket for TLS errors on an established TLS socket, or pass socket_closed = true to the native side so Close() doesn't try to send a GOAWAY over a broken TLS connection.
  • In TLSWrap::DoWrite, treat SSL_write() failures after a fatal alert as fatal (return UV_EPROTO like the SSL_ERROR_SSL/SSL_ERROR_SYSCALL case) instead of queueing the data.
  • In Http2Session::Close, don't wait for an in-progress write that can't complete because reading has been stopped and the stream has errored.

Activity

  1. inoway46 commented on Nov 3, 2026

    @inoway46
    Contributor

    I reproduced this on v26.13.0, but not on current main. The difference appears to be related to the OpenSSL 4.1.0 upgrade in #66971, although I haven't isolated that change to confirm.

    I've opened #67215 as a proposed HTTP/2-side fix, with the aim of backporting it to v26.

  2. added
    tlsIssues and PRs related to the tls subsystem.
    http2Issues and PRs related to the http2 subsystem.
    on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    http2Issues and PRs related to the http2 subsystem.tlsIssues and PRs related to the tls subsystem.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions