From bde3da33ff945ba6fc2986a9de44ccd468d108a3 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 25 Jun 2026 12:16:45 +0200 Subject: [PATCH 1/5] feat(java-concurrent): Add instrumentation unit tests JUnit testing and trace assert make unit testing possible. --- .../java-concurrent-21.0/build.gradle | 7 +- .../groovy/StructuredConcurrencyTest.groovy | 2 +- .../java-concurrent-25.0/build.gradle | 30 ++++ .../StructuredTaskScope25Instrumentation.java | 14 +- .../StructuredTaskScope25Test.java | 140 ++++++++++++++++++ 5 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/build.gradle b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/build.gradle index d95e9432796..5a7b31fe8b1 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/build.gradle +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/build.gradle @@ -1,3 +1,6 @@ +import static org.gradle.api.JavaVersion.VERSION_1_8 +import static org.gradle.api.JavaVersion.VERSION_24 + plugins { id 'idea' } @@ -31,13 +34,13 @@ tasks.named("previewTest").configure { testJvmConstraints { // Structured concurrency is a preview feature in Java 21. Methods (e.g. ShutdownOnFailure) used in this instrumentation test are no longer available in Java 25, so we set the max version to 24. // See: https://download.java.net/java/early_access/loom/docs/api/java.base/java/util/concurrent/StructuredTaskScope.html - maxJavaVersion = JavaVersion.VERSION_24 + maxJavaVersion = VERSION_24 } } // Set all compile tasks to use JDK21 but let instrumentation code targets 1.8 compatibility tasks.withType(AbstractCompile).configureEach { - configureCompiler(it, 21, JavaVersion.VERSION_1_8) + configureCompiler(it, 21, VERSION_1_8) } // Configure groovy test file compilation diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/src/previewTest/groovy/StructuredConcurrencyTest.groovy b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/src/previewTest/groovy/StructuredConcurrencyTest.groovy index 8ef8fce7df4..4c839bed7ef 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/src/previewTest/groovy/StructuredConcurrencyTest.groovy +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-21.0/src/previewTest/groovy/StructuredConcurrencyTest.groovy @@ -49,7 +49,7 @@ class StructuredConcurrencyTest extends InstrumentationSpecification { } /** - * Tests the structured task scope with a multiple tasks. + * Tests the structured task scope with multiple tasks. * Here is the expected task/span structure: *
    *   parent
diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle
index 25ab9e9485a..ae2254c3af0 100644
--- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle
+++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle
@@ -1,3 +1,5 @@
+import static org.gradle.api.JavaVersion.VERSION_25
+
 plugins {
   id 'idea'
 }
@@ -6,6 +8,11 @@ apply from: "$rootDir/gradle/java.gradle"
 // Use slf4j-simple as default; logback has a high chance of getting stuck in a deadlock on CI.
 apply from: "$rootDir/gradle/slf4j-simple.gradle"
 
+testJvmConstraints {
+  // The new StructuredTaskScope API (JEP 505) only exists from Java 25
+  minJavaVersion = VERSION_25
+}
+
 muzzle {
   pass {
     coreJdk('25')
@@ -17,3 +24,26 @@ idea {
     jdkName = '25'
   }
 }
+
+/*
+ * Declare previewTest, a test suite that requires the Javac/Java --enable-preview feature flag
+ */
+addTestSuite('previewTest')
+
+// Configure tests compilation
+tasks.named("compilePreviewTestJava", JavaCompile) {
+  configureCompiler(it, 25)
+  options.compilerArgs.add("--enable-preview")
+}
+// Configure tests execution
+tasks.named("previewTest", Test) {
+  jvmArgs = ['--enable-preview']
+}
+// Require the preview test suite to run as part of module check
+tasks.named("check") {
+  dependsOn "previewTest"
+}
+
+dependencies {
+  testImplementation project(':dd-java-agent:instrumentation:datadog:tracing:trace-annotation')
+}
diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java
index 263ca03b889..cdc1a65fc39 100644
--- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java
+++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java
@@ -3,6 +3,7 @@
 import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast;
 import static datadog.trace.bootstrap.InstrumentationContext.get;
 import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.capture;
+import static java.util.Collections.singletonMap;
 import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
 
 import com.google.auto.service.AutoService;
@@ -10,16 +11,10 @@
 import datadog.trace.agent.tooling.InstrumenterModule;
 import datadog.trace.bootstrap.ContextStore;
 import datadog.trace.bootstrap.instrumentation.java.concurrent.State;
+import java.util.Map;
 import net.bytebuddy.asm.Advice.OnMethodExit;
 import net.bytebuddy.asm.Advice.This;
 
-// WARNING:
-// This instrumentation is tested using smoke tests as instrumented tests cannot run using Java 25.
-// Instrumented tests rely on Spock / Groovy which cannot run using Java 25 due to byte-code
-// compatibility. Check
-// dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0 for this
-// instrumentation test suite.
-
 /**
  * This instrumentation captures the active span scope at StructuredTaskScope task creation
  * (SubtaskImpl). The scope is then activate and close through the {@link Runnable} instrumentation
@@ -44,6 +39,11 @@ public boolean isEnabled() {
     return isJavaVersionAtLeast(25) && super.isEnabled();
   }
 
+  @Override
+  public Map contextStore() {
+    return singletonMap(Runnable.class.getName(), State.class.getName());
+  }
+
   @Override
   public void methodAdvice(MethodTransformer transformer) {
     transformer.applyAdvice(isConstructor(), getClass().getName() + "$ConstructorAdvice");
diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java
new file mode 100644
index 00000000000..82af8018316
--- /dev/null
+++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java
@@ -0,0 +1,140 @@
+package testdog.trace.instrumentation.java.concurrent.structuredconcurrency25;
+
+import static datadog.trace.agent.test.assertions.SpanMatcher.span;
+import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME;
+import static datadog.trace.agent.test.assertions.TraceMatcher.trace;
+import static java.util.Comparator.comparing;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import datadog.trace.agent.test.AbstractInstrumentationTest;
+import datadog.trace.api.DDSpanId;
+import datadog.trace.core.DDSpan;
+import java.util.Comparator;
+import java.util.concurrent.StructuredTaskScope;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings("preview")
+public class StructuredTaskScope25Test extends AbstractInstrumentationTest {
+  /** Tests the structured task scope with a single task. */
+  @Test
+  void testSingleTaskTracking() throws Exception {
+    var span = tracer.startSpan("test", "parent");
+    try (var ignored = tracer.activateSpan(span)) {
+      try (var scope = StructuredTaskScope.open()) {
+        scope.fork(() -> task("child"));
+        scope.join();
+      }
+    }
+    span.finish();
+
+    assertTraces(
+        trace(
+            SORT_BY_START_TIME,
+            span().root().operationName("parent"),
+            span().childOfPrevious().operationName("child")));
+  }
+
+  /**
+   * Tests the structured task scope with multiple tasks. Here is the expected task/span structure:
+   *
+   * 
+   *   parent
+   *   |-- child1
+   *   |-- child2
+   *   \-- child3
+   * 
+ */ + @Test + void testMultipleTasksTracking() throws Exception { + var span = tracer.startSpan("test", "parent"); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open()) { + scope.fork(() -> task("child1")); + scope.fork(() -> task("child2")); + scope.fork(() -> task("child3")); + scope.join(); + } + } + span.finish(); + + assertTraces( + trace( + SORT_BY_START_TIME, + span().root().operationName("parent"), + span().childOfIndex(0).operationName("child1"), + span().childOfIndex(0).operationName("child2"), + span().childOfIndex(0).operationName("child3"))); + } + + /** + * Tests the structured task scope with multiple nested tasks. Here is the expected task/span + * structure: + * + *
+   *   parent
+   *   |-- child1
+   *   |   |-- grandchild1
+   *   |   \-- grandchild2
+   *   \-- child2
+   * 
+ */ + @Test + void testNestedTasksTracking() throws Exception { + var span = tracer.startSpan("test", "parent"); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open()) { + scope.fork(() -> nestedChildren("child1")); + scope.fork(() -> task("child2")); + scope.join(); + } + } + span.finish(); + + assertTraces( + trace( + options -> options.sorter(SORT_BY_OPERATION_NAME), + span().childOfIndex(4).operationName("child1"), + span().childOfIndex(4).operationName("child2"), + span().childOfIndex(0).operationName("grandchild1"), + span().childOfIndex(0).operationName("grandchild2"), + span().root().operationName("parent"))); + } + + Comparator SORT_BY_OPERATION_NAME = + comparing(DDSpan::getOperationName, comparing(CharSequence::toString)); + + Void nestedChildren(String name) throws Exception { + var span = tracer.startSpan("test", name); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open()) { + scope.fork(() -> task("grandchild1")); + scope.fork(() -> task("grandchild2")); + scope.join(); + } + } + span.finish(); + return null; + } + + @Test + void testTasksInheritContext() throws Exception { + var span = tracer.startSpan("test", "parent"); + var expectedSpanId = DDSpanId.toString(span.getSpanId()); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open()) { + for (int i = 0; i < 5; i++) { + scope.fork(() -> assertEquals(expectedSpanId, tracer.getSpanId())); + } + scope.join(); + } + } + span.finish(); + + assertTraces(trace(span().root().operationName("parent"))); + } + + Void task(String name) { + tracer.startSpan("test", name).finish(); + return null; + } +} From ce656accf6a3792224cfad229fa0e4374ea6841a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Sat, 27 Jun 2026 19:49:07 +0200 Subject: [PATCH 2/5] feat(java-concurrent): Prevent continuation leak on canceled scopes --- .../concurrent/StructuredTaskScopeHelper.java | 42 +++++++++++ ...tructuredTaskScopeForkInstrumentation.java | 70 +++++++++++++++++++ .../StructuredTaskScope25Test.java | 49 +++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/StructuredTaskScopeHelper.java create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/StructuredTaskScopeHelper.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/StructuredTaskScopeHelper.java new file mode 100644 index 00000000000..ba0945ba2e2 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/java/concurrent/StructuredTaskScopeHelper.java @@ -0,0 +1,42 @@ +package datadog.trace.bootstrap.instrumentation.java.concurrent; + +import static java.lang.invoke.MethodHandles.publicLookup; +import static java.lang.invoke.MethodType.methodType; + +import java.lang.invoke.MethodHandle; + +/** Helper for the java-concurrent-25.0 {@code StructuredTaskScopeForkInstrumentation}. */ +public final class StructuredTaskScopeHelper { + private static final MethodHandle IS_CANCELLED = resolveIsCancelled(); + + private StructuredTaskScopeHelper() {} + + private static MethodHandle resolveIsCancelled() { + try { + ClassLoader classLoader = StructuredTaskScopeHelper.class.getClassLoader(); + Class scopeClass = + Class.forName("java.util.concurrent.StructuredTaskScope", false, classLoader); + return publicLookup().findVirtual(scopeClass, "isCancelled", methodType(boolean.class)); + } catch (Throwable ignored) { + return null; + } + } + + /** + * Returns whether the given {@code StructuredTaskScope} has been cancelled. + * + * @param scope a {@code java.util.concurrent.StructuredTaskScope} instance. + * @return {@code true} if the scope is cancelled; {@code false} otherwise, including when {@code + * isCancelled()} could not be resolved or invoked. + */ + public static boolean isCancelled(Object scope) { + if (IS_CANCELLED == null || scope == null) { + return false; + } + try { + return (boolean) IS_CANCELLED.invoke(scope); + } catch (Throwable ignored) { + return false; + } + } +} diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java new file mode 100644 index 00000000000..1dfd4e559dd --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java @@ -0,0 +1,70 @@ +package datadog.trace.instrumentation.java.concurrent.structuredconcurrency25; + +import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast; +import static datadog.trace.bootstrap.InstrumentationContext.get; +import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.cancelTask; +import static java.util.Collections.singletonMap; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.ContextStore; +import datadog.trace.bootstrap.instrumentation.java.concurrent.State; +import datadog.trace.bootstrap.instrumentation.java.concurrent.StructuredTaskScopeHelper; +import java.util.Map; +import java.util.concurrent.Callable; +import net.bytebuddy.asm.Advice.OnMethodExit; +import net.bytebuddy.asm.Advice.Return; +import net.bytebuddy.asm.Advice.This; + +/** + * This instrumentation cancels the continuation captured by {@link + * StructuredTaskScope25Instrumentation} for a subtask forked into an already-canceled scope. + * + *

In that case, the subtask's thread is never started, so {@code SubtaskImpl.run()} never runs, + * and the {@link Runnable} instrumentation never activates/closes the captured continuation. It + * leads to continuation leak. This instrumentation verifies the subtask was canceled and releases + * the related continuation. + */ +@SuppressWarnings("unused") +@AutoService(InstrumenterModule.class) +public class StructuredTaskScopeForkInstrumentation extends InstrumenterModule.ContextTracking + implements Instrumenter.ForBootstrap, Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public StructuredTaskScopeForkInstrumentation() { + super("java_concurrent", "structured-task-scope", "structured-task-scope-25"); + } + + @Override + public String instrumentedType() { + return "java.util.concurrent.StructuredTaskScopeImpl"; + } + + @Override + public boolean isEnabled() { + return isJavaVersionAtLeast(25) && super.isEnabled(); + } + + @Override + public Map contextStore() { + return singletonMap(Runnable.class.getName(), State.class.getName()); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("fork").and(takesArgument(0, Callable.class)), getClass().getName() + "$ForkAdvice"); + } + + public static final class ForkAdvice { + @OnMethodExit(suppress = Throwable.class) + public static void afterFork(@This Object scope, @Return Object subtask) { + if (subtask instanceof Runnable && StructuredTaskScopeHelper.isCancelled(scope)) { + ContextStore contextStore = get(Runnable.class, State.class); + cancelTask(contextStore, (Runnable) subtask); + } + } + } +} diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java index 82af8018316..55a4eef163b 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java @@ -5,6 +5,7 @@ import static datadog.trace.agent.test.assertions.TraceMatcher.trace; import static java.util.Comparator.comparing; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import datadog.trace.agent.test.AbstractInstrumentationTest; import datadog.trace.api.DDSpanId; @@ -133,8 +134,56 @@ void testTasksInheritContext() throws Exception { assertTraces(trace(span().root().operationName("parent"))); } + @Test + void testFailingTaskDoesNotLeakContinuation() { + var span = tracer.startSpan("test", "parent"); + var parentSpanId = DDSpanId.toString(span.getSpanId()); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open()) { + scope.fork(this::failingTask); + assertThrows(Exception.class, scope::join); + } + assertEquals( + parentSpanId, tracer.getSpanId(), "parent context should be restored after the scope"); + } + span.finish(); + + assertTraces(trace(span().root().operationName("parent"))); + } + + @Test + void testForkIntoCancelledScopeDoesNotLeakContinuation() throws Exception { + var span = tracer.startSpan("test", "parent"); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open(new CancelOnForkJoiner<>())) { + scope.fork(() -> task("child")); + scope.join(); + } + } + span.finish(); + + assertTraces(trace(span().root().operationName("parent"))); + } + Void task(String name) { tracer.startSpan("test", name).finish(); return null; } + + Void failingTask() { + throw new IllegalStateException("failing"); + } + + /** Cancels the scope as soon as the first subtask is forked. */ + static final class CancelOnForkJoiner implements StructuredTaskScope.Joiner { + @Override + public boolean onFork(StructuredTaskScope.Subtask subtask) { + return true; // Cancel the scope as soon as the first subtask is forked. + } + + @Override + public Void result() { + return null; + } + } } From 5cc0be5f0483bf222abf1ac889e1214a78a2bd9a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Sat, 27 Jun 2026 20:05:58 +0200 Subject: [PATCH 3/5] feat(java-concurrent): Introduce dedicated instrumentation module WIP --- .../StructuredTaskScope25Instrumentation.java | 66 ++++++++--------- .../StructuredTaskScope25Module.java | 40 +++++++++++ ...ucturedTaskScope25TaskInstrumentation.java | 46 ++++++++++++ ...tructuredTaskScopeForkInstrumentation.java | 70 ------------------- 4 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Module.java create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25TaskInstrumentation.java delete mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java index cdc1a65fc39..61b44251dc7 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Instrumentation.java @@ -1,66 +1,60 @@ package datadog.trace.instrumentation.java.concurrent.structuredconcurrency25; -import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; import static datadog.trace.bootstrap.InstrumentationContext.get; -import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.capture; -import static java.util.Collections.singletonMap; -import static net.bytebuddy.matcher.ElementMatchers.isConstructor; +import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.cancelTask; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; -import com.google.auto.service.AutoService; import datadog.trace.agent.tooling.Instrumenter; -import datadog.trace.agent.tooling.InstrumenterModule; import datadog.trace.bootstrap.ContextStore; import datadog.trace.bootstrap.instrumentation.java.concurrent.State; -import java.util.Map; +import datadog.trace.bootstrap.instrumentation.java.concurrent.StructuredTaskScopeHelper; +import java.util.concurrent.Callable; import net.bytebuddy.asm.Advice.OnMethodExit; +import net.bytebuddy.asm.Advice.Return; import net.bytebuddy.asm.Advice.This; /** - * This instrumentation captures the active span scope at StructuredTaskScope task creation - * (SubtaskImpl). The scope is then activate and close through the {@link Runnable} instrumentation - * (SubtaskImpl implementing {@link Runnable}). + * This instrumentation cancels the continuation captured by {@link + * StructuredTaskScope25TaskInstrumentation} for a subtask forked into an already-canceled scope. + * + *

In that case, the subtask's thread is never started, so {@code SubtaskImpl.run()} never runs, + * and the {@link Runnable} instrumentation never activates/closes the captured continuation. It + * leads to continuation leak. This instrumentation verifies the subtask was canceled and releases + * the related continuation. */ @SuppressWarnings("unused") -@AutoService(InstrumenterModule.class) -public class StructuredTaskScope25Instrumentation extends InstrumenterModule.ContextTracking +public class StructuredTaskScope25Instrumentation implements Instrumenter.ForBootstrap, Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { - public StructuredTaskScope25Instrumentation() { - super("java_concurrent", "structured-task-scope", "structured-task-scope-25"); - } - @Override public String instrumentedType() { - return "java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl"; - } - - @Override - public boolean isEnabled() { - return isJavaVersionAtLeast(25) && super.isEnabled(); - } - - @Override - public Map contextStore() { - return singletonMap(Runnable.class.getName(), State.class.getName()); + return "java.util.concurrent.StructuredTaskScopeImpl"; } @Override public void methodAdvice(MethodTransformer transformer) { - transformer.applyAdvice(isConstructor(), getClass().getName() + "$ConstructorAdvice"); + transformer.applyAdvice( + named("fork").and(takesArgument(0, Callable.class)), getClass().getName() + "$ForkAdvice"); } - public static final class ConstructorAdvice { + public static final class ForkAdvice { /** - * Captures task scope to be restored at the start of VirtualThread.run() method by {@link - * Runnable} instrumentation. + * Cancels the task scope continuation captured at task creation when the subtask is forked into + * an already-canceled scope: its thread never starts, so the {@link Runnable} instrumentation + * never releases the continuation. * - * @param subTaskImpl The StructuredTaskScopeImpl.SubtaskImpl object (the advice are compile - * against Java 8 so the type from JDK25 can't be referred, using {@link Object} instead + * @param scope The StructuredTaskScopeImpl object (the advice is compiled against Java 8 so the + * type from JDK25 can't be referred, using {@link Object} instead). + * @param subtask The StructuredTaskScopeImpl.SubtaskImpl object (the advice is compiled against + * Java 8 so the type from JDK25 can't be referred, using {@link Object} instead). */ @OnMethodExit(suppress = Throwable.class) - public static void captureScope(@This Object subTaskImpl) { - ContextStore contextStore = get(Runnable.class, State.class); - capture(contextStore, (Runnable) subTaskImpl); + public static void afterFork(@This Object scope, @Return Object subtask) { + if (subtask instanceof Runnable && StructuredTaskScopeHelper.isCancelled(scope)) { + ContextStore contextStore = get(Runnable.class, State.class); + cancelTask(contextStore, (Runnable) subtask); + } } } } diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Module.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Module.java new file mode 100644 index 00000000000..6afac978632 --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Module.java @@ -0,0 +1,40 @@ +package datadog.trace.instrumentation.java.concurrent.structuredconcurrency25; + +import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonMap; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.instrumentation.java.concurrent.State; +import java.util.List; +import java.util.Map; + +/** + * This module propagates context across {@code java.util.concurrent.StructuredTaskScope} forked + * subtasks (JDK 25+). + */ +@SuppressWarnings("unused") +@AutoService(InstrumenterModule.class) +public class StructuredTaskScope25Module extends InstrumenterModule.ContextTracking { + public StructuredTaskScope25Module() { + super("java_concurrent", "structured-task-scope", "structured-task-scope-25"); + } + + @Override + public boolean isEnabled() { + return isJavaVersionAtLeast(25) && super.isEnabled(); + } + + @Override + public Map contextStore() { + return singletonMap(Runnable.class.getName(), State.class.getName()); + } + + @Override + public List typeInstrumentations() { + return asList( + new StructuredTaskScope25TaskInstrumentation(), new StructuredTaskScope25Instrumentation()); + } +} diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25TaskInstrumentation.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25TaskInstrumentation.java new file mode 100644 index 00000000000..27deddd7361 --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25TaskInstrumentation.java @@ -0,0 +1,46 @@ +package datadog.trace.instrumentation.java.concurrent.structuredconcurrency25; + +import static datadog.trace.bootstrap.InstrumentationContext.get; +import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.capture; +import static net.bytebuddy.matcher.ElementMatchers.isConstructor; + +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.bootstrap.ContextStore; +import datadog.trace.bootstrap.instrumentation.java.concurrent.State; +import net.bytebuddy.asm.Advice.OnMethodExit; +import net.bytebuddy.asm.Advice.This; + +/** + * This instrumentation captures the active span scope at StructuredTaskScope task creation + * (SubtaskImpl). The scope is then activate and close through the {@link Runnable} instrumentation + * (SubtaskImpl implementing {@link Runnable}). + */ +@SuppressWarnings("unused") +public class StructuredTaskScope25TaskInstrumentation + implements Instrumenter.ForBootstrap, Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + @Override + public String instrumentedType() { + return "java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl"; + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice(isConstructor(), getClass().getName() + "$ConstructorAdvice"); + } + + public static final class ConstructorAdvice { + /** + * Captures task scope to be restored at the start of VirtualThread.run() method by {@link + * Runnable} instrumentation. + * + * @param subTaskImpl The StructuredTaskScopeImpl.SubtaskImpl object (the advice is compiled + * against Java 8 so the type from JDK25 can't be referred, using {@link Object} instead). + */ + @OnMethodExit(suppress = Throwable.class) + public static void captureScope(@This Object subTaskImpl) { + ContextStore contextStore = get(Runnable.class, State.class); + capture(contextStore, (Runnable) subTaskImpl); + } + } +} diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java deleted file mode 100644 index 1dfd4e559dd..00000000000 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/main/java/datadog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeForkInstrumentation.java +++ /dev/null @@ -1,70 +0,0 @@ -package datadog.trace.instrumentation.java.concurrent.structuredconcurrency25; - -import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast; -import static datadog.trace.bootstrap.InstrumentationContext.get; -import static datadog.trace.bootstrap.instrumentation.java.concurrent.AdviceUtils.cancelTask; -import static java.util.Collections.singletonMap; -import static net.bytebuddy.matcher.ElementMatchers.named; -import static net.bytebuddy.matcher.ElementMatchers.takesArgument; - -import com.google.auto.service.AutoService; -import datadog.trace.agent.tooling.Instrumenter; -import datadog.trace.agent.tooling.InstrumenterModule; -import datadog.trace.bootstrap.ContextStore; -import datadog.trace.bootstrap.instrumentation.java.concurrent.State; -import datadog.trace.bootstrap.instrumentation.java.concurrent.StructuredTaskScopeHelper; -import java.util.Map; -import java.util.concurrent.Callable; -import net.bytebuddy.asm.Advice.OnMethodExit; -import net.bytebuddy.asm.Advice.Return; -import net.bytebuddy.asm.Advice.This; - -/** - * This instrumentation cancels the continuation captured by {@link - * StructuredTaskScope25Instrumentation} for a subtask forked into an already-canceled scope. - * - *

In that case, the subtask's thread is never started, so {@code SubtaskImpl.run()} never runs, - * and the {@link Runnable} instrumentation never activates/closes the captured continuation. It - * leads to continuation leak. This instrumentation verifies the subtask was canceled and releases - * the related continuation. - */ -@SuppressWarnings("unused") -@AutoService(InstrumenterModule.class) -public class StructuredTaskScopeForkInstrumentation extends InstrumenterModule.ContextTracking - implements Instrumenter.ForBootstrap, Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { - - public StructuredTaskScopeForkInstrumentation() { - super("java_concurrent", "structured-task-scope", "structured-task-scope-25"); - } - - @Override - public String instrumentedType() { - return "java.util.concurrent.StructuredTaskScopeImpl"; - } - - @Override - public boolean isEnabled() { - return isJavaVersionAtLeast(25) && super.isEnabled(); - } - - @Override - public Map contextStore() { - return singletonMap(Runnable.class.getName(), State.class.getName()); - } - - @Override - public void methodAdvice(MethodTransformer transformer) { - transformer.applyAdvice( - named("fork").and(takesArgument(0, Callable.class)), getClass().getName() + "$ForkAdvice"); - } - - public static final class ForkAdvice { - @OnMethodExit(suppress = Throwable.class) - public static void afterFork(@This Object scope, @Return Object subtask) { - if (subtask instanceof Runnable && StructuredTaskScopeHelper.isCancelled(scope)) { - ContextStore contextStore = get(Runnable.class, State.class); - cancelTask(contextStore, (Runnable) subtask); - } - } - } -} From fb70e060b13b74b85d21df4c6bed8038ec333919 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Sun, 28 Jun 2026 17:17:56 +0200 Subject: [PATCH 4/5] feat(java-concurrent): Remove unused trace annotation dependency --- .../java/java-concurrent/java-concurrent-25.0/build.gradle | 3 --- 1 file changed, 3 deletions(-) diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle index ae2254c3af0..c04b4accf4a 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle @@ -44,6 +44,3 @@ tasks.named("check") { dependsOn "previewTest" } -dependencies { - testImplementation project(':dd-java-agent:instrumentation:datadog:tracing:trace-annotation') -} From 829131e19d265aa62ca87390d638fe41a0e6c6cf Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Sun, 28 Jun 2026 17:18:20 +0200 Subject: [PATCH 5/5] feat(java-concurrent): Add JDK specific test suites --- .../java-concurrent-25.0/build.gradle | 56 +++++++++++++++++-- .../StructuredTaskScope25Test.java | 27 --------- .../StructuredTaskScopeCancelTest.java | 47 ++++++++++++++++ .../StructuredTaskScopeCancelTest.java | 47 ++++++++++++++++ .../StructuredTaskScopeCancelTest.java | 48 ++++++++++++++++ 5 files changed, 192 insertions(+), 33 deletions(-) create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk25/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk26/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java create mode 100644 dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk27/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle index c04b4accf4a..35992b6be40 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/build.gradle @@ -1,4 +1,6 @@ import static org.gradle.api.JavaVersion.VERSION_25 +import static org.gradle.api.JavaVersion.VERSION_26 +import static org.gradle.api.JavaVersion.VERSION_27 plugins { id 'idea' @@ -26,21 +28,63 @@ idea { } /* - * Declare previewTest, a test suite that requires the Javac/Java --enable-preview feature flag + * Declare previewTest, a test suite that requires the Javac/Java --enable-preview feature flag. + * --enable-preview class files are version-locked to the compiling JDK, so the suite is compiled with + * the SAME JDK that runs it (the selected test JVM, clamped to >= 25). */ addTestSuite('previewTest') -// Configure tests compilation +def previewTestJvm = tasks.named("previewTest", Test) + .flatMap { it.javaLauncher } + .map { Math.max(25, it.metadata.languageVersion.asInt()) } + .orElse(25) + tasks.named("compilePreviewTestJava", JavaCompile) { - configureCompiler(it, 25) + javaCompiler = javaToolchains.compilerFor { + languageVersion = previewTestJvm.map { + JavaLanguageVersion.of(it) + } + } + options.release = previewTestJvm options.compilerArgs.add("--enable-preview") } -// Configure tests execution tasks.named("previewTest", Test) { jvmArgs = ['--enable-preview'] } -// Require the preview test suite to run as part of module check + +/* + * Per-JDK suites test: + * Each suite has its own source set, is compiled for one JDK, + * and is pinned to it (preview class files are version-locked). + */ +def previewCheckSuites = ['previewTest'] +def addJoinerLeakSuite = { int jdk, JavaVersion version -> + String suite = "previewTestJdk$jdk" + addTestSuite(suite) + tasks.named("compile${suite.capitalize()}Java", JavaCompile) { + configureCompiler(it, jdk) + options.compilerArgs.add("--enable-preview") + } + tasks.named(suite, Test) { + jvmArgs = ['--enable-preview'] + testJvmConstraints { + minJavaVersion = version + maxJavaVersion = version + } + } + previewCheckSuites << suite +} + +addJoinerLeakSuite(25, VERSION_25) +if (System.getenv('JAVA_26_HOME') != null) { + addJoinerLeakSuite(26, VERSION_26) +} +if (System.getenv('JAVA_27_HOME') != null) { + addJoinerLeakSuite(27, VERSION_27) +} + +// Require the preview test suites to run as part of module check tasks.named("check") { - dependsOn "previewTest" + dependsOn previewCheckSuites } diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java index 55a4eef163b..c7dc13b9ef3 100644 --- a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTest/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScope25Test.java @@ -151,20 +151,6 @@ void testFailingTaskDoesNotLeakContinuation() { assertTraces(trace(span().root().operationName("parent"))); } - @Test - void testForkIntoCancelledScopeDoesNotLeakContinuation() throws Exception { - var span = tracer.startSpan("test", "parent"); - try (var ignored = tracer.activateSpan(span)) { - try (var scope = StructuredTaskScope.open(new CancelOnForkJoiner<>())) { - scope.fork(() -> task("child")); - scope.join(); - } - } - span.finish(); - - assertTraces(trace(span().root().operationName("parent"))); - } - Void task(String name) { tracer.startSpan("test", name).finish(); return null; @@ -173,17 +159,4 @@ Void task(String name) { Void failingTask() { throw new IllegalStateException("failing"); } - - /** Cancels the scope as soon as the first subtask is forked. */ - static final class CancelOnForkJoiner implements StructuredTaskScope.Joiner { - @Override - public boolean onFork(StructuredTaskScope.Subtask subtask) { - return true; // Cancel the scope as soon as the first subtask is forked. - } - - @Override - public Void result() { - return null; - } - } } diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk25/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk25/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java new file mode 100644 index 00000000000..7daf6b9dc36 --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk25/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java @@ -0,0 +1,47 @@ +package testdog.trace.instrumentation.java.concurrent.structuredconcurrency25; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import java.util.concurrent.StructuredTaskScope; +import org.junit.jupiter.api.Test; + +/** + * JDK specific tests for the fork-into-canceled-scope continuation leak, isolated from {@code + * StructuredTaskScope25Test} because it uses the Java 25 {@code StructuredTaskScope.Joiner} API. + */ +@SuppressWarnings("preview") +public class StructuredTaskScopeCancelTest extends AbstractInstrumentationTest { + @Test + void testForkIntoCancelledScopeDoesNotLeakContinuation() throws Exception { + var span = tracer.startSpan("test", "parent"); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open(new CancelOnForkJoiner<>())) { + scope.fork(this::task); + scope.join(); + } + } + span.finish(); + + assertTraces(trace(span().root().operationName("parent"))); + } + + Void task() { + tracer.startSpan("test", "child").finish(); + return null; + } + + /** Cancels the scope as soon as the first subtask is forked. */ + static final class CancelOnForkJoiner implements StructuredTaskScope.Joiner { + @Override + public boolean onFork(StructuredTaskScope.Subtask subtask) { + return true; // Cancel the scope as soon as the first subtask is forked. + } + + @Override + public Void result() { + return null; + } + } +} diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk26/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk26/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java new file mode 100644 index 00000000000..66aa08ae39b --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk26/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java @@ -0,0 +1,47 @@ +package testdog.trace.instrumentation.java.concurrent.structuredconcurrency25; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import java.util.concurrent.StructuredTaskScope; +import org.junit.jupiter.api.Test; + +/** + * JDK specific tests for the fork-into-canceled-scope continuation leak, isolated from {@code + * StructuredTaskScope25Test} because it uses the Java 26 {@code StructuredTaskScope.Joiner} API. + */ +@SuppressWarnings("preview") +public class StructuredTaskScopeCancelTest extends AbstractInstrumentationTest { + @Test + void testForkIntoCancelledScopeDoesNotLeakContinuation() throws Exception { + var span = tracer.startSpan("test", "parent"); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open(new CancelOnForkJoiner<>())) { + scope.fork(this::task); + scope.join(); + } + } + span.finish(); + + assertTraces(trace(span().root().operationName("parent"))); + } + + Void task() { + tracer.startSpan("test", "child").finish(); + return null; + } + + /** Cancels the scope as soon as the first subtask is forked. */ + static final class CancelOnForkJoiner implements StructuredTaskScope.Joiner { + @Override + public boolean onFork(StructuredTaskScope.Subtask subtask) { + return true; // Cancel the scope as soon as the first subtask is forked. + } + + @Override + public Void result() { + return null; + } + } +} diff --git a/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk27/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk27/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java new file mode 100644 index 00000000000..cfa2cdea42f --- /dev/null +++ b/dd-java-agent/instrumentation/java/java-concurrent/java-concurrent-25.0/src/previewTestJdk27/java/testdog/trace/instrumentation/java/concurrent/structuredconcurrency25/StructuredTaskScopeCancelTest.java @@ -0,0 +1,48 @@ +package testdog.trace.instrumentation.java.concurrent.structuredconcurrency25; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import java.util.concurrent.StructuredTaskScope; +import org.junit.jupiter.api.Test; + +/** + * JDK specific tests for the fork-into-canceled-scope continuation leak, isolated from {@code + * StructuredTaskScope25Test} because it uses the Java 27 {@code StructuredTaskScope.Joiner} API. + */ +@SuppressWarnings("preview") +public class StructuredTaskScopeCancelTest extends AbstractInstrumentationTest { + @Test + void testForkIntoCancelledScopeDoesNotLeakContinuation() throws Exception { + var span = tracer.startSpan("test", "parent"); + try (var ignored = tracer.activateSpan(span)) { + try (var scope = StructuredTaskScope.open(new CancelOnForkJoiner<>())) { + scope.fork(this::task); + scope.join(); + } + } + span.finish(); + + assertTraces(trace(span().root().operationName("parent"))); + } + + Void task() { + tracer.startSpan("test", "child").finish(); + return null; + } + + /** Cancels the scope as soon as the first subtask is forked. */ + static final class CancelOnForkJoiner + implements StructuredTaskScope.Joiner { + @Override + public boolean onFork(StructuredTaskScope.Subtask subtask) { + return true; // Cancel the scope as soon as the first subtask is forked. + } + + @Override + public Void result() { + return null; + } + } +}