diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index b3c7c654..5f7f06dd 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -43,6 +43,103 @@ 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 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() + { + if (s_moduleUnloadPrevented) + { + return; + } + + s_moduleUnloadPrevented = true; + + bool isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !isMacOS) + { + 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; + + 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. The flag + // values differ between glibc and macOS/dyld. + const int RTLD_LAZY = 0x0001; + 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 + { + 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; + } + + // 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); + + [LibraryImport("libSystem", EntryPoint = "dlopen")] + private static partial nint DlOpenMacOS(nint filename, int flags); + [UnmanagedCallersOnly( EntryPoint = nameof(napi_register_module_v1), CallConvs = new[] { typeof(CallConvCdecl) })] @@ -50,6 +147,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