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