From e92873b822bf78de7cdce380b85c99150ae4f464 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Fri, 9 Oct 2026 14:54:03 -0400 Subject: [PATCH 01/10] Code coverage for Java Profiler --- .../native/config/ConfigurationPresets.kt | 46 ++++++++++++++ ddprof-lib/build.gradle.kts | 63 +++++++++++++++++-- ddprof-test/build.gradle.kts | 58 +++++++++++++++++ 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt index ea4a9c7c62..05fadf59a9 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt @@ -54,6 +54,16 @@ object ConfigurationPresets { // reason: those configs instrument every memory access, so the measured // per-sample times say more about the sanitizer than about the sampler. val samplerPerf = project.hasProperty("enableSamplerPerf") + + // Opt-in gcov/llvm-cov instrumented build. Inactive by default -- only + // registered as an active configuration (and thus only gets its + // compile/link/assemble/gtest tasks created at all, see + // NativeBuildPlugin/GtestPlugin filtering on getActiveConfigurations) + // when -PenableCoverage is passed. This keeps coverage instrumentation + // completely out of the default build and out of CI runs that don't + // ask for it, mirroring how asan/tsan are gated off when unsupported. + val enableCoverage = project.hasProperty("enableCoverage") + extension.buildConfigurations.apply { register("release") { configureRelease(this, currentPlatform, currentArch, version) @@ -74,6 +84,9 @@ object ConfigurationPresets { register("fuzzer") { configureFuzzer(this, currentPlatform, currentArch, version, rootDir, compiler) } + register("coverage") { + configureCoverage(this, currentPlatform, currentArch, version, enableCoverage) + } } val activeConfigs = extension.getActiveConfigurations(currentPlatform, currentArch) @@ -406,4 +419,37 @@ object ConfigurationPresets { } } } + + /** + * gcov/llvm-cov-instrumented debug build (`--coverage` is understood by both + * gcc and clang and emits gcov-compatible .gcno/.gcda files, so one preset + * covers both toolchains/platforms without a separate clang source-based + * coverage format). Inactive unless the caller opts in via -PenableCoverage. + */ + fun configureCoverage( + config: BuildConfiguration, + platform: Platform, + architecture: Architecture, + version: String, + active: Boolean + ) { + config.platform.set(platform) + config.architecture.set(architecture) + config.active.set(active) + + when (platform) { + Platform.LINUX -> { + config.compilerArgs.set( + listOf("-O0", "-g", "-DDEBUG", "--coverage") + commonLinuxCompilerArgs(version) + ) + config.linkerArgs.set(commonLinuxLinkerArgs() + listOf("--coverage")) + } + Platform.MACOS -> { + config.compilerArgs.set( + commonMacosCompilerArgs(version) + listOf("-O0", "-g", "-DDEBUG", "--coverage") + ) + config.linkerArgs.set(listOf("--coverage")) + } + } + } } diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index a672ab0c4e..c08ba35a41 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -96,9 +96,15 @@ val copyExternalLibs by tasks.registering(Copy::class) { } // Create JAR tasks for each build configuration using nativeBuild extension utilities -// Uses afterEvaluate to discover configurations dynamically from NativeBuildExtension +// Uses afterEvaluate to discover configurations dynamically from NativeBuildExtension. +// `coverage` is excluded: nativeBuild.buildConfigurations.names lists every registered +// config regardless of its `active` flag (the same quirk already lets inactive +// asan/tsan show up here on platforms where they're unsupported), so without this +// filter a --coverage-instrumented classifier jar would get built and added to the +// published Maven artifact by every `publish` run, coverage build or not. afterEvaluate { - nativeBuild.buildConfigurations.names.forEach { name -> + val packagedConfigNames = nativeBuild.buildConfigurations.names.filterNot { it == "coverage" } + packagedConfigNames.forEach { name -> val capitalizedName = name.replaceFirstChar { it.uppercase() } val copyTask = tasks.register("copy${capitalizedName}Libs", Copy::class) { @@ -151,6 +157,54 @@ afterEvaluate { } } +// C++ coverage report (only meaningful, and only registered, alongside the opt-in +// `coverage` build configuration -- see ConfigurationPresets.configureCoverage). +// Requires gcovr (`pip install gcovr`), which reads the gcc/clang-compatible +// .gcno/.gcda files gtestCoverage produces directly, so one tool covers both +// toolchains without a separate lcov+genhtml install. +if (project.hasProperty("enableCoverage")) { + tasks.register("gtestCoverageReport") { + group = "verification" + description = "Generates an HTML C++ coverage report from the gtestCoverage run (requires gcovr)" + dependsOn("gtestCoverage") + + val reportDir = layout.buildDirectory.dir("reports/coverage/cpp") + val objDir = layout.buildDirectory.dir("obj/gtest/coverage") + val srcRoot = project.file("src/main/cpp") + + doLast { + val gcovExecutable = when { + PlatformUtils.currentPlatform == Platform.MACOS -> "xcrun llvm-cov gcov" + File(PlatformUtils.findCompiler(project)).name.contains("clang") -> "llvm-cov gcov" + else -> "gcov" + } + + val outDir = reportDir.get().asFile.also { it.mkdirs() } + val cmd = listOf( + "gcovr", + "--root", srcRoot.absolutePath, + "--gcov-executable", gcovExecutable, + "--html", "--html-details", + "-o", "${outDir.absolutePath}/index.html", + "--print-summary", + objDir.get().asFile.absolutePath + ) + project.logger.lifecycle("Running: ${cmd.joinToString(" ")}") + val process = ProcessBuilder(cmd) + .directory(project.projectDir) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + println(output) + if (exitCode != 0) { + throw GradleException("gcovr failed (exit $exitCode) -- install it with 'pip install gcovr' if missing") + } + project.logger.lifecycle("Coverage report written to file://${outDir.absolutePath}/index.html") + } + } +} + // Add runBenchmarks task tasks.register("runBenchmarks") { dependsOn(":ddprof-lib:benchmarks:runBenchmark") @@ -199,9 +253,10 @@ publishing { groupId = "com.datadoghq" artifactId = "ddprof" - // Add artifacts from each build configuration + // Add artifacts from each build configuration (coverage excluded -- see the + // packagedConfigNames comment above) afterEvaluate { - nativeBuild.buildConfigurations.names.forEach { name -> + nativeBuild.buildConfigurations.names.filterNot { it == "coverage" }.forEach { name -> val capitalizedName = name.replaceFirstChar { it.uppercase() } artifact(tasks.named("assemble${capitalizedName}Jar")) } diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index cbbb573b57..fe6e2e26ef 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -1,13 +1,71 @@ import com.datadoghq.profiler.ProfilerTestExtension +import org.gradle.api.tasks.SourceSetContainer plugins { java `java-library` application + jacoco id("com.datadoghq.profiler-test") id("com.datadoghq.java-conventions") } +jacoco { + toolVersion = "0.8.12" +} + +// JaCoCo's plugin auto-attaches a JacocoTaskExtension (enabled by default) to every Test +// task in this project, including testAsan/testSlowAsan/testTsan/testSlowTsan. Restrict +// instrumentation to the plain debug config's tasks only -- asan/tsan already carry +// sanitizer instrumentation and don't need coverage data, and leaving JaCoCo enabled +// everywhere would multiply CI cost for no additional signal. +val coverageTaskNames: Set = if (project.hasProperty("disableJacoco")) emptySet() else setOf("testDebug", "testSlowDebug") + +// When jacocoTestReport is actually being requested, don't let a handful of +// known-flaky native-thread/CPU tests (already @RetryingTest-annotated; see +// NativeThreadTest, DynamicNativeThread, VtableReceiverFrameTest, ThreadEntryDetectionTest) +// block report generation -- testDebug still records and prints its failures, +// it just won't fail the build, so jacocoTestReport's dependency is satisfied +// and the exec data that was produced gets aggregated regardless. +val requestingCoverageReport = gradle.startParameter.taskNames.any { it.contains("jacocoTestReport") } + +tasks.withType().configureEach { + extensions.configure { + isEnabled = name in coverageTaskNames + } + if (name in coverageTaskNames && requestingCoverageReport) { + ignoreFailures = true + } +} + +// The jacoco plugin already registers a default "jacocoTestReport" task (wired to the +// disabled "test" task); reconfigure it to aggregate our actual coverage-producing tasks +// instead of registering a new one. +tasks.named("jacocoTestReport") { + group = "verification" + description = "Generates an aggregated HTML/XML coverage report from testDebug and testSlowDebug" + + val coverageTasks = tasks.matching { it.name in coverageTaskNames } + dependsOn(coverageTasks) + executionData.setFrom(coverageTasks.map { task -> + fileTree(task.project.layout.buildDirectory) { include("jacoco/${task.name}.exec") } + }) + + // The tests here exercise ddprof-lib's classes (the profiler itself), not this + // project's own trivial main sourceSet (just the UnwindingValidator app) -- so + // coverage must be measured against ddprof-lib, the actual target under test. + val libMainSourceSet = project(":ddprof-lib").extensions.getByType()["main"] + sourceDirectories.setFrom(libMainSourceSet.allSource.srcDirs) + classDirectories.setFrom(libMainSourceSet.output) + + reports { + html.required.set(true) + xml.required.set(true) + html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/jacocoTestReport/html")) + xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/jacocoTestReport/jacocoTestReport.xml")) + } +} + // Reference to native test helpers library directory val testNativeLibDir = project(":ddprof-test-native").layout.buildDirectory.dir("lib") From 6d08c413a94b9e68a6bb068827669784c9820df4 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Mon, 12 Oct 2026 16:23:33 +0200 Subject: [PATCH 02/10] Run Java integration tests against the coverage build and harden coverage tasks - Package the `coverage` native library jar and consumable configuration when -PenableCoverage is set, so :ddprof-test:testCoverage can resolve it (still excluded from publishing). - gtestCoverageReport now also depends on :ddprof-test:testCoverage and merges the gtest and Java-test .gcda data into one gcovr report; negative hit counts are warned about instead of failing the report. - Build the coverage config with -fprofile-update=atomic so concurrent profiler threads don't corrupt gcov counters, and link it on Linux with -Wl,-z,nodelete like the debug config. - Clear stale .gcda files before each gtestCoverage_ and testCoverage run, and derive the gcov tool from the configured compiler instead of the gcov on PATH. - Enable JaCoCo on testDebug/testSlowDebug only when jacocoTestReport is requested (drops -PdisableJacoco). When a coverage report is requested, test failures are deferred rather than ignored: the report is generated, then the build fails listing the failed test tasks. - Add NativeBuildConfig so debug-only tests also run against the coverage build. Co-Authored-By: Claude Opus 5.5 --- .../native/config/ConfigurationPresets.kt | 19 +++- ddprof-lib/build.gradle.kts | 58 +++++++--- ddprof-test/build.gradle.kts | 105 +++++++++++++++--- .../profiler/AbstractProfilerTest.java | 6 +- .../com/datadoghq/profiler/JVMAccessTest.java | 9 +- .../datadoghq/profiler/JavaProfilerTest.java | 12 +- .../datadoghq/profiler/NativeBuildConfig.java | 31 ++++++ .../profiler/UnwindFailuresTest.java | 8 +- 8 files changed, 193 insertions(+), 55 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt index 05fadf59a9..183482a80e 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + package com.datadoghq.native.config @@ -425,6 +430,11 @@ object ConfigurationPresets { * gcc and clang and emits gcov-compatible .gcno/.gcda files, so one preset * covers both toolchains/platforms without a separate clang source-based * coverage format). Inactive unless the caller opts in via -PenableCoverage. + * + * `-fprofile-update=atomic`: the default counter updates are plain + * read-modify-writes, so the profiler's concurrent threads (samplers, signal + * handlers, JVM threads) lose and corrupt counts -- a Linux run produced + * negative branch counts (GCC bug 68080) that gcovr refuses to parse. */ fun configureCoverage( config: BuildConfiguration, @@ -440,13 +450,16 @@ object ConfigurationPresets { when (platform) { Platform.LINUX -> { config.compilerArgs.set( - listOf("-O0", "-g", "-DDEBUG", "--coverage") + commonLinuxCompilerArgs(version) + listOf("-O0", "-g", "-DDEBUG", "--coverage", "-fprofile-update=atomic") + commonLinuxCompilerArgs(version) ) - config.linkerArgs.set(commonLinuxLinkerArgs() + listOf("--coverage")) + // -z,nodelete: same as configureDebug -- testCoverage loads this library + // into the same JVMs, so it is exposed to the same unmap-while-executing + // SIGSEGV on the DestroyJavaVM path. + config.linkerArgs.set(commonLinuxLinkerArgs() + listOf("-Wl,-z,nodelete", "--coverage")) } Platform.MACOS -> { config.compilerArgs.set( - commonMacosCompilerArgs(version) + listOf("-O0", "-g", "-DDEBUG", "--coverage") + commonMacosCompilerArgs(version) + listOf("-O0", "-g", "-DDEBUG", "--coverage", "-fprofile-update=atomic") ) config.linkerArgs.set(listOf("--coverage")) } diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index c08ba35a41..ad4b4624b3 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -97,13 +97,15 @@ val copyExternalLibs by tasks.registering(Copy::class) { // Create JAR tasks for each build configuration using nativeBuild extension utilities // Uses afterEvaluate to discover configurations dynamically from NativeBuildExtension. -// `coverage` is excluded: nativeBuild.buildConfigurations.names lists every registered -// config regardless of its `active` flag (the same quirk already lets inactive -// asan/tsan show up here on platforms where they're unsupported), so without this -// filter a --coverage-instrumented classifier jar would get built and added to the -// published Maven artifact by every `publish` run, coverage build or not. +// `coverage` is only packaged with -PenableCoverage: nativeBuild.buildConfigurations.names +// lists every registered config regardless of its `active` flag (the same quirk already +// lets inactive asan/tsan show up here on platforms where they're unsupported). With the +// flag, its jar and consumable configuration are what ddprof-test's testCoverage loads; +// it is still never published (see the publishing block below). afterEvaluate { - val packagedConfigNames = nativeBuild.buildConfigurations.names.filterNot { it == "coverage" } + val packagedConfigNames = nativeBuild.buildConfigurations.names.filter { + it != "coverage" || project.hasProperty("enableCoverage") + } packagedConfigNames.forEach { name -> val capitalizedName = name.replaceFirstChar { it.uppercase() } @@ -160,23 +162,46 @@ afterEvaluate { // C++ coverage report (only meaningful, and only registered, alongside the opt-in // `coverage` build configuration -- see ConfigurationPresets.configureCoverage). // Requires gcovr (`pip install gcovr`), which reads the gcc/clang-compatible -// .gcno/.gcda files gtestCoverage produces directly, so one tool covers both -// toolchains without a separate lcov+genhtml install. +// .gcno/.gcda files directly, so one tool covers both toolchains without a +// separate lcov+genhtml install. +// +// Two runs feed it: the gtest binaries (gtestCoverage, objects under +// obj/gtest/coverage) and the Java integration tests (:ddprof-test:testCoverage -- +// the testDebug suite run against the coverage-instrumented library, objects +// under obj/main/coverage). gcovr merges both into one per-source-file report. if (project.hasProperty("enableCoverage")) { + val gtestObjDir = layout.buildDirectory.dir("obj/gtest/coverage") + + // gcov adds to existing .gcda counts, and .gcda files left over from before a + // recompile no longer match the new .gcno stamps -- so every gtest binary must start + // from zero. Runs before each gtestCoverage_ task rather than in gtestCoverage + // itself, which is only an aggregate that executes after its test tasks. + val cleanGtestCoverageData = tasks.register("cleanGtestCoverageData") { + delete(fileTree(gtestObjDir) { include("**/*.gcda") }) + } + tasks.matching { it.name.startsWith("gtestCoverage_") }.configureEach { + dependsOn(cleanGtestCoverageData) + } + tasks.register("gtestCoverageReport") { group = "verification" - description = "Generates an HTML C++ coverage report from the gtestCoverage run (requires gcovr)" - dependsOn("gtestCoverage") + description = "Generates an HTML C++ coverage report from gtestCoverage and :ddprof-test:testCoverage (requires gcovr)" + dependsOn("gtestCoverage", ":ddprof-test:testCoverage") val reportDir = layout.buildDirectory.dir("reports/coverage/cpp") - val objDir = layout.buildDirectory.dir("obj/gtest/coverage") + val libObjDir = layout.buildDirectory.dir("obj/main/coverage") val srcRoot = project.file("src/main/cpp") doLast { + // The gcov tool must come from the same toolchain that wrote the .gcno files, + // so derive it from the compiler (e.g. /usr/bin/g++-13 -> /usr/bin/gcov-13, + // clang++-17 -> llvm-cov-17 gcov) instead of whatever `gcov` is on PATH. + val compiler = File(PlatformUtils.findCompiler(project)) + fun siblingOf(tool: String) = compiler.parentFile?.resolve(tool)?.path ?: tool val gcovExecutable = when { PlatformUtils.currentPlatform == Platform.MACOS -> "xcrun llvm-cov gcov" - File(PlatformUtils.findCompiler(project)).name.contains("clang") -> "llvm-cov gcov" - else -> "gcov" + compiler.name.contains("clang") -> siblingOf(compiler.name.replace(Regex("clang(\\+\\+)?"), "llvm-cov")) + " gcov" + else -> siblingOf(compiler.name.replace(Regex("[gc]\\+\\+"), "gcov")) } val outDir = reportDir.get().asFile.also { it.mkdirs() } @@ -187,7 +212,12 @@ if (project.hasProperty("enableCoverage")) { "--html", "--html-details", "-o", "${outDir.absolutePath}/index.html", "--print-summary", - objDir.get().asFile.absolutePath + // Safety net only: -fprofile-update=atomic (see configureCoverage) should keep + // counters consistent, but a stray negative count must not discard a + // ~20-minute run -- warn and ignore it instead of failing. + "--gcov-ignore-parse-errors=negative_hits.warn_once_per_file", + gtestObjDir.get().asFile.absolutePath, + libObjDir.get().asFile.absolutePath, ) project.logger.lifecycle("Running: ${cmd.joinToString(" ")}") val process = ProcessBuilder(cmd) diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index fe6e2e26ef..c94653d04c 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -1,5 +1,11 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + import com.datadoghq.profiler.ProfilerTestExtension import org.gradle.api.tasks.SourceSetContainer +import java.util.concurrent.ConcurrentHashMap plugins { java @@ -15,29 +21,90 @@ jacoco { } // JaCoCo's plugin auto-attaches a JacocoTaskExtension (enabled by default) to every Test -// task in this project, including testAsan/testSlowAsan/testTsan/testSlowTsan. Restrict -// instrumentation to the plain debug config's tasks only -- asan/tsan already carry -// sanitizer instrumentation and don't need coverage data, and leaving JaCoCo enabled -// everywhere would multiply CI cost for no additional signal. -val coverageTaskNames: Set = if (project.hasProperty("disableJacoco")) emptySet() else setOf("testDebug", "testSlowDebug") - -// When jacocoTestReport is actually being requested, don't let a handful of -// known-flaky native-thread/CPU tests (already @RetryingTest-annotated; see -// NativeThreadTest, DynamicNativeThread, VtableReceiverFrameTest, ThreadEntryDetectionTest) -// block report generation -- testDebug still records and prints its failures, -// it just won't fail the build, so jacocoTestReport's dependency is satisfied -// and the exec data that was produced gets aggregated regardless. +// task in this project. Instrument only when jacocoTestReport is actually requested, and +// then only the plain debug config's tasks: ordinary testDebug runs (CI included) keep +// running the profiler against unmodified bytecode -- JaCoCo's injected $jacocoInit +// methods and probe arrays would otherwise show up in the allocation and stack-sampling +// data those tests assert on -- and asan/tsan already carry sanitizer instrumentation. val requestingCoverageReport = gradle.startParameter.taskNames.any { it.contains("jacocoTestReport") } +val requestingCppCoverageReport = gradle.startParameter.taskNames.any { it.contains("gtestCoverageReport") } +val coverageTaskNames: Set = if (requestingCoverageReport) setOf("testDebug", "testSlowDebug") else emptySet() + +// When a coverage report is requested, a handful of known-flaky native-thread/CPU tests +// (already @RetryingTest-annotated; see NativeThreadTest, DynamicNativeThread, +// VtableReceiverFrameTest, ThreadEntryDetectionTest) must not keep the report from being +// generated. Their failures are deferred, not ignored: the test task records them and +// lets the report run, and the report task fails the build once the report is written. +val deferredTestFailures: MutableSet = ConcurrentHashMap.newKeySet() + +fun Task.deferFailuresToCoverageReport() { + val taskName = name + when (this) { + is Test -> { + ignoreFailures = true + addTestListener(object : TestListener { + override fun beforeSuite(suite: TestDescriptor) {} + override fun beforeTest(testDescriptor: TestDescriptor) {} + override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {} + override fun afterSuite(suite: TestDescriptor, result: TestResult) { + if (suite.parent == null && result.resultType == TestResult.ResultType.FAILURE) { + deferredTestFailures.add(taskName) + } + } + }) + } + is Exec -> { + isIgnoreExitValue = true + doLast { + if (executionResult.get().exitValue != 0) { + deferredTestFailures.add(taskName) + } + } + } + } +} + +fun failOnDeferredTestFailures(taskNames: Collection) { + val failed = taskNames.filter { it in deferredTestFailures } + if (failed.isNotEmpty()) { + throw GradleException("Coverage report generated, but tests failed in: ${failed.joinToString()}") + } +} tasks.withType().configureEach { extensions.configure { isEnabled = name in coverageTaskNames } - if (name in coverageTaskNames && requestingCoverageReport) { - ignoreFailures = true + // The failed tests' results are cached like a pass would be, so a rerun must not be + // UP-TO-DATE and silently skip the deferred failure. + if (name in coverageTaskNames) { + deferFailuresToCoverageReport() + outputs.upToDateWhen { false } } } +// testCoverage (created by the profiler-test plugin only with -PenableCoverage) runs the +// testDebug suite against ddprof-lib's gcov-instrumented `coverage` library; every test +// JVM that loads it writes .gcda counts next to the library's objects when it exits, and +// :ddprof-lib:gtestCoverageReport folds them into the C++ report. +val libCoverageObjDir = project(":ddprof-lib").layout.buildDirectory.dir("obj/main/coverage") + +tasks.matching { it.name == "testCoverage" }.configureEach { + // gcov adds to existing .gcda counts, so start each run from zero rather than + // accumulating counts from earlier runs (.gcno files, written at compile time, stay). + doFirst { + fileTree(libCoverageObjDir) { include("**/*.gcda") }.forEach { it.delete() } + } + if (requestingCppCoverageReport) { + deferFailuresToCoverageReport() + outputs.upToDateWhen { false } + } +} + +project(":ddprof-lib").tasks.matching { it.name == "gtestCoverageReport" }.configureEach { + doLast { failOnDeferredTestFailures(listOf("testCoverage")) } +} + // The jacoco plugin already registers a default "jacocoTestReport" task (wired to the // disabled "test" task); reconfigure it to aggregate our actual coverage-producing tasks // instead of registering a new one. @@ -47,9 +114,11 @@ tasks.named("jacocoTestReport") { val coverageTasks = tasks.matching { it.name in coverageTaskNames } dependsOn(coverageTasks) - executionData.setFrom(coverageTasks.map { task -> - fileTree(task.project.layout.buildDirectory) { include("jacoco/${task.name}.exec") } - }) + executionData.setFrom( + coverageTasks.map { task -> + fileTree(task.project.layout.buildDirectory) { include("jacoco/${task.name}.exec") } + }, + ) // The tests here exercise ddprof-lib's classes (the profiler itself), not this // project's own trivial main sourceSet (just the UnwindingValidator app) -- so @@ -58,6 +127,8 @@ tasks.named("jacocoTestReport") { sourceDirectories.setFrom(libMainSourceSet.allSource.srcDirs) classDirectories.setFrom(libMainSourceSet.output) + doLast { failOnDeferredTestFailures(coverageTaskNames) } + reports { html.required.set(true) xml.required.set(true) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java index b059bb655c..3399d93874 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java @@ -134,11 +134,11 @@ protected final boolean isAsan() { } /** - * Whether the tests run against the -O0 debug build of the native library, which unwinds - * several times slower than the release build. + * Whether the tests run against an -O0 debug build of the native library (debug or coverage), + * which unwinds several times slower than the release build. */ protected final boolean isDebugBuild() { - return "debug".equals(System.getProperty("ddprof_test.config")); + return NativeBuildConfig.isDebug(); } private static long getPid() { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JVMAccessTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JVMAccessTest.java index f6b983f603..efd717af7a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JVMAccessTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JVMAccessTest.java @@ -27,8 +27,7 @@ static void setUp() { @Test void sanityInitailizationTest() throws Exception { - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); AtomicBoolean initLibraryFound = new AtomicBoolean(false); AtomicBoolean initProfilerFound = new AtomicBoolean(false); @@ -66,8 +65,7 @@ void sanityInitailizationTest() throws Exception { */ @Test void agentOnLoadVMFlagDetectionTest() throws Exception { - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); Path agentLib = LibraryLoader.resolveLibraryPath(System.getProperty("java.io.tmpdir")); @@ -104,8 +102,7 @@ void agentOnLoadVMFlagDetectionTest() throws Exception { @Test void jvmVersionTest() throws Exception { - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); String javaVersion = System.getenv("JAVA_VERSION"); assumeTrue(javaVersion != null); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java index 2023c4757c..9edb26582a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java @@ -26,8 +26,7 @@ public class JavaProfilerTest extends AbstractProcessProfilerTest { @Test void sanityInitailizationTest() throws Exception { - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); AtomicInteger initFlag = new AtomicInteger(0); boolean val = launch("profiler", Collections.emptyList(), "", l -> { @@ -45,8 +44,7 @@ void sanityInitailizationTest() throws Exception { @Test void testJ9DefaultSanity() throws Exception { - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); assumeFalse(Platform.isMac()); // crashy on mac assumeTrue(Platform.isJ9()); @@ -84,8 +82,7 @@ void testJ9DefaultSanity() throws Exception { @Test void testJ9ForceJvmtiSanity() throws Exception { - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); assumeFalse(Platform.isMac()); // crashy on mac assumeTrue(Platform.isJ9()); @@ -137,8 +134,7 @@ void getInstanceFromVirtualThreadThrowsIOException() throws Exception { @Test void vmStackwalkerCrashRecoveryTest() throws Exception { assumeFalse(Platform.isJ9() || Platform.isZing()); // J9 and Zing do not support vmstructs - String config = System.getProperty("ddprof_test.config"); - assumeTrue("debug".equals(config)); + assumeTrue(NativeBuildConfig.isDebug()); Path jfr = Files.createTempFile("work", ".jfr"); jfr.toFile().deleteOnExit(); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java b/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java new file mode 100644 index 0000000000..c0270b9e77 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler; + +/** + * Describes the native library build the tests run against, as passed by the test task in the + * {@code ddprof_test.config} system property (the build configuration name, e.g. {@code release}, + * {@code debug}, {@code asan}, {@code coverage}). + */ +public final class NativeBuildConfig { + private NativeBuildConfig() {} + + /** The build configuration name, or {@code null} when the property is not set. */ + public static String name() { + return System.getProperty("ddprof_test.config"); + } + + /** + * Whether the library is an unoptimized {@code -O0 -DDEBUG} build without a sanitizer: + * {@code debug}, or {@code coverage} (the debug build with gcov instrumentation added). + * Debug-only code paths (e.g. {@code datadog.UnwindFailure} recording) are compiled in, and + * unwinding is several times slower than in the release build. + */ + public static boolean isDebug() { + String config = name(); + return "debug".equals(config) || "coverage".equals(config); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java index b8bca75f8b..ae9d8539bf 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java @@ -40,11 +40,11 @@ protected String getProfilerCommand() { @Test public void unwindFailureEventsHaveSaneSchemaWhenPresent() throws Exception { // UnwindFailures/UnwindStats are compiled out unless the native lib is built with - // -DDEBUG (debug/asan/tsan configs); on a release build datadog.UnwindFailure can + // -DDEBUG (debug/coverage/asan/tsan configs); on a release build datadog.UnwindFailure can // never appear, so there is nothing to check. - String config = System.getProperty("ddprof_test.config"); - assumeTrue(config != null && ("debug".equals(config) || config.endsWith("san")), - "datadog.UnwindFailure is only recorded in DEBUG-instrumented builds (debug/asan/tsan)"); + String config = NativeBuildConfig.name(); + assumeTrue(NativeBuildConfig.isDebug() || (config != null && config.endsWith("san")), + "datadog.UnwindFailure is only recorded in DEBUG-instrumented builds (debug/coverage/asan/tsan)"); // HotspotSupport::walkVM (and therefore UnwindFailures recording) is Hotspot-only. assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal()); From e59c04ad8f93d6750675ff6f4eee578d9bca5963 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Tue, 13 Oct 2026 12:03:04 +0000 Subject: [PATCH 03/10] Generate Java/C++ coverage in one run --- ddprof-lib/build.gradle.kts | 34 +++++++++++------- ddprof-test/build.gradle.kts | 67 +++++++++++++++++++----------------- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index ad4b4624b3..aa3ff07bd3 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -159,16 +159,18 @@ afterEvaluate { } } -// C++ coverage report (only meaningful, and only registered, alongside the opt-in -// `coverage` build configuration -- see ConfigurationPresets.configureCoverage). -// Requires gcovr (`pip install gcovr`), which reads the gcc/clang-compatible -// .gcno/.gcda files directly, so one tool covers both toolchains without a -// separate lcov+genhtml install. +// Java + C++ coverage report (only meaningful, and only registered, alongside the +// opt-in `coverage` build configuration -- see ConfigurationPresets.configureCoverage). +// The Java half is :ddprof-test:jacocoTestReport (reports/coverage/java); this task +// itself writes the C++ half (reports/coverage/cpp). That needs gcovr +// (`pip install gcovr`), which reads the gcc/clang-compatible .gcno/.gcda files +// directly, so one tool covers both toolchains without a separate lcov+genhtml install. // -// Two runs feed it: the gtest binaries (gtestCoverage, objects under -// obj/gtest/coverage) and the Java integration tests (:ddprof-test:testCoverage -- -// the testDebug suite run against the coverage-instrumented library, objects -// under obj/main/coverage). gcovr merges both into one per-source-file report. +// Two runs feed the C++ report: the gtest binaries (gtestCoverage, objects under +// obj/gtest/coverage) and the Java integration tests (:ddprof-test:testCoverage and +// testSlowCoverage -- the same runs that produce the JaCoCo data, against the +// coverage-instrumented library, objects under obj/main/coverage). gcovr merges both +// into one per-source-file report. if (project.hasProperty("enableCoverage")) { val gtestObjDir = layout.buildDirectory.dir("obj/gtest/coverage") @@ -183,10 +185,10 @@ if (project.hasProperty("enableCoverage")) { dependsOn(cleanGtestCoverageData) } - tasks.register("gtestCoverageReport") { + tasks.register("coverageReport") { group = "verification" - description = "Generates an HTML C++ coverage report from gtestCoverage and :ddprof-test:testCoverage (requires gcovr)" - dependsOn("gtestCoverage", ":ddprof-test:testCoverage") + description = "Generates the Java (JaCoCo) and C++ (gcovr) coverage reports from gtestCoverage and :ddprof-test:testCoverage/testSlowCoverage (requires gcovr)" + dependsOn("gtestCoverage", ":ddprof-test:testCoverage", ":ddprof-test:testSlowCoverage", ":ddprof-test:jacocoTestReport") val reportDir = layout.buildDirectory.dir("reports/coverage/cpp") val libObjDir = layout.buildDirectory.dir("obj/main/coverage") @@ -216,6 +218,11 @@ if (project.hasProperty("enableCoverage")) { // counters consistent, but a stray negative count must not discard a // ~20-minute run -- warn and ignore it instead of failing. "--gcov-ignore-parse-errors=negative_hits.warn_once_per_file", + // gcovr flags any count above 2^32 as a GCC-68080 artifact, but the 64-bit atomic + // counters legitimately pass that on hot paths (e.g. threadLocal.h's isKeyValid() + // hit ~4.4e9 over testCoverage + testSlowCoverage). Real counts stay far below + // 1e15 (1e9 hits/s for ~11 days), while wrapped/garbage counts land near 2^64. + "--gcov-suspicious-hits-threshold=1000000000000000", gtestObjDir.get().asFile.absolutePath, libObjDir.get().asFile.absolutePath, ) @@ -230,7 +237,8 @@ if (project.hasProperty("enableCoverage")) { if (exitCode != 0) { throw GradleException("gcovr failed (exit $exitCode) -- install it with 'pip install gcovr' if missing") } - project.logger.lifecycle("Coverage report written to file://${outDir.absolutePath}/index.html") + project.logger.lifecycle("C++ coverage report written to file://${outDir.absolutePath}/index.html") + project.logger.lifecycle("Java coverage report written to file://${outDir.parentFile.absolutePath}/java/html/index.html") } } } diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index c94653d04c..ece208cefa 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -20,15 +20,19 @@ jacoco { toolVersion = "0.8.12" } +// :ddprof-lib:coverageReport produces both the Java (JaCoCo) and the C++ (gcovr) report +// from a single run of the Java test suites: testCoverage and testSlowCoverage (created +// by the profiler-test plugin only with -PenableCoverage) load ddprof-lib's +// gcov-instrumented `coverage` library *and* carry the JaCoCo agent. +// // JaCoCo's plugin auto-attaches a JacocoTaskExtension (enabled by default) to every Test -// task in this project. Instrument only when jacocoTestReport is actually requested, and -// then only the plain debug config's tasks: ordinary testDebug runs (CI included) keep -// running the profiler against unmodified bytecode -- JaCoCo's injected $jacocoInit -// methods and probe arrays would otherwise show up in the allocation and stack-sampling -// data those tests assert on -- and asan/tsan already carry sanitizer instrumentation. -val requestingCoverageReport = gradle.startParameter.taskNames.any { it.contains("jacocoTestReport") } -val requestingCppCoverageReport = gradle.startParameter.taskNames.any { it.contains("gtestCoverageReport") } -val coverageTaskNames: Set = if (requestingCoverageReport) setOf("testDebug", "testSlowDebug") else emptySet() +// task in this project. Instrument only the coverage config's tasks: ordinary testDebug +// runs (CI included) keep running the profiler against unmodified bytecode -- JaCoCo's +// injected $jacocoInit methods and probe arrays would otherwise show up in the allocation +// and stack-sampling data those tests assert on -- and asan/tsan already carry sanitizer +// instrumentation. +val coverageTaskNames: Set = setOf("testCoverage", "testSlowCoverage") +val requestingCoverageReport = gradle.startParameter.taskNames.any { it.contains("coverageReport") } // When a coverage report is requested, a handful of known-flaky native-thread/CPU tests // (already @RetryingTest-annotated; see NativeThreadTest, DynamicNativeThread, @@ -75,42 +79,39 @@ tasks.withType().configureEach { extensions.configure { isEnabled = name in coverageTaskNames } - // The failed tests' results are cached like a pass would be, so a rerun must not be - // UP-TO-DATE and silently skip the deferred failure. - if (name in coverageTaskNames) { - deferFailuresToCoverageReport() - outputs.upToDateWhen { false } - } } -// testCoverage (created by the profiler-test plugin only with -PenableCoverage) runs the -// testDebug suite against ddprof-lib's gcov-instrumented `coverage` library; every test -// JVM that loads it writes .gcda counts next to the library's objects when it exits, and -// :ddprof-lib:gtestCoverageReport folds them into the C++ report. +// Every test JVM that loads the coverage library writes .gcda counts next to the +// library's objects when it exits, and :ddprof-lib:coverageReport folds them into the +// C++ report. gcov adds to existing .gcda counts, so clear them once before the coverage +// test tasks run -- not in each task, which would wipe the counts the other one just +// wrote (.gcno files, written at compile time, stay). val libCoverageObjDir = project(":ddprof-lib").layout.buildDirectory.dir("obj/main/coverage") +val cleanLibCoverageData = tasks.register("cleanLibCoverageData") { + delete(fileTree(libCoverageObjDir) { include("**/*.gcda") }) +} -tasks.matching { it.name == "testCoverage" }.configureEach { - // gcov adds to existing .gcda counts, so start each run from zero rather than - // accumulating counts from earlier runs (.gcno files, written at compile time, stay). - doFirst { - fileTree(libCoverageObjDir) { include("**/*.gcda") }.forEach { it.delete() } - } - if (requestingCppCoverageReport) { +tasks.matching { it.name in coverageTaskNames }.configureEach { + dependsOn(cleanLibCoverageData) + // The failed tests' results are cached like a pass would be, so a rerun must not be + // UP-TO-DATE and silently skip the deferred failure (or the cleared .gcda counts). + if (requestingCoverageReport) { deferFailuresToCoverageReport() outputs.upToDateWhen { false } } } -project(":ddprof-lib").tasks.matching { it.name == "gtestCoverageReport" }.configureEach { - doLast { failOnDeferredTestFailures(listOf("testCoverage")) } +// Runs after coverageReport's own gcovr doLast, so both reports are written first. +project(":ddprof-lib").tasks.matching { it.name == "coverageReport" }.configureEach { + doLast { failOnDeferredTestFailures(coverageTaskNames) } } // The jacoco plugin already registers a default "jacocoTestReport" task (wired to the -// disabled "test" task); reconfigure it to aggregate our actual coverage-producing tasks +// disabled "test" task); reconfigure it as the Java half of :ddprof-lib:coverageReport // instead of registering a new one. tasks.named("jacocoTestReport") { group = "verification" - description = "Generates an aggregated HTML/XML coverage report from testDebug and testSlowDebug" + description = "Java half of :ddprof-lib:coverageReport (run that instead; requires -PenableCoverage)" val coverageTasks = tasks.matching { it.name in coverageTaskNames } dependsOn(coverageTasks) @@ -127,13 +128,15 @@ tasks.named("jacocoTestReport") { sourceDirectories.setFrom(libMainSourceSet.allSource.srcDirs) classDirectories.setFrom(libMainSourceSet.output) - doLast { failOnDeferredTestFailures(coverageTaskNames) } + // Test failures are checked by coverageReport, after both reports are written. + // Next to the C++ report (ddprof-lib/build/reports/coverage/cpp). + val javaReportDir = project(":ddprof-lib").layout.buildDirectory.dir("reports/coverage/java") reports { html.required.set(true) xml.required.set(true) - html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/jacocoTestReport/html")) - xml.outputLocation.set(layout.buildDirectory.file("reports/jacoco/jacocoTestReport/jacocoTestReport.xml")) + html.outputLocation.set(javaReportDir.map { it.dir("html") }) + xml.outputLocation.set(javaReportDir.map { it.file("jacoco.xml") }) } } From cc572d02ede7c40a437bf3ec1bba380ea9d69c22 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Wed, 14 Oct 2026 07:44:18 +0000 Subject: [PATCH 04/10] Fix musl --- .../datadoghq/profiler/ProfilerTestPlugin.kt | 20 ++++++++++++++++ ddprof-lib/build.gradle.kts | 8 ++++++- ddprof-test/build.gradle.kts | 24 ++++++++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt index b7fbae4632..c3faa324bf 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt @@ -78,6 +78,23 @@ import javax.inject.Inject */ class ProfilerTestPlugin : Plugin { + companion object { + private const val ADDITIONAL_JVM_ARGS_EXTENSION = "additionalJvmArgs" + + /** + * Extra JVM args a build script can contribute to a musl Exec test task (e.g. + * ddprof-test's JaCoCo agent for the coverage tasks, which Gradle only attaches to + * Test tasks). Get-or-create, since a build script's configureEach on the task can + * run before the plugin's own configuration of it. + */ + @Suppress("UNCHECKED_CAST") + fun additionalJvmArgs(task: Exec): ListProperty = + task.extensions.findByName(ADDITIONAL_JVM_ARGS_EXTENSION) as ListProperty? + ?: task.project.objects.listProperty(String::class.java).also { + task.extensions.add(ADDITIONAL_JVM_ARGS_EXTENSION, it) + } + } + override fun apply(project: Project) { val extension = project.extensions.create( "profilerTest", @@ -362,6 +379,9 @@ class ProfilerTestPlugin : Plugin { } allArgs.addAll(testConfig.extraJvmArgs) allArgs.addAll(testConfig.configJvmArgs) + // Read here, not contributed via a doFirst of the build script's own: that + // would run before this one and its args would be overwritten. + allArgs.addAll(additionalJvmArgs(execTask).get()) // System properties testConfig.systemProperties.forEach { (key, value) -> diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index aa3ff07bd3..7274a4682e 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -238,7 +238,13 @@ if (project.hasProperty("enableCoverage")) { throw GradleException("gcovr failed (exit $exitCode) -- install it with 'pip install gcovr' if missing") } project.logger.lifecycle("C++ coverage report written to file://${outDir.absolutePath}/index.html") - project.logger.lifecycle("Java coverage report written to file://${outDir.parentFile.absolutePath}/java/html/index.html") + val javaReport = outDir.resolveSibling("java/html/index.html") + if (javaReport.isFile) { + project.logger.lifecycle("Java coverage report written to file://${javaReport.absolutePath}") + } else { + // jacocoTestReport is skipped when no test task wrote JaCoCo execution data. + project.logger.warn("No Java coverage report: no JaCoCo execution data was recorded") + } } } } diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index ece208cefa..0ed73239cf 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -4,6 +4,7 @@ */ import com.datadoghq.profiler.ProfilerTestExtension +import com.datadoghq.profiler.ProfilerTestPlugin import org.gradle.api.tasks.SourceSetContainer import java.util.concurrent.ConcurrentHashMap @@ -17,7 +18,7 @@ plugins { } jacoco { - toolVersion = "0.8.12" + toolVersion = "0.8.14" } // :ddprof-lib:coverageReport produces both the Java (JaCoCo) and the C++ (gcovr) report @@ -81,6 +82,27 @@ tasks.withType().configureEach { } } +// On musl the profiler-test plugin creates the test tasks as Exec tasks (running its own +// ProfilerTestRunner), which the jacoco plugin doesn't instrument -- attach the agent +// by hand so they write the same build/jacoco/.exec file a Test task would. +// The agent jar ships nested inside the org.jacoco.agent artifact (`jacocoAgent`). +val extractJacocoAgent = tasks.register("extractJacocoAgent") { + from(configurations.named("jacocoAgent").map { zipTree(it.singleFile) }) { + include("jacocoagent.jar") + } + into(layout.buildDirectory.dir("tmp/jacocoAgent")) +} + +tasks.withType().matching { it.name in coverageTaskNames }.configureEach { + dependsOn(extractJacocoAgent) + val agentJar = extractJacocoAgent.map { it.destinationDir.resolve("jacocoagent.jar") } + val execFile = layout.buildDirectory.file("jacoco/$name.exec") + outputs.file(execFile) + ProfilerTestPlugin.additionalJvmArgs(this).add( + provider { "-javaagent:${agentJar.get().absolutePath}=destfile=${execFile.get().asFile.absolutePath},append=false" }, + ) +} + // Every test JVM that loads the coverage library writes .gcda counts next to the // library's objects when it exits, and :ddprof-lib:coverageReport folds them into the // C++ report. gcov adds to existing .gcda counts, so clear them once before the coverage From e1cd10266f9d13b20f1942bdc7b571301ff3645d Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Wed, 14 Oct 2026 11:23:03 +0000 Subject: [PATCH 05/10] musl/c++ coverage --- ddprof-lib/build.gradle.kts | 20 +++++++++++++++----- utils/run-containers-tests.sh | 12 +++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index 7274a4682e..316daacf78 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -227,15 +227,25 @@ if (project.hasProperty("enableCoverage")) { libObjDir.get().asFile.absolutePath, ) project.logger.lifecycle("Running: ${cmd.joinToString(" ")}") - val process = ProcessBuilder(cmd) - .directory(project.projectDir) - .redirectErrorStream(true) - .start() + // A missing gcovr surfaces here as a bare IOException, after the Java report has + // already been written -- turn it into the install hint instead. + val process = try { + ProcessBuilder(cmd) + .directory(project.projectDir) + .redirectErrorStream(true) + .start() + } catch (e: java.io.IOException) { + throw GradleException( + "gcovr not found -- install it with 'pip install gcovr' (Alpine/musl: 'apk add py3-pip && " + + "pip install --break-system-packages gcovr'; distro gcovr packages may be too old for the flags used here)", + e, + ) + } val output = process.inputStream.bufferedReader().readText() val exitCode = process.waitFor() println(output) if (exitCode != 0) { - throw GradleException("gcovr failed (exit $exitCode) -- install it with 'pip install gcovr' if missing") + throw GradleException("gcovr failed (exit $exitCode) -- see its output above") } project.logger.lifecycle("C++ coverage report written to file://${outDir.absolutePath}/index.html") val javaReport = outDir.resolveSibling("java/html/index.html") diff --git a/utils/run-containers-tests.sh b/utils/run-containers-tests.sh index fa2a1f9e24..c6c90af25c 100755 --- a/utils/run-containers-tests.sh +++ b/utils/run-containers-tests.sh @@ -1026,11 +1026,14 @@ FROM alpine:3.21 # - compiler-rt provides sanitizer runtimes (ASan, TSan) for clang # - llvm provides libFuzzer # - openssh-client for git clone over SSH +# - py3-pip + gcovr for :ddprof-lib:coverageReport (-PenableCoverage); Alpine +# doesn't package gcovr, so it comes from PyPI RUN apk update && \ apk add --no-cache \ curl wget bash make g++ clang git jq cmake coreutils \ gtest-dev gmock tar binutils musl-dbg linux-headers \ - compiler-rt llvm openssh-client gdb + compiler-rt llvm openssh-client gdb py3-pip && \ + pip install --no-cache-dir --break-system-packages gcovr # Set up Gradle cache directory ENV GRADLE_USER_HOME=/gradle-cache @@ -1075,13 +1078,16 @@ ENV DEBIAN_FRONTEND=noninteractive # - libclang-rt-dev for clang sanitizers and libFuzzer (x64 only) # - gnupg for LLVM apt key import # - openssh-client for git clone over SSH +# - python3-pip + gcovr for :ddprof-lib:coverageReport (-PenableCoverage); the +# distro's gcovr 5.0 predates flags the report task passes, so use PyPI's RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl wget gnupg bash make g++ clang git jq cmake \ libgtest-dev libgmock-dev tar binutils libc6-dbg \ ca-certificates linux-libc-dev \ - libasan6 libtsan0 ${CLANG_RT_PKG} openssh-client build-essential gdb && \ - rm -rf /var/lib/apt/lists/* + libasan6 libtsan0 ${CLANG_RT_PKG} openssh-client build-essential gdb python3-pip && \ + rm -rf /var/lib/apt/lists/* && \ + pip3 install --no-cache-dir gcovr ${CLANG17_BLOCK} # Set up Gradle cache directory From ddd1fe5f89f7c21c1fc3a2042a2cc48d94783ec3 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Thu, 15 Oct 2026 11:18:58 +0000 Subject: [PATCH 06/10] fix --- .../com/datadoghq/native/gtest/GtestPlugin.kt | 17 +- .../datadoghq/native/util/PlatformUtils.kt | 18 ++ ddprof-lib/build.gradle.kts | 191 +++++++++++++++--- 3 files changed, 185 insertions(+), 41 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt index d01da8087e..409a705153 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt @@ -76,7 +76,7 @@ class GtestPlugin : Plugin { } // Check if gtest is available - val hasGtest = checkGtestAvailable() + val hasGtest = PlatformUtils.isGtestAvailable() if (!hasGtest) { project.logger.warn("WARNING: Google Test not found - skipping native tests") } @@ -96,21 +96,6 @@ class GtestPlugin : Plugin { } } - private fun checkGtestAvailable(): Boolean { - // Check common gtest locations - val locations = when (PlatformUtils.currentPlatform) { - Platform.MACOS -> listOf( - "/opt/homebrew/opt/googletest", - "/usr/local/opt/googletest" - ) - Platform.LINUX -> listOf( - "/usr/include/gtest", - "/usr/local/include/gtest" - ) - } - return locations.any { File(it).exists() } - } - private fun createBuildNativeLibsTask(project: Project, extension: GtestExtension, hasGtest: Boolean) { project.tasks.register("buildNativeLibs") { group = "build" diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt index fcedd69b63..2ce3889cee 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt @@ -62,6 +62,24 @@ object PlatformUtils { ) } + /** + * Google Test install locations probed by the gtest plugin (and by the coverage + * preflight check, since a missing gtest silently drops the gtest half of coverage). + */ + val gtestLocations: List + get() = when (currentPlatform) { + Platform.MACOS -> listOf( + "/opt/homebrew/opt/googletest", + "/usr/local/opt/googletest" + ) + Platform.LINUX -> listOf( + "/usr/include/gtest", + "/usr/local/include/gtest" + ) + } + + fun isGtestAvailable(): Boolean = gtestLocations.any { File(it).exists() } + /** * Check if a compiler is available and functional */ diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index 316daacf78..b0e8fe486f 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -174,6 +174,165 @@ afterEvaluate { if (project.hasProperty("enableCoverage")) { val gtestObjDir = layout.buildDirectory.dir("obj/gtest/coverage") + // The gcov tool must come from the same toolchain that wrote the .gcno files, + // so derive it from the compiler (e.g. /usr/bin/g++-13 -> /usr/bin/gcov-13, + // clang++-17 -> llvm-cov-17 gcov) instead of whatever `gcov` is on PATH. + fun gcovCommand(compilerPath: String): List { + val compiler = File(compilerPath) + fun siblingOf(tool: String) = compiler.parentFile?.resolve(tool)?.path ?: tool + return when { + PlatformUtils.currentPlatform == Platform.MACOS -> listOf("xcrun", "llvm-cov", "gcov") + compiler.name.contains("clang") -> listOf(siblingOf(compiler.name.replace(Regex("clang(\\+\\+)?"), "llvm-cov")), "gcov") + else -> listOf(siblingOf(compiler.name.replace(Regex("[gc]\\+\\+"), "gcov"))) + } + } + + // gcovr flags coverageReport depends on (see its command line below); 8.3 is the + // first gcovr that accepts all of them. + val requiredGcovrFlags = listOf("--gcov-ignore-parse-errors", "negative_hits.warn_once_per_file", "--gcov-suspicious-hits-threshold") + + // Fails fast -- before ~30 minutes of instrumented builds and test runs -- when a tool + // the coverage run needs is missing, reporting every problem at once with how to fix it. + // Every coverage task depends on it (below, and :ddprof-test's coverage test tasks). + val checkCoverageTools = tasks.register("checkCoverageTools") { + group = "verification" + description = "Checks that the tools needed for the coverage build and report are installed" + val reportRequested = provider { gradle.taskGraph.hasTask("${project.path}:coverageReport") } + + doLast { + // Fetched here, not at configuration time: temporaryDir (re)creates the directory, + // which a `clean` in the same invocation would otherwise have deleted by now. + val probeDir = temporaryDir + val isMacos = PlatformUtils.currentPlatform == Platform.MACOS + val isMusl = PlatformUtils.isMusl() + fun hint(musl: String, glibc: String, macos: String) = when { + isMacos -> macos + isMusl -> musl + else -> glibc + } + + // Runs cmd and returns (exit code, combined output), or null if it cannot be started. + fun run(cmd: List): Pair? = try { + val process = ProcessBuilder(cmd).directory(probeDir).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() to output.trim() + } catch (e: java.io.IOException) { + null + } + // The first line carrying a version number, which isn't always the first line + // (llvm-cov starts with "LLVM (http://llvm.org/):"). + val versionNumber = Regex("\\d+\\.\\d+") + fun firstLine(output: String) = output.lineSequence().map { it.trim() }.filter { it.isNotEmpty() } + .let { lines -> lines.firstOrNull { versionNumber.containsMatchIn(it) } ?: lines.firstOrNull() }.orEmpty() + + val found = mutableListOf() + val problems = mutableListOf() + + // 1. C++ compiler able to build *and link* --coverage code (clang needs its profile + // runtime, gcc its libgcov -- both shipped separately on some distros). + val compiler = try { + PlatformUtils.findCompiler(project) + } catch (e: GradleException) { + problems += "C++ compiler: ${e.message}" + null + } + if (compiler != null) { + val src = probeDir.resolve("probe.cpp").apply { writeText("int main() { return 0; }\n") } + val result = run(listOf(compiler, "--coverage", "-fprofile-update=atomic", src.path, "-o", probeDir.resolve("probe").path)) + if (result != null && result.first == 0) { + found += "compiler: $compiler (links with --coverage)" + } else { + problems += "C++ compiler '$compiler' cannot build with --coverage -fprofile-update=atomic " + + "(needed for the instrumented library and gtests):\n" + + (result?.second ?: "could not run '$compiler'").prependIndent(" ") + "\n" + + " Install the coverage runtime: " + + hint( + musl = "'apk add compiler-rt' (clang) or 'apk add g++' (gcc)", + glibc = "'apt install libclang-rt-dev' or the libclang-rt--dev matching your clang (clang), or libgcc--dev (gcc)", + macos = "Xcode command line tools: 'xcode-select --install'", + ) + ", or pick another compiler with -Pnative.forceCompiler=" + } + + // 2. gcov tool matching that compiler (used by gcovr to read the .gcda files). + val gcov = gcovCommand(compiler) + val gcovResult = run(gcov + "--version") + if (gcovResult != null && gcovResult.first == 0) { + found += "gcov: ${gcov.joinToString(" ")} (${firstLine(gcovResult.second)})" + } else { + problems += "gcov tool '${gcov.joinToString(" ")}' (must match compiler '$compiler') " + + (if (gcovResult == null) "not found" else "failed: ${firstLine(gcovResult.second)}") + "\n" + + " Install it: " + + hint( + musl = "'apk add llvm' (clang) or 'apk add gcc' (gcc)", + glibc = "'apt install llvm' or the llvm- matching your clang (clang), or gcc- (gcc)", + macos = "Xcode command line tools: 'xcode-select --install'", + ) + } + } + + // 3. Google Test: without it the gtest tasks are skipped with only a warning, and + // the C++ report silently loses the gtest half of its coverage. + if (PlatformUtils.isGtestAvailable()) { + found += "gtest: ${PlatformUtils.gtestLocations.first { File(it).exists() }}" + } else { + problems += "Google Test not found (looked in ${PlatformUtils.gtestLocations.joinToString()}); " + + "gtestCoverage would be skipped\n" + + " Install it: " + + hint(musl = "'apk add gtest-dev gmock'", glibc = "'apt install libgtest-dev libgmock-dev'", macos = "'brew install googletest'") + } + + // 4. make: buildNativeLibs builds the gtests' helper libraries with it (Linux only). + if (!isMacos) { + val makeResult = run(listOf("make", "--version")) + if (makeResult != null && makeResult.first == 0) { + found += "make: ${firstLine(makeResult.second)}" + } else { + problems += "make not found (buildNativeLibs needs it for the gtests' native libraries)\n" + + " Install it: " + hint(musl = "'apk add make'", glibc = "'apt install make'", macos = "") + } + } + + // 5. gcovr, only when the report is actually requested (gtestCoverage/testCoverage + // alone just produce .gcda files). + if (reportRequested.get()) { + val gcovrInstall = hint( + musl = "'apk add py3-pip && pip install --break-system-packages gcovr'", + glibc = "'pip install gcovr' (distro packages are usually too old)", + macos = "'pip install gcovr' or 'brew install gcovr'", + ) + val version = run(listOf("gcovr", "--version")) + if (version == null || version.first != 0) { + problems += "gcovr not found (generates the C++ report)\n Install it: $gcovrInstall" + } else { + val help = run(listOf("gcovr", "--help"))?.second.orEmpty() + val missingFlags = requiredGcovrFlags.filterNot { help.contains(it) } + if (missingFlags.isEmpty()) { + found += "gcovr: ${firstLine(version.second)}" + } else { + problems += "${firstLine(version.second)} is too old: it lacks ${missingFlags.joinToString()} (gcovr 8.3+ needed)\n" + + " Upgrade it: ${gcovrInstall.replace("pip install", "pip install --upgrade")}" + } + } + } + + found.forEach { project.logger.lifecycle(" [ok] $it") } + if (problems.isNotEmpty()) { + throw GradleException( + "Coverage tools check failed -- ${problems.size} problem(s), no coverage build or test was run:\n" + + problems.joinToString("\n") { " - $it" }, + ) + } + } + } + tasks.matching { it.name != "checkCoverageTools" && it.name.contains("Coverage") }.configureEach { + dependsOn(checkCoverageTools) + } + // Not a coverage task by name, but gtestCoverage needs it and it fails without make -- + // let the check report that instead. + tasks.matching { it.name == "buildNativeLibs" }.configureEach { + mustRunAfter(checkCoverageTools) + } + // gcov adds to existing .gcda counts, and .gcda files left over from before a // recompile no longer match the new .gcno stamps -- so every gtest binary must start // from zero. Runs before each gtestCoverage_ task rather than in gtestCoverage @@ -187,7 +346,7 @@ if (project.hasProperty("enableCoverage")) { tasks.register("coverageReport") { group = "verification" - description = "Generates the Java (JaCoCo) and C++ (gcovr) coverage reports from gtestCoverage and :ddprof-test:testCoverage/testSlowCoverage (requires gcovr)" + description = "Generates the Java (JaCoCo) and C++ (gcovr) coverage reports from gtestCoverage and :ddprof-test:testCoverage/testSlowCoverage (requires gcovr 8.3+; see checkCoverageTools)" dependsOn("gtestCoverage", ":ddprof-test:testCoverage", ":ddprof-test:testSlowCoverage", ":ddprof-test:jacocoTestReport") val reportDir = layout.buildDirectory.dir("reports/coverage/cpp") @@ -195,16 +354,7 @@ if (project.hasProperty("enableCoverage")) { val srcRoot = project.file("src/main/cpp") doLast { - // The gcov tool must come from the same toolchain that wrote the .gcno files, - // so derive it from the compiler (e.g. /usr/bin/g++-13 -> /usr/bin/gcov-13, - // clang++-17 -> llvm-cov-17 gcov) instead of whatever `gcov` is on PATH. - val compiler = File(PlatformUtils.findCompiler(project)) - fun siblingOf(tool: String) = compiler.parentFile?.resolve(tool)?.path ?: tool - val gcovExecutable = when { - PlatformUtils.currentPlatform == Platform.MACOS -> "xcrun llvm-cov gcov" - compiler.name.contains("clang") -> siblingOf(compiler.name.replace(Regex("clang(\\+\\+)?"), "llvm-cov")) + " gcov" - else -> siblingOf(compiler.name.replace(Regex("[gc]\\+\\+"), "gcov")) - } + val gcovExecutable = gcovCommand(PlatformUtils.findCompiler(project)).joinToString(" ") val outDir = reportDir.get().asFile.also { it.mkdirs() } val cmd = listOf( @@ -227,20 +377,11 @@ if (project.hasProperty("enableCoverage")) { libObjDir.get().asFile.absolutePath, ) project.logger.lifecycle("Running: ${cmd.joinToString(" ")}") - // A missing gcovr surfaces here as a bare IOException, after the Java report has - // already been written -- turn it into the install hint instead. - val process = try { - ProcessBuilder(cmd) - .directory(project.projectDir) - .redirectErrorStream(true) - .start() - } catch (e: java.io.IOException) { - throw GradleException( - "gcovr not found -- install it with 'pip install gcovr' (Alpine/musl: 'apk add py3-pip && " + - "pip install --break-system-packages gcovr'; distro gcovr packages may be too old for the flags used here)", - e, - ) - } + // checkCoverageTools has already verified gcovr is installed and new enough. + val process = ProcessBuilder(cmd) + .directory(project.projectDir) + .redirectErrorStream(true) + .start() val output = process.inputStream.bufferedReader().readText() val exitCode = process.waitFor() println(output) From 50984e087aa07e8b943de0336a6b15e76b322df3 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Fri, 23 Oct 2026 08:57:08 -0400 Subject: [PATCH 07/10] Fix coverage build review findings - Build the coverage config on configureDebug and add only the --coverage flags, so it measures the same binary as testDebug. - Add a BuildConfiguration.publishable flag (false for coverage) and filter packaging/publishing on it instead of the "coverage" name. - Pick Apple's llvm-cov on macOS only when the compiler is Apple clang, so a Homebrew g++- gets its matching gcov-. - Always rerun testCoverage/testSlowCoverage, since cleanLibCoverageData always clears the .gcda counts (the musl Exec tasks could otherwise be UP-TO-DATE). - Gate the coverage test tasks and cleanLibCoverageData on :ddprof-lib:checkCoverageTools. - Point jacocoTestReport at the known .exec files instead of walking the build directory, and use tasks.named { } instead of tasks.matching { }. - Treat the coverage build as a debug build in LazyTraceIdJfrValidationTest, ReferenceChainAssertions and BaseContextWallClockTest. - Install llvm in the Ubuntu test image for the unversioned llvm-cov. Co-Authored-By: Claude Opus 5.5 --- .../native/config/ConfigurationPresets.kt | 26 +++--------- .../native/model/BuildConfiguration.kt | 12 ++++++ ddprof-lib/build.gradle.kts | 41 ++++++++++++------- ddprof-test/build.gradle.kts | 31 ++++++++------ .../cpu/LazyTraceIdJfrValidationTest.java | 3 +- .../ReferenceChainAssertions.java | 5 ++- .../wallclock/BaseContextWallClockTest.java | 3 +- utils/run-containers-tests.sh | 4 +- 8 files changed, 73 insertions(+), 52 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt index 183482a80e..1211be9060 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt @@ -443,26 +443,12 @@ object ConfigurationPresets { version: String, active: Boolean ) { - config.platform.set(platform) - config.architecture.set(architecture) + // Built on configureDebug so coverage measures the same binary testDebug runs + // (including -z,nodelete on Linux), only with the instrumentation added. + configureDebug(config, platform, architecture, version) config.active.set(active) - - when (platform) { - Platform.LINUX -> { - config.compilerArgs.set( - listOf("-O0", "-g", "-DDEBUG", "--coverage", "-fprofile-update=atomic") + commonLinuxCompilerArgs(version) - ) - // -z,nodelete: same as configureDebug -- testCoverage loads this library - // into the same JVMs, so it is exposed to the same unmap-while-executing - // SIGSEGV on the DestroyJavaVM path. - config.linkerArgs.set(commonLinuxLinkerArgs() + listOf("-Wl,-z,nodelete", "--coverage")) - } - Platform.MACOS -> { - config.compilerArgs.set( - commonMacosCompilerArgs(version) + listOf("-O0", "-g", "-DDEBUG", "--coverage", "-fprofile-update=atomic") - ) - config.linkerArgs.set(listOf("--coverage")) - } - } + config.publishable.set(false) + config.compilerArgs.addAll("--coverage", "-fprofile-update=atomic") + config.linkerArgs.add("--coverage") } } diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/model/BuildConfiguration.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/model/BuildConfiguration.kt index 1224abd67c..233abab22d 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/model/BuildConfiguration.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/model/BuildConfiguration.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ package com.datadoghq.native.model @@ -18,11 +22,19 @@ abstract class BuildConfiguration @Inject constructor( abstract val testJvmArgs: ListProperty abstract val active: Property + /** + * Whether this configuration's jar is part of the published artifact set. An + * unpublishable configuration (e.g. the opt-in coverage build) is only packaged + * while it is active, for in-repo consumers such as the test projects. + */ + abstract val publishable: Property + override fun getName(): String = configName init { // Default to active unless overridden active.convention(true) + publishable.convention(true) testEnvironment.convention(emptyMap()) testJvmArgs.convention(emptyList()) } diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index 306c7e4f24..d8051f7406 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -129,15 +129,15 @@ val copyExternalLibs by tasks.registering(Copy::class) { // Create JAR tasks for each build configuration using nativeBuild extension utilities // Uses afterEvaluate to discover configurations dynamically from NativeBuildExtension. -// `coverage` is only packaged with -PenableCoverage: nativeBuild.buildConfigurations.names -// lists every registered config regardless of its `active` flag (the same quirk already -// lets inactive asan/tsan show up here on platforms where they're unsupported). With the -// flag, its jar and consumable configuration are what ddprof-test's testCoverage loads; -// it is still never published (see the publishing block below). +// Every publishable config is packaged, even an inactive one (inactive asan/tsan on +// platforms where they're unsupported still get a jar). An unpublishable config (e.g. +// `coverage`) is packaged only while active: its jar and consumable configuration are +// what ddprof-test's testCoverage loads, and it is never published (see the publishing +// block below). afterEvaluate { - val packagedConfigNames = nativeBuild.buildConfigurations.names.filter { - it != "coverage" || project.hasProperty("enableCoverage") - } + val packagedConfigNames = nativeBuild.buildConfigurations + .filter { it.publishable.get() || it.active.get() } + .map { it.name } packagedConfigNames.forEach { name -> val capitalizedName = name.replaceFirstChar { it.uppercase() } @@ -206,6 +206,16 @@ afterEvaluate { if (project.hasProperty("enableCoverage")) { val gtestObjDir = layout.buildDirectory.dir("obj/gtest/coverage") + // Whether the compiler is Apple's clang. On macOS that includes /usr/bin/g++, a clang + // shim, so it is decided from the `--version` banner, not from the name; a Homebrew + // g++- prints the GCC banner instead. + fun isAppleClang(compilerPath: String): Boolean = try { + val process = ProcessBuilder(compilerPath, "--version").redirectErrorStream(true).start() + process.inputStream.bufferedReader().readText().also { process.waitFor() }.contains("Apple clang") + } catch (e: java.io.IOException) { + false + } + // The gcov tool must come from the same toolchain that wrote the .gcno files, // so derive it from the compiler (e.g. /usr/bin/g++-13 -> /usr/bin/gcov-13, // clang++-17 -> llvm-cov-17 gcov) instead of whatever `gcov` is on PATH. @@ -213,7 +223,7 @@ if (project.hasProperty("enableCoverage")) { val compiler = File(compilerPath) fun siblingOf(tool: String) = compiler.parentFile?.resolve(tool)?.path ?: tool return when { - PlatformUtils.currentPlatform == Platform.MACOS -> listOf("xcrun", "llvm-cov", "gcov") + PlatformUtils.currentPlatform == Platform.MACOS && isAppleClang(compilerPath) -> listOf("xcrun", "llvm-cov", "gcov") compiler.name.contains("clang") -> listOf(siblingOf(compiler.name.replace(Regex("clang(\\+\\+)?"), "llvm-cov")), "gcov") else -> listOf(siblingOf(compiler.name.replace(Regex("[gc]\\+\\+"), "gcov"))) } @@ -225,7 +235,8 @@ if (project.hasProperty("enableCoverage")) { // Fails fast -- before ~30 minutes of instrumented builds and test runs -- when a tool // the coverage run needs is missing, reporting every problem at once with how to fix it. - // Every coverage task depends on it (below, and :ddprof-test's coverage test tasks). + // Every coverage task depends on it (below, and :ddprof-test's coverage test tasks and + // cleanLibCoverageData). val checkCoverageTools = tasks.register("checkCoverageTools") { group = "verification" description = "Checks that the tools needed for the coverage build and report are installed" @@ -356,12 +367,12 @@ if (project.hasProperty("enableCoverage")) { } } } - tasks.matching { it.name != "checkCoverageTools" && it.name.contains("Coverage") }.configureEach { + tasks.named { it != "checkCoverageTools" && it.contains("Coverage") }.configureEach { dependsOn(checkCoverageTools) } // Not a coverage task by name, but gtestCoverage needs it and it fails without make -- // let the check report that instead. - tasks.matching { it.name == "buildNativeLibs" }.configureEach { + tasks.named { it == "buildNativeLibs" }.configureEach { mustRunAfter(checkCoverageTools) } @@ -372,7 +383,7 @@ if (project.hasProperty("enableCoverage")) { val cleanGtestCoverageData = tasks.register("cleanGtestCoverageData") { delete(fileTree(gtestObjDir) { include("**/*.gcda") }) } - tasks.matching { it.name.startsWith("gtestCoverage_") }.configureEach { + tasks.named { it.startsWith("gtestCoverage_") }.configureEach { dependsOn(cleanGtestCoverageData) } @@ -480,10 +491,10 @@ publishing { groupId = "com.datadoghq" artifactId = "ddprof" - // Add artifacts from each build configuration (coverage excluded -- see the + // Add artifacts from each publishable build configuration (see the // packagedConfigNames comment above) afterEvaluate { - nativeBuild.buildConfigurations.names.filterNot { it == "coverage" }.forEach { name -> + nativeBuild.buildConfigurations.filter { it.publishable.get() }.map { it.name }.forEach { name -> val capitalizedName = name.replaceFirstChar { it.uppercase() } artifact(tasks.named("assemble${capitalizedName}Jar")) } diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index 86b22b9c4d..57fde0c71a 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -33,6 +33,7 @@ jacoco { // and stack-sampling data those tests assert on -- and asan/tsan already carry sanitizer // instrumentation. val coverageTaskNames: Set = setOf("testCoverage", "testSlowCoverage") +val enableCoverage = project.hasProperty("enableCoverage") val requestingCoverageReport = gradle.startParameter.taskNames.any { it.contains("coverageReport") } // When a coverage report is requested, a handful of known-flaky native-thread/CPU tests @@ -93,7 +94,7 @@ val extractJacocoAgent = tasks.register("extractJacocoAgent") { into(layout.buildDirectory.dir("tmp/jacocoAgent")) } -tasks.withType().matching { it.name in coverageTaskNames }.configureEach { +tasks.withType().named { it in coverageTaskNames }.configureEach { dependsOn(extractJacocoAgent) val agentJar = extractJacocoAgent.map { it.destinationDir.resolve("jacocoagent.jar") } val execFile = layout.buildDirectory.file("jacoco/$name.exec") @@ -111,20 +112,26 @@ tasks.withType().matching { it.name in coverageTaskNames }.configureEach { val libCoverageObjDir = project(":ddprof-lib").layout.buildDirectory.dir("obj/main/coverage") val cleanLibCoverageData = tasks.register("cleanLibCoverageData") { delete(fileTree(libCoverageObjDir) { include("**/*.gcda") }) + // Fail on missing coverage tools before wiping the previous run's counts. + if (enableCoverage) { + dependsOn(":ddprof-lib:checkCoverageTools") + } } -tasks.matching { it.name in coverageTaskNames }.configureEach { - dependsOn(cleanLibCoverageData) - // The failed tests' results are cached like a pass would be, so a rerun must not be - // UP-TO-DATE and silently skip the deferred failure (or the cleared .gcda counts). +tasks.named { it in coverageTaskNames }.configureEach { + dependsOn(cleanLibCoverageData, ":ddprof-lib:checkCoverageTools") + // cleanLibCoverageData always clears the .gcda counts, so the tests must always rerun to + // write them again -- never UP-TO-DATE (which the musl Exec tasks would otherwise be: they + // declare the JaCoCo .exec file as an output but no inputs). The same applies to a deferred + // failure, whose results are cached like a pass would be. + outputs.upToDateWhen { false } if (requestingCoverageReport) { deferFailuresToCoverageReport() - outputs.upToDateWhen { false } } } // Runs after coverageReport's own gcovr doLast, so both reports are written first. -project(":ddprof-lib").tasks.matching { it.name == "coverageReport" }.configureEach { +project(":ddprof-lib").tasks.named { it == "coverageReport" }.configureEach { doLast { failOnDeferredTestFailures(coverageTaskNames) } } @@ -135,12 +142,12 @@ tasks.named("jacocoTestReport") { group = "verification" description = "Java half of :ddprof-lib:coverageReport (run that instead; requires -PenableCoverage)" - val coverageTasks = tasks.matching { it.name in coverageTaskNames } - dependsOn(coverageTasks) + dependsOn(tasks.named { it in coverageTaskNames }) + // The .exec file each coverage task writes (Test tasks via the jacoco plugin, the musl Exec + // tasks via the agent attached above). Only the existing ones: a task that ran no tests + // writes none. executionData.setFrom( - coverageTasks.map { task -> - fileTree(task.project.layout.buildDirectory) { include("jacoco/${task.name}.exec") } - }, + files(coverageTaskNames.map { layout.buildDirectory.file("jacoco/$it.exec") }).filter { it.exists() }, ) // The tests here exercise ddprof-lib's classes (the profiler itself), not this diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java index 590353a55a..1cf5c0cde8 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -6,6 +6,7 @@ package com.datadoghq.profiler.cpu; import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.NativeBuildConfig; import com.datadoghq.profiler.Platform; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; @@ -52,7 +53,7 @@ public class LazyTraceIdJfrValidationTest extends AbstractProcessProfilerTest { @Timeout(120) public void calibratedTraceIdMatchesJfrClassId() throws Exception { // The profiler's side of the comparison is a debug-build log line. - Assumptions.assumeTrue("debug".equals(System.getProperty("ddprof_test.config")), "needs a debug build"); + Assumptions.assumeTrue(NativeBuildConfig.isDebug(), "needs a debug build"); Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); Assumptions.assumeTrue(jfrConsumerAvailable(), "JFR not available"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java index 68b7c58364..11c99af313 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -7,6 +7,7 @@ import com.datadoghq.profiler.JfrEvent; import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.NativeBuildConfig; import org.openjdk.jmc.common.IMCType; import org.openjdk.jmc.common.item.IAccessorKey; import org.openjdk.jmc.common.item.IItem; @@ -43,12 +44,12 @@ private ReferenceChainAssertions() {} /** * True on every native build that compiles javaApi.cpp's {@code #ifdef DEBUG} test seams: debug, - * asan and tsan (all add -DDEBUG, ConfigurationPresets.kt). Release builds get no-op seam stubs. + * coverage, asan and tsan (all add -DDEBUG, ConfigurationPresets.kt). Release builds get no-op seam stubs. * Child JVMs see the same answer because the launchers forward {@code -Dddprof_test.config}. */ public static boolean isDebugInstrumentedBuild() { String config = System.getProperty("ddprof_test.config"); - return config != null && ("debug".equals(config) || config.endsWith("san")); + return NativeBuildConfig.isDebug() || (config != null && config.endsWith("san")); } /** diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java index 10719177dc..4fa8242543 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java @@ -7,6 +7,7 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.NativeBuildConfig; import com.datadoghq.profiler.Platform; import com.datadoghq.profiler.context.ContextExecutor; import com.datadoghq.profiler.context.Tracing; @@ -182,7 +183,7 @@ void test(AbstractProfilerTest test, boolean assertContext, String cstack) throw assertFalse(states.contains("TERMINATED")); // the sanitizer configurations are not playing that well with the sample distribution // still useful to run the profiler, though - so just skipping the assertions here - if (config.equals("release") || config.equals("debug")) { + if (config.equals("release") || NativeBuildConfig.isDebug()) { double totalWeight = method1Weight + method2Weight + method3Weight + unattributedWeight; // Each method sleeps for the same duration (10ms), so each should contribute ~33%. // method1Impl's monitor.wait time is excluded from method1Weight (see attribution above) diff --git a/utils/run-containers-tests.sh b/utils/run-containers-tests.sh index 43c40e4c5a..1057b2149c 100755 --- a/utils/run-containers-tests.sh +++ b/utils/run-containers-tests.sh @@ -1080,12 +1080,14 @@ ENV DEBIAN_FRONTEND=noninteractive # - openssh-client for git clone over SSH # - python3-pip + gcovr for :ddprof-lib:coverageReport (-PenableCoverage); the # distro's gcovr 5.0 predates flags the report task passes, so use PyPI's +# - llvm for the unversioned llvm-cov that coverageReport pairs with the +# unversioned clang++ (the clang package doesn't pull it in) RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl wget gnupg bash make g++ clang git jq cmake \ libgtest-dev libgmock-dev tar binutils libc6-dbg \ ca-certificates linux-libc-dev \ - libasan6 libtsan0 ${CLANG_RT_PKG} openssh-client build-essential gdb python3-pip && \ + libasan6 libtsan0 ${CLANG_RT_PKG} openssh-client build-essential gdb python3-pip llvm && \ rm -rf /var/lib/apt/lists/* && \ pip3 install --no-cache-dir gcovr ${CLANG17_BLOCK} From b7cc60dd1f2f51bc0314dd3248c1818635febf6a Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Fri, 23 Oct 2026 14:20:05 +0000 Subject: [PATCH 08/10] Fix --- ddprof-lib/build.gradle.kts | 8 +++-- ddprof-test/build.gradle.kts | 32 +++++++++++++++++-- .../profiler/AbstractProcessProfilerTest.java | 21 ++++++++++++ .../datadoghq/profiler/NativeBuildConfig.java | 10 ++++++ .../profiler/UnwindFailuresTest.java | 3 +- .../ReferenceChainAssertions.java | 3 +- 6 files changed, 67 insertions(+), 10 deletions(-) diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index d8051f7406..878cdc359e 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -211,7 +211,9 @@ if (project.hasProperty("enableCoverage")) { // g++- prints the GCC banner instead. fun isAppleClang(compilerPath: String): Boolean = try { val process = ProcessBuilder(compilerPath, "--version").redirectErrorStream(true).start() - process.inputStream.bufferedReader().readText().also { process.waitFor() }.contains("Apple clang") + val output = process.inputStream.bufferedReader().use { it.readText() } + process.waitFor() + output.contains("Apple clang") } catch (e: java.io.IOException) { false } @@ -257,7 +259,7 @@ if (project.hasProperty("enableCoverage")) { // Runs cmd and returns (exit code, combined output), or null if it cannot be started. fun run(cmd: List): Pair? = try { val process = ProcessBuilder(cmd).directory(probeDir).redirectErrorStream(true).start() - val output = process.inputStream.bufferedReader().readText() + val output = process.inputStream.bufferedReader().use { it.readText() } process.waitFor() to output.trim() } catch (e: java.io.IOException) { null @@ -425,7 +427,7 @@ if (project.hasProperty("enableCoverage")) { .directory(project.projectDir) .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().readText() + val output = process.inputStream.bufferedReader().use { it.readText() } val exitCode = process.waitFor() println(output) if (exitCode != 0) { diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index 57fde0c71a..6309258d39 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -83,6 +83,19 @@ tasks.withType().configureEach { } } +// Pass JaCoCo agent info to Test tasks (glibc/macOS) so AbstractProcessProfilerTest can +// forward coverage to child JVMs. The JaCoCo extension already attaches the agent; we just +// need to tell the test code where to find it for child processes. +tasks.withType().named { it in coverageTaskNames }.configureEach { + dependsOn(extractJacocoAgent) + val agentJar = extractJacocoAgent.map { it.destinationDir.resolve("jacocoagent.jar") } + doFirst { + childJacocoDir.get().asFile.mkdirs() + environment("JACOCO_AGENT_JAR", agentJar.get().absolutePath) + environment("JACOCO_CHILD_DIR", childJacocoDir.get().asFile.absolutePath) + } +} + // On musl the profiler-test plugin creates the test tasks as Exec tasks (running its own // ProfilerTestRunner), which the jacoco plugin doesn't instrument -- attach the agent // by hand so they write the same build/jacoco/.exec file a Test task would. @@ -94,6 +107,10 @@ val extractJacocoAgent = tasks.register("extractJacocoAgent") { into(layout.buildDirectory.dir("tmp/jacocoAgent")) } +// Directory where child JVMs spawned by AbstractProcessProfilerTest.launch() write their +// JaCoCo execution data. Each child uses a unique file name to avoid overwrites. +val childJacocoDir = layout.buildDirectory.dir("jacoco/children") + tasks.withType().named { it in coverageTaskNames }.configureEach { dependsOn(extractJacocoAgent) val agentJar = extractJacocoAgent.map { it.destinationDir.resolve("jacocoagent.jar") } @@ -102,6 +119,12 @@ tasks.withType().named { it in coverageTaskNames }.configureEach { ProfilerTestPlugin.additionalJvmArgs(this).add( provider { "-javaagent:${agentJar.get().absolutePath}=destfile=${execFile.get().asFile.absolutePath},append=false" }, ) + // Pass the agent jar path to AbstractProcessProfilerTest so it can forward coverage to child JVMs. + doFirst { + childJacocoDir.get().asFile.mkdirs() + (this as Exec).environment("JACOCO_AGENT_JAR", agentJar.get().absolutePath) + (this as Exec).environment("JACOCO_CHILD_DIR", childJacocoDir.get().asFile.absolutePath) + } } // Every test JVM that loads the coverage library writes .gcda counts next to the @@ -112,6 +135,8 @@ tasks.withType().named { it in coverageTaskNames }.configureEach { val libCoverageObjDir = project(":ddprof-lib").layout.buildDirectory.dir("obj/main/coverage") val cleanLibCoverageData = tasks.register("cleanLibCoverageData") { delete(fileTree(libCoverageObjDir) { include("**/*.gcda") }) + // Also clear child JVM JaCoCo data from the previous run. + delete(fileTree(childJacocoDir) { include("*.exec") }) // Fail on missing coverage tools before wiping the previous run's counts. if (enableCoverage) { dependsOn(":ddprof-lib:checkCoverageTools") @@ -143,11 +168,12 @@ tasks.named("jacocoTestReport") { description = "Java half of :ddprof-lib:coverageReport (run that instead; requires -PenableCoverage)" dependsOn(tasks.named { it in coverageTaskNames }) - // The .exec file each coverage task writes (Test tasks via the jacoco plugin, the musl Exec - // tasks via the agent attached above). Only the existing ones: a task that ran no tests - // writes none. + // The .exec files from: (1) the main test tasks (Test via jacoco plugin, musl Exec via agent + // attached above), and (2) child JVMs spawned by AbstractProcessProfilerTest.launch(). + // Only existing files: a task that ran no tests writes none. executionData.setFrom( files(coverageTaskNames.map { layout.buildDirectory.file("jacoco/$it.exec") }).filter { it.exists() }, + fileTree(childJacocoDir) { include("*.exec") }, ) // The tests here exercise ddprof-lib's classes (the profiler itself), not this diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java index 78ea02983f..30eb6db08e 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; import java.util.function.Function; @@ -25,6 +26,20 @@ public abstract class AbstractProcessProfilerTest { // descendant that inherited the pipes from keeping launch() from returning. private static final long READER_JOIN_TIMEOUT_MS = 10_000; + // Counter for generating unique JaCoCo execution data file names for child JVMs. + private static final AtomicLong CHILD_COUNTER = new AtomicLong(); + + /** + * JaCoCo agent jar path, set by the coverage test tasks via JACOCO_AGENT_JAR environment + * variable. When non-null, child JVMs are instrumented for coverage. + */ + private static final String JACOCO_AGENT_JAR = System.getenv("JACOCO_AGENT_JAR"); + + /** + * Directory where child JVM JaCoCo execution data is written, set via JACOCO_CHILD_DIR. + */ + private static final String JACOCO_CHILD_DIR = System.getenv("JACOCO_CHILD_DIR"); + public static final class LaunchResult { public final boolean inTime; public final int exitCode; @@ -77,6 +92,12 @@ protected final LaunchResult launch(String target, List jvmArgs, String List args = new ArrayList<>(); args.add(javaHome + "/bin/java"); + // Forward JaCoCo agent to child JVM for coverage collection if configured. + if (JACOCO_AGENT_JAR != null && JACOCO_CHILD_DIR != null) { + long childId = CHILD_COUNTER.incrementAndGet(); + String execFile = JACOCO_CHILD_DIR + "/child-" + childId + "-" + System.currentTimeMillis() + ".exec"; + args.add("-javaagent:" + JACOCO_AGENT_JAR + "=destfile=" + execFile + ",append=false"); + } args.addAll(jvmArgs); args.add("-cp"); args.add(System.getProperty("java.class.path")); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java b/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java index c0270b9e77..fdccccad0e 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java @@ -28,4 +28,14 @@ public static boolean isDebug() { String config = name(); return "debug".equals(config) || "coverage".equals(config); } + + /** + * Whether the library is built with {@code -DDEBUG}, which compiles in debug-only code paths + * like {@code javaApi.cpp}'s test seams and {@code datadog.UnwindFailure} recording. This + * includes {@link #isDebug()} builds (debug, coverage) plus sanitizer builds (asan, tsan). + */ + public static boolean isDebugInstrumented() { + String config = name(); + return isDebug() || (config != null && config.endsWith("san")); + } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java index ae9d8539bf..97faf74dfb 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/UnwindFailuresTest.java @@ -42,8 +42,7 @@ public void unwindFailureEventsHaveSaneSchemaWhenPresent() throws Exception { // UnwindFailures/UnwindStats are compiled out unless the native lib is built with // -DDEBUG (debug/coverage/asan/tsan configs); on a release build datadog.UnwindFailure can // never appear, so there is nothing to check. - String config = NativeBuildConfig.name(); - assumeTrue(NativeBuildConfig.isDebug() || (config != null && config.endsWith("san")), + assumeTrue(NativeBuildConfig.isDebugInstrumented(), "datadog.UnwindFailure is only recorded in DEBUG-instrumented builds (debug/coverage/asan/tsan)"); // HotspotSupport::walkVM (and therefore UnwindFailures recording) is Hotspot-only. diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java index 11c99af313..28717b4046 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -48,8 +48,7 @@ private ReferenceChainAssertions() {} * Child JVMs see the same answer because the launchers forward {@code -Dddprof_test.config}. */ public static boolean isDebugInstrumentedBuild() { - String config = System.getProperty("ddprof_test.config"); - return NativeBuildConfig.isDebug() || (config != null && config.endsWith("san")); + return NativeBuildConfig.isDebugInstrumented(); } /** From 4cbeb4eaf93238af1cf02e0d6155bfa930095538 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Mon, 26 Oct 2026 16:25:32 -0400 Subject: [PATCH 09/10] Review comments --- AGENTS.md | 4 +- .../native/config/ConfigurationPresets.kt | 1 - ddprof-test/build.gradle.kts | 11 +++- doc/README.md | 1 + doc/building/BuildSystemGuide.md | 57 ++++++++----------- doc/building/GradleTasks.md | 31 +++++++++- doc/building/NativeBuildPlugin.md | 7 ++- doc/building/TestingGuide.md | 12 ++++ 8 files changed, 81 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a574e785fa..e06254030c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,9 @@ and `:ddprof-lib:benchmarks`), `ddprof-test` (Java integration tests), `ddprof-t ## Build & Test Quick Reference Configurations: `release`, `debug`, plus `asan`/`tsan`/`fuzzer` when the sanitizer runtime -is found for the selected compiler (inactive on macOS and musl). +is found for the selected compiler (inactive on macOS and musl), plus opt-in `coverage` +with `-PenableCoverage` (`:ddprof-lib:coverageReport`; the C++ report covers only the +`-O0 -DDEBUG` build — see [doc/building/CodeCoverage.md](doc/building/CodeCoverage.md)). ```bash ./.claude/commands/build-and-summarize buildRelease # release library + JAR diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt index 1211be9060..b454f67ce6 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ - package com.datadoghq.native.config import com.datadoghq.native.model.Architecture diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index 6309258d39..4d697873b5 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -34,7 +34,6 @@ jacoco { // instrumentation. val coverageTaskNames: Set = setOf("testCoverage", "testSlowCoverage") val enableCoverage = project.hasProperty("enableCoverage") -val requestingCoverageReport = gradle.startParameter.taskNames.any { it.contains("coverageReport") } // When a coverage report is requested, a handful of known-flaky native-thread/CPU tests // (already @RetryingTest-annotated; see NativeThreadTest, DynamicNativeThread, @@ -150,8 +149,14 @@ tasks.named { it in coverageTaskNames }.configureEach { // declare the JaCoCo .exec file as an output but no inputs). The same applies to a deferred // failure, whose results are cached like a pass would be. outputs.upToDateWhen { false } - if (requestingCoverageReport) { - deferFailuresToCoverageReport() +} + +// Decided from the resolved task graph, not the CLI task names, so abbreviations +// (`covRep`) and runs that pull the report in as a dependency defer failures too. +gradle.taskGraph.whenReady { + if (hasTask(":ddprof-lib:coverageReport")) { + allTasks.filter { it.project == project && it.name in coverageTaskNames } + .forEach { it.deferFailuresToCoverageReport() } } } diff --git a/doc/README.md b/doc/README.md index 39d51ddf17..abae5579e9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -33,6 +33,7 @@ All documentation files use **PascalCase** naming (e.g., `BuildSystemGuide.md`). ### Build System - [BuildSystemGuide](building/BuildSystemGuide.md) - Comprehensive build system documentation +- [CodeCoverage](building/CodeCoverage.md) - Opt-in C++ (gcovr) and Java (JaCoCo) coverage reports, prerequisites, debug-only scope - [GradleTasks](building/GradleTasks.md) - Available Gradle tasks reference - [JdkUpgrades](building/JdkUpgrades.md) - Two-JDK pattern and JDK upgrade touchpoints - [NativeBuildPlugin](building/NativeBuildPlugin.md) - Native C++ compilation plugin diff --git a/doc/building/BuildSystemGuide.md b/doc/building/BuildSystemGuide.md index 8a7c376c6b..0c76ec61ca 100644 --- a/doc/building/BuildSystemGuide.md +++ b/doc/building/BuildSystemGuide.md @@ -62,7 +62,7 @@ build-logic/conventions/src/main/kotlin/com/datadoghq/ ## How Build Configurations Work -Build configurations (release, debug, asan, tsan, fuzzer) are defined in `ConfigurationPresets.kt`: +Build configurations (release, debug, asan, tsan, fuzzer, coverage) are defined in `ConfigurationPresets.kt`: 1. **Registration**: `ConfigurationPresets.setupStandardConfigurations()` registers the configs in the project's `nativeBuild` extension (`NativeBuildExtension.buildConfigurations`). Both @@ -74,6 +74,7 @@ Build configurations (release, debug, asan, tsan, fuzzer) are defined in `Config (`libclang_rt.asan-` for clang, otherwise `libasan`) - tsan: `PlatformUtils.hasTsan(compiler)` — same pattern with the TSan runtime - fuzzer: `PlatformUtils.hasFuzzer()` — not musl, and clang supports `-fsanitize=fuzzer` + - coverage: only with `-PenableCoverage` (see [CodeCoverage](CodeCoverage.md)) 3. **Discovery**: `NativeBuildPlugin` creates tasks for `extension.getActiveConfigurations(currentPlatform, currentArch)` and logs `Active configurations: ...` @@ -94,7 +95,7 @@ Build configurations (release, debug, asan, tsan, fuzzer) are defined in `Config │ RootProjectPlugin creates root nativeBuild extension │ │ ↓ │ │ afterEvaluate: ConfigurationPresets.setupStandardConfigurations│ -│ → Registers: release, debug, asan, tsan, fuzzer │ +│ → Registers: release, debug, asan, tsan, fuzzer, coverage │ │ → Checks availability via PlatformUtils │ └─────────────────────────────────────────────────────────────────┘ │ @@ -113,60 +114,48 @@ Build configurations (release, debug, asan, tsan, fuzzer) are defined in `Config ## Adding a New Build Configuration -To add a new configuration (e.g., "coverage"): +The `coverage` configuration is a worked example of an opt-in configuration. It reuses +an existing preset and only adds flags: ### Step 1: Add to ConfigurationPresets.kt ```kotlin // In setupStandardConfigurations() +val enableCoverage = project.hasProperty("enableCoverage") extension.buildConfigurations.apply { // ... existing configs ... register("coverage") { - configureCoverage(this, currentPlatform, currentArch, version, rootDir) + configureCoverage(this, currentPlatform, currentArch, version, enableCoverage) } } -// Add configuration function fun configureCoverage( config: BuildConfiguration, platform: Platform, architecture: Architecture, version: String, - rootDir: File + active: Boolean ) { - config.platform.set(platform) - config.architecture.set(architecture) - config.active.set(PlatformUtils.hasGcov()) // Add detection in PlatformUtils - - when (platform) { - Platform.LINUX -> { - config.compilerArgs.set( - listOf("-O0", "-g", "--coverage", "-fprofile-arcs", "-ftest-coverage") + - commonLinuxCompilerArgs(version) - ) - config.linkerArgs.set(commonLinuxLinkerArgs() + listOf("--coverage")) - } - Platform.MACOS -> { - config.compilerArgs.set( - listOf("-O0", "-g", "--coverage") + commonMacosCompilerArgs(version) - ) - config.linkerArgs.set(listOf("--coverage")) - } - } + configureDebug(config, platform, architecture, version) + config.active.set(active) // Inactive configs get no tasks at all + config.publishable.set(false) // JAR built only while active, never published + config.compilerArgs.addAll("--coverage", "-fprofile-update=atomic") + config.linkerArgs.add("--coverage") } ``` -### Step 2: Add Detection to PlatformUtils.kt (if needed) - -```kotlin -fun hasGcov(): Boolean { - return isCompilerAvailable("gcov") -} -``` +Gate `active` on a detection helper in `PlatformUtils` when the configuration needs a +toolchain feature (see `configureAsan` / `configureFuzzer`), or on a project property +when it must stay out of default builds (as `coverage` does). -### Step 3: No Changes Needed in Build Scripts +### Step 2: Build Script Wiring (only if needed) -Configurations are discovered dynamically - all subprojects automatically pick up the new config. +Configurations are discovered dynamically: compile/link/assemble, `gtest{Config}` and +`test{Config}` tasks are created for every active configuration without build-script +changes. Extra tasks that consume a configuration's output go in the build scripts — +for `coverage`, `coverageReport` and its preflight check in `ddprof-lib/build.gradle.kts` +and the JaCoCo wiring in `ddprof-test/build.gradle.kts` (see +[CodeCoverage](CodeCoverage.md)). ## Modifying Compiler/Linker Flags diff --git a/doc/building/GradleTasks.md b/doc/building/GradleTasks.md index b82f9a96c3..f6197a21ad 100644 --- a/doc/building/GradleTasks.md +++ b/doc/building/GradleTasks.md @@ -15,13 +15,14 @@ This document describes the custom Gradle tasks available in the java-profiler p ### Native Library Compilation -Tasks are generated for each active build configuration (release, debug, asan, tsan, fuzzer; -see `ConfigurationPresets.kt`). Configuration availability depends on platform and compiler -capabilities (the build logs `Active configurations: ...` during configuration): +Tasks are generated for each active build configuration (release, debug, asan, tsan, fuzzer, +coverage; see `ConfigurationPresets.kt`). Configuration availability depends on platform and +compiler capabilities (the build logs `Active configurations: ...` during configuration): - **release/debug**: Always available - **asan**: Available when the ASan runtime is found for the selected compiler (not on musl) - **tsan**: Available when the TSan runtime is found for the selected compiler (not on musl) - **fuzzer**: Available when clang with libFuzzer support is detected (not on musl) +- **coverage**: Only with `-PenableCoverage` (see [Coverage Tasks](#coverage-tasks)) In practice the sanitizer configs are active on Linux glibc CI images and usually inactive on macOS with Apple clang, so tasks such as `gtestAsan` / `testAsan` simply do not exist there. @@ -162,6 +163,29 @@ Requires clang with `-fsanitize=fuzzer` (on macOS, Homebrew LLVM). Without it th with a warning; `listFuzzTargets` still works. Crashes are written to `ddprof-lib/fuzz/build/fuzz-crashes/`. +## Coverage Tasks + +Exist only with `-PenableCoverage`. Prerequisites (gcovr 8.3+, gtest, make on Linux), +the debug-only scope of the C++ report, and container setup are in +[CodeCoverage](CodeCoverage.md). + +``` +:ddprof-lib:coverageReport # C++ (gcovr) + Java (JaCoCo) reports; runs everything below +:ddprof-lib:checkCoverageTools # Preflight: compiler, gcov, gtest, make; gcovr if the report is requested +:ddprof-lib:gtestCoverage # gtests against the coverage config +:ddprof-test:testCoverage # Java tests against the instrumented library, with JaCoCo +:ddprof-test:testSlowCoverage # Same, for tests tagged `slow` +:ddprof-test:jacocoTestReport # Java half of coverageReport +``` + +**Example:** +```bash +./gradlew :ddprof-lib:coverageReport -PenableCoverage +``` + +Reports are written to `ddprof-lib/build/reports/coverage/{cpp,java}/`. Java test +failures are deferred until both reports are written; `coverageReport` then fails. + ## Task Dependency Graph ``` @@ -242,5 +266,6 @@ See `gradle.properties.template` for all available properties: | `native.forceCompiler` | Force specific C++ compiler (`clang++`, `g++`, or a path) | | `enableFaultInjection` | Compile release/debug with `-D__FAULT_INJECTION__` | | `enableSamplerPerf` | Compile release/debug with `-D__SAMPLER_PERF__` (see `doc/reference/SamplerPerfCounters.md`) | +| `enableCoverage` | Activate the `coverage` configuration and the coverage tasks (see [CodeCoverage](CodeCoverage.md)) | | `ddprof_version` | Override project version | | `CI` | Enable CI-specific behavior | diff --git a/doc/building/NativeBuildPlugin.md b/doc/building/NativeBuildPlugin.md index f56f694929..07f8b164c5 100644 --- a/doc/building/NativeBuildPlugin.md +++ b/doc/building/NativeBuildPlugin.md @@ -82,7 +82,7 @@ After project evaluation, the plugin: 2. **Detects Compiler**: Runs the compiler detection algorithm (see below) -3. **Creates Standard Configurations**: If no configurations are explicitly defined, `ConfigurationPresets.setupStandardConfigurations()` creates release, debug, asan, tsan, and fuzzer configurations +3. **Creates Standard Configurations**: If no configurations are explicitly defined, `ConfigurationPresets.setupStandardConfigurations()` creates release, debug, asan, tsan, fuzzer, and coverage configurations 4. **Filters Active Configurations**: Only configurations whose `active` flag is set and that match the current platform/architecture are processed @@ -148,6 +148,10 @@ The compiler detection algorithm prioritizes explicit overrides, then auto-detec | asan | ASan runtime found (`libclang_rt.asan-` for clang, else `libasan`) + not musl | None | `-g` | ASan, UBSan, LSan | | tsan | TSan runtime found (`libclang_rt.tsan-` for clang, else `libtsan`) + not musl | None | `-g` | TSan | | fuzzer | clang++ with libFuzzer + not musl | None | `-g` | ASan, UBSan | +| coverage | `-PenableCoverage` | `-O0` (debug + `--coverage -fprofile-update=atomic`) | `-g` | None | + +`coverage` is not publishable (`publishable = false`): its JAR is built only while the +configuration is active and is never published. See [CodeCoverage](CodeCoverage.md). ### Configuration Model @@ -162,6 +166,7 @@ abstract class BuildConfiguration { val testEnvironment: MapProperty // Test env vars val testJvmArgs: ListProperty // Extra JVM args for Java tests val active: Property // Whether to build + val publishable: Property // Whether the JAR is published (default true) } ``` diff --git a/doc/building/TestingGuide.md b/doc/building/TestingGuide.md index 95cd09bb32..373bece0c6 100644 --- a/doc/building/TestingGuide.md +++ b/doc/building/TestingGuide.md @@ -306,12 +306,24 @@ catching races with per-second probability too low for any bounded CI window. ./gradlew :ddprof-test:testAsan -Pskip-gtest ``` +### Coverage + +```bash +# C++ (gcovr) + Java (JaCoCo) reports; needs gcovr 8.3+, gtest, make (Linux) +./gradlew :ddprof-lib:coverageReport -PenableCoverage +``` + +The C++ report covers only the `-O0 -DDEBUG` build. Prerequisites and limits are in +[CodeCoverage](CodeCoverage.md). + ### Using containers to approximate CI `utils/run-containers-tests.sh` defaults to Podman. On macOS use Docker through colima (`--container=docker`): podman's credential-helper lookup for the Datadog registries (ddtool) fails outside the CI network and aborts container commands. Run `./utils/run-containers-tests.sh --help` for the full flag list (`--tests`, `--gtest`, `--gtest-task`, `--mount`, `--rebuild`, `--matrix`, ...). +Cached base images are reused until `--rebuild-base` (or `--rebuild`) is passed. Use it +after the base image's package list changes, e.g. to pick up gcovr for coverage runs. ```bash # Matches the nightly configuration From be33268f754b9e0dcbef82dead04aaf118c33604 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Mon, 26 Oct 2026 20:18:04 -0400 Subject: [PATCH 10/10] Fix review comments --- .../datadoghq/native/util/PlatformUtils.kt | 4 + .../datadoghq/profiler/ProfilerTestPlugin.kt | 4 + ddprof-lib/build.gradle.kts | 45 +++++-- ddprof-test/build.gradle.kts | 115 +++++++++++------ .../profiler/AbstractProcessProfilerTest.java | 26 ++-- .../datadoghq/profiler/NativeBuildConfig.java | 4 + .../wallclock/BaseContextWallClockTest.java | 8 +- doc/building/CodeCoverage.md | 119 ++++++++++++++++++ doc/building/GradleTasks.md | 3 +- doc/building/TestingGuide.md | 5 +- utils/run-containers-tests.sh | 6 +- 11 files changed, 271 insertions(+), 68 deletions(-) create mode 100644 doc/building/CodeCoverage.md diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt index 2ce3889cee..cff43b0f1b 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ package com.datadoghq.native.util diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt index c3faa324bf..08a27e4d0f 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ package com.datadoghq.profiler diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index 1d6e5583c5..1f0bd302ac 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -206,29 +206,45 @@ afterEvaluate { if (project.hasProperty("enableCoverage")) { val gtestObjDir = layout.buildDirectory.dir("obj/gtest/coverage") - // Whether the compiler is Apple's clang. On macOS that includes /usr/bin/g++, a clang - // shim, so it is decided from the `--version` banner, not from the name; a Homebrew - // g++- prints the GCC banner instead. - fun isAppleClang(compilerPath: String): Boolean = try { + // The compiler's `--version` output, or "" if it cannot be run. + fun compilerBanner(compilerPath: String): String = try { val process = ProcessBuilder(compilerPath, "--version").redirectErrorStream(true).start() val output = process.inputStream.bufferedReader().use { it.readText() } process.waitFor() - output.contains("Apple clang") + output } catch (e: java.io.IOException) { - false + "" } - // The gcov tool must come from the same toolchain that wrote the .gcno files, - // so derive it from the compiler (e.g. /usr/bin/g++-13 -> /usr/bin/gcov-13, + // The gcov tool must come from the same toolchain that wrote the .gcno files: GCC's + // gcov cannot read clang's .gcno format. Whether the compiler is clang is decided from + // its `--version` banner, never from its name -- /usr/bin/c++ may be clang through + // update-alternatives, and macOS's /usr/bin/g++ is an Apple clang shim. The tool is + // then derived from the compiler's path (e.g. /usr/bin/g++-13 -> /usr/bin/gcov-13, // clang++-17 -> llvm-cov-17 gcov) instead of whatever `gcov` is on PATH. fun gcovCommand(compilerPath: String): List { + val banner = compilerBanner(compilerPath) + if (banner.contains("Apple clang")) { + return listOf("xcrun", "llvm-cov", "gcov") + } val compiler = File(compilerPath) - fun siblingOf(tool: String) = compiler.parentFile?.resolve(tool)?.path ?: tool - return when { - PlatformUtils.currentPlatform == Platform.MACOS && isAppleClang(compilerPath) -> listOf("xcrun", "llvm-cov", "gcov") - compiler.name.contains("clang") -> listOf(siblingOf(compiler.name.replace(Regex("clang(\\+\\+)?"), "llvm-cov")), "gcov") - else -> listOf(siblingOf(compiler.name.replace(Regex("[gc]\\+\\+"), "gcov"))) + fun siblingOf(file: File, tool: String) = file.parentFile?.resolve(tool)?.path ?: tool + if (!banner.contains("clang version")) { + return listOf(siblingOf(compiler, compiler.name.replace(Regex("[gc]\\+\\+"), "gcov"))) + } + val clangNamePattern = Regex("clang(\\+\\+)?") + if (clangNamePattern.containsMatchIn(compiler.name)) { + return listOf(siblingOf(compiler, compiler.name.replace(clangNamePattern, "llvm-cov")), "gcov") } + // A clang under another name (c++ via update-alternatives) symlinks to the real clang + // binary (e.g. /usr/lib/llvm-17/bin/clang); that directory holds the matching + // llvm-cov when the llvm- package is installed. Otherwise, and for wrappers that + // do not resolve to clang (ccache), fall back to llvm-cov on PATH. + val llvmCov = compiler.takeIf { it.isAbsolute && it.exists() }?.canonicalFile + ?.takeIf { clangNamePattern.containsMatchIn(it.name) } + ?.let { File(siblingOf(it, it.name.replace(clangNamePattern, "llvm-cov"))) } + ?.takeIf { it.canExecute() }?.path ?: "llvm-cov" + return listOf(llvmCov, "gcov") } // gcovr flags coverageReport depends on (see its command line below); 8.3 is the @@ -393,6 +409,9 @@ if (project.hasProperty("enableCoverage")) { group = "verification" description = "Generates the Java (JaCoCo) and C++ (gcovr) coverage reports from gtestCoverage and :ddprof-test:testCoverage/testSlowCoverage (requires gcovr 8.3+; see checkCoverageTools)" dependsOn("gtestCoverage", ":ddprof-test:testCoverage", ":ddprof-test:testSlowCoverage", ":ddprof-test:jacocoTestReport") + // The Java test tasks defer their failures while this report runs; the finalizer + // fails the build on them once both reports are written. + finalizedBy(":ddprof-test:checkCoverageTestFailures") val reportDir = layout.buildDirectory.dir("reports/coverage/cpp") val libObjDir = layout.buildDirectory.dir("obj/main/coverage") diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index 4d697873b5..828cb50fdc 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -5,8 +5,8 @@ import com.datadoghq.profiler.ProfilerTestExtension import com.datadoghq.profiler.ProfilerTestPlugin +import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.SourceSetContainer -import java.util.concurrent.ConcurrentHashMap plugins { java @@ -35,15 +35,22 @@ jacoco { val coverageTaskNames: Set = setOf("testCoverage", "testSlowCoverage") val enableCoverage = project.hasProperty("enableCoverage") -// When a coverage report is requested, a handful of known-flaky native-thread/CPU tests -// (already @RetryingTest-annotated; see NativeThreadTest, DynamicNativeThread, -// VtableReceiverFrameTest, ThreadEntryDetectionTest) must not keep the report from being -// generated. Their failures are deferred, not ignored: the test task records them and -// lets the report run, and the report task fails the build once the report is written. -val deferredTestFailures: MutableSet = ConcurrentHashMap.newKeySet() +// When a coverage report is requested, any failure in the coverage test tasks is deferred +// until both reports are written, so a single failing test does not discard the run's +// coverage data (the motivating cases are known-flaky native-thread/CPU tests, already +// @RetryingTest-annotated: NativeThreadTest, DynamicNativeThread, VtableReceiverFrameTest, +// ThreadEntryDetectionTest). Failures are deferred, not ignored: a failing test task +// writes a marker file named after itself into deferredFailuresDir and lets the report +// run; checkCoverageTestFailures, a finalizer of :ddprof-lib:coverageReport, then fails +// the build. +val deferredFailuresDir = layout.buildDirectory.dir("coverage-deferred-failures") fun Task.deferFailuresToCoverageReport() { - val taskName = name + val marker = deferredFailuresDir.get().file(name).asFile + fun recordFailure() { + marker.parentFile.mkdirs() + marker.createNewFile() + } when (this) { is Test -> { ignoreFailures = true @@ -53,7 +60,7 @@ fun Task.deferFailuresToCoverageReport() { override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {} override fun afterSuite(suite: TestDescriptor, result: TestResult) { if (suite.parent == null && result.resultType == TestResult.ResultType.FAILURE) { - deferredTestFailures.add(taskName) + recordFailure() } } }) @@ -62,26 +69,73 @@ fun Task.deferFailuresToCoverageReport() { isIgnoreExitValue = true doLast { if (executionResult.get().exitValue != 0) { - deferredTestFailures.add(taskName) + recordFailure() } } } } } -fun failOnDeferredTestFailures(taskNames: Collection) { - val failed = taskNames.filter { it in deferredTestFailures } - if (failed.isNotEmpty()) { - throw GradleException("Coverage report generated, but tests failed in: ${failed.joinToString()}") +// Finalizer of :ddprof-lib:coverageReport (declared there), so it runs after the reports +// are written -- and also when gcovr fails, so a test failure is never masked by it. +tasks.register("checkCoverageTestFailures") { + group = "verification" + description = "Fails if a coverage test task deferred its failures to :ddprof-lib:coverageReport" + val markers = deferredFailuresDir + doLast { + val failed = markers.get().asFile.list()?.sorted().orEmpty() + if (failed.isNotEmpty()) { + throw GradleException("Coverage report generated, but tests failed in: ${failed.joinToString()}") + } } } +// The tests here exercise ddprof-lib's classes (the profiler itself), not this +// project's own trivial main sourceSet (just the UnwindingValidator app) -- so +// coverage must be measured against ddprof-lib, the actual target under test. +// Resolved lazily, only once a coverage task is configured. +val libMainSourceSet: SourceSet by lazy { + project(":ddprof-lib").extensions.getByType()["main"] +} + +// JaCoCo `includes` (':'-separated) naming exactly ddprof-lib's classes and their nested +// classes, for every agent: the Test tasks', the musl Exec tasks' and the child JVMs'. +// Without it JaCoCo instruments every class it loads -- JMC, JUnit, the tests themselves -- +// adding load-time and probe overhead to tests that assert on sampling and timing, for data +// jacocoTestReport ignores. A package wildcard is no narrower: the tests live in and below +// ddprof-lib's package com.datadoghq.profiler, and JaCoCo's `*` also matches dots. Derived +// from the source file names, which works because each file declares one top-level type. +val jacocoIncludes: String by lazy { + libMainSourceSet.java.srcDirs.filter { it.isDirectory }.flatMap { dir -> + dir.walk() + .filter { it.isFile && it.extension == "java" && it.nameWithoutExtension !in setOf("package-info", "module-info") } + .map { it.relativeTo(dir).path.removeSuffix(".java").replace(File.separatorChar, '.') } + .toList() + }.sorted().flatMap { listOf(it, "$it\$*") }.joinToString(":") +} + tasks.withType().configureEach { extensions.configure { isEnabled = name in coverageTaskNames + if (isEnabled) { + includes = jacocoIncludes.split(":") + } } } +// The JaCoCo agent jar, for child JVMs and for the musl Exec test tasks (below). It ships +// nested inside the org.jacoco.agent artifact (`jacocoAgent`). +val extractJacocoAgent = tasks.register("extractJacocoAgent") { + from(configurations.named("jacocoAgent").map { zipTree(it.singleFile) }) { + include("jacocoagent.jar") + } + into(layout.buildDirectory.dir("tmp/jacocoAgent")) +} + +// Directory where child JVMs spawned by AbstractProcessProfilerTest.launch() write their +// JaCoCo execution data. Each child uses a unique file name to avoid overwrites. +val childJacocoDir = layout.buildDirectory.dir("jacoco/children") + // Pass JaCoCo agent info to Test tasks (glibc/macOS) so AbstractProcessProfilerTest can // forward coverage to child JVMs. The JaCoCo extension already attaches the agent; we just // need to tell the test code where to find it for child processes. @@ -92,37 +146,30 @@ tasks.withType().named { it in coverageTaskNames }.configureEach { childJacocoDir.get().asFile.mkdirs() environment("JACOCO_AGENT_JAR", agentJar.get().absolutePath) environment("JACOCO_CHILD_DIR", childJacocoDir.get().asFile.absolutePath) + environment("JACOCO_INCLUDES", jacocoIncludes) } } // On musl the profiler-test plugin creates the test tasks as Exec tasks (running its own // ProfilerTestRunner), which the jacoco plugin doesn't instrument -- attach the agent // by hand so they write the same build/jacoco/.exec file a Test task would. -// The agent jar ships nested inside the org.jacoco.agent artifact (`jacocoAgent`). -val extractJacocoAgent = tasks.register("extractJacocoAgent") { - from(configurations.named("jacocoAgent").map { zipTree(it.singleFile) }) { - include("jacocoagent.jar") - } - into(layout.buildDirectory.dir("tmp/jacocoAgent")) -} - -// Directory where child JVMs spawned by AbstractProcessProfilerTest.launch() write their -// JaCoCo execution data. Each child uses a unique file name to avoid overwrites. -val childJacocoDir = layout.buildDirectory.dir("jacoco/children") - tasks.withType().named { it in coverageTaskNames }.configureEach { dependsOn(extractJacocoAgent) val agentJar = extractJacocoAgent.map { it.destinationDir.resolve("jacocoagent.jar") } val execFile = layout.buildDirectory.file("jacoco/$name.exec") outputs.file(execFile) ProfilerTestPlugin.additionalJvmArgs(this).add( - provider { "-javaagent:${agentJar.get().absolutePath}=destfile=${execFile.get().asFile.absolutePath},append=false" }, + provider { + "-javaagent:${agentJar.get().absolutePath}=destfile=${execFile.get().asFile.absolutePath},append=false," + + "includes=$jacocoIncludes" + }, ) // Pass the agent jar path to AbstractProcessProfilerTest so it can forward coverage to child JVMs. doFirst { childJacocoDir.get().asFile.mkdirs() (this as Exec).environment("JACOCO_AGENT_JAR", agentJar.get().absolutePath) (this as Exec).environment("JACOCO_CHILD_DIR", childJacocoDir.get().asFile.absolutePath) + (this as Exec).environment("JACOCO_INCLUDES", jacocoIncludes) } } @@ -134,8 +181,9 @@ tasks.withType().named { it in coverageTaskNames }.configureEach { val libCoverageObjDir = project(":ddprof-lib").layout.buildDirectory.dir("obj/main/coverage") val cleanLibCoverageData = tasks.register("cleanLibCoverageData") { delete(fileTree(libCoverageObjDir) { include("**/*.gcda") }) - // Also clear child JVM JaCoCo data from the previous run. + // Also clear child JVM JaCoCo data and deferred-failure markers from the previous run. delete(fileTree(childJacocoDir) { include("*.exec") }) + delete(deferredFailuresDir) // Fail on missing coverage tools before wiping the previous run's counts. if (enableCoverage) { dependsOn(":ddprof-lib:checkCoverageTools") @@ -160,11 +208,6 @@ gradle.taskGraph.whenReady { } } -// Runs after coverageReport's own gcovr doLast, so both reports are written first. -project(":ddprof-lib").tasks.named { it == "coverageReport" }.configureEach { - doLast { failOnDeferredTestFailures(coverageTaskNames) } -} - // The jacoco plugin already registers a default "jacocoTestReport" task (wired to the // disabled "test" task); reconfigure it as the Java half of :ddprof-lib:coverageReport // instead of registering a new one. @@ -181,14 +224,10 @@ tasks.named("jacocoTestReport") { fileTree(childJacocoDir) { include("*.exec") }, ) - // The tests here exercise ddprof-lib's classes (the profiler itself), not this - // project's own trivial main sourceSet (just the UnwindingValidator app) -- so - // coverage must be measured against ddprof-lib, the actual target under test. - val libMainSourceSet = project(":ddprof-lib").extensions.getByType()["main"] sourceDirectories.setFrom(libMainSourceSet.allSource.srcDirs) classDirectories.setFrom(libMainSourceSet.output) - // Test failures are checked by coverageReport, after both reports are written. + // Test failures are checked by checkCoverageTestFailures, after both reports are written. // Next to the C++ report (ddprof-lib/build/reports/coverage/cpp). val javaReportDir = project(":ddprof-lib").layout.buildDirectory.dir("reports/coverage/java") diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java index 30eb6db08e..c407d62c6d 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java @@ -12,8 +12,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; import java.util.function.Function; @@ -26,9 +26,6 @@ public abstract class AbstractProcessProfilerTest { // descendant that inherited the pipes from keeping launch() from returning. private static final long READER_JOIN_TIMEOUT_MS = 10_000; - // Counter for generating unique JaCoCo execution data file names for child JVMs. - private static final AtomicLong CHILD_COUNTER = new AtomicLong(); - /** * JaCoCo agent jar path, set by the coverage test tasks via JACOCO_AGENT_JAR environment * variable. When non-null, child JVMs are instrumented for coverage. @@ -40,6 +37,13 @@ public abstract class AbstractProcessProfilerTest { */ private static final String JACOCO_CHILD_DIR = System.getenv("JACOCO_CHILD_DIR"); + /** + * JaCoCo {@code includes} pattern list (':'-separated) restricting child JVM instrumentation + * to ddprof-lib's classes, set via JACOCO_INCLUDES. When null, the agent instruments every + * class it loads. + */ + private static final String JACOCO_INCLUDES = System.getenv("JACOCO_INCLUDES"); + public static final class LaunchResult { public final boolean inTime; public final int exitCode; @@ -92,13 +96,17 @@ protected final LaunchResult launch(String target, List jvmArgs, String List args = new ArrayList<>(); args.add(javaHome + "/bin/java"); - // Forward JaCoCo agent to child JVM for coverage collection if configured. + args.addAll(jvmArgs); + // Forward JaCoCo agent to child JVM for coverage collection if configured. It goes after + // the test's own JVM args because the JVM loads agents in command-line order: a test's + // -agentpath keeps running its Agent_OnLoad first, as it does without coverage. if (JACOCO_AGENT_JAR != null && JACOCO_CHILD_DIR != null) { - long childId = CHILD_COUNTER.incrementAndGet(); - String execFile = JACOCO_CHILD_DIR + "/child-" + childId + "-" + System.currentTimeMillis() + ".exec"; - args.add("-javaagent:" + JACOCO_AGENT_JAR + "=destfile=" + execFile + ",append=false"); + // The name must be unique across every test JVM of the run (each forked test JVM + // starts with fresh static state), so a per-JVM counter is not enough. + String execFile = JACOCO_CHILD_DIR + "/child-" + UUID.randomUUID() + ".exec"; + String includes = JACOCO_INCLUDES != null ? ",includes=" + JACOCO_INCLUDES : ""; + args.add("-javaagent:" + JACOCO_AGENT_JAR + "=destfile=" + execFile + ",append=false" + includes); } - args.addAll(jvmArgs); args.add("-cp"); args.add(System.getProperty("java.class.path")); args.add(ExternalLauncher.class.getName()); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java b/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java index fdccccad0e..197fb3cc0f 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/NativeBuildConfig.java @@ -23,6 +23,10 @@ public static String name() { * {@code debug}, or {@code coverage} (the debug build with gcov instrumentation added). * Debug-only code paths (e.g. {@code datadog.UnwindFailure} recording) are compiled in, and * unwinding is several times slower than in the release build. + * + *

Not a gate for timing- or sample-distribution-sensitive assertions: the coverage build + * adds atomic gcov counter updates on every edge, signal handlers included. Key those on + * {@code "debug".equals(name())} instead. */ public static boolean isDebug() { String config = name(); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java index 4fa8242543..4882608095 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/BaseContextWallClockTest.java @@ -7,7 +7,6 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.JavaProfiler; -import com.datadoghq.profiler.NativeBuildConfig; import com.datadoghq.profiler.Platform; import com.datadoghq.profiler.context.ContextExecutor; import com.datadoghq.profiler.context.Tracing; @@ -182,8 +181,11 @@ void test(AbstractProfilerTest test, boolean assertContext, String cstack) throw assertFalse(states.contains("NEW")); assertFalse(states.contains("TERMINATED")); // the sanitizer configurations are not playing that well with the sample distribution - // still useful to run the profiler, though - so just skipping the assertions here - if (config.equals("release") || NativeBuildConfig.isDebug()) { + // still useful to run the profiler, though - so just skipping the assertions here. + // The same goes for coverage: atomic gcov counter updates on every edge of the signal + // handlers perturb timing like the sanitizers do, and a failure here would keep + // coverageReport red, so the check is keyed on "debug" itself, not isDebug(). + if (config.equals("release") || config.equals("debug")) { double totalWeight = method1Weight + method2Weight + method3Weight + unattributedWeight; // Each method sleeps for the same duration (10ms), so each should contribute ~33%. // method1Impl's monitor.wait time is excluded from method1Weight (see attribution above) diff --git a/doc/building/CodeCoverage.md b/doc/building/CodeCoverage.md new file mode 100644 index 0000000000..dcd86ef5e3 --- /dev/null +++ b/doc/building/CodeCoverage.md @@ -0,0 +1,119 @@ + + +# Code Coverage + +`:ddprof-lib:coverageReport` produces two coverage reports from one run: + +| Report | Tool | Sources | Output | +|--------|------|---------|--------| +| C++ | gcovr | gtest binaries + Java integration tests | `ddprof-lib/build/reports/coverage/cpp/index.html` | +| Java | JaCoCo | Java integration tests (incl. child JVMs) | `ddprof-lib/build/reports/coverage/java/html/index.html`, `.../java/jacoco.xml` | + +Coverage is opt-in. Pass `-PenableCoverage`, or none of the coverage tasks exist. + +```bash +./.claude/commands/build-and-summarize :ddprof-lib:coverageReport -PenableCoverage +``` + +A full run takes about 30 minutes. It builds an instrumented library and gtests, +then runs `gtestCoverage`, `:ddprof-test:testCoverage` and +`:ddprof-test:testSlowCoverage`. + +## What the C++ Report Measures + +The `coverage` build configuration (`configureCoverage` in `ConfigurationPresets.kt`) +is built on `configureDebug`: `-O0 -g -DDEBUG`, plus `--coverage +-fprofile-update=atomic`. The C++ report therefore covers **only the debug variant** +of the native code: + +- Code under `#ifndef DEBUG` is never compiled into the coverage build. It never shows + as covered, even if the release build runs it on every sample. +- Code under `#ifdef DEBUG` (test seams, `datadog.UnwindFailure` recording, + `DEBUG_ASSERT_NOT_IN_SIGNAL()`) is compiled in and counts toward coverage, although it does not + exist in the shipped library. +- Behavior that only differs with optimizations on (inlining, tail calls, frame + layout seen by the stack walkers) is not exercised. + +Read the C++ numbers as "lines the debug build executed", not as coverage of the +release library. + +`-fprofile-update=atomic` is required. Plain counter updates race between profiler +threads and signal handlers, and produce negative branch counts (GCC bug 68080) that +gcovr refuses to parse. + +## Prerequisites + +`checkCoverageTools` runs before any coverage build or test. It reports every missing +tool at once, with an install hint, and fails before the long build starts. + +| Tool | Needed for | Checked | +|------|-----------|---------| +| C++ compiler that links `--coverage -fprofile-update=atomic` | Instrumented library and gtests. Clang needs its profile runtime (`compiler-rt` / `libclang-rt-dev`), gcc needs libgcov | Always | +| gcov matching the compiler | gcovr reads `.gcda` files with it: `gcov-` for `g++-`, `llvm-cov- gcov` for `clang++-`, `xcrun llvm-cov gcov` for Apple clang. Clang vs. GCC is decided from the compiler's `--version` banner, not its name; a clang under another name (e.g. `c++` via update-alternatives) uses the `llvm-cov` next to the binary it resolves to, else `llvm-cov` on PATH | Always | +| Google Test | `gtestCoverage`. Without gtest the gtest tasks are skipped with only a warning, and the C++ report silently loses the gtest half | Always | +| make | `buildNativeLibs`, which builds the gtests' helper libraries | Linux only | +| gcovr **8.3+** | The C++ report. Older versions lack flags the report passes (`--gcov-ignore-parse-errors=negative_hits.warn_once_per_file`, `--gcov-suspicious-hits-threshold`). Distro packages are usually too old; install from PyPI | Only when `coverageReport` is in the task graph | + +Pick a different compiler with `-Pnative.forceCompiler=`. The gcov tool is +derived from that compiler's path. + +## Tasks + +All tasks exist only with `-PenableCoverage`. + +``` +:ddprof-lib:coverageReport # Both reports; depends on all tasks below +:ddprof-lib:checkCoverageTools # Preflight tool check; every coverage task depends on it +:ddprof-lib:gtestCoverage # gtests against the coverage config (gtestCoverage_ per binary) +:ddprof-lib:assembleCoverageJar # Instrumented library JAR (never published) +:ddprof-test:testCoverage # Java tests against the instrumented library, with JaCoCo +:ddprof-test:testSlowCoverage # Same, for tests tagged `slow` +:ddprof-test:jacocoTestReport # Java half of coverageReport +``` + +`cleanGtestCoverageData` and `:ddprof-test:cleanLibCoverageData` delete old `.gcda` +files before the tests run, because gcov adds to existing counts. The coverage test +tasks therefore never report `UP-TO-DATE`. + +JaCoCo instruments only `testCoverage` and `testSlowCoverage`. Other test tasks run +against unmodified bytecode, because JaCoCo's injected methods and probe arrays would +appear in the allocation and stack samples those tests assert on. Child JVMs started +by `AbstractProcessProfilerTest` get the agent through `JACOCO_AGENT_JAR` / +`JACOCO_CHILD_DIR` / `JACOCO_INCLUDES`. On musl, the test tasks are `Exec` tasks; the +agent is attached to them explicitly. + +Within those tasks, every agent instruments only ddprof-lib's classes, the only classes +the report covers. The `includes` list names each class, derived from ddprof-lib's +source file names. A package wildcard would not narrow it: the tests share the +`com.datadoghq.profiler` package root, and JaCoCo's `*` also matches `.`. + +## Test Failures + +When `coverageReport` is in the task graph, failures in `testCoverage` and +`testSlowCoverage` are deferred: each failing task writes a marker file to +`ddprof-test/build/coverage-deferred-failures/`, and both reports are written first. +`:ddprof-test:checkCoverageTestFailures`, a finalizer of `coverageReport`, then fails +and names the failed tasks. As a finalizer it also runs when gcovr fails, so a gcovr +error does not hide test failures. Deferral is decided from the resolved task graph, +so it also applies to abbreviated task names (`covRep`). + +Running `testCoverage` without the report fails on test failures as usual. +`gtestCoverage` failures are not deferred: the gtest plugin runs with `failFast`, so a +failing gtest stops the build before the report. + +## Containers + +`utils/run-containers-tests.sh` base images include gcovr from PyPI, and `llvm` on +glibc for the unversioned `llvm-cov`. The script reuses cached images, so a base image +cached before these packages were added lacks them, and so does every JDK test image +built on it. Pass `--rebuild-base` once: it rebuilds the base image and the JDK image +on top of it. + +The script's `--config` does not accept `coverage`. Open a shell in the container and +run the task there: + +```bash +./utils/run-containers-tests.sh --libc=glibc --jdk=21 --rebuild-base --shell +# inside the container: +./gradlew :ddprof-lib:coverageReport -PenableCoverage +``` diff --git a/doc/building/GradleTasks.md b/doc/building/GradleTasks.md index f6197a21ad..60d35c4e14 100644 --- a/doc/building/GradleTasks.md +++ b/doc/building/GradleTasks.md @@ -184,7 +184,8 @@ the debug-only scope of the C++ report, and container setup are in ``` Reports are written to `ddprof-lib/build/reports/coverage/{cpp,java}/`. Java test -failures are deferred until both reports are written; `coverageReport` then fails. +failures are deferred until both reports are written; the `coverageReport` finalizer +`:ddprof-test:checkCoverageTestFailures` then fails. ## Task Dependency Graph diff --git a/doc/building/TestingGuide.md b/doc/building/TestingGuide.md index 373bece0c6..666ca3d850 100644 --- a/doc/building/TestingGuide.md +++ b/doc/building/TestingGuide.md @@ -322,8 +322,9 @@ The C++ report covers only the `-O0 -DDEBUG` build. Prerequisites and limits are (`--container=docker`): podman's credential-helper lookup for the Datadog registries (ddtool) fails outside the CI network and aborts container commands. Run `./utils/run-containers-tests.sh --help` for the full flag list (`--tests`, `--gtest`, `--gtest-task`, `--mount`, `--rebuild`, `--matrix`, ...). -Cached base images are reused until `--rebuild-base` (or `--rebuild`) is passed. Use it -after the base image's package list changes, e.g. to pick up gcovr for coverage runs. +Cached images are reused until `--rebuild-base` (or `--rebuild`) is passed. Use it +after the base image's package list changes, e.g. to pick up gcovr for coverage runs: +it rebuilds the base image and the JDK image built on it. ```bash # Matches the nightly configuration diff --git a/utils/run-containers-tests.sh b/utils/run-containers-tests.sh index 1057b2149c..feae25bc58 100755 --- a/utils/run-containers-tests.sh +++ b/utils/run-containers-tests.sh @@ -20,7 +20,7 @@ # --shell (drop to shell instead of running tests; enables SYS_PTRACE for gdb) # --mount (mount local repo instead of cloning - faster but may have stale artifacts) # --rebuild (force rebuild of container images) -# --rebuild-base (force rebuild of base image only) +# --rebuild-base (force rebuild of the base image, and of the JDK image built on it) # --matrix (preview a full matrix; unset dimensions expand to all) # --run (execute an inferred matrix without prompting) # --fail-fast (stop matrix execution on first failure) @@ -1115,7 +1115,9 @@ fi # ========== Build JDK Image (if needed) ========== IMAGE_EXISTS=false -if [[ "$REBUILD" == "false" ]]; then +# A JDK image cached on top of an older base image would hide the rebuilt base's packages +# (e.g. gcovr), so it is reused only when the base image was reused too. +if [[ "$REBUILD" == "false" && "$BASE_IMAGE_EXISTS" == "true" ]]; then if "$CONTAINER_RUNTIME" image inspect "$IMAGE_NAME" >/dev/null 2>&1; then IMAGE_EXISTS=true echo ">>> Using cached image: $IMAGE_NAME"