From 62e2b695249470854796e256b490394bb79df76e Mon Sep 17 00:00:00 2001 From: AndreasTu Date: Sat, 19 Aug 2023 18:29:05 +0200 Subject: [PATCH] PreInterruptCallback extension Added PreInterruptCallback extension to allow to hook into the @Timeout extension before the executing Thread is interrupted. The default implementation of PreInterruptCallback will simply print the stacks of all Thread to System.out. It is disabled by default and must be enabled with: junit.jupiter.extensions.preinterruptcallback.default.enabled = true Issue: #2938 --- .../docs/asciidoc/user-guide/extensions.adoc | 15 ++ .../asciidoc/user-guide/writing-tests.adoc | 6 + .../api/extension/PreInterruptCallback.java | 45 +++++ .../org/junit/jupiter/engine/Constants.java | 7 + .../config/CachingJupiterConfiguration.java | 6 + .../config/DefaultJupiterConfiguration.java | 6 + .../engine/config/JupiterConfiguration.java | 3 + .../JupiterEngineExecutionContext.java | 7 + .../DefaultPreInterruptCallback.java | 73 ++++++++ .../extension/MutableExtensionRegistry.java | 23 ++- .../PreInterruptCallbackInvocation.java | 24 +++ ...PreInterruptCallbackInvocationFactory.java | 46 +++++ .../SameThreadTimeoutInvocation.java | 21 ++- .../engine/extension/TimeoutExtension.java | 4 +- .../extension/TimeoutInvocationFactory.java | 12 +- .../extension/PreInterruptCallbackTests.java | 177 ++++++++++++++++++ .../SameThreadTimeoutInvocationTests.java | 3 +- .../SeparateThreadTimeoutInvocationTests.java | 3 +- .../TimeoutInvocationFactoryTests.java | 3 +- 19 files changed, 471 insertions(+), 13 deletions(-) create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/PreInterruptCallback.java create mode 100644 junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultPreInterruptCallback.java create mode 100644 junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocation.java create mode 100644 junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocationFactory.java create mode 100644 junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/PreInterruptCallbackTests.java diff --git a/documentation/src/docs/asciidoc/user-guide/extensions.adoc b/documentation/src/docs/asciidoc/user-guide/extensions.adoc index 09ca2ec5e88b..9f15dffa3f17 100644 --- a/documentation/src/docs/asciidoc/user-guide/extensions.adoc +++ b/documentation/src/docs/asciidoc/user-guide/extensions.adoc @@ -599,6 +599,21 @@ test methods. include::{testDir}/example/exception/MultipleHandlersTestCase.java[tags=user_guide] ---- +[[extensions-preinterrupt-callback]] +=== PreInterrupt Callback + +`{PreInterruptCallback}` defines the API for `Extensions` that wish to react on +`Thread.interrupt()` calls issued by Jupiter before the `Thread.interrupt()` is executed. + +This can be used to dump stacks for diagnostics, when the `Timeout` extension +interrupts tests. + +There is also a default implementation available, which will dump the stacks of all +`Threads` to `System.out`. +This default implementation need to be enabled with the +<>: +`junit.jupiter.extensions.preinterruptcallback.default.enabled` + [[extensions-intercepting-invocations]] === Intercepting Invocations diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 0a0862b6f2b8..3ddb4d873889 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -2244,6 +2244,12 @@ NOTE: If you need more control over polling intervals and greater flexibility wi asynchronous tests, consider using a dedicated library such as link:https://github.com/awaitility/awaitility[Awaitility]. +[[writing-tests-dump-stack-timeout]] +=== Dump Stacks on Timeout + +It can be helpful for debugging to dump the stacks of all Threads, when a Timeout happened. +The <> provides a default +implementation for that. [[writing-tests-declarative-timeouts-mode]] ==== Disable @Timeout Globally diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/PreInterruptCallback.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/PreInterruptCallback.java new file mode 100644 index 000000000000..c34f41631b08 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/PreInterruptCallback.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2023 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.jupiter.api.extension; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import org.apiguardian.api.API; + +/** + * {@code PreInterruptCallback} defines the API for {@link Extension + * Extensions} that wish to react on {@link Thread#interrupt()} calls issued by Jupiter + * before the {@link Thread#interrupt()} is executed. + * + *

This can be used to e.g. dump stacks for diagnostics, when the {@link org.junit.jupiter.api.Timeout} + * extension is used.

+ * + *

There is also a default implementation available, which will dump the stacks of all {@link Thread Threads} + * to {@code System.out}. This default implementation need to be enabled with the jupiter property: + * {@code junit.jupiter.extensions.preinterruptcallback.default.enabled} + * + * + * @since 5.21 + * @see org.junit.jupiter.api.Timeout + */ +@API(status = EXPERIMENTAL, since = "5.21") +public interface PreInterruptCallback extends Extension { + + /** + * Callback that is invoked before a {@link Thread} is interrupted with {@link Thread#interrupt()}. + * + *

Caution: There is no guarantee on which {@link Thread} this callback will be executed.

+ * + * @param threadToInterrupt the target {@link Thread}, which will get interrupted. + * @param context the current extension context; never {@code null} + */ + void beforeThreadInterrupt(Thread threadToInterrupt, ExtensionContext context) throws Exception; +} 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 2c7b509c9964..180cc74cac27 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 @@ -107,6 +107,13 @@ public final class Constants { */ public static final String EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME = JupiterConfiguration.EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME; + /** + * Property name used to enable the default {@link org.junit.jupiter.api.extension.PreInterruptCallback} extension. + * + *

The default behavior is not to enable the pre interrupt callback. + */ + public static final String EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME = JupiterConfiguration.EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME; + /** * Property name used to set the default test instance lifecycle mode: {@value} * diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java index 2d61b58c1c32..47b740f9cf26 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java @@ -67,6 +67,12 @@ public boolean isExtensionAutoDetectionEnabled() { key -> delegate.isExtensionAutoDetectionEnabled()); } + @Override + public boolean isExtensionDefaultPreInterruptCallbackEnabled() { + return (boolean) cache.computeIfAbsent(EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME, + key -> delegate.isExtensionDefaultPreInterruptCallbackEnabled()); + } + @Override public ExecutionMode getDefaultExecutionMode() { return (ExecutionMode) cache.computeIfAbsent(DEFAULT_EXECUTION_MODE_PROPERTY_NAME, 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 d64c4ceee318..68e8c8982491 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 @@ -89,6 +89,12 @@ public boolean isExtensionAutoDetectionEnabled() { return configurationParameters.getBoolean(EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME).orElse(false); } + @Override + public boolean isExtensionDefaultPreInterruptCallbackEnabled() { + return configurationParameters.getBoolean(EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME)// + .orElse(false); + } + @Override public ExecutionMode getDefaultExecutionMode() { return executionModeConverter.get(configurationParameters, DEFAULT_EXECUTION_MODE_PROPERTY_NAME, 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 559b4d7d5715..0956e05f54e4 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 @@ -39,6 +39,7 @@ public interface JupiterConfiguration { 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; String EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.enabled"; + String EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.preinterruptcallback.default.enabled"; String DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME = TestInstance.Lifecycle.DEFAULT_LIFECYCLE_PROPERTY_NAME; String DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME = DisplayNameGenerator.DEFAULT_GENERATOR_PROPERTY_NAME; String DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME = MethodOrderer.DEFAULT_ORDER_PROPERTY_NAME; @@ -52,6 +53,8 @@ public interface JupiterConfiguration { boolean isExtensionAutoDetectionEnabled(); + boolean isExtensionDefaultPreInterruptCallbackEnabled(); + ExecutionMode getDefaultExecutionMode(); ExecutionMode getDefaultClassesExecutionMode(); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/JupiterEngineExecutionContext.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/JupiterEngineExecutionContext.java index a31c9c02ad27..fc81750e0e40 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/JupiterEngineExecutionContext.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/JupiterEngineExecutionContext.java @@ -177,12 +177,19 @@ public Builder withThrowableCollector(ThrowableCollector throwableCollector) { public JupiterEngineExecutionContext build() { if (newState != null) { + storeExtensionRegistryInExtensionContext(); originalState = newState; newState = null; } return new JupiterEngineExecutionContext(originalState); } + private void storeExtensionRegistryInExtensionContext() { + if (newState.extensionRegistry != null && newState.extensionContext != null) { + newState.extensionRegistry.storeInExtensionContext(newState.extensionContext); + } + } + private State newState() { if (newState == null) { this.newState = originalState.clone(); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultPreInterruptCallback.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultPreInterruptCallback.java new file mode 100644 index 000000000000..b4d698c0b93c --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DefaultPreInterruptCallback.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015-2023 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.jupiter.engine.extension; + +import java.util.Map; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.PreInterruptCallback; +import org.junit.jupiter.engine.Constants; + +/** + * The default implementation for {@link PreInterruptCallback}, + * which will print the stacks of all {@link Thread}s to {@code System.out}. + * + *

Note: This is disabled by default, and must be enabled with + * {@link Constants#EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME} + * + * @since 5.21 + */ +public class DefaultPreInterruptCallback implements PreInterruptCallback { + private static final String NL = "\n"; + + @Override + public void beforeThreadInterrupt(Thread threadToInterrupt, ExtensionContext context) { + Map stackTraces = Thread.getAllStackTraces(); + StringBuilder sb = new StringBuilder(); + sb.append("Thread "); + appendThreadName(sb, threadToInterrupt); + sb.append(" will be interrupted."); + sb.append(NL); + for (Map.Entry entry : stackTraces.entrySet()) { + Thread thread = entry.getKey(); + StackTraceElement[] stack = entry.getValue(); + if (stack.length > 0) { + sb.append(NL); + appendThreadName(sb, thread); + for (StackTraceElement stackTraceElement : stack) { + sb.append(NL); + //Do the same prefix as java.lang.Throwable.printStackTrace(java.lang.Throwable.PrintStreamOrWriter) + sb.append("\tat "); + sb.append(stackTraceElement.toString()); + + } + sb.append(NL); + } + } + System.out.println(sb); + } + + /** + * Appends the {@link Thread} name and ID in a similar fashion as {@code jstack}. + * @param sb the buffer + * @param th the thread to append + */ + private void appendThreadName(StringBuilder sb, Thread th) { + sb.append("\""); + sb.append(th.getName()); + sb.append("\""); + sb.append(" #"); + sb.append(th.getId()); + if (th.isDaemon()) { + sb.append(" daemon"); + } + } +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java index 0a1433505c7b..4c54a2079520 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java @@ -26,6 +26,7 @@ import org.apiguardian.api.API; import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; @@ -44,7 +45,8 @@ */ @API(status = INTERNAL, since = "5.5") public class MutableExtensionRegistry implements ExtensionRegistry, ExtensionRegistrar { - + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create( + MutableExtensionRegistry.class); private static final Logger logger = LoggerFactory.getLogger(MutableExtensionRegistry.class); private static final List DEFAULT_STATELESS_EXTENSIONS = Collections.unmodifiableList(Arrays.asList(// @@ -63,6 +65,9 @@ public class MutableExtensionRegistry implements ExtensionRegistry, ExtensionReg * auto-detected using Java's {@link ServiceLoader} mechanism and automatically * registered after the default extensions. * + *

If the {@link org.junit.jupiter.engine.Constants#EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME} + * configuration parameter has been set to {@code true}, the {@link DefaultPreInterruptCallback} will be installed. + * * @param configuration configuration parameters used to retrieve the extension * auto-detection flag; never {@code null} * @return a new {@code ExtensionRegistry}; never {@code null} @@ -77,7 +82,9 @@ public static MutableExtensionRegistry createRegistryWithDefaultExtensions(Jupit if (configuration.isExtensionAutoDetectionEnabled()) { registerAutoDetectedExtensions(extensionRegistry); } - + if (configuration.isExtensionDefaultPreInterruptCallbackEnabled()) { + extensionRegistry.registerDefaultExtension(new DefaultPreInterruptCallback()); + } return extensionRegistry; } @@ -166,6 +173,18 @@ public void registerSyntheticExtension(Extension extension, Object source) { registerExtension("synthetic", extension, source); } + public void storeInExtensionContext(ExtensionContext extensionContext) { + ExtensionContext.Store store = extensionContext.getStore(MutableExtensionRegistry.NAMESPACE); + if (store != null) { + store.put(ExtensionRegistry.class, this); + } + } + + static ExtensionRegistry getRegistryFromExtensionContext(ExtensionContext extensionContext) { + return (ExtensionRegistry) extensionContext.getStore(MutableExtensionRegistry.NAMESPACE).get( + ExtensionRegistry.class); + } + private void registerDefaultExtension(Extension extension) { registerExtension("default", extension); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocation.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocation.java new file mode 100644 index 000000000000..c646da27a35d --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocation.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015-2023 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.jupiter.engine.extension; + +import java.util.function.Consumer; + +/** + * @since 5.21 + */ +@FunctionalInterface +interface PreInterruptCallbackInvocation { + PreInterruptCallbackInvocation NOOP = (t, e) -> { + }; + + void executePreInterruptCallback(Thread threadToInterrupt, Consumer errorHandler); +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocationFactory.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocationFactory.java new file mode 100644 index 000000000000..6778698d83dd --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/PreInterruptCallbackInvocationFactory.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015-2023 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.jupiter.engine.extension; + +import java.util.List; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.PreInterruptCallback; +import org.junit.platform.commons.util.UnrecoverableExceptions; + +/** + * @since 5.21 + */ +final class PreInterruptCallbackInvocationFactory { + + private PreInterruptCallbackInvocationFactory() { + + } + + static PreInterruptCallbackInvocation create(ExtensionContext extensionContext) { + ExtensionRegistry registry = MutableExtensionRegistry.getRegistryFromExtensionContext(extensionContext); + if (registry == null) { + return PreInterruptCallbackInvocation.NOOP; + } + List callbacks = registry.getExtensions(PreInterruptCallback.class); + return (thread, errorHandler) -> { + for (PreInterruptCallback callback : callbacks) { + try { + callback.beforeThreadInterrupt(thread, extensionContext); + } + catch (Throwable ex) { + UnrecoverableExceptions.rethrowIfUnrecoverable(ex); + errorHandler.accept(ex); + } + } + }; + } +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocation.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocation.java index e620151dffd7..172457f00ba2 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocation.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocation.java @@ -10,6 +10,8 @@ package org.junit.jupiter.engine.extension; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.function.Supplier; @@ -26,18 +28,20 @@ class SameThreadTimeoutInvocation implements Invocation { private final TimeoutDuration timeout; private final ScheduledExecutorService executor; private final Supplier descriptionSupplier; + private final PreInterruptCallbackInvocation preInterruptCallback; SameThreadTimeoutInvocation(Invocation delegate, TimeoutDuration timeout, ScheduledExecutorService executor, - Supplier descriptionSupplier) { + Supplier descriptionSupplier, PreInterruptCallbackInvocation preInterruptCallback) { this.delegate = delegate; this.timeout = timeout; this.executor = executor; this.descriptionSupplier = descriptionSupplier; + this.preInterruptCallback = preInterruptCallback; } @Override public T proceed() throws Throwable { - InterruptTask interruptTask = new InterruptTask(Thread.currentThread()); + InterruptTask interruptTask = new InterruptTask(Thread.currentThread(), preInterruptCallback); ScheduledFuture future = executor.schedule(interruptTask, timeout.getValue(), timeout.getUnit()); Throwable failure = null; T result = null; @@ -56,6 +60,7 @@ public T proceed() throws Throwable { if (interruptTask.executed) { Thread.interrupted(); failure = TimeoutExceptionFactory.create(descriptionSupplier.get(), timeout, failure); + interruptTask.attachSuppressedExceptions(failure); } } if (failure != null) { @@ -65,20 +70,28 @@ public T proceed() throws Throwable { } static class InterruptTask implements Runnable { - + private final PreInterruptCallbackInvocation preInterruptCallback; + private final List exceptionsDuringInterruption = new CopyOnWriteArrayList<>(); private final Thread thread; private volatile boolean executed; - InterruptTask(Thread thread) { + InterruptTask(Thread thread, PreInterruptCallbackInvocation preInterruptCallback) { this.thread = thread; + this.preInterruptCallback = preInterruptCallback; } @Override public void run() { executed = true; + preInterruptCallback.executePreInterruptCallback(thread, exceptionsDuringInterruption::add); thread.interrupt(); } + void attachSuppressedExceptions(Throwable outerException) { + for (Throwable throwable : exceptionsDuringInterruption) { + outerException.addSuppressed(throwable); + } + } } } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java index 9efe2255e9d3..061d97be08f0 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java @@ -177,8 +177,8 @@ private Invocation decorate(Invocation invocation, ReflectiveInvocatio ThreadMode threadMode = resolveTimeoutThreadMode(extensionContext); return new TimeoutInvocationFactory(extensionContext.getRoot().getStore(NAMESPACE)).create(threadMode, - new TimeoutInvocationParameters<>(invocation, timeout, - () -> describe(invocationContext, extensionContext))); + new TimeoutInvocationParameters<>(invocation, timeout, () -> describe(invocationContext, extensionContext), + PreInterruptCallbackInvocationFactory.create(extensionContext))); } private ThreadMode resolveTimeoutThreadMode(ExtensionContext extensionContext) { diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactory.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactory.java index 08f6851f1943..8739cb75dc62 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactory.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactory.java @@ -43,7 +43,8 @@ Invocation create(ThreadMode threadMode, TimeoutInvocationParameters t } return new SameThreadTimeoutInvocation<>(timeoutInvocationParameters.getInvocation(), timeoutInvocationParameters.getTimeoutDuration(), getThreadExecutorForSameThreadInvocation(), - timeoutInvocationParameters.getDescriptionSupplier()); + timeoutInvocationParameters.getDescriptionSupplier(), + timeoutInvocationParameters.getPreInterruptCallback()); } private ScheduledExecutorService getThreadExecutorForSameThreadInvocation() { @@ -90,13 +91,16 @@ static class TimeoutInvocationParameters { private final Invocation invocation; private final TimeoutDuration timeout; private final Supplier descriptionSupplier; + private final PreInterruptCallbackInvocation preInterruptCallback; TimeoutInvocationParameters(Invocation invocation, TimeoutDuration timeout, - Supplier descriptionSupplier) { + Supplier descriptionSupplier, PreInterruptCallbackInvocation preInterruptCallback) { this.invocation = Preconditions.notNull(invocation, "invocation must not be null"); this.timeout = Preconditions.notNull(timeout, "timeout must not be null"); this.descriptionSupplier = Preconditions.notNull(descriptionSupplier, "description supplier must not be null"); + this.preInterruptCallback = Preconditions.notNull(preInterruptCallback, + "preInterruptCallback must not be null"); } public Invocation getInvocation() { @@ -110,5 +114,9 @@ public TimeoutDuration getTimeoutDuration() { public Supplier getDescriptionSupplier() { return descriptionSupplier; } + + public PreInterruptCallbackInvocation getPreInterruptCallback() { + return preInterruptCallback; + } } } diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/PreInterruptCallbackTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/PreInterruptCallbackTests.java new file mode 100644 index 000000000000..2fb8fadad80d --- /dev/null +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/PreInterruptCallbackTests.java @@ -0,0 +1,177 @@ +/* + * Copyright 2015-2023 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.jupiter.engine.extension; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE; +import static org.junit.jupiter.api.parallel.Resources.SYSTEM_PROPERTIES; +import static org.junit.platform.testkit.engine.EventConditions.event; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.EventConditions.test; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.suppressed; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.PreInterruptCallback; +import org.junit.jupiter.api.parallel.Isolated; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.jupiter.engine.Constants; +import org.junit.platform.testkit.engine.Events; + +/** + * @since 5.21 + */ +@Isolated +class PreInterruptCallbackTests extends AbstractJupiterTestEngineTests { + private static final String TC = "test"; + private static final String TIMEOUT_ERROR_MSG = TC + "() timed out after 1 microsecond"; + private static final String DEFAULT_ENABLE_PROPERTY = Constants.EXTENSIONS_DEFAULT_PRE_INTERRUPT_CALLBACK_ENABLED_PROPERTY_NAME; + private static final AtomicBoolean interruptedTest = new AtomicBoolean(); + private static final AtomicBoolean interruptCallbackCalled = new AtomicBoolean(); + private static final AtomicBoolean interruptCallbackShallThrowException = new AtomicBoolean(); + + @BeforeEach + void setUp() { + interruptedTest.set(true); + interruptCallbackCalled.set(false); + interruptCallbackShallThrowException.set(false); + } + + @Test + @ResourceLock(value = SYSTEM_PROPERTIES, mode = READ_WRITE) + void testCaseWithDefaultInterruptCallbackEnabled() { + String orgValue = System.getProperty(DEFAULT_ENABLE_PROPERTY); + System.setProperty(DEFAULT_ENABLE_PROPERTY, Boolean.TRUE.toString()); + PrintStream orgOut = System.out; + Events tests; + String output; + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + PrintStream outStream = new PrintStream(buffer); + System.setOut(outStream); + tests = executeTestsForClass(DefaultPreInterruptCallbackTimeoutOnMethodTestCase.class).testEvents(); + output = buffer.toString(StandardCharsets.UTF_8); + } + finally { + System.setOut(orgOut); + if (orgValue != null) { + System.setProperty(DEFAULT_ENABLE_PROPERTY, orgValue); + } + else { + System.clearProperty(DEFAULT_ENABLE_PROPERTY); + } + } + + assertTestHasTimedOut(tests); + assertTrue(interruptedTest.get()); + Thread thread = Thread.currentThread(); + assertTrue(output.contains("Thread \"" + thread.getName() + "\" #" + thread.getId() + " will be interrupted."), + output); + assertTrue(output.contains("java.lang.Thread.sleep"), output); + assertTrue(output.contains( + "org.junit.jupiter.engine.extension.PreInterruptCallbackTests$DefaultPreInterruptCallbackTimeoutOnMethodTestCase.test(PreInterruptCallbackTests.java"), + output); + + assertTrue(output.contains("junit-jupiter-timeout-watcher"), output); + assertTrue( + output.contains("org.junit.jupiter.engine.extension.DefaultPreInterruptCallback.beforeThreadInterrupt"), + output); + } + + @Test + void testCaseWithNoInterruptCallbackEnabled() { + + Events tests = executeTestsForClass(DefaultPreInterruptCallbackTimeoutOnMethodTestCase.class).testEvents(); + assertTestHasTimedOut(tests); + assertTrue(interruptedTest.get()); + } + + @Test + void testCaseWithDeclaredInterruptCallbackEnabled() { + Events tests = executeTestsForClass(DefaultPreInterruptCallbackWithExplicitCallbackTestCase.class).testEvents(); + assertTestHasTimedOut(tests); + assertTrue(interruptedTest.get()); + assertTrue(interruptCallbackCalled.get()); + } + + @Test + void testCaseWithDeclaredInterruptCallbackThrowsException() { + interruptCallbackShallThrowException.set(true); + Events tests = executeTestsForClass(DefaultPreInterruptCallbackWithExplicitCallbackTestCase.class).testEvents(); + tests.failed().assertEventsMatchExactly(event(test(TC), + finishedWithFailure(instanceOf(TimeoutException.class), message(TIMEOUT_ERROR_MSG), + suppressed(0, instanceOf(InterruptedException.class)), + suppressed(1, instanceOf(IllegalStateException.class))))); + assertTrue(interruptedTest.get()); + assertTrue(interruptCallbackCalled.get()); + } + + private static void assertTestHasTimedOut(Events tests) { + tests.assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + tests.failed().assertEventsMatchExactly( + event(test(TC), finishedWithFailure(instanceOf(TimeoutException.class), message(TIMEOUT_ERROR_MSG), // + suppressed(0, instanceOf(InterruptedException.class))// + ))); + } + + static class TestPreInterruptCallback implements PreInterruptCallback { + + @Override + public void beforeThreadInterrupt(Thread threadToInterrupt, ExtensionContext context) { + interruptCallbackCalled.set(true); + if (interruptCallbackShallThrowException.get()) { + throw new IllegalStateException("Test-Ex"); + } + } + } + + static class DefaultPreInterruptCallbackTimeoutOnMethodTestCase { + @Test + @Timeout(value = 1, unit = TimeUnit.MICROSECONDS) + void test() throws InterruptedException { + try { + Thread.sleep(1000); + } + catch (InterruptedException ex) { + interruptedTest.set(true); + throw ex; + } + } + } + + @ExtendWith(TestPreInterruptCallback.class) + static class DefaultPreInterruptCallbackWithExplicitCallbackTestCase { + @Test + @Timeout(value = 1, unit = TimeUnit.MICROSECONDS) + void test() throws InterruptedException { + try { + Thread.sleep(1000); + } + catch (InterruptedException ex) { + interruptedTest.set(true); + throw ex; + } + } + } +} diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocationTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocationTests.java index 81630493d66e..a166dbdca3ca 100644 --- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocationTests.java +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SameThreadTimeoutInvocationTests.java @@ -34,7 +34,8 @@ void resetsInterruptFlag() { var exception = assertThrows(TimeoutException.class, () -> withExecutor(executor -> { var delegate = new EventuallyInterruptibleInvocation(); var duration = new TimeoutDuration(1, NANOSECONDS); - var timeoutInvocation = new SameThreadTimeoutInvocation<>(delegate, duration, executor, () -> "execution"); + var timeoutInvocation = new SameThreadTimeoutInvocation<>(delegate, duration, executor, () -> "execution", + PreInterruptCallbackInvocation.NOOP); timeoutInvocation.proceed(); })); assertFalse(Thread.currentThread().isInterrupted()); diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SeparateThreadTimeoutInvocationTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SeparateThreadTimeoutInvocationTests.java index c8d51e7987a8..638d23626ca0 100644 --- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SeparateThreadTimeoutInvocationTests.java +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/SeparateThreadTimeoutInvocationTests.java @@ -73,7 +73,8 @@ private static SeparateThreadTimeoutInvocation aSeparateThreadInvocation( var namespace = ExtensionContext.Namespace.create(SeparateThreadTimeoutInvocationTests.class); var store = new NamespaceAwareStore(new NamespacedHierarchicalStore<>(null), namespace); var parameters = new TimeoutInvocationParameters<>(invocation, - new TimeoutDuration(PREEMPTIVE_TIMEOUT_MILLIS, MILLISECONDS), () -> "method()"); + new TimeoutDuration(PREEMPTIVE_TIMEOUT_MILLIS, MILLISECONDS), () -> "method()", + PreInterruptCallbackInvocation.NOOP); return (SeparateThreadTimeoutInvocation) new TimeoutInvocationFactory(store) // .create(ThreadMode.SEPARATE_THREAD, parameters); } diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactoryTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactoryTests.java index 9d3c46487dab..e0832cd1b86b 100644 --- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactoryTests.java +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TimeoutInvocationFactoryTests.java @@ -46,7 +46,8 @@ class TimeoutInvocationFactoryTests { @BeforeEach void setUp() { - parameters = new TimeoutInvocationParameters<>(invocation, timeoutDuration, () -> "description"); + parameters = new TimeoutInvocationParameters<>(invocation, timeoutDuration, () -> "description", + PreInterruptCallbackInvocation.NOOP); timeoutInvocationFactory = new TimeoutInvocationFactory(store); }