Repository navigation
No-context JSReference cannot release its napi_ref from the finalizer, leaking until environment destruction #496
Description
Summary
A JSReference created in a JSValueScopeType.NoContext scope has _context == null and therefore has no JSSynchronizationContext. When such a reference is finalized on the GC finalizer thread, there is no mechanism to marshal DeleteReference onto the owning JS thread, so the finalizer skips deletion. The napi_ref (and any strongly-referenced JS value it holds) is not released until the entire JS environment is destroyed.
This is a follow-up to #492 (which fixes the fatal crash — the finalizer previously threw JSInvalidThreadAccessException out of the finalizer thread and terminated the process). #492 makes the finalizer safe (no crash) but intentionally leaves this leak for a separate change, because a proper fix requires new infrastructure.
Details
In JSReference.DisposeFromFinalizer() (added in #492), the no-context branch is:
if (_context == null)
{
JSValueScope? scope = JSValueScope.CurrentOrNull;
if (scope != null && scope.UncheckedEnvironmentHandle == _env)
{
scope.Runtime.DeleteReference(_env, _handle);
}
}JSValueScope.CurrentOrNull is [ThreadStatic]. On the real GC finalizer thread it is always null, so this delete never runs there. The guarded delete only executes if Dispose(disposing: false) happens to be invoked on the owning JS thread. In practice, a no-context reference that becomes unreachable while its environment is still alive retains its napi_ref until the environment is destroyed. Finalization is therefore not limited to worker/environment teardown.
NoContext scopes explicitly have no JSRuntimeContext and no synchronization context (JSValueScope.cs, RuntimeContext = null!), so JSReference has no channel to schedule work back onto the JS thread.
Impact
- No crash (that is fixed by Make
JSReferencefinalizer thread-safe to fix worker-teardown crash (exit 139) #492). - A bounded leak: no-context references abandoned mid-environment-lifetime hold their
napi_ref(and potentially a strong JS value) until the environment is destroyed. - In the common case, no-context references are created by the native host during initialization and live for the environment lifetime, so the practical impact is usually negligible — but it is not guaranteed.
Proposed direction
Introduce an environment-scoped cleanup mechanism so that a no-context reference can arrange its DeleteReference to run on the owning JS thread (for example, an env-scoped cleanup queue drained on the JS thread, registered via an environment cleanup hook in the native host). This is a larger, native-host-layer change and should be designed separately from the crash fix.
Status: fixed
Implemented on branch fix/jsreference-nocontext-leak (commit "Delete no-context JSReference on the JS thread instead of leaking it").
When a no-context reference's finalizer runs off the JS thread, it no longer skips the delete. Instead the napi_ref is enqueued into an environment-scoped deferred-deletion queue keyed by napi_env (JSReference.EnqueuePendingDeletion). The queue is drained on the JS thread:
- the next time any scope for that environment is entered, via
JSReference.DrainPendingDeletionscalled from theJSValueScopeconstructor; and - finally when the environment's
Root/NoContextscope is disposed, viaJSReference.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. Unit tests cover both the scope-entry drain and the scope-dispose drain.
References
- PR Make
JSReferencefinalizer thread-safe to fix worker-teardown crash (exit 139) #492 — the crash fix that introduced the safe (non-throwing) finalizer path. src/NodeApi/JSReference.cs—DisposeFromFinalizer()no-context branch.src/NodeApi/JSValueScope.cs—NoContextscopes have no runtime/synchronization context.
Activity
GalaxiasKyklos commentedon Aug 15, 2026
Fix implemented in #495.