You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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():
protectedvirtualvoidDispose(booldisposing){if(!IsDisposed){IsDisposed=true;if(_context==null){ThrowIfInvalidThreadAccess();// reads JSValueScope.Current -> throwsJSValueScope.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.
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.
Summary
When a
worker_threadsWorker that hosted the CLR via node-api-dotnet is torn down, the process can crash withSIGSEGV/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 fromJSReference.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
JSReferencefinalization safe when a worker's JS value scope is already gone.Fixed by #492.
Environment
exit 139); also reproduced as a hang on ≥ 24.14ManagedHost)Captured crash
Reproduced natively (no gdb/ptrace) on 0.9.23; CoreCLR emitted a managed stack:
Root cause
~JSReference()runsDispose(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),DisposecallsThrowIfInvalidThreadAccess():ThrowIfInvalidThreadAccess()readsJSValueScope.Current, which iss_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
JSReferences (returned objects, callbacks, proxies), let the worker self-exit / be terminated, and repeat under concurrency.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
__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.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. ExplicitDispose()keeps its documented thread-access behavior. Implemented in #492.