Skip to content

Commit 922d740

Browse files
committed
Thread leaks and thread filters/ exclusions.
1 parent 2f1b8fb commit 922d740

3 files changed

Lines changed: 177 additions & 13 deletions

File tree

randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.lang.annotation.Retention;
77
import java.lang.annotation.RetentionPolicy;
88
import java.lang.annotation.Target;
9+
import java.util.function.Predicate;
910
import org.junit.jupiter.api.extension.ExtendWith;
1011

1112
/**
@@ -35,7 +36,8 @@ enum Scope {
3536
* Milliseconds to wait for leaked threads to self-terminate before declaring a failure. If all
3637
* leaked threads terminate within this window, the test passes. Default is 0 (no lingering).
3738
*
38-
* <p>Place this annotation on the same class as {@link DetectThreadLeaks}.
39+
* <p>Place this annotation on the same class or method as {@link DetectThreadLeaks}. A
40+
* method-level annotation takes precedence over a class-level one.
3941
*/
4042
@Target({ElementType.TYPE, ElementType.METHOD})
4143
@Retention(RetentionPolicy.RUNTIME)
@@ -44,4 +46,19 @@ enum Scope {
4446
@interface LingerTime {
4547
int millis();
4648
}
49+
50+
/**
51+
* Excludes threads matched by any of the given {@link Predicate} classes from leak detection. A
52+
* thread is excluded when at least one predicate returns {@code true} for it.
53+
*
54+
* <p>Annotations are collected hierarchically: the test method, then the class, then each
55+
* superclass, and the filters from all levels are combined. Place on the same class or method as
56+
* {@link DetectThreadLeaks}.
57+
*/
58+
@Target({ElementType.TYPE, ElementType.METHOD})
59+
@Retention(RetentionPolicy.RUNTIME)
60+
@Documented
61+
@interface ExcludeThreads {
62+
Class<? extends Predicate<Thread>>[] value();
63+
}
4764
}

randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.carrotsearch.randomizedtesting.jupiter;
22

3+
import java.util.Arrays;
34
import java.util.HashSet;
5+
import java.util.LinkedHashSet;
46
import java.util.Map;
57
import java.util.concurrent.TimeUnit;
8+
import java.util.function.Predicate;
69
import java.util.logging.Logger;
710
import java.util.stream.Collectors;
811
import org.junit.jupiter.api.extension.AfterAllCallback;
@@ -36,7 +39,7 @@ public void beforeAll(ExtensionContext context) {
3639
return;
3740
}
3841
if (scope(context) == DetectThreadLeaks.Scope.SUITE) {
39-
context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads());
42+
context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads(buildFilter(context)));
4043
}
4144
}
4245

@@ -48,15 +51,16 @@ public void afterAll(ExtensionContext context) {
4851
checkLeaks(
4952
context.getStore(EXTENSION_NAMESPACE),
5053
"suite [" + context.getDisplayName() + "]",
51-
linger(context));
54+
linger(context),
55+
buildFilter(context));
5256
}
5357

5458
@Override
5559
public void beforeEach(ExtensionContext context) {
5660
if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) {
5761
return;
5862
}
59-
context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads());
63+
context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads(buildFilter(context)));
6064
}
6165

6266
@Override
@@ -67,7 +71,8 @@ public void afterEach(ExtensionContext context) {
6771
checkLeaks(
6872
context.getStore(EXTENSION_NAMESPACE),
6973
"test [" + context.getDisplayName() + "]",
70-
linger(context));
74+
linger(context),
75+
buildFilter(context));
7176
}
7277

7378
private static DetectThreadLeaks.Scope scope(ExtensionContext context) {
@@ -87,6 +92,51 @@ private static int linger(ExtensionContext context) {
8792
return classAnn == null ? 0 : classAnn.millis();
8893
}
8994

95+
/**
96+
* Collects {@link DetectThreadLeaks.ExcludeThreads} filter classes from the entire hierarchy
97+
* (method → class → superclasses) and returns a combined predicate that excludes a thread when
98+
* any filter matches it.
99+
*/
100+
private static Predicate<Thread> buildFilter(ExtensionContext context) {
101+
var filterClasses = new LinkedHashSet<Class<? extends Predicate<Thread>>>();
102+
103+
context
104+
.getTestMethod()
105+
.ifPresent(
106+
m -> {
107+
var ann = m.getAnnotation(DetectThreadLeaks.ExcludeThreads.class);
108+
if (ann != null) {
109+
for (var c : ann.value()) filterClasses.add(c);
110+
}
111+
});
112+
113+
for (Class<?> cls = context.getRequiredTestClass(); cls != null; cls = cls.getSuperclass()) {
114+
var ann = cls.getAnnotation(DetectThreadLeaks.ExcludeThreads.class);
115+
if (ann != null) {
116+
filterClasses.addAll(Arrays.asList(ann.value()));
117+
}
118+
}
119+
120+
if (filterClasses.isEmpty()) {
121+
return t -> false;
122+
}
123+
124+
var predicates =
125+
filterClasses.stream()
126+
.map(
127+
cls -> {
128+
try {
129+
return (Predicate<Thread>) cls.getDeclaredConstructor().newInstance();
130+
} catch (Exception e) {
131+
throw new RuntimeException(
132+
"Cannot instantiate thread filter: " + cls.getName(), e);
133+
}
134+
})
135+
.toList();
136+
137+
return t -> predicates.stream().anyMatch(p -> p.test(t));
138+
}
139+
90140
private static boolean isConcurrentMode(ExtensionContext context) {
91141
// Check the concurrent flag stored in beforeAll (class-level context = parent of method ctx).
92142
return context
@@ -98,11 +148,12 @@ private static boolean isConcurrentMode(ExtensionContext context) {
98148
.orElse(false);
99149
}
100150

101-
private static void checkLeaks(ExtensionContext.Store store, String description, int lingerMs) {
151+
private static void checkLeaks(
152+
ExtensionContext.Store store, String description, int lingerMs, Predicate<Thread> filter) {
102153
var snapshot = store.get(SNAPSHOT_KEY, HashSet.class);
103154
if (snapshot == null) return;
104155

105-
var leaked = leakedSince(snapshot);
156+
var leaked = leakedSince(snapshot, filter);
106157
if (leaked.isEmpty()) return;
107158

108159
// Linger: poll until threads self-terminate or the window expires.
@@ -116,7 +167,7 @@ private static void checkLeaks(ExtensionContext.Store store, String description,
116167
Thread.currentThread().interrupt();
117168
break;
118169
}
119-
leaked = leakedSince(snapshot);
170+
leaked = leakedSince(snapshot, filter);
120171
}
121172
if (leaked.isEmpty()) return;
122173
}
@@ -147,20 +198,22 @@ private static void checkLeaks(ExtensionContext.Store store, String description,
147198
throw new AssertionError(sb.toString());
148199
}
149200

150-
private static Map<Thread, StackTraceElement[]> leakedSince(HashSet<?> snapshot) {
151-
var current = liveThreadsWithStacks();
201+
private static Map<Thread, StackTraceElement[]> leakedSince(
202+
HashSet<?> snapshot, Predicate<Thread> filter) {
203+
var current = liveThreadsWithStacks(filter);
152204
current.keySet().removeAll(snapshot);
153205
return current;
154206
}
155207

156-
private static HashSet<Thread> liveThreads() {
157-
return new HashSet<>(liveThreadsWithStacks().keySet());
208+
private static HashSet<Thread> liveThreads(Predicate<Thread> filter) {
209+
return new HashSet<>(liveThreadsWithStacks(filter).keySet());
158210
}
159211

160-
private static Map<Thread, StackTraceElement[]> liveThreadsWithStacks() {
212+
private static Map<Thread, StackTraceElement[]> liveThreadsWithStacks(Predicate<Thread> filter) {
161213
return Thread.getAllStackTraces().entrySet().stream()
162214
.filter(e -> e.getKey().isAlive())
163215
.filter(e -> !isKnownSystemThread(e.getKey()))
216+
.filter(e -> !filter.test(e.getKey()))
164217
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
165218
}
166219

randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,100 @@ void testMethod() {
249249
}
250250
}
251251

252+
@Nested
253+
class TestExcludeThreads {
254+
@Test
255+
void excludedThreadDoesNotFail() {
256+
collectExecutionResults(testKitBuilder(ExcludedByClassFilter.class))
257+
.results()
258+
.allEvents()
259+
.assertThatEvents()
260+
.doNotHave(event(finishedWithFailure()));
261+
}
262+
263+
@Test
264+
void nonExcludedThreadStillFails() {
265+
collectExecutionResults(testKitBuilder(NonExcludedStillFails.class))
266+
.results()
267+
.allEvents()
268+
.finished()
269+
.failed()
270+
.assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class))));
271+
}
272+
273+
@Test
274+
void methodAndClassFiltersStackHierarchically() {
275+
collectExecutionResults(testKitBuilder(HierarchicalFilters.class))
276+
.results()
277+
.allEvents()
278+
.assertThatEvents()
279+
.doNotHave(event(finishedWithFailure()));
280+
}
281+
282+
// Class filter excludes "excluded-a-*"; the leaked thread matches → pass.
283+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE)
284+
@DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class)
285+
static class ExcludedByClassFilter extends IgnoreInStandaloneRuns {
286+
@Test
287+
void testMethod() {
288+
startNamedThread("excluded-a-1");
289+
}
290+
}
291+
292+
// Class filter excludes "excluded-a-*"; leaked thread is unnamed → still detected → fail.
293+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE)
294+
@DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class)
295+
static class NonExcludedStillFails extends IgnoreInStandaloneRuns {
296+
@Test
297+
void testMethod() {
298+
startSleepingThread();
299+
}
300+
}
301+
302+
// Class filter excludes "excluded-a-*", method filter excludes "excluded-b-*";
303+
// both threads are started → both excluded → pass.
304+
@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST)
305+
@DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class)
306+
static class HierarchicalFilters extends IgnoreInStandaloneRuns {
307+
@Test
308+
@DetectThreadLeaks.ExcludeThreads(ExcludeNamedBFilter.class)
309+
void testMethod() {
310+
startNamedThread("excluded-a-1");
311+
startNamedThread("excluded-b-1");
312+
}
313+
}
314+
}
315+
316+
/** Predicate that excludes threads whose names start with "excluded-a-". */
317+
public static class ExcludeNamedAFilter implements java.util.function.Predicate<Thread> {
318+
@Override
319+
public boolean test(Thread t) {
320+
return t.getName().startsWith("excluded-a-");
321+
}
322+
}
323+
324+
/** Predicate that excludes threads whose names start with "excluded-b-". */
325+
public static class ExcludeNamedBFilter implements java.util.function.Predicate<Thread> {
326+
@Override
327+
public boolean test(Thread t) {
328+
return t.getName().startsWith("excluded-b-");
329+
}
330+
}
331+
332+
private static void startNamedThread(String name) {
333+
var t =
334+
new Thread(
335+
() -> {
336+
try {
337+
Thread.sleep(TimeUnit.MINUTES.toMillis(1));
338+
} catch (InterruptedException ignored) {
339+
}
340+
},
341+
name);
342+
t.setDaemon(true);
343+
t.start();
344+
}
345+
252346
/** Starts a daemon thread that sleeps long enough to be observable as a leak. */
253347
private static void startSleepingThread() {
254348
var t =

0 commit comments

Comments
 (0)