diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc index bd1bce04e35d..a52b75de61b0 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc @@ -21,13 +21,20 @@ repository on GitHub. [[release-notes-6.1.0-M1-junit-platform-deprecations-and-breaking-changes]] ==== Deprecations and Breaking Changes -* ❓ +* Deprecate constructors for `ForkJoinPoolHierarchicalTestExecutorService` in favor of the + new `ParallelHierarchicalTestExecutorServiceFactory` that also supports + `WorkerThreadPoolHierarchicalTestExecutorService`. [[release-notes-6.1.0-M1-junit-platform-new-features-and-improvements]] ==== New Features and Improvements * Support for creating a `ModuleSelector` from a `java.lang.Module` and using its classloader for test discovery. +* New `WorkerThreadPoolHierarchicalTestExecutorService` implementation used for parallel + test execution that is backed by a regular thread pool rather than a `ForkJoinPool`. + Engine authors should switch to use `ParallelHierarchicalTestExecutorServiceFactory` + rather than instantiating a concrete `HierarchicalTestExecutorService` implementation + for parallel execution directly. * `OpenTestReportGeneratingListener` now supports redirecting XML events to a socket via the new `junit.platform.reporting.open.xml.socket` configuration parameter. When set to a port number, events are sent to `127.0.0.1:` instead of being written to a file. @@ -61,6 +68,13 @@ repository on GitHub. * Enrich `assertInstanceOf` failure using the test subject `Throwable` as cause. It results in the stack trace of the test subject `Throwable` to get reported along with the failure. +* Make implementation of `HierarchicalTestExecutorService` used for parallel test + execution configurable via the new + `junit.jupiter.execution.parallel.config.executor-service` configuration parameter to + in order to add support for `WorkerThreadPoolHierarchicalTestExecutorService`. Please + refer to the + <<../user-guide/index.adoc#writing-tests-parallel-execution-config-executor-service, User Guide>> + for details. [[release-notes-6.1.0-M1-junit-vintage]] === JUnit Vintage diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 1ac413a7342f..d3ccc0e96312 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3433,6 +3433,33 @@ used instead. [[writing-tests-parallel-execution-config]] ==== Configuration +[[writing-tests-parallel-execution-config-executor-service]] +===== Executor Service + +If parallel execution is enabled, a thread pool is used behind the scenes to execute tests +concurrently. You can configure which implementation of `HierarchicalTestExecutorService` +is used be setting the `junit.jupiter.execution.parallel.config.executor-service` +configuration parameter to one of the following options: + +`fork_join_pool` (default):: +Use an executor service that is backed by a `ForkJoinPool` from the JDK. This will cause +tests to be executed in a `ForkJoinWorkerThread`. In some cases, usages of +`ForkJoinPool` in test or production code or calls to blocking JDK APIs may cause the +number of concurrently executing tests to increase. To avoid this situation, please use +`worker_thread_pool`. + +`worker_thread_pool` (experimental):: +Use an executor service that is backed by a regular thread pool and does not create +additional threads if test or production code uses `ForkJoinPool` or calls a blocking +API in the JDK. + +WARNING: Using `worker_thread_pool` is currently an _experimental_ feature. You're invited +to give it a try and provide feedback to the JUnit team so they can improve and eventually +<> this feature. + +[[writing-tests-parallel-execution-config-strategies]] +===== Strategies + Properties such as the desired parallelism and the maximum pool size can be configured using a `{ParallelExecutionConfigurationStrategy}`. The JUnit Platform provides two implementations out of the box: `dynamic` and `fixed`. Alternatively, you may implement a @@ -3464,13 +3491,12 @@ strategy with a factor of `1`. Consequently, the desired parallelism will be equ number of available processors/cores. .Parallelism alone does not imply maximum number of concurrent threads -NOTE: By default JUnit Jupiter does not guarantee that the number of concurrently -executing tests will not exceed the configured parallelism. For example, when using one -of the synchronization mechanisms described in the next section, the `ForkJoinPool` that -is used behind the scenes may spawn additional threads to ensure execution continues with -sufficient parallelism. -If you require such guarantees, it is possible to limit the maximum number of concurrent -threads by controlling the maximum pool size of the `dynamic`, `fixed` and `custom` +NOTE: By default, JUnit Jupiter does not guarantee that the number of threads used to +execute test will not exceed the configured parallelism. For example, when using one +of the synchronization mechanisms described in the next section, the executor service +implementation may spawn additional threads to ensure execution continues with sufficient +parallelism. If you require such guarantees, it is possible to limit the maximum number of +threads by configuring the maximum pool size of the `dynamic`, `fixed` and `custom` strategies. [[writing-tests-parallel-execution-config-properties]] @@ -3479,86 +3505,66 @@ strategies. The following table lists relevant properties for configuring parallel execution. See <> for details on how to set such properties. -[cols="d,d,a,d"] -|=== -|Property |Description |Supported Values |Default Value - -| ```junit.jupiter.execution.parallel.enabled``` -| Enable parallel test execution -| - * `true` - * `false` -| ```false``` - -| ```junit.jupiter.execution.parallel.mode.default``` -| Default execution mode of nodes in the test tree -| - * `concurrent` - * `same_thread` -| ```same_thread``` - -| ```junit.jupiter.execution.parallel.mode.classes.default``` -| Default execution mode of top-level classes -| - * `concurrent` - * `same_thread` -| ```same_thread``` - -| ```junit.jupiter.execution.parallel.config.strategy``` -| Execution strategy for desired parallelism and maximum pool size -| - * `dynamic` - * `fixed` - * `custom` -| ```dynamic``` - -| ```junit.jupiter.execution.parallel.config.dynamic.factor``` -| Factor to be multiplied by the number of available processors/cores to determine the - desired parallelism for the ```dynamic``` configuration strategy -| a positive decimal number -| ```1.0``` - -| ```junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor``` -| Factor to be multiplied by the number of available processors/cores and the value of +====== General + +`junit.jupiter.execution.parallel.enabled=true|false`:: + Enable/disable parallel test execution (defaults to `false`). + +`junit.jupiter.execution.parallel.mode.default=concurrent|same_thread`:: + Default execution mode of nodes in the test tree (defaults to `same_thread`). + +`junit.jupiter.execution.parallel.mode.classes.default=concurrent|same_thread`:: + Default execution mode of top-level classes (defaults to `same_thread`). + +`junit.jupiter.execution.parallel.config.executor-service=fork_join_pool|worker_thread_pool`:: + Type of `HierarchicalTestExecutorService` to use for parallel execution (defaults to + `fork_join_pool`). + +`junit.jupiter.execution.parallel.config.strategy=dynamic|fixed|custom`:: + Execution strategy for desired parallelism, maximum pool size, etc. (defaults to `dynamic`). + +====== Dynamic strategy + +`junit.jupiter.execution.parallel.config.dynamic.factor=decimal`:: + Factor to be multiplied by the number of available processors/cores to determine the + desired parallelism for the ```dynamic``` configuration strategy. + Must be a positive decimal number (defaults to `1.0`). + +`junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor=decimal`:: + Factor to be multiplied by the number of available processors/cores and the value of `junit.jupiter.execution.parallel.config.dynamic.factor` to determine the desired - parallelism for the ```dynamic``` configuration strategy -| a positive decimal number, must be greater than or equal to `1.0` -| 256 + the value of `junit.jupiter.execution.parallel.config.dynamic.factor` multiplied - by the number of available processors/cores - -| ```junit.jupiter.execution.parallel.config.dynamic.saturate``` -| Disable saturation of the underlying fork-join pool for the ```dynamic``` configuration -strategy -| -* `true` -* `false` -| ```true``` - -| ```junit.jupiter.execution.parallel.config.fixed.parallelism``` -| Desired parallelism for the ```fixed``` configuration strategy -| a positive integer -| no default value - -| ```junit.jupiter.execution.parallel.config.fixed.max-pool-size``` -| Desired maximum pool size of the underlying fork-join pool for the ```fixed``` - configuration strategy -| a positive integer, must be greater than or equal to `junit.jupiter.execution.parallel.config.fixed.parallelism` -| 256 + the value of `junit.jupiter.execution.parallel.config.fixed.parallelism` - -| ```junit.jupiter.execution.parallel.config.fixed.saturate``` -| Disable saturation of the underlying fork-join pool for the ```fixed``` configuration - strategy -| - * `true` - * `false` -| ```true``` - -| ```junit.jupiter.execution.parallel.config.custom.class``` -| Fully qualified class name of the _ParallelExecutionConfigurationStrategy_ to be - used for the ```custom``` configuration strategy -| for example, _org.example.CustomStrategy_ -| no default value -|=== + parallelism for the ```dynamic``` configuration strategy. + Must be a positive decimal number greater than or equal to `1.0` (defaults to 256 plus + the value of `junit.jupiter.execution.parallel.config.dynamic.factor` multiplied by the + number of available processors/cores) + +`junit.jupiter.execution.parallel.config.dynamic.saturate=true|false`:: + Enable/disable saturation of the underlying `ForkJoinPool` for the ```dynamic``` + configuration strategy (defaults to `true`). Only used if + `junit.jupiter.execution.parallel.config.executor-service` is set to `fork_join_pool`. + +====== Fixed strategy + +`junit.jupiter.execution.parallel.config.fixed.parallelism=integer`:: + Desired parallelism for the ```fixed``` configuration strategy (no default value). Must + be a positive integer. + +`junit.jupiter.execution.parallel.config.fixed.max-pool-size=integer`:: + Desired maximum pool size of the underlying fork-join pool for the ```fixed``` + configuration strategy. Must be a positive integer greater than or equal to + `junit.jupiter.execution.parallel.config.fixed.parallelism` (defaults to 256 plus the + value of `junit.jupiter.execution.parallel.config.fixed.parallelism`). + +`junit.jupiter.execution.parallel.config.fixed.saturate=true|false`:: + Enable/disable saturation of the underlying `ForkJoinPool` for the ```fixed``` + configuration strategy (defaults to `true`). Only used if + `junit.jupiter.execution.parallel.config.executor-service` is set to `fork_join_pool`. + +====== Custom strategy + +`junit.jupiter.execution.parallel.config.custom.class=classname`:: + Fully qualified class name of the `ParallelExecutionConfigurationStrategy` to be used + for the ```custom``` configuration strategy (no default value). [[writing-tests-parallel-execution-synchronization]] ==== Synchronization diff --git a/documentation/src/test/resources/junit-platform.properties b/documentation/src/test/resources/junit-platform.properties index 0f0255f62dbb..835e9ba5e796 100644 --- a/documentation/src/test/resources/junit-platform.properties +++ b/documentation/src/test/resources/junit-platform.properties @@ -1,5 +1,6 @@ junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.config.executor-service=worker_thread_pool junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=6 diff --git a/documentation/src/test/resources/log4j2-test.xml b/documentation/src/test/resources/log4j2-test.xml index 85bbb36f213e..16e89e753e2d 100644 --- a/documentation/src/test/resources/log4j2-test.xml +++ b/documentation/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java index fb08e6e57dee..477e6187841b 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java @@ -39,6 +39,8 @@ import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; import org.junit.platform.engine.support.hierarchical.ParallelExecutionConfigurationStrategy; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; /** * Collection of constants related to the {@link JupiterTestEngine}. @@ -237,7 +239,21 @@ public final class Constants { @API(status = STABLE, since = "5.10") public static final String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; - static final String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; + /** + * Property name used to determine the desired + * {@link ParallelExecutorServiceType ParallelExecutorServiceType}: + * {@value} + * + *

Value must be + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or + * {@link ParallelExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, + * ignoring case. + * + * @since 6.1 + * @see ParallelHierarchicalTestExecutorServiceFactory + */ + @API(status = EXPERIMENTAL, since = "6.1") + public static final String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; /** * Property name used to select the @@ -249,7 +265,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_STRATEGY_PROPERTY_NAME; /** @@ -261,7 +277,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; /** @@ -275,7 +291,7 @@ public final class Constants { * @since 5.10 */ @API(status = MAINTAINED, since = "5.13.3") - public static final String PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME; /** @@ -291,7 +307,7 @@ public final class Constants { * @since 5.10 */ @API(status = MAINTAINED, since = "5.13.3") - public static final String PARALLEL_CONFIG_FIXED_SATURATE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_FIXED_SATURATE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_FIXED_SATURATE_PROPERTY_NAME; /** @@ -304,7 +320,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME; /** @@ -315,7 +331,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_CUSTOM_CLASS_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_CUSTOM_CLASS_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_CUSTOM_CLASS_PROPERTY_NAME; /** diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 7394b7ca73bd..261ba352c8ef 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -29,9 +29,9 @@ import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; /** @@ -79,8 +79,8 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return new ForkJoinPoolHierarchicalTestExecutorService(new PrefixedConfigurationParameters( - request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); + return ParallelHierarchicalTestExecutorServiceFactory.create(new PrefixedConfigurationParameters( + request.getConfigurationParameters(), JupiterConfiguration.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 50ea9550fe8f..9a2e2dd813f0 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -16,6 +16,8 @@ import static org.junit.jupiter.api.io.TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME; import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import static org.junit.jupiter.engine.config.FilteringConfigurationParameterConverter.exclude; +import static org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType.FORK_JOIN_POOL; +import static org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType.WORKER_THREAD_POOL; import java.util.List; import java.util.Optional; @@ -100,6 +102,17 @@ private void validateConfigurationParameters(DiscoveryIssueReporter issueReporte Please remove it from your configuration.""".formatted(key)); issueReporter.reportIssue(warning); })); + if (isParallelExecutionEnabled() + && configurationParameters.get(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME).isEmpty()) { + var info = DiscoveryIssue.create(Severity.INFO, + "Parallel test execution is enabled but the default ForkJoinPool-based executor service will be used. " + + "Please give the new implementation based on a regular thread pool a try by setting the '" + + PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME + "' configuration parameter to '" + + WORKER_THREAD_POOL + "' and report any issues to the JUnit team. " + + "Alternatively, set the configuration parameter to '" + FORK_JOIN_POOL + + "' to hide this message and keep using the original implementation."); + issueReporter.reportIssue(info); + } } @Override diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 1d7ec56e40c2..0934899552ee 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; /** * @since 5.4 @@ -42,6 +43,9 @@ public interface JupiterConfiguration { String EXTENSIONS_AUTODETECTION_EXCLUDE_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.exclude"; String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate"; String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; + String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; + String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + + ParallelHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME; String CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.store.close.autocloseable.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java new file mode 100644 index 000000000000..bd58ae926af8 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java @@ -0,0 +1,59 @@ +/* + * Copyright 2015-2025 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.engine.support.hierarchical; + +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.jspecify.annotations.Nullable; + +/** + * @since 6.1 + */ +abstract class BlockingAwareFuture extends DelegatingFuture { + + BlockingAwareFuture(Future delegate) { + super(delegate); + } + + @Override + public T get() throws InterruptedException, ExecutionException { + if (delegate.isDone()) { + return delegate.get(); + } + return handleSafely(delegate::get); + } + + @Override + public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + if (delegate.isDone()) { + return delegate.get(); + } + return handleSafely(() -> delegate.get(timeout, unit)); + } + + private T handleSafely(Callable callable) { + try { + return handle(callable); + } + catch (Exception e) { + throw throwAsUncheckedException(e); + } + } + + protected abstract T handle(Callable callable) throws Exception; + +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java index 7ac65a73e6a1..3fdca310b5eb 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java @@ -45,6 +45,26 @@ List getLocks() { return this.locks; } + @Override + public boolean tryAcquire() { + List acquiredLocks = new ArrayList<>(this.locks.size()); + for (Lock lock : this.locks) { + if (lock.tryLock()) { + acquiredLocks.add(lock); + } + else { + break; + } + } + if (acquiredLocks.size() == this.locks.size()) { + return true; + } + else { + release(acquiredLocks); + return false; + } + } + @Override public ResourceLock acquire() throws InterruptedException { ForkJoinPool.managedBlock(new CompositeLockManagedBlocker()); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java index 47e88a603deb..c3bbd0d15bf7 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java @@ -216,6 +216,10 @@ public ParallelExecutionConfiguration createConfiguration(ConfigurationParameter */ public static final String CONFIG_CUSTOM_CLASS_PROPERTY_NAME = "custom.class"; + static ParallelExecutionConfiguration toConfiguration(ConfigurationParameters configurationParameters) { + return getStrategy(configurationParameters).createConfiguration(configurationParameters); + } + static ParallelExecutionConfigurationStrategy getStrategy(ConfigurationParameters configurationParameters) { return valueOf( configurationParameters.get(CONFIG_STRATEGY_PROPERTY_NAME).orElse("dynamic").toUpperCase(Locale.ROOT)); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java new file mode 100644 index 000000000000..db74f8ed78b3 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java @@ -0,0 +1,56 @@ +/* + * Copyright 2015-2025 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.engine.support.hierarchical; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.jspecify.annotations.Nullable; + +/** + * @since 6.1 + */ +class DelegatingFuture implements Future { + + protected final Future delegate; + + DelegatingFuture(Future delegate) { + this.delegate = delegate; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return delegate.cancel(mayInterruptIfRunning); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public boolean isDone() { + return delegate.isDone(); + } + + @Override + public T get() throws InterruptedException, ExecutionException { + return delegate.get(); + } + + @Override + public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + return delegate.get(timeout, unit); + } + +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java index 52bd2e05cf5b..f0b72fd99777 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java @@ -11,7 +11,8 @@ package org.junit.platform.engine.support.hierarchical; import static java.util.concurrent.CompletableFuture.completedFuture; -import static org.apiguardian.api.API.Status.STABLE; +import static org.apiguardian.api.API.Status.DEPRECATED; +import static org.apiguardian.api.API.Status.MAINTAINED; import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; @@ -33,6 +34,7 @@ import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.util.ExceptionUtils; import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; /** * A {@link ForkJoinPool}-based @@ -40,10 +42,12 @@ * {@linkplain TestTask test tasks} with the configured parallelism. * * @since 1.3 - * @see ForkJoinPool + * @see ParallelHierarchicalTestExecutorServiceFactory + * @see ParallelExecutorServiceType#FORK_JOIN_POOL * @see DefaultParallelExecutionConfigurationStrategy + * @see ForkJoinPool */ -@API(status = STABLE, since = "1.10") +@API(status = MAINTAINED, since = "1.10") public class ForkJoinPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { // package-private for testing @@ -58,9 +62,18 @@ public class ForkJoinPoolHierarchicalTestExecutorService implements Hierarchical * the supplied {@link ConfigurationParameters}. * * @see DefaultParallelExecutionConfigurationStrategy + * @deprecated Please use + * {@link ParallelHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters)} + * with configuration parameter + * {@value ParallelHierarchicalTestExecutorServiceFactory#EXECUTOR_SERVICE_PROPERTY_NAME} + * set to + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} + * instead. */ + @API(status = DEPRECATED, since = "6.1") + @Deprecated(since = "6.1") public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { - this(createConfiguration(configurationParameters)); + this(DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters)); } /** @@ -68,8 +81,14 @@ public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters confi * the supplied {@link ParallelExecutionConfiguration}. * * @since 1.7 + * @deprecated Please use + * {@link ParallelHierarchicalTestExecutorServiceFactory#create(ParallelExecutorServiceType, ParallelExecutionConfiguration)} + * with + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL ParallelExecutorServiceType.FORK_JOIN_POOL} + * instead. */ - @API(status = STABLE, since = "1.10") + @API(status = DEPRECATED, since = "6.1") + @Deprecated(since = "6.1") public ForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, TaskEventListener.NOOP); } @@ -82,12 +101,6 @@ public ForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguratio LoggerFactory.getLogger(getClass()).config(() -> "Using ForkJoinPool with parallelism of " + parallelism); } - private static ParallelExecutionConfiguration createConfiguration(ConfigurationParameters configurationParameters) { - ParallelExecutionConfigurationStrategy strategy = DefaultParallelExecutionConfigurationStrategy.getStrategy( - configurationParameters); - return strategy.createConfiguration(configurationParameters); - } - private ForkJoinPool createForkJoinPool(ParallelExecutionConfiguration configuration) { try { return new ForkJoinPool(configuration.getParallelism(), new WorkerThreadFactory(), null, false, diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java index ea43494c2b57..e84c3a77fb25 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java @@ -33,6 +33,11 @@ public List getResources() { return emptyList(); } + @Override + public boolean tryAcquire() { + return true; + } + @Override public ResourceLock acquire() { return this; diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java new file mode 100644 index 000000000000..f96c7bd5f8f5 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java @@ -0,0 +1,123 @@ +/* + * Copyright 2015-2025 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.engine.support.hierarchical; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.apiguardian.api.API.Status.MAINTAINED; + +import java.util.Locale; + +import org.apiguardian.api.API; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; +import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService.TaskEventListener; + +/** + * Factory for {@link HierarchicalTestExecutorService} instances that support + * parallel execution. + * + * @since 6.1 + * @see ParallelExecutorServiceType + * @see ForkJoinPoolHierarchicalTestExecutorService + * @see WorkerThreadPoolHierarchicalTestExecutorService + */ +@API(status = MAINTAINED, since = "6.1") +public final class ParallelHierarchicalTestExecutorServiceFactory { + + /** + * Property name used to determine the desired + * {@link ParallelExecutorServiceType ParallelExecutorServiceType}. + * + *

Value must be + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or + * {@link ParallelExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, + * ignoring case. + */ + public static final String EXECUTOR_SERVICE_PROPERTY_NAME = "executor-service"; + + /** + * Create a new {@link HierarchicalTestExecutorService} based on the + * supplied {@link ConfigurationParameters}. + * + *

This method is typically invoked with an instance of + * {@link PrefixedConfigurationParameters} that was created with an + * engine-specific prefix. + * + *

The {@value #EXECUTOR_SERVICE_PROPERTY_NAME} key is used to determine + * which service implementation is to be used. Which other parameters are + * read depends on the configured + * {@link ParallelExecutionConfigurationStrategy} which is determined by the + * {@value DefaultParallelExecutionConfigurationStrategy#CONFIG_STRATEGY_PROPERTY_NAME} + * key. + * + * @see #EXECUTOR_SERVICE_PROPERTY_NAME + * @see ParallelExecutorServiceType + * @see ParallelExecutionConfigurationStrategy + * @see PrefixedConfigurationParameters + */ + public static HierarchicalTestExecutorService create(ConfigurationParameters configurationParameters) { + var type = configurationParameters.get(EXECUTOR_SERVICE_PROPERTY_NAME, ParallelExecutorServiceType::parse) // + .orElse(ParallelExecutorServiceType.FORK_JOIN_POOL); + var configuration = DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters); + return create(type, configuration); + } + + /** + * Create a new {@link HierarchicalTestExecutorService} based on the + * supplied {@link ConfigurationParameters}. + * + *

The {@value #EXECUTOR_SERVICE_PROPERTY_NAME} key is ignored in favor + * of the supplied {@link ParallelExecutorServiceType} parameter when + * invoking this method. + * + * @see ParallelExecutorServiceType + * @see ParallelExecutionConfigurationStrategy + */ + public static HierarchicalTestExecutorService create(ParallelExecutorServiceType executorServiceType, + ParallelExecutionConfiguration configuration) { + return switch (executorServiceType) { + case FORK_JOIN_POOL -> new ForkJoinPoolHierarchicalTestExecutorService(configuration, + TaskEventListener.NOOP); + case WORKER_THREAD_POOL -> new WorkerThreadPoolHierarchicalTestExecutorService(configuration); + }; + } + + private ParallelHierarchicalTestExecutorServiceFactory() { + } + + /** + * Type of {@link HierarchicalTestExecutorService} that supports parallel + * execution. + * + * @since 6.1 + */ + @API(status = MAINTAINED, since = "6.1") + public enum ParallelExecutorServiceType { + + /** + * Indicates that {@link ForkJoinPoolHierarchicalTestExecutorService} + * should be used. + */ + FORK_JOIN_POOL, + + /** + * Indicates that {@link WorkerThreadPoolHierarchicalTestExecutorService} + * should be used. + */ + @API(status = EXPERIMENTAL, since = "6.1") + WORKER_THREAD_POOL; + + private static ParallelExecutorServiceType parse(String value) { + return valueOf(value.toUpperCase(Locale.ROOT)); + } + } + +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java index 5022d88d0101..0f8a1cac639f 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java @@ -10,6 +10,7 @@ package org.junit.platform.engine.support.hierarchical; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.apiguardian.api.API.Status.STABLE; import java.util.List; @@ -26,6 +27,17 @@ @API(status = STABLE, since = "1.10") public interface ResourceLock extends AutoCloseable { + /** + * Try to acquire this resource lock, without blocking. + * + * @return {@code true} if the lock was acquired and {@code false} otherwise + * @since 6.1 + */ + @API(status = EXPERIMENTAL, since = "6.1") + default boolean tryAcquire() { + return false; + } + /** * Acquire this resource lock, potentially blocking. * diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java index d2c39f9d215d..1110ccb42b2b 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java @@ -41,6 +41,11 @@ Lock getLock() { return this.lock; } + @Override + public boolean tryAcquire() { + return this.lock.tryLock(); + } + @Override public ResourceLock acquire() throws InterruptedException { ForkJoinPool.managedBlock(new SingleLockManagedBlocker()); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java new file mode 100644 index 000000000000..a59f3ec5740b --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -0,0 +1,925 @@ +/* + * Copyright 2015-2025 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.engine.support.hierarchical; + +import static java.util.Comparator.comparing; +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; +import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; +import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.EnumMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.Semaphore; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.util.ClassLoaderUtils; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ToStringBuilder; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; + +/** + * An {@linkplain HierarchicalTestExecutorService executor service} based on a + * regular thread pool that executes {@linkplain TestTask test tasks} with the + * configured parallelism. + * + * @since 6.1 + * @see ParallelHierarchicalTestExecutorServiceFactory + * @see ParallelExecutorServiceType#WORKER_THREAD_POOL + * @see DefaultParallelExecutionConfigurationStrategy + */ +@API(status = EXPERIMENTAL, since = "6.1") +public final class WorkerThreadPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + + /* + This implementation is based on a regular thread pool and a work queue shared among all worker threads. + + Each worker thread scans the shared work queue for tasks to run. Since the tasks represent hierarchically + structured tests, container tasks will call `submit(TestTask)` or `invokeAll(List)` for their + children, recursively. + + To maintain the desired parallelism -- regardless whether the user code performs any blocking operations -- + a fixed number of worker leases is configured. Whenever a task is submitted to the work queue to be executed + concurrently, an attempt is made to acquire a worker lease. If a worker lease was acquired, a worker thread is + started. Each worker thread attempts to "steal" queue entries for its children and execute them itself prior to + waiting for its children to finish. + + To optimize CPU utilization, whenever a worker thread does need to block, it temporarily gives up its worker + lease and attempts to start another worker thread to compensate for the reduced `parallelism`. If the max pool + size does not permit starting another thread, the attempt is ignored in case there are still other active worker + threads. + + The same happens in case a resource lock needs to be acquired. + + To minimize the number of idle workers, worker threads will prefer to steal top level tasks, while working + through their own task hierarchy in a depth first fashion. Furthermore, child tasks with execution mode + `CONCURRENT` are submitted to the shared queue prior to executing those with execution mode `SAME_THREAD` + directly. + */ + + private static final Logger LOGGER = LoggerFactory.getLogger(WorkerThreadPoolHierarchicalTestExecutorService.class); + + private final WorkQueue workQueue = new WorkQueue(); + private final ExecutorService threadPool; + private final int parallelism; + private final WorkerLeaseManager workerLeaseManager; + + /** + * Create a new {@code WorkerThreadPoolHierarchicalTestExecutorService} + * based on the supplied {@link ParallelExecutionConfiguration}. + * + *

The following attributes of the supplied configuration are applied to + * the thread pool used by this executor service: + * + *

    + *
  • {@link ParallelExecutionConfiguration#getParallelism()}
  • + *
  • {@link ParallelExecutionConfiguration#getCorePoolSize()}
  • + *
  • {@link ParallelExecutionConfiguration#getMaxPoolSize()}
  • + *
  • {@link ParallelExecutionConfiguration#getKeepAliveSeconds()}
  • + *
+ * + *

The remaining attributes, such as + * {@link ParallelExecutionConfiguration#getMinimumRunnable()} and + * {@link ParallelExecutionConfiguration#getSaturatePredicate()}, are + * ignored. + * + * @see ParallelHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters) + */ + WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { + this(configuration, ClassLoaderUtils.getDefaultClassLoader()); + } + + // package-private for testing + WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, + ClassLoader classLoader) { + ThreadFactory threadFactory = new WorkerThreadFactory(classLoader); + parallelism = configuration.getParallelism(); + workerLeaseManager = new WorkerLeaseManager(parallelism, this::maybeStartWorker); + var rejectedExecutionHandler = new LeaseAwareRejectedExecutionHandler(workerLeaseManager); + threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), + configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory, + rejectedExecutionHandler); + LOGGER.trace(() -> "initialized thread pool for parallelism of " + configuration.getParallelism()); + } + + @Override + public void close() { + LOGGER.trace(() -> "shutting down thread pool"); + threadPool.shutdownNow(); + } + + @Override + public Future<@Nullable Void> submit(TestTask testTask) { + LOGGER.trace(() -> "submit: " + testTask); + + var workerThread = WorkerThread.get(); + if (workerThread == null) { + return enqueue(testTask, 0).future(); + } + + if (testTask.getExecutionMode() == SAME_THREAD) { + workerThread.executeTask(testTask); + return completedFuture(null); + } + + var entry = enqueue(testTask, workerThread.nextChildIndex()); + workerThread.trackSubmittedChild(entry); + return new WorkStealingFuture(entry); + } + + /** + * {@inheritDoc} + * + * @implNote This method must be called from within a worker thread that + * belongs to this executor. + */ + @Override + public void invokeAll(List testTasks) { + LOGGER.trace(() -> "invokeAll: " + testTasks); + + var workerThread = WorkerThread.get(); + Preconditions.condition(workerThread != null && workerThread.executor() == this, + "invokeAll() must be called from a worker thread that belongs to this executor"); + + workerThread.invokeAll(testTasks); + } + + private WorkQueue.Entry enqueue(TestTask testTask, int index) { + var entry = workQueue.add(testTask, index); + maybeStartWorker(); + return entry; + } + + private void forkAll(Collection entries) { + if (entries.isEmpty()) { + return; + } + workQueue.addAll(entries); + // start at most (parallelism - 1) new workers as this method is called from a worker thread holding a lease + for (int i = 0; i < Math.min(parallelism - 1, entries.size()); i++) { + maybeStartWorker(); + } + } + + private void maybeStartWorker() { + maybeStartWorker(() -> false); + } + + private void maybeStartWorker(BooleanSupplier doneCondition) { + if (threadPool.isShutdown() || workQueue.isEmpty() || doneCondition.getAsBoolean()) { + return; + } + var workerLease = workerLeaseManager.tryAcquire(); + if (workerLease == null) { + return; + } + threadPool.execute(new RunLeaseAwareWorker(workerLease, + () -> WorkerThread.getOrThrow().processQueueEntries(workerLease, doneCondition), + () -> this.maybeStartWorker(doneCondition))); + } + + private record RunLeaseAwareWorker(WorkerLease workerLease, Runnable work, Runnable onWorkerFinished) + implements Runnable { + + @Override + public void run() { + LOGGER.trace(() -> "starting worker"); + try { + work.run(); + } + finally { + workerLease.release(false); + LOGGER.trace(() -> "stopping worker"); + } + onWorkerFinished.run(); + } + } + + private class WorkerThreadFactory implements ThreadFactory { + + private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); + + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final int poolNumber; + private final ClassLoader classLoader; + + WorkerThreadFactory(ClassLoader classLoader) { + this.classLoader = classLoader; + this.poolNumber = POOL_NUMBER.getAndIncrement(); + } + + @Override + public Thread newThread(Runnable runnable) { + var thread = new WorkerThread(runnable, + "junit-%d-worker-%d".formatted(poolNumber, threadNumber.getAndIncrement())); + thread.setContextClassLoader(classLoader); + return thread; + } + } + + private class WorkerThread extends Thread { + + private final Deque stateStack = new ArrayDeque<>(); + + @Nullable + WorkerLease workerLease; + + WorkerThread(Runnable runnable, String name) { + super(runnable, name); + } + + static @Nullable WorkerThread get() { + if (Thread.currentThread() instanceof WorkerThread workerThread) { + return workerThread; + } + return null; + } + + static WorkerThread getOrThrow() { + var workerThread = get(); + if (workerThread == null) { + throw new IllegalStateException("Not on a worker thread"); + } + return workerThread; + } + + WorkerThreadPoolHierarchicalTestExecutorService executor() { + return WorkerThreadPoolHierarchicalTestExecutorService.this; + } + + void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) { + this.workerLease = workerLease; + while (!threadPool.isShutdown()) { + if (doneCondition.getAsBoolean()) { + LOGGER.trace(() -> "yielding resource lock"); + break; + } + if (workQueue.isEmpty()) { + LOGGER.trace(() -> "no queue entries available"); + break; + } + processQueueEntries(); + } + } + + private void processQueueEntries() { + var entriesRequiringResourceLocks = new ArrayList(); + + for (var entry : workQueue) { + var result = tryToStealWork(entry, BlockingMode.NON_BLOCKING); + if (result == WorkStealResult.EXECUTED_BY_THIS_WORKER) { + // After executing a test a significant amount of time has passed. + // Process the queue from the beginning + return; + } + if (result == WorkStealResult.RESOURCE_LOCK_UNAVAILABLE) { + entriesRequiringResourceLocks.add(entry); + } + } + + for (var entry : entriesRequiringResourceLocks) { + var result = tryToStealWork(entry, BlockingMode.BLOCKING); + if (result == WorkStealResult.EXECUTED_BY_THIS_WORKER) { + return; + } + } + } + + T runBlocking(BooleanSupplier doneCondition, BlockingAction blockingAction) throws InterruptedException { + var workerLease = requireNonNull(this.workerLease); + workerLease.release(doneCondition); + try { + return blockingAction.run(); + } + finally { + try { + workerLease.reacquire(); + } + catch (InterruptedException e) { + interrupt(); + } + } + } + + void invokeAll(List testTasks) { + + if (testTasks.isEmpty()) { + return; + } + + if (testTasks.size() == 1) { + executeTask(testTasks.get(0)); + return; + } + + List isolatedTasks = new ArrayList<>(testTasks.size()); + List sameThreadTasks = new ArrayList<>(testTasks.size()); + var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + executeAll(sameThreadTasks); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); + tryToStealWorkWithBlocking(queueEntriesByResult); + waitFor(queueEntriesByResult); + executeAll(isolatedTasks); + } + + private List forkConcurrentChildren(List children, + Consumer isolatedTaskCollector, List sameThreadTasks) { + + List queueEntries = new ArrayList<>(children.size()); + for (TestTask child : children) { + if (requiresGlobalReadWriteLock(child)) { + isolatedTaskCollector.accept(child); + } + else if (child.getExecutionMode() == SAME_THREAD) { + sameThreadTasks.add(child); + } + else { + queueEntries.add(new WorkQueue.Entry(child, nextChildIndex())); + } + } + + if (!queueEntries.isEmpty()) { + queueEntries.sort(WorkQueue.Entry.CHILD_COMPARATOR); + if (sameThreadTasks.isEmpty()) { + // hold back one task for this thread + var firstEntry = queueEntries.remove(0); + sameThreadTasks.add(firstEntry.task); + } + forkAll(queueEntries); + } + return queueEntries; + } + + private Map> tryToStealWorkWithoutBlocking( + Iterable queueEntries) { + + Map> queueEntriesByResult = new EnumMap<>(WorkStealResult.class); + tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult); + return queueEntriesByResult; + } + + private void tryToStealWorkWithBlocking(Map> queueEntriesByResult) { + var entriesRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); + if (entriesRequiringResourceLocks == null) { + return; + } + tryToStealWork(entriesRequiringResourceLocks, BlockingMode.BLOCKING, queueEntriesByResult); + } + + private void tryToStealWork(Iterable entries, BlockingMode blocking, + Map> queueEntriesByResult) { + for (var entry : entries) { + var result = tryToStealWork(entry, blocking); + queueEntriesByResult.computeIfAbsent(result, __ -> new ArrayList<>()).add(entry); + } + } + + private WorkStealResult tryToStealWork(WorkQueue.Entry entry, BlockingMode blockingMode) { + if (entry.future.isDone()) { + return WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER; + } + var claimed = workQueue.remove(entry); + if (claimed) { + LOGGER.trace(() -> "stole work: " + entry.task); + var executed = executeStolenWork(entry, blockingMode); + if (executed) { + return WorkStealResult.EXECUTED_BY_THIS_WORKER; + } + workQueue.reAdd(entry); + return WorkStealResult.RESOURCE_LOCK_UNAVAILABLE; + } + return WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER; + } + + private void waitFor(Map> queueEntriesByResult) { + var children = queueEntriesByResult.get(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); + if (children == null) { + return; + } + var future = toCombinedFuture(children); + try { + if (future.isDone()) { + // no need to release worker lease + future.join(); + } + else { + runBlocking(future::isDone, () -> { + LOGGER.trace(() -> "blocking for forked children : %s".formatted(children)); + return future.join(); + }); + } + } + catch (InterruptedException e) { + currentThread().interrupt(); + } + } + + private static boolean requiresGlobalReadWriteLock(TestTask testTask) { + return testTask.getResourceLock().getResources().contains(GLOBAL_READ_WRITE); + } + + private void executeAll(List children) { + if (children.isEmpty()) { + return; + } + LOGGER.trace(() -> "running %d children directly".formatted(children.size())); + if (children.size() == 1) { + executeTask(children.get(0)); + return; + } + for (var testTask : children) { + executeTask(testTask); + } + } + + private boolean executeStolenWork(WorkQueue.Entry entry, BlockingMode blockingMode) { + return switch (blockingMode) { + case NON_BLOCKING -> tryExecute(entry); + case BLOCKING -> { + execute(entry); + yield true; + } + }; + } + + private boolean tryExecute(WorkQueue.Entry entry) { + try { + var executed = tryExecuteTask(entry.task); + if (executed) { + entry.future.complete(null); + } + return executed; + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + return true; + } + } + + private void execute(WorkQueue.Entry entry) { + try { + executeTask(entry.task); + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + } + finally { + entry.future.complete(null); + } + } + + @SuppressWarnings("try") + private void executeTask(TestTask testTask) { + var executed = tryExecuteTask(testTask); + if (!executed) { + var resourceLock = testTask.getResourceLock(); + try (var ignored = runBlocking(() -> false, () -> { + LOGGER.trace(() -> "blocking for resource lock: " + resourceLock); + return resourceLock.acquire(); + })) { + LOGGER.trace(() -> "acquired resource lock: " + resourceLock); + doExecute(testTask); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + finally { + LOGGER.trace(() -> "released resource lock: " + resourceLock); + } + } + } + + private boolean tryExecuteTask(TestTask testTask) { + var resourceLock = testTask.getResourceLock(); + if (resourceLock.tryAcquire()) { + LOGGER.trace(() -> "acquired resource lock: " + resourceLock); + try (resourceLock) { + doExecute(testTask); + return true; + } + finally { + LOGGER.trace(() -> "released resource lock: " + resourceLock); + } + } + else { + LOGGER.trace(() -> "failed to acquire resource lock: " + resourceLock); + } + return false; + } + + private void doExecute(TestTask testTask) { + LOGGER.trace(() -> "executing: " + testTask); + stateStack.push(new State()); + try { + testTask.execute(); + } + finally { + stateStack.pop(); + LOGGER.trace(() -> "finished executing: " + testTask); + } + } + + private static CompletableFuture toCombinedFuture(List entries) { + if (entries.size() == 1) { + return entries.get(0).future(); + } + var futures = entries.stream().map(WorkQueue.Entry::future).toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + } + + private int nextChildIndex() { + return stateStack.element().nextChildIndex(); + } + + private void trackSubmittedChild(WorkQueue.Entry entry) { + stateStack.element().trackSubmittedChild(entry); + } + + private void tryToStealWorkFromSubmittedChildren() { + var currentState = stateStack.element(); + var currentSubmittedChildren = currentState.submittedChildren; + if (currentSubmittedChildren == null || currentSubmittedChildren.isEmpty()) { + return; + } + currentSubmittedChildren.sort(WorkQueue.Entry.CHILD_COMPARATOR); + var iterator = currentSubmittedChildren.iterator(); + while (iterator.hasNext()) { + WorkQueue.Entry entry = iterator.next(); + var result = tryToStealWork(entry, BlockingMode.NON_BLOCKING); + if (result.isExecuted()) { + iterator.remove(); + } + } + currentState.clearIfEmpty(); + } + + private static class State { + + private int nextChildIndex = 0; + + @Nullable + private List submittedChildren; + + private void trackSubmittedChild(WorkQueue.Entry entry) { + if (submittedChildren == null) { + submittedChildren = new ArrayList<>(); + } + submittedChildren.add(entry); + } + + private void clearIfEmpty() { + if (submittedChildren != null && submittedChildren.isEmpty()) { + submittedChildren = null; + } + } + + private int nextChildIndex() { + return nextChildIndex++; + } + } + + private enum WorkStealResult { + EXECUTED_BY_DIFFERENT_WORKER, RESOURCE_LOCK_UNAVAILABLE, EXECUTED_BY_THIS_WORKER; + + private boolean isExecuted() { + return this != RESOURCE_LOCK_UNAVAILABLE; + } + } + + private interface BlockingAction { + T run() throws InterruptedException; + } + + } + + private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Void> { + + private final WorkQueue.Entry entry; + + WorkStealingFuture(WorkQueue.Entry entry) { + super(entry.future); + this.entry = entry; + } + + @Override + protected @Nullable Void handle(Callable<@Nullable Void> callable) throws Exception { + var workerThread = WorkerThread.get(); + if (workerThread == null || entry.future.isDone()) { + return callable.call(); + } + workerThread.tryToStealWork(entry, BlockingMode.BLOCKING); + if (entry.future.isDone()) { + return callable.call(); + } + workerThread.tryToStealWorkFromSubmittedChildren(); + if (entry.future.isDone()) { + return callable.call(); + } + LOGGER.trace(() -> "blocking for child task: " + entry.task); + return workerThread.runBlocking(entry.future::isDone, () -> { + try { + return callable.call(); + } + catch (Exception ex) { + throw throwAsUncheckedException(ex); + } + }); + } + + } + + private enum BlockingMode { + NON_BLOCKING, BLOCKING + } + + private static class WorkQueue implements Iterable { + + private final Set queue = new ConcurrentSkipListSet<>(Entry.QUEUE_COMPARATOR); + + Entry add(TestTask task, int index) { + Entry entry = new Entry(task, index); + LOGGER.trace(() -> "forking: " + entry.task); + return doAdd(entry); + } + + void addAll(Collection entries) { + entries.forEach(this::doAdd); + } + + void reAdd(Entry entry) { + LOGGER.trace(() -> "re-enqueuing: " + entry.task); + doAdd(entry); + } + + private Entry doAdd(Entry entry) { + var added = queue.add(entry); + if (!added) { + throw new IllegalStateException("Could not add entry to the queue for task: " + entry.task); + } + return entry; + } + + boolean remove(Entry entry) { + return queue.remove(entry); + } + + boolean isEmpty() { + return queue.isEmpty(); + } + + @Override + public Iterator iterator() { + return queue.iterator(); + } + + private static final class Entry { + + private static final Comparator QUEUE_COMPARATOR = comparing(Entry::level).reversed() // + .thenComparing(Entry::isContainer) // tests before containers + .thenComparing(Entry::index) // + .thenComparing(Entry::uniqueId, new SameLengthUniqueIdComparator()); + + private static final Comparator CHILD_COMPARATOR = comparing(Entry::isContainer).reversed() // containers before tests + .thenComparing(Entry::index); + + private final TestTask task; + private final CompletableFuture<@Nullable Void> future; + private final int index; + + @SuppressWarnings("FutureReturnValueIgnored") + Entry(TestTask task, int index) { + this.future = new CompletableFuture<>(); + this.future.whenComplete((__, t) -> { + if (t == null) { + LOGGER.trace(() -> "completed normally: " + task); + } + else { + LOGGER.trace(t, () -> "completed exceptionally: " + task); + } + }); + this.task = task; + this.index = index; + } + + private int index() { + return this.index; + } + + private int level() { + return uniqueId().getSegments().size(); + } + + private boolean isContainer() { + return task.getTestDescriptor().isContainer(); + } + + private UniqueId uniqueId() { + return task.getTestDescriptor().getUniqueId(); + } + + CompletableFuture<@Nullable Void> future() { + return future; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (Entry) obj; + return Objects.equals(this.uniqueId(), that.uniqueId()) && this.index == that.index; + } + + @Override + public int hashCode() { + return Objects.hash(uniqueId(), index); + } + + @Override + public String toString() { + return new ToStringBuilder(this) // + .append("task", task) // + .append("index", index) // + .toString(); + } + + private static class SameLengthUniqueIdComparator implements Comparator { + + @Override + public int compare(UniqueId a, UniqueId b) { + var aIterator = a.getSegments().iterator(); + var bIterator = b.getSegments().iterator(); + + // ids have the same length + while (aIterator.hasNext()) { + var aCurrent = aIterator.next(); + var bCurrent = bIterator.next(); + int result = compareBy(aCurrent, bCurrent); + if (result != 0) { + return result; + } + } + return 0; + } + + private static int compareBy(UniqueId.Segment a, UniqueId.Segment b) { + int result = a.getType().compareTo(b.getType()); + if (result != 0) { + return result; + } + return a.getValue().compareTo(b.getValue()); + } + } + + } + } + + static class WorkerLeaseManager { + + private final int parallelism; + private final Semaphore semaphore; + private final Consumer compensation; + + WorkerLeaseManager(int parallelism, Consumer compensation) { + this.parallelism = parallelism; + this.semaphore = new Semaphore(parallelism); + this.compensation = compensation; + } + + @Nullable + WorkerLease tryAcquire() { + boolean acquired = semaphore.tryAcquire(); + if (acquired) { + LOGGER.trace(() -> "acquired worker lease for new worker (available: %d)".formatted( + semaphore.availablePermits())); + return new WorkerLease(this::release); + } + return null; + } + + private ReacquisitionToken release(boolean compensate, BooleanSupplier doneCondition) { + semaphore.release(); + LOGGER.trace(() -> "release worker lease (available: %d)".formatted(semaphore.availablePermits())); + if (compensate) { + compensation.accept(doneCondition); + } + return new ReacquisitionToken(); + } + + public boolean isAtLeastOneLeaseTaken() { + return semaphore.availablePermits() < parallelism; + } + + private class ReacquisitionToken { + + private boolean used = false; + + void reacquire() throws InterruptedException { + Preconditions.condition(!used, "Lease was already reacquired"); + used = true; + semaphore.acquire(); + LOGGER.trace(() -> "reacquired worker lease (available: %d)".formatted(semaphore.availablePermits())); + } + } + + @Override + public String toString() { + return new ToStringBuilder(this) // + .append("parallelism", parallelism) // + .append("semaphore", semaphore) // + .toString(); + } + } + + static class WorkerLease implements AutoCloseable { + + private final BiFunction releaseAction; + private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; + + WorkerLease(BiFunction releaseAction) { + this.releaseAction = releaseAction; + } + + @Override + public void close() { + release(true); + } + + public void release(BooleanSupplier doneCondition) { + release(true, doneCondition); + } + + void release(boolean compensate) { + release(compensate, () -> false); + } + + void release(boolean compensate, BooleanSupplier doneCondition) { + if (reacquisitionToken == null) { + reacquisitionToken = releaseAction.apply(compensate, doneCondition); + } + } + + void reacquire() throws InterruptedException { + Preconditions.notNull(reacquisitionToken, "Cannot reacquire an unreleased WorkerLease"); + reacquisitionToken.reacquire(); + reacquisitionToken = null; + } + } + + private record LeaseAwareRejectedExecutionHandler(WorkerLeaseManager workerLeaseManager) + implements RejectedExecutionHandler { + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + if (!(r instanceof RunLeaseAwareWorker worker)) { + return; + } + worker.workerLease.release(false); + if (executor.isShutdown() || workerLeaseManager.isAtLeastOneLeaseTaken()) { + return; + } + throw new RejectedExecutionException("Task with " + workerLeaseManager + " rejected from " + executor); + } + } +} diff --git a/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java b/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java index 45d9af72b788..be5f4dad130e 100644 --- a/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java +++ b/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java @@ -10,6 +10,9 @@ package org.junit.platform.launcher.core; +import static java.util.stream.Collectors.toMap; +import static org.junit.platform.commons.util.StringUtils.nullSafeToString; + import java.util.Map; import org.junit.platform.engine.ConfigurationParameters; @@ -19,10 +22,14 @@ public class ConfigurationParametersFactoryForTests { private ConfigurationParametersFactoryForTests() { } - public static ConfigurationParameters create(Map configParams) { + public static ConfigurationParameters create(Map configParams) { return LauncherConfigurationParameters.builder() // - .explicitParameters(configParams) // + .explicitParameters(withStringValues(configParams)) // .enableImplicitProviders(false) // .build(); } + + private static Map withStringValues(Map configParams) { + return configParams.entrySet().stream().collect(toMap(Map.Entry::getKey, e -> nullSafeToString(e.getValue()))); + } } diff --git a/junit-vintage-engine/src/test/resources/log4j2-test.xml b/junit-vintage-engine/src/test/resources/log4j2-test.xml index 3bade9825b86..d3b0e4baeb72 100644 --- a/junit-vintage-engine/src/test/resources/log4j2-test.xml +++ b/junit-vintage-engine/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java index 37f0dcc046f3..df79d780076f 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java @@ -20,12 +20,16 @@ import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationNotNullFor; import static org.junit.platform.launcher.core.OutputDirectoryCreators.dummyOutputDirectoryCreator; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Supplier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.MethodOrderer; @@ -37,7 +41,14 @@ import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.engine.Constants; import org.junit.jupiter.engine.descriptor.CustomDisplayNameGenerator; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.DiscoveryIssue; +import org.junit.platform.engine.DiscoveryIssue.Severity; +import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; +import org.junit.platform.launcher.core.ConfigurationParametersFactoryForTests; class DefaultJupiterConfigurationTests { @@ -52,16 +63,16 @@ void getDefaultTestInstanceLifecyclePreconditions() { @Test void getDefaultTestInstanceLifecycleWithNoConfigParamSet() { - JupiterConfiguration configuration = new DefaultJupiterConfiguration(mock(), dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); Lifecycle lifecycle = configuration.getDefaultTestInstanceLifecycle(); assertThat(lifecycle).isEqualTo(PER_METHOD); } @Test void getDefaultTempDirCleanupModeWithNoConfigParamSet() { - JupiterConfiguration configuration = new DefaultJupiterConfiguration(mock(), dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); CleanupMode cleanupMode = configuration.getDefaultTempDirCleanupMode(); assertThat(cleanupMode).isEqualTo(ALWAYS); } @@ -88,9 +99,9 @@ void getDefaultTestInstanceLifecycleWithConfigParamSet() { @Test void shouldGetDefaultDisplayNameGeneratorWithConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.of(CustomDisplayNameGenerator.class.getName())); + var parameters = configurationParameters( + Map.of(Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME, CustomDisplayNameGenerator.class.getName())); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), mock()); @@ -101,11 +112,8 @@ void shouldGetDefaultDisplayNameGeneratorWithConfigParamSet() { @Test void shouldGetStandardAsDefaultDisplayNameGeneratorWithoutConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.empty()); - JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); DisplayNameGenerator defaultDisplayNameGenerator = configuration.getDefaultDisplayNameGenerator(); @@ -114,11 +122,8 @@ void shouldGetStandardAsDefaultDisplayNameGeneratorWithoutConfigParamSet() { @Test void shouldGetNothingAsDefaultTestMethodOrderWithoutConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.empty()); - JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); final Optional defaultTestMethodOrder = configuration.getDefaultTestMethodOrderer(); @@ -127,9 +132,8 @@ void shouldGetNothingAsDefaultTestMethodOrderWithoutConfigParamSet() { @Test void shouldGetDefaultTempDirFactorySupplierWithConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.of(CustomFactory.class.getName())); + var parameters = configurationParameters( + Map.of(Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME, CustomFactory.class.getName())); JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), mock()); @@ -138,37 +142,78 @@ void shouldGetDefaultTempDirFactorySupplierWithConfigParamSet() { assertThat(supplier.get()).isInstanceOf(CustomFactory.class); } - private static class CustomFactory implements TempDirFactory { - - @Override - public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) { - throw new UnsupportedOperationException(); - } - } - @Test void shouldGetStandardAsDefaultTempDirFactorySupplierWithoutConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.empty()); - JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); Supplier supplier = configuration.getDefaultTempDirFactorySupplier(); assertThat(supplier.get()).isSameAs(TempDirFactory.Standard.INSTANCE); } + @Test + void doesNotReportAnyIssuesIfConfigurationParametersAreEmpty() { + List issues = new ArrayList<>(); + + new DefaultJupiterConfiguration(configurationParameters(Map.of()), dummyOutputDirectoryCreator(), + DiscoveryIssueReporter.collecting(issues)).getDefaultTestInstanceLifecycle(); + + assertThat(issues).isEmpty(); + } + + @ParameterizedTest + @EnumSource(ParallelExecutorServiceType.class) + void doesNotReportAnyIssuesIfParallelExecutionIsEnabledAndConfigurationParameterIsSet( + ParallelExecutorServiceType executorServiceType) { + var parameters = Map.of(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, true, // + JupiterConfiguration.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType); + List issues = new ArrayList<>(); + + new DefaultJupiterConfiguration(ConfigurationParametersFactoryForTests.create(parameters), + dummyOutputDirectoryCreator(), DiscoveryIssueReporter.collecting(issues)).getDefaultTestInstanceLifecycle(); + + assertThat(issues).isEmpty(); + } + + @Test + void asksUsersToTryWorkerThreadPoolHierarchicalExecutorServiceIfParallelExecutionIsEnabled() { + var parameters = Map.of(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, true); + List issues = new ArrayList<>(); + + new DefaultJupiterConfiguration(configurationParameters(parameters), dummyOutputDirectoryCreator(), + DiscoveryIssueReporter.collecting(issues)).getDefaultTestInstanceLifecycle(); + + assertThat(issues).containsExactly(DiscoveryIssue.create(Severity.INFO, """ + Parallel test execution is enabled but the default ForkJoinPool-based executor service will be used. \ + Please give the new implementation based on a regular thread pool a try by setting the \ + 'junit.jupiter.execution.parallel.config.executor-service' configuration parameter to \ + 'WORKER_THREAD_POOL' and report any issues to the JUnit team. Alternatively, set the configuration \ + parameter to 'FORK_JOIN_POOL' to hide this message and keep using the original implementation.""")); + } + private void assertDefaultConfigParam(@Nullable String configValue, Lifecycle expected) { var lifecycle = getDefaultTestInstanceLifecycleConfigParam(configValue); assertThat(lifecycle).isEqualTo(expected); } private static Lifecycle getDefaultTestInstanceLifecycleConfigParam(@Nullable String configValue) { - ConfigurationParameters configParams = mock(); - when(configParams.get(KEY)).thenReturn(Optional.ofNullable(configValue)); + var configParams = configurationParameters(configValue == null ? Map.of() : Map.of(KEY, configValue)); return new DefaultJupiterConfiguration(configParams, dummyOutputDirectoryCreator(), mock()).getDefaultTestInstanceLifecycle(); } + private static ConfigurationParameters configurationParameters(Map<@NonNull String, ?> parameters) { + return ConfigurationParametersFactoryForTests.create(parameters); + } + + @NullMarked + private static class CustomFactory implements TempDirFactory { + + @Override + public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) { + throw new UnsupportedOperationException(); + } + } + } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java index 7be354083dfc..64691d61a7cd 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java @@ -70,14 +70,17 @@ import org.junit.jupiter.api.fixtures.TrackLogRecords; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; -import org.junit.jupiter.engine.config.JupiterConfiguration; +import org.junit.jupiter.engine.Constants; import org.junit.jupiter.engine.execution.injection.sample.LongParameterResolver; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.commons.PreconditionViolationException; import org.junit.platform.commons.logging.LogRecordListener; import org.junit.platform.commons.support.ModifierSupport; import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineExecutionResults; /** @@ -205,11 +208,15 @@ void registersProgrammaticTestInstancePostProcessors() { assertOneTestSucceeded(ProgrammaticTestInstancePostProcessorTestCase.class); } - @Test - void createsExtensionPerInstance() { + @ParameterizedTest + @EnumSource(ParallelExecutorServiceType.class) + void createsExtensionPerInstance( + ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType executorServiceType) { var results = executeTests(request() // .selectors(selectClass(InitializationPerInstanceTestCase.class)) // - .configurationParameter(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // + .configurationParameter(Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // + .configurationParameter(Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, + executorServiceType.name()) // ); assertTestsSucceeded(results, 100); } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java index 1a353645e773..a464c896aee0 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Order.DEFAULT; import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE; import static org.junit.jupiter.engine.Constants.DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME; +import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import static org.junit.platform.launcher.LauncherConstants.CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME; @@ -34,6 +35,7 @@ import java.util.logging.LogRecord; import java.util.regex.Pattern; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -57,7 +59,9 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.jupiter.engine.JupiterTestEngine; +import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.commons.logging.LogRecordListener; import org.junit.platform.commons.util.ClassUtils; @@ -65,6 +69,7 @@ import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.support.descriptor.ClassSource; import org.junit.platform.engine.support.descriptor.MethodSource; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineDiscoveryResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Events; @@ -76,7 +81,9 @@ * * @since 5.4 */ -class OrderedMethodTests { +@ParameterizedClass +@EnumSource(ParallelExecutorServiceType.class) +record OrderedMethodTests(ParallelExecutorServiceType executorServiceType) { private static final Set callSequence = Collections.synchronizedSet(new LinkedHashSet<>()); private static final Set threadNames = Collections.synchronizedSet(new LinkedHashSet<>()); @@ -361,12 +368,13 @@ private Events executeTestsInParallel(Class testClass, @Nullable Class testClass, - @Nullable Class defaultOrderer, Severity criticalSeverity) { + private EngineTestKit.Builder testKit(Class testClass, @Nullable Class defaultOrderer, + Severity criticalSeverity) { var testKit = EngineTestKit.engine("junit-jupiter") // .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // .configurationParameter(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent") // + .configurationParameter(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name()) // .configurationParameter(CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME, criticalSeverity.name()); if (defaultOrderer != null) { testKit.configurationParameter(DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME, defaultOrderer.getName()); @@ -767,7 +775,8 @@ public void orderMethods(MethodOrdererContext context) { @SuppressWarnings("unchecked") static T createMethodDescriptorImpersonator(MethodDescriptor method) { - MethodDescriptor stub = new MethodDescriptor() { + @NullMarked + class Stub implements MethodDescriptor { @Override public Method getMethod() { throw new UnsupportedOperationException(); @@ -803,8 +812,8 @@ public boolean equals(Object obj) { public int hashCode() { return method.hashCode(); } - }; - return (T) stub; + } + return (T) new Stub(); } } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java index f64a970dd9f7..bfb48e235ba0 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE; +import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; @@ -46,8 +47,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.engine.DiscoveryIssue.Severity; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.launcher.LauncherDiscoveryRequest; import org.junit.platform.testkit.engine.Events; @@ -135,7 +139,7 @@ static void afterAll() { @BeforeEach @AfterEach void beforeAndAfterEach(TestInfo testInfo, RepetitionInfo repetitionInfo) { - switch (testInfo.getTestMethod().get().getName()) { + switch (testInfo.getTestMethod().orElseThrow().getName()) { case "repeatedOnce" -> { assertThat(repetitionInfo.getCurrentRepetition()).isEqualTo(1); assertThat(repetitionInfo.getTotalRepetitions()).isEqualTo(1); @@ -291,14 +295,16 @@ void failureThreshold3() { // @formatter:on } - @Test - void failureThresholdWithConcurrentExecution() { + @ParameterizedTest + @EnumSource(ParallelExecutorServiceType.class) + void failureThresholdWithConcurrentExecution(ParallelExecutorServiceType executorServiceType) { Class testClass = TestCase.class; String methodName = "failureThresholdWithConcurrentExecution"; - Method method = ReflectionSupport.findMethod(testClass, methodName).get(); + Method method = ReflectionSupport.findMethod(testClass, methodName).orElseThrow(); LauncherDiscoveryRequest request = request()// .selectors(selectMethod(testClass, method))// .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true")// + .configurationParameter(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name()) // .configurationParameter(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent")// .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed")// .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, "4")// @@ -323,7 +329,7 @@ void failureThresholdWithConcurrentExecution() { private Events executeTest(String methodName) { Class testClass = TestCase.class; - Method method = ReflectionSupport.findMethod(testClass, methodName).get(); + Method method = ReflectionSupport.findMethod(testClass, methodName).orElseThrow(); return executeTests(selectMethod(testClass, method)).allEvents(); } diff --git a/jupiter-tests/src/test/resources/log4j2-test.xml b/jupiter-tests/src/test/resources/log4j2-test.xml index c637896979b2..a9bc7e469b5b 100644 --- a/jupiter-tests/src/test/resources/log4j2-test.xml +++ b/jupiter-tests/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java index 3fbbaabf6191..1b062d0b6e25 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java @@ -15,6 +15,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -30,11 +31,27 @@ */ class CompositeLockTests { + @Test + @SuppressWarnings({ "resource", "ResultOfMethodCallIgnored" }) + void triesToAcquireAllLocksInOrder() { + var lock1 = mock(Lock.class, "lock1"); + var lock2 = mock(Lock.class, "lock2"); + + when(lock1.tryLock()).thenReturn(true); + when(lock2.tryLock()).thenReturn(true); + + new CompositeLock(anyResources(2), List.of(lock1, lock2)).tryAcquire(); + + var inOrder = inOrder(lock1, lock2); + inOrder.verify(lock1).tryLock(); + inOrder.verify(lock2).tryLock(); + } + @Test @SuppressWarnings("resource") void acquiresAllLocksInOrder() throws Exception { - var lock1 = mock(Lock.class); - var lock2 = mock(Lock.class); + var lock1 = mock(Lock.class, "lock1"); + var lock2 = mock(Lock.class, "lock2"); new CompositeLock(anyResources(2), List.of(lock1, lock2)).acquire(); @@ -46,8 +63,8 @@ void acquiresAllLocksInOrder() throws Exception { @Test @SuppressWarnings("resource") void releasesAllLocksInReverseOrder() throws Exception { - var lock1 = mock(Lock.class); - var lock2 = mock(Lock.class); + var lock1 = mock(Lock.class, "lock1"); + var lock2 = mock(Lock.class, "lock2"); new CompositeLock(anyResources(2), List.of(lock1, lock2)).acquire().close(); @@ -83,6 +100,25 @@ void releasesLocksInReverseOrderWhenInterruptedDuringAcquire() throws Exception verify(unavailableLock, never()).unlock(); } + @Test + @SuppressWarnings("resource") + void releasesLocksInReverseOrderOnUnsuccessfulAttempt() { + var firstLock = mock(Lock.class, "firstLock"); + var secondLock = mock(Lock.class, "secondLock"); + var unavailableLock = mock(Lock.class, "unavailableLock"); + + when(firstLock.tryLock()).thenReturn(true); + when(secondLock.tryLock()).thenReturn(true); + when(unavailableLock.tryLock()).thenReturn(false); + + new CompositeLock(anyResources(3), List.of(firstLock, secondLock, unavailableLock)).tryAcquire(); + + var inOrder = inOrder(firstLock, secondLock); + inOrder.verify(secondLock).unlock(); + inOrder.verify(firstLock).unlock(); + verify(unavailableLock, never()).unlock(); + } + private Lock mockLock(String name, Executable lockAction) throws InterruptedException { var lock = mock(Lock.class, name); doAnswer(invocation -> { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java index 1df6e43eb0b0..c13e8e94f1aa 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java @@ -32,11 +32,16 @@ import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.engine.Constants; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineTestKit; // https://github.com/junit-team/junit-framework/issues/3945 @Timeout(10) -public class ForkJoinDeadLockTests { +@ParameterizedClass +@EnumSource(ParallelExecutorServiceType.class) +record ForkJoinDeadLockTests(ParallelExecutorServiceType executorServiceType) { @Test void forkJoinExecutionDoesNotLeadToDeadLock() { @@ -53,10 +58,12 @@ void multiLevelLocks() { run(ClassLevelTestCase.class); } - private static void run(Class... classes) { + private void run(Class... classes) { EngineTestKit.engine("junit-jupiter") // .selectors(selectClasses(classes)) // .configurationParameter(Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // + .configurationParameter(Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, + executorServiceType.name()) // .configurationParameter(Constants.DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent") // .configurationParameter(Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME, "concurrent") // .configurationParameter(Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") // diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java index 5845bd164892..b04e34a64586 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java @@ -33,6 +33,8 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -58,7 +60,7 @@ void exceptionsFromInvalidConfigurationAreNotSwallowed() { var configuration = new DefaultParallelExecutionConfiguration(2, 1, 1, 1, 0, __ -> true); JUnitException exception = assertThrows(JUnitException.class, () -> { - try (var pool = new ForkJoinPoolHierarchicalTestExecutorService(configuration)) { + try (var pool = new ForkJoinPoolHierarchicalTestExecutorService(configuration, TaskEventListener.NOOP)) { assertNotNull(pool, "we won't get here"); } }); @@ -168,7 +170,7 @@ static List compatibleLockCombinations() { ); } - @SuppressWarnings("DataFlowIssue") + @SuppressWarnings("NullAway") @ParameterizedTest @MethodSource("compatibleLockCombinations") void canWorkStealTaskWithCompatibleLocks(Set initialResources, @@ -293,8 +295,8 @@ private static void await(CountDownLatch latch, String message) { } private void withForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, - TaskEventListener taskEventListener, ThrowingConsumer action) - throws Throwable { + TaskEventListener taskEventListener, + ThrowingConsumer<@NonNull ForkJoinPoolHierarchicalTestExecutorService> action) throws Throwable { try (var service = new ForkJoinPoolHierarchicalTestExecutorService(configuration, taskEventListener)) { action.accept(service); @@ -304,14 +306,14 @@ private void withForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionCo } } + @NullMarked static final class DummyTestTask implements TestTask { private final String identifier; private final ResourceLock resourceLock; private final Executable action; - @Nullable - private volatile String threadName; + private volatile @Nullable String threadName; private final CountDownLatch started = new CountDownLatch(1); private final CompletableFuture completion = new CompletableFuture<>(); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java index 2602b22e5d5f..2bddf3bfb991 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java @@ -34,10 +34,14 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.function.ThrowingConsumer; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.ExecutionRequest; @@ -47,6 +51,7 @@ import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor; import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; import org.junit.platform.engine.support.hierarchical.Node.DynamicTestExecutor; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.launcher.core.ConfigurationParametersFactoryForTests; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -73,14 +78,14 @@ class HierarchicalTestExecutorTests { CancellationToken cancellationToken = CancellationToken.create(); MyEngineExecutionContext rootContext = new MyEngineExecutionContext(); - HierarchicalTestExecutor executor; + HierarchicalTestExecutor<@NonNull MyEngineExecutionContext> executor; @BeforeEach void init() { executor = createExecutor(new SameThreadHierarchicalTestExecutorService()); } - private HierarchicalTestExecutor createExecutor( + private HierarchicalTestExecutor<@NonNull MyEngineExecutionContext> createExecutor( HierarchicalTestExecutorService executorService) { ExecutionRequest request = mock(); when(request.getRootTestDescriptor()).thenReturn(root); @@ -555,9 +560,10 @@ void executesDynamicTestDescriptorsWithCustomListener() { inOrder.verify(anotherListener).executionFinished(dynamicTestDescriptor, successful()); } - @Test + @ParameterizedTest + @EnumSource(ParallelExecutorServiceType.class) @MockitoSettings(strictness = LENIENT) - void canAbortExecutionOfDynamicChild() throws Exception { + void canAbortExecutionOfDynamicChild(ParallelExecutorServiceType executorServiceType) throws Exception { var leafUniqueId = UniqueId.root("leaf", "child leaf"); var child = spy(new MyLeaf(leafUniqueId)); @@ -587,10 +593,11 @@ void canAbortExecutionOfDynamicChild() throws Exception { }); var parameters = ConfigurationParametersFactoryForTests.create(Map.of(// + ParallelHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType, // DefaultParallelExecutionConfigurationStrategy.CONFIG_STRATEGY_PROPERTY_NAME, "fixed", // - DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, "2")); + DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, 2)); - try (var executorService = new ForkJoinPoolHierarchicalTestExecutorService(parameters)) { + try (var executorService = ParallelHierarchicalTestExecutorServiceFactory.create(parameters)) { createExecutor(executorService).execute().get(); } @@ -602,7 +609,7 @@ private Answer execute(TestDescriptor dynamicChild) { return useDynamicTestExecutor(executor -> executor.execute(dynamicChild)); } - private Answer useDynamicTestExecutor(ThrowingConsumer action) { + private Answer useDynamicTestExecutor(ThrowingConsumer<@NonNull DynamicTestExecutor> action) { return invocation -> { DynamicTestExecutor dynamicTestExecutor = invocation.getArgument(1); action.accept(dynamicTestExecutor); @@ -658,7 +665,7 @@ void exceptionInAfterDoesNotHideEarlierException() throws Exception { inOrder.verify(listener).executionFinished(eq(child), childExecutionResult.capture()); assertThat(childExecutionResult.getValue().getStatus()).isEqualTo(FAILED); - assertThat(childExecutionResult.getValue().getThrowable().get()).isSameAs( + assertThat(childExecutionResult.getValue().getThrowable().orElseThrow()).isSameAs( exceptionInExecute).hasSuppressedException(exceptionInAfter); } @@ -707,7 +714,7 @@ void exceptionInAfterIsReportedInsteadOfEarlierTestAbortedException() throws Exc inOrder.verify(listener).executionFinished(eq(child), childExecutionResult.capture()); assertThat(childExecutionResult.getValue().getStatus()).isEqualTo(FAILED); - assertThat(childExecutionResult.getValue().getThrowable().get()).isSameAs( + assertThat(childExecutionResult.getValue().getThrowable().orElseThrow()).isSameAs( exceptionInAfter).hasSuppressedException(exceptionInExecute); } @@ -776,6 +783,7 @@ void reportsNodeAsSkippedWhenCancelledDuringBefore() throws Exception { private static class MyEngineExecutionContext implements EngineExecutionContext { } + @NullMarked private static class MyContainer extends AbstractTestDescriptor implements Node { MyContainer(UniqueId uniqueId) { @@ -788,6 +796,7 @@ public Type getType() { } } + @NullMarked private static class MyLeaf extends AbstractTestDescriptor implements Node { MyLeaf(UniqueId uniqueId) { @@ -806,6 +815,7 @@ public Type getType() { } } + @NullMarked private static class MyContainerAndTestTestCase extends AbstractTestDescriptor implements Node { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index ddeaaf294906..448432f25dbb 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE; import static org.junit.jupiter.engine.Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE; +import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME; @@ -74,12 +75,15 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.engine.DiscoverySelector; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.reporting.ReportEntry; import org.junit.platform.engine.support.descriptor.MethodSource; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Event; @@ -89,7 +93,9 @@ * @since 1.3 */ @SuppressWarnings({ "JUnitMalformedDeclaration", "NewClassNamingConvention" }) -class ParallelExecutionIntegrationTests { +@ParameterizedClass +@EnumSource(ParallelExecutorServiceType.class) +record ParallelExecutionIntegrationTests(ParallelExecutorServiceType executorServiceType) { @Test void successfulParallelTest(TestReporter reporter) { @@ -579,11 +585,12 @@ private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map< return executeWithFixedParallelism(parallelism, configParams, selectClasses(testClasses)); } - private static EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, + private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, List selectors) { return EngineTestKit.engine("junit-jupiter") // .selectors(selectors) // .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true)) // + .configurationParameter(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name()) // .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") // .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism)) // .configurationParameters(configParams) // diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java index d988d151435c..8237ea65d308 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java @@ -43,6 +43,20 @@ void release() throws Exception { assertFalse(lock.isLocked()); } + @Test + @SuppressWarnings("resource") + void tryAcquireAndRelease() { + var lock = new ReentrantLock(); + + var singleLock = new SingleLock(anyResource(), lock); + + singleLock.tryAcquire(); + assertTrue(lock.isLocked()); + + singleLock.release(); + assertFalse(lock.isLocked()); + } + private static ExclusiveResource anyResource() { return new ExclusiveResource("key", LockMode.READ); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java new file mode 100644 index 000000000000..7bd12990e0a8 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2015-2025 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.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.platform.engine.support.hierarchical.WorkerThreadPoolHierarchicalTestExecutorService.WorkerLeaseManager; + +class WorkerLeaseManagerTests { + + @Test + void releasingIsIdempotent() { + var released = new AtomicInteger(); + var manager = new WorkerLeaseManager(1, __ -> released.incrementAndGet()); + + var lease = manager.tryAcquire(); + assertThat(lease).isNotNull(); + + lease.close(); + assertThat(released.get()).isEqualTo(1); + + lease.close(); + assertThat(released.get()).isEqualTo(1); + } + + @Test + void leaseCanBeReacquired() throws Exception { + var released = new AtomicInteger(); + var manager = new WorkerLeaseManager(1, __ -> released.incrementAndGet()); + + var lease = manager.tryAcquire(); + assertThat(lease).isNotNull(); + + lease.close(); + assertThat(released.get()).isEqualTo(1); + + lease.reacquire(); + assertThat(released.get()).isEqualTo(1); + + lease.close(); + assertThat(released.get()).isEqualTo(2); + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java new file mode 100644 index 000000000000..786cc120a6f4 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -0,0 +1,884 @@ +/* + * Copyright 2015-2025 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.engine.support.hierarchical; + +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.Future.State.SUCCESS; +import static java.util.function.Predicate.isEqual; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; +import static org.junit.platform.engine.TestDescriptor.Type.CONTAINER; +import static org.junit.platform.engine.TestDescriptor.Type.TEST; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.URL; +import java.net.URLClassLoader; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.stream.Stream; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ToStringBuilder; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; +import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; +import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; +import org.junit.platform.fakes.TestDescriptorStub; +import org.opentest4j.AssertionFailedError; + +/** + * @since 6.1 + */ +@SuppressWarnings("resource") +@Timeout(5) +class WorkerThreadPoolHierarchicalTestExecutorServiceTests { + + @AutoClose + @Nullable + WorkerThreadPoolHierarchicalTestExecutorService service; + + @ParameterizedTest + @EnumSource(ExecutionMode.class) + void executesSingleTask(ExecutionMode executionMode) throws Exception { + + var task = new TestTaskStub(executionMode); + + var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); + try (customClassLoader) { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1), customClassLoader); + service.submit(task).get(); + } + + task.assertExecutedSuccessfully(); + + var executionThread = task.executionThread(); + assertThat(executionThread).isNotNull().isNotSameAs(Thread.currentThread()); + assertThat(executionThread.getName()).matches("junit-\\d+-worker-1"); + assertThat(executionThread.getContextClassLoader()).isSameAs(customClassLoader); + } + + @Test + void invokeAllMustBeExecutedFromWithinThreadPool() { + var tasks = List.of(new TestTaskStub(ExecutionMode.CONCURRENT)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); + + assertPreconditionViolationFor(() -> requiredService().invokeAll(tasks)) // + .withMessage("invokeAll() must be called from a worker thread that belongs to this executor"); + } + + @ParameterizedTest + @EnumSource(ExecutionMode.class) + void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode childExecutionMode) + throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); + + var child = new TestTaskStub(childExecutionMode); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(List.of(child))); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + child.assertExecutedSuccessfully(); + + assertThat(root.executionThread()).isNotNull(); + assertThat(child.executionThread()).isSameAs(root.executionThread()); + } + + @Test + void executesTwoChildrenConcurrently() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); + + var latch = new CountDownLatch(2); + Executable behavior = () -> { + latch.countDown(); + latch.await(); + }; + + var children = List.of(new TestTaskStub(ExecutionMode.CONCURRENT, behavior), + new TestTaskStub(ExecutionMode.CONCURRENT, behavior)); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + } + + @Test + void executesTwoChildrenInSameThread() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); + + var children = List.of(new TestTaskStub(ExecutionMode.SAME_THREAD), + new TestTaskStub(ExecutionMode.SAME_THREAD)); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)); + + service.submit(root).get(); + + assertThat(root.executionThread()).isNotNull(); + assertThat(children).extracting(TestTaskStub::executionThread).containsOnly(root.executionThread()); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + } + + @Test + void acquiresResourceLockForRootTask() throws Exception { + var resourceLock = mock(ResourceLock.class); + when(resourceLock.acquire()).thenReturn(resourceLock); + + var task = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock); + + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); + service.submit(task).get(); + + task.assertExecutedSuccessfully(); + + var inOrder = inOrder(resourceLock); + inOrder.verify(resourceLock).acquire(); + inOrder.verify(resourceLock).close(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + void acquiresResourceLockForChildTasks() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); + + var resourceLock = mock(ResourceLock.class); + when(resourceLock.tryAcquire()).thenReturn(true, false); + when(resourceLock.acquire()).thenReturn(resourceLock); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock).withName("child1"); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock).withName("child2"); + var children = List.of(child1, child2); + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> requiredService().invokeAll(children)).withName( + "root"); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(children).extracting(TestTaskStub::executionThread) // + .filteredOn(isEqual(root.executionThread())).hasSizeLessThanOrEqualTo(2); + + verify(resourceLock, atLeast(2)).tryAcquire(); + verify(resourceLock, atLeast(1)).acquire(); + verify(resourceLock, times(2)).close(); + } + + @Test + void runsTasksWithoutConflictingLocksConcurrently() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(3)); + + var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); + + var latch = new CountDownLatch(3); + Executable behavior = () -> { + latch.countDown(); + latch.await(); + }; + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior).withResourceLock(resourceLock).withName( + "child1"); + var child2 = new TestTaskStub(ExecutionMode.SAME_THREAD).withResourceLock(resourceLock).withName("child2"); + var leaf1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior).withName("leaf1"); + var leaf2 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior).withName("leaf2"); + var leaves = List.of(leaf1, leaf2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(leaves)).withName( + "child3"); + var children = List.of(child1, child2, child3); + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> requiredService().invokeAll(children)).withName( + "root"); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(leaves).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + } + + @Test + void processingQueueEntriesSkipsOverUnavailableResources() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); + + var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); + + var lockFreeChildrenStarted = new CountDownLatch(2); + var child1Started = new CountDownLatch(1); + + Executable child1Behaviour = () -> { + child1Started.countDown(); + lockFreeChildrenStarted.await(); + }; + Executable child4Behaviour = () -> { + lockFreeChildrenStarted.countDown(); + child1Started.await(); + }; + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, child1Behaviour) // + .withResourceLock(resourceLock) // + .withName("child1"); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown) // + .withName("child2"); // + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withResourceLock(resourceLock) // + .withName("child3"); + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, child4Behaviour) // + .withName("child4"); + var children = List.of(child1, child2, child3, child4); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)) // + .withName("root"); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child4.executionThread).isEqualTo(child2.executionThread); + assertThat(child3.startTime).isAfterOrEqualTo(child2.startTime); + } + + @Test + void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); + + var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); + + var lockFreeChildrenStarted = new CountDownLatch(2); + var child2Started = new CountDownLatch(1); + + Executable child1Behaviour = () -> { + lockFreeChildrenStarted.countDown(); + child2Started.await(); + }; + Executable child2Behaviour = () -> { + child2Started.countDown(); + lockFreeChildrenStarted.await(); + }; + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, child1Behaviour) // + .withName("child1"); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Behaviour) // + .withResourceLock(resourceLock) // + .withName("child2"); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withResourceLock(resourceLock) // + .withName("child3"); // + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown) // + .withName("child4"); + var children = List.of(child1, child2, child3, child4); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)) // + .withName("root"); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child1.executionThread).isEqualTo(child4.executionThread); + assertThat(child3.startTime).isAfterOrEqualTo(child4.startTime); + } + + @Test + void prioritizesChildrenOfStartedContainers() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + var leafSubmitted = new CountDownLatch(1); + var child2AndLeafStarted = new CountDownLatch(2); + + var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, child2AndLeafStarted::countDown) // + .withName("leaf").withLevel(3); + + Executable child3Behavior = () -> { + var future = requiredService().submit(leaf); + leafSubmitted.countDown(); + child2AndLeafStarted.await(); + future.get(); + }; + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, leafSubmitted::await) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2AndLeafStarted::countDown) // + .withName("child2").withLevel(2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, child3Behavior) // + .withType(CONTAINER).withName("child3").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2, child3))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(List.of(root, child1, child2, leaf, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); + assertThat(leaf.executionThread).isSameAs(child2.executionThread).isNotSameAs(child3.executionThread); + } + + @Test + void prioritizesTestsOverContainers() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); + + var leavesStarted = new CountDownLatch(2); + var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // + .withName("leaf").withLevel(3).withType(TEST); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // + .withName("child1").withLevel(2).withType(CONTAINER); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // + .withName("child2").withLevel(2).withType(TEST); + var child3 = new TestTaskStub(ExecutionMode.SAME_THREAD, leavesStarted::await) // + .withName("child3").withLevel(2).withType(TEST); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2, child3))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(List.of(child1, child2, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + leaf.assertExecutedSuccessfully(); + + assertThat(child2.startTime).isBeforeOrEqualTo(child1.startTime); + } + + @Test + void limitsWorkerThreadsToMaxPoolSize() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(3, 3)); + + CountDownLatch latch = new CountDownLatch(3); + Executable behavior = () -> { + latch.countDown(); + latch.await(); + }; + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1a").withLevel(3); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1b").withLevel(3); + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2a").withLevel(3); + var leaf2b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2b").withLevel(3); + + // When executed, there are 2 worker threads active and 1 available. + // Both invokeAlls race each other trying to start 1 more. + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, + () -> requiredService().invokeAll(List.of(leaf1a, leaf1b))) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, + () -> requiredService().invokeAll(List.of(leaf2a, leaf2b))) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, child1, child2, leaf1a, leaf1b, leaf2a, leaf2b)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(Stream.of(leaf1a, leaf1b, leaf2a, leaf2b).map(TestTaskStub::executionThread).distinct()) // + .hasSize(3); + } + + @Test + void stealsBlockingChildren() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + var child1Started = new CountDownLatch(1); + var leaf2aStarted = new CountDownLatch(1); + var leaf2bStarted = new CountDownLatch(1); + var readWriteLock = new ReentrantReadWriteLock(); + var readOnlyResourceLock = new SingleLock(exclusiveResource(LockMode.READ), readWriteLock.readLock()) { + @Override + public void release() { + super.release(); + try { + leaf2aStarted.await(); + } + catch (InterruptedException e) { + fail(e); + } + } + }; + var readWriteResourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), readWriteLock.writeLock()); + + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, leaf2aStarted::countDown) // + .withResourceLock(readWriteResourceLock) // + .withName("leaf2a").withLevel(3); + var leaf2b = new TestTaskStub(ExecutionMode.SAME_THREAD, leaf2bStarted::countDown) // + .withName("leaf2b").withLevel(3); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + leaf2bStarted.await(); + }) // + .withResourceLock(readOnlyResourceLock) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + child1Started.await(); + requiredService().invokeAll(List.of(leaf2a, leaf2b)); + }) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, child1, child2, leaf2a, leaf2b)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(List.of(leaf2a, leaf2b)).map(TestTaskStub::executionThread) // + .containsOnly(child2.executionThread); + } + + @Test + void executesChildrenInOrder() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); + + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1a").withLevel(2); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1b").withLevel(2); + var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1c").withLevel(2); + var leaf1d = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1d").withLevel(2); + + List children = Arrays.asList(leaf1d, leaf1a, leaf1b, leaf1c); + Collections.shuffle(children); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, // + () -> requiredService().invokeAll(children)) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf1d)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(children) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + + @Test + void testsAreStolenRatherThanContainers() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + // Execute tasks pairwise + CyclicBarrier cyclicBarrier = new CyclicBarrier(2); + Executable behavior = cyclicBarrier::await; + + // With half of the leaves being containers + var container1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("container1").withType(CONTAINER).withLevel(2); + var container2 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("container2").withType(CONTAINER).withLevel(2); + var container3 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("container3").withType(CONTAINER).withLevel(2); + + // And half of the leaves being tests, to be stolen + var test1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("test1").withType(TEST).withLevel(2); + var test2 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("test2").withType(TEST).withLevel(2); + var test3 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("test3").withType(TEST).withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(container1, container2, container3, test1, test2, test3))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, container1, container2, container3, test1, test2, test3)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + // If the last test node was stolen + assertThat(container1.executionThread).isNotEqualTo(test3.executionThread); + // Then it must follow that the test nodes were stolen + assertThat(Stream.of(container1, container2, container3)) // + .extracting(TestTaskStub::executionThread) // + .containsOnly(container1.executionThread); + assertThat(Stream.of(test1, test2, test3)) // + .extracting(TestTaskStub::executionThread) // + .containsOnly(test3.executionThread); + + assertThat(Stream.of(container1, container2, container3)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + assertThat(Stream.of(test1, test2, test3)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + + @Test + void stealsDynamicChildren() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + var child1Started = new CountDownLatch(1); + var child2Finished = new CountDownLatch(1); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + child2Finished.await(); + }) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Finished::countDown) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + child1Started.await(); + var future2 = requiredService().submit(child2); + future1.get(); + future2.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child2.executionThread).isEqualTo(root.executionThread).isNotEqualTo(child1.executionThread); + } + + @Test + void stealsDynamicChildrenInOrder() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + var child1Started = new CountDownLatch(1); + var childrenSubmitted = new CountDownLatch(1); + var childrenFinished = new CountDownLatch(2); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + childrenSubmitted.await(); + }) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, childrenFinished::countDown) // + .withName("child2").withLevel(2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, childrenFinished::countDown) // + .withName("child3").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + child1Started.await(); + var future2 = requiredService().submit(child2); + var future3 = requiredService().submit(child3); + childrenSubmitted.countDown(); + childrenFinished.await(); + future1.get(); + future2.get(); + future3.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2, child3)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(List.of(child1, child2, child3)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + + @Test + void executesDynamicChildrenInSubmitOrder() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child2").withLevel(2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child3").withLevel(2); + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child3").withLevel(2); + + List children = Arrays.asList(child1, child2, child3, child4); + Collections.shuffle(children); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var executor = requiredService(); + var features = children.stream().map(executor::submit).toList(); + for (var future : features) { + future.get(); + } + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(children) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + + @Test + void stealsNestedDynamicChildren() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + var barrier = new CyclicBarrier(2); + + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1a").withLevel(3); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1b").withLevel(3); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + barrier.await(); + var futureA = requiredService().submit(leaf1a); + barrier.await(); + var futureB = requiredService().submit(leaf1b); + futureA.get(); + futureB.get(); + barrier.await(); + }) // + .withName("child1").withLevel(2); + + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf2a").withLevel(3); + var leaf2b = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf2b").withLevel(3); + + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + barrier.await(); + var futureA = requiredService().submit(leaf2a); + barrier.await(); + var futureB = requiredService().submit(leaf2b); + futureB.get(); + futureA.get(); + barrier.await(); + }) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + var future2 = requiredService().submit(child2); + future1.get(); + future2.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2, leaf1a, leaf1b, leaf2a, leaf2b)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child2.executionThread).isNotEqualTo(child1.executionThread); + assertThat(child1.executionThread).isEqualTo(leaf1a.executionThread).isEqualTo(leaf1b.executionThread); + assertThat(child2.executionThread).isEqualTo(leaf2a.executionThread).isEqualTo(leaf2b.executionThread); + } + + @Test + void stealsSiblingDynamicChildrenOnly() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 3)); + + var child1Started = new CountDownLatch(1); + var child3Started = new CountDownLatch(1); + var leaf2ASubmitted = new CountDownLatch(1); + var leaf2AStarted = new CountDownLatch(1); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + leaf2ASubmitted.await(); + }) // + .withName("child1").withLevel(2); + + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + leaf2AStarted.countDown(); + child3Started.await(); + }) // + .withName("leaf1a").withLevel(3); + + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + var futureA = requiredService().submit(leaf2a); + leaf2ASubmitted.countDown(); + leaf2AStarted.await(); + futureA.get(); + }) // + .withName("child2").withType(CONTAINER).withLevel(2); + + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, child3Started::countDown) // + .withName("child3").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + child1Started.await(); + var future2 = requiredService().submit(child2); + var future3 = requiredService().submit(child3); + future1.get(); + future2.get(); + future3.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2, leaf2a, child3)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(child2.executionThread).isNotEqualTo(child1.executionThread).isNotEqualTo(child3.executionThread); + assertThat(child1.executionThread).isNotEqualTo(child3.executionThread); + assertThat(child1.executionThread).isEqualTo(leaf2a.executionThread); + } + + private static ExclusiveResource exclusiveResource(LockMode lockMode) { + return new ExclusiveResource("key", lockMode); + } + + private WorkerThreadPoolHierarchicalTestExecutorService requiredService() { + return requireNonNull(service); + } + + private static ParallelExecutionConfiguration configuration(int parallelism) { + return configuration(parallelism, 256 + parallelism); + } + + private static ParallelExecutionConfiguration configuration(int parallelism, int maxPoolSize) { + return new DefaultParallelExecutionConfiguration(parallelism, parallelism, maxPoolSize, parallelism, 0, + __ -> true); + } + + @NullMarked + private static final class TestTaskStub implements TestTask { + + private final ExecutionMode executionMode; + private final Executable behavior; + + private ResourceLock resourceLock = NopLock.INSTANCE; + private @Nullable String name; + private int level = 1; + private TestDescriptor.Type type = TEST; + + private final CompletableFuture<@Nullable Void> result = new CompletableFuture<>(); + private volatile @Nullable Instant startTime; + private volatile @Nullable Thread executionThread; + + TestTaskStub(ExecutionMode executionMode) { + this(executionMode, () -> { + }); + } + + TestTaskStub(ExecutionMode executionMode, Executable behavior) { + this.executionMode = executionMode; + this.behavior = behavior; + } + + TestTaskStub withName(String name) { + this.name = name; + return this; + } + + TestTaskStub withLevel(int level) { + this.level = level; + return this; + } + + TestTaskStub withType(TestDescriptor.Type type) { + this.type = type; + return this; + } + + TestTaskStub withResourceLock(ResourceLock resourceLock) { + this.resourceLock = resourceLock; + return this; + } + + @Override + public ExecutionMode getExecutionMode() { + return executionMode; + } + + @Override + public ResourceLock getResourceLock() { + return resourceLock; + } + + @Override + public TestDescriptor getTestDescriptor() { + var name = String.valueOf(this.name); + var uniqueId = UniqueId.root("root", name); + for (var i = 1; i < level; i++) { + uniqueId = uniqueId.append("child", name); + } + return new TestDescriptorStub(uniqueId, name) { + @Override + public Type getType() { + return type; + } + }; + } + + @Override + public void execute() { + startTime = Instant.now(); + Preconditions.condition(!result.isDone(), "task was already executed"); + + executionThread = Thread.currentThread(); + try { + behavior.execute(); + result.complete(null); + } + catch (Throwable t) { + result.completeExceptionally(t); + throw throwAsUncheckedException(t); + } + } + + void assertExecutedSuccessfully() { + if (result.isCompletedExceptionally()) { + throw new AssertionFailedError("Failure during execution", result.exceptionNow()); + } + assertThat(result.state()).isEqualTo(SUCCESS); + } + + @Nullable + Thread executionThread() { + return executionThread; + } + + @Nullable + Instant startTime() { + return startTime; + } + + @Override + public String toString() { + return "%s @ %s".formatted(new ToStringBuilder(this).append("name", name), Integer.toHexString(hashCode())); + } + } + +} diff --git a/platform-tests/src/test/resources/junit-platform.properties b/platform-tests/src/test/resources/junit-platform.properties index 6efc0d5e85ce..cbed1a38134b 100644 --- a/platform-tests/src/test/resources/junit-platform.properties +++ b/platform-tests/src/test/resources/junit-platform.properties @@ -1 +1,2 @@ junit.jupiter.extensions.autodetection.enabled=true +junit.platform.stacktrace.pruning.enabled=false diff --git a/platform-tests/src/test/resources/log4j2-test.xml b/platform-tests/src/test/resources/log4j2-test.xml index b973e5f0bc7c..9e69004caa27 100644 --- a/platform-tests/src/test/resources/log4j2-test.xml +++ b/platform-tests/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + @@ -21,6 +21,7 @@ + diff --git a/platform-tooling-support-tests/src/test/resources/junit-platform.properties b/platform-tooling-support-tests/src/test/resources/junit-platform.properties index d24bbed7d3d9..fde4f9aa54a2 100644 --- a/platform-tooling-support-tests/src/test/resources/junit-platform.properties +++ b/platform-tooling-support-tests/src/test/resources/junit-platform.properties @@ -1,5 +1,6 @@ junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.config.executor-service=worker_thread_pool junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=0.25 junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor=1 diff --git a/platform-tooling-support-tests/src/test/resources/log4j2-test.xml b/platform-tooling-support-tests/src/test/resources/log4j2-test.xml index 97726b18a018..89fa6837ce7c 100644 --- a/platform-tooling-support-tests/src/test/resources/log4j2-test.xml +++ b/platform-tooling-support-tests/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - +