Version
22.23.2
Platform
Amazon Linux 2023, x86_64 and aarch64
Subsystem
No response
What steps will reproduce the bug?
Version
v22.23.2 (verified at runtime); same code present in v24.18.0, v26.8.2, v26.x and main
Platform
Linux (Amazon Linux 2023, x86_64 and aarch64), OpenSSL 3.5.7, glibc malloc β not platform specific
Subsystem
tls
What steps will reproduce the bug?
const tls = require('node:tls');
const socket = tls.connect({ host: 'example.com', port: 443 }, () => {
console.log(socket.ssl._secureContext.singleUse); // undefined β expected true
socket.end();
});
tls.connect() sets options.singleUse = true (lib/internal/tls/wrap.js), but the context
it builds never carries the flag.
How often does it reproduce?
Always.
What is the expected behavior?
TLSSocket.prototype._destroySSL() and TLSWrap.prototype.close() both check
ssl._secureContext.singleUse and call context.close(), which frees the underlying
SSL_CTX when the socket closes. Up to v15.14.0 createSecureContext() copied the flag
onto the context it returned (lib/_tls_common.js):
if (options.singleUse) {
c.singleUse = true;
c.context.setFreeListLength(0);
}
What do you see instead?
Since v16.0.0, when context configuration moved into configSecureContext(), that copy was
dropped. singleUse now appears only in lib/internal/tls/wrap.js β nothing assigns it to a
context β so ssl._secureContext.singleUse is always undefined, context.close() is never
called, and each connection's SSL_CTX lives until V8 collects the JS SecureContext wrapper.
Two things make that costly:
SecureContext reports kExternalSize = 1024 bytes to V8 (src/crypto/crypto_context.h),
while an OpenSSL 3 SSL_CTX is roughly 16 KB β and far larger when ca is supplied, since
the context then holds its own parsed copy of the root store. V8 therefore sees almost no
reason to collect. (TLSWrap accounts ~50 KB but subtracts it in Destroy() at close, so
nothing remains on the books.)
- In a process with a low allocation rate, major GCs can be more than an hour apart, so the
dead contexts accumulate for that whole period.
Measured with malloc_stats() before and after a forced full GC, after ~200 sequential
client connections (Node v22.23.2):
|
in use after churn |
after full GC |
held per closed socket |
| as shipped (context per connection) |
7.24 MB |
4.09 MB |
~16 KB |
createSecureContext patched to copy singleUse |
3.99 MB |
3.99 MB |
~0 |
one shared secureContext passed to tls.connect |
3.99 MB |
3.98 MB |
~0 |
Production impact for us: a MongoDB connection pool recycling idle connections
(minPoolSize: 5, maxIdleTimeMS: 60000, four worker threads) grew RSS ~4.5 MB per 10
minutes with trust supplied through NODE_EXTRA_CA_CERTS, and ~113 MB per 10 minutes when
the CA bundle was passed as tlsCAFile β each connection's context then parses its own copy
of the 96-certificate bundle. Passing one shared secureContext stops the growth entirely.
Additional note: the keepAlive condition
tls.connect() only sets singleUse when !options.keepAlive. A client that enables TCP
keepalive (the MongoDB driver sets keepAlive: true unconditionally) therefore never gets the
early close even if the propagation is restored. Since the context is built per connection
either way, and keepAlive concerns the TCP socket rather than the TLS context, should the
context be closed on socket close regardless of that flag?
Possible fixes
- Restore the copy in
createSecureContext() β one line.
- Report a realistic external size for
SecureContext so V8 accounts for it.
- Reconsider the
keepAlive condition, per the note above.
How often does it reproduce? Is there a required condition?
every time
What is the expected behavior? Why is that the expected behavior?
to release memory
What do you see instead?
memory keep increasing
Additional information
No response
Version
22.23.2
Platform
Subsystem
No response
What steps will reproduce the bug?
Version
v22.23.2 (verified at runtime); same code present in v24.18.0, v26.8.2, v26.x and main
Platform
Linux (Amazon Linux 2023, x86_64 and aarch64), OpenSSL 3.5.7, glibc malloc β not platform specific
Subsystem
tls
What steps will reproduce the bug?
tls.connect()setsoptions.singleUse = true(lib/internal/tls/wrap.js), but the contextit builds never carries the flag.
How often does it reproduce?
Always.
What is the expected behavior?
TLSSocket.prototype._destroySSL()andTLSWrap.prototype.close()both checkssl._secureContext.singleUseand callcontext.close(), which frees the underlyingSSL_CTXwhen the socket closes. Up to v15.14.0createSecureContext()copied the flagonto the context it returned (lib/_tls_common.js):
What do you see instead?
Since v16.0.0, when context configuration moved into
configSecureContext(), that copy wasdropped.
singleUsenow appears only inlib/internal/tls/wrap.jsβ nothing assigns it to acontext β so
ssl._secureContext.singleUseis alwaysundefined,context.close()is nevercalled, and each connection's
SSL_CTXlives until V8 collects the JSSecureContextwrapper.Two things make that costly:
SecureContextreportskExternalSize = 1024bytes to V8 (src/crypto/crypto_context.h),while an OpenSSL 3
SSL_CTXis roughly 16 KB β and far larger whencais supplied, sincethe context then holds its own parsed copy of the root store. V8 therefore sees almost no
reason to collect. (
TLSWrapaccounts ~50 KB but subtracts it inDestroy()at close, sonothing remains on the books.)
dead contexts accumulate for that whole period.
Measured with
malloc_stats()before and after a forced full GC, after ~200 sequentialclient connections (Node v22.23.2):
createSecureContextpatched to copysingleUsesecureContextpassed totls.connectProduction impact for us: a MongoDB connection pool recycling idle connections
(
minPoolSize: 5,maxIdleTimeMS: 60000, four worker threads) grew RSS ~4.5 MB per 10minutes with trust supplied through
NODE_EXTRA_CA_CERTS, and ~113 MB per 10 minutes whenthe CA bundle was passed as
tlsCAFileβ each connection's context then parses its own copyof the 96-certificate bundle. Passing one shared
secureContextstops the growth entirely.Additional note: the keepAlive condition
tls.connect()only setssingleUsewhen!options.keepAlive. A client that enables TCPkeepalive (the MongoDB driver sets
keepAlive: trueunconditionally) therefore never gets theearly close even if the propagation is restored. Since the context is built per connection
either way, and keepAlive concerns the TCP socket rather than the TLS context, should the
context be closed on socket close regardless of that flag?
Possible fixes
createSecureContext()β one line.SecureContextso V8 accounts for it.keepAlivecondition, per the note above.How often does it reproduce? Is there a required condition?
every time
What is the expected behavior? Why is that the expected behavior?
to release memory
What do you see instead?
memory keep increasing
Additional information
No response