Skip to content

Synchronize native transport fork teardown - #6127

Open
lloeki wants to merge 3 commits into
masterfrom
lloeki/native-fork-invariant
Open

Synchronize native transport fork teardown#6127
lloeki wants to merge 3 commits into
masterfrom
lloeki/native-fork-invariant

Conversation

@lloeki

@lloeki lloeki commented Jul 30, 2026

Copy link
Copy Markdown
Member

AI-generated code disclosure: this PR was implemented with substantial AI assistance and manually reviewed and validated.

What does this PR do?

Makes native transport fork lifecycle dispatch atomic and failure-safe. It coordinates close with in-flight sends and forks, runs every completion callback before re-raising the first error, and prevents finalizer and reentrant-lock deadlocks.

Motivation:

Native transport teardown can race with sends or fork handling, and exceptional callbacks can otherwise leave lifecycle work incomplete. This is the internal safety work tracked by APMSP-3823.

Change log entry

No.

Additional Notes:

How to test the change?

  • Ran the core/main suite: 6,320 examples, 0 failures.
  • Ran the native transport suite: 120 examples, 0 failures, including repeated seeded runs covering concurrent send, fork, close, callback failure, finalizer, and reentrant lifecycle paths.
  • Ran Standard and Steep successfully.

lloeki added 3 commits July 29, 2026 18:21
Fork hooks, sends, and transport teardown share the exporter and its
runtime. Serialize `close` and finalization against active fork
lifecycles and in-flight sends so hooks cannot disappear mid-fork or
the exporter be released during a send.

Use a blocking mock agent to exercise these races deterministically
instead of relying on timing delays.
A failed fork or `:before` callback can leave earlier callbacks holding
resources. Always run the matching `:parent` callbacks, and continue
running cleanup callbacks after one fails before re-raising the first
error.

Defer finalizer hook removal when it interrupts an active fork so the
runtime and lifecycle locks are restored first.
Capture one callback snapshot for the complete fork lifecycle and
register callback triplets atomically. This prevents concurrent
registration or removal from pairing a `:before` callback with the
wrong `:parent` or `:child` cleanup.

Track lock acquisition explicitly so a reentrant `close` cannot
release locks owned by the surrounding fork lifecycle.
@lloeki
lloeki requested a review from a team as a code owner July 30, 2026 11:04
@lloeki lloeki 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 Jul 30, 2026
@lloeki
lloeki requested review from a team as code owners July 30, 2026 11:04
@lloeki
lloeki requested a review from marcotc July 30, 2026 11:04
@lloeki lloeki 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 Jul 30, 2026
@lloeki
lloeki requested a review from vpellan July 30, 2026 11:04
@dd-octo-sts dd-octo-sts Bot added core Involves Datadog core libraries tracing labels Jul 30, 2026
@dd-octo-sts

dd-octo-sts Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Thank you for updating Change log entry section 👏

Visited at: 2026-07-30 12:12:00 UTC

@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: 0ad246116c

ℹ️ 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 on lines +332 to +334
loop do
fork_mutex.lock
break if fork_state[:pending] == 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer finalization before blocking on the fork mutex

When GC invokes this finalizer on a thread other than the one holding fork_mutex for an active fork, the finalizer blocks here without setting fork_state[:finalize]. If the owner forks while it is waiting, that waiting thread disappears in the child; the child callback restores and unlocks the exporter but does not remove the hooks because :finalize remains false, and the already-started finalizer is not rerun there. The child consequently retains the exporter/runtime and its process-global hooks indefinitely, so finalization needs to be marked as pending before this potentially blocking acquisition.

AGENTS.md reference: AGENTS.md:L110-L110

Useful? React with 👍 / 👎.

Comment on lines 163 to +166
rescue Exception # rubocop:disable Lint/RescueException -- re-raised unchanged; we only need to run parent cleanup first
# The fork failed, so no child was created and we are still in the
# parent. The `:before` blocks already ran (and may hold resources,
# e.g. a locked mutex); run the `:parent` blocks so that state is
# restored, then re-raise.
AtForkMonkeyPatch.run_at_fork_blocks(:parent)
# The fork or a before-fork callback failed, so no child was
# created. Restore state set up by any earlier callbacks.
AtForkMonkeyPatch.run_at_fork_blocks(:parent, snapshot)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Run parent cleanup only for callbacks that started

When a :before callback raises, this dispatches the entire :parent snapshot even though later :before callbacks were skipped at the first failure. A later callback triplet can therefore have its cleanup invoked without its setup—for example, attempting to unlock an unacquired mutex and replacing the original fork error with ThreadError, or resetting unrelated lifecycle state. Track which before callbacks completed and run only their matching cleanup handlers.

Useful? React with 👍 / 👎.

Comment on lines +92 to +93
def self.snapshot_at_fork_blocks
AT_FORK_REGISTRY_MUTEX.synchronize do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid locking the registry mutex from signal traps

When an application calls fork directly from a Signal.trap handler, every patched fork now reaches this Mutex#synchronize; on MRI this raises ThreadError: can't be called from trap context before the real fork is attempted. A plain fork is otherwise supported from such a handler, and previously the callback arrays were read without locking, so initializing Datadog now breaks this valid process-management pattern even when the native transport is not in use. The snapshot path needs a trap-safe synchronization strategy.

Useful? React with 👍 / 👎.

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 56.76%
Overall Coverage: 90.01% (-0.02%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: b0c5772 | Docs | Datadog PR Page | Give us feedback!

@pr-commenter

pr-commenter Bot commented Jul 30, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-30 12:25:52

Comparing candidate commit b0c5772 in PR branch lloeki/native-fork-invariant with baseline commit 0a15637 in branch master.

📊 Benchmarking dashboard

Found 0 performance improvements and 0 performance regressions! Performance is the same for 48 metrics, 1 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 ----------------------------------'

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:tracing - trace.to_digest - Continue

  • unstable throughput [-1795.984op/s; +1234.232op/s] or [-6.040%; +4.151%]

@Strech Strech 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.

It's a bit hard to read and follow tests, but I would give non-blocking 👍🏼


def self.run_at_fork_blocks(stage)
blocks_for(stage).each(&:call)
def self.run_at_fork_blocks(stage, snapshot = nil)

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.

Would you consider snapshot: nil kwarg alternative?

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 core Involves Datadog core libraries tracing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants