Skip to content

Commit c7525c6

Browse files
committed
fix: Prevent crash when test fails on a different thread
When a test fails due to an exception thrown on a different thread, the test class itself may not appear in the exceptions stack trace. The agent was previously unable to handle this scenario, crashing with a `java.lang.InternalError` because it could not find a stack frame matching the test class. This commit introduces a more robust heuristic to find the most likely source of the failure. If an exact match for the test class is not found, the agent now falls back to the stack frame with the longest common package prefix. This prevents the crash and correctly identifies the failure location in a framework-agnostic way. A regression test has been added to simulate this cross-thread exception scenario and verify the fix.
1 parent f0df793 commit c7525c6

File tree

3 files changed

+88
-8
lines changed

3 files changed

+88
-8
lines changed

agent/src/main/java/com/appland/appmap/process/hooks/test/TestSupport.java

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,61 @@ private static void startRecording(TestDetails details, Recorder.Metadata metada
6363
RecordingSupport.startRecording(details, metadata);
6464
}
6565

66+
/**
67+
* Finds the most relevant stack frame for a test failure.
68+
*
69+
* The primary goal is to find the stack frame that corresponds to the test
70+
* class itself. However, in some scenarios (e.g., when an assertion fails on
71+
* a different thread), the test class may not be in the stack trace at all.
72+
*
73+
* To handle this, we use a fallback heuristic: we find the stack frame that
74+
* has the longest common package prefix with the test class. This is usually
75+
* the entry point into the user's code and the most likely source of the
76+
* failure.
77+
*
78+
* @param self The test class instance
79+
* @param exception The exception that caused the failure
80+
* @return The most relevant stack frame
81+
* @throws InternalError if no suitable stack frame can be found
82+
*/
6683
static StackTraceElement findErrorFrame(Object self, Throwable exception) throws InternalError {
6784
String selfClass = self.getClass().getName();
68-
StackTraceElement errorFrame = null;
85+
StackTraceElement bestMatch = null;
86+
int bestMatchLength = 0;
87+
6988
for (StackTraceElement frame : exception.getStackTrace()) {
70-
if (frame.getClassName().equals(selfClass)) {
71-
errorFrame = frame;
72-
break;
89+
final String frameClassName = frame.getClassName();
90+
if (frameClassName.equals(selfClass)) {
91+
// This is the ideal case: we found the test class in the stack trace.
92+
return frame;
7393
}
94+
95+
int commonPrefix = commonPrefixLength(selfClass, frameClassName);
96+
if (commonPrefix >= bestMatchLength) {
97+
// We use >= to get the last best match, which is the most likely to be
98+
// the entry point into the user's code.
99+
bestMatch = frame;
100+
bestMatchLength = commonPrefix;
101+
}
102+
}
103+
104+
if (bestMatch != null) {
105+
// We didn't find the test class, but we have a good fallback.
106+
return bestMatch;
74107
}
75-
if (errorFrame == null) {
76-
throw new InternalError("no stack frame matched test class");
108+
109+
// This can happen if the exception has an empty stack trace, which is rare
110+
// but possible.
111+
throw new InternalError("no stack frame matched test class");
112+
}
113+
114+
private static int commonPrefixLength(String s1, String s2) {
115+
int len = Math.min(s1.length(), s2.length());
116+
int i = 0;
117+
while (i < len && s1.charAt(i) == s2.charAt(i)) {
118+
i++;
77119
}
78-
return errorFrame;
120+
return i;
79121
}
80122

81123
private static boolean hasTestAnnotation(ClassLoader cl, Method stackMethod) {

agent/test/test-frameworks/frameworks.bats

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ run_framework_test() {
5252

5353
assert_json_eq '.metadata.test_status' "failed"
5454
assert_json_contains '.metadata.test_failure.message' 'false is not true'
55-
assert_json_eq '.metadata.test_failure.location' "src/test/java/org/springframework/samples/petclinic/JunitTests.java:20"
55+
assert_json_eq '.metadata.test_failure.location' "src/test/java/org/springframework/samples/petclinic/JunitTests.java:23"
5656
}
5757

5858
@test "test status set for failed test in testng" {
@@ -104,4 +104,12 @@ run_framework_test() {
104104

105105
output="$(< tmp/appmap/testng/org_springframework_samples_petclinic_TestngTests_testItThrows.appmap.json)"
106106
assert_json_eq '.metadata.test_status' "succeeded"
107+
}
108+
109+
@test "No InternalError on different thread exception" {
110+
run_framework_test "junit" "JunitTests.offThreadExceptionTest"
111+
assert_failure
112+
# The test should fail with a RuntimeException, but not an InternalError
113+
assert_output --partial "java.lang.RuntimeException"
114+
refute_output --partial "java.lang.InternalError"
107115
}

agent/test/test-frameworks/src/test/java/org/springframework/samples/petclinic/JunitTests.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package org.springframework.samples.petclinic;
22

3+
import static java.util.concurrent.Executors.newSingleThreadExecutor;
34
import static org.junit.Assert.assertTrue;
45

6+
import java.util.concurrent.Future;
7+
import java.util.concurrent.ExecutorService;
58
import com.appland.appmap.annotation.NoAppMap;
69
import org.junit.Test;
710

@@ -38,4 +41,31 @@ public void testAnnotatedClassNotRecorded() {
3841
}
3942
}
4043

44+
private static class ExecutorRunner {
45+
public Throwable run() {
46+
ExecutorService executor = newSingleThreadExecutor();
47+
Future<?> future = executor.submit(() -> {
48+
throw new RuntimeException("Off-thread exception for testing");
49+
});
50+
try {
51+
future.get();
52+
} catch (java.util.concurrent.ExecutionException e) {
53+
return e.getCause();
54+
} catch (InterruptedException e) {
55+
Thread.currentThread().interrupt();
56+
} finally {
57+
executor.shutdown();
58+
}
59+
return null;
60+
}
61+
}
62+
63+
@Test
64+
public void offThreadExceptionTest() throws Throwable {
65+
Throwable throwable = new ExecutorRunner().run();
66+
if (throwable == null) {
67+
throw new AssertionError("Expected exception from off-thread execution");
68+
}
69+
throw throwable;
70+
}
4171
}

0 commit comments

Comments
 (0)