fix(okhttp): capture scope at AsyncCall.<init> to keep traces intact under virtual-thread Dispatcher#11479
Conversation
This comment has been minimized.
This comment has been minimized.
|
Hi! 👋 Thanks for your pull request! 🎉 To help us review it, please make sure to:
If you need help, please check our contributing guidelines. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f5eb8a9e3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
BitsAI applied the CI fixThe fix has been applied to this PR without validation. Progress 🟢 Investigated · 🟢 Fix prepared · ⚪ Validation skipped · 🟢 Applied Commit: |
…under virtual-thread Dispatcher load
OkHttp's Dispatcher recursively promotes queued AsyncCalls from inside
finished(), running on a worker thread that has a *different* call's
parent scope active. The existing TaskRunnerInstrumentation captures
that wrong scope when the promoted call is executed, so under a
virtual-thread-per-task Dispatcher (or any executor where the same
recursion exposes the issue) okhttp.request spans cross-contaminate
between concurrent caller traces. Single-shot requests don't trigger
this because nothing is queued.
Fix: add AsyncCallInstrumentation that captures the active scope at
AsyncCall.<init> (i.e. the user thread that ran enqueue(), where the
right parent is active) and stores it in the shared
ContextStore<Runnable, State>. RunnableInstrumentation then activates
that state on AsyncCall.run() entry, overriding whatever scope
TaskRunner inherited from the dispatcher-recursion path.
Covers both class locations (3.x and 4.x). Muzzle passes against 24
OkHttp versions in [3.0.0, 5.3.2]. All 168 pre-existing forked OkHttp
tests still pass.
Tests added:
* okhttp-3.0/src/vthread21Test/.../OkHttpVirtualThreadDispatcherTest:
concurrent-burst test (16 parents x 4 requests through a virtual-
thread Dispatcher with capacity 4) plus two single-shot baselines.
The concurrent-burst test fails on master and v1.61.1 without the
fix and passes with it.
* java-concurrent-21.0/src/test/java/ThreadPerTaskExecutorVirtualThreadTest:
pure-executor regression coverage for Thread.ofVirtual().factory(),
the .name(prefix, start) builder variant, recursive submission, and
propagation across virtual-thread unmount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eaf3d86 to
58311a9
Compare
amarziali
left a comment
There was a problem hiding this comment.
I disabled the advice and I had two tests over 3 still green. Only concurrentVirtualThreadPerTaskDispatcher_keepsEachTraceSeparate failed. I can suggest to review what's tested and also there are okhttp 4.x related classes that are matched but the test are only run on 3.11.0
| public String[] knownMatchingTypes() { | ||
| return new String[] { | ||
| "okhttp3.RealCall$AsyncCall", // OkHttp 3.x | ||
| "okhttp3.internal.connection.RealCall$AsyncCall", // OkHttp 4.x |
There was a problem hiding this comment.
according to the gradle build there are no tests at all for 4.x. So how this can be verified?
| * {@code TracingInterceptor.intercept()} on the executor thread using {@code activeSpan()}, so "is | ||
| * the parent scope active on the worker?" is the entire question. | ||
| */ | ||
| class ThreadPerTaskExecutorVirtualThreadTest extends AbstractInstrumentationTest { |
There was a problem hiding this comment.
This is not related to okhttp (directly). I would recommend to add in a different PR
…t suite - Replace javaLauncher override with testJvmConstraints so lower-JDK shards skip vthread21Test rather than forcing JDK 21 - Drop OkHttp 4.x from AsyncCallInstrumentation.knownMatchingTypes() until test coverage exists; trim stale 4.x javadoc - Remove ThreadPerTaskExecutorVirtualThreadTest (no OkHttp dependency, belongs in a separate PR) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| * Runnable}. | ||
| */ | ||
| @AutoService(InstrumenterModule.class) | ||
| public final class AsyncCallInstrumentation extends InstrumenterModule.Tracing |
There was a problem hiding this comment.
This advice is instead InstrumenterModule.ContextTracking given its purpose
Thanks @amarziali — this all landed. Here's how each point is addressed in the changes I'm preparing for the next push: "okhttp 4.x classes are matched but tests only run on 3.11.0" / how can 4.x be verified?Split the
I confirmed the suite genuinely exercises the fix on both 4.x and 5.x by neutralizing the advice and watching "review what's tested" (2 of 3 stayed green with the advice disabled)Correct — only the concurrent test exercises the contamination path. Dropped the
|
….x/5.x
Address review feedback on the virtual-thread Dispatcher trace-contamination fix:
- Split the AsyncCall.<init> scope capture into per-major instrumentations so
each is tested against its own OkHttp version. okhttp-3.0 keeps the v3 type
(okhttp3.RealCall$AsyncCall); a new okhttp-4.0 module matches the relocated
v4+ type (okhttp3.internal.connection.RealCall$AsyncCall). The okhttp-4.0
suite runs the regression against OkHttp 4.12.0 and 5.4.0 (5.x is Kotlin
Multiplatform; classes ship in okhttp-jvm, resolved via Gradle variant
metadata). Muzzle covers 4.0.0 -> 5.4.0.
- Both instrumentations now extend InstrumenterModule.ContextTracking (matching
RunnableInstrumentation, the consumer of the state they write) rather than
Tracing -- their job is scope propagation, not span creation.
- Drop the redundant cachedThreadPoolDispatcher test (covered by OkHttp3AsyncTest)
and document that only the concurrent test exercises the contamination path.
- Cap the vthread suites at maxJavaVersion=24 to match java-concurrent-21.0,
which provides the virtual-thread propagation they depend on and is only
supported through JDK 24. This fixes the TimeoutExceptions on the JDK 25 / 26
("tip") CI shards, where that propagation chain isn't active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
FYI, Basically add |
|
Also merging in latest master should fix the current CI failures |
The split okhttp-4.0 module registers a new "okhttp-4" integration name, so DD_TRACE_OKHTTP_4_ENABLED must be declared in the supported configurations metadata. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@amarziali The single-shot baseline doesn’t queue, so it doesn’t hit the path being fixed. I think the other points have all now been addressed…
|
AsyncCall.<init> to keep traces intact under virtual-thread Dispatcher
| pass { | ||
| group = "com.squareup.okhttp3" | ||
| module = "okhttp" | ||
| versions = "[3.0,)" |
There was a problem hiding this comment.
This is misleading to me since the tracing instrumentation is applying for 3+ (includes 4) while the new Instrumentation for context trackingis only applying for 3. To be correct this should be split and ensure that the new will fail for 4 to avoid overlaps with the 4.x module (that also suffers about it because tests muzzle only for 4 onwards without testing that fails for < 4.
Splitting muzzle checks in this module can be done by having a separate muzzleDirective on the AsyncCallInstrumentation.
There was a problem hiding this comment.
Alright, I'll look into more closely. I'll be honest this was an experiment to see how the AI would do solving a complicated context issue. It did okay finding the problem, but the mechanics of the change could be better.
But honestly, given that I haven't written many instrumentations, I would probably have made many of the same mistakes.
There was a problem hiding this comment.
All in all it did well I think. There are always details to fine tune of course
| minJavaVersion = JavaVersion.VERSION_21 | ||
| // Cap at 24 to match java-concurrent-21.0, which provides the virtual-thread scope propagation | ||
| // these suites rely on and is only supported through JDK 24. See okhttp-3.0 for details. | ||
| maxJavaVersion = JavaVersion.VERSION_24 |
There was a problem hiding this comment.
btw why it hangs for >=25 ? Are we missing something? Our virtual thread instrumentation is supposed to work on 25
There was a problem hiding this comment.
Note: reply from Claude (Claude Code), working with @dougqh.
Good catch — I checked this empirically rather than assuming. The virtual-thread instrumentation does work on 25; the cap was mis-inherited and the "times out on 25" explanation is wrong.
I ran the okhttp-4.0 vthread21Test4 (OkHttp 4.x) and vthread21Test5 (5.x) suites on JDK 25 — lifting the maxJavaVersion = 24 cap, runtime confirmed via the tracer's runtimeVersion='25'. Both pass (2 tests each), including the contention regression concurrentVirtualThreadPerTaskDispatcher_keepsEachTraceSeparate (the one that fails if scope propagation breaks). okhttp-3.0's vthread21Test passes on 25 too.
Root cause of the cap: it traces to java-concurrent-21.0, which caps at 24 because its own test uses StructuredTaskScope.ShutdownOnFailure — a preview API removed in JDK 25. That's a test-compilation constraint on that module, not a limit on the virtual-thread instrumentation. These suites only consume that instrumentation at runtime (never ShutdownOnFailure), so the cap never applied — and the "propagation chain isn't active on 25" comment is empirically false.
Fix: per the usual "no cap until a demonstrated problem" convention, I've removed the cap on both okhttp modules. Verified through JDK 25; I haven't checked 26/"tip", so if a real issue surfaces there CI will catch it and we cap then, with an accurate reason.
There was a problem hiding this comment.
Note: reply from Claude (Claude Code), working with @dougqh.
Correction to my reply above — CI caught what my local run didn't. With the cap lifted, the test_inst shards on JDK 25 and tip fail, so I've restored maxJavaVersion = 24 (commit d17f52367c) with a corrected comment.
To separate what's true from what I overstated:
- The virtual-thread instrumentation does work on 25 — the suites pass locally on 25, including the contention regression. That part stands, and it's the answer to your original question.
- But my local run was fast and unloaded and didn't reproduce the CI shards' conditions. The contention test deliberately creates dispatcher contention and waits on a latch; it isn't stable on the 25/tip shards under that load. So the original "times out on the 25/tip CI shards" note was right about CI — even though its stated reason ("propagation chain isn't active on 25") is wrong.
Net: instrumentation works on 25 ✅; the suite isn't CI-stable on 25/tip yet
There was a problem hiding this comment.
This explanation is not clear to me why in java 25 is failing. either the test is flaky (and it' s an issue since will probably fail in the future) either we're not covering everything?
There was a problem hiding this comment.
Note: reply from Claude (Claude Code), working with @dougqh.
You're right that "flaky" is itself an issue, so I checked which horn it is. 10 runs on JDK 25 locally → 10/10 pass. A real coverage gap would fail deterministically, so this is flakiness under CI-shard load (the stress test depends on stochastic contention timing), not a missed case.
So instead of cap-and-hope, I added a deterministic test — promotedFromFinished_keepsEnqueuingTraceNotFinishingTrace: capacity-1 dispatcher, call_B (parent B) holds the slot at a gated endpoint, call_A (parent A) queues, then releasing the gate promotes call_A from inside call_B's finished() with scope B active. It asserts call_A parents under A — the exact contamination path, constructed rather than raced for.
Verified sensitive: passes with the AsyncCall.<init> capture, fails without it (advice disabled → "leaked to parentB's trace"). Same result every run. The stress test stays as an integration sanity check; this is the reliable guard.
Still capped at 24 (it lives in the vthread suite), but being deterministic it won't flake — so the cap can come off once we're comfortable, and a plain-executor variant could drop the JDK-21 requirement entirely.
- Scope AsyncCallInstrumentation's muzzle to a named [3.0,4.0) directive (okhttp-async-3) so the 3.x-only async-call capture no longer claims the module-wide [3.0,) range; real version gate remains knownMatchingTypes. - Remove two verbose comments amarziali flagged (the rationale lives in the vthread21Test javadoc, which documents and verifies it). - Remove the maxJavaVersion=24 cap on both okhttp vthread suites. The cap was mis-inherited from java-concurrent-21.0, whose cap exists because its own test uses StructuredTaskScope.ShutdownOnFailure (a preview API removed in JDK 25) - not an instrumentation limit. Verified okhttp 3.x/4.x/5.x vthread suites pass on JDK 25. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI showed the suites fail on the JDK 25 / tip test_inst shards once the cap was lifted. The virtual-thread instrumentation does work on 25 (the 3.x/4.x/5.x vthread suites pass locally, including the contention regression), so this is a CI/test-stability issue (the contention test's timing under shard load), not an instrumentation limit. Restoring maxJavaVersion=24 with a corrected comment; lifting it needs the suite made stable on 25+ first (separate follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The existing concurrent test relies on stochastic queue contention to trigger promotion from Dispatcher.finished() on a sibling's worker — which makes it flaky under CI-shard load (the reason the suite is capped at JDK 24). This adds a deterministic counterpart: dispatcher capacity 1, call_B (parent B) occupies the slot and blocks at a gated endpoint, call_A (parent A) is enqueued and necessarily queues, then releasing the gate promotes call_A from inside call_B's finished() with scope B active. Asserts call_A's okhttp.request span parents under A. No bursts, no timing windows — fails identically every run. Verified: passes with the AsyncCall.<init> capture; fails without it (advice disabled) with the cross-trace contamination message. The stress test stays as an integration sanity check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What Does This Do
Fixes
okhttp.requestspans cross-contaminating between concurrent caller traces when OkHttp'sDispatcherruns on a virtual-thread-per-task executor. Captures the active scope atAsyncCall.<init>(i.e. on the user thread that calledCall.enqueue(callback)) and stores it in the sharedContextStore<Runnable, State>.RunnableInstrumentationthen activates that state onAsyncCall.run()entry, overriding whatever scopeTaskRunnerinherited from a dispatcher-recursion path.The capture is split by OkHttp major version, since
AsyncCallmoved packages:AsyncCalltypeokhttp-3.0okhttp3.RealCall$AsyncCallokhttp-4.0(new)okhttp3.internal.connection.RealCall$AsyncCallBoth instrumentations extend
InstrumenterModule.ContextTracking(likeRunnableInstrumentation, which consumes the state they write) rather thanTracing— their sole job is scope propagation through the sharedContextStore<Runnable, State>, not span creation. They re-use the existingokhttpinstrumentation name, so no new feature flag: OkHttp tracing being enabled implies this capture is enabled.Motivation
Reported by DataDog/profiling-backend#8520, which swapped its OkHttp dispatcher from
Executors.newCachedThreadPool(...)toExecutors.newThreadPerTaskExecutor(Thread.ofVirtual().name(prefix, start).factory())and started seeing broken traces.OkHttp's
Dispatcherrecursively promotes queuedAsyncCalls from insideDispatcher.finished(), which runs on a dispatcher worker thread with a different call's parent scope still active (re-instated byTaskRunner.run()). The existingTaskRunnerInstrumentationcaptures that wrong scope when the promoted call is submitted to the executor, so under a virtual-thread-per-task dispatcher — or any executor where this recursion exposes itself —okhttp.requestspans cross-contaminate between concurrent caller traces. Single-shot requests don't trigger it because nothing is queued.The right capture point is the moment the user enqueued the call, where the right parent is active. That's exactly
AsyncCall.<init>.Deep dive: failure mode and propagation walk
Why the old code worked
Executors.newCachedThreadPool(...)returns ajava.util.concurrent.ThreadPoolExecutor, covered byThreadPoolExecutorInstrumentation. That instrumentation captures the active continuation into aStatekeyed by theAsyncCallatexecute()time, thenbeforeExecuteactivates it on the worker. Theokhttp.requestspan is created insideAsyncCall.run()with the parent scope reliably active.Why the new code breaks
Executors.newThreadPerTaskExecutor(Thread.ofVirtual()...)returnsjava.util.concurrent.ThreadPerTaskExecutor, which is not aThreadPoolExecutor:ThreadPoolExecutorInstrumentation'sextendsClass(named("java.util.concurrent.ThreadPoolExecutor"))doesn't match. No advice fires on itsexecute(...).JavaExecutorInstrumentationdoesn't match either —ThreadPerTaskExecutoris not in itsknownMatchingTypesanddd.trace.executors.allis off by default.The only things that fire are:
TaskRunnerInstrumentation— advisesThreadPerTaskExecutor$TaskRunner.<init>andrun(). The constructor stashes a continuation in aStatekeyed by the TaskRunner, not the user'sAsyncCall.VirtualThreadInstrumentation— captures the wholeContextatVirtualThread.<init>, swaps it in/out on eachmount/unmount.In principle this propagates the scope. In practice, both fire on whatever thread calls
executor.execute(...). That's the user thread for the first call from a given caller — but it's a dispatcher worker for any call that gets queued.Dispatcher recursion → cross-trace contamination
readyAsyncCalls.AsyncCall.run()'sfinally,dispatcher.finished(this)runs — still on Parent_A's worker thread with Parent_A's scope active (reinstated byTaskRunner.run).finished()callspromoteAndExecute(), which callsexecutor.execute(nextCall)for a queued call belonging to some other parent.TaskRunnerInstrumentation.Constructcaptures whatever scope is active on the current worker — which is Parent_A, not the parent that actually calledenqueue()fornextCall.okhttp.requestspan lands in Parent_A's trace.Repeat at scale → Parent_A's trace accumulates strays; Parent_B / Parent_C / etc. end up with empty traces. The single-shot tests miss it entirely because nothing is in the ready queue when
enqueue()returns.How the fix propagates correctly
With the
AsyncCall.<init>capture in place:realCall.enqueue(cb)→new AsyncCall(cb)→ capture advice fires → captures Parent_Y's continuation on the AsyncCall inContextStore<Runnable, State>. ✓executor.execute(asyncCall_Y)→TaskRunner.<init>→ captures Parent_A on the TaskRunner. (Irrelevant noise — we don't try to fixTaskRunner.)TaskRunner.run():TaskRunnerInstrumentation.Runactivates Parent_A on the worker.TaskRunner.run()callsasyncCall_Y.run().RunnableInstrumentation.RunnableAdvicefires on entry → reads theStatestored in step 1 → activates Parent_Y on top of Parent_A.getResponseWithInterceptorChain()runs theTracingInterceptor→activeSpan()returns Parent_Y →okhttp.requestis parented under Parent_Y. ✓RunnableAdvicecloses Parent_Y's scope, thenTaskRunnercloses Parent_A's. Stack unwinds cleanly.Two key properties make this safe:
AdviceUtils.captureonly writes a continuation if one isn't already there and async propagation is on, so we never overwrite a previously captured state.RunnableInstrumentation's advice runs insideTaskRunner.run(), afterTaskRunner's activation. That inner scope wins for the entire duration ofasyncCall.run()— which is exactly when theokhttp.requestspan is created.Compatibility
okhttp-3.0module, typeokhttp3.RealCall$AsyncCall.okhttp-4.0module, typeokhttp3.internal.connection.RealCall$AsyncCall. OkHttp 5.x is a Kotlin-Multiplatform library:com.squareup.okhttp3:okhttpis a metadata shell and the JVM classes (includingRealCall$AsyncCall) ship incom.squareup.okhttp3:okhttp-jvm, which a JVM consumer resolves automatically via Gradle variant metadata. The matched type is at the same FQN in 4.x and 5.x, so a single instrumentation covers both majors.Muzzle passes against OkHttp
[3.0,)(okhttp-3.0) and[4.0,)including 5.0.0 → 5.4.0 (okhttp-4.0).Testing
The virtual-thread regression suites live in dedicated JDK-21 test suites (the modules keep a Java 1.8 baseline for the existing tests). They are capped at
maxJavaVersion=24to matchjava-concurrent-21.0, which provides the virtual-thread scope propagation these tests depend on and is only supported through JDK 24 — so they run on JDK 21–24 and skip on 25/tip(26), where that propagation chain isn't active.Each suite has two tests:
concurrentVirtualThreadPerTaskDispatcher_keepsEachTraceSeparate— the regression test. Forces dispatcher-queue contention (16 parents × 4 requests, capacity 4) so calls are promoted fromDispatcher.finished()on a sibling's worker. Fails with cross-trace contamination without the fix; verified by neutralizing the advice on both 4.x and 5.x.virtualThreadPerTaskDispatcher_parentsOkHttpSpanUnderParent— a single-shot baseline (does not by itself exercise the contamination path).Test plan
:dd-java-agent:instrumentation:okhttp:okhttp-3.0:vthread21Test— OkHttp 3.11.0, 2 tests pass (JDK 21), skipped on JDK 25:dd-java-agent:instrumentation:okhttp:okhttp-4.0:vthread21Test4— OkHttp 4.12.0, 2 tests pass (JDK 21), skipped on JDK 25:dd-java-agent:instrumentation:okhttp:okhttp-4.0:vthread21Test5— OkHttp 5.4.0 (→ okhttp-jvm), 2 tests pass (JDK 21), skipped on JDK 25:dd-java-agent:instrumentation:okhttp:okhttp-3.0:forkedTest— pre-existing OkHttp forked tests still pass:dd-java-agent:instrumentation:okhttp:okhttp-3.0:muzzle/okhttp-4.0:muzzle— pass🤖 Generated with Claude Code