From 41b9bb5f1b9c71a893c571735be3a25d0d0518d2 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 7 Apr 2026 11:00:19 +0200 Subject: [PATCH 01/13] Introduce experimental memory cleanup mode Issue: #5344 --- .../platform/launcher/LauncherConstants.java | 15 ++++++ .../org/junit/platform/launcher/TestPlan.java | 8 +++ .../core/EngineExecutionOrchestrator.java | 13 ++++- .../launcher/core/MemoryCleanupListener.java | 54 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/MemoryCleanupListener.java diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java index 8f85bc6301af..1b56ad866fcc 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java @@ -269,6 +269,21 @@ public class LauncherConstants { @API(status = EXPERIMENTAL, since = "6.0") public static final String DISCOVERY_ISSUE_FAILURE_PHASE_PROPERTY_NAME = "junit.platform.discovery.issue.failure.phase"; + /** + * Property name used to enable the experimental memory cleanup + * mode. + * + *

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

If enabled, the Launcher removes finished or skipped tests and their + * children from the test plan right away to reduce memory consumption, + * particularly in the presence of many dynamically reported tests. + * + * @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..d3a968686943 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 @@ -127,6 +127,14 @@ 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"); + allIdentifiers.remove(uniqueId); + roots.removeIf(testIdentifier -> testIdentifier.getUniqueIdObject().equals(uniqueId)); + children.remove(uniqueId); + } + /** * 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..269ff5512877 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 @@ -34,6 +34,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; @@ -141,11 +142,21 @@ private static EngineExecutionListener buildEngineExecutionListener( EngineExecutionListener parentEngineExecutionListener, TestExecutionListener testExecutionListener, TestPlan testPlan) { ListenerRegistry engineExecutionListenerRegistry = forEngineExecutionListeners(); - engineExecutionListenerRegistry.add(new ExecutionListenerAdapter(testPlan, testExecutionListener)); + EngineExecutionListener listener = new ExecutionListenerAdapter(testPlan, testExecutionListener); + if (isMemoryCleanupEnabled(testPlan)) { + listener = new MemoryCleanupListener(listener, testPlan); + } + engineExecutionListenerRegistry.add(listener); engineExecutionListenerRegistry.add(parentEngineExecutionListener); return engineExecutionListenerRegistry.getCompositeListener(); } + private static Boolean isMemoryCleanupEnabled(TestPlan testPlan) { + return testPlan.getConfigurationParameters() // + .getBoolean(LauncherConstants.MEMORY_CLEANUP_ENABLED_PROPERTY_NAME) // + .orElse(false); + } + private void withInterceptedStreams(ConfigurationParameters configurationParameters, ListenerRegistry listenerRegistry, Consumer action) { 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..8f2cb0773a08 --- /dev/null +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/MemoryCleanupListener.java @@ -0,0 +1,54 @@ +/* + * 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 java.util.stream.Stream; + +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) { + var ownUniqueId = Stream.of(testDescriptor.getUniqueId()); + var descendantUniqueIds = testDescriptor.getDescendants().stream() // + .map(TestDescriptor::getUniqueId); + Stream.concat(ownUniqueId, descendantUniqueIds) // + .forEach(testPlan::removeInternal); + if (!testDescriptor.isRoot()) { + testDescriptor.removeFromHierarchy(); + } + } +} From a3eee5e1438b0dd2dcb4de26f081b0a1ce7e7d8d Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 8 Apr 2026 01:02:22 +0200 Subject: [PATCH 02/13] Override removeInternal in InternalTestPlan --- .../org/junit/platform/launcher/core/InternalTestPlan.java | 6 ++++++ 1 file changed, 6 insertions(+) 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..1e3f32746f46 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 @@ -16,6 +16,7 @@ import java.util.function.Predicate; import org.junit.platform.commons.PreconditionViolationException; +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; @@ -61,6 +62,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(); From 8737d94677d3fe1d964f34aebb6116199ae8c283 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 8 Apr 2026 01:50:33 +0200 Subject: [PATCH 03/13] Spotless --- .../java/org/junit/platform/launcher/core/InternalTestPlan.java | 1 - 1 file changed, 1 deletion(-) 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 1e3f32746f46..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 @@ -16,7 +16,6 @@ import java.util.function.Predicate; import org.junit.platform.commons.PreconditionViolationException; -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; From 3fbe8b5b7f9e91e56a3dc693bd3fc0209bda2949 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 8 Apr 2026 02:03:28 +0200 Subject: [PATCH 04/13] Also remove from parent->child mapping --- .../main/java/org/junit/platform/launcher/TestPlan.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 d3a968686943..1182d0e9ee8c 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 @@ -19,6 +19,7 @@ import static org.apiguardian.api.API.Status.STABLE; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; @@ -130,9 +131,13 @@ public void addInternal(TestIdentifier testIdentifier) { @API(status = INTERNAL, since = "6.1") public void removeInternal(UniqueId uniqueId) { Preconditions.notNull(uniqueId, "uniqueId must not be null"); - allIdentifiers.remove(uniqueId); - roots.removeIf(testIdentifier -> testIdentifier.getUniqueIdObject().equals(uniqueId)); + TestIdentifier removedTestIdentifier = allIdentifiers.remove(uniqueId); + roots.removeIf(root -> root.getUniqueIdObject().equals(uniqueId)); children.remove(uniqueId); + if (removedTestIdentifier != null) { + removedTestIdentifier.getParentIdObject().ifPresent( + parentId -> children.getOrDefault(parentId, Collections.emptySet()).remove(removedTestIdentifier)); + } } /** From a53fea8882f0b86be952602f29f6e4d3d8d20c33 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 8 Apr 2026 09:15:21 +0200 Subject: [PATCH 05/13] Wrap parent engine execution listener as well --- .../launcher/core/EngineExecutionOrchestrator.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 269ff5512877..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; @@ -141,14 +140,14 @@ public void postVisitContainer(TestIdentifier testIdentifier) { private static EngineExecutionListener buildEngineExecutionListener( EngineExecutionListener parentEngineExecutionListener, TestExecutionListener testExecutionListener, TestPlan testPlan) { - ListenerRegistry engineExecutionListenerRegistry = forEngineExecutionListeners(); - EngineExecutionListener listener = new ExecutionListenerAdapter(testPlan, testExecutionListener); + 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); } - engineExecutionListenerRegistry.add(listener); - engineExecutionListenerRegistry.add(parentEngineExecutionListener); - return engineExecutionListenerRegistry.getCompositeListener(); + return listener; } private static Boolean isMemoryCleanupEnabled(TestPlan testPlan) { From 3a00e90d9ce180e7b7e9c0c27d3d63ca1d7c7a79 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 8 Apr 2026 09:16:30 +0200 Subject: [PATCH 06/13] Avoid unnecessary operations for descendants of removed identifiers --- .../org/junit/platform/launcher/TestPlan.java | 20 ++++++++++++++----- .../launcher/core/MemoryCleanupListener.java | 8 +------- 2 files changed, 16 insertions(+), 12 deletions(-) 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 1182d0e9ee8c..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 @@ -19,7 +19,6 @@ import static org.apiguardian.api.API.Status.STABLE; import java.util.Collection; -import java.util.Collections; import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; @@ -28,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; @@ -131,15 +131,25 @@ public void addInternal(TestIdentifier testIdentifier) { @API(status = INTERNAL, since = "6.1") public void removeInternal(UniqueId uniqueId) { Preconditions.notNull(uniqueId, "uniqueId must not be null"); - TestIdentifier removedTestIdentifier = allIdentifiers.remove(uniqueId); - roots.removeIf(root -> root.getUniqueIdObject().equals(uniqueId)); - children.remove(uniqueId); + var removedTestIdentifier = removeSubtree(uniqueId); if (removedTestIdentifier != null) { + roots.removeIf(root -> root.getUniqueIdObject().equals(uniqueId)); removedTestIdentifier.getParentIdObject().ifPresent( - parentId -> children.getOrDefault(parentId, Collections.emptySet()).remove(removedTestIdentifier)); + 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/MemoryCleanupListener.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/MemoryCleanupListener.java index 8f2cb0773a08..9bf8fa3fda9c 100644 --- 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 @@ -10,8 +10,6 @@ package org.junit.platform.launcher.core; -import java.util.stream.Stream; - import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.TestExecutionResult; @@ -42,11 +40,7 @@ public void executionFinished(TestDescriptor testDescriptor, TestExecutionResult } private void cleanUp(TestDescriptor testDescriptor) { - var ownUniqueId = Stream.of(testDescriptor.getUniqueId()); - var descendantUniqueIds = testDescriptor.getDescendants().stream() // - .map(TestDescriptor::getUniqueId); - Stream.concat(ownUniqueId, descendantUniqueIds) // - .forEach(testPlan::removeInternal); + testPlan.removeInternal(testDescriptor.getUniqueId()); if (!testDescriptor.isRoot()) { testDescriptor.removeFromHierarchy(); } From 48f77bbebc684339b8f8d173522905f488498d33 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 8 Apr 2026 09:32:59 +0200 Subject: [PATCH 07/13] Fix summary printing --- .../listeners/MutableTestExecutionSummary.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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--) { From b80c372bbfefb4c28c8242cdab0335eaf96350ff Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Apr 2026 16:51:52 +0200 Subject: [PATCH 08/13] Add integration test --- .../memory-cleanup/src/OneMillionTests.java | 30 +++++++ .../support/tests/MemoryCleanupTests.java | 86 +++++++++++++++++++ .../tooling/support/tests/Projects.java | 1 + 3 files changed, 117 insertions(+) create mode 100644 platform-tooling-support-tests/projects/memory-cleanup/src/OneMillionTests.java create mode 100644 platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MemoryCleanupTests.java 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..b42cd1be02fa --- /dev/null +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MemoryCleanupTests.java @@ -0,0 +1,86 @@ +/* + * 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.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 + void runsWithSmallHeapSize(@FilePrefix("javac") OutputFiles javacOutputFiles, + @FilePrefix("java") OutputFiles javaOutputFiles) throws Exception { + + copyToWorkspace(Projects.MEMORY_CLEANUP, workspace); + compile(javacOutputFiles); + + var result = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> 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"; From 1d11a29fbb85bcb0af9e112b10ee7d40ea9c99ae Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Apr 2026 16:52:08 +0200 Subject: [PATCH 09/13] Document default value --- .../java/org/junit/platform/launcher/LauncherConstants.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java index 1b56ad866fcc..b28a5ba08cb9 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java @@ -273,7 +273,8 @@ public class LauncherConstants { * Property name used to enable the experimental memory cleanup * mode. * - *

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

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

If enabled, the Launcher removes finished or skipped tests and their * children from the test plan right away to reduce memory consumption, From b0825f5442d7379a895c1ea98a48c027279599c5 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Apr 2026 17:08:32 +0200 Subject: [PATCH 10/13] Improve Javadoc --- .../platform/launcher/LauncherConstants.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java index b28a5ba08cb9..b9ed677c7bb0 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java @@ -276,9 +276,18 @@ public class LauncherConstants { *

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

If enabled, the Launcher removes finished or skipped tests and their - * children from the test plan right away to reduce memory consumption, - * particularly in the presence of many dynamically reported tests. + *

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 */ From 747739c230777bbef8293e7fb3d56b87cf1e1091 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Apr 2026 17:08:44 +0200 Subject: [PATCH 11/13] Add entry to release notes --- .../ROOT/partials/release-notes/release-notes-6.1.0-M2.adoc | 3 +++ 1 file changed, 3 insertions(+) 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(List Date: Fri, 10 Apr 2026 17:12:07 +0200 Subject: [PATCH 12/13] Disable on OpenJ9 --- .../java/platform/tooling/support/tests/MemoryCleanupTests.java | 2 ++ 1 file changed, 2 insertions(+) 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 index b42cd1be02fa..7a34061a8e26 100644 --- 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 @@ -19,6 +19,7 @@ import java.time.Duration; import org.junit.jupiter.api.Test; +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; @@ -35,6 +36,7 @@ class MemoryCleanupTests { Path workspace; @Test + @DisabledOnOpenJ9 void runsWithSmallHeapSize(@FilePrefix("javac") OutputFiles javacOutputFiles, @FilePrefix("java") OutputFiles javaOutputFiles) throws Exception { From 3c0b0e739eb3d7778cc3afb1e34d101c9a1ecebf Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Apr 2026 17:46:12 +0200 Subject: [PATCH 13/13] Increase timeout on Windows --- .../platform/tooling/support/tests/MemoryCleanupTests.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 7a34061a8e26..860013dd440d 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -43,7 +44,8 @@ void runsWithSmallHeapSize(@FilePrefix("javac") OutputFiles javacOutputFiles, copyToWorkspace(Projects.MEMORY_CLEANUP, workspace); compile(javacOutputFiles); - var result = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> executeWithSmallHeapSize(javaOutputFiles)); + var timeout = Duration.ofSeconds(OS.WINDOWS.isCurrentOs() ? 20 : 10); + var result = assertTimeoutPreemptively(timeout, () -> executeWithSmallHeapSize(javaOutputFiles)); assertThat(result).isNotNull(); assertThat(result.exitCode()).isOne();