1 /* 2 * Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. 3 */ 4 5 package kotlinx.coroutines.debug.junit5 6 7 import kotlinx.coroutines.debug.* 8 import kotlinx.coroutines.debug.runWithTimeoutDumpingCoroutines 9 import org.junit.jupiter.api.extension.* 10 import org.junit.platform.commons.support.AnnotationSupport 11 import java.lang.reflect.* 12 import java.util.* 13 import java.util.concurrent.atomic.* 14 15 internal class CoroutinesTimeoutException(val timeoutMs: Long): Exception("test timed out after $timeoutMs ms") 16 17 /** 18 * This JUnit5 extension allows running test, test factory, test template, and lifecycle methods in a separate thread, 19 * failing them after the provided time limit and interrupting the thread. 20 * 21 * Additionally, it installs [DebugProbes] and dumps all coroutines at the moment of the timeout. It also cancels 22 * coroutines on timeout if [cancelOnTimeout] set to `true`. 23 * [enableCoroutineCreationStackTraces] controls the corresponding [DebugProbes.enableCreationStackTraces] property 24 * and can be optionally disabled to speed-up tests if creation stack traces are not needed. 25 * 26 * Beware that if several tests that use this extension set [enableCoroutineCreationStackTraces] to different values and 27 * execute in parallel, the behavior is ill-defined. In order to avoid conflicts between different instances of this 28 * extension when using JUnit5 in parallel, use [ResourceLock] with resource name `coroutines timeout` on tests that use 29 * it. Note that the tests annotated with [CoroutinesTimeout] already use this [ResourceLock], so there is no need to 30 * annotate them additionally. 31 * 32 * Note that while calls to test factories are verified to finish in the specified time, but the methods that they 33 * produce are not affected by this extension. 34 * 35 * Beware that registering the extension via [CoroutinesTimeout] annotation conflicts with manually registering it on 36 * the same tests via other methods (most notably, [RegisterExtension]) and is prohibited. 37 * 38 * Example of usage: 39 * ``` 40 * class HangingTest { 41 * @JvmField 42 * @RegisterExtension 43 * val timeout = CoroutinesTimeoutExtension.seconds(5) 44 * 45 * @Test 46 * fun testThatHangs() = runBlocking { 47 * ... 48 * delay(Long.MAX_VALUE) // somewhere deep in the stack 49 * ... 50 * } 51 * } 52 * ``` 53 * 54 * @see [CoroutinesTimeout] 55 * */ 56 // NB: the constructor is not private so that JUnit is able to call it via reflection. 57 internal class CoroutinesTimeoutExtension internal constructor( 58 private val enableCoroutineCreationStackTraces: Boolean = true, 59 private val timeoutMs: Long? = null, 60 private val cancelOnTimeout: Boolean? = null): InvocationInterceptor 61 { 62 /** 63 * Creates the [CoroutinesTimeoutExtension] extension with the given timeout in milliseconds. 64 */ 65 public constructor(timeoutMs: Long, cancelOnTimeout: Boolean = false, 66 enableCoroutineCreationStackTraces: Boolean = true): 67 this(enableCoroutineCreationStackTraces, timeoutMs, cancelOnTimeout) 68 69 public companion object { 70 /** 71 * Creates the [CoroutinesTimeoutExtension] extension with the given timeout in seconds. 72 */ 73 @JvmOverloads secondsnull74 public fun seconds(timeout: Int, cancelOnTimeout: Boolean = false, 75 enableCoroutineCreationStackTraces: Boolean = true): CoroutinesTimeoutExtension = 76 CoroutinesTimeoutExtension(enableCoroutineCreationStackTraces, timeout.toLong() * 1000, cancelOnTimeout) 77 } 78 79 /** @see [initialize] */ 80 private val debugProbesOwnershipPassed = AtomicBoolean(false) 81 82 private fun tryPassDebugProbesOwnership() = debugProbesOwnershipPassed.compareAndSet(false, true) 83 84 /* We install the debug probes early so that the coroutines launched from the test constructor are captured as well. 85 However, this is not enough as the same extension instance may be reused several times, even cleaning up its 86 resources from the store. */ 87 init { 88 DebugProbes.enableCreationStackTraces = enableCoroutineCreationStackTraces 89 DebugProbes.install() 90 } 91 92 // This is needed so that a class with no tests still successfully passes the ownership of DebugProbes to JUnit5. interceptTestClassConstructornull93 override fun <T : Any?> interceptTestClassConstructor( 94 invocation: InvocationInterceptor.Invocation<T>, 95 invocationContext: ReflectiveInvocationContext<Constructor<T>>, 96 extensionContext: ExtensionContext 97 ): T { 98 initialize(extensionContext) 99 return invocation.proceed() 100 } 101 102 /** 103 * Initialize this extension instance and/or the extension value store. 104 * 105 * It seems that the only way to reliably have JUnit5 clean up after its extensions is to put an instance of 106 * [ExtensionContext.Store.CloseableResource] into the value store corresponding to the extension instance, which 107 * means that [DebugProbes.uninstall] must be placed into the value store. [debugProbesOwnershipPassed] is `true` 108 * if the call to [DebugProbes.install] performed in the constructor of the extension instance was matched with a 109 * placing of [DebugProbes.uninstall] into the value store. We call the process of placing the cleanup procedure 110 * "passing the ownership", as now JUnit5 (and not our code) has to worry about uninstalling the debug probes. 111 * 112 * However, extension instances can be reused with different value stores, and value stores can be reused across 113 * extension instances. This leads to a tricky scheme of performing [DebugProbes.uninstall]: 114 * 115 * * If neither the ownership of this instance's [DebugProbes] was yet passed nor there is any cleanup procedure 116 * stored, it means that we can just store our cleanup procedure, passing the ownership. 117 * * If the ownership was not yet passed, but a cleanup procedure is already stored, we can't just replace it with 118 * another one, as this would lead to imbalance between [DebugProbes.install] and [DebugProbes.uninstall]. 119 * Instead, we know that this extension context will at least outlive this use of this instance, so some debug 120 * probes other than the ones from our constructor are already installed and won't be uninstalled during our 121 * operation. We simply uninstall the debug probes that were installed in our constructor. 122 * * If the ownership was passed, but the store is empty, it means that this test instance is reused and, possibly, 123 * the debug probes installed in its constructor were already uninstalled. This means that we have to install them 124 * anew and store an uninstaller. 125 */ initializenull126 private fun initialize(extensionContext: ExtensionContext) { 127 val store: ExtensionContext.Store = extensionContext.getStore( 128 ExtensionContext.Namespace.create(CoroutinesTimeoutExtension::class, extensionContext.uniqueId)) 129 /** It seems that the JUnit5 documentation does not specify the relationship between the extension instances and 130 * the corresponding [ExtensionContext] (in which the value stores are managed), so it is unclear whether it's 131 * theoretically possible for two extension instances that run concurrently to share an extension context. So, 132 * just in case this risk exists, we synchronize here. */ 133 synchronized(store) { 134 if (store["debugProbes"] == null) { 135 if (!tryPassDebugProbesOwnership()) { 136 /** This means that the [DebugProbes.install] call from the constructor of this extensions has 137 * already been matched with a corresponding cleanup procedure for JUnit5, but then JUnit5 cleaned 138 * everything up and later reused the same extension instance for other tests. Therefore, we need to 139 * install the [DebugProbes] anew. */ 140 DebugProbes.enableCreationStackTraces = enableCoroutineCreationStackTraces 141 DebugProbes.install() 142 } 143 /** put a fake resource into this extensions's store so that JUnit cleans it up, uninstalling the 144 * [DebugProbes] after this extension instance is no longer needed. **/ 145 store.put("debugProbes", ExtensionContext.Store.CloseableResource { DebugProbes.uninstall() }) 146 } else if (!debugProbesOwnershipPassed.get()) { 147 /** This instance shares its store with other ones. Because of this, there was no need to install 148 * [DebugProbes], they are already installed, and this fact will outlive this use of this instance of 149 * the extension. */ 150 if (tryPassDebugProbesOwnership()) { 151 // We successfully marked the ownership as passed and now may uninstall the extraneous debug probes. 152 DebugProbes.uninstall() 153 } 154 } 155 } 156 } 157 interceptTestMethodnull158 override fun interceptTestMethod( 159 invocation: InvocationInterceptor.Invocation<Void>, 160 invocationContext: ReflectiveInvocationContext<Method>, 161 extensionContext: ExtensionContext 162 ) { 163 interceptNormalMethod(invocation, invocationContext, extensionContext) 164 } 165 interceptAfterAllMethodnull166 override fun interceptAfterAllMethod( 167 invocation: InvocationInterceptor.Invocation<Void>, 168 invocationContext: ReflectiveInvocationContext<Method>, 169 extensionContext: ExtensionContext 170 ) { 171 interceptLifecycleMethod(invocation, invocationContext, extensionContext) 172 } 173 interceptAfterEachMethodnull174 override fun interceptAfterEachMethod( 175 invocation: InvocationInterceptor.Invocation<Void>, 176 invocationContext: ReflectiveInvocationContext<Method>, 177 extensionContext: ExtensionContext 178 ) { 179 interceptLifecycleMethod(invocation, invocationContext, extensionContext) 180 } 181 interceptBeforeAllMethodnull182 override fun interceptBeforeAllMethod( 183 invocation: InvocationInterceptor.Invocation<Void>, 184 invocationContext: ReflectiveInvocationContext<Method>, 185 extensionContext: ExtensionContext 186 ) { 187 interceptLifecycleMethod(invocation, invocationContext, extensionContext) 188 } 189 interceptBeforeEachMethodnull190 override fun interceptBeforeEachMethod( 191 invocation: InvocationInterceptor.Invocation<Void>, 192 invocationContext: ReflectiveInvocationContext<Method>, 193 extensionContext: ExtensionContext 194 ) { 195 interceptLifecycleMethod(invocation, invocationContext, extensionContext) 196 } 197 interceptTestFactoryMethodnull198 override fun <T : Any?> interceptTestFactoryMethod( 199 invocation: InvocationInterceptor.Invocation<T>, 200 invocationContext: ReflectiveInvocationContext<Method>, 201 extensionContext: ExtensionContext 202 ): T = interceptNormalMethod(invocation, invocationContext, extensionContext) 203 204 override fun interceptTestTemplateMethod( 205 invocation: InvocationInterceptor.Invocation<Void>, 206 invocationContext: ReflectiveInvocationContext<Method>, 207 extensionContext: ExtensionContext 208 ) { 209 interceptNormalMethod(invocation, invocationContext, extensionContext) 210 } 211 coroutinesTimeoutAnnotationnull212 private fun<T> Class<T>.coroutinesTimeoutAnnotation(): Optional<CoroutinesTimeout> = 213 AnnotationSupport.findAnnotation(this, CoroutinesTimeout::class.java).or { 214 enclosingClass?.coroutinesTimeoutAnnotation() ?: Optional.empty() 215 } 216 interceptMethodnull217 private fun <T: Any?> interceptMethod( 218 useClassAnnotation: Boolean, 219 invocation: InvocationInterceptor.Invocation<T>, 220 invocationContext: ReflectiveInvocationContext<Method>, 221 extensionContext: ExtensionContext 222 ): T { 223 initialize(extensionContext) 224 val testAnnotationOptional = 225 AnnotationSupport.findAnnotation(invocationContext.executable, CoroutinesTimeout::class.java) 226 val classAnnotationOptional = extensionContext.testClass.flatMap { it.coroutinesTimeoutAnnotation() } 227 if (timeoutMs != null && cancelOnTimeout != null) { 228 // this means we @RegisterExtension was used in order to register this extension. 229 if (testAnnotationOptional.isPresent || classAnnotationOptional.isPresent) { 230 /* Using annotations creates a separate instance of the extension, which composes in a strange way: both 231 timeouts are applied. This is at odds with the concept that method-level annotations override the outer 232 rules and may lead to unexpected outcomes, so we prohibit this. */ 233 throw UnsupportedOperationException("Using CoroutinesTimeout along with instance field-registered CoroutinesTimeout is prohibited; please use either @RegisterExtension or @CoroutinesTimeout, but not both") 234 } 235 return interceptInvocation(invocation, invocationContext.executable.name, timeoutMs, cancelOnTimeout) 236 } 237 /* The extension was registered via an annotation; check that we succeeded in finding the annotation that led to 238 the extension being registered and taking its parameters. */ 239 if (testAnnotationOptional.isEmpty && classAnnotationOptional.isEmpty) { 240 throw UnsupportedOperationException("Timeout was registered with a CoroutinesTimeout annotation, but we were unable to find it. Please report this.") 241 } 242 return when { 243 testAnnotationOptional.isPresent -> { 244 val annotation = testAnnotationOptional.get() 245 interceptInvocation(invocation, invocationContext.executable.name, annotation.testTimeoutMs, 246 annotation.cancelOnTimeout) 247 } 248 useClassAnnotation && classAnnotationOptional.isPresent -> { 249 val annotation = classAnnotationOptional.get() 250 interceptInvocation(invocation, invocationContext.executable.name, annotation.testTimeoutMs, 251 annotation.cancelOnTimeout) 252 } 253 else -> { 254 invocation.proceed() 255 } 256 } 257 } 258 interceptNormalMethodnull259 private fun<T> interceptNormalMethod( 260 invocation: InvocationInterceptor.Invocation<T>, 261 invocationContext: ReflectiveInvocationContext<Method>, 262 extensionContext: ExtensionContext 263 ): T = interceptMethod(true, invocation, invocationContext, extensionContext) 264 265 private fun interceptLifecycleMethod( 266 invocation: InvocationInterceptor.Invocation<Void>, 267 invocationContext: ReflectiveInvocationContext<Method>, 268 extensionContext: ExtensionContext 269 ) = interceptMethod(false, invocation, invocationContext, extensionContext) 270 271 private fun <T : Any?> interceptInvocation( 272 invocation: InvocationInterceptor.Invocation<T>, 273 methodName: String, 274 testTimeoutMs: Long, 275 cancelOnTimeout: Boolean 276 ): T = 277 runWithTimeoutDumpingCoroutines(methodName, testTimeoutMs, cancelOnTimeout, 278 { CoroutinesTimeoutException(testTimeoutMs) }, { invocation.proceed() }) 279 }