Visitar URL original
Never free a call-trace table a put() may still hold after a drain timeout by rkennke · Pull Request #840 · DataDog/java-profiler · GitHub
Skip to content

Never free a call-trace table a put() may still hold after a drain timeout - #840

Merged
rkennke merged 4 commits into
mainfrom
fix/prof-16136-calltrace-drain-timeout
Oct 9, 2026
Merged

rkennke merged 4 commits into
mainfrom
fix/prof-16136-calltrace-drain-timeout

Conversation

@rkennke

@rkennke rkennke commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?:

Documents how CallTraceStorage reclaims tables relative to put(), and makes it never free a table that a put() may still be writing after a timed-out drain. No memory is leaked in normal operation: the leak path below is only taken after a drain timeout, which no production caller can hit today.

  • Reclamation and put(). processTraces() and the destructor tolerate concurrent put(). They only reclaim a table after swapping it out of _active_storage, which put() re-checks after taking its guard, and after draining its guards. clear() resets the active table in place, so it requires put() to be excluded. Today lockAll() excludes put() from all of them anyway. This is now stated on the class, the methods and in CallTraceStorage.md. The old comment said processTraces() "is safe to call concurrently with put() operations" without qualification.
  • RefCountGuard::waitForRefCountToClear() now returns whether it drained. Debug builds still abort on a timeout. Gtest builds (UNIT_TEST) skip the abort so they can exercise the release behavior.
  • On a timeout:
    • clearTableOnly() leaks the detached chunks: it returns an empty ChunkList.
    • The destructor skips the delete.
    • Before this change, release builds freed the memory anyway.
  • Corrects the PROF-14889 explanation in clearTableOnly() and in the header of ProcessCallTracesRaceTest. Both described collect() racing an in-flight put(), which lockAll() rules out. It also adds the missing copyright header to that test file.

Motivation:

PROF-16136 asked for an audit of the other drain callers. CallTraceStorage uses the same "drain, then free regardless" pattern as the dictionary, but there it is latent, not reachable:

  • Every put() runs under one of the Profiler stripe locks (profiler.cpp callers at 641/726/855; the earlier unlock()s are early returns).
  • Every caller of processTraces() (writeStackTraces() ← finishChunk() ← FlightRecorder::stop()/dump()) and of clear() holds lockAll().
  • lockAll() waits for in-flight put()s without a time limit, so the drains find no guard and cannot time out.
  • The destructor never runs, because Profiler::_instance is never deleted.

This was already true when #585 (PROF-14889) landed. So the 500 ms timeouts it observed must have come from the old global wait counting guards on other resources, i.e. StringDictionary lookups, which lockAll() does not exclude. #839 removes that global wait.

Additional Notes:

  • No behavior change on any path production can reach. lockAll() is held for the dump because of the per-stripe JFR buffers; it covers CallTraceStorage incidentally. If that ever changes, the rotation path stays memory-safe with this PR (a stalled put() costs a leaked table in release, an abort in debug), but clear() would need its own gate.
  • On a broken contract processTraces() waits and logs twice: once after the swap, and again inside original_active->clear(). Collection from the old active table still proceeds, as before.
  • StringDictionary::rotate() also calls waitForRefCountToClear() and keeps aborting in debug builds on a timeout. In release, the clearStandby() drain added in Never reclaim dictionary storage after a timed-out drain #839 covers it.

How to test the change?:

New gtest CallTraceHashTableDrainTest.ClearWithGuardHeldLeaksChunks. It holds a RefCountGuard on a table while calling clear(), then reads a trace from the old chunk.

  • Before this change: exits 134, from the old debug abort.
  • With the abort skipped but no leak: ASan reports SEGV reading the munmapped chunk at held->trace_id.
  • With this change: passes in debug, ASan and TSan.

Also run:

  • gtestDebug (full), and test_callTraceStorage under ASan and TSan.
  • compileFuzzer.
  • testDebug: 225 tests, 0 failures, 19 skipped (includes ProcessCallTracesRaceTest).

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 security review (run the dd:platform-security-review
    skill, or file a request via the PSEC review form).
    bewaire also runs automatically on every PR.
  • This PR doesn't touch any of that.
  • JIRA: PROF-16136

🤖 Generated with Claude Code

@rkennke
rkennke requested a review from a team as a code owner October 6, 2026 14:52
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T14:57:47.513109Z ecb767b PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@datadog-prod-us1-6 datadog-prod-us1-6 Bot 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.

Bits Code Review: PASS

More details

Timed-out drains preserve detached trace chunks and skip table deletion, preventing the intended reclamation hazards while retaining successful-drain behavior.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit ecb767b · @DataDog review to ask questions

@dd-octo-sts

dd-octo-sts Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #37922413731 | Commit: 493fd9c | Duration: 17m 17s (longest job)

✅ All 32 test jobs passed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ✅ ✅ - -
8-librca - - ✅ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ✅ ✅ - -
11-librca - - ✅ ✅
17 ✅ ✅ - -
17-graal ✅ ✅ - -
17-j9 ✅ ✅ - -
17-librca - - ✅ ✅
21 ✅ ✅ - -
21-graal ✅ ✅ - -
21-librca - - ✅ ✅
25 ✅ ✅ - -
25-graal ✅ ✅ - -
25-librca - - ✅ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

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


Updated: 2026-10-09 11:33:30 UTC

@dd-octo-sts

dd-octo-sts Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

✅ All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 ab93aa9e

@datadog-prod-us1-6

This comment has been minimized.

@rkennke rkennke changed the title Leak call-trace tables instead of freeing them after a drain timeout Never free a call-trace table a put() may still hold after a drain timeout Oct 6, 2026
@rkennke
rkennke force-pushed the fix/prof-16136-calltrace-drain-timeout branch 2 times, most recently from 6320a58 to 26e27c0 Compare October 7, 2026 16:21
Comment thread ddprof-lib/src/main/cpp/callTraceStorage.cpp
Comment thread ddprof-lib/src/main/cpp/callTraceStorage.cpp
Comment thread ddprof-lib/src/main/cpp/callTraceStorage.h Outdated
Comment thread ddprof-lib/src/main/cpp/refCountGuard.h
Comment thread ddprof-lib/src/main/cpp/callTraceStorage.cpp
Comment thread ddprof-lib/src/main/cpp/callTraceHashTable.cpp
@rkennke
rkennke force-pushed the fix/prof-16136-dictionary-drain-timeout branch from e30f3d0 to f9b0f28 Compare October 8, 2026 13:13
@rkennke
rkennke force-pushed the fix/prof-16136-calltrace-drain-timeout branch from 26e27c0 to bd75c31 Compare October 8, 2026 13:14
Base automatically changed from fix/prof-16136-dictionary-drain-timeout to main October 8, 2026 14:20
@rkennke
rkennke force-pushed the fix/prof-16136-calltrace-drain-timeout branch from bd75c31 to 5c18da9 Compare October 8, 2026 14:21

@datadog-prod-us1-6 datadog-prod-us1-6 Bot 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.

Bits Code Review: FAIL

The dictionary drain can reclaim storage while a live outer guard still protects it. After a drain timeout, reactivating an uncleared buffer can also restore an older ID and leave cached context-value references unresolved in later JFR chunks.

Open Bits AI session

🤖 Bits Code Review · Commit bd75c31 · @DataDog review to ask questions


Findings that could not be posted inline

ddprof-lib/src/main/cpp/stringDictionary.h:714

P2 Prevent the targeted drain from missing a live outer guard

When a dictionary accessor is interrupted by an unrelated nested guard, slotReferences() can read the inner active_ptr, then read an empty outer_stack after the inner destructor restores the outer pointer and clears its stack entry. If the outer resource is the first drain target, subsequent target scans miss it too. clearAll() can therefore report a successful drain and free storage the outer accessor still uses. The previous global drain observed the nonzero count. The scanner must handle reentrant pointer/stack transitions consistently before permitting reclamation.

Was this helpful? React 👍 or 👎
🤖 Bits Code Review · @DataDog review to ask questions · Open Bits AI session

ddprof-lib/src/main/cpp/stringDictionary.h:690

P2 Preserve ID mappings when reactivating an uncleared buffer

After rotate() times out, a straggler can publish a key into the old buffer after both copies missed it, while the current active buffer assigns that key a different ID. If its guard also causes clearStandby() to skip clearing, the retained entry survives reactivation. copyFrom() then preserves its older ID and silently discards the current mapping. Cached context-value encodings can consequently reference IDs absent from later JFR constant pools. Rotation must safely clear retained buffers or reconcile their IDs before reactivation; changing this clear alone is insufficient.

Was this helpful? React 👍 or 👎
🤖 Bits Code Review · @DataDog review to ask questions · Open Bits AI session

@dd-octo-sts

dd-octo-sts Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Reliability & Chaos Results

✅ All reliability & chaos checks passed Pipeline: https://gitlab.ddbuild.io/DataDog/java-profiler/-/pipelines/143415330

@rkennke

rkennke commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Re the Bits Code Review findings on bd75c31 ("targeted drain can miss a live outer guard", "preserve ID mappings when reactivating an uncleared buffer"): both are in code that #839 already merged to main, so they are fixed separately in #845 rather than here.

  • Missed outer guard: nested guards now keep every protected resource in one slot location, so a scan cannot miss it.
  • Stale ids: rotate() retries the drain and clear of a buffer whose clearStandby() was skipped before reusing it.

@rkennke
rkennke force-pushed the fix/prof-16136-calltrace-drain-timeout branch from 57455fd to 97e4d97 Compare October 9, 2026 09:35
rkennke and others added 4 commits October 9, 2026 10:38
CallTraceStorage reclaims table memory in clearTableOnly() and its
destructor after a RefCountGuard drain, and in release builds proceeded
to free the memory even when that drain timed out. In production this
cannot happen: every put() runs under one of the Profiler stripe locks
and every processTraces()/clear() caller holds lockAll(), which waits
for in-flight puts without a time limit. The class comments claimed the
opposite, that processTraces() is safe to run concurrently with put().

Document the real contract on CallTraceStorage and treat the drain as
defense in depth. waitForRefCountToClear() now reports whether it
drained; on a timeout clearTableOnly() leaks the detached chunks and the
destructor leaks the table rather than freeing memory a put() may still
be writing. Debug builds keep aborting on a timeout; gtest builds
(UNIT_TEST) skip the abort so they can exercise the release behavior.

Correct the clearTableOnly() and ProcessCallTracesRaceTest comments that
described collect() racing an in-flight put(): with lockAll() held no
put() can be in flight, so a global wait could only time out on guards
of other resources such as StringDictionary lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous wording said processTraces(), clear() and the destructor
must never run concurrently with put(). That overstates it:
processTraces() and the destructor only reclaim tables that are already
swapped out of _active_storage, which put() re-checks after taking its
guard, so they tolerate concurrent put() by design. Only clear(), which
resets the active table in place, needs put() excluded. Say so, and say
that today lockAll() excludes put() from all of them anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Make RefCountGuard::waitForRefCountToClear() [[nodiscard]]; the one
caller that discards it, StringDictionary::rotate(), now does so
explicitly and says why.

processTraces() keeps the result of its drain of the swapped-out active
table. When that drain timed out, step 10 resets the table with the new
CallTraceHashTable::clearAfterFailedDrain(), which leaks the chunks
without draining a second time, so a broken contract costs one ~500ms
wait and one counted timeout instead of two. A table reset after a
failed drain also skips decrementCounters(): its memory stays allocated,
and a stalled put() could still be changing the table it walks.

The destructor does not tolerate a concurrent put(): put() loads
_active_storage before taking its guard, so a put() in flight could find
the CallTraceStorage object itself freed. Say so in the class comment,
the destructor and CallTraceStorage.md.

Test the destructor's conditional delete and the processTraces() path
after a failed drain, through a test-only activeTableForTest().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LeakSanitizer is off in the ASan gtests, so a destructor that dropped its
deletes passed DestructorLeaksTableStillGuarded. Assert on NM_CALLTRACE
instead: only the guarded table may outlive the destructor, and all
table memory must be freed exactly once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke
rkennke force-pushed the fix/prof-16136-calltrace-drain-timeout branch from 97e4d97 to ab93aa9 Compare October 9, 2026 11:13
Comment thread ddprof-lib/src/main/cpp/callTraceStorage.cpp

@jbachorik jbachorik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looking fine

@rkennke
rkennke merged commit 927a8a0 into main Oct 9, 2026
116 checks passed
@rkennke
rkennke deleted the fix/prof-16136-calltrace-drain-timeout branch October 9, 2026 11:48
@github-actions github-actions Bot added this to the 1.52.0 milestone Oct 9, 2026
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.

2 participants