Skip to content

JSReference finalizer throws JSInvalidThreadAccessException, crashing the process (exit 139`) on worker teardown #493

Description

Summary

When a worker_threads Worker that hosted the CLR via node-api-dotnet is torn down, the process can crash with SIGSEGV / exit 139 (on Node < 24.14; it manifests as a hang on Node ≥ 24.14). The fatal fault is a managed exception escaping the GC finalizer thread, thrown from JSReference.Finalize().

This is separate from — and occurs on top of — the module-unload fix in #487: that fix pins node-api-dotnet's own host module, but does not make JSReference finalization safe when a worker's JS value scope is already gone.

Fixed by #492.

Environment

Captured crash

Reproduced natively (no gdb/ptrace) on 0.9.23; CoreCLR emitted a managed stack:

Unhandled exception. Microsoft.JavaScript.NodeApi.JSInvalidThreadAccessException:
There is no active JS value scope. Current thread: #2030.
   at Microsoft.JavaScript.NodeApi.JSValueScope.get_Current()
   at Microsoft.JavaScript.NodeApi.JSReference.ThrowIfInvalidThreadAccess()
   at Microsoft.JavaScript.NodeApi.JSReference.Dispose(Boolean)
   at Microsoft.JavaScript.NodeApi.JSReference.Finalize()
   at System.Runtime.__Finalizer.DrainQueue()
   at System.Runtime.__Finalizer.ProcessFinalizers()

Root cause

~JSReference() runs Dispose(disposing: false) on the GC finalizer thread, which never has an active JS value scope. For a reference created from a no-context scope (_context == null, e.g. the native host path), Dispose calls ThrowIfInvalidThreadAccess():

protected virtual void Dispose(bool disposing)
{
    if (!IsDisposed)
    {
        IsDisposed = true;
        if (_context == null)
        {
            ThrowIfInvalidThreadAccess();   // reads JSValueScope.Current -> throws
            JSValueScope.CurrentRuntime.DeleteReference(_env, _handle).ThrowIfFailed();
        }
        else { /* post delete to sync context */ }
    }
}

ThrowIfInvalidThreadAccess() reads JSValueScope.Current, which is s_currentScope ?? throw new JSInvalidThreadAccessException(null). On the finalizer thread there is no scope, so it throws — and an exception escaping a finalizer terminates the process.

When a worker is torn down, any JSReferences that outlived their scope get finalized later on the finalizer thread, each hitting this throw. The crash therefore correlates with concurrency and CLR object/finalizer volume at teardown: frequent in a real per-request worker-recycle pattern, and reproducible in a low-churn harness after enough teardown cycles have accumulated finalizable references.

Reproduction

  • Load node-api-dotnet only inside a Worker so no other reference keeps a scope alive, perform interop calls that create JSReferences (returned objects, callbacks, proxies), let the worker self-exit / be terminated, and repeat under concurrency.
  • Deterministic in a gutted harness at ~2030 low-churn teardown cycles; near-every-teardown in a high-churn workload.
  • A minimal deterministic unit-level repro is included in Make JSReference finalizer thread-safe to fix worker-teardown crash (exit 139) #492: a JSValueScopeType.NoContext reference whose finalizer path (Dispose(disposing: false)) is invoked on a thread with no scope reproduces the exact stack above and fails prior to the fix.

Notes

  • This is a red-herring correction to earlier native __nptl_deallocate_tsd / TLS-destructor hypotheses: a pthread_key_create interposer found no crypto library registering a destructor; the actionable current-version fault is the JSReference.Finalize() throw.
  • On Node ≥ 24.14 the same teardown ordering surfaces as a hang rather than a segfault; both failure modes share the throwing finalizer.

Suggested fix

Make JSReference.Dispose(bool disposing) finalizer-safe: when called from the finalizer (disposing == false), never throw and never assert thread access — release the native reference only when it can be done safely (delete a no-context reference only if the matching JS scope is current on the thread; otherwise skip, since the environment is being torn down), and defer a context reference's delete to the JS thread via the synchronization context. Explicit Dispose() keeps its documented thread-access behavior. Implemented in #492.

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