• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 }