Skip to content

Fix SIGSEGV when a worker_threads Worker that loaded node-api-dotnet … - #487

Open
GalaxiasKyklos wants to merge 2 commits into
microsoft:mainfrom
GalaxiasKyklos:fix/aot-worker-teardown-segfault
Open

Fix SIGSEGV when a worker_threads Worker that loaded node-api-dotnet …#487
GalaxiasKyklos wants to merge 2 commits into
microsoft:mainfrom
GalaxiasKyklos:fix/aot-worker-teardown-segfault

Conversation

@GalaxiasKyklos

Copy link
Copy Markdown

Summary

Fixes a SIGSEGV (process crash, exit 139) that occurs on Linux when a worker_threads Worker loads node-api-dotnet and the worker is then terminated.

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');
});

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 .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.

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.

Fix

Pin the native host module for the lifetime of the process on Linux. 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. The change is:

  • Scoped to Linux/glibc — Windows module/thread teardown does not hit this path.
  • Best-effort — any failure is traced and non-fatal, so it never blocks module init.

Testing

Reproduced and validated in Docker (mcr.microsoft.com/dotnet/sdk:10.0 + Node 24.13.0, which crashes deterministically):

Before After
Node 24.13.0 exit 139 (SIGSEGV) exit 0 (terminated ok), 5/5 runs

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

Note: This addresses only the crash. A separate hang on Node.js >= 24.14 (TSFN release deadlock during env teardown) is fixed independently.

…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

s_moduleUnloadPrevented = true;

if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You mentioned above that Windows does not hit this problem; do you know whether this issue affects Mac OS?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thank you! I put together this gist with instructions on how to repro it on macOS

https://gist.github.com/GalaxiasKyklos/8bb730adcd486af5e0067eb2f1c69055

Comment thread src/NodeApi/DotNetHost/NativeHost.cs Outdated
public nint dli_saddr;
}

[DllImport("libc.so.6")]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please fix the formatter issues - it says to use the LibraryImportAttribute instead of DllImportAttribute.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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
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.

3 participants