diff options
Diffstat (limited to 'kotlinx-coroutines-debug/src/junit/junit5/CoroutinesTimeoutExtension.kt')
-rw-r--r-- | kotlinx-coroutines-debug/src/junit/junit5/CoroutinesTimeoutExtension.kt | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/kotlinx-coroutines-debug/src/junit/junit5/CoroutinesTimeoutExtension.kt b/kotlinx-coroutines-debug/src/junit/junit5/CoroutinesTimeoutExtension.kt new file mode 100644 index 00000000..442fdf8c --- /dev/null +++ b/kotlinx-coroutines-debug/src/junit/junit5/CoroutinesTimeoutExtension.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.debug.junit5 + +import kotlinx.coroutines.debug.* +import kotlinx.coroutines.debug.runWithTimeoutDumpingCoroutines +import org.junit.jupiter.api.extension.* +import org.junit.platform.commons.support.AnnotationSupport +import java.lang.reflect.* +import java.util.* +import java.util.concurrent.atomic.* + +internal class CoroutinesTimeoutException(val timeoutMs: Long): Exception("test timed out after $timeoutMs ms") + +/** + * This JUnit5 extension allows running test, test factory, test template, and lifecycle methods in a separate thread, + * failing them after the provided time limit and interrupting the thread. + * + * Additionally, it installs [DebugProbes] and dumps all coroutines at the moment of the timeout. It also cancels + * coroutines on timeout if [cancelOnTimeout] set to `true`. + * [enableCoroutineCreationStackTraces] controls the corresponding [DebugProbes.enableCreationStackTraces] property + * and can be optionally disabled to speed-up tests if creation stack traces are not needed. + * + * Beware that if several tests that use this extension set [enableCoroutineCreationStackTraces] to different values and + * execute in parallel, the behavior is ill-defined. In order to avoid conflicts between different instances of this + * extension when using JUnit5 in parallel, use [ResourceLock] with resource name `coroutines timeout` on tests that use + * it. Note that the tests annotated with [CoroutinesTimeout] already use this [ResourceLock], so there is no need to + * annotate them additionally. + * + * Note that while calls to test factories are verified to finish in the specified time, but the methods that they + * produce are not affected by this extension. + * + * Beware that registering the extension via [CoroutinesTimeout] annotation conflicts with manually registering it on + * the same tests via other methods (most notably, [RegisterExtension]) and is prohibited. + * + * Example of usage: + * ``` + * class HangingTest { + * @JvmField + * @RegisterExtension + * val timeout = CoroutinesTimeoutExtension.seconds(5) + * + * @Test + * fun testThatHangs() = runBlocking { + * ... + * delay(Long.MAX_VALUE) // somewhere deep in the stack + * ... + * } + * } + * ``` + * + * @see [CoroutinesTimeout] + * */ +// NB: the constructor is not private so that JUnit is able to call it via reflection. +internal class CoroutinesTimeoutExtension internal constructor( + private val enableCoroutineCreationStackTraces: Boolean = true, + private val timeoutMs: Long? = null, + private val cancelOnTimeout: Boolean? = null): InvocationInterceptor +{ + /** + * Creates the [CoroutinesTimeoutExtension] extension with the given timeout in milliseconds. + */ + public constructor(timeoutMs: Long, cancelOnTimeout: Boolean = false, + enableCoroutineCreationStackTraces: Boolean = true): + this(enableCoroutineCreationStackTraces, timeoutMs, cancelOnTimeout) + + public companion object { + /** + * Creates the [CoroutinesTimeoutExtension] extension with the given timeout in seconds. + */ + @JvmOverloads + public fun seconds(timeout: Int, cancelOnTimeout: Boolean = false, + enableCoroutineCreationStackTraces: Boolean = true): CoroutinesTimeoutExtension = + CoroutinesTimeoutExtension(enableCoroutineCreationStackTraces, timeout.toLong() * 1000, cancelOnTimeout) + } + + /** @see [initialize] */ + private val debugProbesOwnershipPassed = AtomicBoolean(false) + + private fun tryPassDebugProbesOwnership() = debugProbesOwnershipPassed.compareAndSet(false, true) + + /* We install the debug probes early so that the coroutines launched from the test constructor are captured as well. + However, this is not enough as the same extension instance may be reused several times, even cleaning up its + resources from the store. */ + init { + DebugProbes.enableCreationStackTraces = enableCoroutineCreationStackTraces + DebugProbes.install() + } + + // This is needed so that a class with no tests still successfully passes the ownership of DebugProbes to JUnit5. + override fun <T : Any?> interceptTestClassConstructor( + invocation: InvocationInterceptor.Invocation<T>, + invocationContext: ReflectiveInvocationContext<Constructor<T>>, + extensionContext: ExtensionContext + ): T { + initialize(extensionContext) + return invocation.proceed() + } + + /** + * Initialize this extension instance and/or the extension value store. + * + * It seems that the only way to reliably have JUnit5 clean up after its extensions is to put an instance of + * [ExtensionContext.Store.CloseableResource] into the value store corresponding to the extension instance, which + * means that [DebugProbes.uninstall] must be placed into the value store. [debugProbesOwnershipPassed] is `true` + * if the call to [DebugProbes.install] performed in the constructor of the extension instance was matched with a + * placing of [DebugProbes.uninstall] into the value store. We call the process of placing the cleanup procedure + * "passing the ownership", as now JUnit5 (and not our code) has to worry about uninstalling the debug probes. + * + * However, extension instances can be reused with different value stores, and value stores can be reused across + * extension instances. This leads to a tricky scheme of performing [DebugProbes.uninstall]: + * + * * If neither the ownership of this instance's [DebugProbes] was yet passed nor there is any cleanup procedure + * stored, it means that we can just store our cleanup procedure, passing the ownership. + * * If the ownership was not yet passed, but a cleanup procedure is already stored, we can't just replace it with + * another one, as this would lead to imbalance between [DebugProbes.install] and [DebugProbes.uninstall]. + * Instead, we know that this extension context will at least outlive this use of this instance, so some debug + * probes other than the ones from our constructor are already installed and won't be uninstalled during our + * operation. We simply uninstall the debug probes that were installed in our constructor. + * * If the ownership was passed, but the store is empty, it means that this test instance is reused and, possibly, + * the debug probes installed in its constructor were already uninstalled. This means that we have to install them + * anew and store an uninstaller. + */ + private fun initialize(extensionContext: ExtensionContext) { + val store: ExtensionContext.Store = extensionContext.getStore( + ExtensionContext.Namespace.create(CoroutinesTimeoutExtension::class, extensionContext.uniqueId)) + /** It seems that the JUnit5 documentation does not specify the relationship between the extension instances and + * the corresponding [ExtensionContext] (in which the value stores are managed), so it is unclear whether it's + * theoretically possible for two extension instances that run concurrently to share an extension context. So, + * just in case this risk exists, we synchronize here. */ + synchronized(store) { + if (store["debugProbes"] == null) { + if (!tryPassDebugProbesOwnership()) { + /** This means that the [DebugProbes.install] call from the constructor of this extensions has + * already been matched with a corresponding cleanup procedure for JUnit5, but then JUnit5 cleaned + * everything up and later reused the same extension instance for other tests. Therefore, we need to + * install the [DebugProbes] anew. */ + DebugProbes.enableCreationStackTraces = enableCoroutineCreationStackTraces + DebugProbes.install() + } + /** put a fake resource into this extensions's store so that JUnit cleans it up, uninstalling the + * [DebugProbes] after this extension instance is no longer needed. **/ + store.put("debugProbes", ExtensionContext.Store.CloseableResource { DebugProbes.uninstall() }) + } else if (!debugProbesOwnershipPassed.get()) { + /** This instance shares its store with other ones. Because of this, there was no need to install + * [DebugProbes], they are already installed, and this fact will outlive this use of this instance of + * the extension. */ + if (tryPassDebugProbesOwnership()) { + // We successfully marked the ownership as passed and now may uninstall the extraneous debug probes. + DebugProbes.uninstall() + } + } + } + } + + override fun interceptTestMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) { + interceptNormalMethod(invocation, invocationContext, extensionContext) + } + + override fun interceptAfterAllMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) { + interceptLifecycleMethod(invocation, invocationContext, extensionContext) + } + + override fun interceptAfterEachMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) { + interceptLifecycleMethod(invocation, invocationContext, extensionContext) + } + + override fun interceptBeforeAllMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) { + interceptLifecycleMethod(invocation, invocationContext, extensionContext) + } + + override fun interceptBeforeEachMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) { + interceptLifecycleMethod(invocation, invocationContext, extensionContext) + } + + override fun <T : Any?> interceptTestFactoryMethod( + invocation: InvocationInterceptor.Invocation<T>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ): T = interceptNormalMethod(invocation, invocationContext, extensionContext) + + override fun interceptTestTemplateMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) { + interceptNormalMethod(invocation, invocationContext, extensionContext) + } + + private fun<T> Class<T>.coroutinesTimeoutAnnotation(): Optional<CoroutinesTimeout> = + AnnotationSupport.findAnnotation(this, CoroutinesTimeout::class.java).or { + enclosingClass?.coroutinesTimeoutAnnotation() ?: Optional.empty() + } + + private fun <T: Any?> interceptMethod( + useClassAnnotation: Boolean, + invocation: InvocationInterceptor.Invocation<T>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ): T { + initialize(extensionContext) + val testAnnotationOptional = + AnnotationSupport.findAnnotation(invocationContext.executable, CoroutinesTimeout::class.java) + val classAnnotationOptional = extensionContext.testClass.flatMap { it.coroutinesTimeoutAnnotation() } + if (timeoutMs != null && cancelOnTimeout != null) { + // this means we @RegisterExtension was used in order to register this extension. + if (testAnnotationOptional.isPresent || classAnnotationOptional.isPresent) { + /* Using annotations creates a separate instance of the extension, which composes in a strange way: both + timeouts are applied. This is at odds with the concept that method-level annotations override the outer + rules and may lead to unexpected outcomes, so we prohibit this. */ + throw UnsupportedOperationException("Using CoroutinesTimeout along with instance field-registered CoroutinesTimeout is prohibited; please use either @RegisterExtension or @CoroutinesTimeout, but not both") + } + return interceptInvocation(invocation, invocationContext.executable.name, timeoutMs, cancelOnTimeout) + } + /* The extension was registered via an annotation; check that we succeeded in finding the annotation that led to + the extension being registered and taking its parameters. */ + if (testAnnotationOptional.isEmpty && classAnnotationOptional.isEmpty) { + throw UnsupportedOperationException("Timeout was registered with a CoroutinesTimeout annotation, but we were unable to find it. Please report this.") + } + return when { + testAnnotationOptional.isPresent -> { + val annotation = testAnnotationOptional.get() + interceptInvocation(invocation, invocationContext.executable.name, annotation.testTimeoutMs, + annotation.cancelOnTimeout) + } + useClassAnnotation && classAnnotationOptional.isPresent -> { + val annotation = classAnnotationOptional.get() + interceptInvocation(invocation, invocationContext.executable.name, annotation.testTimeoutMs, + annotation.cancelOnTimeout) + } + else -> { + invocation.proceed() + } + } + } + + private fun<T> interceptNormalMethod( + invocation: InvocationInterceptor.Invocation<T>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ): T = interceptMethod(true, invocation, invocationContext, extensionContext) + + private fun interceptLifecycleMethod( + invocation: InvocationInterceptor.Invocation<Void>, + invocationContext: ReflectiveInvocationContext<Method>, + extensionContext: ExtensionContext + ) = interceptMethod(false, invocation, invocationContext, extensionContext) + + private fun <T : Any?> interceptInvocation( + invocation: InvocationInterceptor.Invocation<T>, + methodName: String, + testTimeoutMs: Long, + cancelOnTimeout: Boolean + ): T = + runWithTimeoutDumpingCoroutines(methodName, testTimeoutMs, cancelOnTimeout, + { CoroutinesTimeoutException(testTimeoutMs) }, { invocation.proceed() }) +}
\ No newline at end of file |