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