gdb-repro-run.sh
gdb-worker-teardown-bt.txt
gdb-worker-teardown-fault-mapping.txt
package.json
Summary
Loading node-api-dotnet inside a Node.js worker_threads Worker and then tearing that worker down - either via worker.terminate() from the parent or a graceful process.exit(0) inside the worker - reliably crashes the entire process with a native SIGSEGV (exit code 139) on Linux.
The crash requires zero interop calls: merely require('node-api-dotnet/net10.0') on the worker thread is enough. The same teardown on the main thread is clean, and a worker that does not load node-api-dotnet is clean.
A native backtrace shows the fault is in glibc's per-thread TLS-destructor cleanup (__nptl_deallocate_tsd) as the worker thread exits: a pthread_key destructor function pointer is dangling (points to unmapped memory), so calling it segfaults.
This is distinct from the thread-safe-function teardown issue in PR #480: applying that patch does not fix this crash, and neither does removing the JSThreadSafeFunction.Release() call entirely.
Environment
|
|
| node-api-dotnet |
0.9.21 |
| .NET runtime |
10.0.10 (Microsoft.NETCore.App), framework-dependent |
| Target framework moniker |
net10.0 |
| OS / libc |
Linux x64, glibc (Debian 12 and Azure Linux 3.0 both affected) |
Node.js version dependence (matches the PR #480 threshold)
| Node.js |
Result on worker teardown |
| 22.17.0 |
SIGSEGV / exit 139 |
| 24.13.0 |
SIGSEGV / exit 139 |
| 24.18.1 (>= 24.14) |
Deadlock / hang (process never exits) |
Minimal reproduction
No LWS/Microsoft code required - just the npm package (npm install node-api-dotnet@0.9.21).
worker.cjs:
// Only load the host on the worker thread. No dotnet.require, no interop calls.
require('node-api-dotnet/net10.0');
require('worker_threads').parentPort.postMessage('ready');
main.cjs:
const { Worker } = require('worker_threads');
const w = new Worker(__dirname + '/worker.cjs');
w.on('message', async () => {
await w.terminate(); // graceful process.exit(0) inside the worker also crashes
process.exit(0);
});
w.on('exit', (code) => console.log('worker exit', code));
Run: node main.cjs ; echo "exit=$?" -> Observed exit=139.
Reproduce in Docker (Node 24.13.0)
docker build --build-arg NODE_VERSION=24.13.0 -t napd-repro:24.13.0 .
docker run --rm napd-repro:24.13.0
# -> v24.13.0 ... exit=139
Image = mcr.microsoft.com/dotnet/sdk:10.0 (provides the .NET 10 runtime) + Node.js 24.x. Building without NODE_VERSION installs the latest 24.x, which hangs instead of crashing.
Control cases (all clean)
- Main-thread teardown (even
dotnet.require(<assembly>)) -> exit 0.
- Worker without node-api-dotnet -> exit 0.
- With interop objects (
dotnet.require + managed objects) -> still 139 at teardown.
Native backtrace (gdb)
Thread N "node" received signal SIGSEGV, Segmentation fault.
#0 0x00007ffff4b3bbc0 in ?? () <- dangling destructor pointer
#1 __GI___nptl_deallocate_tsd () at ./nptl/nptl_deallocate_tsd.c:73
#2 __GI___nptl_deallocate_tsd () at ./nptl/nptl_deallocate_tsd.c:22
#3 start_thread (arg=<optimized out>) at ./nptl/pthread_create.c:455
#4 clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78
The faulting PC is not backed by any mapping (it sits in the guard gap above an 8 MB anonymous thread-stack region adjacent to the .NET Finalizer thread's stack). The value stored as the pthread_key destructor is garbage, so __nptl_deallocate_tsd jumps to unmapped memory. libcoreclr.so is mapped elsewhere, confirming the bad pointer does not point into the still-loaded CLR - it points at freed/reused memory.
Why PR #480 does not fix this
We built node-api-dotnet 0.9.21 with the PR #480 patch (verified the patched Microsoft.JavaScript.NodeApi.dll was actually loaded), and the crash still reproduces (139). Removing the _tsfn.Release() call entirely also still crashes. Consistent with the backtrace: the fault is in pthread TLS destructor dispatch, not the thread-safe-function release path.
What we're asking
- Which
pthread_key/TLS destructor is registered for CLR-hosting threads, and how its lifetime is ordered relative to napi environment (worker) teardown?
- A fix (or supported guidance) so a Worker thread that loaded node-api-dotnet can be torn down without crashing the process.
- If there is a supported pattern to safely detach the CLR from a Node worker thread before it exits (or keep the environment alive for the process lifetime), please document it.
gdb-repro-run.sh
gdb-worker-teardown-bt.txt
gdb-worker-teardown-fault-mapping.txt
package.json
Summary
Loading
node-api-dotnetinside a Node.jsworker_threadsWorker and then tearing that worker down - either viaworker.terminate()from the parent or a gracefulprocess.exit(0)inside the worker - reliably crashes the entire process with a native SIGSEGV (exit code 139) on Linux.The crash requires zero interop calls: merely
require('node-api-dotnet/net10.0')on the worker thread is enough. The same teardown on the main thread is clean, and a worker that does not load node-api-dotnet is clean.A native backtrace shows the fault is in glibc's per-thread TLS-destructor cleanup (
__nptl_deallocate_tsd) as the worker thread exits: apthread_keydestructor function pointer is dangling (points to unmapped memory), so calling it segfaults.This is distinct from the thread-safe-function teardown issue in PR #480: applying that patch does not fix this crash, and neither does removing the
JSThreadSafeFunction.Release()call entirely.Environment
net10.0Node.js version dependence (matches the PR #480 threshold)
Minimal reproduction
No LWS/Microsoft code required - just the npm package (
npm install node-api-dotnet@0.9.21).worker.cjs:main.cjs:Run:
node main.cjs ; echo "exit=$?"-> Observedexit=139.Reproduce in Docker (Node 24.13.0)
Image =
mcr.microsoft.com/dotnet/sdk:10.0(provides the .NET 10 runtime) + Node.js 24.x. Building withoutNODE_VERSIONinstalls the latest 24.x, which hangs instead of crashing.Control cases (all clean)
dotnet.require(<assembly>)) -> exit 0.dotnet.require+ managed objects) -> still 139 at teardown.Native backtrace (gdb)
The faulting PC is not backed by any mapping (it sits in the guard gap above an 8 MB anonymous thread-stack region adjacent to the
.NET Finalizerthread's stack). The value stored as thepthread_keydestructor is garbage, so__nptl_deallocate_tsdjumps to unmapped memory.libcoreclr.sois mapped elsewhere, confirming the bad pointer does not point into the still-loaded CLR - it points at freed/reused memory.Why PR #480 does not fix this
We built node-api-dotnet 0.9.21 with the PR #480 patch (verified the patched
Microsoft.JavaScript.NodeApi.dllwas actually loaded), and the crash still reproduces (139). Removing the_tsfn.Release()call entirely also still crashes. Consistent with the backtrace: the fault is in pthread TLS destructor dispatch, not the thread-safe-function release path.What we're asking
pthread_key/TLS destructor is registered for CLR-hosting threads, and how its lifetime is ordered relative to napi environment (worker) teardown?