Skip to content

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 into
microsoft:mainfrom
GalaxiasKyklos:fix/aot-worker-teardown-segfault
Aug 5, 2026
Merged

Fix worker_threads teardown crash (SIGSEGV) and hang when node-api-dotnet is loaded in a Worker#487
Vladimir Morozov (vmoroz) merged 7 commits into
microsoft:mainfrom
GalaxiasKyklos:fix/aot-worker-teardown-segfault

Conversation

@GalaxiasKyklos

@GalaxiasKyklos Saúl Ponce (GalaxiasKyklos) commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes two failures that occur when a worker_threads Worker loads node-api-dotnet and the worker is then terminated:

  • A SIGSEGV (process crash, exit 139) on Linux and macOS.
  • A hang on Node.js >= 24.14 during environment teardown (TSFN release ordering).

Issue: #486

Repro

Load the module inside a Worker, then terminate the worker:

// worker.cjs
const { parentPort } = require('node:worker_threads');
require('node-api-dotnet/net10.0');
parentPort.postMessage('ready');
// main.cjs
const { Worker } = require('node:worker_threads');
const w = new Worker('./worker.cjs');
w.once('message', async () => {
  await w.terminate();
  console.log('terminated ok');
});

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 .node embeds its own .NET runtime. That runtime registers per-thread cleanup with the OS via a pthread_key destructor 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. The pthread_key destructor is still registered, but now points at unmapped memory. When the worker thread subsequently exits, glibc's __nptl_deallocate_tsd invokes 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 (already dlclosed). No-op'ing dlclose via LD_PRELOAD produced 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 dladdr on one of its functions, then re-open it with dlopen(RTLD_NOLOAD | RTLD_NODELETE):

  • RTLD_NOLOAD resolves the already-loaded module without loading a second copy.
  • RTLD_NODELETE keeps it mapped for the process lifetime, and the extra (never-released) reference prevents Node's dlclose from unmapping it.

This keeps the pthread_key destructor address valid for the life of the process.

  • Linux and macOSdladdr/dlopen are resolved from libc.so.6 (glibc >= 2.34) with a fallback to libdl.so.2 (older glibc), and from libSystem.B.dylib on macOS, using the platform's respective RTLD_* constants.
  • Windows — not affected; module/thread teardown does not hit this path.
  • Best-effort — any failure is traced and non-fatal, so it never blocks module init.

Hang - JSTsfnSynchronizationContext now 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):

Before After
Node 24.13.0 (crash) exit 139 (SIGSEGV) exit 0 (terminated ok)
Node 24.18.1 (hang) hangs exit 0 (terminated ok)

Added a regression test test/TestCases/napi-dotnet/worker_teardown.js that loads the host only inside a Worker (so no main-thread reference masks the unload), terminates it, and asserts a clean exit. It runs under HostedClrTests (the suite that loads the pinned host module) and exits 139 without the fix / 0 with it.

NodeApi builds clean on net8.0, net9.0, net10.0, and netstandard2.0.

…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
Comment thread src/NodeApi/DotNetHost/NativeHost.cs Outdated
Comment thread src/NodeApi/DotNetHost/NativeHost.cs Outdated
- 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 AI balanced review requested due to automatic review settings August 5, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/NodeApi/DotNetHost/NativeHost.cs Outdated
Comment thread src/NodeApi/DotNetHost/NativeHost.cs
Comment thread src/NodeApi/DotNetHost/NativeHost.cs
- 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 AI review requested due to automatic review settings August 5, 2026 17:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js test 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 requires node-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 AI review requested due to automatic review settings August 5, 2026 17:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread test/TestCases/napi-dotnet/worker_teardown.js
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 AI review requested due to automatic review settings August 5, 2026 18:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 5, 2026 21:14
@GalaxiasKyklos Saúl Ponce (GalaxiasKyklos) changed the title Fix SIGSEGV when a worker_threads Worker that loaded node-api-dotnet … Fix worker_threads teardown crash (SIGSEGV) and hang when node-api-dotnet is loaded in a Worker Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

@vmoroz
Vladimir Morozov (vmoroz) merged commit 3e8b91a into microsoft:main Aug 5, 2026
17 checks passed
@GalaxiasKyklos
Saúl Ponce (GalaxiasKyklos) deleted the fix/aot-worker-teardown-segfault branch August 5, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants