Skip to content

Residual __nptl_deallocate_tsd SIGSEGV (Linux exit 139) on worker teardown in 0.9.23 — module pin does not cover other native deps' TLS destructors #490

Description

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

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