diff --git a/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc b/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc index c38852832522..ba26344dc6f0 100644 --- a/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc +++ b/documentation/modules/ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc @@ -38,6 +38,9 @@ repository on GitHub. objects. * New `selectClasspathResources(String...)` and `selectClasspathResources(Listexperimental memory cleanup + * mode. + * + *

Supported values are {@code true} or {@code false}; defaults to + * {@code false}. + * + *

If enabled, the {@link Launcher} removes finished or skipped tests and + * their children from the {@link TestPlan} and engine-internal data + * structures right away to reduce memory consumption, particularly in the + * presence of many dynamically reported tests. + * + *

This is an experimental feature since it breaks some existing + * {@link TestExecutionListener} implementations, in particular if they rely + * on the test plan to provide information about all executed tests + * and containers after they have been executed. Not all + * {@link org.junit.platform.engine.TestEngine TestEngine} implementations + * may be compatible, either. In both cases, please report issues to the + * maintainers of the affected listeners or engines. + * + * @since 6.1 + */ + @API(status = EXPERIMENTAL, since = "6.1") + public static final String MEMORY_CLEANUP_ENABLED_PROPERTY_NAME = "junit.platform.execution.memory.cleanup.enabled"; + private LauncherConstants() { /* no-op */ } diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/TestPlan.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/TestPlan.java index d08cd9df7436..da58c407b463 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/TestPlan.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/TestPlan.java @@ -27,6 +27,7 @@ import java.util.function.Predicate; import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; import org.junit.platform.commons.PreconditionViolationException; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.engine.ConfigurationParameters; @@ -127,6 +128,28 @@ public void addInternal(TestIdentifier testIdentifier) { directChildren.add(testIdentifier); } + @API(status = INTERNAL, since = "6.1") + public void removeInternal(UniqueId uniqueId) { + Preconditions.notNull(uniqueId, "uniqueId must not be null"); + var removedTestIdentifier = removeSubtree(uniqueId); + if (removedTestIdentifier != null) { + roots.removeIf(root -> root.getUniqueIdObject().equals(uniqueId)); + removedTestIdentifier.getParentIdObject().ifPresent( + parentId -> children.getOrDefault(parentId, Set.of()).remove(removedTestIdentifier)); + } + } + + private @Nullable TestIdentifier removeSubtree(UniqueId uniqueId) { + var testIdentifier = allIdentifiers.remove(uniqueId); + var removedChildren = children.remove(uniqueId); + if (removedChildren != null && !removedChildren.isEmpty()) { + for (var child : removedChildren) { + removeSubtree(child.getUniqueIdObject()); + } + } + return testIdentifier; + } + /** * Get the root {@link TestIdentifier TestIdentifiers} for this test plan. * diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java index 1f0584638958..7e94fb156128 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java @@ -14,7 +14,6 @@ import static org.junit.platform.launcher.LauncherConstants.DRY_RUN_PROPERTY_NAME; import static org.junit.platform.launcher.LauncherConstants.STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME; import static org.junit.platform.launcher.core.LauncherPhase.getDiscoveryIssueFailurePhase; -import static org.junit.platform.launcher.core.ListenerRegistry.forEngineExecutionListeners; import java.util.Collection; import java.util.Optional; @@ -34,6 +33,7 @@ import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.support.store.Namespace; import org.junit.platform.engine.support.store.NamespacedHierarchicalStore; +import org.junit.platform.launcher.LauncherConstants; import org.junit.platform.launcher.TestExecutionListener; import org.junit.platform.launcher.TestIdentifier; import org.junit.platform.launcher.TestPlan; @@ -140,10 +140,20 @@ public void postVisitContainer(TestIdentifier testIdentifier) { private static EngineExecutionListener buildEngineExecutionListener( EngineExecutionListener parentEngineExecutionListener, TestExecutionListener testExecutionListener, TestPlan testPlan) { - ListenerRegistry engineExecutionListenerRegistry = forEngineExecutionListeners(); - engineExecutionListenerRegistry.add(new ExecutionListenerAdapter(testPlan, testExecutionListener)); - engineExecutionListenerRegistry.add(parentEngineExecutionListener); - return engineExecutionListenerRegistry.getCompositeListener(); + var registry = ListenerRegistry.forEngineExecutionListeners(); + registry.add(new ExecutionListenerAdapter(testPlan, testExecutionListener)); + registry.add(parentEngineExecutionListener); + var listener = registry.getCompositeListener(); + if (isMemoryCleanupEnabled(testPlan)) { + listener = new MemoryCleanupListener(listener, testPlan); + } + return listener; + } + + private static Boolean isMemoryCleanupEnabled(TestPlan testPlan) { + return testPlan.getConfigurationParameters() // + .getBoolean(LauncherConstants.MEMORY_CLEANUP_ENABLED_PROPERTY_NAME) // + .orElse(false); } private void withInterceptedStreams(ConfigurationParameters configurationParameters, diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/InternalTestPlan.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/InternalTestPlan.java index 32ad825cefe0..745be1248401 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/InternalTestPlan.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/InternalTestPlan.java @@ -61,6 +61,11 @@ public void addInternal(TestIdentifier testIdentifier) { delegate.addInternal(testIdentifier); } + @Override + public void removeInternal(UniqueId uniqueId) { + delegate.removeInternal(uniqueId); + } + @Override public Set getRoots() { return delegate.getRoots(); diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/MemoryCleanupListener.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/MemoryCleanupListener.java new file mode 100644 index 000000000000..9bf8fa3fda9c --- /dev/null +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/MemoryCleanupListener.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.launcher.core; + +import org.junit.platform.engine.EngineExecutionListener; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.TestExecutionResult; +import org.junit.platform.launcher.TestPlan; + +/** + * @since 6.1 + */ +class MemoryCleanupListener extends DelegatingEngineExecutionListener { + + private final TestPlan testPlan; + + MemoryCleanupListener(EngineExecutionListener delegate, TestPlan testPlan) { + super(delegate); + this.testPlan = testPlan; + } + + @Override + public void executionSkipped(TestDescriptor testDescriptor, String reason) { + super.executionSkipped(testDescriptor, reason); + cleanUp(testDescriptor); + } + + @Override + public void executionFinished(TestDescriptor testDescriptor, TestExecutionResult testExecutionResult) { + super.executionFinished(testDescriptor, testExecutionResult); + cleanUp(testDescriptor); + } + + private void cleanUp(TestDescriptor testDescriptor) { + testPlan.removeInternal(testDescriptor.getUniqueId()); + if (!testDescriptor.isRoot()) { + testDescriptor.removeFromHierarchy(); + } + } +} diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/listeners/MutableTestExecutionSummary.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/listeners/MutableTestExecutionSummary.java index bdd4b629dc90..4d73304fd9b0 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/listeners/MutableTestExecutionSummary.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/listeners/MutableTestExecutionSummary.java @@ -20,10 +20,13 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.engine.UniqueId; import org.junit.platform.launcher.TestIdentifier; import org.junit.platform.launcher.TestPlan; @@ -58,6 +61,7 @@ class MutableTestExecutionSummary implements TestExecutionSummary { private final TestPlan testPlan; private final List failures = synchronizedList(new ArrayList<>()); + private final Map descriptions = new ConcurrentHashMap<>(); private final long timeStarted; private final long timeStartedNanos; long timeFinished; @@ -73,6 +77,7 @@ class MutableTestExecutionSummary implements TestExecutionSummary { void addFailure(TestIdentifier testIdentifier, Throwable throwable) { this.failures.add(new DefaultFailure(testIdentifier, throwable)); + this.descriptions.put(testIdentifier.getUniqueIdObject(), describeTest(testIdentifier)); } @Override @@ -204,8 +209,9 @@ public void printFailuresTo(PrintWriter writer, int maxStackTraceLines) { if (getTotalFailureCount() > 0) { writer.printf("%nFailures (%d):%n", getTotalFailureCount()); this.failures.forEach(failure -> { - writer.printf("%s%s%n", TAB, describeTest(failure.getTestIdentifier())); - printSource(writer, failure.getTestIdentifier()); + var testIdentifier = failure.getTestIdentifier(); + writer.printf("%s%s%n", TAB, descriptions.get(testIdentifier.getUniqueIdObject())); + printSource(writer, testIdentifier); writer.printf("%s=> %s%n", DOUBLE_TAB, failure.getException()); printStackTrace(writer, failure.getException(), maxStackTraceLines); }); @@ -229,11 +235,11 @@ private void collectTestDescription(TestIdentifier identifier, List desc this.testPlan.getParent(identifier).ifPresent(parent -> collectTestDescription(parent, descriptionParts)); } - private void printSource(PrintWriter writer, TestIdentifier testIdentifier) { + private static void printSource(PrintWriter writer, TestIdentifier testIdentifier) { testIdentifier.getSource().ifPresent(source -> writer.printf("%s%s%n", DOUBLE_TAB, source)); } - private void printStackTrace(PrintWriter writer, Throwable throwable, int max) { + private static void printStackTrace(PrintWriter writer, Throwable throwable, int max) { if (throwable.getCause() != null || (throwable.getSuppressed() != null && throwable.getSuppressed().length > 0)) { max = max / 2; @@ -242,7 +248,7 @@ private void printStackTrace(PrintWriter writer, Throwable throwable, int max) { writer.flush(); } - private void printStackTrace(PrintWriter writer, StackTraceElement[] parentTrace, Throwable throwable, + private static void printStackTrace(PrintWriter writer, StackTraceElement[] parentTrace, Throwable throwable, String caption, String indentation, Set seenThrowables, int max) { if (seenThrowables.contains(throwable)) { writer.printf("%s%s[%s%s]%n", indentation, TAB, CIRCULAR, throwable); @@ -272,7 +278,7 @@ private void printStackTrace(PrintWriter writer, StackTraceElement[] parentTrace } } - private int numberOfCommonFrames(StackTraceElement[] currentTrace, StackTraceElement[] parentTrace) { + private static int numberOfCommonFrames(StackTraceElement[] currentTrace, StackTraceElement[] parentTrace) { int currentIndex = currentTrace.length - 1; for (int parentIndex = parentTrace.length - 1; currentIndex >= 0 && parentIndex >= 0; currentIndex--, parentIndex--) { diff --git a/platform-tooling-support-tests/projects/memory-cleanup/src/OneMillionTests.java b/platform-tooling-support-tests/projects/memory-cleanup/src/OneMillionTests.java new file mode 100644 index 000000000000..b5ebffae9ce8 --- /dev/null +++ b/platform-tooling-support-tests/projects/memory-cleanup/src/OneMillionTests.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +class OneMillionTests { + + @TestFactory + Stream tests() { + return IntStream.range(0, 1_000_000) // + .mapToObj(i -> dynamicTest("test " + i, () -> { + assertTrue(i + 1 < 1_000_000); // fail last test + })); + } + +} diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MemoryCleanupTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MemoryCleanupTests.java new file mode 100644 index 000000000000..860013dd440d --- /dev/null +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MemoryCleanupTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package platform.tooling.support.tests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.platform.launcher.LauncherConstants.MEMORY_CLEANUP_ENABLED_PROPERTY_NAME; +import static platform.tooling.support.tests.Projects.copyToWorkspace; + +import java.nio.file.Path; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.DisabledOnOpenJ9; +import org.junit.jupiter.api.io.TempDir; +import org.junit.platform.tests.process.OutputFiles; +import org.junit.platform.tests.process.ProcessResult; + +import platform.tooling.support.MavenRepo; +import platform.tooling.support.ProcessStarters; + +/** + * @since 6.1 + */ +class MemoryCleanupTests { + + @TempDir + Path workspace; + + @Test + @DisabledOnOpenJ9 + void runsWithSmallHeapSize(@FilePrefix("javac") OutputFiles javacOutputFiles, + @FilePrefix("java") OutputFiles javaOutputFiles) throws Exception { + + copyToWorkspace(Projects.MEMORY_CLEANUP, workspace); + compile(javacOutputFiles); + + var timeout = Duration.ofSeconds(OS.WINDOWS.isCurrentOs() ? 20 : 10); + var result = assertTimeoutPreemptively(timeout, () -> executeWithSmallHeapSize(javaOutputFiles)); + + assertThat(result).isNotNull(); + assertThat(result.exitCode()).isOne(); + assertThat(result.stdOut()) // + .contains("1000000 tests found") // + .contains(" 999999 tests successful") // + .contains(" 1 tests failed"); + } + + void compile(OutputFiles javacOutputFiles) throws Exception { + var result = ProcessStarters.javaCommand("javac") // + .workingDir(workspace) // + .addArguments("-Xlint:all") // + .addArguments("--release", "17") // + .addArguments("-proc:none") // + .addArguments("-d", workspace.resolve("bin").toString()) // + .addArguments("--class-path", MavenRepo.jar("junit-platform-console-standalone").toString()) // + .addArguments(workspace.resolve("src/OneMillionTests.java").toString()) // + .redirectOutput(javacOutputFiles) // + .startAndWait(); + + assertThat(result.exitCode()).isZero(); + assertThat(result.stdOut()).isEmpty(); + assertThat(result.stdErr()).isEmpty(); + } + + private ProcessResult executeWithSmallHeapSize(OutputFiles outputFiles) throws Exception { + return ProcessStarters.java() // + .workingDir(workspace) // + .addArguments("-Xmx16m") // + .addArguments("-jar", MavenRepo.jar("junit-platform-console-standalone").toString()) // + .addArguments("execute") // + .addArguments("--scan-class-path") // + .addArguments("--disable-banner") // + .addArguments("--classpath", "bin") // + .addArguments("--details=summary") // + .addArguments("--config=%s=%s".formatted(MEMORY_CLEANUP_ENABLED_PROPERTY_NAME, true)) // + .redirectOutput(outputFiles) // + .startAndWait(); + } + +} diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java index fdf72f970313..0bec6f3b1f5a 100644 --- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java @@ -25,6 +25,7 @@ public class Projects { public static final String JUPITER_STARTER = "jupiter-starter"; public static final String KOTLIN_COROUTINES = "kotlin-coroutines"; public static final String MAVEN_SUREFIRE_COMPATIBILITY = "maven-surefire-compatibility"; + public static final String MEMORY_CLEANUP = "memory-cleanup"; public static final String REFLECTION_TESTS = "reflection-tests"; public static final String STANDALONE = "standalone"; public static final String VINTAGE = "vintage";