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; + } +}