Skip to content

fix(okhttp): capture scope at AsyncCall.<init> to keep traces intact under virtual-thread Dispatcher#11479

Open
dougqh wants to merge 8 commits into
masterfrom
fix/okhttp-virtual-thread-dispatcher-trace-contamination
Open

fix(okhttp): capture scope at AsyncCall.<init> to keep traces intact under virtual-thread Dispatcher#11479
dougqh wants to merge 8 commits into
masterfrom
fix/okhttp-virtual-thread-dispatcher-trace-contamination

Conversation

@dougqh

@dougqh dougqh commented May 27, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Fixes okhttp.request spans cross-contaminating between concurrent caller traces when OkHttp's Dispatcher runs on a virtual-thread-per-task executor. Captures the active scope at AsyncCall.<init> (i.e. on the user thread that called Call.enqueue(callback)) and stores it in the shared ContextStore<Runnable, State>. RunnableInstrumentation then activates that state on AsyncCall.run() entry, overriding whatever scope TaskRunner inherited from a dispatcher-recursion path.

The capture is split by OkHttp major version, since AsyncCall moved packages:

Module OkHttp AsyncCall type
okhttp-3.0 3.x okhttp3.RealCall$AsyncCall
okhttp-4.0 (new) 4.x+ okhttp3.internal.connection.RealCall$AsyncCall

Both instrumentations extend InstrumenterModule.ContextTracking (like RunnableInstrumentation, which consumes the state they write) rather than Tracing — their sole job is scope propagation through the shared ContextStore<Runnable, State>, not span creation. They re-use the existing okhttp instrumentation 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(...) to Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name(prefix, start).factory()) and started seeing broken traces.

OkHttp's Dispatcher recursively promotes queued AsyncCalls from inside Dispatcher.finished(), which runs on a dispatcher worker thread with a different call's parent scope still active (re-instated by TaskRunner.run()). The existing TaskRunnerInstrumentation captures 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.request spans 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 a java.util.concurrent.ThreadPoolExecutor, covered by ThreadPoolExecutorInstrumentation. That instrumentation captures the active continuation into a State keyed by the AsyncCall at execute() time, then beforeExecute activates it on the worker. The okhttp.request span is created inside AsyncCall.run() with the parent scope reliably active.

Why the new code breaks

Executors.newThreadPerTaskExecutor(Thread.ofVirtual()...) returns java.util.concurrent.ThreadPerTaskExecutor, which is not a ThreadPoolExecutor:

  • ThreadPoolExecutorInstrumentation's extendsClass(named("java.util.concurrent.ThreadPoolExecutor")) doesn't match. No advice fires on its execute(...).
  • JavaExecutorInstrumentation doesn't match either — ThreadPerTaskExecutor is not in its knownMatchingTypes and dd.trace.executors.all is off by default.

The only things that fire are:

  • TaskRunnerInstrumentation — advises ThreadPerTaskExecutor$TaskRunner.<init> and run(). The constructor stashes a continuation in a State keyed by the TaskRunner, not the user's AsyncCall.
  • VirtualThreadInstrumentation — captures the whole Context at VirtualThread.<init>, swaps it in/out on each mount/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

  1. Parent_A enqueues 4 calls. Dispatcher capacity is 4. They start.
  2. Parent_B (..., Parent_P) enqueue more calls. They queue in readyAsyncCalls.
  3. A call from Parent_A finishes on its virtual thread. Inside AsyncCall.run()'s finally, dispatcher.finished(this) runs — still on Parent_A's worker thread with Parent_A's scope active (reinstated by TaskRunner.run).
  4. finished() calls promoteAndExecute(), which calls executor.execute(nextCall) for a queued call belonging to some other parent.
  5. The agent's TaskRunnerInstrumentation.Construct captures whatever scope is active on the current worker — which is Parent_A, not the parent that actually called enqueue() for nextCall.
  6. That promoted call runs on a new virtual thread with Parent_A's scope, and its okhttp.request span 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:

  1. User thread, Parent_Y active: realCall.enqueue(cb)new AsyncCall(cb) → capture advice fires → captures Parent_Y's continuation on the AsyncCall in ContextStore<Runnable, State>. ✓
  2. AsyncCall is handed to the dispatcher. May execute immediately or queue.
  3. Later: dispatcher worker thread, Parent_A active. executor.execute(asyncCall_Y)TaskRunner.<init> → captures Parent_A on the TaskRunner. (Irrelevant noise — we don't try to fix TaskRunner.)
  4. Virtual thread runs TaskRunner.run():
    • TaskRunnerInstrumentation.Run activates Parent_A on the worker.
    • TaskRunner.run() calls asyncCall_Y.run().
    • RunnableInstrumentation.RunnableAdvice fires on entry → reads the State stored in step 1 → activates Parent_Y on top of Parent_A.
  5. getResponseWithInterceptorChain() runs the TracingInterceptoractiveSpan() returns Parent_Y → okhttp.request is parented under Parent_Y. ✓
  6. On exit: RunnableAdvice closes Parent_Y's scope, then TaskRunner closes Parent_A's. Stack unwinds cleanly.

Two key properties make this safe:

  • AdviceUtils.capture only 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 inside TaskRunner.run(), after TaskRunner's activation. That inner scope wins for the entire duration of asyncCall.run() — which is exactly when the okhttp.request span is created.

Compatibility

  • OkHttp 3.xokhttp-3.0 module, type okhttp3.RealCall$AsyncCall.
  • OkHttp 4.x+okhttp-4.0 module, type okhttp3.internal.connection.RealCall$AsyncCall. OkHttp 5.x is a Kotlin-Multiplatform library: com.squareup.okhttp3:okhttp is a metadata shell and the JVM classes (including RealCall$AsyncCall) ship in com.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=24 to match java-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 from Dispatcher.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
  • Verified in profiling-backend staging deploy

🤖 Generated with Claude Code

@datadog-prod-us1-3

This comment has been minimized.

@dougqh
dougqh marked this pull request as ready for review May 27, 2026 18:46
@dougqh
dougqh requested review from a team as code owners May 27, 2026 18:46
@dougqh
dougqh requested review from sarahchen6 and vandonr and removed request for a team May 27, 2026 18:46
@dd-octo-sts

dd-octo-sts Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Hi! 👋 Thanks for your pull request! 🎉

To help us review it, please make sure to:

  • Add at least one type, and one component or instrumentation label to the pull request

If you need help, please check our contributing guidelines.

@dd-octo-sts dd-octo-sts Bot added the tag: ai generated Largely based on code generated by an AI or LLM label May 27, 2026
@dougqh
dougqh requested review from PerfectSlayer and mcculls May 27, 2026 18:46

@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: 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".

Comment thread dd-java-agent/instrumentation/okhttp/okhttp-3.0/build.gradle
@dd-octo-sts

dd-octo-sts Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.94 s 13.91 s [-0.6%; +1.0%] (no difference)
startup:insecure-bank:tracing:Agent 12.96 s 12.98 s [-0.9%; +0.7%] (no difference)
startup:petclinic:appsec:Agent 16.82 s 16.61 s [+0.3%; +2.2%] (maybe worse)
startup:petclinic:iast:Agent 16.82 s 16.89 s [-1.2%; +0.4%] (no difference)
startup:petclinic:profiling:Agent 16.85 s 16.95 s [-1.6%; +0.5%] (no difference)
startup:petclinic:sca:Agent 16.86 s 16.76 s [-0.4%; +1.6%] (no difference)
startup:petclinic:tracing:Agent 15.98 s 15.98 s [-1.3%; +1.2%] (no difference)

Commit: 664f90c9 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@dougqh dougqh added type: bug fix Bug fix inst: okhttp Square OkHttp instrumentation labels May 27, 2026
@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented May 28, 2026

Copy link
Copy Markdown

BitsAI applied the CI fix

The fix has been applied to this PR without validation.

Progress

🟢 Investigated · 🟢 Fix prepared · ⚪ Validation skipped · 🟢 Applied


Commit: 8f5eb8a

View session in Datadog

…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>
@dougqh
dougqh force-pushed the fix/okhttp-virtual-thread-dispatcher-trace-contamination branch from eaf3d86 to 58311a9 Compare May 29, 2026 03:59

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

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

Comment thread dd-java-agent/instrumentation/okhttp/okhttp-3.0/build.gradle
Comment thread dd-java-agent/instrumentation/okhttp/okhttp-3.0/build.gradle
public String[] knownMatchingTypes() {
return new String[] {
"okhttp3.RealCall$AsyncCall", // OkHttp 3.x
"okhttp3.internal.connection.RealCall$AsyncCall", // OkHttp 4.x

@amarziali amarziali May 29, 2026

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.

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 {

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.

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

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.

This advice is instead InstrumenterModule.ContextTracking given its purpose

@dougqh

dougqh commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Note: this reply was written by Claude (Claude Code) on @dougqh's behalf.

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 AsyncCall capture by major version and added a dedicated okhttp-4.0 module that actually runs the regression against newer OkHttp:

  • okhttp-3.0 keeps the v3 type (okhttp3.RealCall$AsyncCall).
  • okhttp-4.0 matches the relocated v4+ type (okhttp3.internal.connection.RealCall$AsyncCall).
  • The new module runs the same virtual-thread regression suite against OkHttp 4.12.0 and 5.4.0. (OkHttp 5.x is now Kotlin-Multiplatform — com.squareup.okhttp3:okhttp is a metadata shell and the JVM classes ship in okhttp-jvm, which Gradle resolves automatically via variant metadata; the matched type is at the same FQN in both, so one instrumentation covers both majors.)
  • Muzzle exercises the module across 4.0.0 → 5.4.0.

I confirmed the suite genuinely exercises the fix on both 4.x and 5.x by neutralizing the advice and watching concurrentVirtualThreadPerTaskDispatcher_keepsEachTraceSeparate fail (cross-trace contamination), then pass once restored.

"review what's tested" (2 of 3 stayed green with the advice disabled)

Correct — only the concurrent test exercises the contamination path. Dropped the cachedThreadPoolDispatcher_… case (redundant with OkHttp3AsyncTest), kept the concurrent regression test plus one documented single-shot baseline, and updated the class javadoc to make the distinction explicit.

InstrumenterModule.ContextTracking (AsyncCallInstrumentation:34)

Done — both the v3 and v4 instrumentations now extend ContextTracking, matching RunnableInstrumentation (the consumer of the state they write). Their job is pure scope propagation through the shared ContextStore<Runnable, State>, not span creation.

testJvmConstraints instead of a launcher override (build.gradle:33)

Done. Also root-caused the failing CI shards: 25/tip are JDK 25/26, which are above java-concurrent-21.0's maxJavaVersion=24 — and these suites depend on that module's virtual-thread propagation. Added maxJavaVersion = JavaVersion.VERSION_24 so the suites skip on 25/26 (where the propagation chain isn't supported) and run on 21–24. That resolves the TimeoutExceptions.

Separate suite vs test (build.gradle:19)

I kept the dedicated JDK-21 suite. The test suite here has a Java 1.8 baseline (the legacy Groovy tests), so folding a JDK-21-only Java test into it would gate all okhttp tests behind JDK 21 and drop coverage on the 8/11/17 shards. A separate suite + testJvmConstraints + dependsOn check is also the established pattern elsewhere in the repo (e.g. grizzly-client-1.9, aerospike-4.0, java-concurrent-21.0). Happy to revisit if you'd prefer a different split.

….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>
@dougqh
dougqh requested a review from a team as a code owner June 9, 2026 21:10
@mcculls
mcculls requested a review from amarziali June 11, 2026 10:12
@mcculls

mcculls commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

FYI, metadata/supported-configurations.json needs updating with the new okhttp-4 integration name

Basically add

    "DD_TRACE_OKHTTP_4_ENABLED": [
      {
        "version": "A",
        "type": "boolean",
        "default": "true",
        "aliases": ["DD_TRACE_INTEGRATION_OKHTTP_4_ENABLED", "DD_INTEGRATION_OKHTTP_4_ENABLED"]
      }
    ],

@mcculls

mcculls commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Also merging in latest master should fix the current CI failures

dougqh and others added 2 commits June 11, 2026 12:22
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>
@DataDog DataDog deleted a comment from datadog-prod-us1-4 Bot Jun 23, 2026
@dougqh

dougqh commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

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

@amarziali
Yes, the 2 of 3 tests staying green is expected. Unfortunately, since this is a context interaction between instrumentations, it doesn’t divide cleanly into either one.

The single-shot baseline doesn’t queue, so it doesn’t hit the path being fixed.
Calls only get promoted onto sibling thread when there’s sufficient contention, so it is hard to capture in a simple unit test.

I think the other points have all now been addressed…

  • Advice is now an InstrumenterModule.ContextTracking - I didn’t know we’d added that concept
  • okhttp 4 is now its own module - with its own v-thread test
  • config flag has been added to supported-configurations.json

@bric3 bric3 changed the title fix(okhttp): capture scope at AsyncCall.<init> to keep traces intact under virtual-thread Dispatcher fix(okhttp): capture scope at AsyncCall.<init> to keep traces intact under virtual-thread Dispatcher Jun 24, 2026

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

@dougqh overall looks ok but I left few things I'd like to discuss/review with you before approving

pass {
group = "com.squareup.okhttp3"
module = "okhttp"
versions = "[3.0,)"

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

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

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.

btw why it hangs for >=25 ? Are we missing something? Our virtual thread instrumentation is supposed to work on 25

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 ⚠️ — that's what the cap actually guards, and I've corrected the comment to say so. Making the suite stable on 25+ (a more robust latch/timeout) is separate follow-up.

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.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

dougqh and others added 3 commits June 24, 2026 09:37
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

inst: okhttp Square OkHttp instrumentation tag: ai generated Largely based on code generated by an AI or LLM type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants