Skip to content

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 JSReference finalizer 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.DrainPendingDeletions called from the JSValueScope constructor; and
  • finally when the environment's Root/NoContext scope is disposed, via 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. Unit tests cover both the scope-entry drain and the scope-dispose drain.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions