Skip to content

fix(profiler): eliminate SIGSEGV race on _class_map.clear() in signal handler and dump path - #516

Merged
jbachorik merged 8 commits into
muse/sigsegv-in-recording-writeclassesfrom
muse/sigsegv-in-profiler-dump
May 12, 2026
Merged

fix(profiler): eliminate SIGSEGV race on _class_map.clear() in signal handler and dump path#516
jbachorik merged 8 commits into
muse/sigsegv-in-recording-writeclassesfrom
muse/sigsegv-in-profiler-dump

Conversation

@jbachorik

@jbachorikjbachorik commented May 8, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?:
Fixes a SIGSEGV in `Profiler::dump` caused by a three-way race on `_class_map`:

  1. `HotspotSupport::walkVM` (signal handler) called the inserting `Dictionary::lookup()`, which calls `malloc`/`calloc` — async-signal-unsafe — and raced `clear()` which frees `row->keys` and `row->next`.
  2. `Recording::writeClasses` called `Dictionary::collect()` without holding `_class_map_lock`, then iterated `const char*` pointers from the collected map after the lock was released — use-after-free if `clear()` ran between collect and iteration.
  3. `Profiler::dump` calls `_class_map.clear()` under exclusive lock, but neither of the above callers respected the lock.

Motivation:
Crash reports showing SIGSEGV with frames `Dictionary::clear` ← `Profiler::dump` ← `JavaProfiler.dump0`.

Changes:

  • `spinLock.h`: added `tryLockSharedBounded()` — a signal-safe variant that returns false after at most 5 CAS attempts. `SharedLockGuard` and `OptionalSharedLockGuard` gain move constructors + null-check in `SharedLockGuard` dtor, enabling RAII factory methods. New `BoundedOptionalSharedLockGuard` wraps `tryLockSharedBounded()` for signal-handler paths.
  • `profiler.h`: replaced raw `SpinLock *classMapLock()` with `SharedLockGuard classMapSharedGuard()` and `BoundedOptionalSharedLockGuard classMapTrySharedGuard()` factory methods. Callers never hold a raw lock pointer.
  • `flightRecorder.cpp`: `writeClasses()` holds the shared guard for the full function scope (not just during `collect()`), preventing use-after-free during iteration.
  • `hotspotSupport.cpp`: signal-handler path uses `classMapTrySharedGuard()` (non-blocking, bounded retries) + `bounded_lookup(size_limit=0)` (read-only, no malloc). If `clear()` holds the exclusive lock, `ownsLock()` is false and the vtable-target frame is silently dropped — never reading freed memory.
  • `dictionary_concurrent_ut.cpp`: 4 gtest cases covering the `bounded_lookup` contract and a direct regression for the PROF-14550 race pattern (passes cleanly under TSan/ASan).
  • `spinlock_bounded_ut.cpp`: 6 gtest cases covering `tryLockSharedBounded()` and `BoundedOptionalSharedLockGuard` in isolation and under concurrent stress.

Additional Notes:

  • The `dictionary_concurrent_ut.cpp` and `spinlock_bounded_ut.cpp` tests require Google Test. On macOS the test binary is skipped at build time; they will run in CI on Linux.
  • `_class_map_lock` internal usages in `profiler.cpp` (explicit `lock()`/`unlock()` at the dump path) are unchanged — those are in-class accesses that don't need to go through the factory methods.

How to test the change?:

  • `./gradlew :ddprof-lib:buildDebug :ddprof-lib:buildRelease` — both must compile cleanly.
  • On Linux with Google Test: `./gradlew :ddprof-lib:gtestDebug` — all `DictionaryConcurrent` and `SpinLockBounded` tests must pass, including `SignalHandlerBoundedLookupVsDumpClear` which reproduces the PROF-14550 race.
  • Run under TSan: the concurrent test suite detects data races if any locking is omitted.

For Datadog employees:

  • If this PR touches code that signs or publishes builds or packages, or handles
    credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`.
  • This PR doesn't touch any of that.
  • JIRA: PROF-14550

🤖 Generated with Claude Code via muse implement

@jbachorikjbachorik added the AI label May 8, 2026
@jbachorik
jbachorik requested a review from CopilotMay 8, 2026 13:38
@jbachorik

Copy link
Copy Markdown
CollaboratorAuthor

@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:aa0fe7daf0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadddprof-lib/src/main/cpp/spinLock.h
…fter-unlock in writeClasses; cap tryLockShared at 5 attempts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jbachorik
jbachorikforce-pushed the muse/sigsegv-in-profiler-dump branch from 82b3bf3 to 4da7f45CompareMay 8, 2026 13:43
@jbachorik
jbachorik changed the base branch from main to muse/sigsegv-in-recording-writeclassesMay 8, 2026 13:43

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a native crash (SIGSEGV) in the profiler dump path by enforcing a consistent locking protocol around _class_map access across the dump path, JFR writing, and signal-handler stack walking. It also adds a “chaos harness” workload and CI wiring intended to continuously stress profiler crash surfaces, plus improvements to JFR validation skip reporting.

Changes:

  • Make _class_map accesses signal-safe and race-free by using shared/exclusive SpinLock guards consistently (including a try-lock + bounded_lookup(…, size_limit=0) path in the signal handler).
  • Extend SpinLock guard types to support RAII factory methods, and add regression/concurrency gtests for dictionary contracts.
  • Add a long-running “chaos” harness (new Gradle sourceSet + fat jar) and scheduled GitLab reliability jobs; improve JFR validation “skip reason” propagation.

Reviewed changes

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

Show a summary per file
FileDescription
test-validation/validate-jfr-conformance.shReads and reports a concrete skip reason when JFR validation is disabled.
gradle/libs.versions.tomlAdds dd-trace-api dependency for the chaos harness (currently via a dynamic version).
ddprof-stresstest/src/chaos/README.mdDocuments the new chaos harness and its antagonist workloads.
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Antagonist.javaDefines the harness antagonist interface (start/stop).
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.javaAdds a small CLI runner for selecting antagonists and duration.
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/ThreadChurnAntagonist.javaAdds a thread churn workload to stress teardown vs sampling.
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/VirtualThreadChurnAntagonist.javaAdds a vthread churn workload (reflective, Java 21+).
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/ClassLoaderChurnAntagonist.javaAdds classloader/class-unload churn using ASM-generated classes.
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/AllocStormAntagonist.javaAdds allocation pressure workload (heap + reflective Unsafe native alloc).
ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/TraceContextAntagonist.javaAdds a tracer-driven context churn workload via @Trace.
ddprof-stresstest/build.gradle.ktsAdds chaos sourceSet, dependencies, and chaosJar fat-jar task.
ddprof-lib/src/test/cpp/dictionary_concurrent_ut.cppAdds concurrency/regression gtests covering bounded_lookup and locking discipline.
ddprof-lib/src/main/cpp/spinLock.hBounds tryLockShared, adds movable RAII guard types used by new factory methods.
ddprof-lib/src/main/cpp/profiler.hExposes RAII factory methods for acquiring the class-map lock shared/try-shared.
ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cppUses try-shared guard + bounded_lookup(...,0) in the signal-handler walkVM path.
ddprof-lib/src/main/cpp/flightRecorder.cppHolds a shared guard across writeClasses() iteration to prevent UAF vs clear().
ddprof-lib/src/main/cpp/dictionary.hDocuments the signal-unsafety of inserting lookup() and the bounded_lookup(...,0) contract.
.gitlab/reliability/run.shAdds a new reliability variant entry-point for the chaos harness.
.gitlab/reliability/chaos_check.shImplements the chaos run logic (patched agent + chaos jar + allocator variants).
.gitlab/reliability/.gitlab-ci.ymlAdds scheduled chaos reliability jobs (amd64/aarch64 matrix) and notification wiring.
.gitlab/dd-trace-integration/install-prerequisites.shWrites a detailed skip reason and pre-warms the jfr-shell backend.
.gitlab/common.ymlChanges get-versions job setup (adds SDKMAN + JDK install, stricter shell flags).
.gitlab/build-deploy/.gitlab-ci.ymlAdds a schedule-only chaos:build job to prebuild/publish the chaos jar artifact.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment threadddprof-lib/src/main/cpp/spinLock.h
@dd-octo-sts

dd-octo-stsBot commented May 8, 2026

Copy link
Copy Markdown
Contributor

CI Test Results

Run:#28552212991 | Commit:cb6c809 | Duration: 0s (longest job)

All 0 test jobs passed

Summary: Total: 0 | Passed: 0 | Failed: 0


Updated: 2026-07-01 22:40:57 UTC

…unded variant
- ddprof-lib/src/main/cpp/spinLock.h:49 — restored tryLockShared() to spin until exclusive lock released; added tryLockSharedBounded() for signal-handler paths
- ddprof-lib/src/main/cpp/spinLock.h:102 — OptionalSharedLockGuard updated to use tryLockSharedBounded()
Co-Authored-By: muse <muse@noreply>
@jbachorik

Copy link
Copy Markdown
CollaboratorAuthor

@chatgpt-codex-connector review

@jbachorik

Copy link
Copy Markdown
CollaboratorAuthor

@copilot review

…(), not tryLockShared()
The previous commit added tryLockSharedBounded() and claimed OptionalSharedLockGuard
was updated to use it, but the constructor still called tryLockShared() — the spinning
(potentially unbounded) variant. Since OptionalSharedLockGuard is used in signal-handler
paths, it must use the bounded variant to be async-signal-safe.
Agent-Logs-Url: https://github.com/DataDog/java-profiler/sessions/4b50411f-3c52-4938-9d54-a43ada34b94e
Co-authored-by: jbachorik <738413+jbachorik@users.noreply.github.com>

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:aa81372fe3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp

CopilotAI commented May 8, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Fixed in commit 9876873. The previous commit added tryLockSharedBounded() and its message claimed OptionalSharedLockGuard was updated to use it, but the constructor still called tryLockShared() (the unbounded spinning variant). Since OptionalSharedLockGuard is used in signal-handler paths (FlightRecorder::recordEvent, wallClockEpoch, etc.), it must use the bounded variant to be async-signal-safe.

@jbachorik
jbachorik marked this pull request as ready for review May 8, 2026 17:32
@jbachorik
jbachorik requested a review from a team as a code ownerMay 8, 2026 17:32

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:9876873cde

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadddprof-lib/src/main/cpp/spinLock.h Outdated
@jbachorik
jbachorik marked this pull request as draft May 11, 2026 12:13
- ddprof-lib/src/main/cpp/spinLock.h:115 — OptionalSharedLockGuard reverted to tryLockShared() (non-spurious) for FlightRecorder hot paths; BoundedOptionalSharedLockGuard added for signal-handler-only callers
- ddprof-lib/src/main/cpp/profiler.h:208 — classMapTrySharedGuard() now returns BoundedOptionalSharedLockGuard
Co-Authored-By: muse <muse@noreply>
@jbachorik

Copy link
Copy Markdown
CollaboratorAuthor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jbachorik
jbachorik marked this pull request as ready for review May 11, 2026 12:49

@rkennkerkennke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is the PR missing the promised new tests?

@jbachorik

Copy link
Copy Markdown
CollaboratorAuthor

@rkennke 🤦 Good catch. I was rebasing locally and killed the added tests :/ Will fix!

…ounded unit tests
- spinlock_bounded_ut.cpp: 6 tests covering tryLockSharedBounded() and
BoundedOptionalSharedLockGuard in isolation (free lock, exclusive-held
failure, multiple shared holders, RAII destruction, concurrent stress)
- dictionary_concurrent_ut.cpp: update SignalHandlerBoundedLookupVsDumpClear
to use BoundedOptionalSharedLockGuard matching the actual hotspotSupport.cpp
code path (classMapTrySharedGuard())
Co-Authored-By: muse <muse@noreply>
@jbachorik

jbachorik commented May 11, 2026

Copy link
Copy Markdown
CollaboratorAuthor

The tests are now present. Two commits add coverage:

7df7230spinlock_bounded_ut.cpp: 6 unit tests for tryLockSharedBounded() and BoundedOptionalSharedLockGuard in isolation (free-lock success, exclusive-lock failure, multiple shared holders, RAII destruction, concurrent stress).

45454fddictionary_concurrent_ut.cpp: restored OptionalSharedLockGuard in SignalHandlerBoundedLookupVsDumpClear (the original PROF-14550 regression), and added SignalHandlerBoundedOptionalLookupVsDumpClear that uses BoundedOptionalSharedLockGuard — the guard classMapTrySharedGuard() now returns in hotspotSupport.cpp. Both variants are tested against a concurrent dict.clear().

Also fixed the PR description which had stale references (OptionalSharedLockGuard / tryLockShared()) from an earlier iteration.

…nalSharedLockGuard
Restore OptionalSharedLockGuard in SignalHandlerBoundedLookupVsDumpClear and
add SignalHandlerBoundedOptionalLookupVsDumpClear for BoundedOptionalSharedLockGuard,
which is what classMapTrySharedGuard() returns in hotspotSupport.cpp.
Co-Authored-By: muse <muse@noreply>

@rkennkerkennke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems slightly inconsistent/confusing that BoundedOptionalSharedLockGuard is non-movable, but others are now movable. With C++17, this is ok with guaranteed copy elision, but it's still mildly confusing.
OptionalSharedLockGuard move constructor looks like not used at all?
Other than that, the changes look good!

@jbachorik

Copy link
Copy Markdown
CollaboratorAuthor

Addressed — removed unused move constructors from SharedLockGuard and OptionalSharedLockGuard, making all three guard classes uniformly non-movable. Also simplified the SharedLockGuard destructor (the nullptr guard was only there to support moved-from instances, which can no longer exist). With C++17 guaranteed copy elision the factory-return pattern is unaffected.

… non-movable
- SharedLockGuard: remove move ctor, simplify dtor (nullptr guard only
served moved-from instances)
- OptionalSharedLockGuard: remove move ctor
- BoundedOptionalSharedLockGuard: already non-movable, unchanged
C++17 guaranteed copy elision handles the factory-return pattern without
move constructors.
Co-Authored-By: muse <muse@noreply>

@rkennkerkennke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, thank you!

@jbachorik
jbachorik merged commit cb6c809 into muse/sigsegv-in-recording-writeclassesMay 12, 2026
96 checks passed
@jbachorik
jbachorik deleted the muse/sigsegv-in-profiler-dump branch May 12, 2026 10:20
jbachorik added a commit that referenced this pull request May 13, 2026
…ufferedDictionary
Replaces the SpinLock-guarded Dictionary instances for _class_map,
_string_label_map, and _context_value_map with a new TripleBufferedDictionary
that eliminates all locking from the read/write fast paths.
TripleBufferedDictionary holds three Dictionary buffers cycling through three
roles via a generic TripleBufferRotator<T> template:
- active — receives new writes (signal handlers + JNI threads), lock-free via CAS
- dump — snapshot being read by the dump thread; promoted from old active on rotate()
- scratch — two rotations behind active; ready to be cleared lock-free
The scratch role exists for safe lock-free reclamation: when a buffer enters
that role, at least one full dump cycle has elapsed since it was last in the
active or dump role. That grace period is much longer than any signal-handler
or JNI-thread can plausibly outlive a stale active pointer, so the buffer can
be freed without any explicit drain.
bounded_lookup(size_limit=0) is signal-safe (no malloc) and checks the active
buffer only — no fallback to older snapshots.
Dead code removed:
- _class_map_lock (SpinLock)
- classMapSharedGuard() / classMapTrySharedGuard() on Profiler
- tryLockSharedBounded() / BoundedOptionalSharedLockGuard on SpinLock
- spinlock_bounded_ut.cpp / dictionary_concurrent_ut.cpp (subsumed by dictionary_ut.cpp)
Motivation: three production crashes (fingerprint v10.DAECC680F0728EAB44F26DB0B91B703F)
showed SIGSEGV in std::_Rb_tree_increment via writeCpool → writeClasses →
Dictionary::collect, caused by a race between writeClasses and concurrent
Dictionary::clear(). PR #516 patched it with a shared-lock that exhausted
bounded CAS retries under heavy 100 µs wall-clock load on aarch64, causing
class lookups to return -1 and corrupting JFR recordings. This change
eliminates the lock entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jbachorik added a commit that referenced this pull request May 13, 2026
…ufferedDictionary
Replaces the SpinLock-guarded Dictionary instances for _class_map,
_string_label_map, and _context_value_map with a new TripleBufferedDictionary
that eliminates all locking from the read/write fast paths.
TripleBufferedDictionary holds three Dictionary buffers cycling through three
roles via a generic TripleBufferRotator<T> template:
- active — receives new writes (signal handlers + JNI threads), lock-free via CAS
- dump — snapshot being read by the dump thread; promoted from old active on rotate()
- scratch — two rotations behind active; ready to be cleared lock-free
The scratch role exists for safe lock-free reclamation: when a buffer enters
that role, at least one full dump cycle has elapsed since it was last in the
active or dump role. That grace period is much longer than any signal-handler
or JNI-thread can plausibly outlive a stale active pointer, so the buffer can
be freed without any explicit drain.
bounded_lookup(size_limit=0) is signal-safe (no malloc) and checks the active
buffer only — no fallback to older snapshots.
Dead code removed:
- _class_map_lock (SpinLock)
- classMapSharedGuard() / classMapTrySharedGuard() on Profiler
- tryLockSharedBounded() / BoundedOptionalSharedLockGuard on SpinLock
- spinlock_bounded_ut.cpp / dictionary_concurrent_ut.cpp (subsumed by dictionary_ut.cpp)
Motivation: three production crashes (fingerprint v10.DAECC680F0728EAB44F26DB0B91B703F)
showed SIGSEGV in std::_Rb_tree_increment via writeCpool → writeClasses →
Dictionary::collect, caused by a race between writeClasses and concurrent
Dictionary::clear(). PR #516 patched it with a shared-lock that exhausted
bounded CAS retries under heavy 100 µs wall-clock load on aarch64, causing
class lookups to return -1 and corrupting JFR recordings. This change
eliminates the lock entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jbachorik added a commit that referenced this pull request May 14, 2026
…ufferedDictionary
Replaces the SpinLock-guarded Dictionary instances for _class_map,
_string_label_map, and _context_value_map with a new TripleBufferedDictionary
that eliminates all locking from the read/write fast paths.
TripleBufferedDictionary holds three Dictionary buffers cycling through three
roles via a generic TripleBufferRotator<T> template:
- active — receives new writes (signal handlers + JNI threads), lock-free via CAS
- dump — snapshot being read by the dump thread; promoted from old active on rotate()
- scratch — two rotations behind active; ready to be cleared lock-free
The scratch role exists for safe lock-free reclamation: when a buffer enters
that role, at least one full dump cycle has elapsed since it was last in the
active or dump role. That grace period is much longer than any signal-handler
or JNI-thread can plausibly outlive a stale active pointer, so the buffer can
be freed without any explicit drain.
bounded_lookup(size_limit=0) is signal-safe (no malloc) and checks the active
buffer only — no fallback to older snapshots.
Dead code removed:
- _class_map_lock (SpinLock)
- classMapSharedGuard() / classMapTrySharedGuard() on Profiler
- tryLockSharedBounded() / BoundedOptionalSharedLockGuard on SpinLock
- spinlock_bounded_ut.cpp / dictionary_concurrent_ut.cpp (subsumed by dictionary_ut.cpp)
Motivation: three production crashes (fingerprint v10.DAECC680F0728EAB44F26DB0B91B703F)
showed SIGSEGV in std::_Rb_tree_increment via writeCpool → writeClasses →
Dictionary::collect, caused by a race between writeClasses and concurrent
Dictionary::clear(). PR #516 patched it with a shared-lock that exhausted
bounded CAS retries under heavy 100 µs wall-clock load on aarch64, causing
class lookups to return -1 and corrupting JFR recordings. This change
eliminates the lock entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jbachorikjbachorik added the identified-by:crashtracking Issue identified via crash tracking label Jul 1, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Author Signed-offAIidentified-by:crashtrackingIssue identified via crash tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@jbachorik@rkennke