Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CoroutinesTimeout for JUnit5 #2402

Merged
merged 18 commits into from
Apr 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ kotlin_version=1.5.0-RC

# Dependencies
junit_version=4.12
junit5_version=5.7.0
atomicfu_version=0.15.2
knit_version=0.2.3
html_version=0.7.2
Expand Down
5 changes: 5 additions & 0 deletions kotlinx-coroutines-debug/api/kotlinx-coroutines-debug.api
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,8 @@ public final class kotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion {
public static synthetic fun seconds$default (Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion;JZZILjava/lang/Object;)Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout;
}

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

9 changes: 9 additions & 0 deletions kotlinx-coroutines-debug/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ configurations {

dependencies {
compileOnly "junit:junit:$junit_version"
compileOnly "org.junit.jupiter:junit-jupiter-api:$junit5_version"
testCompile "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
testCompile "org.junit.platform:junit-platform-testkit:1.7.0"
shadowDeps "net.bytebuddy:byte-buddy:$byte_buddy_version"
shadowDeps "net.bytebuddy:byte-buddy-agent:$byte_buddy_version"
compileOnly "io.projectreactor.tools:blockhound:$blockhound_version"
Expand All @@ -38,6 +41,12 @@ if (rootProject.ext.jvm_ir_enabled) {
}
}

java {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't seem to be necessary for the latest develop where we have 1.8 target

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I attempted to remove this block, but then the build was failing. I managed to fix the build of this subproject by adding

tasks.withType(JavaCompile) {
    targetCompatibility = JavaVersion.VERSION_1_8
}

However, then kotlinx-coroutines-test also required targetCompatibility set. At that point, I stopped. Do you think this should be pursued further?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's enough, thanks for checking it

/* This is needed to be able to run JUnit5 tests. Otherwise, Gradle complains that it can't find the
JVM1.6-compatible version of the `junit-jupiter-api` artifact. */
disableAutoTargetJvm()
}

jar {
manifest {
attributes "Premain-Class": "kotlinx.coroutines.debug.AgentPremain"
Expand Down
81 changes: 81 additions & 0 deletions kotlinx-coroutines-debug/src/junit/CoroutinesTimeoutImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.coroutines.debug

import java.util.concurrent.*

/**
* Run [invocation] in a separate thread with the given timeout in ms, after which the coroutines info is dumped and, if
* [cancelOnTimeout] is set, the execution is interrupted.
*
* Assumes that [DebugProbes] are installed. Does not deinstall them.
*/
internal inline fun <T : Any?> runWithTimeoutDumpingCoroutines(
methodName: String,
testTimeoutMs: Long,
cancelOnTimeout: Boolean,
initCancellationException: () -> Throwable,
crossinline invocation: () -> T
): T {
val testStartedLatch = CountDownLatch(1)
val testResult = FutureTask {
testStartedLatch.countDown()
invocation()
}
/*
* We are using hand-rolled thread instead of single thread executor
* in order to be able to safely interrupt thread in the end of a test
*/
val testThread = Thread(testResult, "Timeout test thread").apply { isDaemon = true }
try {
testThread.start()
// Await until test is started to take only test execution time into account
testStartedLatch.await()
return testResult.get(testTimeoutMs, TimeUnit.MILLISECONDS)
} catch (e: TimeoutException) {
handleTimeout(testThread, methodName, testTimeoutMs, cancelOnTimeout, initCancellationException())
} catch (e: ExecutionException) {
throw e.cause ?: e
}
}

private fun handleTimeout(testThread: Thread, methodName: String, testTimeoutMs: Long, cancelOnTimeout: Boolean,
cancellationException: Throwable): Nothing {
val units =
if (testTimeoutMs % 1000 == 0L)
"${testTimeoutMs / 1000} seconds"
else "$testTimeoutMs milliseconds"

System.err.println("\nTest $methodName timed out after $units\n")
System.err.flush()

DebugProbes.dumpCoroutines()
System.out.flush() // Synchronize serr/sout

/*
* Order is important:
* 1) Create exception with a stacktrace of hang test
* 2) Cancel all coroutines via debug agent API (changing system state!)
* 3) Throw created exception
*/
cancellationException.attachStacktraceFrom(testThread)
testThread.interrupt()
cancelIfNecessary(cancelOnTimeout)
// If timed out test throws an exception, we can't do much except ignoring it
throw cancellationException
}

private fun cancelIfNecessary(cancelOnTimeout: Boolean) {
if (cancelOnTimeout) {
DebugProbes.dumpCoroutinesInfo().forEach {
it.job?.cancel()
}
}
}

private fun Throwable.attachStacktraceFrom(thread: Thread) {
val stackTrace = thread.stackTrace
this.stackTrace = stackTrace
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.coroutines.debug.junit4

import kotlinx.coroutines.debug.*
import org.junit.runner.*
import org.junit.runners.model.*
import java.util.concurrent.*

internal class CoroutinesTimeoutStatement(
private val testStatement: Statement,
private val testDescription: Description,
private val testTimeoutMs: Long,
private val cancelOnTimeout: Boolean = false
) : Statement() {

override fun evaluate() {
try {
runWithTimeoutDumpingCoroutines(testDescription.methodName, testTimeoutMs, cancelOnTimeout,
{ TestTimedOutException(testTimeoutMs, TimeUnit.MILLISECONDS) })
{
testStatement.evaluate()
}
} finally {
DebugProbes.uninstall()
}
}
}
63 changes: 63 additions & 0 deletions kotlinx-coroutines-debug/src/junit/junit5/CoroutinesTimeout.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.coroutines.debug.junit5
import kotlinx.coroutines.debug.*
import org.junit.jupiter.api.*
import org.junit.jupiter.api.extension.*
import org.junit.jupiter.api.parallel.*
import java.lang.annotation.*

/**
* Coroutines timeout annotation that is similar to JUnit5's [Timeout] annotation. It allows running test methods in a
* separate thread, failing them after the provided time limit and interrupting the thread.
*
* Additionally, it installs [DebugProbes] and dumps all coroutines at the moment of the timeout. It also cancels
* coroutines on timeout if [cancelOnTimeout] set to `true`. The dump contains the coroutine creation stack traces.
*
* This annotation has an effect on test, test factory, test template, and lifecycle methods and test classes that are
* annotated with it.
*
* Annotating a class is the same as annotating every test, test factory, and test template method (but not lifecycle
* methods) of that class and its inner test classes, unless any of them is annotated with [CoroutinesTimeout], in which
* case their annotation overrides the one on the containing class.
*
* Declaring [CoroutinesTimeout] on a test factory checks that it finishes in the specified time, but does not check
* whether the methods that it produces obey the timeout as well.
*
* Example usage:
* ```
* @CoroutinesTimeout(100)
* class CoroutinesTimeoutSimpleTest {
* // does not time out, as the annotation on the method overrides the class-level one
* @CoroutinesTimeout(1000)
* @Test
* fun classTimeoutIsOverridden() {
* runBlocking {
* delay(150)
* }
* }
*
* // times out in 100 ms, timeout value is taken from the class-level annotation
* @Test
* fun classTimeoutIsUsed() {
* runBlocking {
* delay(150)
* }
* }
* }
* ```
*
* @see Timeout
*/
@ExtendWith(CoroutinesTimeoutExtension::class)
@Inherited
@MustBeDocumented
@ResourceLock("coroutines timeout", mode = ResourceAccessMode.READ)
@Retention(value = AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
public annotation class CoroutinesTimeout(
val testTimeoutMs: Long,
val cancelOnTimeout: Boolean = false
)