Skip to content

Fix no-context JSReference leak and TSFN post/release race (follow-ups to #492) - #495

Open
Saúl Ponce (GalaxiasKyklos) wants to merge 2 commits into
microsoft:mainfrom
GalaxiasKyklos:fix/jsreference-nocontext-leak
Open

Fix no-context JSReference leak and TSFN post/release race (follow-ups to #492)#495
Saúl Ponce (GalaxiasKyklos) wants to merge 2 commits into
microsoft:mainfrom
GalaxiasKyklos:fix/jsreference-nocontext-leak

Conversation

@GalaxiasKyklos

@GalaxiasKyklos Saúl Ponce (GalaxiasKyklos) commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Two follow-ups to #492 (which made the JSReference finalizer thread-safe to fix the worker-teardown crash). #492 intentionally left two related issues for a separate change; this PR addresses both.

Each fix is a separate commit.

1. Delete no-context JSReference on the JS thread instead of leaking it

A JSReference created in a NoContext scope has no JSSynchronizationContext, so when its finalizer runs on the GC finalizer thread it cannot marshal DeleteReference back to the owning JS thread. Previously the finalizer simply skipped the delete, leaking the napi_ref (and any strongly-referenced JS value) until the JS environment was destroyed.

This change defers such deletions into an environment-scoped queue keyed by napi_env. The queue is drained on the JS thread:

  • the next time any scope for that environment is entered (JSReference.DrainPendingDeletions, called from the JSValueScope constructor); and
  • finally when the environment's Root/NoContext scope is disposed (JSReference.RemovePendingDeletions, which also removes the registry entry so it does not retain entries for environments that no longer exist).

A process-wide interlocked counter gates the fast path, so the common (nothing-pending) case costs only a volatile read per scope entry. Deferred handles are always deleted while the environment is still alive, so the queued napi_ref remains valid.

2. Close the TSFN post-then-release race in JSSynchronizationContext

JSTsfnSynchronizationContext.Post/Send checked IsDisposed and then called the thread-safe function via NonBlockingCall. The environment cleanup hook could run Dispose (releasing the TSFN) between the check and the call, a native use-after-release that a managed try/catch cannot guard. This was pre-existing (the original context-path Dispose used the same pattern); #492's deferred delete posts through the same path.

This change adds TsfnCallGate, an interlocked gate that reference-counts in-flight NonBlockingCall invocations:

  • Post/Send wrap each native call in TryEnter()/Exit().
  • Dispose calls Close() before _tsfn.Release(). Close() atomically rejects new entrants and then waits for in-flight calls to drain.

Once Close() returns, no native call is in progress and none can start, so Release() cannot race a NonBlockingCall. The gate is held only around the native call — not around Send's completion wait — so TSFN release cannot deadlock against a callback running on the JS thread. InternalsVisibleTo is added so the gate's concurrency behavior can be unit-tested directly.

Testing

  • New unit tests for the no-context deferral (drain on next scope entry, and drain on env-scope disposal).
  • New TsfnCallGate concurrency tests, including a deterministic "Close waits for in-flight call to exit" test and a concurrent enter/exit-vs-close stress test.
  • Existing JSReferenceTests and JSValueScopeTests continue to pass.
  • NodeApi builds clean on all target frameworks with AOT-compatibility checks enabled; dotnet format is clean.

Related

Follow-ups to #492.

Fixes #496
Fixes #497

A JSReference created in a NoContext scope has no JSSynchronizationContext,
so when its finalizer runs on the GC finalizer thread it cannot marshal
DeleteReference back to the owning JS thread. Previously the finalizer simply
skipped the delete, leaking the napi_ref (and any strongly-referenced JS value)
until the JS environment was destroyed.

Defer such deletions into an environment-scoped queue keyed by napi_env. The
queue is drained on the JS thread the next time any scope for that environment
is entered, and finally when the environment's Root/NoContext scope is disposed
(which also removes the registry entry so it does not retain dead environments).
A cheap interlocked counter gates the fast path, so the common no-pending case
costs only a volatile read per scope entry. Deferred handles are always deleted
while the environment is still alive, keeping the queued napi_ref valid.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7
JSTsfnSynchronizationContext.Post/Send checked IsDisposed and then called the
thread-safe function via NonBlockingCall. The environment cleanup hook could run
Dispose (releasing the TSFN) between the check and the call, causing a native
use-after-release that a managed try/catch cannot guard.

Introduce TsfnCallGate, an interlocked gate that reference-counts in-flight
NonBlockingCall invocations. Post/Send wrap each native call in TryEnter/Exit;
Dispose calls Close before releasing the TSFN. Close atomically rejects new
entrants and then waits for in-flight calls to drain, guaranteeing no native
call is in progress and none can start once the TSFN is released. The gate is
held only around the native call, not around Send's completion wait, so TSFN
release cannot deadlock against a callback running on the JS thread.

Expose internals to the test assembly so the gate's concurrency behavior can be
unit-tested directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses follow-up leaks and teardown races from #492.

Changes:

  • Defers no-context JSReference deletion to the JS thread.
  • Adds a gate around TSFN calls and release.
  • Adds concurrency and reference-cleanup tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/NodeApi/JSReference.cs Queues deferred reference deletions.
src/NodeApi/JSValueScope.cs Drains queued deletions.
src/NodeApi/Interop/TsfnCallGate.cs Implements the TSFN call gate.
src/NodeApi/Interop/JSSynchronizationContext.cs Coordinates TSFN calls with release.
src/NodeApi/NodeApi.csproj Exposes internals to tests.
test/JSReferenceTests.cs Tests deferred reference deletion.
test/TsfnCallGateTests.cs Tests gate behavior and concurrency.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +465 to +467
s_pendingFinalizerDeletions
.GetOrAdd(env, _ => new ConcurrentQueue<napi_ref>())
.Enqueue(handle);
Comment on lines +374 to +381
if (ScopeType == JSValueScopeType.Root || ScopeType == JSValueScopeType.NoContext)
{
// This environment-scoped scope is going away, so perform a final drain of any deferred
// no-context reference deletions and remove the environment's queue, preventing the
// registry from retaining entries for environments that no longer exist. The scope is
// still current here, so deletion runs on the owning JS thread while the environment is
// alive.
JSReference.RemovePendingDeletions(_env, Runtime);
Comment thread test/TsfnCallGateTests.cs
Comment on lines +111 to +114
start.Set();

// Close concurrently with the callers; it must wait for any in-flight call to exit.
gate.Close();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants