Is there an existing issue for this?
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
// 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
- 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).
- 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).
- 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<v8::internal::Factory>::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:
- 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).
- 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.
Is there an existing issue for this?
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
That import alone is enough to trigger the bug — in our case
Sentry.initwas not even called (no DSN configured), yet the process could freeze at startup.Steps to Reproduce
@sentry/profiling-nodeafter 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).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-nodenever freezes the process — ideally thev8::CpuProfileris not created as a side effect ofrequire()at all (lazily create it when profiling actually starts), especially whenSentry.initwas 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):Mechanism (V8 side):
v8::CpuProfiler::New→ProfilingScopewalks all compiled functions viaLogger::CodeCreateEvent, which holds the (non-recursive)Loggermutex while dispatching. Initializing script line-ends inside that walk allocates; if that allocation starts incremental marking,MarkCompactCollector::StartCompactioncalls back intoLogger::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.hstill uses plainbase::MutexGuard), and Node 22 is in maintenance LTS until 2027.Since the addon creates the
CpuProfilereagerly 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:
v8::CpuProfilerlazily on first profile start instead of at N-APIInit— 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).Happy to provide the full gdb/strace captures if useful.