Skip to content

Commit ea31649

Browse files
dkhalanskyjbpablobaxter
authored andcommitted
CoroutinesTimeout for JUnit5 (Kotlin#2402)
1 parent c1098c7 commit ea31649

16 files changed

+973
-87
lines changed

gradle.properties

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ kotlin_version=1.5.0-RC
99

1010
# Dependencies
1111
junit_version=4.12
12+
junit5_version=5.7.0
1213
atomicfu_version=0.15.2
1314
knit_version=0.2.3
1415
html_version=0.7.2

kotlinx-coroutines-debug/api/kotlinx-coroutines-debug.api

+5
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,8 @@ public final class kotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion {
6161
public static synthetic fun seconds$default (Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion;JZZILjava/lang/Object;)Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout;
6262
}
6363

64+
public abstract interface annotation class kotlinx/coroutines/debug/junit5/CoroutinesTimeout : java/lang/annotation/Annotation {
65+
public abstract fun cancelOnTimeout ()Z
66+
public abstract fun testTimeoutMs ()J
67+
}
68+

kotlinx-coroutines-debug/build.gradle

+9
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ configurations {
2020

2121
dependencies {
2222
compileOnly "junit:junit:$junit_version"
23+
compileOnly "org.junit.jupiter:junit-jupiter-api:$junit5_version"
24+
testCompile "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
25+
testCompile "org.junit.platform:junit-platform-testkit:1.7.0"
2326
shadowDeps "net.bytebuddy:byte-buddy:$byte_buddy_version"
2427
shadowDeps "net.bytebuddy:byte-buddy-agent:$byte_buddy_version"
2528
compileOnly "io.projectreactor.tools:blockhound:$blockhound_version"
@@ -38,6 +41,12 @@ if (rootProject.ext.jvm_ir_enabled) {
3841
}
3942
}
4043

44+
java {
45+
/* This is needed to be able to run JUnit5 tests. Otherwise, Gradle complains that it can't find the
46+
JVM1.6-compatible version of the `junit-jupiter-api` artifact. */
47+
disableAutoTargetJvm()
48+
}
49+
4150
jar {
4251
manifest {
4352
attributes "Premain-Class": "kotlinx.coroutines.debug.AgentPremain"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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
6+
7+
import java.util.concurrent.*
8+
9+
/**
10+
* Run [invocation] in a separate thread with the given timeout in ms, after which the coroutines info is dumped and, if
11+
* [cancelOnTimeout] is set, the execution is interrupted.
12+
*
13+
* Assumes that [DebugProbes] are installed. Does not deinstall them.
14+
*/
15+
internal inline fun <T : Any?> runWithTimeoutDumpingCoroutines(
16+
methodName: String,
17+
testTimeoutMs: Long,
18+
cancelOnTimeout: Boolean,
19+
initCancellationException: () -> Throwable,
20+
crossinline invocation: () -> T
21+
): T {
22+
val testStartedLatch = CountDownLatch(1)
23+
val testResult = FutureTask {
24+
testStartedLatch.countDown()
25+
invocation()
26+
}
27+
/*
28+
* We are using hand-rolled thread instead of single thread executor
29+
* in order to be able to safely interrupt thread in the end of a test
30+
*/
31+
val testThread = Thread(testResult, "Timeout test thread").apply { isDaemon = true }
32+
try {
33+
testThread.start()
34+
// Await until test is started to take only test execution time into account
35+
testStartedLatch.await()
36+
return testResult.get(testTimeoutMs, TimeUnit.MILLISECONDS)
37+
} catch (e: TimeoutException) {
38+
handleTimeout(testThread, methodName, testTimeoutMs, cancelOnTimeout, initCancellationException())
39+
} catch (e: ExecutionException) {
40+
throw e.cause ?: e
41+
}
42+
}
43+
44+
private fun handleTimeout(testThread: Thread, methodName: String, testTimeoutMs: Long, cancelOnTimeout: Boolean,
45+
cancellationException: Throwable): Nothing {
46+
val units =
47+
if (testTimeoutMs % 1000 == 0L)
48+
"${testTimeoutMs / 1000} seconds"
49+
else "$testTimeoutMs milliseconds"
50+
51+
System.err.println("\nTest $methodName timed out after $units\n")
52+
System.err.flush()
53+
54+
DebugProbes.dumpCoroutines()
55+
System.out.flush() // Synchronize serr/sout
56+
57+
/*
58+
* Order is important:
59+
* 1) Create exception with a stacktrace of hang test
60+
* 2) Cancel all coroutines via debug agent API (changing system state!)
61+
* 3) Throw created exception
62+
*/
63+
cancellationException.attachStacktraceFrom(testThread)
64+
testThread.interrupt()
65+
cancelIfNecessary(cancelOnTimeout)
66+
// If timed out test throws an exception, we can't do much except ignoring it
67+
throw cancellationException
68+
}
69+
70+
private fun cancelIfNecessary(cancelOnTimeout: Boolean) {
71+
if (cancelOnTimeout) {
72+
DebugProbes.dumpCoroutinesInfo().forEach {
73+
it.job?.cancel()
74+
}
75+
}
76+
}
77+
78+
private fun Throwable.attachStacktraceFrom(thread: Thread) {
79+
val stackTrace = thread.stackTrace
80+
this.stackTrace = stackTrace
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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.junit4
6+
7+
import kotlinx.coroutines.debug.*
8+
import org.junit.runner.*
9+
import org.junit.runners.model.*
10+
import java.util.concurrent.*
11+
12+
internal class CoroutinesTimeoutStatement(
13+
private val testStatement: Statement,
14+
private val testDescription: Description,
15+
private val testTimeoutMs: Long,
16+
private val cancelOnTimeout: Boolean = false
17+
) : Statement() {
18+
19+
override fun evaluate() {
20+
try {
21+
runWithTimeoutDumpingCoroutines(testDescription.methodName, testTimeoutMs, cancelOnTimeout,
22+
{ TestTimedOutException(testTimeoutMs, TimeUnit.MILLISECONDS) })
23+
{
24+
testStatement.evaluate()
25+
}
26+
} finally {
27+
DebugProbes.uninstall()
28+
}
29+
}
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
import kotlinx.coroutines.debug.*
7+
import org.junit.jupiter.api.*
8+
import org.junit.jupiter.api.extension.*
9+
import org.junit.jupiter.api.parallel.*
10+
import java.lang.annotation.*
11+
12+
/**
13+
* Coroutines timeout annotation that is similar to JUnit5's [Timeout] annotation. It allows running test methods in a
14+
* separate thread, failing them after the provided time limit and interrupting the thread.
15+
*
16+
* Additionally, it installs [DebugProbes] and dumps all coroutines at the moment of the timeout. It also cancels
17+
* coroutines on timeout if [cancelOnTimeout] set to `true`. The dump contains the coroutine creation stack traces.
18+
*
19+
* This annotation has an effect on test, test factory, test template, and lifecycle methods and test classes that are
20+
* annotated with it.
21+
*
22+
* Annotating a class is the same as annotating every test, test factory, and test template method (but not lifecycle
23+
* methods) of that class and its inner test classes, unless any of them is annotated with [CoroutinesTimeout], in which
24+
* case their annotation overrides the one on the containing class.
25+
*
26+
* Declaring [CoroutinesTimeout] on a test factory checks that it finishes in the specified time, but does not check
27+
* whether the methods that it produces obey the timeout as well.
28+
*
29+
* Example usage:
30+
* ```
31+
* @CoroutinesTimeout(100)
32+
* class CoroutinesTimeoutSimpleTest {
33+
* // does not time out, as the annotation on the method overrides the class-level one
34+
* @CoroutinesTimeout(1000)
35+
* @Test
36+
* fun classTimeoutIsOverridden() {
37+
* runBlocking {
38+
* delay(150)
39+
* }
40+
* }
41+
*
42+
* // times out in 100 ms, timeout value is taken from the class-level annotation
43+
* @Test
44+
* fun classTimeoutIsUsed() {
45+
* runBlocking {
46+
* delay(150)
47+
* }
48+
* }
49+
* }
50+
* ```
51+
*
52+
* @see Timeout
53+
*/
54+
@ExtendWith(CoroutinesTimeoutExtension::class)
55+
@Inherited
56+
@MustBeDocumented
57+
@ResourceLock("coroutines timeout", mode = ResourceAccessMode.READ)
58+
@Retention(value = AnnotationRetention.RUNTIME)
59+
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
60+
public annotation class CoroutinesTimeout(
61+
val testTimeoutMs: Long,
62+
val cancelOnTimeout: Boolean = false
63+
)

0 commit comments

Comments
 (0)