diff --git a/CHANGELOG.md b/CHANGELOG.md
index 77ffde161..4163402ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ We use [semantic versioning](http://semver.org/):
- PATCH version when you make backwards compatible bug fixes.
# Next version
+- [breaking] _agent_: Log lines now show the simple class name (`INFO Agent - ...`) instead of the fully qualified one.
# 37.0.2
- [fix] _agent_: In some cases the hostname was wrongly added to the PID when sending it to Teamscale.
diff --git a/README.md b/README.md
index 1242bb723..3d6f25dba 100644
--- a/README.md
+++ b/README.md
@@ -107,43 +107,11 @@ git config --local core.hooksPath .githooks
### Debug locally
-For IntelliJ, there is a run config `SampleApp` which profiles the included `sample-debugging-app` and can be used to
-debug the agent. This run config omits relocating packages in the shadow jar because with relocating, the package
-names in the jar would not match with the ones IntelliJ knows from the code and so debugging would not work.
-The agent is configured in the config file `sample-debugging-app/jacocoagent.properties`. By default, no upload
-is configured but the file includes all required options to upload to Teamscale, they are just commented out.
-Feel free to adapt it to your needs.
-If you get an `IllegalStateException: Cannot process instrumented class com/example/Main`, make sure that you use
-the built-in IntelliJ functionality for building and running instead of
-gradle (IntelliJ Settings -> Build, Execution, Deployment -> Build Tools -> Gradle -> Build and run using: IntelliJ IDEA).
-
-### Debugging the Gradle plugin
-
-* increase the plugin version (=`appVersion`) in [build.gradle.kts](build.gradle.kts)
-* `./gradlew publishToMavenLocal` will deploy your checked out version to your local m2 cache
-* then you can import this version into any other gradle project by
- * adding the following to the `settings.gradle.kts`
-```kotlin
-pluginManagement {
- repositories {
- mavenLocal()
- gradlePluginPortal()
- }
-}
-
-dependencyResolutionManagement {
- repositories {
- mavenLocal()
- mavenCentral()
- }
-}
-```
- * declaring a plugin dependency on the incremented version of the teamscale plugin
-* to debug a build that uses the plugin, run `./gradlew` with `--no-daemon -Dorg.gradle.debug=true`.
- The build will pause and wait for you to attach a debugger, via IntelliJ's `Run > Attach to Process`.
-* to debug the impacted test engine during a build, run `./gradlew` with `--no-daemon --debug-jvm` and wait for the test phase to start.
- The build will pause and wait for you to attach a debugger, via IntelliJ's `Run > Attach to Process`.
-* These two debug flags can also be combined. The build will then pause twice.
+Debug the `SampleApp` run configuration in IntelliJ to debug the included `sample-app` with breakpoints in the agent working.
+
+**[docs/DEBUGGING.md](docs/DEBUGGING.md) is the full guide**: setting up end-to-end communication with a real Teamscale
+instance, how the agent's configuration is resolved from its five sources, where the profiler writes its logs, why a
+misconfigured profiler fails silently, and how to attach a debugger to system tests.
### Contributing
diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts
index e8e6c82d7..3cf24b7db 100644
--- a/agent/build.gradle.kts
+++ b/agent/build.gradle.kts
@@ -5,9 +5,9 @@ plugins {
com.teamscale.`java-convention`
application
- // we don't want to cause conflicts between our dependencies and the target application
+ // we don't want to cause conflicts between the classes we ship and the target application
// since the agent will be loaded with the same class loader as the profiled application
- // so we use the shadow plugin to relocate our dependencies
+ // so we use the shadow plugin to relocate our dependencies and our own classes
com.teamscale.`shadow-convention`
com.teamscale.coverage
com.teamscale.publish
@@ -15,7 +15,15 @@ plugins {
alias(libs.plugins.oci)
}
-evaluationDependsOn(":installer")
+// The jlink runtime image of the installer, which the distribution below ships alongside the agent.
+val installerImageDependency = configurations.dependencyScope("installerImage")
+val installerImage = configurations.resolvable("installerImagePath") {
+ extendsFrom(installerImageDependency.get())
+}
+
+dependencies {
+ installerImageDependency(project(":installer", JLINK_IMAGE_CONFIGURATION))
+}
publishAs {
artifactId = "teamscale-jacoco-agent"
@@ -23,7 +31,7 @@ publishAs {
description = "JVM profiler that simplifies various aspects around recording and uploading test coverage"
}
-val appVersion = rootProject.extra["appVersion"].toString()
+val appVersion = extra["appVersion"].toString()
val jacocoVersion = libs.versions.jacoco.get()
val outputVersion = "$appVersion-jacoco-$jacocoVersion"
@@ -64,7 +72,7 @@ dependencies {
}
application {
- mainClass = "com.teamscale.jacoco.agent.Main"
+ mainClass = "$AGENT_PACKAGE.Main"
}
tasks.shadowJar {
@@ -73,32 +81,57 @@ tasks.shadowJar {
// update
archiveFileName = "teamscale-jacoco-agent.jar"
+ // The shadow plugin's auto relocation only covers the dependencies, so the agent's own classes are
+ // relocated explicitly. The entry points below have to name them by their relocated names, and so do the
+ // logback configuration files, cf. ShadowedPackages.kt.
+ if (usesShadowedPackages.get()) {
+ relocate(AGENT_PACKAGE, "$SHADOW_PACKAGE_PREFIX.$AGENT_PACKAGE")
+ }
+
manifest {
- attributes["Premain-Class"] = "com.teamscale.jacoco.agent.PreMain"
+ attributes["Premain-Class"] = shadowed("$AGENT_PACKAGE.PreMain")
+ attributes["Main-Class"] = shadowed("$AGENT_PACKAGE.Main")
}
}
tasks.startShadowScripts {
applicationName = "convert"
+ mainClass = shadowed("$AGENT_PACKAGE.Main")
}
+// Shares the shaded agent jar with the projects that attach the profiler to a JVM, cf. the
+// com.teamscale.agent-jar convention plugin.
+configurations.consumable(AGENT_JAR_CONFIGURATION)
+artifacts.add(AGENT_JAR_CONFIGURATION, tasks.shadowJar)
+
distributions {
named("shadow") {
distributionBaseName = "teamscale-jacoco-agent"
contents {
- from(project(":installer").tasks["jlink"]) {
+ from(installerImage.get()) {
into("installer")
}
+ // Captured in a local so the copy action does not reference the build script itself,
+ // which cannot be stored in the configuration cache.
+ val distributionVersion = outputVersion
filesMatching("**/VERSION.txt") {
filter {
- it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", outputVersion)
+ it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", distributionVersion)
}
}
}
}
}
+// The logging templates in src/dist are checked in without the shadow prefix so they also work when
+// relocation is disabled. Add the prefix while packaging them, since the distribution ships the shaded agent.
+listOf(tasks.shadowDistZip, tasks.shadowDistTar, tasks.installShadowDist).forEach { task ->
+ task { shadowLoggingPackages(usesShadowedPackages) }
+}
+
+verifyShadowedLoggingConfigs(tasks.shadowJar, tasks.shadowDistZip)
+
tasks.shadowDistZip {
archiveFileName = "teamscale-jacoco-agent.zip"
}
diff --git a/agent/src/dist/logging/logback.console.xml b/agent/src/dist/logging/logback.console.xml
index c03fb4d28..74d2735bb 100644
--- a/agent/src/dist/logging/logback.console.xml
+++ b/agent/src/dist/logging/logback.console.xml
@@ -1,15 +1,12 @@
-
+
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
-
-
-
diff --git a/agent/src/dist/logging/logback.debug.xml b/agent/src/dist/logging/logback.debug.xml
index 67af8e37e..7a7644f7c 100644
--- a/agent/src/dist/logging/logback.debug.xml
+++ b/agent/src/dist/logging/logback.debug.xml
@@ -2,29 +2,29 @@
-
+
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
-
+ ${defaultLogDir}/teamscale-jacoco-agent.log
-
+ ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip110
-
+ 1MB
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
@@ -32,7 +32,4 @@
-
-
-
diff --git a/agent/src/dist/logging/logback.rolling-file.xml b/agent/src/dist/logging/logback.rolling-file.xml
index 2675158ad..8015e6c1e 100644
--- a/agent/src/dist/logging/logback.rolling-file.xml
+++ b/agent/src/dist/logging/logback.rolling-file.xml
@@ -2,30 +2,27 @@
-
+ ${defaultLogDir}/teamscale-jacoco-agent.log
-
+ ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip110
-
+ 1MB
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
-
-
-
diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
index d1254c7ac..5917eddcc 100644
--- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
+++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt
@@ -68,7 +68,7 @@ object Main {
/** Creates a builder for a [com.beust.jcommander.JCommander] object. */
private fun createJCommanderBuilder() =
- JCommander.newBuilder().programName(Main::class.java.getName())
+ JCommander.newBuilder().programName("convert")
.addObject(defaultArguments)
.addObject(command)
diff --git a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml
index 99ce5765c..73f2c2c10 100644
--- a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml
+++ b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default-debugging.xml
@@ -2,29 +2,29 @@
-
+
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
-
+ ${defaultLogDir}/teamscale-jacoco-agent.log
-
+ ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip110
-
+ 1MB
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
@@ -32,7 +32,4 @@
-
-
-
diff --git a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml
index 0f5dce35b..a19f450d8 100644
--- a/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml
+++ b/agent/src/main/resources/com/teamscale/jacoco/agent/logback-default.xml
@@ -2,29 +2,26 @@
-
+ ${defaultLogDir}/teamscale-jacoco-agent.log
-
+ ${defaultLogDir}/teamscale-jacoco-agent-%i.log.zip110
-
+ 1MB
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
-
-
-
diff --git a/build.gradle.kts b/build.gradle.kts
index c363f9c79..ec94a1fd5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,41 +1,50 @@
plugins {
- alias(libs.plugins.nexusPublish)
+ // Ships in the same artifact as com.gradleup.nmcp, which buildSrc puts on the classpath for the
+ // com.teamscale.publish convention plugin, so this must not repeat the version.
+ id("com.gradleup.nmcp.aggregation")
}
-group = "com.teamscale"
-
-val appVersion = "37.0.2"
-extra.set("appVersion", appVersion)
-
-val snapshotVersion = appVersion + if (VersionUtils.isTaggedRelease()) "" else "-SNAPSHOT"
-
-allprojects {
- version = snapshotVersion
-}
+// group and version are set for every project from settings.gradle.kts
+
+/**
+ * The projects that publish Maven artifacts, i.e. those applying the `com.teamscale.publish` convention
+ * plugin. Listed explicitly because looking them up in the other projects would break project isolation.
+ */
+val publishedProjects = listOf(
+ ":agent",
+ ":impacted-test-engine",
+ ":report-generator",
+ ":teamscale-client",
+ ":teamscale-gradle-plugin",
+ ":teamscale-maven-plugin",
+ ":tia-client",
+ ":tia-runlisteners",
+)
// Installs all Maven artifacts to your local Maven repository
-val publishToMavenLocal = tasks.register("publishToMavenLocal")
+tasks.register("publishToMavenLocal") {
+ dependsOn(publishedProjects.map { "$it:publishToMavenLocal" })
+}
-subprojects {
- // must be run after evaluation because the publishToMavenLocal tasks are generated by our plugin during
- // project evaluation
- afterEvaluate {
- val publishTask = tasks.findByPath("publishToMavenLocal")
- if (publishTask != null) {
- publishToMavenLocal.configure {
- dependsOn(publishTask)
- }
- }
+// Collects the publications of all projects below into a single deployment and uploads it to Maven Central
+// via the Central Portal. Publishing this way, rather than by cross-configuring the projects from here, is
+// what keeps the release path compatible with project isolation.
+nmcpAggregation {
+ centralPortal {
+ // The user token generated at https://central.sonatype.com/account, not the portal login itself.
+ username = providers.gradleProperty("sonatypeUsername")
+ password = providers.gradleProperty("sonatypePassword")
+ // Release the deployment as soon as the portal has validated it. Use USER_MANAGED to stop after
+ // validation and release by hand from the portal instead.
+ publishingType = "AUTOMATIC"
}
}
-nexusPublishing {
- repositories {
- // see https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/#configuration
- sonatype {
- nexusUrl = uri("https://ossrh-staging-api.central.sonatype.com/service/local/")
- snapshotRepositoryUrl = uri("https://central.sonatype.com/repository/maven-snapshots/")
- }
+dependencies {
+ // The Gradle plugin is released through the Gradle Plugin Portal, so it is not part of the deployment.
+ // The com.teamscale.publish convention plugin leaves it out on the producing side for the same reason.
+ (publishedProjects - ":teamscale-gradle-plugin").forEach {
+ nmcpAggregation(project(it))
}
}
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 3a91ed04d..de9f1544d 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -8,8 +8,9 @@ repositories {
dependencies {
implementation(plugin(libs.plugins.shadow))
- implementation(plugin(libs.plugins.kotlinJvm))
+ implementation(embeddedKotlin("gradle-plugin"))
implementation(plugin(libs.plugins.testRetry))
+ implementation(plugin(libs.plugins.nmcp))
implementation(libs.asm.core)
implementation(libs.asm.commons)
diff --git a/buildSrc/src/main/kotlin/AgentJarExtension.kt b/buildSrc/src/main/kotlin/AgentJarExtension.kt
index 08138cf52..f2e2388b7 100644
--- a/buildSrc/src/main/kotlin/AgentJarExtension.kt
+++ b/buildSrc/src/main/kotlin/AgentJarExtension.kt
@@ -1,25 +1,87 @@
+import org.gradle.api.Action
import org.gradle.api.Task
+import org.gradle.api.tasks.Input
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.testing.Test
+import org.gradle.process.CommandLineArgumentProvider
+import org.gradle.process.JavaForkOptions
import java.io.File
+import java.io.Serializable
-/** Determines the path under which the com.teamscale.agent-jar plugin stored the agent jar. */
+/**
+ * Determines the path under which the com.teamscale.agent-jar plugin stored the agent jar.
+ *
+ * The file name has to stay `teamscale-jacoco-agent.jar`: `PreMain` recognises its own `-javaagent` option by that
+ * name, and warns about interference from other Java agents for every option it does not recognise.
+ */
val Task.agentJar: File
- get() = this.temporaryDir.resolve("libs/agent.jar")
+ get() = this.temporaryDir.resolve("libs/teamscale-jacoco-agent.jar")
+/**
+ * The project-relative directory that the agent writes its debug logs to during a system test, cf. the agent's
+ * `debug` option. The system test convention deletes it before every run.
+ */
val Test.logFilePath
get() = "logTest"
/** Adds a convenient way to attach the Teamscale JaCoCo agent to the JVM with the given options in a readable map format. */
fun JavaExec.teamscaleAgent(options: Map) {
- jvmArgs(
- "-javaagent:$agentJar=${options.entries.joinToString(separator = ",") { "${it.key}=${it.value}" }}"
- )
+ addTeamscaleAgent(options)
}
/** Adds a convenient way to attach the Teamscale JaCoCo agent to the test JVM with the given options in a readable map format. */
fun Test.teamscaleAgent(options: Map) {
- jvmArgs(
- "-javaagent:$agentJar=${options.entries.joinToString(separator = ",") { "${it.key}=${it.value}" }}"
+ addTeamscaleAgent(options)
+}
+
+/**
+ * Attaches the agent through a [CommandLineArgumentProvider], which puts its `-javaagent` option behind every
+ * ordinary JVM argument. Do not turn this back into a [JavaForkOptions.jvmArgs] call.
+ *
+ * The JVM starts its JVMTI agents in the order in which they appear on the command line, and that is where both a
+ * debugger and a Java agent's `premain` do their work. A debugger behind the profiler therefore only attaches once
+ * `PreMain` has finished, and no breakpoint in the agent's startup code is ever hit. Keeping the profiler last is
+ * what lets the debugger option the IDE appends to [JavaForkOptions.jvmArgs] come first;
+ * [startDebuggerBeforeProfiler] does the same for `--debug-jvm`.
+ */
+private fun T.addTeamscaleAgent(options: Map) where T : Task, T : JavaForkOptions {
+ jvmArgumentProviders.add(
+ TeamscaleAgentArgumentProvider(
+ "-javaagent:$agentJar=${options.entries.joinToString(separator = ",") { "${it.key}=${it.value}" }}"
+ )
)
}
+
+/** Supplies the `-javaagent` option of the profiler, cf. [addTeamscaleAgent]. */
+class TeamscaleAgentArgumentProvider(
+ /** The `-javaagent` option, including the agent jar path and its options. */
+ @get:Input val argument: String
+) : CommandLineArgumentProvider, Serializable {
+ override fun asArguments() = listOf(argument)
+}
+
+/**
+ * Makes `--debug-jvm` debug the profiler as well: Gradle appends the `-agentlib:jdwp` option it asks for behind
+ * everything else, and thus behind the profiler, so we turn the request off and add an equivalent ordinary JVM
+ * argument instead, which lands in front of it, cf. [addTeamscaleAgent].
+ *
+ * This has to happen after Gradle applied the command line option to the task, but before it finalizes the task's
+ * properties — a `doFirst` is already too late — which leaves exactly the window between the task graph being ready
+ * and the start of the execution phase.
+ */
+fun T.startDebuggerBeforeProfiler() where T : Task, T : JavaForkOptions {
+ project.gradle.taskGraph.whenReady {
+ val options = debugOptions
+ if (!options.enabled.get()) return@whenReady
+
+ val server = options.server.get().asJdwpFlag()
+ val suspend = options.suspend.get().asJdwpFlag()
+ val address = options.host.map { "$it:" }.getOrElse("") + options.port.get()
+ // Disabled so that Gradle does not append a second, conflicting option of its own.
+ options.enabled.set(false)
+ jvmArgs("-agentlib:jdwp=transport=dt_socket,server=$server,suspend=$suspend,address=$address")
+ }
+}
+
+/** Renders the boolean as the `y`/`n` value that the `jdwp` agent expects. */
+private fun Boolean.asJdwpFlag() = if (this) "y" else "n"
diff --git a/buildSrc/src/main/kotlin/ShadowedPackages.kt b/buildSrc/src/main/kotlin/ShadowedPackages.kt
new file mode 100644
index 000000000..662737a1b
--- /dev/null
+++ b/buildSrc/src/main/kotlin/ShadowedPackages.kt
@@ -0,0 +1,87 @@
+import org.gradle.api.Action
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.file.CopySpec
+import org.gradle.api.file.FileCopyDetails
+import org.gradle.api.provider.Provider
+import org.gradle.kotlin.dsl.named
+import org.gradle.kotlin.dsl.register
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import java.io.Serializable
+
+/** The prefix that the shadow plugin's auto relocation puts all relocated dependency packages under. */
+const val SHADOW_PACKAGE_PREFIX = "shadow"
+
+/**
+ * The agent's own package. Unlike the dependencies, the shadow plugin does not relocate it on its own, so the
+ * agent jar asks for it explicitly. Everything the jar ships then lives under [SHADOW_PACKAGE_PREFIX] and thus
+ * cannot interfere with the application the agent is loaded into.
+ */
+const val AGENT_PACKAGE = "com.teamscale.jacoco.agent"
+
+/** Ant patterns under which our own logback configuration files are packaged. */
+private val LOGBACK_CONFIG_PATTERNS = listOf("com/teamscale/**/logback*.xml", "logging/logback*.xml")
+
+/** Packages that our logback configuration files reference by fully qualified name and that get relocated. */
+internal val RELOCATED_LOGGING_PACKAGES = listOf("ch.qos.logback", AGENT_PACKAGE)
+
+/**
+ * Whether this build relocates dependencies under [SHADOW_PACKAGE_PREFIX]. Auto relocation is disabled via
+ * `-Punshaded=true` to make the agent easier to debug locally.
+ */
+val Project.usesShadowedPackages: Provider
+ get() = providers.gradleProperty("unshaded").map { it != "true" }.orElse(true)
+
+/** Prefixes the given class or package name with [SHADOW_PACKAGE_PREFIX] if this build relocates. */
+fun Project.shadowed(name: String): Provider =
+ usesShadowedPackages.map { if (it) "$SHADOW_PACKAGE_PREFIX.$name" else name }
+
+/**
+ * Prefixes references to relocated packages in the logback configuration files packaged by this task, cf.
+ * [ShadowLoggingPackages].
+ *
+ * Whether we relocate is decided by a gradle property, which Gradle does not track when it is read at
+ * configuration time, so it is registered as an explicit task input. Otherwise switching between debug and
+ * production builds would leave the previously packaged configuration files in place.
+ */
+fun T.shadowLoggingPackages(relocated: Provider) where T : Task, T : CopySpec {
+ inputs.property("shadowedLoggingPackages", relocated)
+ filesMatching(LOGBACK_CONFIG_PATTERNS, ShadowLoggingPackages(relocated.get()))
+}
+
+/**
+ * Registers a [VerifyShadowedLoggingConfigs] task for the given archives and hooks it into `check`.
+ *
+ * The archives are anything a file collection accepts, in particular the archive tasks that packaged the
+ * logback configuration files, e.g. `verifyShadowedLoggingConfigs(tasks.jar)`.
+ */
+fun Project.verifyShadowedLoggingConfigs(vararg archives: Any) {
+ val verifyTask = tasks.register("verifyShadowedLoggingConfigs") {
+ this.archives.from(*archives)
+ // The Kotlin DSL's assignment operator is not available outside of build scripts.
+ relocated.set(usesShadowedPackages)
+ }
+ tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME) {
+ dependsOn(verifyTask)
+ }
+}
+
+/**
+ * Prefixes references to [RELOCATED_LOGGING_PACKAGES] in logback configuration files with
+ * [SHADOW_PACKAGE_PREFIX] so they match the relocated classes in the shaded agent jar.
+ *
+ * This lets us keep the configuration files in the source tree free of the prefix, so they can be used as-is
+ * from the IDE, from unit tests and in `-Punshaded=true` builds, where no relocation happens.
+ */
+private class ShadowLoggingPackages(private val enabled: Boolean) : Action, Serializable {
+ override fun execute(details: FileCopyDetails) {
+ if (!enabled) return
+ // Anchoring on the opening quote restricts the replacement to XML attribute values, which covers
+ // both `class="..."` and ``.
+ details.filter { line ->
+ RELOCATED_LOGGING_PACKAGES.fold(line) { result, packageName ->
+ result.replace("\"$packageName.", "\"$SHADOW_PACKAGE_PREFIX.$packageName.")
+ }
+ }
+ }
+}
diff --git a/buildSrc/src/main/kotlin/SharedArtifacts.kt b/buildSrc/src/main/kotlin/SharedArtifacts.kt
new file mode 100644
index 000000000..610a80d7d
--- /dev/null
+++ b/buildSrc/src/main/kotlin/SharedArtifacts.kt
@@ -0,0 +1,11 @@
+// Names of the configurations through which projects share build artifacts with each other.
+//
+// Producers expose an artifact under these names, consumers depend on the producing project targeting the
+// same name. Sharing artifacts this way instead of reaching into another project's tasks is what keeps the
+// build compatible with project isolation.
+
+/** The shaded agent jar, produced by :agent and consumed via the com.teamscale.agent-jar convention plugin. */
+const val AGENT_JAR_CONFIGURATION = "agentJarElements"
+
+/** The jlink runtime image of the installer, produced by :installer and shipped in the :agent distribution. */
+const val JLINK_IMAGE_CONFIGURATION = "jlinkImageElements"
diff --git a/buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt b/buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt
new file mode 100644
index 000000000..01a6b7ea8
--- /dev/null
+++ b/buildSrc/src/main/kotlin/VerifyShadowedLoggingConfigs.kt
@@ -0,0 +1,81 @@
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import java.io.File
+import java.util.zip.ZipFile
+
+/**
+ * Asserts that the logback configuration files packaged into the given archives reference the relocated
+ * logback classes, i.e. that [ShadowLoggingPackages] was applied to all of them.
+ *
+ * The archives are scanned for logback configurations instead of checking a fixed list of files, so this also
+ * fails if a newly added configuration file is not covered by [LOGBACK_CONFIG_PATTERNS].
+ */
+abstract class VerifyShadowedLoggingConfigs : DefaultTask() {
+
+ /** The archives to check. Nested archives are not inspected. */
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.NONE)
+ abstract val archives: ConfigurableFileCollection
+
+ /** Whether the build relocates dependencies, cf. [usesShadowedPackages]. */
+ @get:Input
+ abstract val relocated: Property
+
+ /** Checks every logback configuration in every archive, cf. [VerifyShadowedLoggingConfigs]. */
+ @TaskAction
+ fun verify() {
+ val isRelocated = relocated.get()
+ archives.forEach { archive ->
+ ZipFile(archive).use { zip ->
+ val configs = zip.entries().asSequence()
+ .filter { CONFIG_NAME.matches(it.name.substringAfterLast('/')) }
+ .associate { it.name to zip.getInputStream(it).reader().readText() }
+ if (configs.isEmpty()) {
+ throw GradleException("Did not find any logback configuration in ${archive.name}")
+ }
+ configs.forEach { (path, content) -> verify(archive, path, content, isRelocated) }
+ }
+ }
+ }
+
+ private fun verify(archive: File, path: String, content: String, relocated: Boolean) {
+ // Every configuration references logback classes, so this one doubles as the canary for a
+ // configuration file that the packaging did not rewrite at all.
+ val relocatedReference = "\"$SHADOW_PACKAGE_PREFIX.$LOGBACK_PACKAGE."
+ if (relocated && !content.contains(relocatedReference)) {
+ throw GradleException(
+ "$path in ${archive.name} does not reference any relocated logback class." +
+ " Is it covered by one of the LOGBACK_CONFIG_PATTERNS?"
+ )
+ }
+ if (!relocated && content.contains(relocatedReference)) {
+ throw GradleException(
+ "$path in ${archive.name} references relocated logback classes," +
+ " but this build does not relocate anything."
+ )
+ }
+
+ // The agent's own classes are relocated as well, so a configuration naming one of them by its plain
+ // name would fail at logging initialisation with a ClassNotFoundException.
+ RELOCATED_LOGGING_PACKAGES.forEach { packageName ->
+ if (relocated && content.contains("\"$packageName.")) {
+ throw GradleException(
+ "$path in ${archive.name} still references non-relocated $packageName classes," +
+ " which do not exist in the shaded jar."
+ )
+ }
+ }
+ }
+
+ private companion object {
+ val CONFIG_NAME = Regex("logback.*\\.xml")
+ const val LOGBACK_PACKAGE = "ch.qos.logback"
+ }
+}
diff --git a/buildSrc/src/main/kotlin/VersionUtils.kt b/buildSrc/src/main/kotlin/VersionUtils.kt
deleted file mode 100644
index 9eb632855..000000000
--- a/buildSrc/src/main/kotlin/VersionUtils.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-object VersionUtils {
- fun isTaggedRelease() = System.getenv()["GITHUB_REF"]?.contains("/tags/") ?: false
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts
index 9c389a9db..4a6a9fca4 100644
--- a/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts
+++ b/buildSrc/src/main/kotlin/com.teamscale.agent-jar.gradle.kts
@@ -4,31 +4,42 @@ plugins {
id("com.teamscale.java-convention")
}
+/** The shaded agent jar, shared by the :agent project via [AGENT_JAR_CONFIGURATION]. */
+val agentJarDependency = configurations.dependencyScope("teamscaleAgent")
+val agentJarSource = configurations.resolvable("teamscaleAgentJar") {
+ extendsFrom(agentJarDependency.get())
+}
+
+dependencies {
+ agentJarDependency(project(":agent", AGENT_JAR_CONFIGURATION))
+}
+
/**
* Creates a copy of the agent jar file in the temporary directory of this task
* to isolate it from other tasks running in parallel.
*/
fun Task.createAgentCopy() {
- val shadowJarOutputs = project.project(":agent").tasks.named("shadowJar").map { it.outputs.files.singleFile }
- dependsOn(shadowJarOutputs)
- doFirst("copyAgent", CopyAgent(shadowJarOutputs, agentJar))
+ val agentJarFiles = agentJarSource.get()
+ dependsOn(agentJarFiles)
+ doFirst("copyAgent", CopyAgent(agentJarFiles.elements.map { it.single().asFile }, agentJar))
}
class CopyAgent(
- val shadowJarOutputs: Provider,
+ val source: Provider,
val agentJar: File
) : Action, Serializable {
override fun execute(t: Task) {
agentJar.parentFile.mkdir()
- shadowJarOutputs.get().copyTo(agentJar, overwrite = true)
+ source.get().copyTo(agentJar, overwrite = true)
}
}
tasks.withType {
createAgentCopy()
+ startDebuggerBeforeProfiler()
}
tasks.test {
createAgentCopy()
+ startDebuggerBeforeProfiler()
}
-
diff --git a/buildSrc/src/main/kotlin/com.teamscale.kotlin-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.kotlin-convention.gradle.kts
index 8407c28a8..c1f1df851 100644
--- a/buildSrc/src/main/kotlin/com.teamscale.kotlin-convention.gradle.kts
+++ b/buildSrc/src/main/kotlin/com.teamscale.kotlin-convention.gradle.kts
@@ -8,6 +8,5 @@ plugins {
tasks.compileKotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
- freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
diff --git a/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts
index 4e462ec95..92979c604 100644
--- a/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts
+++ b/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts
@@ -27,6 +27,12 @@ publishing {
}
}
+// Contributes this project's publications to the aggregated deployment that the root project uploads to
+// Maven Central. The Gradle plugin is released through the Gradle Plugin Portal instead, so it stays out.
+if (project.name != "teamscale-gradle-plugin") {
+ pluginManager.apply("com.gradleup.nmcp")
+}
+
signing {
setRequired({
// Do not require signing for deployment to maven local
diff --git a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts
index 955ff9f24..c4991d885 100644
--- a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts
+++ b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts
@@ -1,4 +1,5 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import com.github.jengelman.gradle.plugins.shadow.transformers.KotlinModuleMetadataTransformer
plugins {
java
@@ -7,14 +8,25 @@ plugins {
}
tasks.named("shadowJar") {
- enableAutoRelocation = providers.gradleProperty("debug").map { it != "true" }.orElse(true)
+ enableAutoRelocation = usesShadowedPackages
archiveClassifier = null as String?
mergeServiceFiles()
- // Relocates the .kotlin_metadata files to ensure reflection in Kotlin does not break
- relocate("kotlin", "shadow.kotlin")
- relocate("okhttp3", "shadow.okhttp3")
- relocate("okio", "shadow.okio")
- relocate("retrofit", "shadow.retrofit")
+ // The duplicates strategy takes precedence over the transformers and defaults to EXCLUDE, i.e. all
+ // but the first copy of a resource are dropped before a transformer gets to see them. Let the
+ // transformers handle the resources they merge, keeping EXCLUDE for everything else.
+ filesMatching(listOf("META-INF/services/**", "**/*.kotlin_module")) {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+ }
+ // Guards the INCLUDE above: every duplicate we let through must be consumed by a transformer
+ // instead of ending up as a duplicate entry in the jar.
+ failOnDuplicateEntries = true
+ // Our logback configurations are checked in without the shadow prefix so they also work when
+ // relocation is disabled. Add the prefix while packaging them into the shaded jar.
+ shadowLoggingPackages(usesShadowedPackages)
+ // Rewrites the package parts inside the .kotlin_module files so they match the relocated
+ // classes. Shadow still does this implicitly via the deprecated enableKotlinModuleRemapping
+ // flag, but that flag is removed in Shadow 10, so we apply the transformer explicitly.
+ transform(KotlinModuleMetadataTransformer::class.java)
val archiveFile = this.archiveFile
doLast("revertKotlinPackageChanges") { revertKotlinPackageChanges(archiveFile) }
}
diff --git a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts
index 895fac377..2a544d5f0 100644
--- a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts
+++ b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts
@@ -5,10 +5,20 @@ plugins {
val provider = SystemTestPorts.registerWith(project)
+/**
+ * Port on which JVMs spawned by the system tests wait for a debugger, requested via `-PdebugSut[=]`.
+ * Absent unless the property is set, in which case no JVM waits for anything.
+ */
+val debugSutPort = providers.gradleProperty("debugSut")
+ .map { if (it.isEmpty() || it == "true") "5005" else it }
+
tasks.test {
- dependsOn(":agent:shadowJar")
usesService(provider)
+ // The spawned JVM suspends until a debugger attaches, so the test must not be run in parallel with others
+ // and must not inherit a timeout. Both are the caller's responsibility (see docs/DEBUGGING.md).
+ debugSutPort.orNull?.let { environment("SYSTEM_TEST_DEBUG_PORT", it) }
+
val teamscalePort = provider.get().pickFreePort()
val agentPort = provider.get().pickFreePort()
extensions.create("ports", provider).apply {
diff --git a/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt b/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt
index 74b59825f..649be4514 100644
--- a/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt
+++ b/common-system-test/src/main/kotlin/com/teamscale/test/commons/ProcessUtils.kt
@@ -117,13 +117,37 @@ object ProcessUtils {
*
* @return ProcessBuilder configured with commands and working directory
*/
- fun build(): ProcessBuilder = ProcessBuilder(commands).apply {
+ fun build(): ProcessBuilder = ProcessBuilder(commands.withDebuggerArgumentIfRequested()).apply {
workingDirectory?.let { directory(it) }
environmentVariables?.let { environment().putAll(it) }
removeEnvironmentVariables.forEach { environment().remove(it) }
}
}
+ /**
+ * Environment variable through which the build tells us that JVMs spawned by system tests should wait for a
+ * debugger to attach. Set by the system test convention plugin when Gradle is run with `-PdebugSut`.
+ */
+ private const val DEBUG_PORT_ENVIRONMENT_VARIABLE = "SYSTEM_TEST_DEBUG_PORT"
+
+ /**
+ * Makes a `java` command line suspend at startup until a debugger connects, if the build requested it via
+ * [DEBUG_PORT_ENVIRONMENT_VARIABLE]. Other commands (Maven, `chcp.com`, ...) are left untouched, so only the
+ * JVM that the system test actually profiles waits for the debugger.
+ */
+ private fun List.withDebuggerArgumentIfRequested(): List {
+ val port = System.getenv(DEBUG_PORT_ENVIRONMENT_VARIABLE) ?: return this
+ val executable = firstOrNull()?.takeIf { it.isJavaExecutable() } ?: return this
+ return listOf(
+ executable,
+ "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$port"
+ ) + drop(1)
+ }
+
+ /** Whether this command is a `java` executable, with or without a path and the Windows `.exe` extension. */
+ private fun String.isJavaExecutable() =
+ substringAfterLast('/').substringAfterLast('\\').removeSuffix(".exe") == "java"
+
/**
* Immutable result of process execution.
*/
diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md
new file mode 100644
index 000000000..ec6a7e24b
--- /dev/null
+++ b/docs/DEBUGGING.md
@@ -0,0 +1,189 @@
+# Debugging the Teamscale Java Profiler
+
+This document is for developers working **on** the profiler.
+For debugging a profiler *deployment*, see the [official documentation](https://docs.teamscale.com/reference/coverage-profilers/teamscale-java-profiler/).
+
+- [Debugging the agent in the IDE](#debugging-the-agent-in-the-ide)
+- [Where the profiler writes its logs](#where-the-profiler-writes-its-logs)
+- [Running the profiler locally](#running-the-profiler-locally)
+- [Debugging system tests](#debugging-system-tests)
+- [Debugging the Gradle and Maven plugins](#debugging-the-gradle-and-maven-plugins)
+
+## Debugging the agent in the IDE
+
+Use the `SampleApp` run configuration. It executes `:sample-app:run` with `-Punshaded=true` and attaches the
+freshly built agent to a tiny application (`com.example.Main`). When started as _Debug_, breakpoints
+anywhere in the agent sources work.
+
+**`-Punshaded=true` turns off relocation.** The agent is loaded by the same class loader as the application it
+profiles, so anything it ships can interfere with that application. The jar therefore carries everything under a
+`shadow.` package prefix which keeps the two apart. Those class names do not match what the IDE knows from the source
+tree, so breakpoints would not bind and stack traces would be unreadable.
+
+The flip side: **this is not the artifact that ships.** Bugs that are caused by relocation itself — a class name
+built at runtime, a resource path, Kotlin module metadata — will not reproduce under `-Punshaded=true`. If a problem
+disappears when you enable debugging, suspect the shading, and reproduce against a normal `./gradlew :agent:shadowJar`
+build.
+
+### Which application to profile
+
+`sample-app` is a playground: nothing in the build depends on it, so you can change it freely to reproduce
+whatever you are chasing without breaking anything. Its `run` task profiles the application's jar instead of the class
+directories, so that the generated `git.properties` is where the agent looks for it — see
+[uploading coverage to Teamscale](#2-uploading-coverage-to-teamscale).
+
+### Useful breakpoints
+
+| Where | Fires when |
+|----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `PreMain.premain` | Once, at JVM startup. Good entry point for anything option- or startup-related. |
+| `AgentOptionsParser.parse` | Once, while options are being merged, so you can see which source contributed which option. |
+| `Agent.dumpReport` | On every dump: interval, `POST /dump`, and JVM shutdown. |
+| `LenientCoverageTransformer.transform` | **For every single loaded class.** Always make this breakpoint conditional, e.g. `classname.startsWith("com/example")`, or the JVM will not make progress. |
+
+## Where the profiler writes its logs
+
+`sample-app/java-profiler.properties` sets `debug=true`, so the profiler logs to the console at DEBUG level. Without
+that option it logs into a new temporary directory per process.
+
+## Running the profiler locally
+
+Three scenarios, in increasing order of setup, all configured through `sample-app/java-profiler.properties`. Each of
+them runs `sample-app`, which stays alive for ten seconds — long enough for one dump at shutdown. Pass
+`-PruntimeSeconds=300` to keep it running for five minutes instead, e.g. to take your time in the debugger.
+
+### 1. Only the system under test
+
+```bash
+./gradlew :sample-app:run
+```
+
+The committed file works as it is, with everything Teamscale-related still commented out: the profiler instruments
+`com.example.*`, logs to the console and writes the reports next to its logs into `/coverage//`.
+
+### 2. Uploading coverage to Teamscale
+
+Credentials must not end up in the committed file, so copy it — `java-profiler.local.properties` is git-ignored and the
+`run` task prefers it:
+
+```bash
+cp sample-app/java-profiler.properties sample-app/java-profiler.local.properties
+```
+
+Then uncomment the connection to Teamscale, plus the project and partition under _Local configuration_. The access key
+comes from the avatar menu → **Access Keys** → _Generate New Access Key_; the REST API does not accept your password:
+
+```properties
+teamscale-server-url=http://127.0.0.1:8080/
+teamscale-user=admin
+teamscale-access-token=
+
+teamscale-project=
+teamscale-partition=Agent Debugging
+```
+
+`./gradlew :sample-app:run` then uploads the coverage during JVM shutdown (`dump-on-exit` defaults to `true`); add
+`interval=1` to also upload every minute while the application is still running. The commit is auto-detected from the
+`git.properties` the build generates into the jar, so coverage lands on the revision you have checked out —
+**Teamscale has to know that revision** so if you used a File System Connector for example you need to manually specify
+ a branch name instead, otherwise the upload is rejected. Commit and let it be analyzed, or set
+`teamscale-commit`/`teamscale-revision` explicitly.
+
+### 3. Configuration from the Teamscale profiler configuration UI
+
+In Teamscale, go to _Project Configuration → Coverage Profilers → New profiler configuration → Create for a JVM
+project_:
+
+- Select your project
+- Pick an arbitrary partition name, for example Agent Debugging
+- Profiled Packages: com.example
+
+Teamscale now supplies exactly what the _Local configuration_ section did, so all that stays in
+`java-profiler.local.properties` is the connection — the profiler needs it to fetch the configuration in the first
+place — and the ID of the configuration you just created:
+
+```properties
+debug=true
+
+teamscale-server-url=http://127.0.0.1:8080/
+teamscale-user=admin
+teamscale-access-token=
+
+config-id=
+```
+
+Give the application more than the default ten seconds here, so that there is time to watch it register, heartbeat and
+unregister:
+
+```bash
+./gradlew :sample-app:run -PruntimeSeconds=300
+```
+
+While it runs, the profiler shows up under _Running Profilers_ in Teamscale. `ConfigurationViaTeamscale` implements
+the requests behind that — registration, heartbeat and unregistration on shutdown.
+
+## Debugging system tests
+
+System tests exercise the **packaged** agent jar, so they catch shading problems that `-Punshaded=true` hides. They
+come in two shapes, and they are debugged differently.
+
+**The agent is attached to the Gradle test JVM** (most tests — those calling `teamscaleAgent(...)` in their
+`build.gradle.kts`). Use Gradle's built-in flag:
+
+```bash
+./gradlew :system-tests:default-excludes-test:test --debug-jvm
+```
+
+The build pauses on port 5005 until you attach via IntelliJ's _Run → Attach to Process_ or a Remote JVM Debug
+configuration.
+
+**The test spawns its own JVM** (e.g. `teamscale-profiler-configuration-test`, `sut-uses-logback-test`, which call
+`ProcessUtils.execute("java", ...)`). `--debug-jvm` only suspends the test JVM, not the spawned one. Use `-PdebugSut`
+instead:
+
+```bash
+./gradlew :system-tests:teamscale-profiler-configuration-test:test -PdebugSut
+```
+
+Every `java` process the test spawns then starts with
+`-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005` and waits for a debugger. Pass a port explicitly
+with `-PdebugSut=5006`. Because the spawned JVM suspends until you attach, run a **single** system test at a time.
+
+Notes:
+
+- Combine with `-Punshaded=true` to also get unrelocated class names — but remember that this changes the artifact
+ under test
+- Only directly spawned `java` processes are affected. JVMs forked by Maven in the Maven-based system tests are not.
+- Tests that opt in with `teamscaleAgent(mapOf("debug" to logFilePath))` write the agent's log to their project's
+ `logTest/` directory. That directory is wiped at the start of every test run, so copy anything you want to keep.
+
+## Debugging the Gradle and Maven plugins
+
+To try your working copy out in another project, run `./gradlew publishToMavenLocal` to deploy it to your local m2 cache.
+The consuming project can then pick it up by adding the following to its `settings.gradle.kts`:
+
+```kotlin
+pluginManagement {
+ repositories {
+ mavenLocal()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ }
+}
+```
+
+and declaring a plugin dependency on the -SNAPSHOT version.
+
+To attach a debugger:
+
+- **A build that uses the plugin**: `./gradlew --no-daemon -Dorg.gradle.debug=true`. The build pauses and waits for you
+ to attach via IntelliJ's _Run → Attach to Process_.
+- **The impacted test engine during a build**: `./gradlew --no-daemon --debug-jvm`, then attach once the test phase
+ starts.
+- Both flags can be combined; the build then pauses twice.
diff --git a/gradle.properties b/gradle.properties
index ff61dcad1..90ad3a47b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,2 +1,19 @@
-#org.gradle.configuration-cache=true
-#org.gradle.configuration-cache.parallel=true
+# The defaults are not enough for this build: the Kotlin daemon runs out of memory while compiling the
+# larger modules, and configuring all projects in parallel (project isolation) needs more headroom in the
+# Gradle daemon itself.
+org.gradle.jvmargs=-Xmx3g
+kotlin.daemon.jvmargs=-Xmx2g
+
+# The Adoptium JDK that the installer's runtime images are linked against. Both the repository in
+# settings.gradle.kts and the archive names in installer/build.gradle.kts are derived from it.
+runtimeJdkVersion=21.0.6+7
+
+# The whole build works with the configuration cache, so it is enabled by default.
+org.gradle.configuration-cache=true
+org.gradle.configuration-cache.parallel=true
+
+# Project isolation configures the projects in parallel and caches their configuration separately, so that
+# a change to one project's build script does not invalidate the whole build.
+# The property is intentionally the stable name; org.gradle.unsafe.isolated-projects is deprecated as of
+# Gradle 9.7.
+org.gradle.isolated-projects=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c0f172992..f670272ae 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -80,12 +80,12 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version = "26.1.
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
[plugins]
-nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version = "2.0.0" }
+nmcp = { id = "com.gradleup.nmcp", version = "1.6.1" }
pluginPublish = { id = "com.gradle.plugin-publish", version = "2.1.1" }
-gitProperties = { id = "com.gorylenko.gradle-git-properties", version = "4.0.1" }
mavenPluginDevelopment = { id = "org.gradlex.maven-plugin-development", version = "1.0.3" }
shadow = { id = "com.gradleup.shadow", version = "9.6.1" }
oci = { id = "io.github.sgtsilvio.gradle.oci", version = "0.30.0" }
-kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version = "2.4.10" }
-jlink = { id = "org.beryx.jlink", version = "4.1.1" }
+jlink = { id = "com.github.iherasymenko.jlink", version = "0.9" }
+extraJavaModuleInfo = { id = "org.gradlex.extra-java-module-info", version = "1.14.2" }
testRetry = { id = "org.gradle.test-retry", version = "1.6.5" }
+gitProperties = { id = "com.gorylenko.gradle-git-properties", version = "4.0.1" }
diff --git a/installer/build.gradle.kts b/installer/build.gradle.kts
index 2a141ee08..f73bebb07 100644
--- a/installer/build.gradle.kts
+++ b/installer/build.gradle.kts
@@ -1,6 +1,4 @@
-import org.beryx.jlink.BaseTask
-import org.beryx.jlink.CreateMergedModuleTask
-import org.beryx.jlink.util.JdkUtil
+import com.github.iherasymenko.jlink.JlinkImageTask
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@@ -9,8 +7,8 @@ plugins {
application
com.teamscale.`java-convention`
com.teamscale.coverage
- com.teamscale.`system-test-convention`
alias(libs.plugins.jlink)
+ alias(libs.plugins.extraJavaModuleInfo)
}
tasks.jar {
@@ -23,7 +21,9 @@ tasks.jar {
// Workaround for https://youtrack.jetbrains.com/issue/KT-55389
tasks.compileJava {
- val mainSourceSetOutput = sourceSets.main.get().output
+ // Typed as FileCollection because the configuration cache cannot restore the captured value
+ // into a field declared as the more specific SourceSetOutput.
+ val mainSourceSetOutput: FileCollection = sourceSets.main.get().output
options.compilerArgumentProviders.add(CommandLineArgumentProvider {
listOf(
"--patch-module",
@@ -36,16 +36,36 @@ tasks.withType {
options.release = 21
}
-tasks.withType {
- notCompatibleWithConfigurationCache("https://github.com/beryx/badass-jlink-plugin/issues/304")
+tasks.withType {
+ compilerOptions.jvmTarget = JvmTarget.JVM_21
}
-tasks.withType {
- notCompatibleWithConfigurationCache("https://github.com/beryx/badass-jlink-plugin/issues/304")
-}
+// jlink can only link real modules, so every dependency that ships without a module descriptor gets one
+// here. Only the main source set needs them; the tests run on the classpath, and patching their
+// dependencies (spark and its Jetty stack in particular) would be pure busywork.
+// `failOnAutomaticModules` is what keeps this list honest: a new non-modular dependency fails the build
+// instead of silently ending up as an automatic module that jlink then refuses to link.
+extraJavaModuleInfo {
+ failOnAutomaticModules = true
+ deactivate(sourceSets.test)
+ deactivate(configurations.annotationProcessor)
-tasks.withType {
- compilerOptions.jvmTarget = JvmTarget.JVM_21
+ module("com.squareup.okio:okio-jvm", "okio") {
+ requires("kotlin.stdlib")
+ exportAllPackages()
+ }
+ module("net.java.dev.jna:jna", "com.sun.jna") {
+ exportAllPackages()
+ }
+ module("net.java.dev.jna:jna-platform", "com.sun.jna.platform") {
+ requires("com.sun.jna")
+ exportAllPackages()
+ }
+ // Annotations with class file retention that kotlin-stdlib pulls in. Nothing reads them at runtime, but
+ // they are on the module path and thus need a descriptor like everything else.
+ module("org.jetbrains:annotations", "org.jetbrains.annotations") {
+ exportAllPackages()
+ }
}
application {
@@ -59,39 +79,73 @@ application {
)
}
-val ADOPTIUM_BINARY_REPOSITORY = "https://api.adoptium.net/v3/binary"
-val RUNTIME_JDK_VERSION = "21.0.6+7"
-jlink {
- forceMerge("kotlin")
- options = listOf(
- "--no-header-files",
- "--no-man-pages",
- "--dedup-legal-notices", "error-if-not-same-content"
- )
- launcher {
- name = "installer"
- }
+// The launcher name, main module and main class come from the application block above, and so do the JVM
+// arguments, which jlink bakes into the image as `--add-options`.
+jlinkApplication {
+ noHeaderFiles = true
+ noManPages = true
+ dedupLegalNoticesErrorIfNotSameContent = true
+}
- targetPlatform("linux-x86_64") {
- setJdkHome(
- jdkDownload(
- "$ADOPTIUM_BINARY_REPOSITORY/version/jdk-${RUNTIME_JDK_VERSION}/linux/x64/jdk/hotspot/normal/eclipse",
- closureOf {
- archiveExtension = "tar.gz"
- })
- )
- }
- targetPlatform("windows-x86_64") {
- setJdkHome(
- jdkDownload(
- "$ADOPTIUM_BINARY_REPOSITORY/version/jdk-${RUNTIME_JDK_VERSION}/windows/x64/jdk/hotspot/normal/eclipse",
- closureOf {
- archiveExtension = "zip"
- })
- )
+/**
+ * The coordinates of the Adoptium archive holding the JDK that the runtime image for the given operating
+ * system is linked against, resolved from the repository declared in settings.gradle.kts.
+ */
+fun jdkArchive(operatingSystem: String, archiveExtension: String): String {
+ val runtimeJdkVersion = providers.gradleProperty("runtimeJdkVersion").get()
+ return "net.adoptium.cdn:OpenJDK${runtimeJdkVersion.substringBefore(".")}U-jdk_x64_" +
+ "${operatingSystem}_hotspot_${runtimeJdkVersion.replace("+", "_")}@$archiveExtension"
+}
+
+// jlink links against the `jmods` folder of the JDK it is given rather than the one it runs on, so a single
+// machine builds the images for every operating system. Each entry adds an `image` task, which the
+// plugin wires into `assemble`, and a `jdkArchive` configuration holding the JDK to link against.
+//
+// That JDK is declared below rather than through the plugin's own `group`/`jdkArchive` properties, because
+// the plugin turns those into a dependency in the map notation that Gradle 9 deprecated and Gradle 10
+// removes. Leaving them unset keeps the dependency it adds empty, so ours is the only one.
+jlinkImages {
+ create("linux")
+ create("windows")
+}
+
+dependencies {
+ "jdkArchiveLinux"(jdkArchive("linux", "tar.gz"))
+ "jdkArchiveWindows"(jdkArchive("windows", "zip"))
+}
+
+/**
+ * The directory holding the runtime images of all operating systems, which :agent consumes as a whole.
+ *
+ * The names of the subdirectories below it are part of the distribution's layout and must not change:
+ * `agent/src/dist/installer.sh` and `installer.bat` start the launcher inside them, and `Installer` derives
+ * the directory to install from by walking up from its own `java.home`.
+ */
+val imagesDirectory = layout.buildDirectory.dir("installer-images")
+
+mapOf("Linux" to "linux", "Windows" to "windows").forEach { (imageName, operatingSystem) ->
+ tasks.named("image$imageName") {
+ output = imagesDirectory.map { it.dir("installer-$operatingSystem-x86_64") }
}
}
+// Shares the jlink runtime images with the :agent project, which ships them in its distribution.
+configurations.consumable(JLINK_IMAGE_CONFIGURATION)
+artifacts.add(JLINK_IMAGE_CONFIGURATION, imagesDirectory) {
+ type = "directory"
+ builtBy(tasks.named("imageLinux"), tasks.named("imageWindows"))
+}
+
+// The tests start a mock Teamscale server and thus need a port that no other test uses. They take it from
+// the same shared service as the system tests, but do not apply com.teamscale.system-test-convention: they
+// are plain unit tests and need none of the rest of it, in particular not the agent jar.
+val portProvider = SystemTestPorts.registerWith(project)
+
+tasks.test {
+ usesService(portProvider)
+ systemProperty("teamscalePort", portProvider.get().pickFreePort())
+}
+
dependencies {
implementation(libs.okhttp.core)
implementation(libs.commonsLang)
@@ -101,5 +155,4 @@ dependencies {
implementation(libs.jna.platform)
testImplementation(libs.spark)
- testImplementation(project(":common-system-test"))
}
diff --git a/installer/src/test/kotlin/com/teamscale/profiler/installer/AllPlatformsInstallerTest.kt b/installer/src/test/kotlin/com/teamscale/profiler/installer/AllPlatformsInstallerTest.kt
index b97711e49..97e807dd4 100644
--- a/installer/src/test/kotlin/com/teamscale/profiler/installer/AllPlatformsInstallerTest.kt
+++ b/installer/src/test/kotlin/com/teamscale/profiler/installer/AllPlatformsInstallerTest.kt
@@ -5,7 +5,6 @@ import com.teamscale.profiler.installer.utils.MockRegistry
import com.teamscale.profiler.installer.utils.MockTeamscale
import com.teamscale.profiler.installer.utils.TestUtils
import com.teamscale.profiler.installer.utils.UninstallErrorReporterAssert
-import com.teamscale.test.commons.SystemTestUtils
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.AfterAll
@@ -92,14 +91,14 @@ internal class AllPlatformsInstallerTest {
@Test
fun connectionRefused() {
- Assertions.assertThatThrownBy { install("http://localhost:" + (SystemTestUtils.TEAMSCALE_PORT + 1)) }
+ Assertions.assertThatThrownBy { install("http://localhost:" + (TEAMSCALE_PORT + 1)) }
.hasMessageContaining("refused a connection")
Assertions.assertThat(targetDirectory).doesNotExist()
}
@Test
fun httpsInsteadOfHttp() {
- Assertions.assertThatThrownBy { install("https://localhost:" + SystemTestUtils.TEAMSCALE_PORT) }
+ Assertions.assertThatThrownBy { install("https://localhost:$TEAMSCALE_PORT") }
.hasMessageContaining("configured for HTTPS, not HTTP")
Assertions.assertThat(targetDirectory).doesNotExist()
}
@@ -133,14 +132,21 @@ internal class AllPlatformsInstallerTest {
companion object {
private const val FILE_TO_INSTALL_CONTENT = "install-me"
private const val NESTED_FILE_CONTENT = "nested-file"
- private val TEAMSCALE_URL = "http://localhost:" + SystemTestUtils.TEAMSCALE_PORT + "/"
+
+ /**
+ * The port for the mock Teamscale server, picked by the build script so that it does not conflict
+ * with the ports of any other test.
+ */
+ private val TEAMSCALE_PORT: Int = Integer.getInteger("teamscalePort")
+
+ private val TEAMSCALE_URL = "http://localhost:$TEAMSCALE_PORT/"
private var mockTeamscale: MockTeamscale? = null
@JvmStatic
@BeforeAll
fun startFakeTeamscale() {
- mockTeamscale = MockTeamscale(SystemTestUtils.TEAMSCALE_PORT)
+ mockTeamscale = MockTeamscale(TEAMSCALE_PORT)
}
@JvmStatic
diff --git a/renovate.json b/renovate.json
index b6483c8ff..ece71d2e3 100644
--- a/renovate.json
+++ b/renovate.json
@@ -9,7 +9,6 @@
"rangeStrategy": "bump",
"separateMajorMinor": false,
"ignorePaths": [
- "sample-app/**",
"report-generator/build.gradle.kts"
],
"packageRules": [
@@ -75,12 +74,6 @@
"com.teamscale:teamscale-client"
],
"enabled": false
- },
- {
- "matchPackageNames": [
- "org.jetbrains.kotlin.jvm"
- ],
- "enabled": false
}
]
}
diff --git a/sample-app/.gitignore b/sample-app/.gitignore
deleted file mode 100644
index a9a5aecf4..000000000
--- a/sample-app/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-tmp
diff --git a/sample-app/build.gradle.kts b/sample-app/build.gradle.kts
index c28971305..b508ae205 100644
--- a/sample-app/build.gradle.kts
+++ b/sample-app/build.gradle.kts
@@ -1,44 +1,46 @@
-// Needed to make git properties work with Java 8,
-// see https://github.com/n0mer/gradle-git-properties/issues/195#issuecomment-982326268
-buildscript {
- dependencies {
- classpath("org.eclipse.jgit:org.eclipse.jgit") {
- version {
- strictly("5.13.0.202109080827-r")
- }
- }
- }
-}
-
plugins {
- application
com.teamscale.`java-convention`
- com.teamscale.coverage
+ application
+ com.teamscale.`agent-jar`
alias(libs.plugins.gitProperties)
}
+application {
+ mainClass = "com.example.Main"
+}
+
version = "unspecified"
-application {
- applicationName = "sample-app"
- mainClass = "Main"
+dependencies {
+ testImplementation(libs.junit4)
}
tasks.jar {
manifest {
- attributes["Main-Class"] = "Main"
+ attributes["Main-Class"] = "com.example.Main"
}
- // make it a fat jar
- from(configurations.runtimeClasspath.get().files.map { if (it.isDirectory) it else zipTree(it) })
}
gitProperties {
- dotGitDirectory = rootProject.layout.projectDirectory.dir(".git")
+ keys = listOf("git.branch", "git.commit.id", "git.commit.time")
}
-dependencies {
- // this logback version is the oldest one available that I could get to work and possibly incompatible
- // with the one used in the agent. This way, we can test if the shadowing works correctly
- implementation("ch.qos.logback:logback-core:1.0.0")
- implementation("ch.qos.logback:logback-classic:1.0.0")
+/**
+ * Uses `java-profiler.local.properties` if it exists, so credentials for a real Teamscale instance can be kept out of
+ * version control (the file is git-ignored), and the committed `java-profiler.properties` otherwise.
+ */
+val agentConfigFile =
+ listOf("java-profiler.local.properties", "java-profiler.properties")
+ .first { layout.projectDirectory.file(it).asFile.exists() }
+
+tasks.named("run") {
+ classpath = files(tasks.jar, configurations.runtimeClasspath)
+ // How long the application should keep running, e.g. `./gradlew :sample-app:run -PruntimeSeconds=300` to profile
+ // for five minutes. Without it the application uses its own default of ten seconds.
+ providers.gradleProperty("runtimeSeconds").orNull?.let { args(it) }
+ teamscaleAgent(
+ mapOf(
+ "config-file" to agentConfigFile
+ )
+ )
}
diff --git a/sample-app/config.properties b/sample-app/config.properties
deleted file mode 100644
index f1df248bf..000000000
--- a/sample-app/config.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-teamscale-server-url=http://localhost:8080
-teamscale-project=t
-teamscale-user=admin
-teamscale-partition=Manual Tests
-teamscale-access-token=1234
diff --git a/sample-app/java-profiler.properties b/sample-app/java-profiler.properties
new file mode 100644
index 000000000..37729c4ba
--- /dev/null
+++ b/sample-app/java-profiler.properties
@@ -0,0 +1,29 @@
+# Configuration for profiling the sample-app, see docs/DEBUGGING.md.
+#
+# This file is committed, so do NOT put credentials in it. To talk to a real Teamscale instance, copy it to
+# java-profiler.local.properties (git-ignored) and fill in the values there; the run task prefers that file if present.
+
+debug=true
+# Uncomment and complete the following to connect to a Teamscale instance.
+#teamscale-server-url=http://127.0.0.1:8080/
+#teamscale-user=admin
+#teamscale-access-token= Access Keys>
+
+# The commit to upload to needs no configuration: the build applies the gradle-git-properties plugin, which generates
+# a git.properties file into the jar, and the agent picks the commit up from there — the same mechanism most profiled
+# applications use. Coverage therefore lands on the revision that is currently checked out, which Teamscale only
+# knows once it has analyzed it. Uncomment one of the following to override that; they are mutually exclusive, and
+# either one turns the auto-detection off. In teamscale-commit, HEAD is a valid timestamp.
+#teamscale-commit=master:HEAD
+#teamscale-revision=
+
+
+# Local configuration
+includes=*com.example.*
+# Uncomment and complete the following to upload coverage to a local Teamscale instance.
+#teamscale-project=
+#teamscale-partition=Agent Debugging
+
+
+# Teamscale server configuration
+#config-id=
diff --git a/sample-app/log-test.properties b/sample-app/log-test.properties
deleted file mode 100644
index 7430d4e09..000000000
--- a/sample-app/log-test.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-logging-config=./tmp/teamscale-jacoco-agent/logging/logback.console.xml
-
-# Invalid config option causing TS-23151. Comment out to see the good case
-foo=bar
\ No newline at end of file
diff --git a/sample-app/run-log-test.sh b/sample-app/run-log-test.sh
deleted file mode 100755
index bcfc235e5..000000000
--- a/sample-app/run-log-test.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/bash
-profiler_dist="../agent/build/distributions/teamscale-jacoco-agent.zip"
-
-# Please comment out in case you don't need to build either of these
-../gradlew :agent:assemble
-../gradlew :sample-app:assemble
-
-rm -rf tmp
-unzip -d tmp "$profiler_dist"
-unzip -d tmp build/distributions/sample-app.zip
-
-# Make default logs dir readonly
-mkdir tmp/teamscale-jacoco-agent/logs
-chmod u-w tmp/teamscale-jacoco-agent/logs
-
-JAVA_TOOL_OPTIONS="-javaagent:tmp/teamscale-jacoco-agent/lib/teamscale-jacoco-agent.jar=config-file=./log-test.properties" tmp/sample-app/bin/sample-app
-
-chmod u+w tmp/teamscale-jacoco-agent/logs
diff --git a/sample-app/run-with-profiler.sh b/sample-app/run-with-profiler.sh
deleted file mode 100755
index 49d0661df..000000000
--- a/sample-app/run-with-profiler.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/bash
-profiler_dist="../agent/build/distributions/teamscale-jacoco-agent.zip"
-
-# Please comment out in case you don't need to build either of these
-../gradlew :agent:assemble
-../gradlew :sample-app:assemble
-
-rm -rf tmp
-unzip -d tmp "$profiler_dist"
-unzip -d tmp build/distributions/sample-app.zip
-JAVA_TOOL_OPTIONS="-javaagent:tmp/teamscale-jacoco-agent/lib/teamscale-jacoco-agent.jar=config-file=./config.properties" tmp/sample-app/bin/sample-app
diff --git a/sample-app/src/main/java/Main.java b/sample-app/src/main/java/Main.java
deleted file mode 100644
index c10249f58..000000000
--- a/sample-app/src/main/java/Main.java
+++ /dev/null
@@ -1,11 +0,0 @@
-import org.slf4j.LoggerFactory;
-
-/** Main class. */
-public class Main {
-
- /** Main method. */
- public static void main(String[] args) {
- LoggerFactory.getLogger("testlogger").error("testing logging with incompatible logback version");
- }
-
-}
diff --git a/sample-app/src/main/java/com/example/Main.java b/sample-app/src/main/java/com/example/Main.java
new file mode 100644
index 000000000..40dbc248a
--- /dev/null
+++ b/sample-app/src/main/java/com/example/Main.java
@@ -0,0 +1,22 @@
+package com.example;
+
+/**
+ * A tiny application to attach the profiler to, see docs/DEBUGGING.md. It sleeps for ten seconds, which leaves room to
+ * attach a debugger or to watch coverage being uploaded. Pass a different runtime in seconds as the first argument,
+ * e.g. {@code ./gradlew :sample-app:run -PruntimeSeconds=300}.
+ */
+public class Main {
+
+ private static final int DEFAULT_RUNTIME_SECONDS = 10;
+ private static final int TICK_SECONDS = 5;
+
+ public static void main(String[] args) throws InterruptedException {
+ int runtime = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_RUNTIME_SECONDS;
+ System.out.println("Hello Java Profiler! Staying alive for " + runtime + "s.");
+ for (int elapsed = 0; elapsed < runtime; elapsed += TICK_SECONDS) {
+ System.out.println("Still running: " + elapsed + "s of " + runtime + "s");
+ Thread.sleep(Math.min(TICK_SECONDS, runtime - elapsed) * 1000L);
+ }
+ System.out.println("Done. Coverage is dumped while the JVM shuts down.");
+ }
+}
diff --git a/sample-debugging-app/build.gradle.kts b/sample-debugging-app/build.gradle.kts
deleted file mode 100644
index 95fbd3fb8..000000000
--- a/sample-debugging-app/build.gradle.kts
+++ /dev/null
@@ -1,30 +0,0 @@
-plugins {
- com.teamscale.`java-convention`
- application
- com.teamscale.`agent-jar`
-}
-
-application {
- mainClass = "com.example.Main"
-}
-
-version = "unspecified"
-
-dependencies {
- testImplementation(libs.junit4)
-}
-
-tasks.jar {
- manifest {
- attributes["Main-Class"] = "com.example.Main"
- }
-}
-
-tasks.named("run") {
- teamscaleAgent(
- mapOf(
- "config-file" to "jacocoagent.properties"
- )
- )
- dependsOn(":agent:shadowJar")
-}
diff --git a/sample-debugging-app/jacocoagent.properties b/sample-debugging-app/jacocoagent.properties
deleted file mode 100644
index 1ab66e2bf..000000000
--- a/sample-debugging-app/jacocoagent.properties
+++ /dev/null
@@ -1,8 +0,0 @@
-includes=*com.example.*
-logging-config=./logback.console.xml
-# teamscale-commit=master:HEAD
-# teamscale-server-url=http://localhost:8080/
-# teamscale-project=
-# teamscale-user=admin
-# teamscale-access-token=
-# teamscale-partition=Agent Debugging
diff --git a/sample-debugging-app/logback.console.xml b/sample-debugging-app/logback.console.xml
deleted file mode 100644
index 1fb559c9d..000000000
--- a/sample-debugging-app/logback.console.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/sample-debugging-app/src/main/java/com/example/Main.java b/sample-debugging-app/src/main/java/com/example/Main.java
deleted file mode 100644
index 42bbd26cf..000000000
--- a/sample-debugging-app/src/main/java/com/example/Main.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.example;
-
-public class Main {
- public static void main(String[] args) {
- System.out.println("Hello Java Profiler!");
- }
-}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index bd7876ef7..6a42fd388 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -6,6 +6,27 @@ plugins {
dependencyResolutionManagement {
repositories {
mavenCentral()
+
+ // The JDKs that the installer's runtime images are linked against. Declaring them as dependencies
+ // instead of letting the jlink plugin download them keeps the download out of the configuration
+ // phase and makes it cacheable, cf. installer/build.gradle.kts.
+ ivy {
+ val jdkVersion = providers.gradleProperty("runtimeJdkVersion").get()
+ val repository = "temurin${jdkVersion.substringBefore(".")}-binaries"
+ url = uri(
+ "https://github.com/adoptium/$repository/releases/download/jdk-${jdkVersion.replace("+", "%2B")}/"
+ )
+ patternLayout {
+ artifact("[artifact].[ext]")
+ }
+ // The release only contains the archives themselves, there is no module metadata to fetch.
+ metadataSources {
+ artifact()
+ }
+ content {
+ includeGroup("net.adoptium.cdn")
+ }
+ }
}
oci {
registries {
@@ -16,16 +37,33 @@ dependencyResolutionManagement {
}
}
+// Coordinates are configured from the settings file rather than via allprojects {} in the root build
+// file, because cross-configuring projects from the root is incompatible with project isolation.
+// Everything the action below reads must be a local, since values it captures are isolated from the
+// settings script and script object references cannot be serialized.
+run {
+ /** The version of the profiler. Released builds use it as is, all others get a snapshot suffix. */
+ val appVersion = "37.0.2"
+ val isTaggedRelease = providers.environmentVariable("GITHUB_REF").map { it.contains("/tags/") }
+ val projectVersion = appVersion + if (isTaggedRelease.getOrElse(false)) "" else "-SNAPSHOT"
+
+ gradle.lifecycle.beforeProject {
+ group = "com.teamscale"
+ version = projectVersion
+ // The plain version without the snapshot suffix, e.g. for naming the distribution.
+ extra.set("appVersion", appVersion)
+ }
+}
+
include(":agent")
include(":report-generator")
include(":teamscale-gradle-plugin")
include(":teamscale-client")
-include(":sample-app")
include(":impacted-test-engine")
include(":tia-client")
include(":tia-runlisteners")
include(":common-system-test")
-include(":sample-debugging-app")
+include(":sample-app")
include(":teamscale-maven-plugin")
include(":installer")
diff --git a/system-tests/cucumber-maven-tia/build.gradle.kts b/system-tests/cucumber-maven-tia/build.gradle.kts
index 708306ce3..36400e900 100644
--- a/system-tests/cucumber-maven-tia/build.gradle.kts
+++ b/system-tests/cucumber-maven-tia/build.gradle.kts
@@ -6,5 +6,5 @@ plugins {
tasks.test {
// install dependencies needed by the Maven test project
- dependsOn(rootProject.tasks["publishToMavenLocal"])
+ dependsOn(":publishToMavenLocal")
}
diff --git a/system-tests/debug-logging-test/build.gradle.kts b/system-tests/debug-logging-test/build.gradle.kts
index 37bb35cb5..725f8b65d 100644
--- a/system-tests/debug-logging-test/build.gradle.kts
+++ b/system-tests/debug-logging-test/build.gradle.kts
@@ -4,7 +4,6 @@ plugins {
}
tasks.register("runWithoutGradleWorker") {
- dependsOn(":agent:shadowJar")
mainClass = "jul.test.SystemUnderTest"
classpath = sourceSets["main"].runtimeClasspath
systemProperty("java.util.logging.manager", "jul.test.CustomLogManager")
diff --git a/system-tests/gradle-cucumber/build.gradle.kts b/system-tests/gradle-cucumber/build.gradle.kts
index 3b8c4c0a4..4edcc7677 100644
--- a/system-tests/gradle-cucumber/build.gradle.kts
+++ b/system-tests/gradle-cucumber/build.gradle.kts
@@ -6,5 +6,5 @@ plugins {
tasks.test {
// install dependencies needed by the Gradle test project
- dependsOn(rootProject.tasks["publishToMavenLocal"])
+ dependsOn(":publishToMavenLocal")
}
diff --git a/system-tests/gradle-multi-module/src/test/kotlin/com/teamscale/tia/TestwiseCoverageGradleSystemTest.kt b/system-tests/gradle-multi-module/src/test/kotlin/com/teamscale/tia/TestwiseCoverageGradleSystemTest.kt
index 993180a42..410a60e06 100644
--- a/system-tests/gradle-multi-module/src/test/kotlin/com/teamscale/tia/TestwiseCoverageGradleSystemTest.kt
+++ b/system-tests/gradle-multi-module/src/test/kotlin/com/teamscale/tia/TestwiseCoverageGradleSystemTest.kt
@@ -106,7 +106,7 @@ class TestwiseCoverageGradleSystemTest {
assertThat(result.isSuccess).isTrue()
assertThat(File("gradle-project/app/build/jacoco/systemTest/logs/teamscale-jacoco-agent.log")).content()
- .contains("DEBUG com.teamscale.jacoco.agent.Agent - No explicit teamscale.properties file given.")
+ .contains("DEBUG Agent - No explicit teamscale.properties file given.")
assertThat(File("gradle-project/app/build/jacoco/systemTest/engine.log")).content()
.contains("[FINE] com.teamscale.test_impacted.engine.TestEngineRegistry: Found test engines: [junit-jupiter]")
}
diff --git a/system-tests/http-server-shutdown/build.gradle.kts b/system-tests/http-server-shutdown/build.gradle.kts
index a1b3e040a..9d9e77412 100644
--- a/system-tests/http-server-shutdown/build.gradle.kts
+++ b/system-tests/http-server-shutdown/build.gradle.kts
@@ -4,9 +4,17 @@ plugins {
com.teamscale.coverage
}
+tasks.jar {
+ manifest {
+ attributes["Main-Class"] = "systemundertest.SystemUnderTest"
+ }
+ // create a fat jar so the Kotlin standard library is available when the jar is run via `java -jar`
+ from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
tasks.test {
environment("AGENT_JAR", agentJar)
- val sampleJar = project(":sample-app").tasks["jar"].outputs.files.singleFile
- environment("SAMPLE_JAR", sampleJar)
- dependsOn(":sample-app:assemble")
+ environment("SYSTEM_UNDER_TEST_JAR", tasks.jar.get().outputs.files.singleFile)
+ dependsOn(tasks.jar)
}
diff --git a/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt b/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt
index b7d25de8f..9d9db40b9 100644
--- a/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt
+++ b/system-tests/http-server-shutdown/src/main/kotlin/systemundertest/SystemUnderTest.kt
@@ -1,6 +1,14 @@
package systemundertest
-/** Fake system under test to generate some coverage. */
-class SystemUnderTest {
+/**
+ * Fake system under test to generate some coverage. Exits on its own so the test can verify that the agent's HTTP
+ * server does not keep the JVM alive with non-daemon threads.
+ */
+object SystemUnderTest {
+ @JvmStatic
+ fun main(args: Array) {
+ println("Production code: ${foo()}")
+ }
+
fun foo() = 2
}
diff --git a/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt b/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt
index 1652f8782..a39dd5440 100644
--- a/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt
+++ b/system-tests/http-server-shutdown/src/test/kotlin/com/teamscale/tia/HttpServerShutdownSystemTest.kt
@@ -17,11 +17,11 @@ class HttpServerShutdownSystemTest {
@Throws(Exception::class)
fun testShutdown() {
val agentJar = System.getenv("AGENT_JAR")
- val sampleJar = System.getenv("SAMPLE_JAR")
+ val systemUnderTestJar = System.getenv("SYSTEM_UNDER_TEST_JAR")
val result = ProcessUtils.execute(
"java",
"-javaagent:$agentJar=http-server-port=${SystemTestUtils.AGENT_PORT}",
- "-jar", sampleJar
+ "-jar", systemUnderTestJar
)
println(result.stderr)
println(result.stdout)
diff --git a/system-tests/invalid-options-logging-test/build.gradle.kts b/system-tests/invalid-options-logging-test/build.gradle.kts
new file mode 100644
index 000000000..143ca2f63
--- /dev/null
+++ b/system-tests/invalid-options-logging-test/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ com.teamscale.`kotlin-convention`
+ com.teamscale.`system-test-convention`
+}
+
+tasks.jar {
+ manifest.attributes["Main-Class"] = "systemundertest.SystemUnderTest"
+}
+
+tasks.test {
+ environment("AGENT_JAR", agentJar)
+ environment("SYSTEM_UNDER_TEST_JAR", tasks.jar.get().outputs.files.singleFile)
+ dependsOn(tasks.jar)
+}
diff --git a/system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java b/system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java
new file mode 100644
index 000000000..0c99958ab
--- /dev/null
+++ b/system-tests/invalid-options-logging-test/src/main/java/systemundertest/SystemUnderTest.java
@@ -0,0 +1,10 @@
+package systemundertest;
+
+/** Fake system under test to generate some coverage. */
+public class SystemUnderTest {
+
+ public static void main(String[] args) {
+ System.out.println("Production code");
+ }
+
+}
diff --git a/system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt b/system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt
new file mode 100644
index 000000000..304c200e2
--- /dev/null
+++ b/system-tests/invalid-options-logging-test/src/test/kotlin/com/teamscale/logging/InvalidOptionsLoggingSystemTest.kt
@@ -0,0 +1,71 @@
+package com.teamscale.logging
+
+import com.teamscale.test.commons.ProcessUtils
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Assumptions.assumeTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.condition.DisabledOnOs
+import org.junit.jupiter.api.condition.OS
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Regression tests for TS-23151: a misconfigured profiler must report the problem on the console.
+ *
+ * By default the agent logs into a temporary directory that users have no realistic way of finding, and it never
+ * fails the profiled application. So if these messages do not reach the console, the only symptom of a broken
+ * configuration is an application that runs perfectly and silently collects no coverage.
+ *
+ * These tests replace the former manual `sample-app/run-log-test.sh` script.
+ */
+class InvalidOptionsLoggingSystemTest {
+
+ /**
+ * Options that fail to parse are reported on stderr. This covers errors raised while the options are still being
+ * parsed, i.e. before the configured logging is up, which is what makes them easy to lose.
+ */
+ @Test
+ fun optionParseErrorIsPrintedToTheConsole() {
+ val result = ProcessUtils.execute(
+ "java", "-javaagent:$AGENT_JAR=config-id=foo", "-jar", SYSTEM_UNDER_TEST_JAR
+ )
+
+ assertThat(result.stderr)
+ .`as`("the parse error must reach the console and not only the log file")
+ .contains("Failed to parse agent options")
+ .contains("teamscale-server-url")
+ assertThat(result.exitCode)
+ .`as`("a configuration error must never stop the profiled application from starting")
+ .isEqualTo(0)
+ assertThat(result.stdout).contains("Production code")
+ }
+
+ /**
+ * If the log directory cannot be written to, the agent says so on the console instead of losing the message it
+ * was about to write into exactly that directory.
+ */
+ @Test
+ @DisabledOnOs(OS.WINDOWS, disabledReason = "file permissions behave differently on Windows")
+ fun unwritableLogDirectoryIsReportedOnTheConsole(@TempDir tempDirectory: Path) {
+ val logDirectory = Files.createDirectory(tempDirectory.resolve("read-only"))
+ logDirectory.toFile().setWritable(false)
+ assumeTrue(!Files.isWritable(logDirectory), "requires a non-writable directory, so cannot run as root")
+
+ val result = ProcessUtils.execute(
+ "java", "-javaagent:$AGENT_JAR=debug=$logDirectory", "-jar", SYSTEM_UNDER_TEST_JAR
+ )
+
+ assertThat(result.stdout)
+ .`as`("the agent must report that it cannot write its logs")
+ .contains("Could not create debug log directory")
+ .contains("Falling back to console-only logging")
+ assertThat(result.exitCode).isEqualTo(0)
+ assertThat(result.stdout).contains("Production code")
+ }
+
+ companion object {
+ private val AGENT_JAR: String = System.getenv("AGENT_JAR")
+ private val SYSTEM_UNDER_TEST_JAR: String = System.getenv("SYSTEM_UNDER_TEST_JAR")
+ }
+}
diff --git a/system-tests/junit-run-listener-test/build.gradle.kts b/system-tests/junit-run-listener-test/build.gradle.kts
index 0711312c5..148a92b2b 100644
--- a/system-tests/junit-run-listener-test/build.gradle.kts
+++ b/system-tests/junit-run-listener-test/build.gradle.kts
@@ -6,5 +6,5 @@ plugins {
tasks.test {
// install dependencies needed by the Maven test projects
- dependsOn(rootProject.tasks["publishToMavenLocal"])
+ dependsOn(":publishToMavenLocal")
}
diff --git a/system-tests/teamscale-profiler-configuration-test/build.gradle.kts b/system-tests/teamscale-profiler-configuration-test/build.gradle.kts
index cd00f588e..2c6129210 100644
--- a/system-tests/teamscale-profiler-configuration-test/build.gradle.kts
+++ b/system-tests/teamscale-profiler-configuration-test/build.gradle.kts
@@ -16,7 +16,6 @@ tasks.test {
environment("AGENT_JAR", agentJar)
environment("SYSTEM_UNDER_TEST_JAR", tasks.jar.get().outputs.files.singleFile)
- dependsOn(":sample-app:assemble")
val teamscalePropertiesPath = agentJar.toPath().parent.parent.resolve("teamscale.properties")
doFirst {
diff --git a/system-tests/teamscale-properties-test/build.gradle.kts b/system-tests/teamscale-properties-test/build.gradle.kts
index 1857b9fed..2294b7fff 100644
--- a/system-tests/teamscale-properties-test/build.gradle.kts
+++ b/system-tests/teamscale-properties-test/build.gradle.kts
@@ -1,3 +1,4 @@
+import kotlin.io.path.deleteIfExists
import kotlin.io.path.writeText
plugins {
@@ -18,7 +19,7 @@ tasks.test {
)
}
doLast {
- delete(teamscalePropertiesPath)
+ teamscalePropertiesPath.deleteIfExists()
}
teamscaleAgent(
diff --git a/system-tests/tia-maven/build.gradle.kts b/system-tests/tia-maven/build.gradle.kts
index 9ca8c0b58..95042d297 100644
--- a/system-tests/tia-maven/build.gradle.kts
+++ b/system-tests/tia-maven/build.gradle.kts
@@ -6,6 +6,6 @@ plugins {
tasks.test {
// install dependencies needed by the Maven test project
- dependsOn(rootProject.tasks["publishToMavenLocal"])
+ dependsOn(":publishToMavenLocal")
}
diff --git a/system-tests/tia-maven/src/test/kotlin/com/teamscale/tia/TiaMavenSystemTest.kt b/system-tests/tia-maven/src/test/kotlin/com/teamscale/tia/TiaMavenSystemTest.kt
index ba890ae30..38adff810 100644
--- a/system-tests/tia-maven/src/test/kotlin/com/teamscale/tia/TiaMavenSystemTest.kt
+++ b/system-tests/tia-maven/src/test/kotlin/com/teamscale/tia/TiaMavenSystemTest.kt
@@ -136,7 +136,7 @@ class TiaMavenSystemTest {
runMavenTests("maven-project", "-DdebugLogging=true", "-Dtia")
assertThat(File("maven-project/sub-project-A/target/tia/agent.log")).content()
- .contains("DEBUG com.teamscale.jacoco.agent.Agent - No explicit teamscale.properties file given.");
+ .contains("DEBUG Agent - No explicit teamscale.properties file given.");
assertThat(File("maven-project/sub-project-A/target/tia/engine.log")).content()
.contains("[FINE] com.teamscale.test_impacted.engine.TestEngineRegistry: Found test engines: [junit-jupiter]")
}
diff --git a/teamscale-maven-plugin/build.gradle.kts b/teamscale-maven-plugin/build.gradle.kts
index 7c84f996e..0092762fe 100644
--- a/teamscale-maven-plugin/build.gradle.kts
+++ b/teamscale-maven-plugin/build.gradle.kts
@@ -14,6 +14,14 @@ mavenPlugin {
helpMojoPackage = "com.teamscale.maven.help"
}
+// This module is not shaded itself, but the bundled logback configuration is handed to the shaded
+// agent, so it has to reference the relocated logback classes.
+tasks.processResources {
+ shadowLoggingPackages(usesShadowedPackages)
+}
+
+verifyShadowedLoggingConfigs(tasks.jar)
+
dependencies {
runtimeOnly(project(":agent"))
implementation(project(":report-generator"))
diff --git a/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml b/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml
index 3915f2c46..258f0fc26 100644
--- a/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml
+++ b/teamscale-maven-plugin/src/main/resources/com/teamscale/maven/tia/logback-agent.xml
@@ -1,16 +1,13 @@
-
+ ${TEAMSCALE_AGENT_LOG_FILE}
- %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{35} - %msg%n
+ %date{yyyy-MM-dd HH:mm:ss} %-5level %logger{0} - %msg%n
-
-
-