Skip to content

tls: createSecureContext() drops options.singleUse, so per-connection contexts live until GCΒ #66002

Description

@naturalfreak

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

  1. Restore the copy in createSecureContext() β€” one line.
  2. Report a realistic external size for SecureContext so V8 accounts for it.
  3. 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

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions