Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/NodeApi/DotNetHost/NativeHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,114 @@ public static void Trace(string msg)
}
}

private static bool s_moduleUnloadPrevented;

/// <summary>
/// Pins this native host module in memory so the OS never unloads it.
/// </summary>
/// <remarks>
/// This native host is compiled with NativeAOT, so it embeds a .NET runtime whose
/// per-thread cleanup is registered with the OS via a <c>pthread_key</c> destructor that
/// points into this module's own code. Node.js unloads (<c>dlclose</c>) an addon when the
/// environment that loaded it is torn down. When a <c>worker_threads</c> 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 <c>__nptl_deallocate_tsd</c>).
/// Keeping the module mapped for the lifetime of the process keeps that destructor valid.
/// <para/>
/// This affects Unix hosting (Linux and macOS), which unload modules via <c>dlclose</c> 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 <c>RTLD_*</c> flag values and system library. The pin is best-effort: any
/// failure is traced but does not block init.
/// </remarks>
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]<napi_env, napi_value, napi_value>)
&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) })]
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
Expand Down
Loading