Skip to content

Commit 10806f8

Browse files
committed
TS-45056 Rework
1 parent d04633f commit 10806f8

7 files changed

Lines changed: 72 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +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
9-
- [feature] _agent_: Automatically detects application server reloads (for example, JBoss reload) and clears stale class files from the dump directory
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
109
- [feature] _teamscale-gradle-plugin_: Annotated tasks with `@DisableCachingByDefault` where caching can't be applied
1110

1211
# 36.4.0

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

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,13 @@
2121
import org.glassfish.jersey.server.ResourceConfig;
2222
import org.glassfish.jersey.server.ServerProperties;
2323

24-
import org.jacoco.core.data.ExecutionData;
25-
2624
import java.io.File;
2725
import java.io.IOException;
2826
import java.lang.instrument.Instrumentation;
2927
import java.nio.file.Files;
3028
import java.nio.file.Path;
3129
import java.time.Duration;
32-
import java.util.HashMap;
3330
import java.util.List;
34-
import java.util.Map;
3531
import java.util.Properties;
3632
import java.util.stream.Stream;
3733

@@ -50,12 +46,6 @@ public class Agent extends AgentBase {
5046
/** Stores the XML files. */
5147
protected final IUploader uploader;
5248

53-
/**
54-
* Tracks class IDs (CRC64) by class name across dumps to detect application server reloads. When a known class
55-
* name appears with a different ID, we know the class was reloaded with different bytecode.
56-
*/
57-
private final Map<String, Long> previousClassIds = new HashMap<>();
58-
5949
/** Constructor. */
6050
public Agent(AgentOptions options, Instrumentation instrumentation)
6151
throws IllegalStateException, UploaderException {
@@ -192,8 +182,6 @@ private void dumpReportUnsafe() {
192182
return;
193183
}
194184

195-
clearClassDumpDirectoryIfReloadDetected(dump);
196-
197185
JaCoCoXmlReportGenerator generator = new JaCoCoXmlReportGenerator(
198186
options.getClassDirectoriesOrZips(), options.getLocationIncludeFilter(),
199187
options.getDuplicateClassFileBehavior(), options.shouldIgnoreUncoveredClasses(), wrap(logger));
@@ -208,40 +196,4 @@ private void dumpReportUnsafe() {
208196
logger.error("No coverage was collected. " + e.getMessage(), e);
209197
}
210198
}
211-
212-
/**
213-
* Detects application server reloads by comparing class IDs (CRC64) across dumps. When a class that was
214-
* previously seen with one ID now appears with a different ID, it means the class was reloaded with different
215-
* bytecode (for example, an application server reload such as JBoss reload). In that case, the auto-created class
216-
* dump directory is cleared so that stale class files from the old deployment are removed.
217-
*
218-
* <p>This correctly distinguishes reloads from normal multi-classloader setups: in a multi-classloader setup,
219-
* both class IDs are present from the first dump onward, so no ID change is detected on subsequent dumps.
220-
*/
221-
private void clearClassDumpDirectoryIfReloadDetected(Dump dump) {
222-
boolean reloadDetected = false;
223-
for (ExecutionData data : dump.getStore().getContents()) {
224-
Long previousId = previousClassIds.put(data.getName(), data.getId());
225-
if (previousId != null && previousId != data.getId()) {
226-
reloadDetected = true;
227-
}
228-
}
229-
230-
if (!reloadDetected) {
231-
return;
232-
}
233-
234-
logger.info("Detected application server reload: class files were reloaded with different bytecode. "
235-
+ "Clearing the class dump directory to remove stale class files from the previous deployment.");
236-
237-
Path classDumpDir = options.getClassDumpDirectory();
238-
if (classDumpDir != null) {
239-
FileSystemUtils.deleteRecursively(classDumpDir.toFile());
240-
try {
241-
Files.createDirectories(classDumpDir);
242-
} catch (IOException e) {
243-
logger.error("Failed to recreate class dump directory after reload detection", e);
244-
}
245-
}
246-
}
247199
}

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

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,6 @@ public class AgentOptions {
110110
*/
111111
/* package */ List<File> classDirectoriesOrZips = new ArrayList<>();
112112

113-
/**
114-
* The auto-created class dump directory, or null if the user specified class-dir explicitly.
115-
* Used to clear stale class files when an application server reload is detected.
116-
*/
117-
/* package */ Path classDumpDirectory = null;
118-
119113
/**
120114
* The logging configuration file.
121115
*/
@@ -633,13 +627,6 @@ public List<File> getClassDirectoriesOrZips() {
633627
return classDirectoriesOrZips;
634628
}
635629

636-
/**
637-
* @see #classDumpDirectory
638-
*/
639-
public Path getClassDumpDirectory() {
640-
return classDumpDirectory;
641-
}
642-
643630
/** @see #teamscaleServer */
644631
public TeamscaleServer getTeamscaleServerOptions() {
645632
return teamscaleServer;

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,14 @@ public String createJacocoAgentOptions() throws AgentOptionParseException, IOExc
3232
builder.append(",excludes=").append(agentOptions.jacocoExcludes);
3333
}
3434

35-
// In EXEC_FILE mode, the agent writes raw execution data directly and doesn't
36-
// convert it to a coverage report, so it doesn't need class files for probe-to-source mapping.
37-
// The conversion happens externally, where class files are already available.
35+
// Don't dump class files in testwise mode when coverage is written to an exec file
3836
boolean needsClassFiles = agentOptions.mode == EMode.NORMAL || agentOptions.testwiseCoverageMode != ETestwiseCoverageMode.EXEC_FILE;
3937
if (agentOptions.classDirectoriesOrZips.isEmpty() && needsClassFiles) {
4038
Path classDumpDirectory = createTemporaryDumpDirectory();
4139
classDumpDirectory.toFile().deleteOnExit();
4240
builder.append(",classdumpdir=").append(classDumpDirectory.toAbsolutePath());
4341

4442
agentOptions.classDirectoriesOrZips = Collections.singletonList(classDumpDirectory.toFile());
45-
agentOptions.classDumpDirectory = classDumpDirectory;
4643
}
4744

4845
agentOptions.additionalJacocoOptions

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

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,10 @@ abstract class JaCoCoBasedReportGenerator<Visitor : ICoverageVisitor>(
7474
* Analyzes the structure of the class files in [codeDirectoriesOrArchives] and builds an in-memory coverage
7575
* report with the coverage in the given store.
7676
*
77-
* [codeDirectoriesOrArchives] is populated in one of two ways:
78-
* - **Auto-created dump directory**: When the user does not specify `class-dir`, JaCoCo is configured with a
79-
* `classdumpdir` pointing to a temporary directory (for example,
80-
* `teamscale-java-profiler-<pid>-<random>/jacoco-class-dump`). JaCoCo writes each instrumented class file there
81-
* at load time, using the class's internal name as the file path (for example, `com/example/MyClass.class`). If a
82-
* class is reloaded (for example, after an application server reload), the new version overwrites the old file. This
83-
* typically results in a single directory entry.
84-
* - **User-specified `class-dir`**: The user points directly at directories or archives (JARs, WARs, EARs)
85-
* containing their application's class files. This may result in multiple entries, and the same class can appear
86-
* in several archives, for example, when both an old and a new WAR coexist during an application server reload.
87-
*
8877
* We share a single [EnhancedCoverageVisitor] across all entries so that its duplicate-detection map tracks classes
89-
* globally. Without this, the same class appearing in two different archives (for example, old and new WAR) would
90-
* bypass our duplicate handling and hit `CoverageBuilder` directly, which always throws `IllegalStateException`
91-
* regardless of the configured [duplicateClassFileBehavior].
78+
* globally. Without this, the same class appearing in two different archives (for example, old and new WAR after an
79+
* application server reload) would bypass our duplicate handling and hit `CoverageBuilder` directly, which always
80+
* throws `IllegalStateException` regardless of the configured [duplicateClassFileBehavior].
9281
*/
9382
@Throws(IOException::class)
9483
private fun analyzeStructureAndAnnotateCoverage(store: ExecutionDataStore) {

report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,38 @@ class JaCoCoXmlReportGeneratorTest : TestDataBase() {
7777
}
7878
}
7979

80+
/**
81+
* Ensures that two non-identical, duplicate classes in separate archives (directories) do not cause an exception
82+
* with the default WARN behavior. This tests the scenario where the same class exists in multiple JARs/WARs,
83+
* for example, during an application server reload where both old and new deployments coexist.
84+
*/
85+
@Test
86+
fun testDuplicateClassesAcrossSeparateArchivesShouldNotCrashWithWarn() {
87+
try {
88+
runGeneratorWithMultipleArchives(
89+
"different-duplicate-classes",
90+
EDuplicateClassFileBehavior.WARN
91+
)
92+
} catch (e: EmptyReportException) {
93+
// Expected: we don't configure matching class IDs. We only verify no IllegalStateException is thrown.
94+
}
95+
}
96+
97+
/**
98+
* Ensures that two non-identical, duplicate classes in separate archives (directories) cause an exception
99+
* when FAIL behavior is configured.
100+
*/
101+
@Test
102+
fun testDuplicateClassesAcrossSeparateArchivesShouldThrowWithFail() {
103+
assertThatThrownBy {
104+
runGeneratorWithMultipleArchives(
105+
"different-duplicate-classes",
106+
EDuplicateClassFileBehavior.FAIL
107+
)
108+
}.isExactlyInstanceOf(IOException::class.java)
109+
.hasCauseExactlyInstanceOf(IllegalStateException::class.java)
110+
}
111+
80112
@Test
81113
fun testEmptyCoverageFileThrowsException() {
82114
val testFolderName = "empty-report-handling"
@@ -166,6 +198,31 @@ class JaCoCoXmlReportGeneratorTest : TestDataBase() {
166198
).convertSingleDumpToReport(dump, Paths.get(outputFilePath).toFile())
167199
}
168200

201+
/**
202+
* Runs the report generator with each subdirectory of the test data folder as a separate archive entry.
203+
* This simulates the case where the same class exists in multiple JARs/WARs (for example, after an application
204+
* server reload where both old and new deployments coexist on the classpath).
205+
*/
206+
@Throws(IOException::class, EmptyReportException::class)
207+
private fun runGeneratorWithMultipleArchives(
208+
testDataFolder: String,
209+
duplicateClassFileBehavior: EDuplicateClassFileBehavior,
210+
dump: Dump = createDummyDump()
211+
): CoverageFile {
212+
val parentFolder = useTestFile(testDataFolder)
213+
val subdirectories = parentFolder.listFiles { file -> file.isDirectory }?.toList()
214+
?: error("No subdirectories found in $parentFolder")
215+
val currentTime = System.currentTimeMillis()
216+
val outputFilePath = "test-coverage-multi-archive-$currentTime.xml"
217+
return JaCoCoXmlReportGenerator(
218+
subdirectories,
219+
ClasspathWildcardIncludeFilter(null, null),
220+
duplicateClassFileBehavior,
221+
false,
222+
Mockito.mock()
223+
).convertSingleDumpToReport(dump, Paths.get(outputFilePath).toFile())
224+
}
225+
169226
companion object {
170227
/**
171228
* Creates a fake dump with the specified class ID. The class ID can currently be calculated with [org.jacoco.core.internal.data.CRC64.classId].

system-tests/reload-detection-test/src/test/kotlin/com/teamscale/systemtest/ReloadDetectionSystemTest.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,22 @@ import org.junit.jupiter.api.Test
99
import systemundertest.SystemUnderTest
1010

1111
/**
12-
* Tests that the agent handles application server reloads correctly:
13-
* 1. Multiple sequential dumps work without crashing (fresh CoverageBuilder per dump).
14-
* 2. When a class is reloaded with different bytecode, the agent detects this and clears the class dump directory.
12+
* Tests that the agent handles class reloads (for example, JBoss reload) correctly. When a class is reloaded with
13+
* different bytecode, JaCoCo assigns a different class ID (CRC64). With a shared report generator, this would cause
14+
* an `IllegalStateException: Can't add different class with same name` because the `CoverageBuilder` still holds the
15+
* old class ID from a previous dump. The fix is to create a fresh report generator per dump so the `CoverageBuilder`
16+
* never carries state across dumps.
1517
*
1618
* The reload is simulated by loading a modified copy of the [SystemUnderTest] class through a custom classloader,
17-
* which causes JaCoCo to create execution data with a different class ID (CRC64) for the same class name.
19+
* which causes JaCoCo to create execution data with a different class ID for the same class name.
1820
*/
1921
class ReloadDetectionSystemTest {
2022

2123
@Test
22-
fun multipleDumpsAndReloadDetection() {
24+
fun dumpAfterClassReloadDoesNotCrash() {
2325
val teamscaleMockServer = TeamscaleMockServer(SystemTestUtils.TEAMSCALE_PORT).acceptingReportUploads()
2426

25-
// First dump: generate coverage and establish baseline class IDs
27+
// First dump: generate coverage
2628
SystemUnderTest().foo()
2729
dumpCoverage(SystemTestUtils.AGENT_PORT)
2830

@@ -31,8 +33,8 @@ class ReloadDetectionSystemTest {
3133
// entry with a different CRC64 for the same class name.
3234
loadModifiedSystemUnderTest()
3335

34-
// Generate more coverage and dump again. The agent should detect the reload
35-
// (different class ID for the same name) and handle it without crashing.
36+
// Generate more coverage and dump again. Because the agent creates a fresh report generator
37+
// (and thus a fresh CoverageBuilder) per dump, the changed class ID does not cause a crash.
3638
SystemUnderTest().bar()
3739
dumpCoverage(SystemTestUtils.AGENT_PORT)
3840

@@ -65,7 +67,6 @@ class ReloadDetectionSystemTest {
6567
modifiedBytes[markerIndex + marker.size - 1] = 'x'.code.toByte()
6668

6769
val classLoader = object : ClassLoader(ClassLoader.getSystemClassLoader()) {
68-
/** Defines the modified class bytecode in this class loader, triggering a class reload. */
6970
fun defineModifiedClass(): Class<*> {
7071
return defineClass("systemundertest.SystemUnderTest", modifiedBytes, 0, modifiedBytes.size)
7172
}

0 commit comments

Comments
 (0)