Skip to content

Commit 0f0581f

Browse files
authored
Merge pull request #881 from cqse/ts/45056_class_hot_reload_coverage_issue
TS-45056 Class hot reloading detected and coverage dump fixed
2 parents daea970 + fd1ac13 commit 0f0581f

9 files changed

Lines changed: 256 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ We use [semantic versioning](http://semver.org/):
55
- PATCH version when you make backwards compatible bug fixes.
66

77
# Next version
8+
- [fix] _agent_: Fixed `IllegalStateException: Can't add different class with same name` when application servers like JBoss perform a reload without full restart or when duplicate class files exist across multiple archives
89
- [feature] _teamscale-gradle-plugin_: Annotated tasks with `@DisableCachingByDefault` where caching can't be applied
910

1011
# 36.4.0

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Entry point: `PreMain.premain()` in `agent/src/main/java/com/teamscale/jacoco/ag
5151
4. Classes are instrumented at load time with coverage probes
5252

5353
**Coverage modes:**
54-
- **Interval-based** (`Agent` class) - Periodically dumps coverage (default every 10 min)
54+
- **Interval-based** (`Agent` class) - Periodically dumps coverage (default every 480 min)
5555
- **Test-wise** (`TestwiseCoverageAgent` class) - Per-test coverage via HTTP endpoints (`/test/start`, `/test/end`)
5656

5757
**Key classes:**

README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,86 @@
1111
* [Teamscale Gradle Plugin](https://docs.teamscale.com/reference/integrations/gradle-plugin/)
1212
* [Teamscale Maven Plugin](https://docs.teamscale.com/reference/integrations/maven-plugin/)
1313

14+
## Architecture
15+
16+
The profiler is a JVM agent that uses [JaCoCo](https://www.jacoco.org/) under the hood for bytecode instrumentation and coverage recording.
17+
The diagram below shows the data flow from JVM startup to coverage upload.
18+
19+
```
20+
┌──────────────────────────────────────────────────────────────────────────┐
21+
│ JVM │
22+
│ │
23+
│ -javaagent:teamscale-jacoco-agent.jar=... │
24+
│ │ │
25+
│ ▼ │
26+
│ ┌──────────┐ ┌─────────────────────┐ ┌──────────────────────┐ │
27+
│ │ PreMain │────▶│ JaCoCoPreMain │────▶│ LenientCoverage- │ │
28+
│ │ │ │ │ │ Transformer │ │
29+
│ │ Parses │ │ Creates JaCoCo │ │ │ │
30+
│ │ options, │ │ runtime, registers │ │ Registered with JVM │ │
31+
│ │ logging │ │ class transformer │ │ via addTransformer() │ │
32+
│ └──────────┘ └─────────────────────┘ └──────────┬───────────┘ │
33+
│ │ │ │
34+
│ │ ┌──────────────────────────────────────────────────┐ │
35+
│ │ │ Class Loading │ │
36+
│ │ │ │ │
37+
│ │ │ For every class loaded by the JVM: │ │
38+
│ │ │ 1. Transformer receives original bytecode │ │
39+
│ │ │ 2. JaCoCo injects boolean[] probes at branches │ │
40+
│ │ │ and lines │ │
41+
│ │ │ 3. Modified bytecode returned to JVM │ │
42+
│ │ │ │ │
43+
│ │ │ Instrumentation happens ONLY at class load │ │
44+
│ │ │ time. Already-loaded classes are never │ │
45+
│ │ │ retransformed. │ │
46+
│ │ └──────────────────────────────────────────────────┘ │
47+
│ │ │ │
48+
│ │ ┌──────────────────────────────────────────────────┐ │
49+
│ │ │ Runtime │ │
50+
│ │ │ │ │
51+
│ │ │ Probes fire during normal code execution. │ │
52+
│ │ │ Each probe sets a flag in a per-class │ │
53+
│ │ │ boolean[], tracking which lines/branches ran. │ │
54+
│ │ │ Data accumulates in JaCoCo runtime memory. │ │
55+
│ │ └──────────────────────────────────────────────────┘ │
56+
│ │ │
57+
│ ▼ │
58+
│ ┌─────────────────────────────────────────────────────────────────┐ │
59+
│ │ Agent (Normal mode) OR TestwiseCoverageAgent │ │
60+
│ │ │ │
61+
│ │ HTTP server (Jetty + Jersey) for control: │ │
62+
│ │ POST /dump ─── trigger coverage dump │ │
63+
│ │ POST /test/start, /test/end ─── per-test coverage │ │
64+
│ └─────────────────────────────────────────────────────────────────┘ │
65+
│ │
66+
└──────────────────────────────────────────────────────────────────────────┘
67+
68+
│ Periodically (default: every 480 min) or on HTTP request
69+
70+
┌─────────────────────────────────────────────────────────────────────────┐
71+
│ Coverage Dump Pipeline │
72+
│ │
73+
│ 1. JacocoRuntimeController.dumpAndReset() │
74+
│ Retrieves execution data from JaCoCo runtime and resets probes. │
75+
│ │
76+
│ 2. JaCoCoXmlReportGenerator.convertSingleDumpToReport() │
77+
│ Reads class files (from auto-created dump dir or user-specified │
78+
│ class-dir), matches them with execution data by CRC64 class ID, │
79+
│ and produces a JaCoCo XML coverage report. │
80+
│ │
81+
│ 3. IUploader.upload() │
82+
│ Sends the XML report to the configured destination. │
83+
│ │
84+
└────────────────────────────┬────────────────────────────────────────────┘
85+
86+
┌──────────────┼──────────────┐
87+
▼ ▼ ▼
88+
┌──────────┐ ┌───────────┐ ┌────────────┐
89+
│Teamscale │ │Artifactory│ │Local disk │
90+
│ Server │ │ / Azure │ │ │
91+
└──────────┘ └───────────┘ └────────────┘
92+
```
93+
1494
## Development
1595

1696
Before starting development, please enable the pre-commit hook by running:

agent/src/main/java/com/teamscale/jacoco/agent/Agent.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@
4040
*/
4141
public class Agent extends AgentBase {
4242

43-
/** Converts binary data to XML. */
44-
private final JaCoCoXmlReportGenerator generator;
45-
4643
/** Regular dump task. */
4744
private Timer timer;
4845

@@ -57,9 +54,6 @@ public Agent(AgentOptions options, Instrumentation instrumentation)
5754
uploader = options.createUploader(instrumentation);
5855
logger.info("Upload method: {}", uploader.describe());
5956
retryUnsuccessfulUploads(options, uploader);
60-
generator = new JaCoCoXmlReportGenerator(options.getClassDirectoriesOrZips(),
61-
options.getLocationIncludeFilter(), options.getDuplicateClassFileBehavior(),
62-
options.shouldIgnoreUncoveredClasses(), wrap(logger));
6357

6458
if (options.shouldDumpInIntervals()) {
6559
timer = new Timer(this::dumpReport, Duration.ofMinutes(options.getDumpIntervalInMinutes()));
@@ -165,9 +159,11 @@ private static void deleteDirectoryIfEmpty(Path directory) throws IOException {
165159
/**
166160
* Dumps the current execution data, converts it, writes it to the output directory defined in {@link #options} and
167161
* uploads it if an uploader is configured. Logs any errors, never throws an exception.
162+
* <p>
163+
* Synchronized because this can be triggered concurrently by the timer and by the HTTP /dump endpoint.
168164
*/
169165
@Override
170-
public void dumpReport() {
166+
public synchronized void dumpReport() {
171167
logger.debug("Starting dump");
172168

173169
try {
@@ -188,6 +184,10 @@ private void dumpReportUnsafe() {
188184
return;
189185
}
190186

187+
JaCoCoXmlReportGenerator generator = new JaCoCoXmlReportGenerator(
188+
options.getClassDirectoriesOrZips(), options.getLocationIncludeFilter(),
189+
options.getDuplicateClassFileBehavior(), options.shouldIgnoreUncoveredClasses(), wrap(logger));
190+
191191
try (Benchmark ignored = new Benchmark("Generating the XML report")) {
192192
File outputFile = options.createNewFileInOutputDirectory("jacoco", "xml");
193193
CoverageFile coverageFile = generator.convertSingleDumpToReport(dump, outputFile);

agent/src/main/java/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ public String createJacocoAgentOptions() throws AgentOptionParseException, IOExc
3535
// Don't dump class files in testwise mode when coverage is written to an exec file
3636
boolean needsClassFiles = agentOptions.mode == EMode.NORMAL || agentOptions.testwiseCoverageMode != ETestwiseCoverageMode.EXEC_FILE;
3737
if (agentOptions.classDirectoriesOrZips.isEmpty() && needsClassFiles) {
38-
Path tempDir = createTemporaryDumpDirectory();
39-
tempDir.toFile().deleteOnExit();
40-
builder.append(",classdumpdir=").append(tempDir.toAbsolutePath());
38+
Path classDumpDirectory = createTemporaryDumpDirectory();
39+
classDumpDirectory.toFile().deleteOnExit();
40+
builder.append(",classdumpdir=").append(classDumpDirectory.toAbsolutePath());
4141

42-
agentOptions.classDirectoriesOrZips = Collections.singletonList(tempDir.toFile());
42+
agentOptions.classDirectoriesOrZips = Collections.singletonList(classDumpDirectory.toFile());
4343
}
4444

4545
agentOptions.additionalJacocoOptions

report-generator/src/main/kotlin/com/teamscale/report/compact/CompactCoverageReportGenerator.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,16 @@ class CompactCoverageReportGenerator(
3030
duplicateClassFileBehavior,
3131
true,
3232
logger,
33-
TeamscaleCompactCoverageBuilder()
33+
{ TeamscaleCompactCoverageBuilder() }
3434
) {
3535

3636
/** Creates an XML report based on the given session and coverage data. */
3737
@Throws(IOException::class)
3838
override fun createReport(
3939
output: OutputStream,
4040
sessionInfo: SessionInfo?,
41-
store: ExecutionDataStore
41+
store: ExecutionDataStore,
42+
coverageVisitor: TeamscaleCompactCoverageBuilder
4243
) {
4344
val compactReportData = coverageVisitor.buildReport()
4445
compactReportData.checkForEmptyReport()

report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoBasedReportGenerator.kt

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ import java.io.OutputStream
1717
/**
1818
* Base class for generating reports based on the binary JaCoCo exec dump files.
1919
*
20-
* It takes care of ignoring non-identical classes with the same fully qualified name and classes without coverage.
20+
* JaCoCo's execution data only contains per-class boolean arrays indicating which probes fired. It does not contain
21+
* any structural information (method names, line numbers, branch locations). The class files in
22+
* [codeDirectoriesOrArchives] provide that structural information: JaCoCo re-analyzes them to reconstruct which probes
23+
* correspond to which lines and branches, then merges this with the execution data to produce a meaningful coverage
24+
* report.
25+
*
26+
* Since the same class can appear in multiple archives (for example, a dependency bundled in two WARs), this class
27+
* detects such duplicates via [EnhancedCoverageVisitor] and handles them according to [duplicateClassFileBehavior].
2128
*
2229
* @param codeDirectoriesOrArchives Directories and zip files that contain class files.
2330
* @param locationIncludeFilter Include filter to apply to all locations during class file traversal.
@@ -30,8 +37,16 @@ abstract class JaCoCoBasedReportGenerator<Visitor : ICoverageVisitor>(
3037
private val duplicateClassFileBehavior: EDuplicateClassFileBehavior,
3138
private val ignoreUncoveredClasses: Boolean,
3239
private val logger: ILogger,
33-
/** The coverage visitor which will be called with all the data found in the exec files. */
34-
protected val coverageVisitor: Visitor,
40+
/**
41+
* Supplier that creates a fresh coverage visitor for each dump. This is a supplier rather than
42+
* a plain instance because [EnhancedCoverageVisitor] and the coverage visitor must share the
43+
* same lifecycle: both are created per [convertSingleDumpToReport] call. If the coverage visitor
44+
* were reused across dumps, it would still carry class IDs from a previous dump, and a class
45+
* that reappears with a different CRC64 (for example, after an application server hot-reload)
46+
* would crash inside JaCoCo's CoverageBuilder instead of being handled by our duplicate
47+
* detection in [EnhancedCoverageVisitor].
48+
*/
49+
private val coverageVisitorSupplier: () -> Visitor,
3550
) {
3651

3752
/**
@@ -43,9 +58,10 @@ abstract class JaCoCoBasedReportGenerator<Visitor : ICoverageVisitor>(
4358
fun convertSingleDumpToReport(dump: Dump, outputFilePath: File): CoverageFile {
4459
val coverageFile = CoverageFile(outputFilePath)
4560
val mergedStore = dump.store
46-
analyzeStructureAndAnnotateCoverage(mergedStore)
61+
val coverageVisitor = coverageVisitorSupplier()
62+
analyzeStructureAndAnnotateCoverage(mergedStore, coverageVisitor)
4763
coverageFile.outputStream.use { outputStream ->
48-
createReport(outputStream, dump.info, mergedStore)
64+
createReport(outputStream, dump.info, mergedStore, coverageVisitor)
4965
}
5066
return coverageFile
5167
}
@@ -62,27 +78,48 @@ abstract class JaCoCoBasedReportGenerator<Visitor : ICoverageVisitor>(
6278
convertSingleDumpToReport(Dump(sessionInfo, loader.executionDataStore), outputFilePath)
6379
}
6480

65-
/** Creates an XML report based on the given session and coverage data. */
81+
/**
82+
* Creates an XML report based on the given session and coverage data.
83+
*
84+
* @param coverageVisitor The visitor that was populated during [analyzeStructureAndAnnotateCoverage] for this dump.
85+
* Passed as a parameter (rather than being a field) because a fresh instance is created per dump via
86+
* [coverageVisitorSupplier].
87+
*/
6688
@Throws(IOException::class)
6789
protected abstract fun createReport(
6890
output: OutputStream,
6991
sessionInfo: SessionInfo?,
70-
store: ExecutionDataStore
92+
store: ExecutionDataStore,
93+
coverageVisitor: Visitor
7194
)
7295

7396
/**
74-
* Analyzes the structure of the class files in [.codeDirectoriesOrArchives] and builds an in-memory coverage
97+
* Analyzes the structure of the class files in [codeDirectoriesOrArchives] and builds an in-memory coverage
7598
* report with the coverage in the given store.
99+
*
100+
* We share a single [EnhancedCoverageVisitor] across all entries so that its duplicate-detection map tracks classes
101+
* globally. Without this, the same class appearing in two different archives (for example, old and new WAR after an
102+
* application server reload) would bypass our duplicate handling and hit `CoverageBuilder` directly, which always
103+
* throws `IllegalStateException` regardless of the configured [duplicateClassFileBehavior].
76104
*/
77105
@Throws(IOException::class)
78-
private fun analyzeStructureAndAnnotateCoverage(store: ExecutionDataStore) {
106+
private fun analyzeStructureAndAnnotateCoverage(store: ExecutionDataStore, coverageVisitor: Visitor) {
107+
val visitor = EnhancedCoverageVisitor(coverageVisitor)
79108
codeDirectoriesOrArchives.forEach { file ->
80-
FilteringAnalyzer(store, EnhancedCoverageVisitor(), locationIncludeFilter, logger)
109+
FilteringAnalyzer(store, visitor, locationIncludeFilter, logger)
81110
.analyzeAll(file)
82111
}
83112
}
84113

85-
private inner class EnhancedCoverageVisitor : ICoverageVisitor {
114+
/**
115+
* Wrapper around the actual coverage visitor (typically JaCoCo's [org.jacoco.core.analysis.CoverageBuilder] or
116+
* [com.teamscale.report.compact.TeamscaleCompactCoverageBuilder]) that intercepts duplicate, non-identical class
117+
* files before they reach the wrapped visitor. Without this, duplicates would hit `CoverageBuilder` directly,
118+
* which always throws `IllegalStateException` regardless of the configured [duplicateClassFileBehavior].
119+
*/
120+
private inner class EnhancedCoverageVisitor(
121+
private val coverageVisitor: Visitor
122+
) : ICoverageVisitor {
86123

87124
private val classIdByClassName: MutableMap<String, Long> = mutableMapOf()
88125

@@ -95,6 +132,7 @@ abstract class JaCoCoBasedReportGenerator<Visitor : ICoverageVisitor>(
95132
warnAboutDuplicateClassFile(coverage)
96133
return
97134
}
135+
98136
coverageVisitor.visitCoverage(coverage)
99137
}
100138

report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ class JaCoCoXmlReportGenerator(
3434
duplicateClassFileBehavior,
3535
ignoreUncoveredClasses,
3636
logger,
37-
CoverageBuilder()
37+
{ CoverageBuilder() }
3838
) {
3939

4040
/** Creates an XML report based on the given session and coverage data. */
4141
@Throws(IOException::class)
4242
override fun createReport(
4343
output: OutputStream,
4444
sessionInfo: SessionInfo?,
45-
store: ExecutionDataStore
45+
store: ExecutionDataStore,
46+
coverageVisitor: CoverageBuilder
4647
) {
4748
val bundleCoverage = BundleCoverageImpl("dummybundle", emptyList(), coverageVisitor.sourceFiles)
4849
bundleCoverage.checkForEmptyReport()

0 commit comments

Comments
 (0)