Fix SIGSEGV when a worker_threads Worker that loaded node-api-dotnet … - #487
Fix SIGSEGV when a worker_threads Worker that loaded node-api-dotnet …#487GalaxiasKyklos wants to merge 2 commits into
Conversation
…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
|
|
||
| s_moduleUnloadPrevented = true; | ||
|
|
||
| if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) |
There was a problem hiding this comment.
You mentioned above that Windows does not hit this problem; do you know whether this issue affects Mac OS?
There was a problem hiding this comment.
I've extended the pin to macOS in the latest commit, I could only validate on Linux (Docker repro); the macOS path is best-effort and unvalidated on real hardware. Happy to gate it Linux-only if you'd prefer until it's validated on a Mac.
There was a problem hiding this comment.
PR builds run on macOS, so at least they will validate it's not broken (once the package issues are resolved). But I'd prefer to confirm whether or not this fix is actually needed on that platform. Is there a simple repro? I use macOS as my main dev environment now, so I could easily try it out.
There was a problem hiding this comment.
Thank you! I put together this gist with instructions on how to repro it on macOS
https://gist.github.com/GalaxiasKyklos/8bb730adcd486af5e0067eb2f1c69055
| public nint dli_saddr; | ||
| } | ||
|
|
||
| [DllImport("libc.so.6")] |
There was a problem hiding this comment.
Please fix the formatter issues - it says to use the LibraryImportAttribute instead of DllImportAttribute.
There was a problem hiding this comment.
Done, converted the dladdr / dlopen imports to source-generated [LibraryImport] in the latest commit
- 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
Summary
Fixes a SIGSEGV (process crash, exit 139) that occurs on Linux when a
worker_threadsWorker loadsnode-api-dotnetand the worker is then terminated.Issue: #486
Repro
Load the module inside a Worker, then terminate the worker:
On Linux the process crashes with SIGSEGV instead of printing
terminated ok.Root cause
The native host (
Microsoft.JavaScript.NodeApi.node) is compiled with NativeAOT, so the.nodeembeds its own .NET runtime. That runtime registers per-thread cleanup with the OS via apthread_keydestructor whose function pointer points into this module's own code.When a Worker environment is torn down, Node.js unloads the addon (
dlclose) while the worker's OS thread is still alive. Thepthread_keydestructor is still registered, but now points at unmapped memory. When the worker thread subsequently exits, glibc's__nptl_deallocate_tsdinvokes that dangling destructor → SIGSEGV.This was confirmed with gdb: at crash time the faulting destructor belonged to the
.node, which had 0 memory mappings (alreadydlclosed). No-op'ingdlcloseviaLD_PRELOADproduced a clean exit, confirming the unmap-while-thread-alive diagnosis.Fix
Pin the native host module for the lifetime of the process on Linux. Resolve the module's own path via
dladdron one of its functions, then re-open it withdlopen(RTLD_NOLOAD | RTLD_NODELETE):RTLD_NOLOADresolves the already-loaded module without loading a second copy.RTLD_NODELETEkeeps it mapped for the process lifetime, and the extra (never-released) reference prevents Node'sdlclosefrom unmapping it.This keeps the
pthread_keydestructor address valid for the life of the process. The change is:Testing
Reproduced and validated in Docker (
mcr.microsoft.com/dotnet/sdk:10.0+ Node 24.13.0, which crashes deterministically):terminated ok), 5/5 runsNodeApibuilds clean onnet8.0,net9.0,net10.0, andnetstandard2.0.