Fix worker_threads teardown crash (SIGSEGV) and hang when node-api-dotnet is loaded in a Worker - #487
Merged
Vladimir Morozov (vmoroz) merged 7 commits intoAug 5, 2026
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
- 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
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
Copilot started reviewing on behalf of
Saúl Ponce (GalaxiasKyklos)
August 5, 2026 16:48
View session
There was a problem hiding this comment.
Pull request overview
Prevents Linux Worker teardown crashes by pinning the NativeAOT host module for the process lifetime.
Changes:
- Resolves the host module path with
dladdr. - Reopens it using
RTLD_NOLOAD | RTLD_NODELETE. - Adds Linux and macOS loader interop.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- 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
Copilot started reviewing on behalf of
Saúl Ponce (GalaxiasKyklos)
August 5, 2026 17:28
View session
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/NodeApi/DotNetHost/NativeHost.cs:173
- Add an automated regression case for the worker-only load/teardown path. The existing
multi_instance.jstest loads the host on the main thread before creating its Worker, so that main-thread reference keeps the module mapped and masks the reported crash. A test process where only the Worker requiresnode-api-dotnet, then is explicitly terminated and awaited, would fail with exit 139 before this fix and protect this lifecycle behavior on the Linux CI jobs.
PreventModuleUnload();
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
Copilot started reviewing on behalf of
Saúl Ponce (GalaxiasKyklos)
August 5, 2026 17:43
View session
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
Copilot started reviewing on behalf of
Saúl Ponce (GalaxiasKyklos)
August 5, 2026 18:09
View session
Vladimir Morozov (vmoroz)
approved these changes
Aug 5, 2026
…zationContext cleanup
Vladimir Morozov (vmoroz)
approved these changes
Aug 5, 2026
Jason Ginchereau (jasongin)
approved these changes
Aug 5, 2026
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/NodeApi/Interop/JSSynchronizationContext.cs:275
- This introduces the separate TSFN teardown fix even though the PR description says this change addresses only the SIGSEGV and that the Node >=24.14 hang is fixed independently. It also changes synchronization-context disposal for every environment, while the stated validation uses Node 24.13 and the CI matrix only covers Node 18/22, so the affected >=24.14 path is not tested here. Please either split this change out, or update the PR scope and add a >=24.14 teardown regression test.
// 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,
Copilot started reviewing on behalf of
Saúl Ponce (GalaxiasKyklos)
August 5, 2026 21:24
View session
Saúl Ponce (GalaxiasKyklos)
deleted the
fix/aot-worker-teardown-segfault
branch
August 5, 2026 21:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two failures that occur 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:
Instead of printing
terminated ok, the process crashes with SIGSEGV (Node 24.13.x) or hangs (Node >= 24.14).Root cause
Crash: 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. The same unload-before-thread-exit sequence applies on macOS.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.Hang: On Node.js >= 24.14, releasing the sync-context thread-safe function (TSFN) during environment teardown deadlocks because the TSFN's libuv handle is closed by Node before the managed side releases it.
Fix
Crash - Pin the native host module for the lifetime of the process. 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.dladdr/dlopenare resolved fromlibc.so.6(glibc >= 2.34) with a fallback tolibdl.so.2(older glibc), and fromlibSystem.B.dylibon macOS, using the platform's respectiveRTLD_*constants.Hang -
JSTsfnSynchronizationContextnow registers a Node env cleanup hook after creating the TSFN. Because Node runs cleanup hooks in reverse registration order, this releases the TSFN before Node closes its libuv handle, avoiding the deadlock.Testing
Reproduced and validated in Docker (
mcr.microsoft.com/dotnet/sdk:10.0):terminated ok)terminated ok)Added a regression test
test/TestCases/napi-dotnet/worker_teardown.jsthat loads the host only inside a Worker (so no main-thread reference masks the unload), terminates it, and asserts a clean exit. It runs underHostedClrTests(the suite that loads the pinned host module) and exits 139 without the fix / 0 with it.NodeApibuilds clean onnet8.0,net9.0,net10.0, andnetstandard2.0.