Skip to content

Fix jax-rs/jakarta-rs span double-finish on synchronous AsyncResponse#resume() - #12637

Draft
Yeison2020 wants to merge 2 commits into
masterfrom
yeison.casado/gh-12597-jakarta-rs-scope
Draft

Yeison2020 wants to merge 2 commits into
masterfrom
yeison.casado/gh-12597-jakarta-rs-scope

Conversation

@Yeison2020

@Yeison2020 Yeison2020 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What's the problem?

JAX-RS lets a resource method accept a @Suspended AsyncResponse and call resume() on it synchronously, mid-method, without ever really suspending. That's spec-legal and it's what real applications do (including our own pre-existing test for this).

When that happens, two separate pieces of tracer code each thought they were the one responsible for finishing the span:

  1. The AsyncResponse#resume()/cancel() advice finished the span the instant resume() was called — even though the resource method was still running.
  2. The resource method's own "on exit" advice finished the same span again once the method actually returned.

Between those two points, any work the method did (logging, extra processing, nested spans) got attached to a span that Datadog already considered finished. In a busy production app (this surfaced via TomEE + CXF), that's the kind of "phantom activity under an already-finished span" that can eventually corrupt the tracer's internal scope bookkeeping on a reused worker thread — reported as #12597.

What's the fix?

When resume()/cancel() is called, check whether we're still, right now, nested inside the resource method that owns this span (as opposed to being called later, from a different thread, which is the normal async pattern). If we are, don't finish the span here — let the resource method's own exit advice do it, exactly once, once the method actually returns.

That "are we still nested inside it" check needs two things, not one:

  • Is this span still the active one on this thread?
  • Is this thread currently executing inside a resource-method call at all?

Both are needed together — the first question alone gives a false positive whenever the tracer's own executor instrumentation has propagated this span's context onto a background worker thread for genuinely asynchronous work, which would otherwise look identical from that check alone.

The genuinely-asynchronous case (resume() called later, from a different thread — the standard, intended pattern) is completely unaffected by this change.

How was this verified?

  • Added 3 new regression tests alongside the existing one, covering: synchronous resume(), synchronous cancel(), and a real cross-thread resume via a background executor (the case that specifically validates the fix doesn't regress the normal async pattern).
  • Enabled the tracer's own "span finished more than once" detector for this test class as a standing guard against this exact bug recurring.
  • Ran the full test suites for every module touched, plus the dedicated muzzle compatibility check across the full supported javax.ws.rs/jakarta.ws.rs version matrix.
  • Re-verified end-to-end against a real embedded Tomcat + CXF application with a freshly built agent jar: before the fix, 6 requests produced 6 "span finished twice" events; after the fix, 0.
  • Reviewed for tech debt and performance overhead; both came back clean — the added per-thread counter is a cheap, bounded, allocate-once-per-thread mechanism, not a per-request cost.

…#resume()

AsyncResponse#resume()/cancel() finished the span immediately, even when
called synchronously from within the still-running resource method that
owns it. The resource method's own exit advice then finished the same
span again once it actually returned, and any work done in between was
attributed to an already-finished span. Defer to the resource method's
own exit advice whenever resume()/cancel() is nested inside it; a
per-thread reentrancy counter distinguishes that case from a genuine
cross-thread resume, which is unaffected.

Fixes #12597.
@Yeison2020 Yeison2020 added inst: jax-rs JAX-RS instrumentation type: bug fix Bug fix tag: ai generated Largely based on code generated by an AI or LLM labels Sep 24, 2026
@datadog-datadog-prod-us1

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

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
• Patch Coverage: 0.00%
• Overall Coverage: 59.25% (+0.11%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 1a62c3a | Docs | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Sep 24, 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.96 s 13.96 s [-0.7%; +0.8%] (no difference)
startup:insecure-bank:tracing:Agent 12.88 s 13.01 s [-1.6%; -0.3%] (maybe better)
startup:petclinic:appsec:Agent 17.06 s 16.31 s [+0.2%; +8.9%] (maybe worse)
startup:petclinic:iast:Agent 17.00 s 16.89 s [-0.1%; +1.4%] (no difference)
startup:petclinic:profiling:Agent 16.72 s 16.83 s [-1.7%; +0.5%] (no difference)
startup:petclinic:sca:Agent 16.99 s 16.89 s [-0.4%; +1.6%] (no difference)
startup:petclinic:tracing:Agent 16.08 s 16.17 s [-1.6%; +0.4%] (no difference)

Commit: 1a62c3ae · 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.

…ead counter

Independent code review found the activeSpan()+counter check from the
previous commit had three real gaps: it broke when resume()/cancel() was
called from a nested @trace helper (activeSpan() became the helper's
span, not the resource method's), it could misclassify a genuinely-async
resume as synchronous on a shared thread pool (leaking the span instead
of double-finishing it), and the counter lived on a per-classloader
helper class, so a modular container loading the two advices into
different classloaders would silently make the whole check a no-op.

Replace the counter with ResourceMethodSpanTracker, a small
bootstrap-loaded class (visible across all classloaders, like the
existing CallDepthThreadLocalMap) holding a per-thread stack of the
actual span references for currently-open resource-method invocations.
The check becomes a direct "is this span still the innermost open
invocation on this thread", with no activeSpan() comparison needed.

Also: clear the stale AsyncResponse->span mapping on the resource
method's throwable exit path (previously only done on the normal path),
and add jakarta.ws.rs test coverage for this fix, which had none despite
being duplicated into that module.
@Yeison2020 Yeison2020 added the tag: override groovy enforcement Override the "Enforce Groovy Migration" check label Sep 28, 2026
@Yeison2020

Copy link
Copy Markdown
Contributor Author

Adding `tag: override groovy enforcement` for the one new file this flags: `JakartaRsAsyncResponseInstrumentationTest.groovy`.

This test needs `InstrumentationSpecification` (the `assertTraces`/`enabledFinishTimingChecks()` DSL that does real ByteBuddy transformation and asserts on actual span/scope lifecycle) to verify this fix at all — a plain unit test calling the advice method directly wouldn't exercise the actual bug (which is about span/scope timing across two separate advices). `InstrumentationSpecification` extends Spock's `DDSpecification`, and there's currently no JUnit5 equivalent in this codebase for that level of instrumentation testing, so this can't be mechanically converted without losing the verification it exists to provide.

Context: this test closes a real coverage gap an earlier code-review pass on this PR found — the jakarta.ws.rs path (this fix touches both javax.ws.rs and jakarta.ws.rs, mirrored) had zero automated regression coverage for the exact bug being fixed. The javax.ws.rs path's equivalent coverage lives in the pre-existing `CxfContextPropagationTest.groovy` (modified, not new, so it doesn't trip this check).

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

inst: jax-rs JAX-RS instrumentation tag: ai generated Largely based on code generated by an AI or LLM tag: override groovy enforcement Override the "Enforce Groovy Migration" check type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant