Release the TLSWrap active-handle registration on destroySSL (WAX-609) - #151
Merged
Merged
Conversation
Every outbound TLS connection permanently retained its TlsWrap, ~107 KB a
time. EdgeStreamBaseSetWrapperRef registers every stream in
Environment::active_handles_, holding a strong napi_ref to the handle's JS
wrapper so process._getActiveHandles() can see it. Only EdgeStreamBaseOnClosed
releases that registration, and TCP reaches it from its libuv close callback.
TLSWrap owns no libuv handle of its own -- kTlsWrapOps is all-nullptr -- so its
only routes there are ParentStreamOnClose and TlsWrapClose. TlsWrapClose is
dead code, because Node's JS TLSWrap.prototype.close shadows it and delegates
to the parent handle; and that JS close calls destroySSL() first, which detaches
the parent listener, so ParentStreamOnClose can no longer fire either. The
registration was therefore never released, which pinned the wrapper, which
stopped TlsWrapFinalize from ever running -- taking the TlsWrap, its 64 KiB
read buffer, the parent handle and the SecureContext with it. Self-sustaining:
the registry pinned the object, so the finalizer that would clear the registry
could never run.
Release the registration in DestroySsl, and nothing else -- emitting the full
close/destroy lifecycle there re-enters JS while the socket still owns the
handle and fails several node:tls tests under the QuickJS provider.
Letting the wrapper actually be collected then exposed a latent
use-after-free, because TlsWrapFinalize runs inside QuickJS's cycle sweep,
where other members of the cycle are still visible as zombies:
- EdgeStreamBaseGetWrapper now reports nothing once the stream is finalizing.
Every JS-touching stream caller funnels through it, so it is the one place
to stop a finalizer from materialising -- and so resurrecting -- a wrapper
the collector is about to release.
- TlsWrapFinalize marks the stream finalized before doing any work, and
during env teardown drops parent_stream_base without walking the parent's
listener chain: finalizer order within a teardown sweep is unspecified, so
the parent may already be freed. That was a second use-after-free, at exit.
Finally, report the SecureContext's SSL_CTX to the GC. tls.connect() mints one
per connection at ~30 KiB of native memory the collector cannot see, so the JS
heap stays small, no collection is triggered, and dead contexts accumulate.
Measured, 10 000 TLS connect/close cycles:
- WASIX (QuickJS): 809.5 MB of growth -> 35.5 MB, 92 -> 3.6 KB/cycle.
- Native V8: was 107 KB/cycle and dead linear; now plateaus.
node:tls + node:https + node:crypto pass 433/433 on both the V8 and QuickJS
providers, matching baseline, and an ASAN build is clean on the four tests
that previously aborted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes WAX-609.
The leak
Every outbound TLS connection permanently retained its
TlsWrap— ~107 KB a time.EdgeStreamBaseSetWrapperRefregisters every stream inEnvironment::active_handles_, holding a strongnapi_refto the handle's JS wrapper soprocess._getActiveHandles()can see it. OnlyEdgeStreamBaseOnClosedreleases that registration, and TCP reaches it from its libuv close callback.TLSWrapowns no libuv handle of its own (kTlsWrapOpsis all-nullptr), so its only routes there areParentStreamOnCloseandTlsWrapClose:TlsWrapCloseis dead code — Node's JSTLSWrap.prototype.closeinlib/_tls_wrap.jsshadows it and delegates to the parent handle.destroySSL()first, which detaches the parent listener — soParentStreamOnClosecan never fire either.The registration was therefore never released → the wrapper was pinned →
TlsWrapFinalizenever ran → theTlsWrap, its 64 KiB read buffer, the parent TCP handle and theSecureContext/SSL_CTXall leaked. Self-sustaining: the registry pinned the object, so the finalizer that would have cleared the registry could never run.Confirmed with heaptrack (26.21 MB over exactly 400 calls for 400 cycles — precisely 64 KiB each),
WeakRef+ GC pressure (TLS 200/200 retained vs TCP 0/200), and gdb (3TLSWrapregistrations, 0 unregistrations over 3 cycles).The fix
Release the registration in
DestroySsl— and nothing else. Emitting the full close/destroy lifecycle there re-enters JS while the socket still owns the handle, and fails severalnode:tlstests under QuickJS.Letting the wrapper actually be collected then exposed a latent use-after-free:
TlsWrapFinalizeruns inside QuickJS's cycle sweep, where other cycle members are still visible as zombies (whatJS_IsLiveObjectis for). It reached back throughEdgeStreamBaseGetWrapper→napi_get_reference_value→dup_inner()and resurrected one.EdgeStreamBaseGetWrappernow reports nothing once the stream is finalizing. Every JS-touching stream caller funnels through it, so it is the one place to stop this.TlsWrapFinalizemarks the stream finalized before doing any work, and during env teardown dropsparent_stream_basewithout walking the parent's listener chain — finalizer order in a teardown sweep is unspecified, so the parent may already be freed. That was a second UAF, at exit.Report the
SecureContext'sSSL_CTXto the GC.tls.connect()mints one per connection at ~30 KiB of native memory the collector cannot see, so the JS heap stays small, no collection is triggered, and dead contexts accumulate.Results
10 000 TLS connect/close cycles:
The WASIX baseline reproduces the ticket almost exactly (ticket: 10 016 cycles, 730.9 → 1256.6 MB; here: 10 000 cycles, 376 → 1271 MB). Controls: an idle loop is flat (0.1 MB/10k) and plaintext TCP plateaus (12 MB/40k), matching the ticket's plaintext arm.
Testing
node:tls+node:https+node:crypto: 433/433 on both the V8 and QuickJS providers, matching baseline.-fsanitize=address, QuickJS provider) clean on the four tests that previously aborted withcorrupted double-linked list.node:http2+node:http+node:streamshow no new failures;test-http2-response-splittingandtest-stream-readable-async-iteratorsfail on QuickJS before this change too, and thetest-http-server-*-timeout-keepalivepair flakes under-j 8on both providers but passes in isolation.Related
wasmerio/napi#67 hardens
napi_get_reference_valueagainst the same zombie resurrection for any addon. It is not required by this PR — this branch is green and ASAN-clean with or without it.Known remaining gap
WASIX still does not fully plateau (late/early slope ~1.0 vs V8's 0.08). The
SecureContextaccounting drives V8's GC but is inert under QuickJS, whosenapi_adjust_external_memoryonly increments a counter. Tracked separately.🤖 Generated with Claude Code