microsoft / microsoft/node-api-dotnet

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

Abierto
#486 1 comentario 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

Lenguaje dominante
C#
Estrellas
783
Forks
80
Métricas de merge de PR
Sin PR fusionados en 30 d

Descripción

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 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.

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Línea de trabajo

Empieza ejecutando el gdb-repro-run.sh adjunto con worker.cjs y main.cjs, y después inspecciona gdb-worker-teardown-bt.txt y gdb-worker-teardown-fault-mapping.txt. Traza qué clave pthread de CLR-hosting registra el destructor colgante y cómo se relaciona su ciclo de vida con el desmontaje del worker de Node. Se considera terminado cuando tanto worker.terminate() como process.exit(0) graceful se completan sin SIGSEGV ni bloqueo en los entornos indicados.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
csharp, javascript, node.js
Área
backend
Tipo de issue
Error
Dificultad
5/5
Tiempo estimado
Más de una semana
Estado de actividad
Tranquilo
Claridad
Bastante claro
Aptitud para principiantes
35/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.