tls: propagate singleUse to the secure context - #66025
ViniciusDev26 wants to merge 2 commits into
Conversation
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>
|
Review requested:
|
|
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 Report✅ All modified and coverable lines are covered by tests. 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
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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>
tls.connect()setsoptions.singleUse = true, and bothTLSWrap.prototype.close()andTLSSocket.prototype._destroySSL()checkssl._secureContext.singleUsebefore callingcontext.close()to release theSSL_CTXwhen the socket closes. Nothing ever assigned that flag to thecontext, so the check never fired:
createSecureContext()used to copy the flag onto the context it returned. Thecopy was lost when the context configuration was extracted into
configSecureContext(), which receives the native context rather than the JSwrapper the flag is read from. Every client connection that builds its own
context therefore keeps its
SSL_CTXalive until V8 collects the wrapper, whichSecureContextdiscourages by reportingkExternalSize = 1024for an objectthat is roughly 16 KB (much more when
cais supplied).Changes
lib/internal/tls/common.js— restore the assignment increateSecureContext(), which owns the JS wrapper.lib/internal/tls/wrap.js— make the close idempotent. This is required bythe first change rather than optional:
net.Socket.prototype[kReinitializeHandle]closes the previous handle, and a socket that swaps its handle shares one
SecureContextacross the successiveTLSWraps, so the teardown is reachedmore than once for the same context. Without the guard the second pass hits
null.close(). This affectsautoSelectFamilyretries, not just thekReinitializeHandletest.test/parallel/test-tls-connect-single-use-context.js— new regression test.It covers both sides: a context created by
tls.connect()is marked singleuse 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
mainwithundefined !== trueat thesingleUseassertion, matching the report, and passes with the change.
kReinitializeHandle()calls to confirmthe 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-*andtest-http2-*: 587 passing,0 failing.
parallelandsequentialsuites: 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:
kExternalSizeforSecureContext.!options.keepAlivecondition onsingleUseintls.connect(), whichmeans 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:
claim that
singleUseis never assigned anywhere was confirmed by greppinglib/andsrc/.setFreeListLength(0)call from the original block was checkedto no longer exist in the tree, so only the flag needed restoring.
draft of this patch broke
test-tls-reinitialize-listeners, which led to theidempotency guard.
than assumed from reading the code.
reverted.
I have reviewed the diff, understand it, and will handle review feedback myself.
Fixes: #66002
Refs: #38116