Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions async-test-agent/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,89 @@
</archive>
</configuration>
</plugin>
<!-- Attach-the-packaged-jar gate. Failsafe (not Surefire) because the subject is
the jar that package produced, not the classes: the 1.7.0-RC1..RC8 jars aborted
every consumer JVM at premain (unshaded Byte Buddy) and no test ever noticed,
because nothing attached the artifact the way docs/AGENT.md documents. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<!-- AgentJarPremainIT filters Byte Buddy out of java.class.path; a
manifest-only booter jar would hide the real entries from it. -->
<useManifestOnlyJar>false</useManifestOnlyJar>
<systemPropertyVariables>
<agent.jar>${project.build.directory}/${project.build.finalName}.jar</agent.jar>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Bundle Byte Buddy into the agent jar, relocated. -javaagent: loads this jar on
its own: the JVM resolves AsyncTestAgent's method signatures before premain runs,
and every 1.7.0-RC jar aborted consumer JVM startup right there
(NoClassDefFoundError: net.bytebuddy...) because nothing on a consumer classpath
provides Byte Buddy — ArchitectureTest forbids the library from carrying it.
Relocation (rather than plain bundling) keeps the copy invisible to consumers
that ship their own Byte Buddy, such as anything using Mockito. AgentJarPremainIT
is the gate: it attaches the packaged jar to a Byte-Buddy-free JVM. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>${maven-shade-plugin.version}</version>
<configuration>
<artifactSet>
<includes>
<include>net.bytebuddy:byte-buddy</include>
<include>net.bytebuddy:byte-buddy-agent</include>
</includes>
</artifactSet>
<relocations>
<relocation>
<pattern>net.bytebuddy</pattern>
<shadedPattern>se.deversity.asynctest.agent.shaded.bytebuddy</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>net.bytebuddy:*</artifact>
<excludes>
<!-- The shaded jar stays a classpath artifact; a relocated
module descriptor would be a lie in two ways. -->
<exclude>module-info.class</exclude>
<exclude>META-INF/versions/*/module-info.class</exclude>
</excludes>
</filter>
</filters>
<transformers>
<!-- Byte Buddy is a multi-release jar; its versioned classes are
relocated under META-INF/versions/ and need this flag to load. -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</transformer>
</transformers>
<!-- Out of the source tree: the reduced pom (no bundled deps) is what
deploy publishes, but it is a build product, not a source. -->
<dependencyReducedPomLocation>${project.build.directory}/dependency-reduced-pom.xml</dependencyReducedPomLocation>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,17 @@ private static void install(@Nullable String agentArgs, Instrumentation inst, bo
* @return an ignore matcher over {@link TypeDescription}
*/
static ElementMatcher.Junction<TypeDescription> ignoreMatcher() {
// Assembled at runtime so the Shade plugin's relocation cannot rewrite it: a
// literal "net.bytebuddy." would be relocated along with the type references,
// and the matcher would stop ignoring a consumer's own (unrelocated) Byte Buddy
// — for example Mockito's. The shaded copy needs no entry of its own: it lives
// under se.deversity.asynctest., which the next prefix already covers.
String byteBuddyPrefix = String.join(".", "net", "bytebuddy") + ".";
return ElementMatchers.<TypeDescription>nameStartsWith("java.")
.or(ElementMatchers.nameStartsWith("jdk."))
.or(ElementMatchers.nameStartsWith("sun."))
.or(ElementMatchers.nameStartsWith("com.sun."))
.or(ElementMatchers.nameStartsWith("net.bytebuddy."))
.or(ElementMatchers.nameStartsWith(byteBuddyPrefix))
.or(ElementMatchers.nameStartsWith("se.deversity.asynctest."))
.or(ElementMatchers.isSynthetic());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package se.deversity.asynctest.agent;

import org.junit.jupiter.api.Test;

import java.io.File;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Attaches the <em>packaged</em> agent jar to a fresh JVM the way the docs tell a
* consumer to ({@code -javaagent:async-test-agent-<version>.jar}) and requires the
* JVM to reach {@code main}.
*
* <p>The child classpath is this JVM's classpath with every Byte Buddy jar removed,
* because that is what a consumer's test JVM looks like: {@code async-test-lib}
* present, Byte Buddy absent (the library module is forbidden from carrying it).
* The agent jar must therefore bundle its own relocated copy — an unshaded jar
* fails premain method resolution with {@code NoClassDefFoundError:
* net/bytebuddy/matcher/ElementMatcher}, which the JVM escalates to a fatal
* startup abort. That is exactly the failure this test exists to catch: it shipped
* unnoticed through eight release candidates because no gate ever attached the
* packaged jar standalone.
*
* <p>Runs under Maven Failsafe only (needs the packaged jar, so it must run after
* {@code package}). Failsafe is configured with {@code useManifestOnlyJar=false}
* so {@code java.class.path} holds the real entries — a manifest-only booter jar
* would smuggle Byte Buddy past the filter below. The Gradle build excludes
* {@code *IT} from its test task.
*/
class AgentJarPremainIT {

@Test
void packagedJarPremainMustNotAbortJvmStartup() throws Exception {
assertChildJvmCompletes(List.of("-javaagent:" + packagedAgentJar()),
PremainChildMain.class.getName(), PremainChildMain.MARKER);
}

/**
* The second documented attach mode. Surefire's {@code SelfAttachTest} covers the
* logic pre-shade; this scenario re-runs it against the packaged jar, where
* {@code selfAttach()} must reach the relocated {@code byte-buddy-agent} bundled
* inside — the published pom no longer declares Byte Buddy at all.
*/
@Test
void packagedJarSelfAttachMustSucceedWithoutByteBuddyOnClasspath() throws Exception {
packagedAgentJar(); // fail fast with the clearer message if the jar is absent
assertChildJvmCompletes(List.of("-Djdk.attach.allowAttachSelf=true"),
SelfAttachChildMain.class.getName(), SelfAttachChildMain.MARKER);
}

private static String packagedAgentJar() {
String agentJar = System.getProperty("agent.jar");
assertNotNull(agentJar, "agent.jar system property not set — run via Maven Failsafe");
assertTrue(new File(agentJar).isFile(), "packaged agent jar missing: " + agentJar);
return agentJar;
}

private static void assertChildJvmCompletes(List<String> jvmFlags, String mainClass, String marker)
throws Exception {
// Failsafe substitutes the packaged (shaded) jar for target/classes on this
// classpath, so the child JVM runs the artifact consumers actually get — the
// premain scenario proved that substitution: with target/classes present, the
// unshaded AsyncTestAgent would have been found first and aborted the child.
String java = Path.of(System.getProperty("java.home"), "bin", "java").toString();
List<String> command = new ArrayList<>();
command.add(java);
command.addAll(jvmFlags);
command.addAll(List.of("-Dlicense.mock.mode=true", "-cp", classpathWithoutByteBuddy(), mainClass));

Process child = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(child.getInputStream().readAllBytes(), Charset.defaultCharset());
assertTrue(child.waitFor(60, TimeUnit.SECONDS), "child JVM did not exit within 60s:\n" + output);

assertEquals(0, child.exitValue(),
"child JVM failed (" + mainClass + ") with the packaged agent jar:\n" + output);
assertTrue(output.contains(marker),
"child JVM exited 0 but never printed its marker:\n" + output);
}

/**
* This JVM's classpath minus every Byte Buddy entry. Matching on the Maven
* artifact directory names ({@code byte-buddy}, {@code byte-buddy-agent})
* keeps async-test-lib, JUnit and the test classes while guaranteeing the
* child can only get Byte Buddy from inside the agent jar itself.
*/
private static String classpathWithoutByteBuddy() {
List<String> kept = new ArrayList<>();
for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) {
if (!entry.replace(File.separatorChar, '/').contains("byte-buddy")) {
kept.add(entry);
}
}
return String.join(File.pathSeparator, kept);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package se.deversity.asynctest.agent;

/**
* Child entry point for {@link AgentJarPremainIT}. Launched in a fresh JVM with
* {@code -javaagent:} pointing at the packaged agent jar; reaching {@code main}
* proves {@code premain} completed without aborting JVM startup.
*
* <p>Not a test class — no JUnit annotations — so Surefire, Failsafe and Gradle
* all ignore it during discovery.
*/
public final class PremainChildMain {

/** Printed on stdout so the parent can assert the JVM survived premain. */
static final String MARKER = "premain-survived-main-ran";

private PremainChildMain() {}

public static void main(String[] args) {
System.out.println(MARKER);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package se.deversity.asynctest.agent;

/**
* Child entry point for {@link AgentJarPremainIT}'s self-attach scenario. Launched
* in a fresh JVM whose classpath holds the packaged (shaded) agent jar and no Byte
* Buddy; {@code selfAttach()} must find everything it needs — including the
* relocated {@code byte-buddy-agent} — inside the jar.
*
* <p>Not a test class — no JUnit annotations — so Surefire, Failsafe and Gradle
* all ignore it during discovery.
*/
public final class SelfAttachChildMain {

/** Printed on stdout after selfAttach() returns without throwing. */
static final String MARKER = "self-attach-succeeded";

private SelfAttachChildMain() {}

public static void main(String[] args) {
AsyncTestAgent.selfAttach();
System.out.println(MARKER);
}
}
6 changes: 6 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ subprojects {
// and would run intentionally-buggy "Dummy" fixtures directly, causing failures.
filter {
excludeTestsMatching("*\$*")
// Failsafe integration tests (*IT) verify the Maven-packaged artifact — for
// the agent, the shaded jar that only the Maven build produces. Gradle builds
// an unshaded jar for local iteration, so running them here would fail on a
// difference that is expected. Maven (`mvn verify`, and CI's `mvn clean
// install`) is the build that runs them.
excludeTestsMatching("*IT")
}
}

Expand Down
5 changes: 3 additions & 2 deletions docs/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ spin path is never taken.

## 3. How to attach (all three ways)

The library JAR is agent-capable: its `MANIFEST.MF` declares `Premain-Class`, `Agent-Class`,
`Can-Retransform-Classes: true`, and `Can-Redefine-Classes: true`.
The agent JAR (`async-test-agent`, not the library JAR — the manifest moved there in the
module split) is the agent-capable artifact: its `MANIFEST.MF` declares `Premain-Class`,
`Agent-Class`, `Can-Retransform-Classes: true`, and `Can-Redefine-Classes: true`.

### 3.1 Launch flag (static attach), plain

Expand Down
28 changes: 28 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed — the published agent jar aborted every consumer JVM it was attached to

`-javaagent:async-test-agent-<version>.jar`, the attach flag AGENT.md documents, was fatal in
every 1.7.0 release candidate: the jar shipped without Byte Buddy, the JVM resolves
`AsyncTestAgent`'s method signatures before `premain` runs, and the resulting
`NoClassDefFoundError: net/bytebuddy/matcher/ElementMatcher` escalates to `FATAL ERROR in native
method: processing of -javaagent failed` — the consumer's test JVM never starts. Nothing caught it
because no gate ever attached the packaged jar standalone: the agent's own tests run with Byte
Buddy on the module test classpath, and downstream suites use the library without the agent.

The agent jar now bundles `byte-buddy` and `byte-buddy-agent`, relocated to
`se.deversity.asynctest.agent.shaded.bytebuddy` (11.7 KB → ~5.2 MB). Relocation rather than plain
bundling, so the copy cannot collide with a consumer's own Byte Buddy — Mockito's, typically. The
published pom no longer declares Byte Buddy at all (dependency-reduced pom), so `selfAttach()`
consumers also stop pulling it transitively; the relocated copy inside the jar serves both attach
modes. The ignore-matcher prefix for `net.bytebuddy.` is now assembled at runtime so relocation
cannot rewrite the literal — consumers' unrelocated Byte Buddy stays unwoven, pinned by the
existing `ignoreMatcher_ignoresByteBuddyClasses`.

The gate is `AgentJarPremainIT` (Failsafe, so it runs against the packaged artifact in
`mvn verify` and CI's `mvn clean install`): it launches a fresh JVM whose classpath contains
async-test-lib but no Byte Buddy — a consumer's classpath — and attaches the packaged jar via
`-javaagent:` in one scenario and `selfAttach()` in the other, requiring both children to reach
`main`. Verified failing-first: against the unshaded jar the premain scenario reproduces the fatal
abort verbatim; with shading both scenarios pass and the agent's 43 unit and end-to-end tests are
unchanged. Also corrected AGENT.md and contention-engine.md, which still claimed the library JAR
carries the premain manifest — it moved to the agent jar in the module split.

## [1.7.0-RC8] - 2026-08-04

### Fixed — 17 shared-instance detectors asserted corruption they cannot observe
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/contention-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ traces). Absent/blank args preserve the default `any()` behavior. A `DiagnosticL
for weaving errors that Byte Buddy would otherwise swallow.

#### Attachment
The library JAR is agent-capable — its MANIFEST contains:
The agent JAR (`async-test-agent`; the manifest moved there in the module split) is the agent-capable artifact — its MANIFEST contains:
```
Premain-Class: se.deversity.asynctest.agent.AsyncTestAgent
Agent-Class: se.deversity.asynctest.agent.AsyncTestAgent
Expand Down
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
<!-- Plugin versions -->
<maven-compiler-plugin.version>3.15.0</maven-compiler-plugin.version>
<maven-surefire-plugin.version>3.5.6</maven-surefire-plugin.version>
<maven-shade-plugin.version>3.6.2</maven-shade-plugin.version>
<!-- Pinned because nothing else does: without it Maven falls back to its built-in
default binding, which resolves maven-jar-plugin 2.4 (2013). That plugin writes
the agent's Premain-Class manifest, and an unpinned version means a different
Expand Down
Loading