From f9131974b8778276591e60b2822d08345eb19025 Mon Sep 17 00:00:00 2001 From: Saul Ponce Razo Date: Mon, 3 Aug 2026 09:55:17 -0700 Subject: [PATCH 1/2] Fix SIGSEGV when a worker_threads Worker that loaded node-api-dotnet is terminated The native host is compiled with NativeAOT, so the .node embeds its own .NET runtime. That runtime registers a per-thread cleanup via a pthread_key destructor pointing into the module's own code. When a worker_threads Worker loads the module and is then terminated, Node.js dlcloses the addon while the worker OS thread is still alive. The now-dangling destructor fires as the thread exits (glibc __nptl_deallocate_tsd), crashing the process with SIGSEGV. Pin the native host module for the lifetime of the process on Linux by re-opening it with dlopen(RTLD_NOLOAD | RTLD_NODELETE), resolving its path via dladdr on one of its own functions. This keeps the destructor valid. Scoped to Linux/glibc; best-effort with tracing, non-fatal on failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7 --- src/NodeApi/DotNetHost/NativeHost.cs | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index b3c7c654..2c6bf7b6 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -43,6 +43,83 @@ public static void Trace(string msg) } } + private static bool s_moduleUnloadPrevented; + + /// + /// Pins this native host module in memory so the OS never unloads it. + /// + /// + /// This native host is compiled with NativeAOT, so it embeds a .NET runtime whose + /// per-thread cleanup is registered with the OS via a pthread_key destructor that + /// points into this module's own code. Node.js unloads (dlclose) an addon when the + /// environment that loaded it is torn down. When a worker_threads Worker loads this + /// module and is then terminated, Node unloads the module while the worker's OS thread is + /// still alive; the still-registered destructor then points at unmapped memory and the + /// process crashes with SIGSEGV as the thread exits (glibc __nptl_deallocate_tsd). + /// Keeping the module mapped for the lifetime of the process keeps that destructor valid. + /// + /// This only affects Unix (glibc) hosting; on Windows module/thread teardown does not hit + /// this issue. The pin is best-effort: any failure is traced but does not block init. + /// + private static unsafe void PreventModuleUnload() + { + if (s_moduleUnloadPrevented) + { + return; + } + + s_moduleUnloadPrevented = true; + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return; + } + + try + { + // Resolve the file path of this shared library from the address of one of its + // own functions, then re-open it with RTLD_NODELETE so it is never unmapped. + nint moduleFunction = + (nint)(delegate* unmanaged[Cdecl]) + &InitializeModule; + + if (dladdr(moduleFunction, out Dl_info info) != 0 && info.dli_fname != default) + { + // RTLD_NOLOAD resolves the already-loaded module without loading a new copy; + // RTLD_NODELETE keeps it mapped for the process lifetime. The extra (never + // released) reference also prevents Node's dlclose from unmapping it. + const int RTLD_LAZY = 0x0001; + const int RTLD_NOLOAD = 0x0004; + const int RTLD_NODELETE = 0x1000; + nint handle = dlopen(info.dli_fname, RTLD_LAZY | RTLD_NOLOAD | RTLD_NODELETE); + Trace($" Pinned native host module ({(handle != default ? "ok" : "no-op")})."); + } + else + { + Trace(" Could not resolve native host module path to pin it."); + } + } + catch (Exception ex) + { + Trace(" Failed to pin native host module: " + ex); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct Dl_info + { + public nint dli_fname; + public nint dli_fbase; + public nint dli_sname; + public nint dli_saddr; + } + + [DllImport("libc.so.6")] + private static extern int dladdr(nint addr, out Dl_info info); + + [DllImport("libc.so.6")] + private static extern nint dlopen(nint filename, int flags); + [UnmanagedCallersOnly( EntryPoint = nameof(napi_register_module_v1), CallConvs = new[] { typeof(CallConvCdecl) })] @@ -50,6 +127,10 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) { Trace($"> NativeHost.InitializeModule({env.Handle:X8}, {exports.Handle:X8})"); + // Ensure this native module stays loaded for the lifetime of the process. See + // PreventModuleUnload() for details on the worker-thread teardown crash this avoids. + PreventModuleUnload(); + s_jsRuntime ??= new NodejsRuntime(); // The native host JSValueScope is not disposed after a successful initialization. It From 13c436256f765c74c61dfc29b68fb08611c526bf Mon Sep 17 00:00:00 2001 From: Saul Ponce Razo Date: Mon, 3 Aug 2026 15:35:29 -0700 Subject: [PATCH 2/2] Address review: use LibraryImport and cover macOS - Convert the dladdr/dlopen P/Invokes from DllImport to source-generated LibraryImport (resolves SYSLIB1054). - Extend the module pin to macOS in addition to Linux: the same dlclose + NativeAOT pthread-destructor teardown crash applies. Select the correct RTLD_NOLOAD/RTLD_NODELETE flag values and system library (libc.so.6 on Linux, libSystem on macOS) per platform. macOS remains best-effort and is unvalidated (Linux verified: pin ok, repro exits 0 on Node 24.13 and 24.18). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7 --- src/NodeApi/DotNetHost/NativeHost.cs | 44 ++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index 2c6bf7b6..5f7f06dd 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -58,8 +58,11 @@ public static void Trace(string msg) /// process crashes with SIGSEGV as the thread exits (glibc __nptl_deallocate_tsd). /// Keeping the module mapped for the lifetime of the process keeps that destructor valid. /// - /// This only affects Unix (glibc) hosting; on Windows module/thread teardown does not hit - /// this issue. The pin is best-effort: any failure is traced but does not block init. + /// This affects Unix hosting (Linux and macOS), which unload modules via dlclose and + /// run NativeAOT's per-thread destructors from the dynamic loader; on Windows module/thread + /// teardown does not hit this issue. The macOS path mirrors the Linux one but uses that + /// platform's RTLD_* flag values and system library. The pin is best-effort: any + /// failure is traced but does not block init. /// private static unsafe void PreventModuleUnload() { @@ -70,7 +73,8 @@ private static unsafe void PreventModuleUnload() s_moduleUnloadPrevented = true; - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + bool isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !isMacOS) { return; } @@ -83,15 +87,24 @@ private static unsafe void PreventModuleUnload() (nint)(delegate* unmanaged[Cdecl]) &InitializeModule; - if (dladdr(moduleFunction, out Dl_info info) != 0 && info.dli_fname != default) + Dl_info info; + int found = isMacOS + ? DlAddrMacOS(moduleFunction, out info) + : DlAddrLinux(moduleFunction, out info); + + if (found != 0 && info.dli_fname != default) { // RTLD_NOLOAD resolves the already-loaded module without loading a new copy; // RTLD_NODELETE keeps it mapped for the process lifetime. The extra (never - // released) reference also prevents Node's dlclose from unmapping it. + // released) reference also prevents Node's dlclose from unmapping it. The flag + // values differ between glibc and macOS/dyld. const int RTLD_LAZY = 0x0001; - const int RTLD_NOLOAD = 0x0004; - const int RTLD_NODELETE = 0x1000; - nint handle = dlopen(info.dli_fname, RTLD_LAZY | RTLD_NOLOAD | RTLD_NODELETE); + int rtldNoLoad = isMacOS ? 0x0010 : 0x0004; + int rtldNoDelete = isMacOS ? 0x0080 : 0x1000; + int flags = RTLD_LAZY | rtldNoLoad | rtldNoDelete; + nint handle = isMacOS + ? DlOpenMacOS(info.dli_fname, flags) + : DlOpenLinux(info.dli_fname, flags); Trace($" Pinned native host module ({(handle != default ? "ok" : "no-op")})."); } else @@ -114,11 +127,18 @@ private struct Dl_info public nint dli_saddr; } - [DllImport("libc.so.6")] - private static extern int dladdr(nint addr, out Dl_info info); + // dladdr / dlopen live in libc.so.6 on Linux (glibc) and libSystem on macOS. + [LibraryImport("libc.so.6", EntryPoint = "dladdr")] + private static partial int DlAddrLinux(nint addr, out Dl_info info); + + [LibraryImport("libSystem", EntryPoint = "dladdr")] + private static partial int DlAddrMacOS(nint addr, out Dl_info info); + + [LibraryImport("libc.so.6", EntryPoint = "dlopen")] + private static partial nint DlOpenLinux(nint filename, int flags); - [DllImport("libc.so.6")] - private static extern nint dlopen(nint filename, int flags); + [LibraryImport("libSystem", EntryPoint = "dlopen")] + private static partial nint DlOpenMacOS(nint filename, int flags); [UnmanagedCallersOnly( EntryPoint = nameof(napi_register_module_v1),