Skip to content

Release the TLSWrap active-handle registration on destroySSL (WAX-609) - #151

Merged
syrusakbary merged 1 commit into
mainfrom
fix/tls-wrap-active-handle-leak
Sep 8, 2026
Merged

syrusakbary merged 1 commit into
mainfrom
fix/tls-wrap-active-handle-leak

Conversation

@Arshia001

Copy link
Copy Markdown
Member

Fixes WAX-609.

The leak

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 — Node's JS TLSWrap.prototype.close in lib/_tls_wrap.js shadows it and delegates to the parent handle.
  • That JS close calls destroySSL() first, which detaches the parent listener — so ParentStreamOnClose can never fire either.

The registration was therefore never released → the wrapper was pinned → TlsWrapFinalize never ran → the TlsWrap, its 64 KiB read buffer, the parent TCP handle and the SecureContext/SSL_CTX all 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 (3 TLSWrap registrations, 0 unregistrations over 3 cycles).

The fix

  1. 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 QuickJS.

  2. Letting the wrapper actually be collected then exposed a latent use-after-free: TlsWrapFinalize runs inside QuickJS's cycle sweep, where other cycle members are still visible as zombies (what JS_IsLiveObject is for). It reached back through EdgeStreamBaseGetWrappernapi_get_reference_valuedup_inner() and resurrected one.

    • 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 this.
    • 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 in a teardown sweep is unspecified, so the parent may already be freed. That was a second UAF, at exit.
  3. 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.

Results

10 000 TLS connect/close cycles:

before after
WASIX (QuickJS), RSS growth 809.5 MB 35.5 MB (92 → 3.6 KB/cycle)
Native V8 107 KB/cycle, dead linear plateaus

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.
  • ASAN build (-fsanitize=address, QuickJS provider) clean on the four tests that previously aborted with corrupted double-linked list.
  • node:http2 + node:http + node:stream show no new failures; test-http2-response-splitting and test-stream-readable-async-iterators fail on QuickJS before this change too, and the test-http-server-*-timeout-keepalive pair flakes under -j 8 on both providers but passes in isolation.

Related

wasmerio/napi#67 hardens napi_get_reference_value against 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 SecureContext accounting drives V8's GC but is inert under QuickJS, whose napi_adjust_external_memory only increments a counter. Tracked separately.

🤖 Generated with Claude Code

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>
@syrusakbary
syrusakbary merged commit 3f1aaa0 into main Sep 8, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants