Skip to content

Draft support for cancelling test execution #4709

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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;

import org.apiguardian.api.API;

/**
* Token that should be checked to determine whether an operation was requested
* to be cancelled.
*
* <p>For example, this is used by {@link org.junit.platform.engine.TestEngine}
* implementations to determine whether
*
* <p>This interface is not intended to be implemented by clients.
*
* @since 6.0
* @see ExecutionRequest#getCancellationToken()
*/
@API(status = API.Status.EXPERIMENTAL, since = "6.0")
public interface CancellationToken {

static CancellationToken create() {
return new DefaultCancellationToken();
}

boolean isCancellationRequested();

void cancel();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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;

import java.util.concurrent.atomic.AtomicBoolean;

class DefaultCancellationToken implements CancellationToken {

private final AtomicBoolean cancelled = new AtomicBoolean();

@Override
public boolean isCancellationRequested() {
return cancelled.get();
}

@Override
public void cancel() {
cancelled.set(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,29 @@ public class ExecutionRequest {
private final TestDescriptor rootTestDescriptor;
private final EngineExecutionListener engineExecutionListener;
private final ConfigurationParameters configurationParameters;

private final @Nullable OutputDirectoryProvider outputDirectoryProvider;

private final @Nullable NamespacedHierarchicalStore<Namespace> requestLevelStore;
private final @Nullable CancellationToken cancellationToken;

@Deprecated
@API(status = DEPRECATED, since = "1.11")
public ExecutionRequest(TestDescriptor rootTestDescriptor, EngineExecutionListener engineExecutionListener,
ConfigurationParameters configurationParameters) {
this(rootTestDescriptor, engineExecutionListener, configurationParameters, null, null);
this(rootTestDescriptor, engineExecutionListener, configurationParameters, null, null, null);
}

private ExecutionRequest(TestDescriptor rootTestDescriptor, EngineExecutionListener engineExecutionListener,
ConfigurationParameters configurationParameters, @Nullable OutputDirectoryProvider outputDirectoryProvider,
@Nullable NamespacedHierarchicalStore<Namespace> requestLevelStore) {
@Nullable NamespacedHierarchicalStore<Namespace> requestLevelStore,
@Nullable CancellationToken cancellationToken) {
this.rootTestDescriptor = Preconditions.notNull(rootTestDescriptor, "rootTestDescriptor must not be null");
this.engineExecutionListener = Preconditions.notNull(engineExecutionListener,
"engineExecutionListener must not be null");
this.configurationParameters = Preconditions.notNull(configurationParameters,
"configurationParameters must not be null");
this.outputDirectoryProvider = outputDirectoryProvider;
this.requestLevelStore = requestLevelStore;
this.cancellationToken = cancellationToken;
}

/**
Expand Down Expand Up @@ -105,11 +106,13 @@ public static ExecutionRequest create(TestDescriptor rootTestDescriptor,
@API(status = INTERNAL, since = "1.13")
public static ExecutionRequest create(TestDescriptor rootTestDescriptor,
EngineExecutionListener engineExecutionListener, ConfigurationParameters configurationParameters,
OutputDirectoryProvider outputDirectoryProvider, NamespacedHierarchicalStore<Namespace> requestLevelStore) {
OutputDirectoryProvider outputDirectoryProvider, NamespacedHierarchicalStore<Namespace> requestLevelStore,
CancellationToken cancellationToken) {

return new ExecutionRequest(rootTestDescriptor, engineExecutionListener, configurationParameters,
Preconditions.notNull(outputDirectoryProvider, "outputDirectoryProvider must not be null"),
Preconditions.notNull(requestLevelStore, "requestLevelStore must not be null"));
Preconditions.notNull(requestLevelStore, "requestLevelStore must not be null"),
Preconditions.notNull(cancellationToken, "cancellationToken must not be null"));
}

/**
Expand Down Expand Up @@ -171,4 +174,8 @@ public NamespacedHierarchicalStore<Namespace> getStore() {
"No NamespacedHierarchicalStore was configured for this request");
}

public CancellationToken getCancellationToken() {
Copy link
Member Author

@marcphilipp marcphilipp Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New API for TestEngine implementors

return Preconditions.notNull(this.cancellationToken, "No CancellationToken was configured for this request");
Copy link
Contributor

@mpkorstanje mpkorstanje Jul 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had some difficulty inferring from what code how I could safely use the cancellation token. Instead of making the cancellationToken nullable, I would expect a default implementation to be provided in the deprecated constructor.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can instead return a default one here since it has a sensible default.

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.util.concurrent.Future;

import org.jspecify.annotations.Nullable;
import org.junit.platform.engine.CancellationToken;
import org.junit.platform.engine.EngineExecutionListener;
import org.junit.platform.engine.ExecutionRequest;
import org.junit.platform.engine.TestDescriptor;
Expand Down Expand Up @@ -51,8 +52,9 @@ class HierarchicalTestExecutor<C extends EngineExecutionContext> {
TestDescriptor rootTestDescriptor = this.request.getRootTestDescriptor();
EngineExecutionListener executionListener = this.request.getEngineExecutionListener();
NodeExecutionAdvisor executionAdvisor = new NodeTreeWalker().walk(rootTestDescriptor);
CancellationToken cancellationToken = this.request.getCancellationToken();
NodeTestTaskContext taskContext = new NodeTestTaskContext(executionListener, this.executorService,
this.throwableCollectorFactory, executionAdvisor);
this.throwableCollectorFactory, executionAdvisor, cancellationToken);
NodeTestTask<C> rootTestTask = new NodeTestTask<>(taskContext, rootTestDescriptor);
rootTestTask.setParentContext(this.rootContext);
return this.executorService.submit(rootTestTask);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
class NodeTestTask<C extends EngineExecutionContext> implements TestTask {

private static final Logger logger = LoggerFactory.getLogger(NodeTestTask.class);

private static final SkipResult CANCELLED_SKIP_RESULT = SkipResult.skip("Test execution cancelled");

private static final Runnable NOOP = () -> {
};

Expand Down Expand Up @@ -101,7 +104,7 @@ public void execute() {
throwableCollector = taskContext.throwableCollectorFactory().create();
prepare();
if (throwableCollector.isEmpty()) {
checkWhetherSkipped();
throwableCollector.execute(() -> skipResult = checkWhetherSkipped());
}
if (throwableCollector.isEmpty() && !requiredSkipResult().isSkipped()) {
executeRecursively();
Expand Down Expand Up @@ -139,8 +142,10 @@ private void prepare() {
parentContext = null;
}

private void checkWhetherSkipped() {
requiredThrowableCollector().execute(() -> skipResult = node.shouldBeSkipped(requiredContext()));
private SkipResult checkWhetherSkipped() throws Exception {
return taskContext.cancellationToken().isCancellationRequested() //
? CANCELLED_SKIP_RESULT //
: node.shouldBeSkipped(requiredContext());
Comment on lines +145 to +148
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Support for engines extending HierarchicalTestEngine

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Cucumber this means that cancellation will work out of the box. 👍

}

private void executeRecursively() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@

package org.junit.platform.engine.support.hierarchical;

import org.junit.platform.engine.CancellationToken;
import org.junit.platform.engine.EngineExecutionListener;

/**
* @since 1.3.1
*/
record NodeTestTaskContext(EngineExecutionListener listener, HierarchicalTestExecutorService executorService,
ThrowableCollector.Factory throwableCollectorFactory, NodeExecutionAdvisor executionAdvisor) {
ThrowableCollector.Factory throwableCollectorFactory, NodeExecutionAdvisor executionAdvisor,
CancellationToken cancellationToken) {

NodeTestTaskContext withListener(EngineExecutionListener listener) {
if (this.listener == listener) {
return this;
}
return new NodeTestTaskContext(listener, executorService, throwableCollectorFactory, executionAdvisor);
return new NodeTestTaskContext(listener, executorService, throwableCollectorFactory, executionAdvisor,
cancellationToken);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

package org.junit.platform.launcher;

import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.apiguardian.api.API.Status.STABLE;

import org.apiguardian.api.API;
import org.junit.platform.engine.CancellationToken;

/**
* The {@code Launcher} API is the main entry point for client code that
Expand Down Expand Up @@ -108,6 +110,37 @@ public interface Launcher {
*/
void execute(LauncherDiscoveryRequest launcherDiscoveryRequest, TestExecutionListener... listeners);

/**
* Execute a {@link TestPlan} which is built according to the supplied
* {@link LauncherDiscoveryRequest} by querying all registered engines and
* collecting their results, and notify
* {@linkplain #registerTestExecutionListeners registered listeners} about
* the progress and results of the execution.
*
* <p>Supplied test execution listeners are registered in addition to already
* registered listeners but only for the supplied launcher discovery request.
*
* <p>Additionally, it's possible to request cancellation of the started
* execution via the supplied {@link CancellationToken}.
*
* @apiNote Calling this method will cause test discovery to be executed for
* all registered engines. If the same {@link LauncherDiscoveryRequest} was
* previously passed to {@link #discover(LauncherDiscoveryRequest)}, you
* should instead call {@link #execute(TestPlan, TestExecutionListener...)}
* and pass the already acquired {@link TestPlan} to avoid the potential
* performance degradation (e.g., classpath scanning) of running test
* discovery twice.
*
* @param launcherDiscoveryRequest the launcher discovery request; never {@code null}
* @param cancellationToken the token used to request cancellation of the
* started execution; never {@code null}
* @param listeners additional test execution listeners; never {@code null}
* @since 6.0
*/
@API(status = EXPERIMENTAL, since = "6.0")
void execute(LauncherDiscoveryRequest launcherDiscoveryRequest, CancellationToken cancellationToken,
TestExecutionListener... listeners);

/**
* Execute the supplied {@link TestPlan} and notify
* {@linkplain #registerTestExecutionListeners registered listeners} about
Expand All @@ -127,4 +160,28 @@ public interface Launcher {
@API(status = STABLE, since = "1.4")
void execute(TestPlan testPlan, TestExecutionListener... listeners);

/**
* Execute the supplied {@link TestPlan} and notify
* {@linkplain #registerTestExecutionListeners registered listeners} about
* the progress and results of the execution.
*
* <p>Supplied test execution listeners are registered in addition to
* already registered listeners but only for the execution of the supplied
* test plan.
*
* <p>Additionally, it's possible to request cancellation of the started
* execution via the supplied {@link CancellationToken}.
*
* @apiNote The supplied {@link TestPlan} must not have been executed
* previously.
*
* @param testPlan the test plan to execute; never {@code null}
* @param cancellationToken the token used to request cancellation of the
* started execution; never {@code null}
* @param listeners additional test execution listeners; never {@code null}
* @since 6.0
*/
@API(status = EXPERIMENTAL, since = "6.0")
void execute(TestPlan testPlan, CancellationToken cancellationToken, TestExecutionListener... listeners);
Copy link
Member Author

@marcphilipp marcphilipp Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New API for build tools and IDEs


}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.util.Collection;

import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.engine.CancellationToken;
import org.junit.platform.engine.TestEngine;
import org.junit.platform.engine.support.store.Namespace;
import org.junit.platform.engine.support.store.NamespacedHierarchicalStore;
Expand Down Expand Up @@ -85,28 +86,42 @@ public TestPlan discover(LauncherDiscoveryRequest discoveryRequest) {

@Override
public void execute(LauncherDiscoveryRequest discoveryRequest, TestExecutionListener... listeners) {
execute(discoveryRequest, CancellationToken.create(), listeners);
}

@Override
public void execute(LauncherDiscoveryRequest discoveryRequest, CancellationToken cancellationToken,
TestExecutionListener... listeners) {

Preconditions.notNull(discoveryRequest, "LauncherDiscoveryRequest must not be null");
Preconditions.notNull(listeners, "TestExecutionListener array must not be null");
Preconditions.containsNoNullElements(listeners, "individual listeners must not be null");
execute(InternalTestPlan.from(discover(discoveryRequest, EXECUTION)), listeners);
execute(InternalTestPlan.from(discover(discoveryRequest, EXECUTION)), cancellationToken, listeners);
}

@Override
public void execute(TestPlan testPlan, TestExecutionListener... listeners) {
execute(testPlan, CancellationToken.create(), listeners);
}

@Override
public void execute(TestPlan testPlan, CancellationToken cancellationToken, TestExecutionListener... listeners) {
Preconditions.notNull(testPlan, "TestPlan must not be null");
Preconditions.condition(testPlan instanceof InternalTestPlan, "TestPlan was not returned by this Launcher");
Preconditions.notNull(cancellationToken, "CancellationToken must not be null");
Preconditions.notNull(listeners, "TestExecutionListener array must not be null");
Preconditions.containsNoNullElements(listeners, "individual listeners must not be null");
execute((InternalTestPlan) testPlan, listeners);
execute((InternalTestPlan) testPlan, cancellationToken, listeners);
}

private LauncherDiscoveryResult discover(LauncherDiscoveryRequest discoveryRequest, LauncherPhase phase) {
return discoveryOrchestrator.discover(discoveryRequest, phase);
}

private void execute(InternalTestPlan internalTestPlan, TestExecutionListener[] listeners) {
private void execute(InternalTestPlan internalTestPlan, CancellationToken cancellationToken,
TestExecutionListener[] listeners) {
try (NamespacedHierarchicalStore<Namespace> requestLevelStore = createRequestLevelStore()) {
executionOrchestrator.execute(internalTestPlan, requestLevelStore, listeners);
executionOrchestrator.execute(internalTestPlan, requestLevelStore, cancellationToken, listeners);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.util.function.Supplier;

import org.junit.platform.commons.PreconditionViolationException;
import org.junit.platform.engine.CancellationToken;
import org.junit.platform.engine.support.store.Namespace;
import org.junit.platform.engine.support.store.NamespacedHierarchicalStore;
import org.junit.platform.launcher.Launcher;
Expand Down Expand Up @@ -121,10 +122,22 @@ public void execute(LauncherDiscoveryRequest launcherDiscoveryRequest, TestExecu
throw new PreconditionViolationException("Launcher session has already been closed");
}

@Override
public void execute(LauncherDiscoveryRequest launcherDiscoveryRequest, CancellationToken cancellationToken,
TestExecutionListener... listeners) {
throw new PreconditionViolationException("Launcher session has already been closed");
}

@Override
public void execute(TestPlan testPlan, TestExecutionListener... listeners) {
throw new PreconditionViolationException("Launcher session has already been closed");
}

@Override
public void execute(TestPlan testPlan, CancellationToken cancellationToken,
TestExecutionListener... listeners) {
throw new PreconditionViolationException("Launcher session has already been closed");
}
}

private static LauncherInterceptor composite(List<LauncherInterceptor> interceptors) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

package org.junit.platform.launcher.core;

import org.junit.platform.engine.CancellationToken;
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherDiscoveryListener;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
Expand Down Expand Up @@ -47,9 +48,19 @@ public void execute(LauncherDiscoveryRequest launcherDiscoveryRequest, TestExecu
delegate.execute(launcherDiscoveryRequest, listeners);
}

@Override
public void execute(LauncherDiscoveryRequest launcherDiscoveryRequest, CancellationToken cancellationToken,
TestExecutionListener... listeners) {
delegate.execute(launcherDiscoveryRequest, cancellationToken, listeners);
}

@Override
public void execute(TestPlan testPlan, TestExecutionListener... listeners) {
delegate.execute(testPlan, listeners);
}

@Override
public void execute(TestPlan testPlan, CancellationToken cancellationToken, TestExecutionListener... listeners) {
delegate.execute(testPlan, cancellationToken, listeners);
}
}
Loading
Loading