Repository navigation
Description
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
TLSSocketis never destroyed. Its handle and file descriptor stay open forever. - The session, the socket, the native
Http2Session(nghttp2 state), a pendingWriteWrap, 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):
- The alert arrives after the TLS handshake is established, so the TLS
onerrorhandler takes the_emitTLSError(err)branch. It emits'error'without destroying the socket (lib/internal/tls/wrap.js#L480-L496). - http2's
socketOnErrorcallssession.destroy(error)(lib/internal/http2/core.js#L3229-L3239).closeSessionthen setshandle.ondone = finishSessionCloseand callshandle.destroy(code, socket.destroyed)withsocket.destroyed === false(core.js#L1219-L1248). Http2Session::Close(code, socket_closed=false)stops reading, terminates the nghttp2 session and callsSendPendingData()to send a GOAWAY (src/node_http2.cc#L816-L855).- In
TLSWrap::DoWrite,SSL_write()returns-1on the dead TLS connection.SSL_get_error()returns something other thanSSL_ERROR_SSL/SSL_ERROR_SYSCALL, so the data is kept "for later write" andcurrent_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. - Back in
Http2Session::Close,is_write_in_progress()is true, soondoneisn't called ("If we are writing we will get to make the callback in OnStreamAfterWrite", node_http2.cc#L845-L856).OnStreamAfterWritenever runs. ondoneisfinishSessionClose, 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 passsocket_closed = trueto the native side soClose()doesn't try to send a GOAWAY over a broken TLS connection. - In
TLSWrap::DoWrite, treatSSL_write()failures after a fatal alert as fatal (returnUV_EPROTOlike theSSL_ERROR_SSL/SSL_ERROR_SYSCALLcase) 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
inoway46 commentedon Nov 3, 2026
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.
tls: destroy sockets after fatal errors
tls: destroy sockets after fatal errors