Skip to content

Fix flaky: OTelThreadContext.clear returns true on a fresh thread - #6384

Draft
p-datadog wants to merge 21 commits into
masterfrom
fix-flaky-otel-thread-context
Draft

p-datadog wants to merge 21 commits into
masterfrom
fix-flaky-otel-thread-context

Conversation

@p-datadog

@p-datadog p-datadog commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Fixes the flaky test spec/datadog/tracing/otel_thread_context_spec.rb:140 (Datadog::Tracing::OTelThreadContext.clear ... returns false when no context record was attached), and the underlying production defect that caused it.

Motivation:

Flaky test: Ruby 2.7 / build & test (standard) [0] on commit 5f7ee4dc0452, seed 2235, in PR #6344. The same task with the same seed passed on Ruby 2.6/3.0/3.1/3.2/3.3/3.4/4.0.

Failure:

1) Datadog::Tracing::OTelThreadContext.clear when enabled returns false when no context record was attached
   Failure/Error: expect(otel_thread_context.clear).to eq(false)
     expected: false
          got: true

Root cause: otel_thread_ctx_v1 is a C __thread (per-native-thread) TLS pointer set by ddog_otel_thread_ctx_attach and cleared by ddog_otel_thread_ctx_detach. CRuby caches and recycles native threads, so a new Thread.new may reuse a native thread previously occupied by a Thread that exited. On Ruby < 3.2 the extension registers on_thread_end on RUBY_EVENT_THREAD_END, which fires only when a thread exits on its own; Thread#kill terminates a thread through a path that bypasses this event. So a thread that called set (attaching otel_thread_ctx_v1) and is then Thread#kill-ed keeps its TLS attached. When CRuby reuses that native thread for the next Thread.new, the new Ruby thread inherits the stale context record, so clear returns true ("a context record was attached") even though that record was attached by the killed thread.

The describe "#set" group's test "releases the thread context when a Thread exits" creates a thread that calls set and is then Thread#kill-ed, leaking its TLS; with a randomized seed the later describe "#clear" test "returns false when no context record was attached" (wrapped in a fresh Thread.new by the around(:each)) can reuse that native thread and inherit the stale TLS. This is also a real production defect: a customer thread running traced code that is Thread#kill-ed leaks its OTel thread-context record onto the next thread reusing the native thread, so the eBPF profiler briefly associates the wrong trace context with that thread.

Ruby 3.2+ uses RUBY_INTERNAL_THREAD_EVENT_EXITED (on_thread_exited), which fires for killed threads too and detaches the TLS, so 3.2+ stays safe — which is why the flake surfaced on Ruby 2.7 (3.0–3.1 share the RUBY_EVENT_THREAD_END path and are equally vulnerable, but happened to pass in that CI run).

Fix: Register an on_thread_begin hook on RUBY_EVENT_THREAD_BEGIN for Ruby < 3.2 (the #else branch that already uses RUBY_EVENT_THREAD_END) that calls ddog_otel_thread_ctx_detach(). When a new Ruby thread begins on a recycled native thread, the hook drops the stale record before the thread runs any Ruby code, so set/clear on that thread observe only the context the thread attaches itself. RUBY_EVENT_THREAD_BEGIN fires after the native thread has started executing, so an asynchronous reader such as the eBPF profiler can still observe the stale record in the brief window before the hook runs; the hook narrows that residual window. Ruby 3.2+ is unchanged.

Related PRs:

Change log entry

A changelog fragment is added in this PR (Fixed / Tracing).

How to test the change?

A new regression test returns false on fresh threads recycling killed context-holding threads' native threads kills a thread that attached a context and asserts a fresh thread's clear returns false. Verified locally:

  • Without the fix the test fails deterministically (expected false, got true) on Ruby 2.7 and 3.0; it passes on Ruby 3.3, which already detaches the TLS for killed threads.
  • With the fix the test passes, and spec:core_with_libdatadog_api (182 examples) passes on Ruby 2.7 and 3.3 across seeds 2235, 1, 42, 999, 30721; 50/50 repeated full-task runs pass on Ruby 2.7 with seed 2235.

Root cause: otel_thread_ctx_v1 is a C __thread (per-native-thread) TLS
pointer. CRuby caches and recycles native threads, so a new Thread.new
may reuse a native thread previously occupied by a Thread that exited.
On Ruby < 3.3 the extension cleans up via RUBY_EVENT_THREAD_END, but that
event does not fire for threads terminated via Thread#kill, so a killed
thread that called `set` never has its TLS detached. The next Thread.new
that reuses that native thread inherits the stale context record, so
`clear` returns true ("a context record was attached") even though that
Ruby thread never attached one.

This is the flaky test `OTelThreadContext.clear ... returns false when
no context record was attached` (spec/datadog/tracing/otel_thread_context_spec.rb),
which failed on Ruby 2.7 in PR #6344 CI and passed on every other Ruby
version with the same seed: 2.7 and 3.0-3.2 share the
RUBY_EVENT_THREAD_END code path (vulnerable), while 3.3+ uses
RUBY_INTERNAL_THREAD_EVENT_EXITED, which fires for killed threads (not
vulnerable). It is also a real production defect: a customer thread
running traced code that is Thread#kill-ed leaks its OTel thread
context onto the next thread reusing the native thread, so the eBPF
profiler briefly associates the wrong trace context.

Fix: register an on_thread_begin hook on RUBY_EVENT_THREAD_BEGIN for
Ruby < 3.3 that calls ddog_otel_thread_ctx_detach(), so every new Ruby
thread starts from a clean TLS and discards any context left by a
previously killed thread. Ruby 3.3+ is unchanged.

Verified locally: the new regression test fails without the fix
(expected false, got true) and passes with it; spec:core_with_libdatadog_api
(182 examples) passes on Ruby 2.7 and 3.3.

Co-Authored-By: Claude <noreply@anthropic.com>
@p-datadog p-datadog added the AI Generated Largely based on code generated by an AI or LLM. This label is the same across all dd-trace-* repos label Sep 25, 2026
@p-datadog

Copy link
Copy Markdown
Member Author

@codex review

@p-datadog
p-datadog requested a lite review from Copilot September 25, 2026 13:56
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 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-04T03:53:54.321310Z 8b772ac Manual request
🔒 Security Review ✅ Completed 2026-10-04T03:55:07.651874Z 8b772ac Manual request
ℹ️ 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.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: cecb157a1f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

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

Copilot review overview

🟢 Approval recommended

The implementation and regression coverage address the defect; remaining feedback is limited to changelog wording.

Review effort: Lite
Findings: None

What changed in this PR

Fixes stale OpenTelemetry thread context leakage after Thread#kill on Ruby versions below 3.3.

Changes:

  • Adds thread-begin TLS cleanup.
  • Adds regression coverage for killed-thread reuse.
  • Adds a Tracing changelog fragment.
File Description
unreleased/​20260925094811.json Documents the tracing fix.
spec/​datadog/​tracing/​otel_thread_context_spec.rb Tests fresh-thread behavior after Thread#kill.
ext/​libdatadog_api/​otel_thread_context.c Registers the thread-begin cleanup hook.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cecb157a1f

ℹ️ 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 thread ext/libdatadog_api/otel_thread_context.c
Comment thread unreleased/20260925094811.json Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

ddog_otel_thread_ctx_detach();

P2 Badge Clear stale TLS before the thread-begin sampling window

On Ruby < 3.3, when a killed traced thread's native thread is recycled, this callback does not run until RUBY_EVENT_THREAD_BEGIN; collectors_thread_context.c:200-204 documents that an early SIGPROF and postponed sampling can run before that event. During that window otel_thread_ctx_v1 still exposes the killed thread's trace/span IDs, so the profiler can still misattribute a sample even though the new clear regression test passes after the callback. Cleanup needs to occur during teardown or readers must otherwise be prevented from consuming the stale record before the begin hook.


"message": "Fix the OpenTelemetry Thread Context leaking a killed thread's trace/span IDs onto a new thread reusing a recycled native thread after a traced thread was `Thread#kill`-ed on Ruby < 3.3."

P2 Badge Correct the Ruby version scope in the changelog

The < 3.3 scope incorrectly includes Ruby 3.2: Ruby 3.2 defines RUBY_INTERNAL_THREAD_EVENT_EXITED, so the #ifdef at otel_thread_context.c:86 selects on_thread_exited and this commit's new thread-begin hook is not compiled there. Either narrow the customer-facing scope to Ruby < 3.2 or change the feature detection if Ruby 3.2 is actually meant to receive this fix; the adjacent C comment should use the same corrected boundary.

AGENTS.md reference: AGENTS.md:L169-L169

ℹ️ 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".

@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Tests

✅ All CI checks and tests passed.

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
• Patch Coverage: 100.00%
• Overall Coverage: 90.66% (+0.01%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 2ba9533 | Docs | View more details | Give us feedback!

The root-cause mechanism for the killed-thread context leak is documented
on the on_thread_begin hook in ext/libdatadog_api/otel_thread_context.c.
Restating it in the regression spec duplicated the rationale.
Both the Thread-exit memcheck spec and the fresh-thread regression spec
attached a context in a thread and then killed it via identical setup.
Extract kill_thread_holding_context so the setup lives in one place.
RUBY_INTERNAL_THREAD_EVENT_EXITED and rb_internal_thread_add_event_hook
landed in Ruby 3.2.0, so the RUBY_EVENT_THREAD_END path that leaks a
killed thread's context compiles on Ruby < 3.2, not < 3.3. Correct the
changelog and the on_thread_begin comment accordingly.
The comment convention forbids the word never; state that a killed
thread's context is left attached instead.
@pr-commenter

pr-commenter Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-10-04 04:28:48

Comparing candidate commit 2ba9533 in PR branch fix-flaky-otel-thread-context with baseline commit b328f4c in branch master.

📊 Benchmarking dashboard

Found 0 performance improvements and 0 performance regressions! Performance is the same for 52 metrics, 0 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

The fresh-thread regression and the Thread-exit memcheck spec both rely on
kill_thread_holding_context having attached a context record before the
thread is killed. Read the record back inside the thread and assert it is
present, so an unsupported build fails the precondition instead of running
the body against a thread that attached nothing.
The it description and the assertion already state that a fresh thread
reports no attached context. Rename the result binding to
context_present_on_fresh_thread so the assertion reads on its own, and
remove the comment that paraphrased it.
Cut the native-thread recycling walkthrough, the Ruby 3.2+ digression, and
the clean-TLS claim. Keep the load-bearing hazard: RUBY_EVENT_THREAD_END
does not fire for Thread#kill on Ruby < 3.2, and the action the hook takes.
The regression test kills a context-holding thread, then asserts a fresh
thread's clear returns false. A single cycle exposes the leaked context
only when CRuby recycles the killed thread's native thread, and that
reuse is nondeterministic, so the assertion could hold against a no-op
reintroduction of the bug.

Drive 100 kill/spawn cycles so a fresh thread reliably recycles a killed
context-holding thread's native thread and observes the stale context
when the on_thread_begin detach is missing.

Validated on Ruby 3.1: the loop fails against a no-op on_thread_begin,
where nearly every cycle observes the stale context, and passes with the
fix.
kill_thread_holding_context runs 100 times in the fresh-thread clear
regression test; a bare signal_queue.pop or killed.join would hang the
whole suite when a thread fails to signal or terminate. Wrap the pop in
Timeout.timeout and assert the join returns within the bound so the
helper fails fast.

@y9v y9v left a comment

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.

Thank you for investigating this! I was ready to dismiss it as a flaky spec

Comment on lines +226 to +229
# A fresh thread exposes the leak only by recycling the killed thread's
# native thread, which CRuby schedules nondeterministically; many
# kill/spawn cycles make the stale context reliably surface if the
# detach regresses.

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.

I think this comment could be dropped?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Naturally, I trimmed it to a single sentence keeping the one part the test name doesn't carry: CRuby schedules that recycling nondeterministically, which is why the test runs many kill/spawn cycles. Fixed in: 026c960c89.

Comment thread unreleased/20260925094811.json Outdated
"type": "Fixed",
"product": "Tracing",
"pull_request": "https://github.com/DataDog/dd-trace-rb/pull/6384",
"message": "Fix the OpenTelemetry Thread Context leaking a killed thread's trace/span IDs onto a new thread reusing a recycled native thread after a traced thread was `Thread#kill`-ed on Ruby < 3.2."

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.

as we didn't release this feature yet, I wouldn't add a changelog entry

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Indeed, the setting and its wiring in #6297 merged after the 2.43.0 cut, so no released version exhibits this defect; a Fixed entry would describe a bug no customer can run into. Dropping the fragment; fixed in: fca6558.

signal_queue.pop # ensure we set the thread context before we kill the thread
killed.kill
killed.join
kill_thread_holding_context

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.

I wouldn't extract this function, since we only use it once, and it would be better to see what is happening in place

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I researched this and the helper has two call sites, the Thread-exit memcheck spec (line 205) and each cycle of the fresh-thread regression loop (line 230), so I've left it in place for now. Happy to inline both spots if having the steps visible in each test reads better.

Comment on lines +105 to +108
// RUBY_EVENT_THREAD_END does not fire for threads terminated via Thread#kill
// on Ruby < 3.2, so a killed thread's otel_thread_ctx_v1 TLS stays attached
// on a native thread CRuby may recycle for a later Ruby thread. Detach on
// thread begin to drop that stale record before the new thread runs.

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.

I would move this comment to native_enable, where we are adding this hook?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Of course, it moves to native_enable, directly above the on_thread_begin registration so the rationale sits with the wiring. Fixed in: 6cb3c768ff.

@y9v

y9v commented Sep 28, 2026

Copy link
Copy Markdown
Member

@p-datadog I can take over from here if you want

p-datadog and others added 2 commits October 2, 2026 23:48
Thread#value blocks until its thread terminates, so a fresh thread
stuck inside clear would hang the 100-cycle regression test. Assert
the fresh thread terminates within 5 seconds before reading its
value, matching the bounded join the spec helper already uses.
p-ddsign and others added 4 commits October 3, 2026 17:07
Add a reference a reader can check for the on_thread_begin comment's
claim that Ruby < 3.2 skips RUBY_EVENT_THREAD_END for threads
terminated via Thread#kill: the v3_1_4 thread_do_start function fires
the event only after the thread body returns, and Thread#kill unwinds
the body via EC_JUMP_TAG(TAG_FATAL).
Address review comment: trim the comment above the fresh-thread
regression test.

The test name already states the recycling precondition. The loop count
needs its own rationale: CRuby schedules native-thread recycling
nondeterministically, so many kill/spawn cycles make the leak reliably
observable. The comment now carries only that rationale.

- Trimmed in spec/datadog/tracing/otel_thread_context_spec.rb:226

Co-Authored-By: Claude <noreply@anthropic.com>
Address review comment: drop the changelog fragment because the fixed
defect is unreachable in released versions.

DD_TRACE_OTEL_CTX_ENABLED and its Tracing wiring merged in #6297 after
the 2.43.0 cut, so the feature first ships with the fix already in it
and no released version exhibits the killed-thread context leak.

- Removed unreleased/20260925094811.json

Co-Authored-By: Claude <noreply@anthropic.com>
Address review comment: place the begin-hook rationale next to the
wiring in native_enable, above the rb_add_event_hook registration,
rather than above the callback definition.

Wording unchanged.

- Moved from ext/libdatadog_api/otel_thread_context.c:104 (original location)
- Inserted above rb_add_event_hook(on_thread_begin, ...) at
  ext/libdatadog_api/otel_thread_context.c:179

Co-Authored-By: Claude <noreply@anthropic.com>
@p-datadog

Copy link
Copy Markdown
Member Author

The deterministic reproducer in #6383 is what moved this past flake suspicion. Thank you for the review, and for the offer to take over: I'll push the remaining edits shortly, so the PR can get a final look after that.

@p-datadog

Copy link
Copy Markdown
Member Author

@codex review

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

Copilot review overview

🟢 Approval recommended

The reviewed changes address the defect and include regression coverage.

Review effort: Lite
Findings: None

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 8b772ac995

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8b772ac995

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Unicorn Enterprises added 3 commits October 3, 2026 23:57
* master:
  chore: remove catch-all (*) rule from CODEOWNERS (#6216)
  DI: gate condition-error snapshots with the global rate limiter (#6250)
* u/fix-flaky-otel-thread-context:
  Link upstream source for OTel thread end event claim
  Bound fresh-thread wait in OTel clear regression test

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Generated Largely based on code generated by an AI or LLM. This label is the same across all dd-trace-* repos

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants