Skip to content

Fix Kotlin coroutines losing scopes when resumed on another thread - #12864

Open
mcculls wants to merge 26 commits into
masterfrom
mcculls/kotlin-shared-scope-stack
Open

mcculls wants to merge 26 commits into
masterfrom
mcculls/kotlin-shared-scope-stack

Conversation

@mcculls

@mcculls mcculls commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Stops Kotlin coroutines from losing Datadog scopes when they suspend on one thread and resume on another, with the legacy scope manager (the default).

In legacy mode each coroutine with its own Datadog element now uses one persistent scope stack. Whichever worker thread runs it swaps that same stack in to use for their work, so nothing is copied between workers and nothing is written back afterwards. Scoped coroutines can share their caller's element and stack.

  • DatadogThreadContextElement captures a handle to a new SharedScopeStack during active-coroutine construction or lazy start, with a first-update fallback. updateThreadContext swaps it in; restoreThreadContext swaps the worker's own stack back and, in legacy mode, writes nothing back into the element.
  • Builder calls instrumented through newCoroutineContext get a fresh element. A context passed to launch/async that already holds a parent's element (e.g. launch(coroutineContext)) no longer makes independent parent and child coroutines share one element. Scoped coroutines such as coroutineScope and withContext can still share their caller's element. The fresh element starts from the parent's current context and keeps the old element's position in the context.
  • The shared-stack path applies only to the legacy context manager. Fresh-element replacement and the trace-continuation optimization work in both modes.

How Kotlin hands a coroutine between threads, and why scopes got lost

ThreadContextElement is the kotlinx hook we use:

  • updateThreadContext(ctx) runs on a worker just before it runs a slice of the coroutine (up to its next suspension).
  • restoreThreadContext(ctx, oldState) runs on that same worker after the slice ends.

On master the element works in two steps:

  1. update swaps the coroutine's saved context onto the worker. Context.swap() returns the worker's previous context.
  2. restore swaps the worker's context back and saves what was current into the element (context = original.swap()), so scopes opened during the slice carry over to the next slice.

This assumes slice N's restore always finishes before slice N+1's update starts. kotlinx doesn't guarantee that.

  • A coroutine suspends by registering its continuation with something (a timer, a channel, a Deferred, a select) and returning COROUTINE_SUSPENDED.
  • From the moment the continuation is registered, any thread can resume it, and kotlinx dispatches it straight away. Worker A may still be unwinding: it hasn't yet run restoreThreadContext.

So worker B can run updateThreadContext before A has saved the stack:

worker A                                  worker B
--------                                  --------
update: swap in coroutine stack S0
coroutine opens scope X (stack S1)
coroutine suspends; continuation
registered                    ---------> resumed immediately
                                          update: swap in element.context == S0 (stale: no X)
                                          coroutine expects X ... lost
restore: element.context = S1
         (overwrites whatever B saves)

Two more effects of the legacy scope manager make this worse:

  • Shallow copies. In legacy mode a context is a mutable, thread-local ScopeStack. When the saved context is restored on a different thread, ScopeContext.restore() builds a shallow copy holding only the top two scopes. Deeper stacks lose their base context on every hop between threads.
  • Shared elements. With launch(parentContext) the child reused the parent's element, so two coroutines that run at the same time read and write the same saved context. In the benchmark harness, master loses the expected span on essentially every resumption in that mode.

The overlap is part of the ThreadContextElement contract; it does not by itself imply a bug in every element. Elements that install a fixed immutable context, such as OTel's KotlinContextElement, do not depend on publishing mutations during restoration. The mutable legacy scope stack is the part this shared-stack design addresses.

The approach in detail

1. One scope stack per coroutine (SharedScopeStack)

  • Workers swap the same SharedScopeStack object into their thread-local, by reference. B sees everything A's coroutine code did before suspending, because kotlinx's own resume already orders those writes before B runs. No suspension hooks and no copies are needed, and the stack keeps its full depth.
  • A and B can only touch the stack at the same time during the overlap window shown above. This matters when other elements (e.g. OTel's element through our shim) close scopes on A during A's restore. So:
    • writes (push, cleanup, clear, and the iteration-scope checks) are synchronized;
    • the frequent reads (active(), top(), Context.current(), checkTop) read a volatile liveTop and take no lock;
    • liveDepth is a plain int written just before liveTop, so a reader that reads liveTop first also sees its depth. It's only used to enforce the depth limit, so a slightly stale value is fine.
  • SharedScopeStack has its own copies of push and cleanup, which publish the new top before the activation listeners run. Listeners such as MDC log correlation read the active span from afterScopeActivated.

2. Swapping without disturbing code outside coroutines

  • ScopeStack becomes non-final and gains top(), swappedOut(), replacing() and swappedOutForSameThread().
  • swap() uses double dispatch (newStack.replacing(oldStack)) rather than an instanceof:
    • Swapping a plain stack still returns a new SavedScopeContext (renamed from ScopeContext), exactly as before.
    • Swapping a shared stack in returns the worker's reusable ThreadScopeContext. It takes no snapshot, so no allocation is needed per slice.
  • Before SharedScopeStack is loaded, HotSpot can inline the stack calls using class hierarchy analysis. After it is loaded, the measured HotSpot runs still inline the ordinary-stack path using a receiver type profile. Ordinary scopes also gain a shared flag check; the measured cost is described below rather than assumed to be zero.

3. Scope lifetimes on a shared stack

  • Scopes pushed onto, or seeded into, a shared stack are marked shared. The flag fits in existing padding, so ContinuableScope stays at 32 B.
  • Shared scopes change their reference count under their own monitor: reusing the top scope, closing, and clearing overdue iteration scopes. That holds whichever stack the closing thread has swapped in, e.g. when a context element restored after ours closes a scope on the worker's own stack.
  • Reusing a shared scope fails once its count has reached 0, so a closed scope can never be revived.
  • Plain scopes keep the plain unlocked ++/--.

4. Fresh element per coroutine, in the same position

  • addDatadogElement creates a new element for each instrumented builder call. When the supplied context already has one, the new element is seeded from that element's current context. In legacy mode that context is a handle to the parent's live stack; the child initializes its own stack from the current top.
  • It replaces the old element in place, using fold, so elements added after it can still update scopes on top of ours.

5. Avoid a redundant continuation when the parent already keeps the trace open

  • When a builder is supplied a context containing a parent's Datadog element, the fresh element can inherit evidence that the parent keeps the trace open. It skips capturing its own trace continuation only when both of these hold:
    • the Job in the supplied builder context is the parent element's owner, or a scoped coroutine (coroutineScope, withContext, withTimeout, ...) sharing the parent's element;
    • it's in the same trace that the parent keeps open.
  • The parent can't complete before its structured children, so its own continuation already keeps the trace open. This saves a continuation and its reference counting for eligible children. Ordinary launch {} / async {} calls whose supplied argument lacks an inherited Datadog element still capture their own continuation when needed.
  • The seed and the parent's trace are cleared once captured, so a long-lived child doesn't keep its parent's stack or trace alive.

6. One new AgentTracer method

  • @Deprecated Context sharedContextStack(Context initial). The noop tracer returns initial. CoreTracer delegates to the scope manager and throws in non-legacy mode, like closePrevious/activateNext.

Removal once the legacy scope manager is gone

Delete:

  • SharedScopeStack, SharedScopeContext, ThreadScopeContext and ContinuableScopeManager.sharedContextStack;
  • AgentTracer.sharedContextStack, with its noop and CoreTracer versions;
  • the element's LEGACY_CONTEXT_MANAGER branches;
  • the shared flag and the retainShared/releaseShared/clearShared methods on ContinuableScope.

The non-final ScopeStack, ScopeStackContext and SavedScopeContext go away with the rest of the legacy package. The fresh element and the structured-child skip work in either mode, so they stay.

Tests

dd-trace-core, SharedScopeStackTest (JUnit 5)

  • A scope opened on one thread is visible, and can be closed, on another.
  • Seeding with the root context starts with an empty stack.
  • Seeding from another shared context starts from its current top.
  • A scope closed elsewhere is removed when the stack is next swapped in.
  • Concurrent use: two threads attaching and closing on one stack, or reusing the same scope.
  • A scope closed from a thread with another stack swapped in, while it's being reused.
  • A cleared iteration scope is never reused by another thread.
  • After nested swaps a thread gets back its own stack, and it reuses one token for that stack.
  • Listeners see the scope being activated, both for a new child and for the parent when it's reactivated.

kotlin-coroutines-1.3

  • StandardSuspensionHandoffTest, run on kotlinx 1.3 and on latestDepTest, which now runs on kotlinx 1.8. Scenarios:
    • suspension via cancellable, safe, yield, delay and select, with cancellation and nesting;
    • child waits via coroutineScope, supervisorScope, withContext and withTimeout, on return, throw and cancel;
    • early dispatcher changes, and dispatcher switches;
    • nested withContext;
    • isolation of child scopes from the parent;
    • restoration-token replay (1.8);
    • scope-touching ThreadContextElements (1.8). The coroutine must always keep its base context. During the overlap window, other elements can briefly see each other's scopes, so restore-order violations are reported but allowed.
  • Structured-child features in AbstractKotlinCoroutineInstrumentationTest and CoreKotlinCoroutineTests:
    • children outlive the seed span;
    • children of scoped coroutines;
    • a child of an untraced coroutine;
    • a child with a replaced Job / SupervisorJob;
    • a child of a cancelled coroutine.
  • AbstractDatadogThreadContextElementTest (@TableTest): replacing the element keeps the context's contents and the order of ThreadContextElement callbacks, including with a dispatcher present and with custom context implementations.

Motivation

Customers using Kotlin coroutines see spans with the wrong parent, or detached traces, once code suspends while a manual or instrumented span is active. Earlier fixes moved the window around rather than closing it. This approach removes the race in legacy mode, without depending on kotlinx internals.

Benchmark summary

Structured request workload versus andrea.marziali/kotlin

The clearest result is about 41% less agent-added allocation, or about 2.7 KiB saved per request. That is a 3.4% reduction in total application allocation in this workload. Total CPU medians are 2–6% lower, but the CPU-focused runs overlap, so these measurements do not establish a reliable CPU speedup.

Both implementations were built on the same upstream base (d95a6869d8): this branch at f2d1bfd9d0, and Andrea's changes at 2c6da9faad applied to that base. The no-agent baseline executes the same workload with noop tracing calls.

Workload Andrea CPU overhead This branch CPU overhead Andrea extra allocation This branch extra allocation
Simulated backend waits +23.4% +15.8% 6.51 KiB/request 3.82 KiB/request
CPU-focused +26.0% +23.5% 6.47 KiB/request 3.81 KiB/request

CPU overhead and extra allocation are relative to the no-agent baseline. CPU is whole-process CPU time, including agent and GC threads; it is not wall-clock latency. Allocation is whole-process allocated bytes, not retained heap size.

  • Workload: 64 concurrent requests on eight workers. Each request creates a request span and two ordinary structured async children; each child parses data and suspends twice. The supplied builder arguments do not explicitly reuse coroutineContext.
  • Environment: Apple M1 Max, Zulu JDK 17, Kotlin 1.9.21, kotlinx-coroutines 1.8.1, 512 MiB heap.
  • Runs: three independent JVM runs per variant for backend waits, six for CPU-focused work. Each run warms up for 12 rounds and measures 12 rounds of 8,192 requests; variant order is rotated. Values are medians of per-JVM medians, measured with the management beans' process CPU and total allocated-byte counters.
  • Limits: this is a synthetic backend workload, not a full Ktor application. Backend waits use delay(1); the CPU-focused variant uses yield(). There is no HTTP framework or real backend I/O. Trace sampling is 100%, and both agents point at an unavailable local collector. Profiling, telemetry, remote configuration, JMX collection and health metrics are disabled.
  • CPU variability: per-JVM CPU-focused medians were 54.9–61.1 µs/request for Andrea and 54.9–58.6 µs/request here. One backend-wait run here was unusually low (90.5 µs/request versus approximately 149 µs/request in the other two), so the allocation result is stronger evidence than the CPU comparison.
  • All runs produced matching application checksums, created real spans in the traced variants, and observed no loss of the active dependency span after suspension.
Earlier JMH and coroutine-handoff measurements

Earlier JMH and coroutine-handoff measurements

All measured locally (Apple M1 Max), JMH with -prof gc plus an end-to-end Kotlin harness under -javaagent. JDK 25, this branch's tip against master at its base (d95a6869d8), unless stated.

Code outside coroutines (18 JMH benchmarks: scope lifecycle, context manager, depth, virtual threads; 3 forks x 2 rounds)

Comparison Geomean (range) Allocation
master vs itself (round 2 vs round 1, i.e. noise) 0.996 (0.91–1.06) n/a
branch vs master 0.986 (0.92–1.02) identical
SharedScopeStack loaded but unused vs not loaded 0.997 (0.98–1.03) identical
  • A follow-up A/B of the slowest rows (9 forks each, master vs branch vs branch without the shared check) puts the only real cost on activateAndClose. It's about 3.7% slower, roughly 0.45 ns per activate + close, from the shared flag check on the plain close path. attachAndClose and activateSameSpan are within fork spread.
  • JIT logs confirm the ScopeStack calls inline directly while the subclass isn't loaded, and behind a type-profile check once it's loaded.

Shared stack primitives (ns/op)

Plain stack Shared stack
activate + close 23.7 32.4
nested activate + close 46.6 65.9
swap in + out (one slice of a coroutine) 16.0 (32 B) 10.5 (0 B)
active() / Context.current() 2.5 / 2.8 3.8 / 3.8
one hop between workers, vs master's element 235 ns, 208 B 278 ns, 32 B

End to end (20k coroutines per round, 15 + 15 rounds, K suspensions each, 3 reps; kotlinx 1.3 and 1.8; fixed pool and Dispatchers.Default; 12 cells per launch style)

Launch style Throughput vs master (geomean, range) Bytes per coroutine Lost spans: master → branch
plain launch 1.007 (0.91–1.15) lower in 12/12 cells; K=16: 2.6–3.1 KB vs 3.7–6.2 KB up to 277 → 0
launch(parentContext), root span opened outside 0.997 (0.88–1.10) lower in 10/12 cells; K=16: 2.7–3.2 KB vs 4.5–5.7 KB ~every resumption (9.6M) → 0

The branch is fastest where suspensions are frequent: up to 1.15x on the fixed pool at K=16. The two cells where it uses slightly more bytes are K=1 on the fixed pool with parent-context launches (+50–90 B); there, master shares one element across all its coroutines.

From earlier runs on this branch (before the final scope-lifetime changes)

  • JDK 8 and 17: same picture as JDK 25. The only outlier is JDK 8 with launch(parentContext) on Dispatchers.Default at K=1, in short runs. About half of it is biased-lock revocation on the new per-coroutine stacks; the rest is JIT warm-up.
  • Structured-child skip: about 7% more throughput with parent-context launches (15–19% on Dispatchers.Default with one suspension), measured against the branch without it.

Additional Notes

Other approaches we tried or considered

Copying the stack at suspension (mcculls/kotlin-investigation)

  • How: advice on 7 kotlinx internal types copies the coroutine's stack into the element at the point the suspension becomes visible, before another worker can resume it.
  • Pros: works with the existing element model.
  • Cons:
    • brittle across kotlinx versions, and misses custom suspension primitives;
    • needs an activation claim/restore protocol, and a separate element per withContext block;
    • still loses the base context through the shallow copy, whenever other elements touch Datadog scopes.

Reading the previous worker's stack directly (mcculls/kotlin-live-context-stack, mcculls/kotlin-live-context-per-block)

  • How: worker B reads A's live stack instead of a saved copy.
  • Pros: no copy, no suspension hooks.
  • Cons:
    • A may still be writing that stack while B reads it, which the Java memory model doesn't allow without synchronization nobody owns;
    • also needs the claim/restore protocol and a per-block element.

Snapshots published before suspension (andrea.marziali/kotlin)

  • How: advice on several kotlinx suspension classes publishes a snapshot of the stack through exchange objects. Every suspension does two stack-copying swaps.

  • Pros: fixes plain launches (0 lost spans); doesn't touch the core scope manager.

  • Cons:

    • depends on matching kotlinx implementation classes, and explicitly excludes custom primitives that bypass them;
    • still reuses the parent's element for launch(parentContext), so it loses spans almost as often as master there (up to 9.5M);
    • allocates the most: up to about 10 KB per coroutine at 16 suspensions, against about 3 KB here.
  • Measured against an earlier tip of this branch (geomean throughput, this branch vs andrea.marziali/kotlin):

    Throughput Memory per coroutine
    apps without Kotlin 1.006, within noise identical
    plain launch 1.06× 1.5 KB less
    launch(parentContext) 1.14× 1.2 KB less

Inside this design

Alternative Why not
A "shared" flag on ScopeStack, instead of a subclass Adds a branch to every stack method on every thread, including code outside coroutines. The subclass lets HotSpot inline the ordinary-stack methods while the shared implementation remains unloaded.
Every shared-stack method synchronized, reads included Measured: the reads behind activeSpan() and Context.current() got noticeably slower. A volatile top gives lock-free reads that are still safe.
Keeping the inherited element for launch(coroutineContext) Two coroutines running at the same time would share one stack.
The scope holds a reference to its owning stack, to lock it on close +8 B per scope for every app (32 → 40 B).
Atomic (CAS) reference counts A CAS on every close for every app. The shared flag fits in padding and only shared scopes lock.
Encoding "shared" into the reference count (negative counts, or counting in steps of 2 with the low bit as the marker) Prototyped and measured: the same 32 B layout, and it removes about 0.4 ns from activateAndClose in JMH. No measurable end-to-end difference: an app would need tens of millions of activate/close pairs per second per core to see 1% CPU. The separate flag keeps the counting code easier to read.
Locking in alive() The unlocked read is a best-effort cleanup check. A shared scope cannot be retained once closed, so a stale read can delay removal without removing a live scope. Cleanup retries during later stack operations and swaps; the unlocked read does not guarantee removal on the very next swap.
Switching to the non-legacy context manager This is the long-term fix: its contexts are immutable, like OTel's. It isn't ready to be the default yet. Everything legacy-specific here is designed to be deleted with the legacy manager.

Known behaviour

  • Scope-touching elements during the overlap window. With ThreadContextElements that open and close Datadog scopes themselves (e.g. OTel's element via our shim), worker B can briefly see A's element scope until A closes it. A's element may also record "scope not on top at restore", which only increments a health metric. The coroutine's own context is never lost. Removing this entirely would mean making B wait for A.
  • Scopes closed from another stack. A shared scope closed from a thread that has a different stack swapped in stays, closed, on top of its owning stack until that stack is next cleaned up or swapped in.
  • Duplicate spans with @Trace. Using @Trace in Kotlin can produce duplicate spans. That's a separate, pre-existing issue, not addressed here.

Contributor Checklist

🤖 Generated with Claude Code

@mcculls mcculls added type: bug fix Bug fix comp: core Tracer core inst: kotlin Kotlin instrumentation tag: ai generated Largely based on code generated by an AI or LLM labels Oct 14, 2026
@datadog-datadog-us1-prod

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 14, 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.97 s 13.96 s [-0.8%; +1.0%] (no difference)
startup:insecure-bank:tracing:Agent 12.83 s 12.94 s [-1.5%; -0.3%] (maybe better)
startup:petclinic:appsec:Agent 17.09 s 17.00 s [-0.4%; +1.4%] (no difference)
startup:petclinic:iast:Agent 16.45 s 17.01 s [-7.5%; +0.9%] (no difference)
startup:petclinic:profiling:Agent 16.15 s 16.68 s [-7.5%; +1.2%] (no difference)
startup:petclinic:sca:Agent 16.61 s 16.91 s [-7.2%; +3.7%] (unstable)
startup:petclinic:tracing:Agent 16.02 s 16.20 s [-1.9%; -0.3%] (maybe better)

Commit: 3efdc695 · 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.

@mcculls mcculls added the tag: override groovy enforcement Override the "Enforce Groovy Migration" check label Oct 14, 2026
mcculls and others added 23 commits October 14, 2026 12:19
… 1.8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In legacy mode, every worker that runs a coroutine now swaps in the same
coroutine-owned scope stack, instead of writing a copy back on restore. A
resumed coroutine no longer depends on its previous worker finishing its
restore first, and the stack keeps its full depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swapping out a shared stack now returns its existing handle instead of
building a snapshot that takes the stack's lock, and swapping it in only
takes the lock when a closed scope needs to be removed from the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A coroutine launched with its parent's job is a structured child, which
completes before its parent. When the parent already keeps the child's
trace open, the child no longer captures its own continuation, avoiding
contended updates of the trace's pending reference count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coroutines built by coroutineScope, withContext and similar share their
caller's element and complete before it, so children launched with their
job are covered by the same trace continuation as the caller's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SharedScopeContext no longer extends the saved-stack snapshot, so it holds
just its stack. Both now extend ScopeStackContext, which is what swap()
checks for, and ScopeContext is renamed SavedScopeContext and final again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checkTop() and depth() only feed the close-error metric and the depth limit,
and cleanup re-checks the stack under the lock, so they now read volatile
copies kept up to date by the locked updates. Activating and closing a scope
on a coroutine's stack takes one lock each instead of two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…routine

When a coroutine's shared stack replaces a thread's stack, the coroutine
always swaps it back on the same thread, so swap() now returns a token
cached on that stack instead of a new snapshot on every activation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The seed and the parent's trace are only needed until the child captures
its initial context, so clear them then; otherwise a long-lived child keeps
its parent's scope stack and trace alive. Also explain why legacy mode
doesn't save the coroutine's context on restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The depth is now a plain field written just before the volatile top, so
readers that read the top first also see its depth, saving a volatile
store on every push and cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check for a kept-open trace before inspecting the child's job, read the
element's context once per activation, and split the capture branches by
whether there is a trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Listeners such as the MDC log-correlation listener read the active span
from afterScopeActivated. The shared stack only updated its volatile top
after the inherited push/cleanup returned, so listeners saw the parent
when a child was activated, and the closed child when the parent was
reactivated. Copy push and cleanup into SharedScopeStack so the new top
is published first; ScopeStack keeps its code unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reusing the top scope incremented its plain reference count outside any
lock, and closing a scope decremented it the same way. While two workers
briefly overlap on a coroutine's shared stack this could lose updates and
remove a scope early, or retain a scope another thread had just removed.

Route both through ScopeStack.retainTop/releaseScope. The plain stack keeps
today's unlocked updates, inlined while it's the only implementation; the
shared stack updates the count under its lock, and only retains a top scope
that is still on top and alive, otherwise a new scope is pushed instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a scope released it through whichever stack the closing thread
had swapped in. A scope from a coroutine's shared stack closed after
swapping away (such as by a context element restored after ours) skipped
the owning stack's lock, so its count could still race with reuse there.

Mark scopes when they're pushed onto a shared stack, and have marked
scopes update their count under their own monitor, wherever they're
closed. Reuse checks the scope is still alive under that monitor, which
also makes the stack-level retainTop/releaseScope unnecessary, so they're
removed. The flag fits in existing padding; scopes stay at 32 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishing overdue iteration scopes cleared their reference count outside
the scope's monitor, so another thread retaining a shared scope at the
same time could bring it back after it was cleared. Clear shared scopes
under their monitor, like the other reference count changes.

Also explain why alive() can read a shared scope's count without the
lock: once it reaches zero it never rises again, so a stale read only
delays removing a scope closed on another thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mcculls mcculls added type: feature Enhancements and improvements and removed type: bug fix Bug fix labels Oct 14, 2026
@mcculls
mcculls marked this pull request as ready for review October 14, 2026 13:16
@mcculls
mcculls requested review from a team as code owners October 14, 2026 13:16
@mcculls
mcculls requested review from ValentinZakharov, bric3 and cecile75 and removed request for a team October 14, 2026 13:16
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T13:21:45.125790Z 0c7bb96 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@datadog-datadog-us1-prod datadog-datadog-us1-prod 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.

Bits Code Review: PASS

More details

The completed static review raised no reportable defect in coroutine stack ownership or trace-continuation lifetimes. Runtime handoff and concurrency behavior remain unverified.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit 0c7bb96

@dougqh

dougqh commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

I think I was able to follow what the code is doing, but the flow is hard to follow. To a large extent, I think that's inherent to the problem, but I also find that we don't have agreed upon terms to describe what we're doing. I had Claude map seed, owner, trace kept open into my own terms, so I could understand what's happening here.

Since we're doing similar changes in a number of locations, I think it might be good to add some extra conveniences into the Context system to fit these situations.
That's not to say there's anything wrong with the Context system, I think what OTel offers is largely sufficient.
I just find that we keep having to compose operations in complicated ways that make the intent hard to determine.
I think having some more expressive "macros" would reduce the amount of code and make the code easier to read.

Claude was able to reproduce an issue around withContext (and likely coroutineScope).
In my terms, there's a bug with switching context vs resolving a context.
Switching is my term for going to another previously active context -- while not altering PendingTrace accounting.
Whereas resolve / cancel do alter the PendingTrace accounting.

The scenario...
When a scoped coroutine completes, cancelDatadogContext can release the owner's continuation while the owner is still running.
And since traceKeptOpen isn't cleared, a later launch(coroutineContext) from the owner can then skip its own continuation, because it is relying on a hold that's already been released.
I'll have Claude post the reproducer.

And to be clear, adding the "macros" doesn't have to be part of this PR, I just think reviewing will remain hard until we have some common terms for these things.

@dougqh

dougqh commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Reproducer for the scoped-coroutine issue above. With an empty withContext {} before a structured launch, the trace is written in two pieces ([root, seed], then [child] separately, same trace id). The same test without the withContext always passes.

Over four runs: Unconfined failed 4/4, IO 3/4, Default 1/4; the fixed and single-thread pools passed. So it's timing-dependent but reliable on Unconfined.

CoreKotlinCoroutineTests.kt:

/**
 * A child launched by its parent after a scoped coroutine in the parent has completed. The
 * parent is started under a root span that finishes before the child runs.
 */
open fun structuredChildAfterScopedCoroutine(withScoped: Boolean): Int {
  runTest {
    childSpan("root").activateAndUse {
      detached = launch(Job() + jobName("detached")) {
        childSpan("seed").activateAndUse {
          if (withScoped) {
            withContext(jobName("scoped")) {}
          }
          launch(coroutineContext + jobName("child")) {
            gate.await()
            childSpan("child").finish()
          }
        }
      }
    }
  }
  return 3
}

AbstractKotlinCoroutineInstrumentationTest.groovy:

def "child launched after a scoped coroutine keeps the trace open withScoped=#withScoped #dispatcherName"() {
  setup:
  CoreKotlinCoroutineTests kotlinTest = getCoreKotlinCoroutineTestsInstance(dispatcher)
  int expectedNumberOfSpans = kotlinTest.structuredChildAfterScopedCoroutine(withScoped)
  kotlinTest.finishDetached()
  TEST_WRITER.waitForTraces(1)
  List<DDSpan> trace = TEST_WRITER.get(0)

  expect:
  TEST_WRITER.size() == 1
  trace.size() == expectedNumberOfSpans
  findSpan(trace, "child").spanContext().getParentId() == findSpan(trace, "seed").spanContext().getSpanId()
  findSpan(trace, "seed").spanContext().getParentId() == findSpan(trace, "root").spanContext().getSpanId()

  where:
  [dispatcherName, dispatcher, withScoped] << [dispatchersToTest, [true, false]].combinations().collect { it[0] + [it[1]] }
}

Written by Claude, reviewed by @dougqh

@mcculls

mcculls commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@dougqh actually the majority of changes here have nothing to do with the Context component - it's all about making the legacy scope manager (and scope stacks) work with Kotlin suspension points. Much of the changes here will go away when we retire the legacy scope manager and switch over to the new context manager.

@mcculls

mcculls commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@dougqh @amarziali one option could be to declare that we cannot support Kotlin coroutines/suspensions properly with the legacy scope manager and instead pivot to replacing that as soon as we can with the new context manager.

@mcculls

mcculls commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

@dougqh @amarziali regarding the scoped-coroutine issue - I confirmed the issue and fixed it in f3f8dc8. Scoped coroutines share their caller’s Datadog element, so their completion must not release the owning coroutine’s continuation. Release now checks ownership, and owner completion also clears traceKeptOpen.

Note: this issue predates this PR: running the regression tests with the previous element implementation also reproduces premature trace splitting. The PR’s continuation optimization relied on that existing lifetime accounting being correct.

I also added coverage for a child launched after withContext, coroutineScope, or withTimeout completes, plus a control without a scoped coroutine. Both full suites pass on kotlinx 1.3 and 1.8.

}

@Nullable
private static AgentTraceCollector traceOf(Context context) {

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 that I've found the local root span id works equally well here for the local trace identity check, if we prefer not to rely on the collector itself.

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

looks OK for my pov

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

comp: core Tracer core inst: kotlin Kotlin 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: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants