Skip to content

Commit cb910ef

Browse files
committed
Initial implementation of @DetectThreadLeaks annotation.
1 parent 4ef7b03 commit cb910ef

5 files changed

Lines changed: 307 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.carrotsearch.randomizedtesting.jupiter;
2+
3+
import java.lang.annotation.Documented;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Inherited;
6+
import java.lang.annotation.Retention;
7+
import java.lang.annotation.RetentionPolicy;
8+
import java.lang.annotation.Target;
9+
import org.junit.jupiter.api.extension.ExtendWith;
10+
11+
/**
12+
* Detects threads started within the annotated test class that are still alive after the configured
13+
* scope ends.
14+
*
15+
* <p>Only functional in sequential (same-thread) execution mode. Emits a warning and skips
16+
* detection if tests run concurrently.
17+
*/
18+
@Target({ElementType.TYPE})
19+
@Retention(RetentionPolicy.RUNTIME)
20+
@Documented
21+
@ExtendWith(DetectThreadLeaksExtension.class)
22+
@Inherited
23+
public @interface DetectThreadLeaks {
24+
/** Scope at which thread leak detection is performed. */
25+
Scope scope() default Scope.SUITE;
26+
27+
enum Scope {
28+
/** Check for leaked threads once after all tests in the class complete. */
29+
SUITE,
30+
/** Check for leaked threads after each individual test method. */
31+
TEST
32+
}
33+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.carrotsearch.randomizedtesting.jupiter;
2+
3+
import java.util.HashSet;
4+
import java.util.logging.Logger;
5+
import java.util.stream.Collectors;
6+
import org.junit.jupiter.api.extension.AfterAllCallback;
7+
import org.junit.jupiter.api.extension.AfterEachCallback;
8+
import org.junit.jupiter.api.extension.BeforeAllCallback;
9+
import org.junit.jupiter.api.extension.BeforeEachCallback;
10+
import org.junit.jupiter.api.extension.ExtensionContext;
11+
import org.junit.jupiter.api.parallel.ExecutionMode;
12+
13+
/** JUnit Jupiter extension implementing {@link DetectThreadLeaks}. */
14+
public class DetectThreadLeaksExtension
15+
implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
16+
17+
private static final Logger LOGGER = Logger.getLogger(DetectThreadLeaksExtension.class.getName());
18+
private static final ExtensionContext.Namespace EXTENSION_NAMESPACE =
19+
ExtensionContext.Namespace.create(DetectThreadLeaksExtension.class);
20+
private static final String SNAPSHOT_KEY = "snapshot";
21+
private static final String CONCURRENT_KEY = "concurrent";
22+
23+
@Override
24+
public void beforeAll(ExtensionContext context) {
25+
if (context.getExecutionMode() != ExecutionMode.SAME_THREAD) {
26+
LOGGER.warning(
27+
"Thread leak detection is disabled: tests in ["
28+
+ context.getDisplayName()
29+
+ "] run in concurrent execution mode.");
30+
context.getStore(EXTENSION_NAMESPACE).put(CONCURRENT_KEY, Boolean.TRUE);
31+
return;
32+
}
33+
if (scope(context) == DetectThreadLeaks.Scope.SUITE) {
34+
context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads());
35+
}
36+
}
37+
38+
@Override
39+
public void afterAll(ExtensionContext context) {
40+
if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.SUITE) {
41+
return;
42+
}
43+
checkLeaks(context.getStore(EXTENSION_NAMESPACE), "suite [" + context.getDisplayName() + "]");
44+
}
45+
46+
@Override
47+
public void beforeEach(ExtensionContext context) {
48+
if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) {
49+
return;
50+
}
51+
context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads());
52+
}
53+
54+
@Override
55+
public void afterEach(ExtensionContext context) {
56+
if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) {
57+
return;
58+
}
59+
checkLeaks(context.getStore(EXTENSION_NAMESPACE), "test [" + context.getDisplayName() + "]");
60+
}
61+
62+
private static DetectThreadLeaks.Scope scope(ExtensionContext context) {
63+
return context.getRequiredTestClass().getAnnotation(DetectThreadLeaks.class).scope();
64+
}
65+
66+
private static boolean isConcurrentMode(ExtensionContext context) {
67+
// Check the concurrent flag stored in beforeAll (class-level context = parent of method ctx).
68+
return context
69+
.getParent()
70+
.map(
71+
p ->
72+
Boolean.TRUE.equals(
73+
p.getStore(EXTENSION_NAMESPACE).get(CONCURRENT_KEY, Boolean.class)))
74+
.orElse(false);
75+
}
76+
77+
private static void checkLeaks(ExtensionContext.Store store, String description) {
78+
var snapshot = store.get(SNAPSHOT_KEY, HashSet.class);
79+
if (snapshot == null) return;
80+
81+
var leaked = liveThreads();
82+
leaked.removeAll(snapshot);
83+
leaked.removeIf(t -> !t.isAlive());
84+
85+
if (!leaked.isEmpty()) {
86+
var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":");
87+
leaked.forEach(t -> sb.append("\n ").append(Threads.threadName(t)));
88+
throw new AssertionError(sb.toString());
89+
}
90+
}
91+
92+
private static HashSet<Thread> liveThreads() {
93+
return Thread.getAllStackTraces().keySet().stream()
94+
.filter(Thread::isAlive)
95+
.filter(t -> !isKnownSystemThread(t))
96+
.collect(Collectors.toCollection(HashSet::new));
97+
}
98+
99+
private static boolean isKnownSystemThread(Thread t) {
100+
ThreadGroup tgroup = t.getThreadGroup();
101+
102+
if (tgroup != null && "system".equals(tgroup.getName()) && tgroup.getParent() == null) {
103+
return true;
104+
}
105+
106+
return switch (t.getName()) {
107+
case "JFR request timer",
108+
"YJPAgent-Telemetry",
109+
"MemoryPoolMXBean notification dispatcher",
110+
"AWT-AppKit",
111+
"process reaper",
112+
"JUnit5-serializer-daemon" ->
113+
true;
114+
default -> t.getName().contains("Poller SunPKCS11");
115+
};
116+
}
117+
}

randomizedtesting-jupiter/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module com.carrotsearch.randomizedtesting {
22
requires org.junit.jupiter.api;
33
requires org.junit.jupiter.params;
4+
requires java.logging;
45

56
exports com.carrotsearch.randomizedtesting.jupiter;
67

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.carrotsearch.randomizedtesting.jupiter;
2+
3+
import static com.carrotsearch.randomizedtesting.jupiter.infra.TestInfra.*;
4+
import static org.junit.platform.testkit.engine.EventConditions.*;
5+
import static org.junit.platform.testkit.engine.TestExecutionResultConditions.*;
6+
7+
import com.carrotsearch.randomizedtesting.jupiter.infra.IgnoreInStandaloneRuns;
8+
import java.util.concurrent.TimeUnit;
9+
import java.util.stream.Stream;
10+
import org.junit.jupiter.api.AfterAll;
11+
import org.junit.jupiter.api.BeforeAll;
12+
import org.junit.jupiter.api.DynamicTest;
13+
import org.junit.jupiter.api.Nested;
14+
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.api.TestFactory;
16+
import org.junit.jupiter.api.parallel.Execution;
17+
import org.junit.jupiter.api.parallel.ExecutionMode;
18+
19+
/** Verify that {@link DetectThreadLeaks} detects threads leaked from tests. */
20+
public class F005_ThreadLeaks {
21+
@Nested
22+
class TestSuiteScope {
23+
@TestFactory
24+
Stream<DynamicTest> leakedThreadIsDetectedAtSuiteEnd() {
25+
return Stream.of(
26+
LeakInBeforeAllMethod.class, LeakInTestMethod.class, LeakInAfterAllMethod.class)
27+
.map(
28+
clazz ->
29+
DynamicTest.dynamicTest(
30+
clazz.getSimpleName(),
31+
() -> {
32+
collectExecutionResults(testKitBuilder(clazz))
33+
.results()
34+
.allEvents()
35+
.finished()
36+
.failed()
37+
.assertEventsMatchExactly(
38+
event(finishedWithFailure(instanceOf(AssertionError.class))));
39+
}));
40+
}
41+
42+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE)
43+
static class LeakInTestMethod extends IgnoreInStandaloneRuns {
44+
@Test
45+
void testMethod() {
46+
startSleepingThread();
47+
}
48+
}
49+
50+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE)
51+
static class LeakInAfterAllMethod extends IgnoreInStandaloneRuns {
52+
@Test
53+
void testMethod() {}
54+
55+
@AfterAll
56+
static void afterAll() {
57+
startSleepingThread();
58+
}
59+
}
60+
61+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE)
62+
static class LeakInBeforeAllMethod extends IgnoreInStandaloneRuns {
63+
@Test
64+
void testMethod() {}
65+
66+
@BeforeAll
67+
static void beforeAll() {
68+
startSleepingThread();
69+
}
70+
}
71+
}
72+
73+
@Nested
74+
class TestTestScope {
75+
@Test
76+
void leakedThreadIsDetectedAfterTest() {
77+
collectExecutionResults(testKitBuilder(TestScopeWithLeak.class))
78+
.results()
79+
.allEvents()
80+
.finished()
81+
.failed()
82+
.assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class))));
83+
}
84+
85+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST)
86+
static class TestScopeWithLeak extends IgnoreInStandaloneRuns {
87+
@Test
88+
void testMethod() {
89+
startSleepingThread();
90+
}
91+
}
92+
}
93+
94+
@Nested
95+
class TestConcurrentMode {
96+
@Test
97+
void leakedThreadDoesNotFailInConcurrentMode() {
98+
// In concurrent mode the extension is disabled: no AssertionErrors, even with a leak.
99+
collectExecutionResults(testKitBuilder(ConcurrentWithLeak.class))
100+
.results()
101+
.allEvents()
102+
.assertThatEvents()
103+
.doNotHave(event(finishedWithFailure()));
104+
}
105+
106+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST)
107+
@Execution(ExecutionMode.CONCURRENT)
108+
static class ConcurrentWithLeak extends IgnoreInStandaloneRuns {
109+
@Test
110+
void testMethod() {
111+
startSleepingThread();
112+
}
113+
}
114+
}
115+
116+
/** Starts a daemon thread that sleeps long enough to be observable as a leak. */
117+
private static void startSleepingThread() {
118+
var t =
119+
new Thread(
120+
() -> {
121+
try {
122+
Thread.sleep(TimeUnit.MINUTES.toMillis(1));
123+
} catch (InterruptedException ignored) {
124+
}
125+
});
126+
t.setDaemon(true);
127+
t.start();
128+
}
129+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Feature: detecting tests that "leak" threads
2+
3+
## Functionality
4+
5+
* It should be possible to add a `@DetectThreadLeaks` extension which detects new threads forked within the test
6+
container. This extension takes a single parameter - the scope of detection. Either we care about threads leaked
7+
from the entire container or from each individual test. Here is an example of use:
8+
9+
```java
10+
11+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE)
12+
public class TestClass {
13+
@Test
14+
public void testMethod() {
15+
new Thread(() -> {
16+
try { Thread.sleep(1000); } catch (Exception e) {}
17+
}).start();
18+
}
19+
}
20+
```
21+
22+
* The extension is only functional in sequential mode. It should emit a warning and do nothing if tests are
23+
run in concurrent mode.
24+
25+
## Migration notes (from randomizedtesting for junit4)
26+
27+
*

0 commit comments

Comments
 (0)