From cb910ef900e348a7c514884579c323730a114dc1 Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Tue, 10 Mar 2026 12:51:56 +0100 Subject: [PATCH 01/10] Initial implementation of @DetectThreadLeaks annotation. --- .../jupiter/DetectThreadLeaks.java | 33 +++++ .../jupiter/DetectThreadLeaksExtension.java | 117 ++++++++++++++++ .../src/main/java/module-info.java | 1 + .../jupiter/F005_ThreadLeaks.java | 129 ++++++++++++++++++ .../jupiter/F005_ThreadLeaks.md | 27 ++++ 5 files changed, 307 insertions(+) create mode 100644 randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java create mode 100644 randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java create mode 100644 randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java create mode 100644 randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java new file mode 100644 index 0000000..1b1ec43 --- /dev/null +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java @@ -0,0 +1,33 @@ +package com.carrotsearch.randomizedtesting.jupiter; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Detects threads started within the annotated test class that are still alive after the configured + * scope ends. + * + *

Only functional in sequential (same-thread) execution mode. Emits a warning and skips + * detection if tests run concurrently. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(DetectThreadLeaksExtension.class) +@Inherited +public @interface DetectThreadLeaks { + /** Scope at which thread leak detection is performed. */ + Scope scope() default Scope.SUITE; + + enum Scope { + /** Check for leaked threads once after all tests in the class complete. */ + SUITE, + /** Check for leaked threads after each individual test method. */ + TEST + } +} diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java new file mode 100644 index 0000000..d608b5f --- /dev/null +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java @@ -0,0 +1,117 @@ +package com.carrotsearch.randomizedtesting.jupiter; + +import java.util.HashSet; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.parallel.ExecutionMode; + +/** JUnit Jupiter extension implementing {@link DetectThreadLeaks}. */ +public class DetectThreadLeaksExtension + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback { + + private static final Logger LOGGER = Logger.getLogger(DetectThreadLeaksExtension.class.getName()); + private static final ExtensionContext.Namespace EXTENSION_NAMESPACE = + ExtensionContext.Namespace.create(DetectThreadLeaksExtension.class); + private static final String SNAPSHOT_KEY = "snapshot"; + private static final String CONCURRENT_KEY = "concurrent"; + + @Override + public void beforeAll(ExtensionContext context) { + if (context.getExecutionMode() != ExecutionMode.SAME_THREAD) { + LOGGER.warning( + "Thread leak detection is disabled: tests in [" + + context.getDisplayName() + + "] run in concurrent execution mode."); + context.getStore(EXTENSION_NAMESPACE).put(CONCURRENT_KEY, Boolean.TRUE); + return; + } + if (scope(context) == DetectThreadLeaks.Scope.SUITE) { + context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads()); + } + } + + @Override + public void afterAll(ExtensionContext context) { + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.SUITE) { + return; + } + checkLeaks(context.getStore(EXTENSION_NAMESPACE), "suite [" + context.getDisplayName() + "]"); + } + + @Override + public void beforeEach(ExtensionContext context) { + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { + return; + } + context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads()); + } + + @Override + public void afterEach(ExtensionContext context) { + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { + return; + } + checkLeaks(context.getStore(EXTENSION_NAMESPACE), "test [" + context.getDisplayName() + "]"); + } + + private static DetectThreadLeaks.Scope scope(ExtensionContext context) { + return context.getRequiredTestClass().getAnnotation(DetectThreadLeaks.class).scope(); + } + + private static boolean isConcurrentMode(ExtensionContext context) { + // Check the concurrent flag stored in beforeAll (class-level context = parent of method ctx). + return context + .getParent() + .map( + p -> + Boolean.TRUE.equals( + p.getStore(EXTENSION_NAMESPACE).get(CONCURRENT_KEY, Boolean.class))) + .orElse(false); + } + + private static void checkLeaks(ExtensionContext.Store store, String description) { + var snapshot = store.get(SNAPSHOT_KEY, HashSet.class); + if (snapshot == null) return; + + var leaked = liveThreads(); + leaked.removeAll(snapshot); + leaked.removeIf(t -> !t.isAlive()); + + if (!leaked.isEmpty()) { + var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":"); + leaked.forEach(t -> sb.append("\n ").append(Threads.threadName(t))); + throw new AssertionError(sb.toString()); + } + } + + private static HashSet liveThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(Thread::isAlive) + .filter(t -> !isKnownSystemThread(t)) + .collect(Collectors.toCollection(HashSet::new)); + } + + private static boolean isKnownSystemThread(Thread t) { + ThreadGroup tgroup = t.getThreadGroup(); + + if (tgroup != null && "system".equals(tgroup.getName()) && tgroup.getParent() == null) { + return true; + } + + return switch (t.getName()) { + case "JFR request timer", + "YJPAgent-Telemetry", + "MemoryPoolMXBean notification dispatcher", + "AWT-AppKit", + "process reaper", + "JUnit5-serializer-daemon" -> + true; + default -> t.getName().contains("Poller SunPKCS11"); + }; + } +} diff --git a/randomizedtesting-jupiter/src/main/java/module-info.java b/randomizedtesting-jupiter/src/main/java/module-info.java index c833ea1..ab4c2ab 100644 --- a/randomizedtesting-jupiter/src/main/java/module-info.java +++ b/randomizedtesting-jupiter/src/main/java/module-info.java @@ -1,6 +1,7 @@ module com.carrotsearch.randomizedtesting { requires org.junit.jupiter.api; requires org.junit.jupiter.params; + requires java.logging; exports com.carrotsearch.randomizedtesting.jupiter; diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java new file mode 100644 index 0000000..47e14d4 --- /dev/null +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java @@ -0,0 +1,129 @@ +package com.carrotsearch.randomizedtesting.jupiter; + +import static com.carrotsearch.randomizedtesting.jupiter.infra.TestInfra.*; +import static org.junit.platform.testkit.engine.EventConditions.*; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.*; + +import com.carrotsearch.randomizedtesting.jupiter.infra.IgnoreInStandaloneRuns; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +/** Verify that {@link DetectThreadLeaks} detects threads leaked from tests. */ +public class F005_ThreadLeaks { + @Nested + class TestSuiteScope { + @TestFactory + Stream leakedThreadIsDetectedAtSuiteEnd() { + return Stream.of( + LeakInBeforeAllMethod.class, LeakInTestMethod.class, LeakInAfterAllMethod.class) + .map( + clazz -> + DynamicTest.dynamicTest( + clazz.getSimpleName(), + () -> { + collectExecutionResults(testKitBuilder(clazz)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly( + event(finishedWithFailure(instanceOf(AssertionError.class)))); + })); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class LeakInTestMethod extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class LeakInAfterAllMethod extends IgnoreInStandaloneRuns { + @Test + void testMethod() {} + + @AfterAll + static void afterAll() { + startSleepingThread(); + } + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class LeakInBeforeAllMethod extends IgnoreInStandaloneRuns { + @Test + void testMethod() {} + + @BeforeAll + static void beforeAll() { + startSleepingThread(); + } + } + } + + @Nested + class TestTestScope { + @Test + void leakedThreadIsDetectedAfterTest() { + collectExecutionResults(testKitBuilder(TestScopeWithLeak.class)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class)))); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class TestScopeWithLeak extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + } + + @Nested + class TestConcurrentMode { + @Test + void leakedThreadDoesNotFailInConcurrentMode() { + // In concurrent mode the extension is disabled: no AssertionErrors, even with a leak. + collectExecutionResults(testKitBuilder(ConcurrentWithLeak.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + @Execution(ExecutionMode.CONCURRENT) + static class ConcurrentWithLeak extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + } + + /** Starts a daemon thread that sleeps long enough to be observable as a leak. */ + private static void startSleepingThread() { + var t = + new Thread( + () -> { + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(1)); + } catch (InterruptedException ignored) { + } + }); + t.setDaemon(true); + t.start(); + } +} diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md new file mode 100644 index 0000000..8e6076a --- /dev/null +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md @@ -0,0 +1,27 @@ +# Feature: detecting tests that "leak" threads + +## Functionality + +* It should be possible to add a `@DetectThreadLeaks` extension which detects new threads forked within the test +container. This extension takes a single parameter - the scope of detection. Either we care about threads leaked +from the entire container or from each individual test. Here is an example of use: + +```java + +@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) +public class TestClass { + @Test + public void testMethod() { + new Thread(() -> { + try { Thread.sleep(1000); } catch (Exception e) {} + }).start(); + } +} +``` + +* The extension is only functional in sequential mode. It should emit a warning and do nothing if tests are +run in concurrent mode. + +## Migration notes (from randomizedtesting for junit4) + +* From 2f1b8fbb2358e8afb6b155a2f57fbf1433fde483 Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Tue, 10 Mar 2026 14:48:07 +0100 Subject: [PATCH 02/10] More progress. --- .../jupiter/DetectThreadLeaks.java | 14 ++ .../jupiter/DetectThreadLeaksExtension.java | 96 +++++++++++-- .../jupiter/F005_ThreadLeaks.java | 136 ++++++++++++++++++ .../jupiter/F005_ThreadLeaks.md | 18 ++- 4 files changed, 249 insertions(+), 15 deletions(-) diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java index 1b1ec43..93fa59f 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java @@ -30,4 +30,18 @@ enum Scope { /** Check for leaked threads after each individual test method. */ TEST } + + /** + * Milliseconds to wait for leaked threads to self-terminate before declaring a failure. If all + * leaked threads terminate within this window, the test passes. Default is 0 (no lingering). + * + *

Place this annotation on the same class as {@link DetectThreadLeaks}. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @Inherited + @interface LingerTime { + int millis(); + } } diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java index d608b5f..b4cc334 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java @@ -1,6 +1,8 @@ package com.carrotsearch.randomizedtesting.jupiter; import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import java.util.stream.Collectors; import org.junit.jupiter.api.extension.AfterAllCallback; @@ -20,6 +22,9 @@ public class DetectThreadLeaksExtension private static final String SNAPSHOT_KEY = "snapshot"; private static final String CONCURRENT_KEY = "concurrent"; + /** Total time budget (ms) to join interrupted threads before giving up. */ + private static final long INTERRUPT_JOIN_MS = 2_000L; + @Override public void beforeAll(ExtensionContext context) { if (context.getExecutionMode() != ExecutionMode.SAME_THREAD) { @@ -40,7 +45,10 @@ public void afterAll(ExtensionContext context) { if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.SUITE) { return; } - checkLeaks(context.getStore(EXTENSION_NAMESPACE), "suite [" + context.getDisplayName() + "]"); + checkLeaks( + context.getStore(EXTENSION_NAMESPACE), + "suite [" + context.getDisplayName() + "]", + linger(context)); } @Override @@ -56,13 +64,29 @@ public void afterEach(ExtensionContext context) { if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { return; } - checkLeaks(context.getStore(EXTENSION_NAMESPACE), "test [" + context.getDisplayName() + "]"); + checkLeaks( + context.getStore(EXTENSION_NAMESPACE), + "test [" + context.getDisplayName() + "]", + linger(context)); } private static DetectThreadLeaks.Scope scope(ExtensionContext context) { return context.getRequiredTestClass().getAnnotation(DetectThreadLeaks.class).scope(); } + private static int linger(ExtensionContext context) { + // Method-level annotation takes precedence over class-level. + var methodAnn = + context + .getTestMethod() + .map(m -> m.getAnnotation(DetectThreadLeaks.LingerTime.class)) + .orElse(null); + if (methodAnn != null) return methodAnn.millis(); + + var classAnn = context.getRequiredTestClass().getAnnotation(DetectThreadLeaks.LingerTime.class); + return classAnn == null ? 0 : classAnn.millis(); + } + private static boolean isConcurrentMode(ExtensionContext context) { // Check the concurrent flag stored in beforeAll (class-level context = parent of method ctx). return context @@ -74,26 +98,70 @@ private static boolean isConcurrentMode(ExtensionContext context) { .orElse(false); } - private static void checkLeaks(ExtensionContext.Store store, String description) { + private static void checkLeaks(ExtensionContext.Store store, String description, int lingerMs) { var snapshot = store.get(SNAPSHOT_KEY, HashSet.class); if (snapshot == null) return; - var leaked = liveThreads(); - leaked.removeAll(snapshot); - leaked.removeIf(t -> !t.isAlive()); + var leaked = leakedSince(snapshot); + if (leaked.isEmpty()) return; + + // Linger: poll until threads self-terminate or the window expires. + if (lingerMs > 0) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(lingerMs); + while (!leaked.isEmpty() && System.nanoTime() < deadline) { + try { + long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); + Thread.sleep(Math.max(1L, Math.min(100L, remainingMs))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + leaked = leakedSince(snapshot); + } + if (leaked.isEmpty()) return; + } + + // Interrupt leaked threads for cleanup, then wait briefly for them to terminate. + leaked.keySet().forEach(Thread::interrupt); + long joinDeadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(INTERRUPT_JOIN_MS); + for (Thread t : leaked.keySet()) { + long remaining = TimeUnit.NANOSECONDS.toMillis(joinDeadline - System.nanoTime()); + if (remaining <= 0) break; + try { + t.join(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } - if (!leaked.isEmpty()) { - var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":"); - leaked.forEach(t -> sb.append("\n ").append(Threads.threadName(t))); - throw new AssertionError(sb.toString()); + // Report failure with stack traces captured before the interrupt. + var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":"); + int cnt = 1; + for (var entry : leaked.entrySet()) { + sb.append(String.format("%n %2d) %s", cnt++, Threads.threadName(entry.getKey()))); + for (var ste : entry.getValue()) { + sb.append(String.format("%n at %s", ste)); + } } + throw new AssertionError(sb.toString()); + } + + private static Map leakedSince(HashSet snapshot) { + var current = liveThreadsWithStacks(); + current.keySet().removeAll(snapshot); + return current; } private static HashSet liveThreads() { - return Thread.getAllStackTraces().keySet().stream() - .filter(Thread::isAlive) - .filter(t -> !isKnownSystemThread(t)) - .collect(Collectors.toCollection(HashSet::new)); + return new HashSet<>(liveThreadsWithStacks().keySet()); + } + + private static Map liveThreadsWithStacks() { + return Thread.getAllStackTraces().entrySet().stream() + .filter(e -> e.getKey().isAlive()) + .filter(e -> !isKnownSystemThread(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } private static boolean isKnownSystemThread(Thread t) { diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java index 47e14d4..804707c 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java @@ -7,6 +7,7 @@ import com.carrotsearch.randomizedtesting.jupiter.infra.IgnoreInStandaloneRuns; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import org.assertj.core.api.Condition; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DynamicTest; @@ -70,6 +71,33 @@ static void beforeAll() { } } + @Nested + class TestStackTracesInMessage { + @Test + void leakErrorMessageContainsStackTrace() { + collectExecutionResults(testKitBuilder(SuiteScopeWithLeak.class)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly( + event( + finishedWithFailure( + instanceOf(AssertionError.class), + new Condition<>( + t -> t.getMessage().contains(getClass().getPackageName()), + "error message contains stack frames")))); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class SuiteScopeWithLeak extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + } + @Nested class TestTestScope { @Test @@ -91,6 +119,114 @@ void testMethod() { } } + @Nested + class TestLinger { + @Test + void threadTerminatingWithinLingerWindowPasses() { + collectExecutionResults(testKitBuilder(ShortLivedLeak.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @Test + void methodLingerTakesPrecedenceOverAbsentClassLinger() { + collectExecutionResults(testKitBuilder(MethodLingerOverridesAbsentClassLinger.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @Test + void methodLingerTakesPrecedenceOverClassLinger() { + collectExecutionResults(testKitBuilder(MethodLingerOverridesClassLinger.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @Test + void threadOutlastingLingerWindowFails() { + collectExecutionResults(testKitBuilder(LongLivedLeak.class)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class)))); + } + + // Class linger 10s; thread sleeps 100ms → terminates before linger expires → pass. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + @DetectThreadLeaks.LingerTime(millis = 10_000) + static class ShortLivedLeak extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + var t = + new Thread( + () -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + }); + t.setDaemon(true); + t.start(); + } + } + + // Class linger 50ms; thread sleeps 1 min → outlasts linger → fail. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + @DetectThreadLeaks.LingerTime(millis = 50) + static class LongLivedLeak extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + + // Method linger 10s overrides absent class linger; thread sleeps 100ms → pass. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class MethodLingerOverridesAbsentClassLinger extends IgnoreInStandaloneRuns { + @Test + @DetectThreadLeaks.LingerTime(millis = 10_000) + void testMethod() { + var t = + new Thread( + () -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + }); + t.setDaemon(true); + t.start(); + } + } + + // Method linger 10s overrides class linger 50ms; thread sleeps 100ms → pass. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + @DetectThreadLeaks.LingerTime(millis = 50) + static class MethodLingerOverridesClassLinger extends IgnoreInStandaloneRuns { + @Test + @DetectThreadLeaks.LingerTime(millis = 10_000) + void testMethod() { + var t = + new Thread( + () -> { + try { + Thread.sleep(100); + } catch (InterruptedException ignored) { + } + }); + t.setDaemon(true); + t.start(); + } + } + } + @Nested class TestConcurrentMode { @Test diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md index 8e6076a..673885c 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md @@ -3,7 +3,7 @@ ## Functionality * It should be possible to add a `@DetectThreadLeaks` extension which detects new threads forked within the test -container. This extension takes a single parameter - the scope of detection. Either we care about threads leaked +container. This extension takes a parameter - the scope of detection. Either we care about threads leaked from the entire container or from each individual test. Here is an example of use: ```java @@ -22,6 +22,22 @@ public class TestClass { * The extension is only functional in sequential mode. It should emit a warning and do nothing if tests are run in concurrent mode. +* Occasionally there will be threads that cannot be joined but will eventually terminate. One can specify an additional +"linger" time before the thread leak is reported, for example one second, below: + +```java +@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) +@DetectThreadLeaks.LingerTime(millis = 1_000) +public class TestClass { + @Test + public void testMethod() { + new Thread(() -> { + try { Thread.sleep(1000); } catch (Exception e) {} + }).start(); + } +} +``` + ## Migration notes (from randomizedtesting for junit4) * From 922d740779ca404794c1b63e63b9a0eabcce3731 Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Tue, 10 Mar 2026 19:15:44 +0100 Subject: [PATCH 03/10] Thread leaks and thread filters/ exclusions. --- .../jupiter/DetectThreadLeaks.java | 19 +++- .../jupiter/DetectThreadLeaksExtension.java | 77 ++++++++++++--- .../jupiter/F005_ThreadLeaks.java | 94 +++++++++++++++++++ 3 files changed, 177 insertions(+), 13 deletions(-) diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java index 93fa59f..74a53c0 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java @@ -6,6 +6,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.function.Predicate; import org.junit.jupiter.api.extension.ExtendWith; /** @@ -35,7 +36,8 @@ enum Scope { * Milliseconds to wait for leaked threads to self-terminate before declaring a failure. If all * leaked threads terminate within this window, the test passes. Default is 0 (no lingering). * - *

Place this annotation on the same class as {@link DetectThreadLeaks}. + *

Place this annotation on the same class or method as {@link DetectThreadLeaks}. A + * method-level annotation takes precedence over a class-level one. */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @@ -44,4 +46,19 @@ enum Scope { @interface LingerTime { int millis(); } + + /** + * Excludes threads matched by any of the given {@link Predicate} classes from leak detection. A + * thread is excluded when at least one predicate returns {@code true} for it. + * + *

Annotations are collected hierarchically: the test method, then the class, then each + * superclass, and the filters from all levels are combined. Place on the same class or method as + * {@link DetectThreadLeaks}. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface ExcludeThreads { + Class>[] value(); + } } diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java index b4cc334..e4c5046 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java @@ -1,8 +1,11 @@ package com.carrotsearch.randomizedtesting.jupiter; +import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import java.util.logging.Logger; import java.util.stream.Collectors; import org.junit.jupiter.api.extension.AfterAllCallback; @@ -36,7 +39,7 @@ public void beforeAll(ExtensionContext context) { return; } if (scope(context) == DetectThreadLeaks.Scope.SUITE) { - context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads()); + context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads(buildFilter(context))); } } @@ -48,7 +51,8 @@ public void afterAll(ExtensionContext context) { checkLeaks( context.getStore(EXTENSION_NAMESPACE), "suite [" + context.getDisplayName() + "]", - linger(context)); + linger(context), + buildFilter(context)); } @Override @@ -56,7 +60,7 @@ public void beforeEach(ExtensionContext context) { if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { return; } - context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads()); + context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads(buildFilter(context))); } @Override @@ -67,7 +71,8 @@ public void afterEach(ExtensionContext context) { checkLeaks( context.getStore(EXTENSION_NAMESPACE), "test [" + context.getDisplayName() + "]", - linger(context)); + linger(context), + buildFilter(context)); } private static DetectThreadLeaks.Scope scope(ExtensionContext context) { @@ -87,6 +92,51 @@ private static int linger(ExtensionContext context) { return classAnn == null ? 0 : classAnn.millis(); } + /** + * Collects {@link DetectThreadLeaks.ExcludeThreads} filter classes from the entire hierarchy + * (method → class → superclasses) and returns a combined predicate that excludes a thread when + * any filter matches it. + */ + private static Predicate buildFilter(ExtensionContext context) { + var filterClasses = new LinkedHashSet>>(); + + context + .getTestMethod() + .ifPresent( + m -> { + var ann = m.getAnnotation(DetectThreadLeaks.ExcludeThreads.class); + if (ann != null) { + for (var c : ann.value()) filterClasses.add(c); + } + }); + + for (Class cls = context.getRequiredTestClass(); cls != null; cls = cls.getSuperclass()) { + var ann = cls.getAnnotation(DetectThreadLeaks.ExcludeThreads.class); + if (ann != null) { + filterClasses.addAll(Arrays.asList(ann.value())); + } + } + + if (filterClasses.isEmpty()) { + return t -> false; + } + + var predicates = + filterClasses.stream() + .map( + cls -> { + try { + return (Predicate) cls.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException( + "Cannot instantiate thread filter: " + cls.getName(), e); + } + }) + .toList(); + + return t -> predicates.stream().anyMatch(p -> p.test(t)); + } + private static boolean isConcurrentMode(ExtensionContext context) { // Check the concurrent flag stored in beforeAll (class-level context = parent of method ctx). return context @@ -98,11 +148,12 @@ private static boolean isConcurrentMode(ExtensionContext context) { .orElse(false); } - private static void checkLeaks(ExtensionContext.Store store, String description, int lingerMs) { + private static void checkLeaks( + ExtensionContext.Store store, String description, int lingerMs, Predicate filter) { var snapshot = store.get(SNAPSHOT_KEY, HashSet.class); if (snapshot == null) return; - var leaked = leakedSince(snapshot); + var leaked = leakedSince(snapshot, filter); if (leaked.isEmpty()) return; // Linger: poll until threads self-terminate or the window expires. @@ -116,7 +167,7 @@ private static void checkLeaks(ExtensionContext.Store store, String description, Thread.currentThread().interrupt(); break; } - leaked = leakedSince(snapshot); + leaked = leakedSince(snapshot, filter); } if (leaked.isEmpty()) return; } @@ -147,20 +198,22 @@ private static void checkLeaks(ExtensionContext.Store store, String description, throw new AssertionError(sb.toString()); } - private static Map leakedSince(HashSet snapshot) { - var current = liveThreadsWithStacks(); + private static Map leakedSince( + HashSet snapshot, Predicate filter) { + var current = liveThreadsWithStacks(filter); current.keySet().removeAll(snapshot); return current; } - private static HashSet liveThreads() { - return new HashSet<>(liveThreadsWithStacks().keySet()); + private static HashSet liveThreads(Predicate filter) { + return new HashSet<>(liveThreadsWithStacks(filter).keySet()); } - private static Map liveThreadsWithStacks() { + private static Map liveThreadsWithStacks(Predicate filter) { return Thread.getAllStackTraces().entrySet().stream() .filter(e -> e.getKey().isAlive()) .filter(e -> !isKnownSystemThread(e.getKey())) + .filter(e -> !filter.test(e.getKey())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java index 804707c..7459f51 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java @@ -249,6 +249,100 @@ void testMethod() { } } + @Nested + class TestExcludeThreads { + @Test + void excludedThreadDoesNotFail() { + collectExecutionResults(testKitBuilder(ExcludedByClassFilter.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @Test + void nonExcludedThreadStillFails() { + collectExecutionResults(testKitBuilder(NonExcludedStillFails.class)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class)))); + } + + @Test + void methodAndClassFiltersStackHierarchically() { + collectExecutionResults(testKitBuilder(HierarchicalFilters.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + // Class filter excludes "excluded-a-*"; the leaked thread matches → pass. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + @DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class) + static class ExcludedByClassFilter extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startNamedThread("excluded-a-1"); + } + } + + // Class filter excludes "excluded-a-*"; leaked thread is unnamed → still detected → fail. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + @DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class) + static class NonExcludedStillFails extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + + // Class filter excludes "excluded-a-*", method filter excludes "excluded-b-*"; + // both threads are started → both excluded → pass. + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + @DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class) + static class HierarchicalFilters extends IgnoreInStandaloneRuns { + @Test + @DetectThreadLeaks.ExcludeThreads(ExcludeNamedBFilter.class) + void testMethod() { + startNamedThread("excluded-a-1"); + startNamedThread("excluded-b-1"); + } + } + } + + /** Predicate that excludes threads whose names start with "excluded-a-". */ + public static class ExcludeNamedAFilter implements java.util.function.Predicate { + @Override + public boolean test(Thread t) { + return t.getName().startsWith("excluded-a-"); + } + } + + /** Predicate that excludes threads whose names start with "excluded-b-". */ + public static class ExcludeNamedBFilter implements java.util.function.Predicate { + @Override + public boolean test(Thread t) { + return t.getName().startsWith("excluded-b-"); + } + } + + private static void startNamedThread(String name) { + var t = + new Thread( + () -> { + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(1)); + } catch (InterruptedException ignored) { + } + }, + name); + t.setDaemon(true); + t.start(); + } + /** Starts a daemon thread that sleeps long enough to be observable as a leak. */ private static void startSleepingThread() { var t = From fd27f87ec57e68940c882105b1b1ae7d08c1ea9c Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Tue, 10 Mar 2026 19:49:11 +0100 Subject: [PATCH 04/10] Add documentation. --- .../randomizedtesting/jupiter/F005_ThreadLeaks.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md index 673885c..bb187f6 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md @@ -19,7 +19,7 @@ public class TestClass { } ``` -* The extension is only functional in sequential mode. It should emit a warning and do nothing if tests are +* The extension is **only functional in sequential mode**. It should emit a warning and do nothing if tests are run in concurrent mode. * Occasionally there will be threads that cannot be joined but will eventually terminate. One can specify an additional @@ -38,6 +38,10 @@ public class TestClass { } ``` +* In certain cases, system threads or other threads beyond the test's control may be started and cannot be terminated +within the test's scope. The `@DetectThreadLeaks.ExcludeThreads` annotation can provide programmatic filters which +tell the extension to ignore certain threads. + ## Migration notes (from randomizedtesting for junit4) -* + From 35fab76e63c90e00821166c8fb7201711566fe22 Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Tue, 10 Mar 2026 20:52:08 +0100 Subject: [PATCH 05/10] Add uncaught exception handler. --- .../jupiter/DetectThreadLeaksExtension.java | 177 +++++++++++------- .../jupiter/UncaughtExceptionsHandler.java | 57 ++++++ .../jupiter/F005_ThreadLeaks.java | 66 +++++++ 3 files changed, 235 insertions(+), 65 deletions(-) create mode 100644 randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/UncaughtExceptionsHandler.java diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java index e4c5046..0367950 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java @@ -1,8 +1,9 @@ package com.carrotsearch.randomizedtesting.jupiter; -import java.util.Arrays; +import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; @@ -24,6 +25,7 @@ public class DetectThreadLeaksExtension ExtensionContext.Namespace.create(DetectThreadLeaksExtension.class); private static final String SNAPSHOT_KEY = "snapshot"; private static final String CONCURRENT_KEY = "concurrent"; + private static final String UNCAUGHT_EXCEPTION_HANDLER_KEY = "uncaught-exception-handler"; /** Total time budget (ms) to join interrupted threads before giving up. */ private static final long INTERRUPT_JOIN_MS = 2_000L; @@ -39,40 +41,60 @@ public void beforeAll(ExtensionContext context) { return; } if (scope(context) == DetectThreadLeaks.Scope.SUITE) { - context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads(buildFilter(context))); + var store = context.getStore(EXTENSION_NAMESPACE); + var filter = buildFilter(context); + store.put(UNCAUGHT_EXCEPTION_HANDLER_KEY, installUncaughtExceptionHandler()); + store.put(SNAPSHOT_KEY, liveThreads(filter)); } } @Override public void afterAll(ExtensionContext context) { - if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.SUITE) { - return; + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.SUITE) return; + var store = context.getStore(EXTENSION_NAMESPACE); + var handler = store.get(UNCAUGHT_EXCEPTION_HANDLER_KEY, UncaughtExceptionsHandler.class); + try { + checkLeaks( + store, + "suite [" + context.getDisplayName() + "]", + linger(context), + buildFilter(context), + handler); + } finally { + if (handler != null) handler.restore(); } - checkLeaks( - context.getStore(EXTENSION_NAMESPACE), - "suite [" + context.getDisplayName() + "]", - linger(context), - buildFilter(context)); } @Override public void beforeEach(ExtensionContext context) { - if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { - return; - } - context.getStore(EXTENSION_NAMESPACE).put(SNAPSHOT_KEY, liveThreads(buildFilter(context))); + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) return; + var store = context.getStore(EXTENSION_NAMESPACE); + var filter = buildFilter(context); + store.put(UNCAUGHT_EXCEPTION_HANDLER_KEY, installUncaughtExceptionHandler()); + store.put(SNAPSHOT_KEY, liveThreads(filter)); } @Override public void afterEach(ExtensionContext context) { - if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { - return; + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) return; + var store = context.getStore(EXTENSION_NAMESPACE); + var handler = store.get(UNCAUGHT_EXCEPTION_HANDLER_KEY, UncaughtExceptionsHandler.class); + try { + checkLeaks( + store, + "test [" + context.getDisplayName() + "]", + linger(context), + buildFilter(context), + handler); + } finally { + if (handler != null) handler.restore(); } - checkLeaks( - context.getStore(EXTENSION_NAMESPACE), - "test [" + context.getDisplayName() + "]", - linger(context), - buildFilter(context)); + } + + private static UncaughtExceptionsHandler installUncaughtExceptionHandler() { + var handler = new UncaughtExceptionsHandler(Thread.getDefaultUncaughtExceptionHandler()); + Thread.setDefaultUncaughtExceptionHandler(handler); + return handler; } private static DetectThreadLeaks.Scope scope(ExtensionContext context) { @@ -80,7 +102,6 @@ private static DetectThreadLeaks.Scope scope(ExtensionContext context) { } private static int linger(ExtensionContext context) { - // Method-level annotation takes precedence over class-level. var methodAnn = context .getTestMethod() @@ -94,7 +115,7 @@ private static int linger(ExtensionContext context) { /** * Collects {@link DetectThreadLeaks.ExcludeThreads} filter classes from the entire hierarchy - * (method → class → superclasses) and returns a combined predicate that excludes a thread when + * (method to class to superclasses) and returns a combined predicate that excludes a thread when * any filter matches it. */ private static Predicate buildFilter(ExtensionContext context) { @@ -113,13 +134,11 @@ private static Predicate buildFilter(ExtensionContext context) { for (Class cls = context.getRequiredTestClass(); cls != null; cls = cls.getSuperclass()) { var ann = cls.getAnnotation(DetectThreadLeaks.ExcludeThreads.class); if (ann != null) { - filterClasses.addAll(Arrays.asList(ann.value())); + for (var c : ann.value()) filterClasses.add(c); } } - if (filterClasses.isEmpty()) { - return t -> false; - } + if (filterClasses.isEmpty()) return t -> false; var predicates = filterClasses.stream() @@ -127,7 +146,7 @@ private static Predicate buildFilter(ExtensionContext context) { cls -> { try { return (Predicate) cls.getDeclaredConstructor().newInstance(); - } catch (Exception e) { + } catch (ReflectiveOperationException e) { throw new RuntimeException( "Cannot instantiate thread filter: " + cls.getName(), e); } @@ -138,7 +157,6 @@ private static Predicate buildFilter(ExtensionContext context) { } private static boolean isConcurrentMode(ExtensionContext context) { - // Check the concurrent flag stored in beforeAll (class-level context = parent of method ctx). return context .getParent() .map( @@ -149,53 +167,82 @@ private static boolean isConcurrentMode(ExtensionContext context) { } private static void checkLeaks( - ExtensionContext.Store store, String description, int lingerMs, Predicate filter) { + ExtensionContext.Store store, + String description, + int lingerMs, + Predicate filter, + UncaughtExceptionsHandler handler) { var snapshot = store.get(SNAPSHOT_KEY, HashSet.class); - if (snapshot == null) return; + AssertionError leakError = null; - var leaked = leakedSince(snapshot, filter); - if (leaked.isEmpty()) return; + if (snapshot != null) { + var leaked = leakedSince(snapshot, filter); - // Linger: poll until threads self-terminate or the window expires. - if (lingerMs > 0) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(lingerMs); - while (!leaked.isEmpty() && System.nanoTime() < deadline) { - try { - long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); - Thread.sleep(Math.max(1L, Math.min(100L, remainingMs))); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; + // Linger: poll until threads self-terminate or the window expires. + if (!leaked.isEmpty() && lingerMs > 0) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(lingerMs); + while (!leaked.isEmpty() && System.nanoTime() < deadline) { + try { + long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); + Thread.sleep(Math.max(1L, Math.min(100L, remainingMs))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + leaked = leakedSince(snapshot, filter); } - leaked = leakedSince(snapshot, filter); } - if (leaked.isEmpty()) return; - } - // Interrupt leaked threads for cleanup, then wait briefly for them to terminate. - leaked.keySet().forEach(Thread::interrupt); - long joinDeadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(INTERRUPT_JOIN_MS); - for (Thread t : leaked.keySet()) { - long remaining = TimeUnit.NANOSECONDS.toMillis(joinDeadline - System.nanoTime()); - if (remaining <= 0) break; - try { - t.join(remaining); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; + if (!leaked.isEmpty()) { + // Suppress uncaught exception reporting during the interrupt/join phase to avoid + // capturing expected InterruptedException-related exceptions from cleaned-up threads. + if (handler != null) handler.stopReporting(); + try { + leaked.keySet().forEach(Thread::interrupt); + long joinDeadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(INTERRUPT_JOIN_MS); + for (Thread t : leaked.keySet()) { + long remaining = TimeUnit.NANOSECONDS.toMillis(joinDeadline - System.nanoTime()); + if (remaining <= 0) break; + try { + t.join(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } finally { + if (handler != null) handler.resumeReporting(); + } + + var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":"); + int cnt = 1; + for (var entry : leaked.entrySet()) { + sb.append(String.format("%n %2d) %s", cnt++, Threads.threadName(entry.getKey()))); + for (var ste : entry.getValue()) { + sb.append(String.format("%n at %s", ste)); + } + } + leakError = new AssertionError(sb.toString()); } } - // Report failure with stack traces captured before the interrupt. - var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":"); - int cnt = 1; - for (var entry : leaked.entrySet()) { - sb.append(String.format("%n %2d) %s", cnt++, Threads.threadName(entry.getKey()))); - for (var ste : entry.getValue()) { - sb.append(String.format("%n at %s", ste)); - } + // Collect uncaught exceptions regardless of whether threads leaked. + List uncaught = + handler != null ? handler.getAndClear() : List.of(); + + if (leakError == null && uncaught.isEmpty()) return; + + // Combine: leak error first (if any), uncaught exceptions after; all but the first + // are attached as suppressed on the thrown error. + var errors = new ArrayList(); + if (leakError != null) errors.add(leakError); + for (var ue : uncaught) { + errors.add( + new AssertionError("Uncaught exception in thread [" + ue.threadName() + "]", ue.error())); } - throw new AssertionError(sb.toString()); + var first = errors.get(0); + errors.subList(1, errors.size()).forEach(first::addSuppressed); + throw first; } private static Map leakedSince( diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/UncaughtExceptionsHandler.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/UncaughtExceptionsHandler.java new file mode 100644 index 0000000..927b9ab --- /dev/null +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/UncaughtExceptionsHandler.java @@ -0,0 +1,57 @@ +package com.carrotsearch.randomizedtesting.jupiter; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Collects uncaught exceptions from threads during test execution. */ +class UncaughtExceptionsHandler implements Thread.UncaughtExceptionHandler { + private static final Logger LOGGER = Logger.getLogger(UncaughtExceptionsHandler.class.getName()); + + record UncaughtException(String threadName, Throwable error) {} + + private final Thread.UncaughtExceptionHandler previous; + private final List exceptions = new ArrayList<>(); + private boolean reporting = true; + + UncaughtExceptionsHandler(Thread.UncaughtExceptionHandler previous) { + this.previous = previous; + } + + @Override + public void uncaughtException(Thread t, Throwable e) { + synchronized (exceptions) { + if (reporting) { + LOGGER.log(Level.SEVERE, "Uncaught exception in thread: " + Threads.threadName(t), e); + exceptions.add(new UncaughtException(Threads.threadName(t), e)); + } + } + if (previous != null) previous.uncaughtException(t, e); + } + + void stopReporting() { + synchronized (exceptions) { + reporting = false; + } + } + + void resumeReporting() { + synchronized (exceptions) { + reporting = true; + } + } + + List getAndClear() { + synchronized (exceptions) { + var copy = new ArrayList<>(exceptions); + exceptions.clear(); + return copy; + } + } + + /** Restores the previous default uncaught exception handler. */ + void restore() { + Thread.setDefaultUncaughtExceptionHandler(previous); + } +} diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java index 7459f51..4bec4b5 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java @@ -343,6 +343,72 @@ private static void startNamedThread(String name) { t.start(); } + @Nested + class TestUncaughtExceptions { + @Test + void uncaughtExceptionFailsTheTest() { + collectExecutionResults(testKitBuilder(UncaughtInTestMethod.class)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly( + event( + finishedWithFailure( + instanceOf(AssertionError.class), + new Condition<>( + t -> + t.getCause() instanceof RuntimeException rc + && "uncaught-test-exception".equals(rc.getMessage()), + "cause is the original RuntimeException")))); + } + + @Test + void uncaughtExceptionsWithThreadLeaksAreNotReported() { + collectExecutionResults(testKitBuilder(UncaughtWithLeak.class)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly( + event( + finishedWithFailure( + instanceOf(AssertionError.class), + new Condition<>(t -> t.getCause() == null, "cause is empty.")))); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class UncaughtInTestMethod extends IgnoreInStandaloneRuns { + @Test + void testMethod() throws InterruptedException { + var t = + new Thread( + () -> { + throw new RuntimeException("uncaught-test-exception"); + }); + t.start(); + t.join(); + } + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class UncaughtWithLeak extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + var t1 = + new Thread( + () -> { + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(1)); + } catch (InterruptedException ignored) { + throw new RuntimeException("uncaught-test-exception"); + } + }); + t1.start(); + } + } + } + /** Starts a daemon thread that sleeps long enough to be observable as a leak. */ private static void startSleepingThread() { var t = From 216b896122c696370513d27450bbc93742fd884b Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Tue, 10 Mar 2026 20:59:19 +0100 Subject: [PATCH 06/10] Add migration notes. --- .../jupiter/F005_ThreadLeaks.md | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md index bb187f6..c85c72f 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md @@ -1,10 +1,10 @@ -# Feature: detecting tests that "leak" threads +# Feature: detecting tests that "leak" threads ## Functionality -* It should be possible to add a `@DetectThreadLeaks` extension which detects new threads forked within the test -container. This extension takes a parameter - the scope of detection. Either we care about threads leaked -from the entire container or from each individual test. Here is an example of use: +* It should be possible to add a `@DetectThreadLeaks` extension which detects new threads forked within the test + container. This extension takes a parameter - the scope of detection. Either we care about threads leaked + from the entire container or from each individual test. Here is an example of use: ```java @@ -13,35 +13,87 @@ public class TestClass { @Test public void testMethod() { new Thread(() -> { - try { Thread.sleep(1000); } catch (Exception e) {} + try { + Thread.sleep(1000); + } catch (Exception e) { + } }).start(); } } ``` * The extension is **only functional in sequential mode**. It should emit a warning and do nothing if tests are -run in concurrent mode. + run in concurrent mode. * Occasionally there will be threads that cannot be joined but will eventually terminate. One can specify an additional -"linger" time before the thread leak is reported, for example one second, below: - + "linger" time before the thread leak is reported, for example one second, below: + ```java + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) @DetectThreadLeaks.LingerTime(millis = 1_000) public class TestClass { @Test public void testMethod() { new Thread(() -> { - try { Thread.sleep(1000); } catch (Exception e) {} + try { + Thread.sleep(1000); + } catch (Exception e) { + } }).start(); } } ``` * In certain cases, system threads or other threads beyond the test's control may be started and cannot be terminated -within the test's scope. The `@DetectThreadLeaks.ExcludeThreads` annotation can provide programmatic filters which -tell the extension to ignore certain threads. + within the test's scope. The `@DetectThreadLeaks.ExcludeThreads` annotation can provide programmatic filters which + tell the extension to ignore certain threads. + +* When `@DetectThreadLeaks` is active, it also detects **uncaught exceptions** thrown by any thread + during the scope. Such exceptions are collected and reported as test failures. If both a thread leak and uncaught + exceptions occur, all are reported: the leak error is thrown and the + uncaught-exception errors are attached as suppressed exceptions. + +```java + +@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) +public class TestClass { + @Test + public void testMethod() throws InterruptedException { + Thread t = new Thread(() -> { + throw new RuntimeException("background failure"); + }); + t.start(); + t.join(); + } +} +``` ## Migration notes (from randomizedtesting for junit4) +* `@ThreadLeakScope` is replaced with `@DetectThreadLeaks(scope = ...)`. The `NONE` scope + (disabling all checks) has no equivalent; simply remove `@DetectThreadLeaks` from the class. + The default scope changed: the old default was `TEST`; the new default is `SUITE`. + +* `@ThreadLeakLingering(linger = N)` is replaced with `@DetectThreadLeaks.LingerTime(millis = N)`. + The annotation can now be placed on individual test methods as well as on the class, with the + method-level value taking precedence. + +* `@ThreadLeakFilters(filters = {MyFilter.class})`: replace with + `@DetectThreadLeaks.ExcludeThreads(MyFilter.class)`. The filter interface changed from + `ThreadFilter.reject(Thread)` (return `true` to exclude) to `Predicate.test(Thread)` + (return `true` to exclude). Rename and invert the logic accordingly. The `defaultFilters` + flag has no equivalent; built-in system-thread filters are always applied. Filters are now + collected hierarchically from the method, the class, and all superclasses, and combined with OR. + +* `@ThreadLeakAction`: interrupt-on-leak is now always performed as cleanup (no annotation + needed). Leaked threads are interrupted and joined before the failure is reported. There is no + equivalent of the `WARN`-only mode. + +* `@ThreadLeakZombies`: zombie tracking (marking remaining tests as aborted when a leaked + thread could not be killed) is not implemented. Threads that survive the interrupt/join budget + are still reported in the failure message but subsequent tests are not skipped. +* `@ThreadLeakGroup` — the group scope (`ALL` / `MAIN` / `TESTGROUP`) is not configurable. + The extension always uses `Thread.getAllStackTraces()`, equivalent to the old `ALL` group, and + filters out known system threads automatically. From 4ac1c305d69e2d2f1e77c9b211f32f7d39d83b0d Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Wed, 11 Mar 2026 13:00:52 +0100 Subject: [PATCH 07/10] More fixes to thread leaks. --- .../jupiter/DetectThreadLeaks.java | 9 +- .../jupiter/DetectThreadLeaksExtension.java | 103 +++--- .../jupiter/F005_ThreadLeaks.java | 324 ++++++++++++------ .../jupiter/F005_ThreadLeaks.md | 3 +- 4 files changed, 280 insertions(+), 159 deletions(-) diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java index 74a53c0..9791def 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java @@ -26,6 +26,8 @@ Scope scope() default Scope.SUITE; enum Scope { + /** Disable thread leak detection entirely. */ + NONE, /** Check for leaked threads once after all tests in the class complete. */ SUITE, /** Check for leaked threads after each individual test method. */ @@ -51,11 +53,10 @@ enum Scope { * Excludes threads matched by any of the given {@link Predicate} classes from leak detection. A * thread is excluded when at least one predicate returns {@code true} for it. * - *

Annotations are collected hierarchically: the test method, then the class, then each - * superclass, and the filters from all levels are combined. Place on the same class or method as - * {@link DetectThreadLeaks}. + *

Annotations are collected hierarchically from the class and its superclasses, and the + * filters from all levels are combined. */ - @Target({ElementType.TYPE, ElementType.METHOD}) + @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @interface ExcludeThreads { diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java index 0367950..ef1dac5 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java @@ -23,7 +23,7 @@ public class DetectThreadLeaksExtension private static final Logger LOGGER = Logger.getLogger(DetectThreadLeaksExtension.class.getName()); private static final ExtensionContext.Namespace EXTENSION_NAMESPACE = ExtensionContext.Namespace.create(DetectThreadLeaksExtension.class); - private static final String SNAPSHOT_KEY = "snapshot"; + private static final String THREAD_SNAPSHOT_KEY = "snapshot"; private static final String CONCURRENT_KEY = "concurrent"; private static final String UNCAUGHT_EXCEPTION_HANDLER_KEY = "uncaught-exception-handler"; @@ -32,6 +32,10 @@ public class DetectThreadLeaksExtension @Override public void beforeAll(ExtensionContext context) { + if (scope(context) == DetectThreadLeaks.Scope.NONE) { + return; + } + if (context.getExecutionMode() != ExecutionMode.SAME_THREAD) { LOGGER.warning( "Thread leak detection is disabled: tests in [" @@ -40,23 +44,36 @@ public void beforeAll(ExtensionContext context) { context.getStore(EXTENSION_NAMESPACE).put(CONCURRENT_KEY, Boolean.TRUE); return; } - if (scope(context) == DetectThreadLeaks.Scope.SUITE) { - var store = context.getStore(EXTENSION_NAMESPACE); - var filter = buildFilter(context); - store.put(UNCAUGHT_EXCEPTION_HANDLER_KEY, installUncaughtExceptionHandler()); - store.put(SNAPSHOT_KEY, liveThreads(filter)); + + var store = context.getStore(EXTENSION_NAMESPACE); + var filter = buildFilter(context); + store.put(UNCAUGHT_EXCEPTION_HANDLER_KEY, installUncaughtExceptionHandler()); + store.put(THREAD_SNAPSHOT_KEY, liveThreads(filter)); + } + + @Override + public void beforeEach(ExtensionContext context) { + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { + return; } + + var store = context.getStore(EXTENSION_NAMESPACE); + var filter = buildFilter(context); + store.put(THREAD_SNAPSHOT_KEY, liveThreads(filter)); } @Override - public void afterAll(ExtensionContext context) { - if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.SUITE) return; + public void afterEach(ExtensionContext context) { + if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) { + return; + } + var store = context.getStore(EXTENSION_NAMESPACE); var handler = store.get(UNCAUGHT_EXCEPTION_HANDLER_KEY, UncaughtExceptionsHandler.class); try { checkLeaks( store, - "suite [" + context.getDisplayName() + "]", + "test [" + context.getDisplayName() + "]", linger(context), buildFilter(context), handler); @@ -66,23 +83,17 @@ public void afterAll(ExtensionContext context) { } @Override - public void beforeEach(ExtensionContext context) { - if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) return; - var store = context.getStore(EXTENSION_NAMESPACE); - var filter = buildFilter(context); - store.put(UNCAUGHT_EXCEPTION_HANDLER_KEY, installUncaughtExceptionHandler()); - store.put(SNAPSHOT_KEY, liveThreads(filter)); - } + public void afterAll(ExtensionContext context) { + if (isConcurrentMode(context) || scope(context) == DetectThreadLeaks.Scope.NONE) { + return; + } - @Override - public void afterEach(ExtensionContext context) { - if (isConcurrentMode(context) || scope(context) != DetectThreadLeaks.Scope.TEST) return; var store = context.getStore(EXTENSION_NAMESPACE); var handler = store.get(UNCAUGHT_EXCEPTION_HANDLER_KEY, UncaughtExceptionsHandler.class); try { checkLeaks( store, - "test [" + context.getDisplayName() + "]", + "suite [" + context.getDisplayName() + "]", linger(context), buildFilter(context), handler); @@ -119,41 +130,26 @@ private static int linger(ExtensionContext context) { * any filter matches it. */ private static Predicate buildFilter(ExtensionContext context) { - var filterClasses = new LinkedHashSet>>(); - - context - .getTestMethod() - .ifPresent( - m -> { - var ann = m.getAnnotation(DetectThreadLeaks.ExcludeThreads.class); - if (ann != null) { - for (var c : ann.value()) filterClasses.add(c); - } - }); + var filterClasses = new LinkedHashSet>(); for (Class cls = context.getRequiredTestClass(); cls != null; cls = cls.getSuperclass()) { var ann = cls.getAnnotation(DetectThreadLeaks.ExcludeThreads.class); if (ann != null) { - for (var c : ann.value()) filterClasses.add(c); + for (var c : ann.value()) { + try { + filterClasses.add(c.getDeclaredConstructor().newInstance()); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Cannot instantiate thread filter: " + cls.getName(), e); + } + } } } - if (filterClasses.isEmpty()) return t -> false; - - var predicates = - filterClasses.stream() - .map( - cls -> { - try { - return (Predicate) cls.getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new RuntimeException( - "Cannot instantiate thread filter: " + cls.getName(), e); - } - }) - .toList(); - - return t -> predicates.stream().anyMatch(p -> p.test(t)); + if (filterClasses.isEmpty()) { + return t -> false; + } + + return t -> filterClasses.stream().anyMatch(p -> p.test(t)); } private static boolean isConcurrentMode(ExtensionContext context) { @@ -172,7 +168,7 @@ private static void checkLeaks( int lingerMs, Predicate filter, UncaughtExceptionsHandler handler) { - var snapshot = store.get(SNAPSHOT_KEY, HashSet.class); + var snapshot = store.get(THREAD_SNAPSHOT_KEY, HashSet.class); AssertionError leakError = null; if (snapshot != null) { @@ -196,7 +192,10 @@ private static void checkLeaks( if (!leaked.isEmpty()) { // Suppress uncaught exception reporting during the interrupt/join phase to avoid // capturing expected InterruptedException-related exceptions from cleaned-up threads. - if (handler != null) handler.stopReporting(); + if (handler != null) { + handler.stopReporting(); + } + try { leaked.keySet().forEach(Thread::interrupt); long joinDeadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(INTERRUPT_JOIN_MS); @@ -211,7 +210,9 @@ private static void checkLeaks( } } } finally { - if (handler != null) handler.resumeReporting(); + if (handler != null) { + handler.resumeReporting(); + } } var sb = new StringBuilder(leaked.size() + " thread(s) leaked from " + description + ":"); diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java index 4bec4b5..a9dc57a 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java @@ -5,11 +5,16 @@ import static org.junit.platform.testkit.engine.TestExecutionResultConditions.*; import com.carrotsearch.randomizedtesting.jupiter.infra.IgnoreInStandaloneRuns; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import org.assertj.core.api.Condition; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -18,13 +23,28 @@ import org.junit.jupiter.api.parallel.ExecutionMode; /** Verify that {@link DetectThreadLeaks} detects threads leaked from tests. */ +@Execution(ExecutionMode.SAME_THREAD) public class F005_ThreadLeaks { + private static final List forkedThreads = new ArrayList<>(); + + @AfterEach + void interruptAndJoinForkedThreads() throws InterruptedException { + for (var t : forkedThreads) t.interrupt(); + for (var t : forkedThreads) t.join(); + forkedThreads.clear(); + } + @Nested class TestSuiteScope { @TestFactory - Stream leakedThreadIsDetectedAtSuiteEnd() { + Stream leakedThreadIsDetected() { return Stream.of( - LeakInBeforeAllMethod.class, LeakInTestMethod.class, LeakInAfterAllMethod.class) + LeakInBeforeAll.class, + LeakInBeforeEach.class, + LeakInConstructor.class, + LeakInTestMethod.class, + LeakInAfterEach.class, + LeakInAfterAll.class) .map( clazz -> DynamicTest.dynamicTest( @@ -40,6 +60,38 @@ Stream leakedThreadIsDetectedAtSuiteEnd() { })); } + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class LeakInBeforeAll extends IgnoreInStandaloneRuns { + @BeforeAll + static void beforeAll() { + startSleepingThread(); + } + + @Test + void testMethod() {} + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class LeakInBeforeEach extends IgnoreInStandaloneRuns { + @BeforeEach + void beforeEach() { + startSleepingThread(); + } + + @Test + void testMethod() {} + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class LeakInConstructor extends IgnoreInStandaloneRuns { + LeakInConstructor() { + startSleepingThread(); + } + + @Test + void testMethod() {} + } + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) static class LeakInTestMethod extends IgnoreInStandaloneRuns { @Test @@ -49,25 +101,114 @@ void testMethod() { } @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) - static class LeakInAfterAllMethod extends IgnoreInStandaloneRuns { + static class LeakInAfterEach extends IgnoreInStandaloneRuns { @Test void testMethod() {} - @AfterAll - static void afterAll() { + @AfterEach + void afterEach() { startSleepingThread(); } } @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) - static class LeakInBeforeAllMethod extends IgnoreInStandaloneRuns { + static class LeakInAfterAll extends IgnoreInStandaloneRuns { @Test void testMethod() {} + @AfterAll + static void afterAll() { + startSleepingThread(); + } + } + } + + @Nested + class TestTestScope { + @TestFactory + Stream leakedThreadIsDetected() { + return Stream.of( + LeakInBeforeAll.class, + LeakInBeforeEach.class, + LeakInConstructor.class, + LeakInTestMethod.class, + LeakInAfterEach.class, + LeakInAfterAll.class) + .map( + clazz -> + DynamicTest.dynamicTest( + clazz.getSimpleName(), + () -> { + collectExecutionResults(testKitBuilder(clazz)) + .results() + .allEvents() + .finished() + .failed() + .assertEventsMatchExactly( + event(finishedWithFailure(instanceOf(AssertionError.class)))); + })); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class LeakInBeforeAll extends IgnoreInStandaloneRuns { @BeforeAll static void beforeAll() { startSleepingThread(); } + + @Test + void testMethod() {} + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class LeakInBeforeEach extends IgnoreInStandaloneRuns { + @BeforeEach + void beforeEach() { + startSleepingThread(); + } + + @Test + void testMethod() {} + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class LeakInConstructor extends IgnoreInStandaloneRuns { + LeakInConstructor() { + startSleepingThread(); + } + + @Test + void testMethod() {} + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class LeakInTestMethod extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class LeakInAfterEach extends IgnoreInStandaloneRuns { + @Test + void testMethod() {} + + @AfterEach + void afterEach() { + startSleepingThread(); + } + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + static class LeakInAfterAll extends IgnoreInStandaloneRuns { + @Test + void testMethod() {} + + @AfterAll + static void afterAll() { + startSleepingThread(); + } } } @@ -98,27 +239,6 @@ void testMethod() { } } - @Nested - class TestTestScope { - @Test - void leakedThreadIsDetectedAfterTest() { - collectExecutionResults(testKitBuilder(TestScopeWithLeak.class)) - .results() - .allEvents() - .finished() - .failed() - .assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class)))); - } - - @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) - static class TestScopeWithLeak extends IgnoreInStandaloneRuns { - @Test - void testMethod() { - startSleepingThread(); - } - } - } - @Nested class TestLinger { @Test @@ -164,16 +284,7 @@ void threadOutlastingLingerWindowFails() { static class ShortLivedLeak extends IgnoreInStandaloneRuns { @Test void testMethod() { - var t = - new Thread( - () -> { - try { - Thread.sleep(100); - } catch (InterruptedException ignored) { - } - }); - t.setDaemon(true); - t.start(); + startSleepingThread(Duration.ofMillis(100)); } } @@ -193,16 +304,7 @@ static class MethodLingerOverridesAbsentClassLinger extends IgnoreInStandaloneRu @Test @DetectThreadLeaks.LingerTime(millis = 10_000) void testMethod() { - var t = - new Thread( - () -> { - try { - Thread.sleep(100); - } catch (InterruptedException ignored) { - } - }); - t.setDaemon(true); - t.start(); + startSleepingThread(Duration.ofMillis(100)); } } @@ -213,16 +315,7 @@ static class MethodLingerOverridesClassLinger extends IgnoreInStandaloneRuns { @Test @DetectThreadLeaks.LingerTime(millis = 10_000) void testMethod() { - var t = - new Thread( - () -> { - try { - Thread.sleep(100); - } catch (InterruptedException ignored) { - } - }); - t.setDaemon(true); - t.start(); + startSleepingThread(Duration.ofMillis(100)); } } } @@ -285,7 +378,7 @@ void methodAndClassFiltersStackHierarchically() { static class ExcludedByClassFilter extends IgnoreInStandaloneRuns { @Test void testMethod() { - startNamedThread("excluded-a-1"); + startSleepingThread("excluded-a-1"); } } @@ -299,16 +392,16 @@ void testMethod() { } } - // Class filter excludes "excluded-a-*", method filter excludes "excluded-b-*"; - // both threads are started → both excluded → pass. - @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) @DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class) - static class HierarchicalFilters extends IgnoreInStandaloneRuns { + static class Superclass extends IgnoreInStandaloneRuns {} + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) + @DetectThreadLeaks.ExcludeThreads(ExcludeNamedBFilter.class) + static class HierarchicalFilters extends Superclass { @Test - @DetectThreadLeaks.ExcludeThreads(ExcludeNamedBFilter.class) void testMethod() { - startNamedThread("excluded-a-1"); - startNamedThread("excluded-b-1"); + startSleepingThread("excluded-a-1"); + startSleepingThread("excluded-b-1"); } } } @@ -329,20 +422,6 @@ public boolean test(Thread t) { } } - private static void startNamedThread(String name) { - var t = - new Thread( - () -> { - try { - Thread.sleep(TimeUnit.MINUTES.toMillis(1)); - } catch (InterruptedException ignored) { - } - }, - name); - t.setDaemon(true); - t.start(); - } - @Nested class TestUncaughtExceptions { @Test @@ -381,13 +460,12 @@ void uncaughtExceptionsWithThreadLeaksAreNotReported() { static class UncaughtInTestMethod extends IgnoreInStandaloneRuns { @Test void testMethod() throws InterruptedException { - var t = - new Thread( + startThread( + "bg-thread", () -> { throw new RuntimeException("uncaught-test-exception"); - }); - t.start(); - t.join(); + }) + .join(); } } @@ -395,31 +473,71 @@ void testMethod() throws InterruptedException { static class UncaughtWithLeak extends IgnoreInStandaloneRuns { @Test void testMethod() { - var t1 = - new Thread( - () -> { - try { - Thread.sleep(TimeUnit.MINUTES.toMillis(1)); - } catch (InterruptedException ignored) { - throw new RuntimeException("uncaught-test-exception"); - } - }); - t1.start(); - } - } - } - - /** Starts a daemon thread that sleeps long enough to be observable as a leak. */ - private static void startSleepingThread() { - var t = - new Thread( + startThread( + "bg-thread", () -> { try { Thread.sleep(TimeUnit.MINUTES.toMillis(1)); } catch (InterruptedException ignored) { + throw new RuntimeException("uncaught-test-exception"); } }); - t.setDaemon(true); + } + } + } + + @Nested + class TestNoneScope { + @Test + void leakedThreadDoesNotFailWithNoneScope() { + collectExecutionResults(testKitBuilder(NoneScope.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.NONE) + static class NoneScope extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + startSleepingThread(); + } + } + } + + /** Starts a thread that sleeps long enough to be observable as a leak. */ + private static void startSleepingThread() { + startSleepingThread(Duration.ofMinutes(1)); + } + + /** Starts a named thread that sleeps long enough to be observable as a leak. */ + private static void startSleepingThread(String name) { + startThread( + name, + () -> { + try { + Thread.sleep(Duration.ofMinutes(1)); + } catch (InterruptedException ignored) { + } + }); + } + + private static void startSleepingThread(Duration duration) { + startThread( + "sleeping-thread", + () -> { + try { + Thread.sleep(duration.toMillis()); + } catch (InterruptedException ignored) { + } + }); + } + + private static Thread startThread(String name, Runnable r) { + var t = new Thread(r, name); + forkedThreads.add(t); t.start(); + return t; } } diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md index c85c72f..9bafbee 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md @@ -72,7 +72,8 @@ public class TestClass { ## Migration notes (from randomizedtesting for junit4) * `@ThreadLeakScope` is replaced with `@DetectThreadLeaks(scope = ...)`. The `NONE` scope - (disabling all checks) has no equivalent; simply remove `@DetectThreadLeaks` from the class. + (disabling all checks) maps to `@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.NONE)`, + or simply remove `@DetectThreadLeaks` from the class entirely. The default scope changed: the old default was `TEST`; the new default is `SUITE`. * `@ThreadLeakLingering(linger = N)` is replaced with `@DetectThreadLeaks.LingerTime(millis = N)`. From a956fb7d1f9fa5c1cbfd442b1ef84410fa357b8f Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Wed, 11 Mar 2026 18:56:23 +0100 Subject: [PATCH 08/10] More minor tweaks and changes. --- etc/junit4-missing-features.txt | 27 ++------ .../jupiter/DetectThreadLeaks.java | 4 +- .../jupiter/DetectThreadLeaksExtension.java | 66 +++++++++---------- .../jupiter/SystemThreadFilter.java | 35 ++++++++++ .../jupiter/F005_ThreadLeaks.java | 24 +++++-- .../jupiter/F005_ThreadLeaks.md | 12 +++- 6 files changed, 105 insertions(+), 63 deletions(-) create mode 100644 randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java diff --git a/etc/junit4-missing-features.txt b/etc/junit4-missing-features.txt index 9f6016d..ad440a2 100644 --- a/etc/junit4-missing-features.txt +++ b/etc/junit4-missing-features.txt @@ -1,24 +1,5 @@ [ai generated overview of junit4 features] -8. Timeouts - - Standard JUnit @Test(timeout=N) is honoured - - @Timeout(millis=N) annotation provides an explicit alternative - - Termination sequence: Thread.interrupt() → Thread.stop() → zombie - detection; all attempts are logged with stack traces - -9. Thread-leak detection - - Threads that escape a test's ThreadGroup boundary are killed and cause - a test failure - - Encourages explicit Thread.join() before a test method returns - -10. Lingering threads and advanced thread-leak control - - @ThreadLeakLingering(linger=N) waits up to N ms for stray threads to - finish naturally (useful for Executor pools or other uncontrolled threads) - - Additional annotations for fine-grained policy: - @ThreadLeakScope – suite vs. test scope - @ThreadLeakAction – warn vs. fail - @ThreadLeakZombies – ignore vs. fail on zombie threads - 11. Nightly / scaled tests - @Nightly marks a test that only runs when nightly mode is active (-Dtests.nightly=true) @@ -38,4 +19,10 @@ - predictably shuffled test execution order - blowing up test reps using tests.iters -- \ No newline at end of file + +[to check/ add tests of] + +- is the seed stack trace frame injected for leaked threads + randomized testing ext? +- can we enforce the order of extensions (randomized testing > leaked threads) +- how are jupiter timeouts working together with leaked threads ext.? +- maybe bring back thread leak zombies annotation (if we can't cleanly terminate leaked threads, ignore all remaining tests). diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java index 9791def..b2cab4f 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaks.java @@ -55,11 +55,13 @@ enum Scope { * *

Annotations are collected hierarchically from the class and its superclasses, and the * filters from all levels are combined. + * + * @see SystemThreadFilter */ @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @interface ExcludeThreads { - Class>[] value(); + Class>[] value() default {SystemThreadFilter.class}; } } diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java index ef1dac5..72627ab 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/DetectThreadLeaksExtension.java @@ -1,5 +1,6 @@ package com.carrotsearch.randomizedtesting.jupiter; +import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashSet; @@ -27,8 +28,8 @@ public class DetectThreadLeaksExtension private static final String CONCURRENT_KEY = "concurrent"; private static final String UNCAUGHT_EXCEPTION_HANDLER_KEY = "uncaught-exception-handler"; - /** Total time budget (ms) to join interrupted threads before giving up. */ - private static final long INTERRUPT_JOIN_MS = 2_000L; + /** Total time budget to join interrupted threads before giving up. */ + private static final Duration INTERRUPT_JOIN_MS = Duration.ofSeconds(3); @Override public void beforeAll(ExtensionContext context) { @@ -124,32 +125,45 @@ private static int linger(ExtensionContext context) { return classAnn == null ? 0 : classAnn.millis(); } + @DetectThreadLeaks.ExcludeThreads() + private static class AnnotationDefaultsSource {} + /** * Collects {@link DetectThreadLeaks.ExcludeThreads} filter classes from the entire hierarchy * (method to class to superclasses) and returns a combined predicate that excludes a thread when * any filter matches it. */ private static Predicate buildFilter(ExtensionContext context) { - var filterClasses = new LinkedHashSet>(); + List excludeThreads = new ArrayList<>(); for (Class cls = context.getRequiredTestClass(); cls != null; cls = cls.getSuperclass()) { var ann = cls.getAnnotation(DetectThreadLeaks.ExcludeThreads.class); if (ann != null) { - for (var c : ann.value()) { - try { - filterClasses.add(c.getDeclaredConstructor().newInstance()); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Cannot instantiate thread filter: " + cls.getName(), e); - } + excludeThreads.add(ann); + } + } + + if (excludeThreads.isEmpty()) { + excludeThreads.add( + AnnotationDefaultsSource.class.getAnnotation(DetectThreadLeaks.ExcludeThreads.class)); + } + + var filterClasses = new LinkedHashSet>(); + for (var ann : excludeThreads) { + for (var cls : ann.value()) { + try { + filterClasses.add(cls.getDeclaredConstructor().newInstance()); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Cannot instantiate thread filter: " + cls.getName(), e); } } } if (filterClasses.isEmpty()) { return t -> false; + } else { + return t -> filterClasses.stream().anyMatch(p -> p.test(t)); } - - return t -> filterClasses.stream().anyMatch(p -> p.test(t)); } private static boolean isConcurrentMode(ExtensionContext context) { @@ -197,11 +211,17 @@ private static void checkLeaks( } try { + // Send an interrupt to all threads. leaked.keySet().forEach(Thread::interrupt); - long joinDeadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(INTERRUPT_JOIN_MS); + + // Wait for all those threads. + long joinDeadline = System.nanoTime() + INTERRUPT_JOIN_MS.toNanos(); for (Thread t : leaked.keySet()) { long remaining = TimeUnit.NANOSECONDS.toMillis(joinDeadline - System.nanoTime()); - if (remaining <= 0) break; + if (remaining <= 0) { + break; + } + try { t.join(remaining); } catch (InterruptedException e) { @@ -260,27 +280,7 @@ private static HashSet liveThreads(Predicate filter) { private static Map liveThreadsWithStacks(Predicate filter) { return Thread.getAllStackTraces().entrySet().stream() .filter(e -> e.getKey().isAlive()) - .filter(e -> !isKnownSystemThread(e.getKey())) .filter(e -> !filter.test(e.getKey())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } - - private static boolean isKnownSystemThread(Thread t) { - ThreadGroup tgroup = t.getThreadGroup(); - - if (tgroup != null && "system".equals(tgroup.getName()) && tgroup.getParent() == null) { - return true; - } - - return switch (t.getName()) { - case "JFR request timer", - "YJPAgent-Telemetry", - "MemoryPoolMXBean notification dispatcher", - "AWT-AppKit", - "process reaper", - "JUnit5-serializer-daemon" -> - true; - default -> t.getName().contains("Poller SunPKCS11"); - }; - } } diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java new file mode 100644 index 0000000..13100c3 --- /dev/null +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java @@ -0,0 +1,35 @@ +package com.carrotsearch.randomizedtesting.jupiter; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * @see DetectThreadLeaks.ExcludeThreads + */ +public class SystemThreadFilter implements Predicate { + private static final Pattern KNOWN_SUBSTRINGS = + Pattern.compile("(^ForkJoinPool\\.)|(Poller SunPKCS11)"); + + @Override + public boolean test(Thread t) { + ThreadGroup tgroup = t.getThreadGroup(); + + // Ignore the entire system thread group. + if (tgroup != null && "system".equals(tgroup.getName()) && tgroup.getParent() == null) { + return true; + } + + // These are some of the "known" threads that should be ignored. + var tName = t.getName(); + return switch (tName) { + case "JFR request timer", + "YJPAgent-Telemetry", + "MemoryPoolMXBean notification dispatcher", + "AWT-AppKit", + "process reaper", + "JUnit5-serializer-daemon" -> + true; + default -> KNOWN_SUBSTRINGS.matcher(tName).find(); + }; + } +} diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java index a9dc57a..a5ffdc6 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.java @@ -8,6 +8,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import org.assertj.core.api.Condition; @@ -278,7 +279,6 @@ void threadOutlastingLingerWindowFails() { .assertEventsMatchExactly(event(finishedWithFailure(instanceOf(AssertionError.class)))); } - // Class linger 10s; thread sleeps 100ms → terminates before linger expires → pass. @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) @DetectThreadLeaks.LingerTime(millis = 10_000) static class ShortLivedLeak extends IgnoreInStandaloneRuns { @@ -288,7 +288,6 @@ void testMethod() { } } - // Class linger 50ms; thread sleeps 1 min → outlasts linger → fail. @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) @DetectThreadLeaks.LingerTime(millis = 50) static class LongLivedLeak extends IgnoreInStandaloneRuns { @@ -298,7 +297,6 @@ void testMethod() { } } - // Method linger 10s overrides absent class linger; thread sleeps 100ms → pass. @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) static class MethodLingerOverridesAbsentClassLinger extends IgnoreInStandaloneRuns { @Test @@ -308,7 +306,6 @@ void testMethod() { } } - // Method linger 10s overrides class linger 50ms; thread sleeps 100ms → pass. @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.TEST) @DetectThreadLeaks.LingerTime(millis = 50) static class MethodLingerOverridesClassLinger extends IgnoreInStandaloneRuns { @@ -372,7 +369,23 @@ void methodAndClassFiltersStackHierarchically() { .doNotHave(event(finishedWithFailure())); } - // Class filter excludes "excluded-a-*"; the leaked thread matches → pass. + @Test + void forkJoinPoolStartup() { + collectExecutionResults(testKitBuilder(SysFjPool.class)) + .results() + .allEvents() + .assertThatEvents() + .doNotHave(event(finishedWithFailure())); + } + + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) + static class SysFjPool extends IgnoreInStandaloneRuns { + @Test + void testMethod() { + ForkJoinPool.commonPool().submit(() -> {}).join(); + } + } + @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) @DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class) static class ExcludedByClassFilter extends IgnoreInStandaloneRuns { @@ -382,7 +395,6 @@ void testMethod() { } } - // Class filter excludes "excluded-a-*"; leaked thread is unnamed → still detected → fail. @DetectThreadLeaks(scope = DetectThreadLeaks.Scope.SUITE) @DetectThreadLeaks.ExcludeThreads(ExcludeNamedAFilter.class) static class NonExcludedStillFails extends IgnoreInStandaloneRuns { diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md index 9bafbee..df6ba8e 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F005_ThreadLeaks.md @@ -71,9 +71,7 @@ public class TestClass { ## Migration notes (from randomizedtesting for junit4) -* `@ThreadLeakScope` is replaced with `@DetectThreadLeaks(scope = ...)`. The `NONE` scope - (disabling all checks) maps to `@DetectThreadLeaks(scope = DetectThreadLeaks.Scope.NONE)`, - or simply remove `@DetectThreadLeaks` from the class entirely. +* `@ThreadLeakScope` is replaced with `@DetectThreadLeaks(scope = ...)`. The default scope changed: the old default was `TEST`; the new default is `SUITE`. * `@ThreadLeakLingering(linger = N)` is replaced with `@DetectThreadLeaks.LingerTime(millis = N)`. @@ -98,3 +96,11 @@ public class TestClass { * `@ThreadLeakGroup` — the group scope (`ALL` / `MAIN` / `TESTGROUP`) is not configurable. The extension always uses `Thread.getAllStackTraces()`, equivalent to the old `ALL` group, and filters out known system threads automatically. + +* There are subtle differences in the implementation concerning how threads are interrupted (and + how retry attempts are implemented). In particular, the number of retries and their period is not + user-controlled (`tests.killattempts`, `tests.killwait` properties in `RandomizedRunner`). + +* The default value of `@DetectThreadLeaks.ExcludeThreads` points at `SystemThreadFilter`. There is + no `defaultFilters` parameter; just use the `SystemThreadFilter` in any custom declarations filter + declarations. \ No newline at end of file From adadb7006ebd840bbb1a0fae3fa98ef6ce71da36 Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Wed, 11 Mar 2026 18:59:10 +0100 Subject: [PATCH 09/10] Add a comment about extensions. --- .../jupiter/F999_RemovedFeatures.md | 58 +++++++++++-------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F999_RemovedFeatures.md b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F999_RemovedFeatures.md index 78d78dc..cd1074e 100644 --- a/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F999_RemovedFeatures.md +++ b/randomizedtesting-jupiter/src/test/java/com/carrotsearch/randomizedtesting/jupiter/F999_RemovedFeatures.md @@ -1,30 +1,30 @@ # Migration notes: features removed or replaced by junit5 functionality -The following features of junit4-compatible `RandomizedRunner` have been +The following features of junit4-compatible `RandomizedRunner` have been removed or replaced by built-in functionality in junit5. ## Parameterized tests (`@ParametersFactory`) * This feature is already part of JUnit5: use `@ParameterizedClass`, `@ParameterizedTest` -or any other way to create dynamic tests that JUnit5 offers. + or any other way to create dynamic tests that JUnit5 offers. ## Test groups and test filtering * This feature is already part of JUnit5 in the -form of[`@Tag` annotations](https://docs.junit.org/6.0.3/writing-tests/tagging-and-filtering.html). + form of[`@Tag` annotations](https://docs.junit.org/6.0.3/writing-tests/tagging-and-filtering.html). * Direct use of `tests.filter` and `tests.[group-name]` properties -is not as straightforward as it was with `RandomizedRunner`. These properties -need to be converted and passed to JUnit Jupiter using your build system's -facilities: system properties alone won't have any effect. Here is -an [example for gradle](https://docs.gradle.org/current/userguide/java_testing.html#test_grouping). - + is not as straightforward as it was with `RandomizedRunner`. These properties + need to be converted and passed to JUnit Jupiter using your build system's + facilities: system properties alone won't have any effect. Here is + an [example for gradle](https://docs.gradle.org/current/userguide/java_testing.html#test_grouping). + ## Custom test case order (`@TestCaseOrdering`) * This feature is already part of JUnit5 (`@TestMethodOrder`). However, -It is technically not possible to write a reorderer that will use the root -seed value to reorder methods consistently (because method orderers -are run in discovery phase and execution contexts are not available then). + It is technically not possible to write a reorderer that will use the root + seed value to reorder methods consistently (because method orderers + are run in discovery phase and execution contexts are not available then). ## Test instance creation control (`@TestCaseInstanceProvider`). @@ -37,26 +37,34 @@ are run in discovery phase and execution contexts are not available then). ## Custom test-method providers (`@TestMethodProviders()`) * JUnit5/ Jupiter offers many ways to discover tests dynamically. `@TestFactory` -methods, templates, etc. While there is no one-to-one replacement, it should -be possible to reimplement custom test providers with some minor refactorings. -For example, if a `TestMethodProvider` was including all `test*` methods (JUnit3-style), -you could write a `@TestFactory` in a common superclass and then just extend it. This -test factory would something like this: + methods, templates, etc. While there is no one-to-one replacement, it should + be possible to reimplement custom test providers with some minor refactorings. + For example, if a `TestMethodProvider` was including all `test*` methods (JUnit3-style), + you could write a `@TestFactory` in a common superclass and then just extend it. This + test factory would something like this: + ```java + @TestFactory Stream includeTestMethodsWithNoAnnotations() { return Arrays.stream(getClass().getDeclaredMethods()) - .filter(m -> m.getName().startsWith("test") - && m.getParameterCount() == 0 - && !Modifier.isStatic(m.getModifiers())) - .map(m -> DynamicTest.dynamicTest(m.getName(), () -> { - m.invoke(this); - })); + .filter(m -> m.getName().startsWith("test") + && m.getParameterCount() == 0 + && !Modifier.isStatic(m.getModifiers())) + .map(m -> DynamicTest.dynamicTest(m.getName(), () -> { + m.invoke(this); + })); } ``` ## Repeating tests with @Repeat -* Use standard JUnit5 test repetition facilities (like test templates, or `@RepeatedTest` -annotation). To repeat the same test with a constant seed, `@FixSeed` at class or test level. -If the seed is not fixed, it will be different for each test repetition by default. +* Use standard JUnit5 test repetition facilities (like test templates, or `@RepeatedTest` + annotation). To repeat the same test with a constant seed, `@FixSeed` at class or test level. + If the seed is not fixed, it will be different for each test repetition by default. + +## Multiple extensions instead of a single `RandomizedRunner` + +* The `RandomizedRunner` combined multiple features in one class. This has been replaced by multiple + extensions, which can be used independently. So `@Randomized` and `@DetectThreadLeaks` can be used + completely independently of each other, for example. From 78371821d43a2084fe028eea7db7b0549bc6e8a9 Mon Sep 17 00:00:00 2001 From: Dawid Weiss Date: Wed, 11 Mar 2026 19:09:32 +0100 Subject: [PATCH 10/10] Adding javadocs. --- etc/junit4-missing-features.txt | 2 ++ .../randomizedtesting/jupiter/Seed.java | 1 + .../randomizedtesting/jupiter/SeedChain.java | 14 +++++++------- .../jupiter/SystemThreadFilter.java | 3 +++ .../randomizedtesting/jupiter/Threads.java | 4 ++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/etc/junit4-missing-features.txt b/etc/junit4-missing-features.txt index ad440a2..98ab839 100644 --- a/etc/junit4-missing-features.txt +++ b/etc/junit4-missing-features.txt @@ -26,3 +26,5 @@ - can we enforce the order of extensions (randomized testing > leaked threads) - how are jupiter timeouts working together with leaked threads ext.? - maybe bring back thread leak zombies annotation (if we can't cleanly terminate leaked threads, ignore all remaining tests). +- maybe move some of the implementation details to a non-exposed package? +- regenerate the javadocs with public API only. \ No newline at end of file diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Seed.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Seed.java index 75b9bc1..bafefb0 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Seed.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Seed.java @@ -1,5 +1,6 @@ package com.carrotsearch.randomizedtesting.jupiter; +/** A single randomization seed (typically part of a larger {@link SeedChain}). */ public record Seed(long value) { private static final char[] HEX = "0123456789ABCDEF".toCharArray(); static final Seed UNSPECIFIED = new Seed(0); diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SeedChain.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SeedChain.java index ea181d1..7946f64 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SeedChain.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SeedChain.java @@ -2,11 +2,15 @@ import java.util.List; import java.util.Locale; -import java.util.Random; import java.util.stream.Collectors; import java.util.stream.Stream; -record SeedChain(List seeds) { +/** + * A seed chain determines randomization if {@link Randomized} extension is used. A seed chain is a + * sequence of {@link Seed}s, typically associated with one or more hierarchical junit jupiter + * contexts. + */ +public record SeedChain(List seeds) { private static final SeedChain EMPTY = new SeedChain(List.of()); static SeedChain parse(String chain) { @@ -37,7 +41,7 @@ public String toString() { record FirstAndRest(Seed first, SeedChain rest) {} - public FirstAndRest pop() { + FirstAndRest pop() { if (seeds.isEmpty()) { return new FirstAndRest(Seed.UNSPECIFIED, SeedChain.EMPTY); } @@ -46,8 +50,4 @@ public FirstAndRest pop() { var rest = new SeedChain(seeds.subList(1, seeds.size())); return new FirstAndRest(first, rest); } - - private long nextRandomValue() { - return new Random().nextLong(); - } } diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java index 13100c3..c0cdbeb 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/SystemThreadFilter.java @@ -4,6 +4,9 @@ import java.util.regex.Pattern; /** + * The default filter containing sane defaults excluding system and ignorable threads when {@link + * DetectThreadLeaks} extension is used. + * * @see DetectThreadLeaks.ExcludeThreads */ public class SystemThreadFilter implements Predicate { diff --git a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Threads.java b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Threads.java index f7adb2f..c5355ac 100644 --- a/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Threads.java +++ b/randomizedtesting-jupiter/src/main/java/com/carrotsearch/randomizedtesting/jupiter/Threads.java @@ -1,10 +1,10 @@ package com.carrotsearch.randomizedtesting.jupiter; -public final class Threads { +final class Threads { Threads() {} /** Collect thread information, JVM vendor insensitive. */ - public static String threadName(Thread t) { + static String threadName(Thread t) { return "Thread[" + ("id=" + t.threadId()) + (", name=" + t.getName())