Summary
0.9.23 (which includes the PreventModuleUnload module pin from #487) fixes the simple
worker-thread teardown crash, but it does not eliminate all exit 139 / SIGSEGV crashes in
__nptl_deallocate_tsd at worker-thread teardown on Linux.
The module pin keeps the node-api-dotnet host module mapped, but it does nothing for other
native libraries that a managed assembly loads transitively. If any such native dependency
registers a pthread_key destructor (via pthread_key_create) and is later unmapped, glibc calls
the now-dangling destructor pointer when a worker thread exits, faulting in __nptl_deallocate_tsd
— the same crash #487 aimed to fix, from a source #487 does not (and cannot) cover.
I have a deterministic, minimal reproduction using only node-api-dotnet 0.9.23 plus a ~20-line
native .so. It crashes on the first worker cycle.
Environment
Crash stack (published 0.9.23, Node v24.13.0)
Thread 8 "WorkerThread" received signal SIGSEGV, Segmentation fault.
#0 0x0000...071b9 in ?? () <- dangling destructor in unmapped .so
#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 (...) at ./nptl/pthread_create.c:455
#4 clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:100
Root cause
__nptl_deallocate_tsd is glibc's thread-exit routine: it walks the exiting thread's TSD array and,
for each non-NULL slot with a registered destructor, calls destructor(value). The destructor
pointer is whatever was passed to pthread_key_create(&key, destructor).
If the shared library that owns destructor is unmapped (dlclose, or unloaded at teardown) while a
thread still holds a value for that key, the destructor pointer dangles into unmapped memory. When
that thread later exits, glibc jumps to freed code → SIGSEGV.
#487 / 0.9.23's PreventModuleUnload keeps the node-api-dotnet host module mapped, fixing the
case where its code was the dangling destructor. It does not pin every other native library an
application loads. Any native dependency that registers a TLS destructor and is unmapped reintroduces
the identical crash. Real services drag such libraries in transitively (auth/crypto/interop native
deps), which is why the crash still fires in production even on 0.9.23.
Reproduction
Deterministic — crashes on the first worker cycle. Full sources below.
1. Native lib that registers a pthread_key destructor (tlsdtor.c → libtlsdtor.so):
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static pthread_key_t key;
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void destructor(void *p) { // lives in libtlsdtor.so's text segment
free(p);
}
static void make_key(void) { pthread_key_create(&key, destructor); }
// Arm the destructor on the CALLING (worker) thread.
void arm(void) {
pthread_once(&once, make_key);
pthread_setspecific(key, malloc(64));
}
2. Managed P/Invoke that arms the destructor then unmaps the lib (Native.cs):
using System;
using System.Runtime.InteropServices;
namespace ReproLib
{
public static class Native
{
[DllImport("libdl.so.2", EntryPoint = "dlopen", CharSet = CharSet.Ansi)]
private static extern IntPtr dlopen(string path, int flag);
[DllImport("libdl.so.2", EntryPoint = "dlsym", CharSet = CharSet.Ansi)]
private static extern IntPtr dlsym(IntPtr handle, string symbol);
[DllImport("libdl.so.2", EntryPoint = "dlclose")]
private static extern int dlclose(IntPtr handle);
private const int RTLD_NOW = 2;
public static void ArmAndUnload(string soPath)
{
IntPtr h = dlopen(soPath, RTLD_NOW);
if (h == IntPtr.Zero) throw new Exception("dlopen failed for " + soPath);
IntPtr fn = dlsym(h, "arm");
if (fn == IntPtr.Zero) throw new Exception("dlsym 'arm' failed");
var arm = Marshal.GetDelegateForFunctionPointer<Action>(fn);
arm(); // pthread_key_create(destructor) + pthread_setspecific on THIS thread
dlclose(h); // unmap the lib while the TLS value + destructor are still live
}
}
}
3. Worker: use node-api-dotnet, arm-and-unload, then self-exit (worker.cjs):
const { workerData } = require("node:worker_threads");
const path = require("path");
const dotnet = require("node-api-dotnet/net10.0");
dotnet.load(path.join(__dirname, "reprolib", "out", "ReproLib.dll"));
const Native = dotnet.ReproLib.Native;
// A native dependency registers a TLS destructor then gets unmapped on THIS worker thread.
Native.ArmAndUnload(path.join(__dirname, "native", "libtlsdtor.so"));
// Self-exit; worker-thread teardown runs the dangling destructor -> SIGSEGV.
4. Driver (main.cjs):
const { Worker } = require("node:worker_threads");
const path = require("path");
const w = new Worker(path.join(__dirname, "worker.cjs"));
w.on("exit", (code) => { console.log("worker exit", code); });
Build & run:
gcc -shared -fPIC -o native/libtlsdtor.so native/tlsdtor.c
( cd reprolib && dotnet build -c Release -o out ) # ReproLib.csproj targets net10.0
node main.cjs ; echo "exit=$?" # -> exit=139 (SIGSEGV)
What does NOT reproduce (scoping the trigger)
Pure-managed node-api-dotnet usage on 0.9.23 does not crash, even under heavy stress
(> 11,000 worker-thread teardowns, 0 crashes):
- simple load + self-exit;
- repeated synchronous CLR construction;
- concurrent managed async round-trips (
Task.Delay) driving the TSFN sync context;
- concurrent worker-pool recycle (up to 16 concurrent workers);
- a custom assembly returning interface-typed objects with finalizers + property materialization
ConfigureAwait(false) threadpool hops.
The one ingredient that flips it to a crash is a native dependency that registers a pthread_key
destructor and is unmapped before the arming thread exits. That is the class #487's module pin does
not cover.
Suggested fix direction
A complete fix must prevent any library that registers a pthread_key destructor from being
unmapped before all threads that armed it have exited — e.g.:
- open/keep such libraries with
RTLD_NODELETE (or otherwise refuse to dlclose them), and/or
- clear/deregister TLS destructors before a library is unmapped.
The PreventModuleUnload pin in #487 already does this for the node-api-dotnet host module; the same
guarantee needs to extend to native dependencies loaded through the hosted runtime.
Notes
- Node 24.13.0 and 24.18.1 both produce a hard
exit 139 here. The hang-vs-crash difference reported
for Node ≥ 24.14 applied to the earlier TSFN sync-context teardown bug (fixed for the simple case
in 0.9.23); this residual native-dependency TLS-destructor fault is a pure glibc-level dangling
function-pointer call and is therefore a deterministic SIGSEGV on every Node version.
- A pure-C
standalone.c (dlopen → arm → dlclose → thread exit, no Node/.NET) produces the identical
__nptl_deallocate_tsd crash, confirming the mechanism is independent of node-api-dotnet itself —
node-api-dotnet is affected because it hosts a runtime whose assemblies load such native deps and
whose worker threads tear down while destructors are still armed.
Summary
0.9.23(which includes thePreventModuleUnloadmodule pin from #487) fixes the simpleworker-thread teardown crash, but it does not eliminate all
exit 139/ SIGSEGV crashes in__nptl_deallocate_tsdat worker-thread teardown on Linux.The module pin keeps the node-api-dotnet host module mapped, but it does nothing for other
native libraries that a managed assembly loads transitively. If any such native dependency
registers a
pthread_keydestructor (viapthread_key_create) and is later unmapped, glibc callsthe now-dangling destructor pointer when a worker thread exits, faulting in
__nptl_deallocate_tsd— the same crash #487 aimed to fix, from a source #487 does not (and cannot) cover.
I have a deterministic, minimal reproduction using only
node-api-dotnet0.9.23 plus a ~20-linenative
.so. It crashes on the first worker cycle.Environment
Microsoft.JavaScript.NodeApi0.9.23 (npm + NuGet; repo commit5da09c61, contains Fix worker_threads teardown crash (SIGSEGV) and hang when node-api-dotnet is loaded in a Worker #487'sPreventModuleUnload).Microsoft.NETCore.App 10.0.10),net10.0target.exit 139.Crash stack (published 0.9.23, Node v24.13.0)
Root cause
__nptl_deallocate_tsdis glibc's thread-exit routine: it walks the exiting thread's TSD array and,for each non-NULL slot with a registered destructor, calls
destructor(value). The destructorpointer is whatever was passed to
pthread_key_create(&key, destructor).If the shared library that owns
destructoris unmapped (dlclose, or unloaded at teardown) while athread still holds a value for that key, the destructor pointer dangles into unmapped memory. When
that thread later exits, glibc jumps to freed code → SIGSEGV.
#487 / 0.9.23's
PreventModuleUnloadkeeps the node-api-dotnet host module mapped, fixing thecase where its code was the dangling destructor. It does not pin every other native library an
application loads. Any native dependency that registers a TLS destructor and is unmapped reintroduces
the identical crash. Real services drag such libraries in transitively (auth/crypto/interop native
deps), which is why the crash still fires in production even on 0.9.23.
Reproduction
Deterministic — crashes on the first worker cycle. Full sources below.
1. Native lib that registers a
pthread_keydestructor (tlsdtor.c→libtlsdtor.so):2. Managed P/Invoke that arms the destructor then unmaps the lib (
Native.cs):3. Worker: use node-api-dotnet, arm-and-unload, then self-exit (
worker.cjs):4. Driver (
main.cjs):Build & run:
What does NOT reproduce (scoping the trigger)
Pure-managed
node-api-dotnetusage on 0.9.23 does not crash, even under heavy stress(> 11,000 worker-thread teardowns, 0 crashes):
Task.Delay) driving the TSFN sync context;ConfigureAwait(false)threadpool hops.The one ingredient that flips it to a crash is a native dependency that registers a
pthread_keydestructor and is unmapped before the arming thread exits. That is the class #487's module pin does
not cover.
Suggested fix direction
A complete fix must prevent any library that registers a
pthread_keydestructor from beingunmapped before all threads that armed it have exited — e.g.:
RTLD_NODELETE(or otherwise refuse todlclosethem), and/orThe
PreventModuleUnloadpin in #487 already does this for the node-api-dotnet host module; the sameguarantee needs to extend to native dependencies loaded through the hosted runtime.
Notes
exit 139here. The hang-vs-crash difference reportedfor Node ≥ 24.14 applied to the earlier TSFN sync-context teardown bug (fixed for the simple case
in 0.9.23); this residual native-dependency TLS-destructor fault is a pure glibc-level dangling
function-pointer call and is therefore a deterministic SIGSEGV on every Node version.
standalone.c(dlopen → arm → dlclose → thread exit, no Node/.NET) produces the identical__nptl_deallocate_tsdcrash, confirming the mechanism is independent of node-api-dotnet itself —node-api-dotnet is affected because it hosts a runtime whose assemblies load such native deps and
whose worker threads tear down while destructors are still armed.