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