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

Offen
#486 1 Kommentar 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

Vorherrschende Sprache
C#
Sterne
783
Forks
80
PR-Merge-Kennzahlen
Keine gemergten PRs in 30 T.

Beschreibung

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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Erste Schritte

  1. Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
  3. Forke das Repository und arbeite in einem Branch.
  4. Öffne einen Pull Request, der die Issue-Nummer nennt.

Rechercherichtung

Beginne damit, das angehängte gdb-repro-run.sh mit worker.cjs und main.cjs auszuführen, und untersuche anschließend gdb-worker-teardown-bt.txt und gdb-worker-teardown-fault-mapping.txt. Verfolge, welcher CLR-hosting-pthread-Key den Dangling Destructor registriert und wie seine Lebensdauer mit dem Teardown des Node-Workers zusammenhängt. Als abgeschlossen gilt die Aufgabe, wenn sowohl worker.terminate() als auch ein ordnungsgemäßes process.exit(0) in den gemeldeten Umgebungen ohne SIGSEGV oder Hängenbleiben abgeschlossen werden.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
csharp, javascript, node.js
Bereich
backend
Issue-Typ
Bug
Schwierigkeit
5/5
Geschätzter Aufwand
Über eine Woche
Aktivitätsstatus
Ruhig
Klarheit
Größtenteils klar
Anfängerfreundlichkeit
35/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.