@@ -15,6 +15,7 @@ import org.gradle.api.provider.Property
1515import org.gradle.api.tasks.Exec
1616import org.gradle.api.tasks.SourceSetContainer
1717import org.gradle.api.tasks.testing.Test
18+ import java.io.File
1819import java.time.Duration
1920import javax.inject.Inject
2021
@@ -76,6 +77,61 @@ import javax.inject.Inject
7677 * ```
7778 */
7879class ProfilerTestPlugin : Plugin <Project > {
80+
81+ /* *
82+ * Major version of the *test* JVM, read from its `release` file (`JAVA_VERSION="..."`) rather
83+ * than by executing the launcher.
84+ *
85+ * Executing `$JAVA_TEST_HOME/bin/java -version` (PlatformUtils.testJvmMajorVersion()) is
86+ * unreliable here: in the musl split-JDK matrix it has been observed to report the build JDK
87+ * (21) even when the test JVM is JDK 8, which put a JDK-21-only `--add-exports` onto a JDK-8
88+ * launcher and aborted it. Reading the `release` file is a pure file read of the same
89+ * JAVA_TEST_HOME the executable is resolved from — deterministic, no subprocess, no exec-format
90+ * or PATH hazards. Returns 0 when it cannot be determined (missing/old `release`), so callers
91+ * fail safe: they omit the flag, the profiler degrades to thread-scoped storage, and the
92+ * carrier-scoping tests skip — never an abort.
93+ */
94+ private fun testJvmMajorVersionFromRelease (): Int = try {
95+ val release = File (PlatformUtils .testJavaHome(), " release" )
96+ val version = release.takeIf { it.isFile }
97+ ?.readLines()
98+ ?.firstOrNull { it.startsWith(" JAVA_VERSION=" ) }
99+ ?.substringAfter(' =' )?.trim()?.trim(' "' )
100+ // "1.8.0_452" -> 8 ; "21.0.5" -> 21
101+ val parts = version?.split(' .' ).orEmpty()
102+ val majorToken = when {
103+ parts.isEmpty() -> " "
104+ parts[0 ] == " 1" && parts.size > 1 -> parts[1 ]
105+ else -> parts[0 ]
106+ }
107+ majorToken.takeWhile { it.isDigit() }.toIntOrNull() ? : 0
108+ } catch (e: Exception ) {
109+ 0
110+ }
111+
112+ /* *
113+ * JVM args required to enable carrier-scoped OTEL context storage
114+ * (`OtelContextStorage.Mode.CARRIER`), or an empty list when the test JVM does not support it.
115+ *
116+ * Carrier scoping resolves `jdk.internal.misc.CarrierThreadLocal`, which lives in a
117+ * non-exported package, so it needs `--add-exports java.base/jdk.internal.misc=ALL-UNNAMED`.
118+ * That type only exists on JDK 21+, and the flag *aborts* a Java 8 JVM ("Unrecognized option"),
119+ * so it is gated on the version of the actual test JVM.
120+ *
121+ * MUST be evaluated at task execution time (inside doFirst), not configuration time: the test
122+ * JVM is selected via JAVA_TEST_HOME, which the CI only makes resolvable at execution time (see
123+ * the `executable` assignments below).
124+ */
125+ private fun carrierExportJvmArgs (project : Project ): List <String > {
126+ val major = testJvmMajorVersionFromRelease()
127+ val enabled = major >= 21
128+ project.logger.info(
129+ " ddprof: carrier --add-exports gate — testJavaHome={}, detected major={}, flag {}" ,
130+ PlatformUtils .testJavaHome(), major, if (enabled) " ADDED" else " omitted"
131+ )
132+ return if (enabled) listOf (" --add-exports=java.base/jdk.internal.misc=ALL-UNNAMED" ) else emptyList()
133+ }
134+
79135 override fun apply (project : Project ) {
80136 val extension = project.extensions.create(
81137 " profilerTest" ,
@@ -238,6 +294,8 @@ class ProfilerTestPlugin : Plugin<Project> {
238294 testTask.doFirst {
239295 val allArgs = mutableListOf<String >()
240296 allArgs.addAll(testConfig.standardJvmArgs)
297+ // Version-gated at execution time, when the real test JVM is resolvable.
298+ allArgs.addAll(carrierExportJvmArgs(project))
241299
242300 if (extension.nativeLibDir.isPresent) {
243301 allArgs.add(" -Djava.library.path=${extension.nativeLibDir.get().asFile.absolutePath} " )
@@ -255,12 +313,21 @@ class ProfilerTestPlugin : Plugin<Project> {
255313
256314 // Sanitizer conditions
257315 when (testConfig.configName) {
258- " asan" -> testTask.onlyIf {
259- PlatformUtils .locateLibasan() != null &&
260- // Skip J9+ASAN: OpenJ9 has known GC stack-scanning and defineClass
261- // race bugs exposed by ASAN timing
262- // https://github.com/eclipse-openj9/openj9/issues/23514
263- ! PlatformUtils .isTestJvmJ9()
316+ " asan" -> {
317+ testTask.onlyIf {
318+ PlatformUtils .locateLibasan() != null &&
319+ // Skip J9+ASAN: OpenJ9 has known GC stack-scanning and defineClass
320+ // race bugs exposed by ASAN timing
321+ // https://github.com/eclipse-openj9/openj9/issues/23514
322+ ! PlatformUtils .isTestJvmJ9()
323+ }
324+ // The ASAN test JVM is pinned to -Xmx512m (see ConfigurationPresets.kt)
325+ // to keep the heap below ASan's shadow-memory region. Without forking,
326+ // Gradle reuses one JVM for the whole suite, and per-test allocations
327+ // (JFR recordings, JMC object models) accumulate until it OOMs late in
328+ // the run even though every individual test passes. Restart periodically
329+ // to bound cumulative heap growth.
330+ testTask.setForkEvery(25 )
264331 }
265332 // TSan + JVM integration tests are incompatible: the profiler's signal
266333 // handlers (SIGPROF at 1ms) are TSan-instrumented; when a signal fires
@@ -302,6 +369,8 @@ class ProfilerTestPlugin : Plugin<Project> {
302369
303370 // JVM args
304371 allArgs.addAll(testConfig.standardJvmArgs)
372+ // Version-gated at execution time, when the real test JVM (JAVA_TEST_HOME) is resolvable.
373+ allArgs.addAll(carrierExportJvmArgs(project))
305374 if (extension.nativeLibDir.isPresent) {
306375 allArgs.add(" -Djava.library.path=${extension.nativeLibDir.get().asFile.absolutePath} " )
307376 }
@@ -661,7 +730,13 @@ abstract class ProfilerTestExtension @Inject constructor(
661730 abstract val applicationMainClass: Property <String >
662731
663732 init {
664- // Standard JVM arguments for profiler testing
733+ // Standard JVM arguments for profiler testing.
734+ // NOTE: JDK-version-gated flags (e.g. the carrier-scoping --add-exports) must NOT be
735+ // added here. This convention is computed at configuration time, where JAVA_TEST_HOME
736+ // is not yet resolvable and PlatformUtils.testJavaHome() falls back to the *build* JDK
737+ // (JAVA_HOME) — which misdetects in the musl split-JDK CI (build JDK 21, test JDK 8) and
738+ // would emit a JDK-21 flag onto a JDK-8 test JVM. Version-gated flags are added at
739+ // execution time in the task doFirst blocks instead (see ProfilerTestPlugin).
665740 standardJvmArgs.convention(listOf (
666741 " -Djdk.attach.allowAttachSelf" , // Allow profiler to attach to self
667742 " -Djol.tryWithSudo=true" , // JOL memory layout analysis
0 commit comments