diff --git a/.github/actions/setup_cached_java/action.yml b/.github/actions/setup_cached_java/action.yml index 952e45b71..2ed09050d 100644 --- a/.github/actions/setup_cached_java/action.yml +++ b/.github/actions/setup_cached_java/action.yml @@ -18,11 +18,12 @@ runs: shell: bash id: infer_build_jdk run: | - echo "Infering JDK 11 [${{ inputs.arch }}]" + # Gradle 9 requires JDK 17+ to run; using JDK 21 (LTS) + echo "Inferring JDK 21 [${{ inputs.arch }}]" if [[ ${{ inputs.arch }} =~ "-musl" ]]; then - echo "build_jdk=jdk11-librca" >> $GITHUB_OUTPUT + echo "build_jdk=jdk21-librca" >> $GITHUB_OUTPUT else - echo "build_jdk=jdk11" >> $GITHUB_OUTPUT + echo "build_jdk=jdk21" >> $GITHUB_OUTPUT fi - name: Cache Build JDK [${{ inputs.arch }}] id: cache_build_jdk diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..448767244 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + # Gradle dependencies (root project) + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + gradle-minor: + update-types: + - "minor" + - "patch" + + # Gradle dependencies (build-logic composite build) + - package-ecosystem: "gradle" + directory: "/build-logic" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + gradle-minor: + update-types: + - "minor" + - "patch" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47dd5696c..0f0c90005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,13 @@ jobs: if: needs.check-for-pr.outputs.skip != 'true' steps: - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: '21' + - name: Setup OS run: | sudo apt-get update diff --git a/.github/workflows/test_workflow.yml b/.github/workflows/test_workflow.yml index 3deb10c48..df82c620a 100644 --- a/.github/workflows/test_workflow.yml +++ b/.github/workflows/test_workflow.yml @@ -15,6 +15,22 @@ jobs: cache-jdks: # This job is used to cache the JDKs for the test jobs uses: ./.github/workflows/cache_java.yml + filter-musl-configs: + # Sanitizers (asan/tsan) are not supported on musl - filter them out + runs-on: ubuntu-latest + outputs: + configs: ${{ steps.filter.outputs.configs }} + has_configs: ${{ steps.filter.outputs.has_configs }} + steps: + - id: filter + run: | + configs=$(echo '${{ inputs.configuration }}' | jq -c '[.[] | select(. != "asan" and . != "tsan")]') + if [ "$configs" = "[]" ]; then + echo "has_configs=false" >> $GITHUB_OUTPUT + else + echo "has_configs=true" >> $GITHUB_OUTPUT + fi + echo "configs=$configs" >> $GITHUB_OUTPUT test-linux-glibc-amd64: needs: cache-jdks strategy: @@ -132,12 +148,13 @@ jobs: path: test-reports test-linux-musl-amd64: - needs: cache-jdks + needs: [cache-jdks, filter-musl-configs] + if: needs.filter-musl-configs.outputs.has_configs == 'true' strategy: fail-fast: false matrix: java_version: [ "8-librca", "11-librca", "17-librca", "21-librca", "25-librca" ] - config: ${{ fromJson(inputs.configuration) }} + config: ${{ fromJson(needs.filter-musl-configs.outputs.configs) }} runs-on: ubuntu-latest container: image: "alpine:3.21" @@ -368,12 +385,13 @@ jobs: path: test-reports test-linux-musl-aarch64: - needs: cache-jdks + needs: [cache-jdks, filter-musl-configs] + if: needs.filter-musl-configs.outputs.has_configs == 'true' strategy: fail-fast: false matrix: java_version: [ "8-librca", "11-librca", "17-librca", "21-librca", "25-librca" ] - config: ${{ fromJson(inputs.configuration) }} + config: ${{ fromJson(needs.filter-musl-configs.outputs.configs) }} runs-on: group: ARM LINUX SHARED labels: arm-4core-linux-ubuntu24.04 diff --git a/AGENTS.md b/AGENTS.md index 308f682bf..3752e9461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,11 +274,22 @@ Release builds automatically extract debug symbols: ## Development Workflow ### Running Single Tests -Use standard Gradle syntax: +Use the `-Ptests` property across all platforms: ```bash -./gradlew :ddprof-test:test --tests "ClassName.methodName" +./gradlew :ddprof-test:testDebug -Ptests=ClassName.methodName # Single method +./gradlew :ddprof-test:testDebug -Ptests=ClassName # Entire class +./gradlew :ddprof-test:testDebug -Ptests="*.ClassName" # Pattern matching ``` +**Platform Implementation Details:** +- **glibc/macOS**: Test tasks use Gradle's native Test task type with JUnit Platform integration +- **musl (Alpine)**: Exec tasks with custom ProfilerTestRunner (bypasses Gradle 9 toolchain probe issues) +- **Custom Test Runner**: Uses JUnit Platform Launcher API directly (same API used by IDEs and Gradle internally) +- **Result**: Unified `-Ptests` property works identically across all platforms, no platform-specific syntax required + +**Why `-Ptests` instead of `--tests`?** +The `-Ptests` property works consistently across both Test and Exec task types, while `--tests` only works with Test tasks. This ensures a truly unified interface across all platforms. + ### Working with Native Code Native compilation is automatic during build. C++ code changes require: 1. Full rebuild: `/build-and-summarize clean build` @@ -604,8 +615,56 @@ See `gradle.properties.template` for all options. Key ones: - Exclude ddprof-lib/build/async-profiler from searches of active usage -- Run tests with 'testdebug' gradle task -- Use at most Java 21 to build and run tests +- Run tests with 'testDebug' gradle task + +## Build JDK Configuration + +The project uses a **two-JDK pattern**: +- **Build JDK** (`JAVA_HOME`): Used to run Gradle itself. Must be JDK 17+ for Gradle 9. +- **Test JDK** (`JAVA_TEST_HOME`): Used to run tests against different Java versions. + +**Current requirement:** JDK 21 (LTS) for building, targeting Java 8 bytecode via `--release 8`. + +### Files to Modify When Changing Build JDK Version + +When upgrading the build JDK (e.g., from JDK 21 to JDK 25), update these files: + +| File | What to Change | +|------|----------------| +| `README.md` | Update "Prerequisites" section with new JDK version | +| `.github/actions/setup_cached_java/action.yml` | Change `build_jdk=jdk21` to new version (line ~25) | +| `.github/workflows/ci.yml` | Update `java-version` in `check-formatting` job's Setup Java step | +| `utils/run-docker-tests.sh` | Update `BUILD_JDK_VERSION="21"` constant | +| `build-logic/.../JavaConventionsPlugin.kt` | Update documentation comment if minimum changes | + +### Files to Modify When Changing Target JDK Version + +When changing the target bytecode version (e.g., from Java 8 to Java 11): + +| File | What to Change | +|------|----------------| +| `build-logic/.../JavaConventionsPlugin.kt` | Change `--release 8` to new version | +| `ddprof-lib/build.gradle.kts` | Change `sourceCompatibility`/`targetCompatibility` | +| `README.md` | Update minimum Java runtime version | + +### Gradle 9 API Changes Reference + +When upgrading Gradle major versions, watch for these breaking changes: + +| Old API | New API (Gradle 9+) | Affected Files | +|---------|---------------------|----------------| +| `project.exec { }` in task actions | `ProcessBuilder` directly | `GtestPlugin.kt` | +| `String.capitalize()` | `replaceFirstChar { it.uppercaseChar() }` | Kotlin plugins | +| `createTempFile()` | `kotlin.io.path.createTempFile()` | `PlatformUtils.kt` | +| Spotless `userData()` | `editorConfigOverride()` | `SpotlessConventionPlugin.kt` | +| Spotless `indentWithSpaces()` | `leadingTabsToSpaces()` | `SpotlessConventionPlugin.kt` | + +### CI JDK Caching + +The CI caches JDKs via `.github/workflows/cache_java.yml`. When adding a new JDK version: +1. Add version URLs to `cache_java.yml` environment variables +2. Add to the `java_variant` matrix in cache jobs +3. Run the `cache_java.yml` workflow manually to populate caches ## Agentic Work @@ -623,11 +682,11 @@ See `gradle.properties.template` for all options. Key ones: ``` - Instead of: ```bash - ./gradlew :prof-utils:test --tests "UpscaledMethodSampleEventSinkTest" + ./gradlew :ddprof-test:testDebug -Ptests=MuslDetectionTest ``` use: ```bash - ./.claude/commands/build-and-summarize :prof-utils:test --tests "UpscaledMethodSampleEventSinkTest" + ./.claude/commands/build-and-summarize :ddprof-test:testdebug -Ptests=MuslDetectionTest ``` - This ensures the full build log is captured to a file and only a summary is shown in the main session. diff --git a/README.md b/README.md index e94163bb1..5824a8ef6 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ If you need a full-fledged Java profiler head back to [async-profiler](https://g ## Build ### Prerequisites -1. JDK 8 or later (required for building) -2. Gradle (included in wrapper) +1. JDK 21 or later (required for building - Gradle 9 requirement) +2. Gradle 9.3.1 (included in wrapper) 3. C++ compiler (clang++ preferred, g++ supported) - Build system auto-detects clang++ or g++ - Override with: `./gradlew build -Pnative.forceCompiler=g++` diff --git a/build-logic/conventions/build.gradle.kts b/build-logic/conventions/build.gradle.kts index 633469242..c863e0e6c 100644 --- a/build-logic/conventions/build.gradle.kts +++ b/build-logic/conventions/build.gradle.kts @@ -9,7 +9,7 @@ repositories { dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib") - implementation("com.diffplug.spotless:spotless-plugin-gradle:6.11.0") + implementation("com.diffplug.spotless:spotless-plugin-gradle:7.0.2") } gradlePlugin { 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 a58ff29c2..e7d593630 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 @@ -142,13 +142,18 @@ class GtestPlugin : Plugin { val libDir = File("$targetDir/$libName") val libSrcDir = File("$srcDir/$libName") - project.exec { - commandLine("sh", "-c", """ - echo "Processing library: $libName @ $libSrcDir" - mkdir -p $libDir - cd $libSrcDir - make TARGET_DIR=$libDir - """.trimIndent()) + // Use ProcessBuilder directly (Gradle 9 removed project.exec in task actions) + val process = ProcessBuilder("sh", "-c", """ + echo "Processing library: $libName @ $libSrcDir" + mkdir -p $libDir + cd $libSrcDir + make TARGET_DIR=$libDir + """.trimIndent()) + .inheritIO() + .start() + val exitCode = process.waitFor() + if (exitCode != 0) { + throw org.gradle.api.GradleException("Failed to build native lib: $libName (exit code: $exitCode)") } } } 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 0d529db45..6c2e101ae 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 @@ -6,6 +6,9 @@ import com.datadoghq.native.model.Platform import org.gradle.api.GradleException import org.gradle.api.Project import java.io.File +import kotlin.io.path.createTempFile +import kotlin.io.path.deleteIfExists +import kotlin.io.path.writeText import java.util.concurrent.TimeUnit object PlatformUtils { @@ -83,31 +86,43 @@ object PlatformUtils { return null } - return try { - // Try the specified compiler first, fall back to gcc - val compilerToUse = if (isCompilerAvailable(compiler)) { - compiler - } else if (compiler != "gcc" && isCompilerAvailable("gcc")) { - "gcc" - } else { - return null + // Try the specified compiler first + if (isCompilerAvailable(compiler)) { + try { + val process = ProcessBuilder(compiler, "-print-file-name=$libName.so") + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + + if (process.exitValue() == 0 && output != "$libName.so") { + return output + } + } catch (e: Exception) { + // Fall through to try gcc } + } - val process = ProcessBuilder(compilerToUse, "-print-file-name=$libName.so") - .redirectErrorStream(true) - .start() + // If the specified compiler didn't find it, try gcc as fallback + if (compiler != "gcc" && isCompilerAvailable("gcc")) { + try { + val process = ProcessBuilder("gcc", "-print-file-name=$libName.so") + .redirectErrorStream(true) + .start() - val output = process.inputStream.bufferedReader().readText().trim() - process.waitFor() + val output = process.inputStream.bufferedReader().readText().trim() + process.waitFor() - if (process.exitValue() == 0 && !output.endsWith("$libName.so")) { - output - } else { - null + if (process.exitValue() == 0 && output != "$libName.so") { + return output + } + } catch (e: Exception) { + // Fall through to return null } - } catch (e: Exception) { - null } + + return null } fun locateLibasan(compiler: String = "gcc"): String? = locateLibrary("libasan", compiler) @@ -124,7 +139,7 @@ object PlatformUtils { "clang++", "-fsanitize=fuzzer", "-c", - testFile.absolutePath, + testFile.toAbsolutePath().toString(), "-o", "/dev/null" ).redirectErrorStream(true).start() @@ -132,7 +147,7 @@ object PlatformUtils { process.waitFor() process.exitValue() == 0 } finally { - testFile.delete() + testFile.deleteIfExists() } } catch (e: Exception) { false diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/JavaConventionsPlugin.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/JavaConventionsPlugin.kt index 85983bcee..d9ba73953 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/JavaConventionsPlugin.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/JavaConventionsPlugin.kt @@ -10,8 +10,9 @@ import org.gradle.api.tasks.compile.JavaCompile * * Applies standard Java compilation options across all subprojects: * - Java 8 release target for broad JVM compatibility + * - Suppresses JDK 21+ deprecation warnings for --release 8 * - * Requires JDK 9+ for building (uses --release flag). + * Requires JDK 21+ for building (Gradle 9 requirement). * The compiled bytecode targets Java 8 runtime. * * Usage: @@ -23,18 +24,10 @@ import org.gradle.api.tasks.compile.JavaCompile */ class JavaConventionsPlugin : Plugin { override fun apply(project: Project) { - val javaVersion = System.getProperty("java.specification.version")?.toDoubleOrNull() ?: 0.0 - project.tasks.withType(JavaCompile::class.java).configureEach { - if (javaVersion >= 9) { - // JDK 9+ supports --release flag which handles source, target, and boot classpath - options.compilerArgs.addAll(listOf("--release", "8")) - } else { - // Fallback for JDK 8 (not recommended for building) - sourceCompatibility = "8" - targetCompatibility = "8" - project.logger.warn("Building with JDK 8 is not recommended. Use JDK 11+ with --release 8 for better compatibility.") - } + // JDK 21+ deprecated --release 8 with warnings; suppress with -Xlint:-options + // The deprecation is informational - Java 8 targeting still works + options.compilerArgs.addAll(listOf("--release", "8", "-Xlint:-options")) } } } 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 aeb7c9d4d..f7897d3c4 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 @@ -2,18 +2,19 @@ package com.datadoghq.profiler import com.datadoghq.native.NativeBuildExtension +import com.datadoghq.native.model.BuildConfiguration import com.datadoghq.native.util.PlatformUtils import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.Configuration +import org.gradle.api.file.FileCollection import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property -import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.Exec import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.testing.Test -import org.gradle.api.tasks.testing.logging.TestLogEvent import javax.inject.Inject /** @@ -21,11 +22,29 @@ import javax.inject.Inject * * Provides: * - Standard JVM arguments for profiler testing (attach self, error files, etc.) - * - Java executable selection (JAVA_TEST_HOME or JAVA_HOME) + * - Java executable selection (JAVA_TEST_HOME or JAVA_HOME) on ALL platforms * - Common environment variables (CI, rate limiting) - * - JUnit Platform configuration + * - Unified -Ptests flag support across all platforms * - Automatic multi-config test task generation from NativeBuildExtension * + * Implementation: + * - glibc/macOS: Uses native Test tasks with Gradle's JUnit Platform integration + * - musl (Alpine): Uses Exec tasks with custom ProfilerTestRunner (bypasses toolchain probe) + * - Unified interface: -Ptests property works identically on all platforms + * - Supports multi-JDK testing via JAVA_TEST_HOME on all platforms + * - Same task names everywhere (testdebug, testrelease, unwindingReportRelease) + * + * Platform Detection: + * - Uses PlatformUtils.isMusl() at configuration time to select task implementation + * - musl systems: Exec task with ProfilerTestRunner (uses JUnit Platform Launcher API directly) + * - glibc/macOS: Normal Test task with native JUnit integration + * + * Custom Test Runner: + * - ProfilerTestRunner uses JUnit Platform Launcher API directly + * - Avoids Console Launcher issues (assertions, JVM args, NoSuchMethodError on musl + JDK 11) + * - Same API used by IDEs and Gradle's Test task internally + * - Supports test filtering via -Dtest.filter system property + * * Usage: * ```kotlin * plugins { @@ -42,12 +61,17 @@ import javax.inject.Inject * // Optional: add extra JVM args * extraJvmArgs.add("-Xms256m") * - * // Optional: specify which configs get application tasks (default: release, debug) + * // Optional: specify which configs get application tasks (default: all active configs) * applicationConfigs.set(listOf("release", "debug")) * * // Optional: main class for application tasks * applicationMainClass.set("com.datadoghq.profiler.unwinding.UnwindingValidator") * } + * + * // Run tests (all platforms use same syntax): + * ./gradlew :ddprof-test:testdebug -Ptests=ClassName.methodName + * ./gradlew :ddprof-test:testdebug -Ptests=ClassName + * ./gradlew :ddprof-test:testdebug -Ptests="*.Pattern*" * ``` */ class ProfilerTestPlugin : Plugin { @@ -61,24 +85,14 @@ class ProfilerTestPlugin : Plugin { // Create base configurations eagerly so they can be extended by build scripts // without needing afterEvaluate project.configurations.maybeCreate("testCommon").apply { - isCanBeConsumed = true + isCanBeConsumed = false isCanBeResolved = true } project.configurations.maybeCreate("mainCommon").apply { - isCanBeConsumed = true + isCanBeConsumed = false isCanBeResolved = true } - // Configure all Test tasks with standard settings - project.tasks.withType(Test::class.java).configureEach { - configureTestTask(this, extension, project) - } - - // Configure all JavaExec tasks with standard settings - project.tasks.withType(JavaExec::class.java).configureEach { - configureJavaExecTask(this, extension, project) - } - // After evaluation, generate multi-config tasks if profilerLibProject is set project.afterEvaluate { if (extension.profilerLibProject.isPresent) { @@ -87,48 +101,241 @@ class ProfilerTestPlugin : Plugin { } } - private fun configureTestTask(task: Test, extension: ProfilerTestExtension, project: Project) { - task.onlyIf { !project.hasProperty("skip-tests") } + /** + * Shared test task configuration extracted for reuse between Test and Exec paths. + */ + private data class TestTaskConfiguration( + val configName: String, + val isActive: Boolean, + val testClasspath: FileCollection, + val standardJvmArgs: List, + val extraJvmArgs: List, + val systemProperties: Map, + val environmentVariables: Map + ) + + /** + * Build shared test configuration used by both Test and Exec task creation. + */ + private fun buildTestConfiguration( + project: Project, + extension: ProfilerTestExtension, + config: BuildConfiguration, + testCfg: Configuration, + sourceSets: SourceSetContainer + ): TestTaskConfiguration { + val configName = config.name + val testEnv = config.testEnvironment.get() + + // Build classpath + val testClasspath = sourceSets.getByName("test").runtimeClasspath.filter { file -> + !file.name.contains("ddprof-") || file.name.contains("test-tracer") + } + testCfg + + // System properties + val keepRecordings = project.hasProperty("keepJFRs") || + System.getenv("KEEP_JFRS")?.toBoolean() ?: false + val systemPropsBase = mapOf( + "ddprof_test.keep_jfrs" to keepRecordings.toString(), + "ddprof_test.config" to configName, + "ddprof_test.ci" to (project.hasProperty("CI")).toString(), + "DDPROF_TEST_DISABLE_RATE_LIMIT" to "1", + "CI" to (project.hasProperty("CI") || System.getenv("CI")?.toBoolean() ?: false).toString() + ) + val systemProps = systemPropsBase + testEnv + + // Environment variables (explicit for consistency across both paths) + val envVars = buildMap { + putAll(testEnv) + put("DDPROF_TEST_DISABLE_RATE_LIMIT", "1") + put("CI", (project.hasProperty("CI") || System.getenv("CI")?.toBoolean() ?: false).toString()) + // Pass through CI vars (needed for Exec, optional for Test) + System.getenv("LIBC")?.let { put("LIBC", it) } + System.getenv("KEEP_JFRS")?.let { put("KEEP_JFRS", it) } + System.getenv("TEST_COMMIT")?.let { put("TEST_COMMIT", it) } + System.getenv("TEST_CONFIGURATION")?.let { put("TEST_CONFIGURATION", it) } + System.getenv("SANITIZER")?.let { put("SANITIZER", it) } + } + + return TestTaskConfiguration( + configName = configName, + isActive = config.active.get(), + testClasspath = testClasspath, + standardJvmArgs = extension.standardJvmArgs.get(), + extraJvmArgs = extension.extraJvmArgs.get(), + systemProperties = systemProps, + environmentVariables = envVars + ) + } + + /** + * Create native Test task for glibc/macOS (normal path). + * Uses Gradle's Test task with -Ptests property support. + */ + private fun createTestTask( + project: Project, + extension: ProfilerTestExtension, + testConfig: TestTaskConfiguration, + testCfg: Configuration, + sourceSets: SourceSetContainer + ) { + project.tasks.register("test${testConfig.configName.replaceFirstChar { it.uppercase() }}", Test::class.java) { + val testTask = this + testTask.description = "Runs unit tests with the ${testConfig.configName} library variant" + testTask.group = "verification" + testTask.onlyIf { testConfig.isActive && !project.hasProperty("skip-tests") } + + // Dependencies + testTask.dependsOn(project.tasks.named("compileTestJava")) + testTask.dependsOn(testCfg) + testTask.dependsOn(sourceSets.getByName("test").output) + + // Test class directories and classpath + testTask.testClassesDirs = sourceSets.getByName("test").output.classesDirs + testTask.classpath = testConfig.testClasspath + + // Use JUnit Platform + testTask.useJUnitPlatform() + + // Configure Java executable - bypasses toolchain system + testTask.setExecutable(PlatformUtils.testJavaExecutable()) + + // Environment variables (from testConfig which already includes DDPROF_TEST_DISABLE_RATE_LIMIT and CI) + testConfig.environmentVariables.forEach { (key, value) -> + testTask.environment(key, value) + } + + // Test output + testTask.testLogging { + val logging = this + logging.events("passed", "skipped", "failed") + logging.showStandardStreams = true + } - // Use JUnit Platform - task.useJUnitPlatform() + // UNIFIED INTERFACE: Support -Ptests property (same as musl) + val testsFilter = project.findProperty("tests") as String? + if (testsFilter != null) { + // Forward -Ptests to Test task's filter + testTask.filter.includeTestsMatching(testsFilter) + } - // Configure Java executable - use centralized utility for JAVA_TEST_HOME/JAVA_HOME resolution - task.setExecutable(PlatformUtils.testJavaExecutable()) + // Warn if --tests flag was used instead of -Ptests + testTask.doFirst { + val filterPatterns = testTask.filter.includePatterns + if (filterPatterns.isNotEmpty() && testsFilter == null) { + project.logger.warn("") + project.logger.warn("WARNING: --tests flag detected. While it works on glibc/macOS, it will FAIL on musl systems.") + project.logger.warn("For consistent behavior across all platforms, please use -Ptests instead:") + project.logger.warn(" ./gradlew :ddprof-test:${testTask.name} -Ptests=${filterPatterns.first()}") + project.logger.warn("") + } + } - // Standard environment variables - task.environment("DDPROF_TEST_DISABLE_RATE_LIMIT", "1") - task.environment("CI", project.hasProperty("CI") || System.getenv("CI")?.toBoolean() ?: false) + // JVM arguments and system properties - configure in doFirst like main does + testTask.doFirst { + val allArgs = mutableListOf() + allArgs.addAll(testConfig.standardJvmArgs) - // Test logging - task.testLogging.showStandardStreams = true - task.testLogging.events(TestLogEvent.FAILED, TestLogEvent.SKIPPED) + if (extension.nativeLibDir.isPresent) { + allArgs.add("-Djava.library.path=${extension.nativeLibDir.get().asFile.absolutePath}") + } - // JVM arguments - combine standard + extra - task.doFirst { - val allArgs = mutableListOf() - allArgs.addAll(extension.standardJvmArgs.get()) + // System properties as JVM args + testConfig.systemProperties.forEach { (key, value) -> + allArgs.add("-D$key=$value") + } - // Add native library path if configured - if (extension.nativeLibDir.isPresent) { - allArgs.add("-Djava.library.path=${extension.nativeLibDir.get().asFile.absolutePath}") + allArgs.addAll(testConfig.extraJvmArgs) + testTask.jvmArgs(allArgs) } - allArgs.addAll(extension.extraJvmArgs.get()) - task.jvmArgs(allArgs) + // Sanitizer conditions + when (testConfig.configName) { + "asan" -> testTask.onlyIf { PlatformUtils.locateLibasan() != null } + "tsan" -> testTask.onlyIf { PlatformUtils.locateLibtsan() != null } + } } } - private fun configureJavaExecTask(task: JavaExec, extension: ProfilerTestExtension, project: Project) { - // Configure Java executable - use centralized utility for JAVA_TEST_HOME/JAVA_HOME resolution - task.setExecutable(PlatformUtils.testJavaExecutable()) + /** + * Create Exec task with custom test runner for musl platforms. + * Uses ProfilerTestRunner with JUnit Platform Launcher API directly. + * Supports unified -Ptests property interface for test filtering. + */ + private fun createExecTestTask( + project: Project, + extension: ProfilerTestExtension, + testConfig: TestTaskConfiguration, + testCfg: Configuration, + sourceSets: SourceSetContainer + ) { + project.tasks.register("test${testConfig.configName.replaceFirstChar { it.uppercase() }}", Exec::class.java) { + val execTask = this + execTask.description = "Runs unit tests with the ${testConfig.configName} library variant (musl workaround)" + execTask.group = "verification" + execTask.onlyIf { testConfig.isActive && !project.hasProperty("skip-tests") } + + // Dependencies + execTask.dependsOn(project.tasks.named("compileTestJava")) + execTask.dependsOn(testCfg) + execTask.dependsOn(sourceSets.getByName("test").output) + + // Configure at execution time to capture -Ptests filter + execTask.doFirst { + execTask.executable = PlatformUtils.testJavaExecutable() + + val allArgs = mutableListOf() + + // JVM args + allArgs.addAll(testConfig.standardJvmArgs) + if (extension.nativeLibDir.isPresent) { + allArgs.add("-Djava.library.path=${extension.nativeLibDir.get().asFile.absolutePath}") + } + allArgs.addAll(testConfig.extraJvmArgs) + + // System properties + testConfig.systemProperties.forEach { (key, value) -> + allArgs.add("-D$key=$value") + } + + // UNIFIED INTERFACE: Test filter from -Ptests property + val testsFilter = project.findProperty("tests") as String? + if (testsFilter != null) { + allArgs.add("-Dtest.filter=$testsFilter") + } + + // Classpath (includes custom test runner) + allArgs.add("-cp") + allArgs.add(testConfig.testClasspath.asPath) - // JVM arguments for JavaExec tasks - task.doFirst { - val allArgs = mutableListOf() - allArgs.addAll(extension.standardJvmArgs.get()) - allArgs.addAll(extension.extraJvmArgs.get()) - task.jvmArgs(allArgs) + // Use custom test runner (NOT ConsoleLauncher) + allArgs.add("com.datadoghq.profiler.test.ProfilerTestRunner") + + execTask.args = allArgs + } + + // Environment variables + testConfig.environmentVariables.forEach { (key, value) -> + execTask.environment(key, value) + } + + // CRITICAL FIX: Remove LD_LIBRARY_PATH to let RPATH work correctly + // The test JDK's launcher has RPATH set to find its own libraries ($ORIGIN/../lib/jli) + // But LD_LIBRARY_PATH overrides RPATH and causes it to load the wrong libjli.so + // Solution: Unset LD_LIBRARY_PATH entirely to let RPATH take precedence + execTask.doFirst { + val currentLdLibPath = (execTask.environment["LD_LIBRARY_PATH"] as? String) ?: System.getenv("LD_LIBRARY_PATH") + if (!currentLdLibPath.isNullOrEmpty()) { + project.logger.info("Removing LD_LIBRARY_PATH to prevent cross-JDK library conflicts (was: $currentLdLibPath)") + execTask.environment.remove("LD_LIBRARY_PATH") + } + } + + // Sanitizer conditions + when (testConfig.configName) { + "asan" -> execTask.onlyIf { PlatformUtils.locateLibasan() != null } + "tsan" -> execTask.onlyIf { PlatformUtils.locateLibtsan() != null } + } } } @@ -176,8 +383,8 @@ class ProfilerTestPlugin : Plugin { configNames.add(configName) // Create test configuration - val testCfg = project.configurations.maybeCreate("test${configName.capitalize()}Implementation").apply { - isCanBeConsumed = true + val testCfg = project.configurations.maybeCreate("test${configName.replaceFirstChar { it.uppercaseChar() }}Implementation").apply { + isCanBeConsumed = false isCanBeResolved = true extendsFrom(testCommon) } @@ -185,39 +392,25 @@ class ProfilerTestPlugin : Plugin { project.dependencies.project(mapOf("path" to profilerLibProjectPath, "configuration" to configName)) ) - // Create test task using configuration closure - project.tasks.register("test$configName", Test::class.java) { - val testTask = this - testTask.onlyIf { isActive } - testTask.dependsOn(project.tasks.named("compileTestJava")) - testTask.description = "Runs unit tests with the $configName library variant" - testTask.group = "verification" - - // Filter classpath to include only necessary dependencies - testTask.classpath = sourceSets.getByName("test").runtimeClasspath.filter { file -> - !file.name.contains("ddprof-") || file.name.contains("test-tracer") - } + testCfg - - // Apply test environment from config - if (testEnv.isNotEmpty()) { - testEnv.forEach { (key, value) -> - testTask.environment(key, value) - } - } - - // Sanitizer-specific conditions - when (configName) { - "asan" -> testTask.onlyIf { PlatformUtils.locateLibasan() != null } - "tsan" -> testTask.onlyIf { PlatformUtils.locateLibtsan() != null } - else -> { /* no additional conditions */ } - } + // Build shared configuration + val testConfig = buildTestConfiguration(project, extension, config, testCfg, sourceSets) + + // Platform-conditional task creation + // Check both PlatformUtils.isMusl() and LIBC environment variable (set by Docker) + val isMuslSystem = PlatformUtils.isMusl() || System.getenv("LIBC") == "musl" + if (isMuslSystem) { + project.logger.info("Creating Exec task for $configName (musl workaround, LIBC=${System.getenv("LIBC")})") + createExecTestTask(project, extension, testConfig, testCfg, sourceSets) + } else { + project.logger.info("Creating Test task for $configName (glibc/macOS, LIBC=${System.getenv("LIBC")})") + createTestTask(project, extension, testConfig, testCfg, sourceSets) } // Create application tasks for specified configs if (configName in applicationConfigs && appMainClass.isNotEmpty()) { // Create main configuration val mainCfg = project.configurations.maybeCreate("${configName}Implementation").apply { - isCanBeConsumed = true + isCanBeConsumed = false isCanBeResolved = true extendsFrom(mainCommon) } @@ -225,15 +418,36 @@ class ProfilerTestPlugin : Plugin { project.dependencies.project(mapOf("path" to profilerLibProjectPath, "configuration" to configName)) ) - // Create run task - project.tasks.register("runUnwindingValidator${configName.capitalize()}", JavaExec::class.java) { + // Create run task using Exec to bypass Gradle's toolchain system + project.tasks.register("runUnwindingValidator${configName.replaceFirstChar { it.uppercaseChar() }}", Exec::class.java) { val runTask = this runTask.onlyIf { isActive } - runTask.dependsOn(project.tasks.named("compileJava")) runTask.description = "Run the unwinding validator application ($configName config)" runTask.group = "application" - runTask.mainClass.set(appMainClass) - runTask.classpath = sourceSets.getByName("main").runtimeClasspath + mainCfg + + runTask.dependsOn(project.tasks.named("compileJava")) + runTask.dependsOn(mainCfg) + + val mainClasspath = sourceSets.getByName("main").runtimeClasspath + mainCfg + + runTask.doFirst { + // Set executable at execution time so environment variables are read correctly + runTask.executable = PlatformUtils.testJavaExecutable() + + val allArgs = mutableListOf() + allArgs.addAll(extension.standardJvmArgs.get()) + allArgs.addAll(extension.extraJvmArgs.get()) + allArgs.add("-cp") + allArgs.add(mainClasspath.asPath) + allArgs.add(appMainClass) + + // Handle validatorArgs property + if (project.hasProperty("validatorArgs")) { + allArgs.addAll((project.property("validatorArgs") as String).split(" ")) + } + + runTask.args = allArgs + } if (testEnv.isNotEmpty()) { testEnv.forEach { (key, value) -> @@ -241,25 +455,45 @@ class ProfilerTestPlugin : Plugin { } } - // Handle validatorArgs property - if (project.hasProperty("validatorArgs")) { - runTask.setArgs((project.property("validatorArgs") as String).split(" ")) + // CRITICAL FIX: Remove LD_LIBRARY_PATH to let RPATH work correctly + runTask.doFirst { + val currentLdLibPath = (runTask.environment["LD_LIBRARY_PATH"] as? String) ?: System.getenv("LD_LIBRARY_PATH") + if (!currentLdLibPath.isNullOrEmpty()) { + project.logger.info("Removing LD_LIBRARY_PATH to prevent cross-JDK library conflicts (was: $currentLdLibPath)") + runTask.environment.remove("LD_LIBRARY_PATH") + } } } - // Create report task - project.tasks.register("unwindingReport${configName.capitalize()}", JavaExec::class.java) { + // Create report task using Exec to bypass Gradle's toolchain system + project.tasks.register("unwindingReport${configName.replaceFirstChar { it.uppercaseChar() }}", Exec::class.java) { val reportTask = this reportTask.onlyIf { isActive } - reportTask.dependsOn(project.tasks.named("compileJava")) reportTask.description = "Generate unwinding report for CI ($configName config)" reportTask.group = "verification" - reportTask.mainClass.set(appMainClass) - reportTask.classpath = sourceSets.getByName("main").runtimeClasspath + mainCfg - reportTask.args = listOf( - "--output-format=markdown", - "--output-file=build/reports/unwinding-summary.md" - ) + + reportTask.dependsOn(project.tasks.named("compileJava")) + reportTask.dependsOn(mainCfg) + + val mainClasspath = sourceSets.getByName("main").runtimeClasspath + mainCfg + + reportTask.doFirst { + // Set executable at execution time so environment variables are read correctly + reportTask.executable = PlatformUtils.testJavaExecutable() + + project.file("${project.layout.buildDirectory.get()}/reports").mkdirs() + + val allArgs = mutableListOf() + allArgs.addAll(extension.standardJvmArgs.get()) + allArgs.addAll(extension.extraJvmArgs.get()) + allArgs.add("-cp") + allArgs.add(mainClasspath.asPath) + allArgs.add(appMainClass) + allArgs.add("--output-format=markdown") + allArgs.add("--output-file=build/reports/unwinding-summary.md") + + reportTask.args = allArgs + } if (testEnv.isNotEmpty()) { testEnv.forEach { (key, value) -> @@ -268,8 +502,13 @@ class ProfilerTestPlugin : Plugin { } reportTask.environment("CI", project.hasProperty("CI") || System.getenv("CI")?.toBoolean() ?: false) + // CRITICAL FIX: Remove LD_LIBRARY_PATH to let RPATH work correctly reportTask.doFirst { - project.file("${project.layout.buildDirectory.get()}/reports").mkdirs() + val currentLdLibPath = (reportTask.environment["LD_LIBRARY_PATH"] as? String) ?: System.getenv("LD_LIBRARY_PATH") + if (!currentLdLibPath.isNullOrEmpty()) { + project.logger.info("Removing LD_LIBRARY_PATH to prevent cross-JDK library conflicts (was: $currentLdLibPath)") + reportTask.environment.remove("LD_LIBRARY_PATH") + } } } } @@ -315,12 +554,12 @@ class ProfilerTestPlugin : Plugin { val profilerLibProject = project.rootProject.findProject(profilerLibProjectPath) if (profilerLibProject != null) { - val assembleTask = profilerLibProject.tasks.findByName("assemble${cfgName.capitalize()}") + val assembleTask = profilerLibProject.tasks.findByName("assemble${cfgName.replaceFirstChar { it.uppercaseChar() }}") if (testTask != null && assembleTask != null) { assembleTask.dependsOn(testTask) } - val gtestTask = profilerLibProject.tasks.findByName("gtest${cfgName.capitalize()}") + val gtestTask = profilerLibProject.tasks.findByName("gtest${cfgName.replaceFirstChar { it.uppercaseChar() }}") if (testTask != null && gtestTask != null) { testTask.dependsOn(gtestTask) } @@ -353,7 +592,7 @@ abstract class ProfilerTestExtension @Inject constructor( ) { /** - * Standard JVM arguments applied to all Test and JavaExec tasks. + * Standard JVM arguments applied to all Exec-based test and application tasks. * These are the common profiler testing requirements. */ abstract val standardJvmArgs: ListProperty @@ -365,7 +604,7 @@ abstract class ProfilerTestExtension @Inject constructor( /** * Directory containing native test libraries. - * When set, adds -Djava.library.path to Test tasks. + * When set, adds -Djava.library.path to test Exec tasks. */ val nativeLibDir: org.gradle.api.file.DirectoryProperty = objects.directoryProperty() diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/SpotlessConventionPlugin.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/SpotlessConventionPlugin.kt index 4c1236a8f..1d9e5fc69 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/SpotlessConventionPlugin.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/SpotlessConventionPlugin.kt @@ -37,8 +37,8 @@ class SpotlessConventionPlugin : Plugin { spotless.kotlinGradle { toggleOffOn() target("*.gradle.kts") - // ktlint 0.41.0 is compatible with older Kotlin versions in this build - ktlint("0.41.0").userData( + // ktlint 1.5.0 is compatible with Kotlin 2.x and Spotless 7.x + ktlint("1.5.0").editorConfigOverride( mapOf( "indent_size" to "2", "continuation_indent_size" to "2" @@ -92,7 +92,7 @@ class SpotlessConventionPlugin : Plugin { "tooling/*.sh", ".circleci/*.sh" ) - indentWithSpaces() + leadingTabsToSpaces() trimTrailingWhitespace() endWithNewline() } diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index b5c04397e..3e7e0dc96 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -1,12 +1,14 @@ import com.datadoghq.native.model.Platform import com.datadoghq.native.util.PlatformUtils +import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven +import org.gradle.api.tasks.VerificationTask plugins { java `maven-publish` signing - id("com.github.ben-manes.versions") version "0.27.0" - id("de.undercouch.download") version "4.1.1" + id("com.github.ben-manes.versions") version "0.51.0" + id("de.undercouch.download") version "5.6.0" id("com.datadoghq.native-build") id("com.datadoghq.gtest") id("com.datadoghq.scanbuild") @@ -25,8 +27,8 @@ nativeBuild { includeDirectories.set( listOf( "src/main/cpp", - "${project(":malloc-shim").file("src/main/public")}" - ) + "${project(":malloc-shim").file("src/main/public")}", + ), ) } @@ -46,7 +48,7 @@ gtest { "src/main/cpp", "$javaHome/include", "$javaHome/include/$platformInclude", - project(":malloc-shim").file("src/main/public") + project(":malloc-shim").file("src/main/public"), ) } @@ -85,6 +87,14 @@ val copyExternalLibs by tasks.registering(Copy::class) { } } +// Gradle 9 requires explicit dependency: compileJava9Java uses mainSourceSet.output +// which includes the copyExternalLibs destination directory +afterEvaluate { + tasks.named("compileJava9Java") { + dependsOn(copyExternalLibs) + } +} + // Create JAR tasks for each build configuration using nativeBuild extension utilities // Uses afterEvaluate to discover configurations dynamically from NativeBuildExtension afterEvaluate { @@ -164,7 +174,7 @@ val sourcesJar by tasks.registering(Jar::class) { } // Javadoc configuration -tasks.withType { +tasks.withType().configureEach { // Allow javadoc to access internal sun.nio.ch package used by BufferWriter8 (options as StandardJavadocDocletOptions).addStringOption("-add-exports", "java.base/sun.nio.ch=ALL-UNNAMED") } @@ -175,7 +185,104 @@ val javadocJar by tasks.registering(Jar::class) { archiveBaseName.set(libraryName) archiveClassifier.set("javadoc") archiveVersion.set(componentVersion) - from(tasks.javadoc.get().destinationDir) + from( + tasks.javadoc.map { + it.destinationDir ?: throw GradleException("Javadoc task destinationDir is null - task may not have been configured properly") + }, + ) } -// Publishing configuration will be added later +// Publishing configuration +val isGitlabCI = System.getenv("GITLAB_CI") != null +val isCI = System.getenv("CI") != null + +publishing { + publications { + create("assembled") { + groupId = "com.datadoghq" + artifactId = "ddprof" + + // Add artifacts from each build configuration + afterEvaluate { + nativeBuild.buildConfigurations.names.forEach { name -> + val capitalizedName = name.replaceFirstChar { it.uppercase() } + artifact(tasks.named("assemble${capitalizedName}Jar")) + } + } + artifact(sourcesJar) + artifact(javadocJar) + + pom { + name.set(project.name) + description.set("${project.description} ($componentVersion)") + packaging = "jar" + url.set("https://github.com/datadog/java-profiler") + + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + + scm { + connection.set("scm:https://datadog@github.com/datadog/java-profiler") + developerConnection.set("scm:git@github.com:datadog/java-profiler") + url.set("https://github.com/datadog/java-profiler") + } + + developers { + developer { + id.set("datadog") + name.set("Datadog") + } + } + } + } + } +} + +signing { + useInMemoryPgpKeys(System.getenv("GPG_PRIVATE_KEY"), System.getenv("GPG_PASSWORD")) + sign(publishing.publications["assembled"]) +} + +tasks.withType().configureEach { + onlyIf { + isGitlabCI || (System.getenv("GPG_PRIVATE_KEY") != null && System.getenv("GPG_PASSWORD") != null) + } +} + +// Publication assertions +gradle.taskGraph.whenReady { + if (hasTask(":ddprof-lib:publish") || hasTask(":publishToSonatype")) { + check(project.findProperty("removeJarVersionNumbers") != true) { + "Cannot publish with removeJarVersionNumbers=true" + } + if (hasTask(":publishToSonatype")) { + checkNotNull(System.getenv("SONATYPE_USERNAME")) { "SONATYPE_USERNAME must be set" } + checkNotNull(System.getenv("SONATYPE_PASSWORD")) { "SONATYPE_PASSWORD must be set" } + if (isCI) { + checkNotNull(System.getenv("GPG_PRIVATE_KEY")) { "GPG_PRIVATE_KEY must be set in CI" } + checkNotNull(System.getenv("GPG_PASSWORD")) { "GPG_PASSWORD must be set in CI" } + } + } + } +} + +// Verify project has description (required for published projects) +afterEvaluate { + requireNotNull(description) { "Project ${project.path} is published, must have a description" } +} + +// Ensure published artifacts depend on release JAR +// Note: assembleReleaseJar is registered in afterEvaluate, so use matching instead of named +tasks.withType().configureEach { + if (name.contains("AssembledPublication")) { + dependsOn(tasks.matching { it.name == "assembleReleaseJar" }) + } + rootProject.subprojects.forEach { subproject -> + mustRunAfter(subproject.tasks.matching { it is VerificationTask }) + } +} diff --git a/ddprof-lib/fuzz/build.gradle.kts b/ddprof-lib/fuzz/build.gradle.kts index c47eb472d..db87efffc 100644 --- a/ddprof-lib/fuzz/build.gradle.kts +++ b/ddprof-lib/fuzz/build.gradle.kts @@ -22,7 +22,7 @@ fuzzTargets { // Additional include directories additionalIncludes.set( listOf( - project(":malloc-shim").file("src/main/public").absolutePath - ) + project(":malloc-shim").file("src/main/public").absolutePath, + ), ) } diff --git a/ddprof-stresstest/README.md b/ddprof-stresstest/README.md index f0b62333d..f22caff0e 100644 --- a/ddprof-stresstest/README.md +++ b/ddprof-stresstest/README.md @@ -230,9 +230,11 @@ Use reduced iterations: ### Profiler fails to start Verify profiler library loads: ```bash -./gradlew :ddprof-test:test --tests "JavaProfilerTest.testGetInstance" +./gradlew :ddprof-test:testDebug -Ptests=JavaProfilerTest.testGetInstance ``` +**Note**: The `-Ptests` property works uniformly across all platforms with config-specific test tasks. + ### Out of memory errors - Reduce concurrent thread counts - Use smaller parameter values diff --git a/ddprof-stresstest/build.gradle.kts b/ddprof-stresstest/build.gradle.kts index 42222b5bb..181f6288e 100644 --- a/ddprof-stresstest/build.gradle.kts +++ b/ddprof-stresstest/build.gradle.kts @@ -2,7 +2,7 @@ import com.datadoghq.native.util.PlatformUtils plugins { java - id("me.champeau.jmh") version "0.7.1" + id("me.champeau.jmh") version "0.7.3" id("com.datadoghq.java-conventions") } @@ -35,13 +35,13 @@ jmh { // Configure all JMH-related JavaExec tasks to use the correct JDK tasks.withType().matching { it.name.startsWith("jmh") }.configureEach { - executable = PlatformUtils.testJavaExecutable() + setExecutable(PlatformUtils.testJavaExecutable()) } tasks.named("jmhJar") { manifest { attributes( - "Main-Class" to "com.datadoghq.profiler.stresstest.Main" + "Main-Class" to "com.datadoghq.profiler.stresstest.Main", ) } archiveFileName.set("stresstests.jar") @@ -58,6 +58,6 @@ tasks.register("runStressTests") { "build/libs/stresstests.jar", "-prof", "com.datadoghq.profiler.stresstest.WhiteboxProfiler", - "counters.*" + "counters.*", ) } diff --git a/ddprof-test-native/build.gradle.kts b/ddprof-test-native/build.gradle.kts index 51e9841ab..969a92629 100644 --- a/ddprof-test-native/build.gradle.kts +++ b/ddprof-test-native/build.gradle.kts @@ -26,14 +26,14 @@ simpleNativeLib { when (PlatformUtils.currentPlatform) { Platform.LINUX -> listOf("-fPIC") Platform.MACOS -> emptyList() - } + }, ) linkerArgs.set( when (PlatformUtils.currentPlatform) { Platform.LINUX -> listOf("-shared", "-Wl,--build-id") Platform.MACOS -> listOf("-dynamiclib") - } + }, ) // Create consumable configurations for other projects to depend on diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index dd3d6e247..035e13037 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -22,7 +22,7 @@ configure { // Extra JVM args specific to this project's tests extraJvmArgs.addAll( "-Dddprof.disable_unsafe=true", - "-XX:OnError=/tmp/do_stuff.sh" + "-XX:OnError=/tmp/do_stuff.sh", ) } @@ -51,19 +51,11 @@ dependencies { } // Additional test task configuration beyond what the plugin provides -tasks.withType().configureEach { +// The plugin creates Test tasks on glibc/macOS and Exec tasks on musl +// Both need the native test library to be built first +tasks.matching { it.name.startsWith("test") && it.name != "test" }.configureEach { // Ensure native test library is built before running tests dependsOn(":ddprof-test-native:linkLib") - - // Extract config name from task name for test-specific JVM args - val configName = name.replace("test", "") - val keepRecordings = project.hasProperty("keepJFRs") || System.getenv("KEEP_JFRS")?.toBoolean() ?: false - - jvmArgs( - "-Dddprof_test.keep_jfrs=$keepRecordings", - "-Dddprof_test.config=$configName", - "-Dddprof_test.ci=${project.hasProperty("CI")}" - ) } // Disable the default 'test' task - we use config-specific tasks instead diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/test/ProfilerTestRunner.java b/ddprof-test/src/test/java/com/datadoghq/profiler/test/ProfilerTestRunner.java new file mode 100644 index 000000000..704d343a2 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/test/ProfilerTestRunner.java @@ -0,0 +1,152 @@ +package com.datadoghq.profiler.test; + +import org.junit.platform.engine.discovery.ClassNameFilter; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; + +import java.io.PrintWriter; + +/** + * Custom test runner using JUnit Platform Launcher API. + * + * This runner bypasses JUnit Platform Console Launcher, avoiding known issues with: + * - Assertion handling differences + * - JVM argument forwarding + * - Classpath mounting problems + * - NoSuchMethodError on musl + JDK 11 + * + * Uses the same Launcher API that Gradle's Test task and IDEs use internally, + * ensuring consistent test execution across all platforms. + * + * Test Filtering: + * - -Dtest.filter=ClassName - Run all tests in a class + * - -Dtest.filter=ClassName#method - Run specific test method + * - -Dtest.filter=*.Pattern* - Pattern matching on class names + */ +public class ProfilerTestRunner { + public static void main(String[] args) { + try { + runTests(); + } catch (Throwable t) { + System.err.println("FATAL ERROR in ProfilerTestRunner:"); + t.printStackTrace(System.err); + System.exit(2); + } + } + + private static void runTests() { + // Parse test filter from system property + String testFilter = System.getProperty("test.filter"); + + // Build discovery request + LauncherDiscoveryRequestBuilder requestBuilder = LauncherDiscoveryRequestBuilder.request(); + + if (testFilter != null && !testFilter.isEmpty()) { + // Apply test filter based on format + // Support both # and . as method separators for consistency with Gradle Test tasks + if (testFilter.contains("#")) { + // Method filter with # separator: ClassName#methodName + String[] parts = testFilter.split("#", 2); + requestBuilder.selectors( + DiscoverySelectors.selectMethod(parts[0], parts[1]) + ); + } else if (testFilter.contains(".") && !testFilter.startsWith("*") && isMethodFilter(testFilter)) { + // Method filter with . separator: ClassName.methodName + // Heuristic: if last segment starts with lowercase, it's probably a method name + int lastDot = testFilter.lastIndexOf('.'); + String className = testFilter.substring(0, lastDot); + String methodName = testFilter.substring(lastDot + 1); + requestBuilder.selectors( + DiscoverySelectors.selectMethod(className, methodName) + ); + } else if (testFilter.contains("*")) { + // Pattern filter: *.ClassName or package.* + // Scan entire classpath and apply pattern filter + requestBuilder.selectors( + DiscoverySelectors.selectPackage("") + ); + requestBuilder.filters( + ClassNameFilter.includeClassNamePatterns(testFilter) + ); + } else { + // Class filter - support both fully qualified and short names + // JUnit uses regex patterns, not globs: + // - Fully qualified: com.foo.Bar -> com\.foo\.Bar (escape dots) + // - Short name: Bar -> .*\.Bar (match any package) + String classPattern; + if (testFilter.contains(".")) { + // Fully qualified name - escape dots for regex + classPattern = testFilter.replace(".", "\\."); + } else { + // Short name - prefix with .* to match any package + classPattern = ".*\\." + testFilter; + } + requestBuilder.selectors( + DiscoverySelectors.selectPackage("") + ); + requestBuilder.filters( + ClassNameFilter.includeClassNamePatterns(classPattern) + ); + } + } else { + // No filter: scan all classes in classpath + requestBuilder.selectors( + DiscoverySelectors.selectPackage("") + ); + } + + LauncherDiscoveryRequest request = requestBuilder.build(); + + // Create launcher and register listener + Launcher launcher = LauncherFactory.create(); + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + launcher.registerTestExecutionListeners(listener); + + // Execute tests + launcher.execute(request); + + // Print summary to console + listener.getSummary().printTo(new PrintWriter(System.out)); + + // Exit with appropriate code (0 = success, 1 = failures) + long failures = listener.getSummary().getFailures().size(); + System.exit(failures > 0 ? 1 : 0); + } + + /** + * Heuristic to determine if a filter string is a method filter (ClassName.methodName) + * rather than just a class name. + * + * Convention: method names start with lowercase, class names start with uppercase. + * + * IMPORTANT: When using the dot-separator for method filtering, you MUST provide a + * fully qualified class name. JUnit Platform's selectMethod() requires a FQCN. + * + * Examples: + * - "com.foo.Bar" -> false (class name) + * - "com.foo.Bar.testSomething" -> true (method filter, FQCN + lowercase method) + * - "com.foo.Bar.InnerClass" -> false (inner class, "InnerClass" starts with uppercase) + * - "Bar.testSomething" -> true (but will FAIL - "Bar" is not a FQCN) + * + * For short class names with methods, use the # separator instead: "Bar#testSomething" + * Or provide the FQCN: "com.foo.Bar.testSomething" + */ + private static boolean isMethodFilter(String filter) { + int lastDot = filter.lastIndexOf('.'); + if (lastDot < 0 || lastDot >= filter.length() - 1) { + return false; // No dot or dot is at the end + } + + String lastSegment = filter.substring(lastDot + 1); + if (lastSegment.isEmpty()) { + return false; + } + + // Method names conventionally start with lowercase + return Character.isLowerCase(lastSegment.charAt(0)); + } +} diff --git a/doc/build/GradleTasks.md b/doc/build/GradleTasks.md index 866ad6117..1470b2745 100644 --- a/doc/build/GradleTasks.md +++ b/doc/build/GradleTasks.md @@ -62,9 +62,11 @@ testTsan # Java tests with TSAN library (Linux) **Examples:** ```bash ./gradlew :ddprof-test:testRelease -./gradlew :ddprof-test:testDebug --tests "*.ProfilerTest" +./gradlew :ddprof-test:testDebug -Ptests=ProfilerTest ``` +**Note**: Use `-Ptests` (not `--tests`) with config-specific test tasks. The `-Ptests` property works uniformly across all platforms. On glibc/macOS, config-specific tasks use Gradle's Test task type. On musl systems, they use Exec task type to bypass toolchain probing issues. + ### C++ Unit Tests (Google Test) ``` @@ -178,7 +180,7 @@ linkFuzz_{TargetName} # Link fuzz target ### Quick Development Cycle ```bash -./gradlew assembleDebug :ddprof-test:testDebug --tests "*.MyTest" +./gradlew assembleDebug :ddprof-test:testDebug -Ptests=MyTest ``` ### Pre-commit Checks diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b37a07660..bcb79ee0b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,10 +3,11 @@ [versions] cpp-standard = "17" kotlin = "1.9.22" -spotless = "6.11.0" +spotless = "7.0.2" # Testing junit = "5.9.2" +junit-platform = "1.9.2" # JUnit Platform version corresponding to JUnit Jupiter 5.9.2 junit-pioneer = "1.9.1" slf4j = "1.7.32" @@ -28,6 +29,7 @@ jmh = "1.36" junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } junit-pioneer = { module = "org.junit-pioneer:junit-pioneer", version.ref = "junit-pioneer" } # Logging @@ -50,8 +52,8 @@ jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } jmh-annprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } [bundles] -# Core testing framework (JUnit + logging) -testing = ["junit-api", "junit-engine", "junit-params", "junit-pioneer", "slf4j-simple"] +# Core testing framework (JUnit + logging + launcher API for custom test runner) +testing = ["junit-api", "junit-engine", "junit-params", "junit-platform-launcher", "junit-pioneer", "slf4j-simple"] # Profiler runtime dependencies (JFR analysis + compression) profiler-runtime = ["jmc-flightrecorder", "jol-core", "lz4", "snappy", "zstd"] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a793a..37f78a6af 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/malloc-shim/build.gradle.kts b/malloc-shim/build.gradle.kts index 1a819db48..8317701ef 100644 --- a/malloc-shim/build.gradle.kts +++ b/malloc-shim/build.gradle.kts @@ -28,8 +28,8 @@ simpleNativeLib { "-fvisibility=hidden", "-std=c++17", "-DPROFILER_VERSION=\"${project.version}\"", - "-fPIC" - ) + "-fPIC", + ), ) linkerArgs.set(listOf("-ldl")) diff --git a/utils/run-docker-tests.sh b/utils/run-docker-tests.sh index a0331b3ff..4d1d4a15f 100755 --- a/utils/run-docker-tests.sh +++ b/utils/run-docker-tests.sh @@ -230,7 +230,8 @@ fi echo "=== Docker Test Runner ===" echo "LIBC: $LIBC" -echo "JDK: $JDK_VERSION" +echo "Build JDK: 21 (Gradle 9 requirement)" +echo "Test JDK: $JDK_VERSION" echo "Arch: $ARCH" echo "Config: $CONFIG" echo "Tests: ${TESTS:-}" @@ -272,7 +273,7 @@ FROM alpine:3.21 # - openssh-client for git clone over SSH RUN apk update && \ apk add --no-cache \ - curl wget bash make g++ clang git jq cmake \ + curl wget bash make g++ clang git jq cmake coreutils \ gtest-dev gmock tar binutils musl-dbg linux-headers \ compiler-rt llvm openssh-client @@ -313,6 +314,15 @@ EOF echo ">>> Base image built: $BASE_IMAGE_NAME" fi +# ========== Get Build JDK URL (always JDK 21 for Gradle 9) ========== +# Gradle 9 requires JDK 17+ to run; we use JDK 21 (LTS) as the build JDK +BUILD_JDK_VERSION="21" +if [[ "$LIBC" == "musl" ]]; then + BUILD_JDK_URL=$(get_musl_jdk_url "$BUILD_JDK_VERSION" "$ARCH") +else + BUILD_JDK_URL=$(get_glibc_jdk_url "$BUILD_JDK_VERSION" "$ARCH") +fi + # ========== Build JDK Image (if needed) ========== IMAGE_EXISTS=false if [[ "$REBUILD" == "false" ]]; then @@ -324,17 +334,22 @@ fi if [[ "$IMAGE_EXISTS" == "false" ]]; then echo ">>> Building JDK image: $IMAGE_NAME" + echo ">>> Build JDK (for Gradle): $BUILD_JDK_VERSION" + echo ">>> Test JDK: $JDK_VERSION" - cat > "$DOCKERFILE_DIR/Dockerfile" < "$DOCKERFILE_DIR/Dockerfile" < "$DOCKERFILE_DIR/Dockerfile" <>> JDK image built: $IMAGE_NAME" @@ -361,10 +415,12 @@ fi # Build gradle test command # Capitalize first letter for gradle task names (testDebug, testAsan, etc.) +# Note: -Ptests property works uniformly across all platforms (glibc, musl, macOS) CONFIG_CAPITALIZED="$(tr '[:lower:]' '[:upper:]' <<< ${CONFIG:0:1})${CONFIG:1}" GRADLE_CMD="./gradlew -PCI -PkeepJFRs :ddprof-test:test${CONFIG_CAPITALIZED}" if [[ -n "$TESTS" ]]; then - GRADLE_CMD="$GRADLE_CMD --tests \"$TESTS\"" + # No need for quotes around $TESTS - Gradle property values don't require quoting + GRADLE_CMD="$GRADLE_CMD -Ptests=$TESTS" fi if ! $GTEST_ENABLED; then GRADLE_CMD="$GRADLE_CMD -Pskip-gtest"