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/7] 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/7] 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), From f3248056db953f9348a606e1e5f3b3e70a4fafb4 Mon Sep 17 00:00:00 2001 From: Saul Ponce Razo Date: Wed, 5 Aug 2026 09:47:34 -0700 Subject: [PATCH 3/7] Fix IDE0018 format check: inline dladdr out-variables per platform The CI 'dotnet format --severity info' check failed with IDE0018 on the shared Dl_info declaration used across both platform branches. Resolve the module path in per-platform if/else branches with inlined out-variables so no separate declaration is needed; behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7 --- src/NodeApi/DotNetHost/NativeHost.cs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index 5f7f06dd..1823b0d2 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -87,12 +87,23 @@ private static unsafe void PreventModuleUnload() (nint)(delegate* unmanaged[Cdecl]) &InitializeModule; - Dl_info info; - int found = isMacOS - ? DlAddrMacOS(moduleFunction, out info) - : DlAddrLinux(moduleFunction, out info); + nint fileName = default; + if (isMacOS) + { + if (DlAddrMacOS(moduleFunction, out Dl_info info) != 0) + { + fileName = info.dli_fname; + } + } + else + { + if (DlAddrLinux(moduleFunction, out Dl_info info) != 0) + { + fileName = info.dli_fname; + } + } - if (found != 0 && info.dli_fname != default) + if (fileName != 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 @@ -103,8 +114,8 @@ private static unsafe void PreventModuleUnload() int rtldNoDelete = isMacOS ? 0x0080 : 0x1000; int flags = RTLD_LAZY | rtldNoLoad | rtldNoDelete; nint handle = isMacOS - ? DlOpenMacOS(info.dli_fname, flags) - : DlOpenLinux(info.dli_fname, flags); + ? DlOpenMacOS(fileName, flags) + : DlOpenLinux(fileName, flags); Trace($" Pinned native host module ({(handle != default ? "ok" : "no-op")})."); } else From acd6b017aed2001041244e455416755cc1f918c4 Mon Sep 17 00:00:00 2001 From: Saul Ponce Razo Date: Wed, 5 Aug 2026 10:20:46 -0700 Subject: [PATCH 4/7] Address review: gate pin to Linux and support pre-2.34 glibc - Restrict the module pin to Linux only. The macOS path was unvalidated on real hardware, so it is removed pending validation (per review feedback). - dladdr/dlopen are exported by libc.so.6 on glibc >= 2.34 but by libdl.so.2 on older glibc. Import from libc.so.6 first and fall back to libdl.so.2 so the pin is effective across glibc versions instead of silently no-op'ing (and leaving the SIGSEGV) on pre-2.34 systems. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7 --- src/NodeApi/DotNetHost/NativeHost.cs | 78 ++++++++++++++++------------ 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index 1823b0d2..1ff3aaf5 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -58,11 +58,10 @@ 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 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. + /// This is scoped to Linux (glibc), where the crash has been reproduced and the fix + /// validated; on Windows module/thread teardown does not hit this issue. (The same + /// mechanism may affect macOS, but that is not enabled here pending validation.) The pin + /// is best-effort: any failure is traced but does not block init. /// private static unsafe void PreventModuleUnload() { @@ -73,8 +72,7 @@ private static unsafe void PreventModuleUnload() s_moduleUnloadPrevented = true; - bool isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !isMacOS) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { return; } @@ -88,34 +86,20 @@ private static unsafe void PreventModuleUnload() &InitializeModule; nint fileName = default; - if (isMacOS) + if (DlAddr(moduleFunction, out Dl_info info) != 0) { - if (DlAddrMacOS(moduleFunction, out Dl_info info) != 0) - { - fileName = info.dli_fname; - } - } - else - { - if (DlAddrLinux(moduleFunction, out Dl_info info) != 0) - { - fileName = info.dli_fname; - } + fileName = info.dli_fname; } if (fileName != 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. + // released) reference also prevents Node's dlclose from unmapping it. 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(fileName, flags) - : DlOpenLinux(fileName, flags); + const int RTLD_NOLOAD = 0x0004; + const int RTLD_NODELETE = 0x1000; + nint handle = DlOpen(fileName, RTLD_LAZY | RTLD_NOLOAD | RTLD_NODELETE); Trace($" Pinned native host module ({(handle != default ? "ok" : "no-op")})."); } else @@ -129,6 +113,33 @@ private static unsafe void PreventModuleUnload() } } + // dladdr and dlopen are exported by libc.so.6 on glibc >= 2.34, but by libdl.so.2 on older + // glibc (where libc.so.6 does not export them). Try libc first, then fall back to libdl so + // the pin works across glibc versions. + private static int DlAddr(nint addr, out Dl_info info) + { + try + { + return DlAddrLibc(addr, out info); + } + catch (Exception ex) when (ex is EntryPointNotFoundException or DllNotFoundException) + { + return DlAddrLibdl(addr, out info); + } + } + + private static nint DlOpen(nint fileName, int 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_info { @@ -138,18 +149,17 @@ private struct Dl_info 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); + private static partial int DlAddrLibc(nint addr, out Dl_info info); - [LibraryImport("libSystem", EntryPoint = "dladdr")] - private static partial int DlAddrMacOS(nint addr, out Dl_info info); + [LibraryImport("libdl.so.2", EntryPoint = "dladdr")] + private static partial int DlAddrLibdl(nint addr, out Dl_info info); [LibraryImport("libc.so.6", EntryPoint = "dlopen")] - private static partial nint DlOpenLinux(nint filename, int flags); + private static partial nint DlOpenLibc(nint filename, int flags); - [LibraryImport("libSystem", EntryPoint = "dlopen")] - private static partial nint DlOpenMacOS(nint filename, int flags); + [LibraryImport("libdl.so.2", EntryPoint = "dlopen")] + private static partial nint DlOpenLibdl(nint filename, int flags); [UnmanagedCallersOnly( EntryPoint = nameof(napi_register_module_v1), From 93669dd59885030a5ad70e3dcf7431dd891389ee Mon Sep 17 00:00:00 2001 From: Saul Ponce Razo Date: Wed, 5 Aug 2026 10:42:01 -0700 Subject: [PATCH 5/7] Add worker_threads teardown regression test for AOT host unload crash Loads the native host only inside a Worker (so no other reference keeps the module mapped), terminates the Worker, and asserts a clean process exit. Fails (child exits 139/SIGSEGV) without PreventModuleUnload(); passes with it. multi_instance.js cannot cover this because it loads the binding on the main thread first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7 --- test/TestCases/napi-dotnet/worker_teardown.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/TestCases/napi-dotnet/worker_teardown.js diff --git a/test/TestCases/napi-dotnet/worker_teardown.js b/test/TestCases/napi-dotnet/worker_teardown.js new file mode 100644 index 00000000..26825613 --- /dev/null +++ b/test/TestCases/napi-dotnet/worker_teardown.js @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Regression test for a worker_threads teardown crash. +// +// When the native host is loaded ONLY inside a Worker (so no other reference keeps the +// module mapped) and the Worker is then terminated, Node.js unloads (dlclose) the addon +// while the worker's OS thread is still exiting. For a NativeAOT host that leaves a +// dangling pthread-key destructor, so the process crashes with SIGSEGV as the thread exits. +// NativeHost.PreventModuleUnload() pins the module to prevent that. This test fails (the +// child node process exits non-zero) if the crash regresses. +// +// The binding is intentionally NOT loaded on the main thread: doing so would keep another +// module reference alive and mask the unload crash (which is why multi_instance.js cannot +// cover this case). + +const assert = require('assert'); +const { Worker, isMainThread, parentPort } = require('worker_threads'); + +if (isMainThread) { + const worker = new Worker(__filename); + worker.on('error', (err) => { throw err; }); + worker.once('message', (message) => { + assert.strictEqual(message, 'ready'); + // Let the worker settle, then tear it down. An unfixed host crashes during the + // worker thread's teardown after the module is unloaded. + setTimeout(async () => { + await worker.terminate(); + // Keep the process alive briefly so any teardown crash surfaces as a non-zero + // exit code instead of being skipped by an immediate process exit. + setTimeout(() => process.exit(0), 300); + }, 300); + }); +} else { + // Load the native host ONLY in the worker. + const binding = require('../common').binding; + assert.ok(binding); + parentPort.postMessage('ready'); +} From db3edcc1e4301da5b6c0c2b4f18b2e392dfa85b5 Mon Sep 17 00:00:00 2001 From: Saul Ponce Razo Date: Wed, 5 Aug 2026 11:08:51 -0700 Subject: [PATCH 6/7] Scope worker teardown regression test to the hosted host The NativeAOT test suite loads the generated module directly, whose entry point never calls NativeHost.PreventModuleUnload(); only the hosted host module (Microsoft.JavaScript.NodeApi.node) is pinned. Exclude the case from NativeAotTests so it runs under HostedClrTests, where it actually exercises the fix, and avoid a latent SIGSEGV on Node >=24 in the AOT run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 144c87db-8d78-474f-bff9-21031a23e3e7 --- test/NativeAotTests.cs | 6 ++++++ test/TestCases/napi-dotnet/worker_teardown.js | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/test/NativeAotTests.cs b/test/NativeAotTests.cs index 5ce4b411..6ce5562a 100644 --- a/test/NativeAotTests.cs +++ b/test/NativeAotTests.cs @@ -18,6 +18,12 @@ public class NativeAotTests public static IEnumerable TestCases { get; } = ListTestCases((testCaseName) => !testCaseName.Contains("/dynamic_") && + + // The worker_teardown case validates NativeHost.PreventModuleUnload(), which only runs + // when the hosted host module (Microsoft.JavaScript.NodeApi.node) is loaded. A generated + // NativeAOT module has its own entry point that never calls it, so this case is only + // meaningful under the hosted CLR host. See HostedClrTests. + !testCaseName.Contains("/worker_teardown") && !testCaseName.StartsWith("projects/", StringComparison.Ordinal)); [Theory] diff --git a/test/TestCases/napi-dotnet/worker_teardown.js b/test/TestCases/napi-dotnet/worker_teardown.js index 26825613..ec8244d6 100644 --- a/test/TestCases/napi-dotnet/worker_teardown.js +++ b/test/TestCases/napi-dotnet/worker_teardown.js @@ -10,6 +10,10 @@ // NativeHost.PreventModuleUnload() pins the module to prevent that. This test fails (the // child node process exits non-zero) if the crash regresses. // +// This validates the hosted host module (Microsoft.JavaScript.NodeApi.node), which is what +// PreventModuleUnload() pins, so it runs under HostedClrTests only (excluded from +// NativeAotTests, whose generated module has a separate entry point). +// // The binding is intentionally NOT loaded on the main thread: doing so would keep another // module reference alive and mask the unload crash (which is why multi_instance.js cannot // cover this case). From d9f5371a299a3366941e38060cde5c0f4770f10a Mon Sep 17 00:00:00 2001 From: Jason Ginchereau Date: Wed, 5 Aug 2026 09:54:06 -1000 Subject: [PATCH 7/7] Enhance PreventModuleUnload to support macOS, improve JSTsfnSynchronizationContext cleanup --- src/NodeApi/DotNetHost/NativeHost.cs | 42 +++++++++---- .../Interop/JSSynchronizationContext.cs | 60 ++++++++++++++++++- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index 1ff3aaf5..e925cb19 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -58,10 +58,9 @@ 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 is scoped to Linux (glibc), where the crash has been reproduced and the fix - /// validated; on Windows module/thread teardown does not hit this issue. (The same - /// mechanism may affect macOS, but that is not enabled here pending validation.) The pin - /// is best-effort: any failure is traced but does not block init. + /// This is scoped to Linux (glibc) and macOS, where the native host can be unloaded before + /// the worker thread exits; 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() { @@ -72,7 +71,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; } @@ -97,9 +97,14 @@ private static unsafe void PreventModuleUnload() // 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(fileName, RTLD_LAZY | RTLD_NOLOAD | RTLD_NODELETE); + const int RTLD_NOLOAD_LINUX = 0x0004; + const int RTLD_NODELETE_LINUX = 0x1000; + const int RTLD_NOLOAD_MACOS = 0x0010; + const int RTLD_NODELETE_MACOS = 0x0080; + int flags = RTLD_LAZY | (isMacOS ? + RTLD_NOLOAD_MACOS | RTLD_NODELETE_MACOS : + RTLD_NOLOAD_LINUX | RTLD_NODELETE_LINUX); + nint handle = DlOpen(fileName, flags); Trace($" Pinned native host module ({(handle != default ? "ok" : "no-op")})."); } else @@ -113,11 +118,15 @@ private static unsafe void PreventModuleUnload() } } - // dladdr and dlopen are exported by libc.so.6 on glibc >= 2.34, but by libdl.so.2 on older - // glibc (where libc.so.6 does not export them). Try libc first, then fall back to libdl so - // the pin works across glibc versions. + // dladdr and dlopen are exported by libSystem on macOS. On Linux they are exported by + // libc.so.6 on glibc >= 2.34, but by libdl.so.2 on older glibc versions. private static int DlAddr(nint addr, out Dl_info info) { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return DlAddrLibSystem(addr, out info); + } + try { return DlAddrLibc(addr, out info); @@ -130,6 +139,11 @@ private static int DlAddr(nint addr, out Dl_info info) private static nint DlOpen(nint fileName, int flags) { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return DlOpenLibSystem(fileName, flags); + } + try { return DlOpenLibc(fileName, flags); @@ -161,6 +175,12 @@ private struct Dl_info [LibraryImport("libdl.so.2", EntryPoint = "dlopen")] private static partial nint DlOpenLibdl(nint filename, int flags); + [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "dladdr")] + private static partial int DlAddrLibSystem(nint addr, out Dl_info info); + + [LibraryImport("/usr/lib/libSystem.B.dylib", EntryPoint = "dlopen")] + private static partial nint DlOpenLibSystem(nint filename, int flags); + [UnmanagedCallersOnly( EntryPoint = nameof(napi_register_module_v1), CallConvs = new[] { typeof(CallConvCdecl) })] diff --git a/src/NodeApi/Interop/JSSynchronizationContext.cs b/src/NodeApi/Interop/JSSynchronizationContext.cs index 5e6fd181..48e2463b 100644 --- a/src/NodeApi/Interop/JSSynchronizationContext.cs +++ b/src/NodeApi/Interop/JSSynchronizationContext.cs @@ -2,8 +2,13 @@ // Licensed under the MIT License. using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.JavaScript.NodeApi.Runtime; +using static Microsoft.JavaScript.NodeApi.Runtime.JSRuntime; +using static Microsoft.JavaScript.NodeApi.Runtime.NodejsRuntime; namespace Microsoft.JavaScript.NodeApi.Interop; @@ -244,12 +249,17 @@ public Task RunAsync(Func> asyncAction) } } -internal sealed class JSTsfnSynchronizationContext : JSSynchronizationContext +internal sealed unsafe class JSTsfnSynchronizationContext : JSSynchronizationContext { + private readonly JSRuntime _runtime; + private readonly napi_env _env; private readonly JSThreadSafeFunction _tsfn; + private GCHandle _cleanupHandle; public JSTsfnSynchronizationContext() { + _runtime = JSValueScope.Current.Runtime; + _env = (napi_env)JSValueScope.Current; _tsfn = new JSThreadSafeFunction( maxQueueSize: 0, initialThreadCount: 1, @@ -257,12 +267,35 @@ public JSTsfnSynchronizationContext() // Unref TSFN to indicate that this TSFN is not preventing Node.JS shutdown. _tsfn.Unref(); + + // Node runs environment cleanup hooks in reverse registration order. Registering this + // after the TSFN ensures it is released before Node closes the TSFN's libuv handle. + _cleanupHandle = GCHandle.Alloc(this); + napi_status status = _runtime.AddEnvCleanupHook( + _env, + new napi_cleanup_hook(s_cleanup), + (nint)_cleanupHandle); + if (status != napi_status.napi_ok) + { + _cleanupHandle.Free(); + _tsfn.Release(); + status.ThrowIfFailed(); + } } public override void Dispose() { if (IsDisposed) return; + if (_cleanupHandle.IsAllocated) + { + _runtime.RemoveEnvCleanupHook( + _env, + new napi_cleanup_hook(s_cleanup), + (nint)_cleanupHandle).ThrowIfFailed(); + _cleanupHandle.Free(); + } + base.Dispose(); // Destroy TSFN by releasing last thread use count. @@ -270,6 +303,31 @@ public override void Dispose() _tsfn.Release(); } +#if !UNMANAGED_DELEGATES + private static readonly napi_cleanup_hook.Delegate s_cleanup = Cleanup; +#else + private static readonly unsafe delegate* unmanaged[Cdecl] s_cleanup = &Cleanup; +#endif + +#if UNMANAGED_DELEGATES + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] +#endif + private static unsafe void Cleanup(nint data) + { + GCHandle cleanupHandle = GCHandle.FromIntPtr(data); + JSTsfnSynchronizationContext context = + (JSTsfnSynchronizationContext)cleanupHandle.Target!; + context._cleanupHandle = default; + try + { + context.Dispose(); + } + finally + { + cleanupHandle.Free(); + } + } + /// /// Increment reference count for the main loop async resource. /// Non-zero count prevents Node.JS process from exiting.