diff --git a/src/NodeApi.DotNetHost/ManagedHost.cs b/src/NodeApi.DotNetHost/ManagedHost.cs
index 402befe6..b51f5c37 100644
--- a/src/NodeApi.DotNetHost/ManagedHost.cs
+++ b/src/NodeApi.DotNetHost/ManagedHost.cs
@@ -187,6 +187,11 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
DebugHelper.AttachDebugger("NODE_API_DEBUG_RUNTIME");
+ // Pin the .NET runtime's native libraries (CLR, crypto, etc.) so their pthread_key TLS
+ // destructors are never unmapped and cannot dangle when a worker thread tears down. This
+ // complements the native host module pin done by the (native) host. Best-effort/idempotent.
+ NodeApiNativeLibrary.PreventRuntimeLibrariesUnload();
+
JSRuntime runtime = new NodejsRuntime();
if (Debugger.IsAttached ||
diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs
index e925cb19..f16b6d09 100644
--- a/src/NodeApi/DotNetHost/NativeHost.cs
+++ b/src/NodeApi/DotNetHost/NativeHost.cs
@@ -116,6 +116,12 @@ private static unsafe void PreventModuleUnload()
{
Trace(" Failed to pin native host module: " + ex);
}
+
+ // The host module pin above only covers node-api-dotnet's own code. The hosted .NET runtime
+ // (and its crypto libraries) register their own pthread_key TLS destructors for managed
+ // thread / finalizer / OpenSSL cleanup; pin those too so they cannot be unmapped before a
+ // worker thread that used them exits. See NativeLibraryPinning for details.
+ NativeLibraryPinning.PinLoadedRuntimeLibraries();
}
// dladdr and dlopen are exported by libSystem on macOS. On Linux they are exported by
diff --git a/src/NodeApi/DotNetHost/NativeLibraryPinning.cs b/src/NodeApi/DotNetHost/NativeLibraryPinning.cs
new file mode 100644
index 00000000..c6c34db1
--- /dev/null
+++ b/src/NodeApi/DotNetHost/NativeLibraryPinning.cs
@@ -0,0 +1,263 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#if !(NETFRAMEWORK || NETSTANDARD)
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.JavaScript.NodeApi.DotNetHost;
+
+///
+/// Pins native shared libraries in memory (Linux/macOS) so the OS never unloads them,
+/// avoiding a worker-thread teardown crash caused by dangling pthread_key destructors.
+///
+///
+/// A native library can register per-thread cleanup with the OS via
+/// pthread_key_create(&key, destructor). glibc's __nptl_deallocate_tsd calls that
+/// destructor when a thread exits. If the library that owns the destructor is unloaded
+/// (dlclose) while a thread still holds a value for the key — as happens when a
+/// worker_threads Worker that used native code is torn down — the destructor pointer dangles
+/// into unmapped memory and the process crashes with SIGSEGV as the worker thread exits.
+///
+/// Re-opening such a library with RTLD_NODELETE keeps it mapped for the lifetime of the
+/// process, so the destructor pointer stays valid. This complements the node-api-dotnet host module
+/// pin (see NativeHost.PreventModuleUnload): the host module pin only covers node-api-dotnet's
+/// own code, while the .NET runtime and other native dependencies register their own TLS destructors.
+///
+internal static unsafe partial class NativeLibraryPinning
+{
+ private const int RTLD_LAZY = 0x0001;
+ private const int RTLD_NOLOAD_LINUX = 0x0004;
+ private const int RTLD_NODELETE_LINUX = 0x1000;
+ private const int RTLD_NOLOAD_MACOS = 0x0010;
+ private const int RTLD_NODELETE_MACOS = 0x0080;
+
+ private static bool IsMacOS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
+
+ private static bool IsSupported =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || IsMacOS;
+
+ private static bool s_runtimeLibrariesPinned;
+
+ // Basename prefixes of the .NET runtime native libraries that register pthread_key TLS
+ // destructors (managed thread / finalizer cleanup, and OpenSSL thread-local state used by the
+ // crypto libraries). Pinning these keeps their destructors valid across worker-thread teardown.
+ private static readonly string[] s_runtimeLibraryPrefixes = new[]
+ {
+ "libcoreclr",
+ "libclrjit",
+ "libclrgc",
+ "libhostpolicy",
+ "libSystem.Native",
+ "libSystem.Security.Cryptography.Native",
+ "libSystem.Net.Security.Native",
+ };
+
+ ///
+ /// Pins the already-loaded .NET runtime native libraries so their per-thread TLS destructors
+ /// remain mapped for the lifetime of the process. Best-effort: failures are traced, not thrown.
+ ///
+ internal static void PinLoadedRuntimeLibraries()
+ {
+ if (s_runtimeLibrariesPinned || !IsSupported)
+ {
+ return;
+ }
+
+ s_runtimeLibrariesPinned = true;
+
+ try
+ {
+ foreach (string path in EnumerateLoadedLibraries())
+ {
+ string name = GetFileName(path);
+ if (MatchesRuntimeLibrary(name) && TryPinByPath(path))
+ {
+ NativeHost.Trace($" Pinned runtime native library ({name}).");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ NativeHost.Trace(" Failed to pin runtime native libraries: " + ex);
+ }
+ }
+
+ ///
+ /// Pins a specific native library (by path or already-loaded name) with RTLD_NODELETE.
+ ///
+ /// True if the library was found loaded and pinned; otherwise false.
+ internal static bool PinLibrary(string libraryNameOrPath)
+ {
+ if (string.IsNullOrEmpty(libraryNameOrPath) || !IsSupported)
+ {
+ return false;
+ }
+
+ try
+ {
+ return TryPinByPath(libraryNameOrPath);
+ }
+ catch (Exception ex)
+ {
+ NativeHost.Trace($" Failed to pin native library '{libraryNameOrPath}': " + ex);
+ return false;
+ }
+ }
+
+ private static bool MatchesRuntimeLibrary(string fileName)
+ {
+ foreach (string prefix in s_runtimeLibraryPrefixes)
+ {
+ if (fileName.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // RTLD_NOLOAD resolves an already-loaded library without loading a new copy; if it is not
+ // loaded, dlopen returns null and this is a no-op. RTLD_NODELETE keeps it mapped for the
+ // process lifetime. The extra (never released) reference also blocks a later dlclose.
+ private static bool TryPinByPath(string path)
+ {
+ int flags = RTLD_LAZY | (IsMacOS ?
+ RTLD_NOLOAD_MACOS | RTLD_NODELETE_MACOS :
+ RTLD_NOLOAD_LINUX | RTLD_NODELETE_LINUX);
+
+ nint utf8 = Marshal.StringToCoTaskMemUTF8(path);
+ try
+ {
+ return DlOpen(utf8, flags) != default;
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(utf8);
+ }
+ }
+
+ private static string GetFileName(string path)
+ {
+ int slash = path.LastIndexOf('/');
+ return slash >= 0 ? path.Substring(slash + 1) : path;
+ }
+
+ private static IEnumerable EnumerateLoadedLibraries()
+ {
+ return IsMacOS ? EnumerateLoadedLibrariesMacOS() : EnumerateLoadedLibrariesLinux();
+ }
+
+ // On Linux, walk the dynamic linker's list of loaded objects. Names are collected during
+ // iteration and returned afterward: dl_iterate_phdr holds the loader lock, so calling dlopen
+ // from inside the callback would deadlock. The dlpi_name pointers remain valid after iteration
+ // because they reference strings owned by the loaded objects.
+ private static IEnumerable EnumerateLoadedLibrariesLinux()
+ {
+ var names = new List();
+ var handle = GCHandle.Alloc(names);
+ try
+ {
+ DlIteratePhdr(&CollectLibraryName, (void*)GCHandle.ToIntPtr(handle));
+ }
+ finally
+ {
+ handle.Free();
+ }
+
+ return names;
+ }
+
+ [UnmanagedCallersOnly(CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
+ private static int CollectLibraryName(dl_phdr_info* info, nuint size, void* data)
+ {
+ try
+ {
+ if (info != null && info->dlpi_name != default)
+ {
+ string? name = Marshal.PtrToStringUTF8(info->dlpi_name);
+ if (!string.IsNullOrEmpty(name))
+ {
+ var list = (List)GCHandle.FromIntPtr((nint)data).Target!;
+ list.Add(name!);
+ }
+ }
+ }
+ catch
+ {
+ // Never let an exception propagate through the native dl_iterate_phdr callback.
+ }
+
+ return 0;
+ }
+
+ private static IEnumerable EnumerateLoadedLibrariesMacOS()
+ {
+ uint count = DyldImageCount();
+ for (uint i = 0; i < count; i++)
+ {
+ nint namePtr = DyldGetImageName(i);
+ if (namePtr != default)
+ {
+ string? name = Marshal.PtrToStringUTF8(namePtr);
+ if (!string.IsNullOrEmpty(name))
+ {
+ yield return name!;
+ }
+ }
+ }
+ }
+
+ // dlopen is exported by libSystem on macOS. On Linux it is exported by libc.so.6 on
+ // glibc >= 2.34, but by libdl.so.2 on older glibc versions.
+ private static nint DlOpen(nint fileName, int flags)
+ {
+ if (IsMacOS)
+ {
+ return DlOpenLibSystem(fileName, flags);
+ }
+
+ try
+ {
+ return DlOpenLibc(fileName, flags);
+ }
+ catch (Exception ex) when (ex is EntryPointNotFoundException or DllNotFoundException)
+ {
+ return DlOpenLibdl(fileName, flags);
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct dl_phdr_info
+ {
+ public nint dlpi_addr;
+ public nint dlpi_name;
+ // Remaining fields (phdr table pointer/count, etc.) are not needed here.
+ }
+
+ [LibraryImport("libc.so.6", EntryPoint = "dl_iterate_phdr")]
+ private static partial int DlIteratePhdr(
+ delegate* unmanaged[Cdecl] callback, void* data);
+
+ [LibraryImport("libc.so.6", EntryPoint = "dlopen")]
+ private static partial nint DlOpenLibc(nint filename, int flags);
+
+ [LibraryImport("libdl.so.2", EntryPoint = "dlopen")]
+ private static partial nint DlOpenLibdl(nint filename, int flags);
+
+ [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "dlopen")]
+ private static partial nint DlOpenLibSystem(nint filename, int flags);
+
+ [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "_dyld_image_count")]
+ private static partial uint DyldImageCount();
+
+ [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "_dyld_get_image_name")]
+ private static partial nint DyldGetImageName(uint index);
+}
+
+#endif
diff --git a/src/NodeApi/NodeApiNativeLibrary.cs b/src/NodeApi/NodeApiNativeLibrary.cs
new file mode 100644
index 00000000..d7220d5f
--- /dev/null
+++ b/src/NodeApi/NodeApiNativeLibrary.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.JavaScript.NodeApi;
+
+///
+/// Helpers for managing native libraries loaded alongside node-api-dotnet.
+///
+public static class NodeApiNativeLibrary
+{
+ ///
+ /// Pins an already-loaded native library in memory (Linux/macOS) so the OS never unloads it,
+ /// preventing a worker-thread teardown crash caused by a dangling pthread_key destructor.
+ ///
+ ///
+ /// The path, or already-loaded name, of the native library to pin (for example an application's
+ /// native authentication or cryptography dependency).
+ ///
+ ///
+ /// True if the library was found loaded and pinned; false if it was not loaded, if the platform
+ /// is not affected (Windows), or if pinning is not supported on the current target framework.
+ ///
+ ///
+ /// A native library can register per-thread cleanup with the OS via
+ /// pthread_key_create(&key, destructor); glibc's __nptl_deallocate_tsd calls that
+ /// destructor when a thread exits. If the library is unloaded (dlclose) while a thread
+ /// still holds a value for the key — as can happen when a worker_threads Worker that used
+ /// the library is torn down — the destructor pointer dangles into unmapped memory and the process
+ /// crashes with SIGSEGV (exit 139) as the worker thread exits.
+ ///
+ /// node-api-dotnet already pins its own host module and the .NET runtime's native libraries.
+ /// Call this method during application startup for any additional native dependency your
+ /// application loads that registers a TLS destructor, so it is kept mapped for the process
+ /// lifetime. The library must already be loaded when this is called. This is a no-op on Windows.
+ ///
+ public static bool PreventUnload(string libraryNameOrPath)
+ {
+#if NETFRAMEWORK || NETSTANDARD
+ _ = libraryNameOrPath;
+ return false;
+#else
+ return DotNetHost.NativeLibraryPinning.PinLibrary(libraryNameOrPath);
+#endif
+ }
+
+ ///
+ /// Pins the .NET runtime's already-loaded native libraries (for example the CLR and its
+ /// cryptography libraries) in memory on Linux/macOS, so their pthread_key TLS
+ /// destructors are never unmapped and cannot dangle when a worker thread tears down.
+ ///
+ ///
+ /// node-api-dotnet calls this automatically during host initialization; it is exposed publicly
+ /// so applications can also invoke it explicitly. It is idempotent and a no-op on Windows.
+ ///
+ public static void PreventRuntimeLibrariesUnload()
+ {
+#if NETFRAMEWORK || NETSTANDARD
+ // No-op: the affected teardown crash is specific to Linux/macOS hosted-runtime scenarios.
+#else
+ DotNetHost.NativeLibraryPinning.PinLoadedRuntimeLibraries();
+#endif
+ }
+}
diff --git a/test/NativeLibraryPinningTests.cs b/test/NativeLibraryPinningTests.cs
new file mode 100644
index 00000000..ae0a5fcf
--- /dev/null
+++ b/test/NativeLibraryPinningTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Xunit;
+
+namespace Microsoft.JavaScript.NodeApi.Test;
+
+///
+/// Tests for , which pins already-loaded native libraries in
+/// memory (Linux/macOS) so their pthread_key TLS destructors are never unmapped and cannot
+/// dangle when a worker thread tears down (SIGSEGV / exit 139). See the worker_teardown regression
+/// case for the end-to-end host-module scenario; these tests cover the public pinning API contract
+/// and exercise the underlying dlopen(RTLD_NODELETE) primitive against a real loaded library.
+///
+public class NativeLibraryPinningTests
+{
+ private static bool IsAffectedPlatform =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ||
+ RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
+
+ [Fact]
+ public void PreventUnload_NullOrEmpty_ReturnsFalse()
+ {
+ Assert.False(NodeApiNativeLibrary.PreventUnload(null!));
+ Assert.False(NodeApiNativeLibrary.PreventUnload(string.Empty));
+ }
+
+ [Fact]
+ public void PreventUnload_NotLoadedLibrary_ReturnsFalse()
+ {
+ // A library that is not loaded is resolved with RTLD_NOLOAD, so dlopen returns null and no
+ // new copy is loaded. On unaffected platforms (Windows) this is always a no-op returning
+ // false. Either way the result must be false.
+ Assert.False(NodeApiNativeLibrary.PreventUnload(
+ "node-api-dotnet-this-library-does-not-exist.so"));
+ }
+
+ [Fact]
+ public void PreventUnload_LoadedRuntimeLibrary_ReturnsTrueOnAffectedPlatforms()
+ {
+ string? loadedRuntimeLibraryPath = FindLoadedRuntimeLibraryPath();
+
+ if (!IsAffectedPlatform)
+ {
+ // Pinning is a no-op on Windows; the API must report that nothing was pinned even if a
+ // matching module name happens to be loaded.
+ Assert.False(NodeApiNativeLibrary.PreventUnload(
+ loadedRuntimeLibraryPath ?? "kernel32.dll"));
+ return;
+ }
+
+ // The test itself runs on the CoreCLR runtime, so libcoreclr (or another runtime native
+ // library) is loaded and must be found; a null here indicates a real problem locating it.
+ Assert.NotNull(loadedRuntimeLibraryPath);
+
+ // Pinning an already-loaded library must succeed, and must remain successful when repeated
+ // (the pin is idempotent; the extra, never-released reference simply blocks a later dlclose).
+ Assert.True(NodeApiNativeLibrary.PreventUnload(loadedRuntimeLibraryPath!));
+ Assert.True(NodeApiNativeLibrary.PreventUnload(loadedRuntimeLibraryPath!));
+ }
+
+ [Fact]
+ public void PreventRuntimeLibrariesUnload_DoesNotThrow_AndIsIdempotent()
+ {
+ // Best-effort and idempotent on every platform: it pins the runtime's native libraries on
+ // Linux/macOS and is a no-op on Windows. It must never throw.
+ NodeApiNativeLibrary.PreventRuntimeLibrariesUnload();
+ NodeApiNativeLibrary.PreventRuntimeLibrariesUnload();
+ }
+
+ // Finds the full path of a loaded .NET runtime native library (for example libcoreclr). It
+ // first walks the current process's loaded modules, then falls back to the well-known runtime
+ // directory. Returns null if none is found.
+ private static string? FindLoadedRuntimeLibraryPath()
+ {
+ string[] prefixes =
+ {
+ "libcoreclr",
+ "libclrjit",
+ "libhostpolicy",
+ "libSystem.Native",
+ };
+
+ try
+ {
+ foreach (ProcessModule module in Process.GetCurrentProcess().Modules
+ .Cast())
+ {
+ string fileName = module.ModuleName ?? string.Empty;
+ if (prefixes.Any(p => fileName.StartsWith(p, StringComparison.Ordinal)) &&
+ !string.IsNullOrEmpty(module.FileName))
+ {
+ return module.FileName;
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Module enumeration can be unavailable on some platforms/configurations; fall through.
+ }
+
+ // Fall back to the loaded CLR native library in the runtime directory.
+ string extension = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? ".dylib" : ".so";
+ string candidate = System.IO.Path.Combine(
+ RuntimeEnvironment.GetRuntimeDirectory(), "libcoreclr" + extension);
+ return System.IO.File.Exists(candidate) ? candidate : null;
+ }
+}