Summary
JSSynchronizationContext.Post(...) checks IsDisposed and then calls into the thread-safe function (TSFN) to schedule the callback on the JS thread. The environment cleanup hook can concurrently run Dispose() — setting IsDisposed and releasing the TSFN — between the IsDisposed check and the TSFN call. This is a potential native use-after-release that the managed layer cannot fully prevent with a try/catch.
This is a follow-up to #492. The finalizer path in #492 posts its deferred DeleteReference through this same Post(...) pattern, so it is subject to this race — but the race is pre-existing: the original Dispose(disposing: true) context path used the identical SynchronizationContext.Post(...) pattern. #492 does not introduce it.
Details
JSTsfnSynchronizationContext (in src/NodeApi/Interop/JSSynchronizationContext.cs):
public override void Post(SendOrPostCallback callback, object? state)
{
if (IsDisposed) return; // (1) check
_tsfn.NonBlockingCall(() => callback(state)); // (2) use
}
and cleanup:
public override void Dispose()
{
if (IsDisposed) return;
// remove env cleanup hook ...
base.Dispose(); // sets IsDisposed = true
_tsfn.Release(); // TSFN deleted; must not be used after this
}
Cleanup (the env cleanup hook) calls Dispose(). A poster thread can pass the IsDisposed check at (1), then the cleanup hook runs Dispose() (setting IsDisposed and releasing the TSFN), and then the poster calls NonBlockingCall at (2) on a released TSFN — a native use-after-release. The managed try/catch around the finalizer delete cannot reliably guard a native crash.
Impact
Proposed direction
Add atomic post/close coordination inside JSSynchronizationContext / JSTsfnSynchronizationContext so that posting and TSFN release cannot interleave — for example, reference-counting active posts, or taking a lock / using an interlocked state so Release() waits for (or safely rejects) in-flight NonBlockingCalls. This affects the shared synchronization-context implementation and should be designed and tested independently of the crash fix.
Status: fixed
Implemented on branch fix/jsreference-nocontext-leak (commit "Close TSFN post-then-release race in JSSynchronizationContext").
Added TsfnCallGate (src/NodeApi/Interop/TsfnCallGate.cs), an interlocked gate that reference-counts in-flight NonBlockingCall invocations:
Post/Send wrap each native NonBlockingCall in TryEnter()/Exit().
Dispose calls _callGate.Close() before _tsfn.Release(). Close() atomically sets a closed flag so no further TryEnter() can succeed, then spins until all in-flight calls have exited.
This guarantees that once Close() returns, no native TSFN 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 that must run on the JS thread. InternalsVisibleTo was added so the gate's concurrency behavior is unit-tested directly (including a deterministic "Close waits for in-flight call" test and a concurrent-stress test).
References
Summary
JSSynchronizationContext.Post(...)checksIsDisposedand then calls into the thread-safe function (TSFN) to schedule the callback on the JS thread. The environment cleanup hook can concurrently runDispose()— settingIsDisposedand releasing the TSFN — between theIsDisposedcheck and the TSFN call. This is a potential native use-after-release that the managed layer cannot fully prevent with atry/catch.This is a follow-up to #492. The finalizer path in #492 posts its deferred
DeleteReferencethrough this samePost(...)pattern, so it is subject to this race — but the race is pre-existing: the originalDispose(disposing: true)context path used the identicalSynchronizationContext.Post(...)pattern. #492 does not introduce it.Details
JSTsfnSynchronizationContext(insrc/NodeApi/Interop/JSSynchronizationContext.cs):and cleanup:
Cleanup(the env cleanup hook) callsDispose(). A poster thread can pass theIsDisposedcheck at (1), then the cleanup hook runsDispose()(settingIsDisposedand releasing the TSFN), and then the poster callsNonBlockingCallat (2) on a released TSFN — a native use-after-release. The managedtry/catcharound the finalizer delete cannot reliably guard a native crash.Impact
Post.Postcallers, not just the finalizer added in MakeJSReferencefinalizer thread-safe to fix worker-teardown crash (exit 139) #492.Proposed direction
Add atomic post/close coordination inside
JSSynchronizationContext/JSTsfnSynchronizationContextso that posting and TSFN release cannot interleave — for example, reference-counting active posts, or taking a lock / using an interlocked state soRelease()waits for (or safely rejects) in-flightNonBlockingCalls. This affects the shared synchronization-context implementation and should be designed and tested independently of the crash fix.Status: fixed
Implemented on branch
fix/jsreference-nocontext-leak(commit "Close TSFN post-then-release race in JSSynchronizationContext").Added
TsfnCallGate(src/NodeApi/Interop/TsfnCallGate.cs), an interlocked gate that reference-counts in-flightNonBlockingCallinvocations:Post/Sendwrap each nativeNonBlockingCallinTryEnter()/Exit().Disposecalls_callGate.Close()before_tsfn.Release().Close()atomically sets a closed flag so no furtherTryEnter()can succeed, then spins until all in-flight calls have exited.This guarantees that once
Close()returns, no native TSFN call is in progress and none can start, soRelease()cannot race aNonBlockingCall. The gate is held only around the native call — not aroundSend's completion wait — so TSFN release cannot deadlock against a callback that must run on the JS thread.InternalsVisibleTowas added so the gate's concurrency behavior is unit-tested directly (including a deterministic "Close waits for in-flight call" test and a concurrent-stress test).References
JSReferencefinalizer thread-safe to fix worker-teardown crash (exit 139) #492 — deferred cleanup posts through this path.src/NodeApi/Interop/JSSynchronizationContext.cs—JSTsfnSynchronizationContext.PostandDispose.