Skip to content

SIGSEGV (exit 139) on Node worker_threads teardown after require('node-api-dotnet/net10.0') - pthread TLS destructor use-after-free #486

Description

@GalaxiasKyklos

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

  1. Which pthread_key/TLS destructor is registered for CLR-hosting threads, and how its lifetime is ordered relative to napi environment (worker) teardown?
  2. A fix (or supported guidance) so a Worker thread that loaded node-api-dotnet can be torn down without crashing the process.
  3. 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.

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