Skip to content

Commit a20c8f2

Browse files
committed
WIP on CoroutinesTimeout for JUnit5
1 parent 05d3018 commit a20c8f2

File tree

6 files changed

+231
-0
lines changed

6 files changed

+231
-0
lines changed

gradle.properties

Lines changed: 1 addition & 0 deletions
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.5.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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,20 @@ 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 enableCoroutineCreationStackTraces ()Z
67+
public abstract fun testTimeoutMs ()J
68+
}
69+
70+
public final class kotlinx/coroutines/debug/junit5/CoroutinesTimeoutExtension : org/junit/jupiter/api/extension/InvocationInterceptor {
71+
public fun <init> ()V
72+
public fun interceptAfterAllMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)V
73+
public fun interceptAfterEachMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)V
74+
public fun interceptBeforeAllMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)V
75+
public fun interceptBeforeEachMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)V
76+
public fun interceptTestFactoryMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)Ljava/lang/Object;
77+
public fun interceptTestMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)V
78+
public fun interceptTestTemplateMethod (Lorg/junit/jupiter/api/extension/InvocationInterceptor$Invocation;Lorg/junit/jupiter/api/extension/ReflectiveInvocationContext;Lorg/junit/jupiter/api/extension/ExtensionContext;)V
79+
}
80+

kotlinx-coroutines-debug/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@ configurations {
1818
configureKotlinJvmPlatform(shadow)
1919
}
2020

21+
test {
22+
useJUnitPlatform()
23+
}
24+
2125
dependencies {
2226
compileOnly "junit:junit:$junit_version"
27+
compileOnly "org.junit.jupiter:junit-jupiter-api:$junit5_version"
28+
testCompile "org.junit.jupiter:junit-jupiter-api:$junit5_version"
29+
testCompile "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
30+
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit5_version"
2331
shadowDeps "net.bytebuddy:byte-buddy:$byte_buddy_version"
2432
shadowDeps "net.bytebuddy:byte-buddy-agent:$byte_buddy_version"
2533
compileOnly "io.projectreactor.tools:blockhound:$blockhound_version"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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 org.junit.jupiter.api.extension.*
7+
import java.lang.annotation.*
8+
9+
/**
10+
*/
11+
@MustBeDocumented
12+
@Retention(value = AnnotationRetention.RUNTIME)
13+
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
14+
@ExtendWith(CoroutinesTimeoutExtension::class)
15+
@Inherited
16+
public annotation class CoroutinesTimeout(
17+
val testTimeoutMs: Long,
18+
val cancelOnTimeout: Boolean = false,
19+
val enableCoroutineCreationStackTraces: Boolean = true
20+
)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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.ExperimentalCoroutinesApi
8+
import kotlinx.coroutines.debug.DebugProbes
9+
import org.junit.jupiter.api.extension.BeforeAllCallback
10+
import org.junit.jupiter.api.extension.ExtensionConfigurationException
11+
import org.junit.jupiter.api.extension.ExtensionContext
12+
import org.junit.jupiter.api.extension.InvocationInterceptor
13+
import org.junit.jupiter.api.extension.ReflectiveInvocationContext
14+
import org.junit.platform.commons.JUnitException
15+
import org.junit.platform.commons.support.AnnotationSupport
16+
import org.junit.runner.*
17+
import org.junit.runners.model.*
18+
import java.io.ByteArrayOutputStream
19+
import java.io.PrintStream
20+
import java.lang.reflect.Method
21+
import java.time.Duration
22+
import java.time.format.DateTimeParseException
23+
import java.util.concurrent.CountDownLatch
24+
import java.util.concurrent.ExecutionException
25+
import java.util.concurrent.FutureTask
26+
import java.util.concurrent.TimeUnit
27+
import java.util.concurrent.TimeoutException
28+
29+
public class CoroutinesTimeoutExtension: InvocationInterceptor {
30+
31+
override fun interceptTestMethod(
32+
invocation: InvocationInterceptor.Invocation<Void>,
33+
invocationContext: ReflectiveInvocationContext<Method>,
34+
extensionContext: ExtensionContext
35+
) {
36+
val annotation =
37+
AnnotationSupport.findAnnotation(invocationContext.executable, CoroutinesTimeout::class.java).or {
38+
AnnotationSupport.findAnnotation(extensionContext.testClass, CoroutinesTimeout::class.java)
39+
}.orElseGet {
40+
throw UnsupportedOperationException("CoroutinesTimeoutExtension should not be used directly; annotate the test class or method with CoroutinesTimeout instead.")
41+
}
42+
invocation.proceed()
43+
}
44+
45+
override fun interceptAfterAllMethod(
46+
invocation: InvocationInterceptor.Invocation<Void>,
47+
invocationContext: ReflectiveInvocationContext<Method>,
48+
extensionContext: ExtensionContext
49+
) {
50+
interceptLifecycleMethod(invocation, invocationContext, extensionContext)
51+
}
52+
53+
override fun interceptAfterEachMethod(
54+
invocation: InvocationInterceptor.Invocation<Void>,
55+
invocationContext: ReflectiveInvocationContext<Method>,
56+
extensionContext: ExtensionContext
57+
) {
58+
interceptLifecycleMethod(invocation, invocationContext, extensionContext)
59+
}
60+
61+
override fun interceptBeforeAllMethod(
62+
invocation: InvocationInterceptor.Invocation<Void>,
63+
invocationContext: ReflectiveInvocationContext<Method>,
64+
extensionContext: ExtensionContext
65+
) {
66+
interceptLifecycleMethod(invocation, invocationContext, extensionContext)
67+
}
68+
69+
override fun interceptBeforeEachMethod(
70+
invocation: InvocationInterceptor.Invocation<Void>,
71+
invocationContext: ReflectiveInvocationContext<Method>,
72+
extensionContext: ExtensionContext
73+
) {
74+
interceptLifecycleMethod(invocation, invocationContext, extensionContext)
75+
}
76+
77+
private fun interceptLifecycleMethod(
78+
invocation: InvocationInterceptor.Invocation<Void>,
79+
invocationContext: ReflectiveInvocationContext<Method>,
80+
extensionContext: ExtensionContext
81+
) {
82+
invocation.proceed()
83+
}
84+
85+
override fun <T : Any?> interceptTestFactoryMethod(
86+
invocation: InvocationInterceptor.Invocation<T>,
87+
invocationContext: ReflectiveInvocationContext<Method>,
88+
extensionContext: ExtensionContext
89+
): T = invocation.proceed()
90+
91+
override fun interceptTestTemplateMethod(
92+
invocation: InvocationInterceptor.Invocation<Void>,
93+
invocationContext: ReflectiveInvocationContext<Method>,
94+
extensionContext: ExtensionContext
95+
) {
96+
invocation.proceed()
97+
}
98+
99+
private fun <T : Any?> interceptInvocation(
100+
invocation: InvocationInterceptor.Invocation<T>,
101+
methodName: String,
102+
annotation: CoroutinesTimeout
103+
): T {
104+
val testStartedLatch = CountDownLatch(1)
105+
val testResult = FutureTask<T> {
106+
testStartedLatch.countDown()
107+
invocation.proceed()
108+
}
109+
val testThread = Thread(testResult, "Timeout test thread").apply { isDaemon = true }
110+
try {
111+
testThread.start()
112+
// Await until test is started to take only test execution time into account
113+
testStartedLatch.await()
114+
return testResult.get(annotation.testTimeoutMs, TimeUnit.MILLISECONDS)
115+
} catch (e: TimeoutException) {
116+
handleTimeout(methodName, testThread, annotation)
117+
} catch (e: ExecutionException) {
118+
throw e.cause ?: e
119+
} finally {
120+
DebugProbes.uninstall()
121+
}
122+
}
123+
124+
private fun handleTimeout(methodName: String, testThread: Thread, annotation: CoroutinesTimeout): Nothing {
125+
val units =
126+
if (annotation.testTimeoutMs % 1000 == 0L)
127+
"${annotation.testTimeoutMs / 1000} seconds"
128+
else "$annotation.testTimeoutMs milliseconds"
129+
130+
System.err.println("\nTest $methodName timed out after $units\n")
131+
System.err.flush()
132+
133+
DebugProbes.dumpCoroutines()
134+
System.out.flush() // Synchronize serr/sout
135+
136+
/*
137+
* Order is important:
138+
* 1) Create exception with a stacktrace of hang test
139+
* 2) Cancel all coroutines via debug agent API (changing system state!)
140+
* 3) Throw created exception
141+
*/
142+
val exception = createTimeoutException(testThread, annotation.testTimeoutMs)
143+
cancelIfNecessary(annotation.cancelOnTimeout)
144+
// If timed out test throws an exception, we can't do much except ignoring it
145+
throw exception
146+
}
147+
148+
private fun cancelIfNecessary(cancelOnTimeout: Boolean) {
149+
if (cancelOnTimeout) {
150+
DebugProbes.dumpCoroutinesInfo().forEach {
151+
it.job?.cancel()
152+
}
153+
}
154+
}
155+
156+
private fun createTimeoutException(thread: Thread, testTimeoutMs: Long): Exception {
157+
val stackTrace = thread.stackTrace
158+
val exception = TestTimedOutException(testTimeoutMs, TimeUnit.MILLISECONDS)
159+
exception.stackTrace = stackTrace
160+
thread.interrupt()
161+
return exception
162+
}
163+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 junit5
6+
import kotlinx.coroutines.debug.junit5.*
7+
import org.junit.jupiter.api.*
8+
9+
@CoroutinesTimeout(6)
10+
class CoroutinesTimeoutTest {
11+
12+
@CoroutinesTimeout(5)
13+
@Test
14+
fun test() {
15+
16+
}
17+
18+
@Test
19+
fun test2() {
20+
21+
}
22+
}

0 commit comments

Comments
 (0)