Skip to content

tls: propagate singleUse to the secure context - #66025

Open
ViniciusDev26 wants to merge 2 commits into
nodejs:mainfrom
ViniciusDev26:fix-tls-single-use-context
Open

ViniciusDev26 wants to merge 2 commits into
nodejs:mainfrom
ViniciusDev26:fix-tls-single-use-context

Conversation

@ViniciusDev26

Copy link
Copy Markdown

tls.connect() sets options.singleUse = true, and both
TLSWrap.prototype.close() and TLSSocket.prototype._destroySSL() check
ssl._secureContext.singleUse before calling context.close() to release the
SSL_CTX when the socket closes. Nothing ever assigned that flag to the
context, so the check never fired:

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();
});

createSecureContext() used to copy the flag onto the context it returned. The
copy was lost when the context configuration was extracted into
configSecureContext(), which receives the native context rather than the JS
wrapper the flag is read from. Every client connection that builds its own
context therefore keeps its SSL_CTX alive until V8 collects the wrapper, which
SecureContext discourages by reporting kExternalSize = 1024 for an object
that is roughly 16 KB (much more when ca is supplied).

Changes

  1. lib/internal/tls/common.js — restore the assignment in
    createSecureContext(), which owns the JS wrapper.

  2. lib/internal/tls/wrap.js — make the close idempotent. This is required by
    the first change rather than optional: net.Socket.prototype[kReinitializeHandle]
    closes the previous handle, and a socket that swaps its handle shares one
    SecureContext across the successive TLSWraps, so the teardown is reached
    more than once for the same context. Without the guard the second pass hits
    null.close(). This affects autoSelectFamily retries, not just the
    kReinitializeHandle test.

  3. test/parallel/test-tls-connect-single-use-context.js — new regression test.
    It covers both sides: a context created by tls.connect() is marked single
    use and is closed with the socket, while a context supplied by the user is
    neither marked nor closed and still works for a following connection.

Verification

  • The new test fails on main with undefined !== true at the singleUse
    assertion, matching the report, and passes with the change.
  • Instrumented a socket across several kReinitializeHandle() calls to confirm
    the shared context survives each handle swap and is closed exactly once, when
    the socket closes, rather than prematurely on the first swap.
  • test/parallel/test-tls-*, test-https-* and test-http2-*: 587 passing,
    0 failing.
  • Full parallel and sequential suites: 5033 passing. The 6 failures
    (debugger, permission, watch-mode) reproduce unchanged with the patch
    reverted, so they are pre-existing on this machine and unrelated.

Not addressed here

The issue lists two further items that I left out to keep this a focused
regression fix, and that I think deserve a maintainer decision:

  • Reporting a realistic kExternalSize for SecureContext.
  • The !options.keepAlive condition on singleUse in tls.connect(), which
    means a client enabling TCP keepalive (the MongoDB driver does so
    unconditionally) still never gets the early close.

AI disclosure

Parts of this change were drafted with the help of an AI coding assistant. Its
analysis was treated as a hypothesis and checked against the source rather than
taken at face value:

  • The regression was traced to the commit that dropped the assignment, and the
    claim that singleUse is never assigned anywhere was confirmed by grepping
    lib/ and src/.
  • The removed setFreeListLength(0) call from the original block was checked
    to no longer exist in the tree, so only the flag needed restoring.
  • The new test was confirmed to fail without the change and pass with it.
  • The double-close was found by running the suite, not predicted: the first
    draft of this patch broke test-tls-reinitialize-listeners, which led to the
    idempotency guard.
  • The handle-swap behaviour was verified with an instrumented socket rather
    than assumed from reading the code.
  • The unrelated suite failures were confirmed to reproduce with the patch
    reverted.

I have reviewed the diff, understand it, and will handle review feedback myself.

Fixes: #66002
Refs: #38116

tls.connect() sets options.singleUse, and both TLSWrap.prototype.close()
and TLSSocket.prototype._destroySSL() check _secureContext.singleUse
before closing the context. Nothing ever assigned the flag to the
context, so the check never fired and each connection's SSL_CTX stayed
alive until the JS wrapper was garbage collected. The assignment was
lost when the context configuration was extracted into
configSecureContext().

Restore it in createSecureContext(), which owns the JS wrapper the flag
is read from, and make the close idempotent: a socket that swaps its
handle, as autoSelectFamily retries do, shares a single context between
the successive TLSWraps and each of them reaches the same teardown.

Signed-off-by: Carlos Vinicius <viniciusdev.26@gmail.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/crypto
  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. tls Issues and PRs related to the tls subsystem. labels Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Welcome to Node.js, and thank you for your first contribution!

Before review, please take a moment to read:

Please make sure every commit is signed off. For a first pull request, GitHub Actions require collaborator approval and Jenkins CI must be started by a collaborator or triager, so an initial wait is normal.

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.21%. Comparing base (c1e2478) to head (e7be7bd).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66025      +/-   ##
==========================================
- Coverage   90.23%   90.21%   -0.02%     
==========================================
  Files         785      785              
  Lines      269670   269699      +29     
  Branches    51589    51586       -3     
==========================================
- Hits       243325   243311      -14     
- Misses      16825    16865      +40     
- Partials     9520     9523       +3     
Files with missing lines Coverage Δ
lib/internal/tls/common.js 97.57% <100.00%> (+0.12%) ⬆️
lib/internal/tls/wrap.js 95.60% <100.00%> (+0.43%) ⬆️

... and 30 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasnell jasnell added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 15, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 15, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

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

I reproduced a regression on macOS x64 by applying this PR’s diff (e7be7bd) to main (1f6fe2f) and rebuilding normally.

With keepAlive: false, retrying two unavailable addresses before a reachable one causes an uncaught ERR_TLS_INVALID_CONTEXT. Unpatched main succeeds.

Reproduction script (run from the repository root)
const tls = require('node:tls');
const fs = require('node:fs');

const server = tls.createServer({
  key: fs.readFileSync('test/fixtures/keys/agent1-key.pem'),
  cert: fs.readFileSync('test/fixtures/keys/agent1-cert.pem'),
}, socket => socket.end('ok'));

server.listen(0, '127.0.0.1', () => {
  const socket = tls.connect({
    host: 'localhost',
    port: server.address().port,
    rejectUnauthorized: false,
    keepAlive: false,
    autoSelectFamily: true,
    lookup(host, options, callback) {
      setImmediate(callback, null, [
        { address: '::1', family: 6 },
        { address: '127.0.0.2', family: 4 },
        { address: '127.0.0.1', family: 4 },
      ]);
    },
  });

  socket.on('data', data => console.log(data.toString()));
  socket.on('error', console.error);
  socket.on('close', () => server.close());
});

Unpatched main — exit code 0:

ok

Main + this PR — exit code 1 (stack excerpt):

TypeError [ERR_TLS_INVALID_CONTEXT]: context must be a SecureContext
    at TLSSocket._wrapHandle (node:internal/tls/wrap:825:11)
    at TLSSocket.reinitializeHandle (node:internal/tls/wrap:855:22)
    at internalConnectMultiple (node:net:1505:30)
    at Timeout.internalConnectMultipleTimeout (node:net:2192:5)

The context is closed during handle replacement, but subsequent TLSWraps still need it. The null guard prevents double-close, not premature cleanup. Could we preserve the context throughout retries and add a regression test before landing?
Sorry for revisiting this after my earlier approval.

The context tls.connect() creates is shared by every handle the socket
goes through, so closing it from TLSWrap.prototype.close() releases it
while the socket is still using it. autoSelectFamily reinitializes the
handle after each failed attempt, and the attempt following the first
swap then fails with ERR_TLS_INVALID_CONTEXT.

Make the socket the owner of the context and release it in
_destroySSL(), which runs when the socket itself closes, as the original
single-use teardown did. The guard added earlier only turned the
premature close into a null dereference; it did not keep the context
alive.

Signed-off-by: Carlos Vinicius <viniciusdev.26@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. tls Issues and PRs related to the tls subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

5 participants