Repository navigation
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
- node-api-dotnet: 0.9.23 (reproduced on the shipping binary; Fix worker_threads teardown crash (SIGSEGV) and hang when node-api-dotnet is loaded in a Worker #487 marker present)
- Node.js: v24.13.0 (
exit 139); also reproduced as a hang on ≥ 24.14 - .NET: 10.0.x, CoreCLR hosted (
ManagedHost) - OS: Linux (glibc); container / distroless
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
JSReferencefinalizer thread-safe to fix worker-teardown crash (exit 139) #492: aJSValueScopeType.NoContextreference 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: apthread_key_createinterposer found no crypto library registering a destructor; the actionable current-version fault is theJSReference.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.
Activity