APMSP-3550 DI: add re-entrancy guard for method probes on stdlib#5560
Conversation
Typing analysisNote: Ignored files are excluded from the next sections.
|
BenchmarksBenchmark execution time: 2026-07-01 18:57:40 Comparing candidate commit c23780a in PR branch Found 0 performance improvements and 2 performance regressions! Performance is the same for 46 metrics, 1 unstable metrics.
|
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: c23780a | Docs | Datadog PR Page | Give us feedback! |
64d3c33 to
1be8770
Compare
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e33141cc8
ℹ️ 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".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 640a63cb5b
ℹ️ 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".
|
@codex review comprehensively |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
@codex find non-major issuse |
|
I found one non-major issue worth flagging:
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad618de255
ℹ️ 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".
ad618de to
be7293d
Compare
|
Since we have the recursion check in place, if the user instruments e.g. Would be great to have a spec for that. |
To reduce that, and since you are already using C, maybe using a C We do have very fast per-Ruby-Thread state in profiling, but that seems difficult to access here (cc @ivoanjo). FWIW I think 3μs is actually a lot, a normal method call takes about 0.027μs, about 100x less: ( |
Replace the version-agnostic *args/**kwargs capture and four-way super
ladder with the per-version delegation from docs/Delegation.md: Ruby 3+
captures and forwards positional and keyword arguments separately, while
Ruby < 3 captures a single ruby2_keywords-flagged splat and forwards it
intact. This makes an empty trailing hash (e.g. foo('x', {})) reach the
probed method on Ruby 2.6/2.7, which the old ladder dropped.
Argument serialization on Ruby < 3 splits the trailing hash out via
kwargs_from_splat; forwarding always replays the original splat.
kwargs_from_splat runs before the method-probe re-entrancy guard is set, so a customer method probe on Kernel#is_a? would re-enter the wrapper and recurse. Capture Kernel#is_a? as an unbound method at load time (before any probe can prepend an override) and invoke the original through it, bypassing the probe.
The Ruby < 3 argument-serialization split tested the trailing element with a captured Kernel#is_a? UnboundMethod. Replace it with a DI.hash? C singleton method (RB_TYPE_P), consistent with DI.array_empty?/hash_empty?: a singleton method cannot be targeted by a method probe, so it has no recursion surface, and it avoids the per-call Method allocation that UnboundMethod#bind incurred. Add RBS signatures for DI.hash? and Instrumenter#kwargs_from_splat.
Completes the previous commit, which only captured the RBS change due to a bad git-add pathspec. Adds the DI.hash? C function (RB_TYPE_P), its use in Instrumenter#kwargs_from_splat in place of the Kernel#is_a? UnboundMethod, and the DI.hash? RBS signature.
…arisons The method-probe wrapper and the Kernel#lambda integration test gated behavior on bare RUBY_VERSION string comparisons. Lexical comparison misorders multi-digit version segments: "3.10" >= "3.3" is false as a string compare, so the `>= "3.3"` skip guard would silently stop firing on a future Ruby 3.10. Datadog::RubyVersion.is? wraps Gem::Requirement and orders versions semantically; it is already the established pattern in DI code (component.rb, base.rb). Adds an explicit require of ruby_version to instrumenter.rb because the wrapper selection runs at class-body load time, so the constant must be available even when instrumenter.rb is loaded in isolation. Verified: spec/datadog/di/ext/in_probe_spec.rb, spec/datadog/di/instrumenter_spec.rb, spec/datadog/di/integration/stdlib_probe_spec.rb, spec/datadog/di/probe_spec.rb — 164 examples, 0 failures on Ruby 3.2.3.
|
I made another pass, looks much better & simpler to me, still a couple small things to clean it up and then I think it will be good to go |
Conflict in lib/datadog/di/instrumenter.rb resolved by keeping the branch's refactored hook_method (RubyVersion split, extracted run_method_probe, re-entrancy guard, DI.invoke_proc) and folding in master's changes since the merge-base: - require_relative 'fatal_exceptions' and Datadog::DI.reraise_if_fatal in all catch-all rescues (run_method_probe and line_trace_point_callback). - The #5954 Kernel#lambda probe rejection, via target_method&.owner == ::Kernel. Kernel#lambda probing is prohibited, so the branch's stdlib integration test that installed a Kernel#lambda probe was removed; master's rejection tests in instrumenter_spec.rb cover the prohibition.
DI.hash_empty? has no callers. DI.array_empty?'s only caller was a
redundant early return in kwargs_from_splat — an empty args list already
returns [args, {}] via the trailing-hash branch below it.
- ext/libdatadog_api/di.c: drop array_empty_p, hash_empty_p, and their
singleton-method registrations
- lib/datadog/di/instrumenter.rb: drop the redundant DI.array_empty?
early return in kwargs_from_splat
- sig/datadog/di.rbs: drop the array_empty? and hash_empty? signatures
- spec/datadog/di/ext/in_probe_spec.rb: drop their unit specs
- spec/datadog/di/integration/stdlib_probe_spec.rb: describe the
re-entrancy guard (not the removed primitives) in the Array#empty? /
Hash#empty? context comments
Verified on Ruby 3.2.3: rake compile, rspec (di/ext/in_probe_spec,
di/integration/stdlib_probe_spec, di/instrumenter_spec — only the
pre-existing Set#include? failure, which also fails on a clean tree),
steep check, standard.
A customer method probe on a stdlib method that DI's own configuration layer uses (e.g. Set#include?, called by ConfigHelper#get_environment_variable through SUPPORTED_CONFIGURATION_NAMES) deadlocked the application: add_probe -> worker.add_status (holds @lock) -> logger.trace reads dynamic_instrumentation.internal.trace_logging -> ConfigHelper#get_environment_variable -> Set#include? -> method-probe wrapper fires (DI.in_probe? is false here) -> probe_executed_callback -> worker.add_snapshot -> @lock again -> ThreadError: deadlock; recursive locking The re-entrancy guard was only set inside run_method_probe, so DI-internal stdlib calls made while enqueuing (outside any probe callback) still fired the probe. Hold the guard across the add_status/add_snapshot body so those internal calls reach the original method without firing, preventing the re-entry. State is saved/restored so a caller that already holds the guard (e.g. run_method_probe post-processing calling add_snapshot) is not cleared early. begin/ensure (not block-level ensure) keeps Ruby 2.5 compatibility. Fixes the previously-deadlocking spec/datadog/di/integration/ stdlib_probe_spec.rb "method probe on Set#include? with snapshot capture". Verified on Ruby 3.2.3: rspec spec/datadog/di (excluding contrib appraisal specs) 950 examples 0 failures, steep check clean, standard clean.
Replace the do_super lambda argument to run_method_probe with a literal block invoked via yield. yield bypasses Proc#call dispatch (so a user probe on Proc#call cannot intercept the trampoline) and allocates no Proc object, removing the need for DI.invoke_proc, which is deleted along with its RBS signature and unit tests.
The super block closes over the wrapper's own args/kwargs/target_block, so run_method_probe no longer threads them back in: the block takes no parameters and is invoked with a bare yield. Also refresh the wrapper comments and RBS block signature to match.


What does this PR do?
Adds a fiber-local re-entrancy guard to method probes and integration tests exercising it.
When a probe targets a stdlib method that DI calls internally during snapshot building (e.g.,
String#lengthviaSecureRandom.uuid), the probe would re-enter DI processing recursively. The guard prevents this by skipping DI processing for calls that occur while DI is already processing a probe.The guard is a split guard: held during DI pre-processing (serialization) and post-processing (notification), but released during
super()so that recursive calls and nested probes fire normally during user code execution.Motivation:
Without this, a non-capture probe on
String#lengthcausesSystemStackErrorvia:probe fires → build_snapshot → SecureRandom.uuid → gen_random_urandom → String#length → probe fires → ...Change log entry
Yes. Dynamic Instrumentation: fix SystemStackError when probes target standard library methods used internally by probe processing.
Architecture:
The guard storage is fiber-local. Reads and writes go through seven new C-implemented module methods on
Datadog::DI:in_probe?/enter_probe/leave_probe— guard state, backed byrb_thread_local_aref/rb_thread_local_aset(the same hashtable that backsThread#[]/Thread#[]=, but bypassing Ruby method dispatch — so a user probe onThread#[]orThread#[]=cannot intercept guard reads/writes).invoke_proc— invokes a Proc viarb_proc_call_with_block, bypassingProc#calldispatch. The wrapper invokes thedo_supertrampoline through this so a user probe onProc#callcannot intercept it.array_empty?(RARRAY_LEN) /hash?(RB_TYPE_P) — used by the Ruby < 3 argument-splitting path (kwargs_from_splat) to test args shape and detect a trailing hash without dispatchingArray#empty?/Kernel#is_a?(which would self-recurse if a user probe targets either).hash_empty?(RHASH_SIZE) is a symmetric counterpart that is not currently called by the wrapper.The method probe wrapper body lives in
Instrumenter#run_method_probe, an instance method that is lexically public (so the prepended wrapper can call it without.send, which a user probe onObject#sendcould otherwise intercept). Thedefine_methodblock defines ado_superlambda that capturessuper's lexical binding: on Ruby 3+ it forwardssuper(*a, **k, &blk), on Ruby < 3 it replays the originalruby2_keywords-flagged splat viasuper(*args, &blk).run_method_probeinvokes the original method viaDI.invoke_proc(do_super, args, kwargs, target_block)instead ofsuperdirectly — preservingsuper's prepended-class semantics while keeping the wrapper body at normal indentation in a regular method.Line probes are not affected because Ruby's TracePoint is self-disabling during its callback.
Additional Notes:
Customer-observable contract: probes on stdlib methods called by DI internally are safe; nested invocations during DI processing don't fire the probe and don't count toward the rate limit.
How to test the change?
Automated:
159 examples, 0 failures, 8 pending (unrelated pre-existing).
Test skips added in this PR:
spec/datadog/di/integration/stdlib_probe_spec.rb—"does not self-recurse during do_super construction in another probed method":skip "..." if Datadog::RubyVersion.is?("< 3.0")). On Ruby 2.7, prepending alambdamethod onto Kernel does not interceptlambda { ... }calls — the wrapper is never entered, so the recursion bug being tested is unreachable. Platform limitation skip.skip "..." if Datadog::RubyVersion.is?(">= 3.3")). On Ruby 3.3+, the originalKernel#lambdaraisesArgumentErrorwhen invoked viasuper(&blk)with a non-literal block — which is what the wrapper'sdo_superdoes. ProbingKernel#lambdaon Ruby 3.3+ is therefore broken at a language layer below this PR's scope. This is a known customer-visible limitation; rejectingKernel#lambdaprobes outright is handled in the companion PR DI: reject method probes on Kernel#lambda #5954.Ruby X.Y / build & test (standard) [di:di_with_ext slot]jobs across the full supported Ruby range (2.5–4.0).Known limitation (customer-facing): On Ruby 3.3+, method probes on
Kernel#lambdamay raiseArgumentErrorwhen the probed call site passes a non-literal block. This is a Ruby language behavior the tracer cannot work around; the companion PR #5954 rejectsKernel#lambdaprobes at hook time and surfaces an ERROR status to the customer.Gobo (manual verification via Live Debugger UI):
Stringclass,lengthmethod — this is the C-implemented stdlib method that triggered the SystemStackErrorString#lengthis called pervasively by Ruby codeSystemStackError: stack level too deepActual results: without PR: in production env the server is surviving but printing SystemStackError in the log. Memory consumption grows without bounds (stopped server at 5 GB RSS in each worker). In development env the server becomes DOSed and shows a huge stack trace with the SystemStackError on top.
With PR: no errors in the log. Memory usage stable at 250 MB per worker.
Performance impact
This PR adds approximately 3 μs of overhead per probed method call.
The PR adds a re-entrancy guard to the method-probe wrapper: set a thread-local flag on entry, check it on every call, clear it on exit. This prevents DI from recursing into itself when it calls stdlib methods that customers have probed.
Costs (measured with
benchmarks/di_instrument.rbagainstmasteratf9044e8cea, PR at83ede0ecd5, pinned to a single Intel Core Ultra 7 165U E-core at 1.2 GHz viataskset -c 4, Ruby 3.2.3, one run per branch):caller_locationscapture and movesProc#callto C, partially offsetting the guard cost.The +3.4 μs is well outside the run's ±5% baseline noise. The +1.4 μs is close to it.
Why the firing-path regression (+1.4 μs) is smaller than the skip-path regression (+3.4 μs)
The two paths share the wrapper entry but diverge at the rate-limit check.
Skip path (rate limit rejects):
superto original methodThe PR adds the guard set/check/clear; almost nothing else changed on this path. The wrapper body was already small (~5.6 μs), so the guard's added cost dominates and the delta is ~+3.4 μs.
Firing path (rate limit accepts):
superto original methodThe PR adds the guard here too — but the PR also changes this path:
caller_locationscapture is deferred into a depth-3 lambda thunk and only invoked when the responder asks for it (commitf292d07951)Proc#calldispatch moved to C to bypass user probes onProc#call(commitc08dac2768).sendeliminated from the hot path (commitce69d653d7)Array#empty?/Hash#empty?calls replaced with C-levelRARRAY_LEN/RHASH_SIZEreads (commit7040a5a2b6)These optimizations only have surface area on the firing path (the skip path doesn't capture callers, doesn't dispatch to responders, doesn't run the argument-shape branches). So the firing path pays the guard cost (+~3 μs) and reclaims some of it from the optimizations (-~1.6 μs), netting +1.4 μs. The skip path pays the guard cost with nothing to reclaim.
The +1.4 μs delta is close to the ±5% in-run baseline noise, so the true firing-path overhead could be anywhere from roughly 0 to +3 μs — a second run would tighten it. The skip-path delta is large enough that noise can't account for it.