getsentry / getsentry/sentry-javascript
profiling-node: importing the package can hard-deadlock the process at startup on Node ≤22 (V8 bug 41497149 — eager v8::CpuProfiler creation at require time)
- Lingua principale
- TypeScript
- Stelle
- 8.7k
- Fork
- 1.8k
- Merge medio
- 1g 17h
- PR unite (30g)
- 515
Descrizione
### Is there an existing issue for this?
- [x] I have checked for existing issues https://github.com/getsentry/sentry-javascript/issues
- [x] I have reviewed the documentation https://docs.sentry.io/
- [x] I am using the latest SDK release https://github.com/getsentry/sentry-javascript/releases
### How do you use Sentry?
Self-hosted/on-premise
### Which SDK are you using?
@sentry/node
### SDK Version
@sentry/nestjs 10.26.0, @sentry/profiling-node 10.45.0 (also reproduced with the 10.25.0 binary)
### Framework Version
NestJS 10, Node.js 22.20.0 (V8 12.4.254.21), linux x64 glibc (Docker `node:22.20-bookworm-slim`)
### Link to Sentry event
_No event — the process freezes before any code runs._
### Reproduction Example/SDK Setup
```ts
// first import of the app's main.ts
import { nodeProfilingIntegration } from '@sentry/profiling-node';
```
That import alone is enough to trigger the bug — in our case `Sentry.init` was **not even called** (no DSN configured), yet the process could freeze at startup.
### Steps to Reproduce
1. Run a large Node **22** app (NestJS monolith, thousands of modules) that imports `@sentry/profiling-node` after a sizable part of its module graph (we import it via a wrapper that is itself imported first, so ~the whole `@sentry/nestjs` + OTel graph is already compiled when the addon loads).
2. Boot the app repeatedly under CPU/IO contention (in our case: 2-vCPU host, full docker-compose stack cold-starting simultaneously, page caches dropped — i.e. a normal container deploy).
3. Roughly 1 boot in 3-4 freezes forever: zero output, listen port never bound, ~0 CPU, process never crashes. A process manager (pm2) keeps reporting it `online`.
The non-determinism is inherent: the deadlock only fires if V8 starts an incremental GC cycle exactly while the profiler walks already-compiled code (see below). Calm restarts of the same build essentially never hit it; contended cold boots hit it frequently.
### Expected Result
Importing `@sentry/profiling-node` never freezes the process — ideally the `v8::CpuProfiler` is not created as a side effect of `require()` at all (lazily create it when profiling actually starts), especially when `Sentry.init` was never called.
### Actual Result
Hard deadlock of the main thread inside the addon's N-API `Init`, on Node ≤ 22. gdb backtrace of the frozen main thread (all other threads idle in their normal wait loops — this is a same-thread self-deadlock):
```
#1 pthread_mutex_lock
#2 v8::internal::Isolate::AllowsCodeCompaction()
#3 v8::internal::MarkCompactCollector::StartCompaction(...)
#4 v8::internal::IncrementalMarking::StartMarkingMajor()
#6 v8::internal::Heap::StartIncrementalMarking(...)
#7 v8::internal::PagedSpaceAllocatorPolicy::EnsureAllocation(...)
#8 v8::internal::MainAllocator::AllocateRawSlow(...)
#11 v8::internal::FactoryBase::NewFixedArray(...)
#12 v8::internal::String::CalculateLineEnds(...)
#13 v8::internal::Script::InitLineEndsInternal(...)
#14 v8::internal::Script::GetPositionInfo(...)
#16 v8::internal::SourcePosition::InliningStack(...)
#17 v8::internal::ProfilerListener::CodeCreateEvent(...)
#18 v8::internal::ExistingCodeLogger::LogExistingFunction(...)
#19 v8::internal::ExistingCodeLogger::LogCompiledFunctions(...)
#20 v8::internal::ProfilingScope::ProfilingScope(...)
#21 v8::internal::CpuProfiler::EnableLogging()
#22 v8::internal::CpuProfiler::CpuProfiler(...)
#23 v8::CpuProfiler::New(...)
#24 Init(napi_env__*, napi_value__*) ()
from .../node_modules/@sentry-internal/node-cpu-profiler/lib/sentry_cpu_profiler-linux-x64-glibc-127.node
#25 napi_module_register_by_symbol(...)
#28 node::binding::DLOpen(...)
```
Mechanism (V8 side): `v8::CpuProfiler::New` → `ProfilingScope` walks all compiled functions via `Logger::CodeCreateEvent`, which holds the (non-recursive) `Logger` mutex while dispatching. Initializing script line-ends inside that walk allocates; if that allocation starts incremental marking, `MarkCompactCollector::StartCompaction` calls back into `Logger::allows_code_compaction()` → same mutex, same thread → deadlock.
This is V8 bug **41497149**, fixed 2024-05 by V8 commit `a6eaf75741` ("[logging] Use RecursiveMutex for Logger", https://chromium-review.googlesource.com/c/v8/v8/+/5572952). The fix is in V8 ≥ 12.7, so **Node 23/24 are safe, but every Node ≤ 22 is affected** — including current latest v22.23.1 (checked: `deps/v8/src/logging/code-events.h` still uses plain `base::MutexGuard`), and Node 22 is in maintenance LTS until 2027.
Since the addon creates the `CpuProfiler` eagerly at module load, the exposure window is "every process start that imports the package" — larger apps (more compiled code to walk, more line-ends to allocate under the mutex) are more exposed.
Suggestions:
1. Create the `v8::CpuProfiler` lazily on first profile start instead of at N-API `Init` — that both removes the cost for non-profiling processes and moves the risk out of the critical boot path (and applications that never enable profiling are never exposed).
2. Failing that, a docs warning for Node ≤ 22 would help: the failure mode is brutal to diagnose (silent freeze before any app output, no crash, no event).
Happy to provide the full gdb/strace captures if useful.
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.