Skip to content

CoroutinesTimeout debug rule for JUnit 4 #991

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

Merged
merged 4 commits into from
Feb 20, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ GlobalScope.launch {
* `Dispatchers.setMain` to override `Dispatchers.Main` in tests.
* [debug](kotlinx-coroutines-debug/README.md) — debug utilities for coroutines.
* `DebugProbes` API to probe, keep track of, print and dump active coroutines.
* `CoroutinesTimeout` test rule to automatically dump coroutines on test timeout.
* [reactive](reactive/README.md) — modules that provide builders and iteration support for various reactive streams libraries:
* Reactive Streams, RxJava 2.x, and Project Reactor.
* [ui](ui/README.md) — modules that provide coroutine dispatchers for various single-threaded UI libraries:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,16 @@ public final class kotlinx/coroutines/debug/State : java/lang/Enum {
public static fun values ()[Lkotlinx/coroutines/debug/State;
}

public final class kotlinx/coroutines/debug/junit4/CoroutinesTimeout : org/junit/rules/TestRule {
public static final field Companion Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion;
public fun <init> (JZ)V
public synthetic fun <init> (JZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement;
public static final fun seconds (IZ)Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout;
}

public final class kotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion {
public final fun seconds (IZ)Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout;
public static synthetic fun seconds$default (Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout$Companion;IZILjava/lang/Object;)Lkotlinx/coroutines/debug/junit4/CoroutinesTimeout;
}

6 changes: 3 additions & 3 deletions kotlinx-coroutines-core/jvm/test/guide/example-compose-05.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ import kotlinx.coroutines.*
import kotlin.system.*

fun main() = runBlocking<Unit> {
//sampleStart
//sampleStart
val time = measureTimeMillis {
println("The answer is ${concurrentSum()}")
}
println("Completed in $time ms")
//sampleEnd
//sampleEnd
}

suspend fun concurrentSum(): Int = coroutineScope {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
one.await() + two.await()
one.await() + two.await()
}

suspend fun doSomethingUsefulOne(): Int {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ suspend fun failedConcurrentSum(): Int = coroutineScope {
println("Second child throws an exception")
throw ArithmeticException()
}
one.await() + two.await()
one.await() + two.await()
}
39 changes: 39 additions & 0 deletions kotlinx-coroutines-debug/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ dependencies {
}
```

### Using in unit tests

For JUnit4 debug module provides special test rule, [CoroutinesTimeout], for installing debug probes
and dump coroutines on timeout to simplify tests debugging.

Its usage is better to demonstrate by the example (runnable code is [here](test/TestRuleExample.kt)):

```kotlin
class TestRuleExample {
@Rule
@JvmField
public val timeout = CoroutinesTimeout.seconds(1)

private suspend fun someFunctionDeepInTheStack() {
withContext(Dispatchers.IO) {
delay(Long.MAX_VALUE)
println("This line is never executed")
}

println("This line is never executed as well")
}

@Test
fun hangingTest() = runBlocking {
val job = launch {
someFunctionDeepInTheStack()
}

println("Doing some work...")
job.join()
}
}
```

After 1 second, test will fail with `TestTimeoutException` and all coroutines (`runBlocking` and `launch`) and their
stacktraces will be dumped to the console.

### Using as JVM agent

It is possible to use this module as a standalone JVM agent to enable debug probes on the application startup.
Expand Down Expand Up @@ -112,4 +149,6 @@ Do not use this module in production environment and do not rely on the format o
[DebugProbes.dumpCoroutinesState]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-debug/kotlinx.coroutines.debug/-debug-probes/dump-coroutines-state.html
[DebugProbes.printJob]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-debug/kotlinx.coroutines.debug/-debug-probes/print-job.html
[DebugProbes.printScope]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-debug/kotlinx.coroutines.debug/-debug-probes/print-scope.html
<!--- INDEX kotlinx.coroutines.debug.junit4 -->
[CoroutinesTimeout]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-debug/kotlinx.coroutines.debug.junit4/-coroutines-timeout/index.html
<!--- END -->
1 change: 1 addition & 0 deletions kotlinx-coroutines-debug/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
apply plugin: "com.github.johnrengelman.shadow"

dependencies {
compileOnly "junit:junit:$junit_version"
compile "net.bytebuddy:byte-buddy:$byte_buddy_version"
compile "net.bytebuddy:byte-buddy-agent:$byte_buddy_version"
}
Expand Down
63 changes: 63 additions & 0 deletions kotlinx-coroutines-debug/src/junit4/CoroutinesTimeout.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2016-2019 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.rules.*
import org.junit.runner.*
import org.junit.runners.model.*
import java.util.concurrent.*

/**
* Coroutines timeout rule for JUnit4 that is applied to all methods in the class.
* This rule is very similar to [Timeout] rule: it runs tests in a separate thread,
* fails tests after the given timeout and interrupts test thread.
*
* Additionally, this rule installs [DebugProbes] and dumps all coroutines at the moment of the timeout.
* It may cancel coroutines on timeout if [cancelOnTimeout] set to `true`.
*
* Example of usage:
* ```
* class HangingTest {
*
* @Rule
* @JvmField
* val timeout = CoroutinesTimeout.seconds(5)
*
* @Test
* fun testThatHangs() = runBlocking {
* ...
* delay(Long.MAX_VALUE) // somewhere deep in the stack
* ...
* }
* }
* ```
*/
public class CoroutinesTimeout(
private val testTimeoutMs: Long,
private val cancelOnTimeout: Boolean = false
) : TestRule {

init {
require(testTimeoutMs > 0) { "Expected positive test timeout, but had $testTimeoutMs" }
}

companion object {
/**
* Creates [CoroutinesTimeout] rule with the given timeout in seconds.
*/
@JvmStatic
public fun seconds(seconds: Int, cancelOnTimeout: Boolean = false): CoroutinesTimeout {
return CoroutinesTimeout(TimeUnit.SECONDS.toMillis(seconds.toLong()), cancelOnTimeout)
}
}

/**
* @suppress suppress from Dokka
*/
override fun apply(base: Statement, description: Description): Statement {
return CoroutinesTimeoutStatement(base, description, testTimeoutMs, cancelOnTimeout)
}
}
92 changes: 92 additions & 0 deletions kotlinx-coroutines-debug/src/junit4/CoroutinesTimeoutStatement.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2016-2019 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.*
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() {

private val testExecutor = Executors.newSingleThreadExecutor {
Thread(it).apply {
name = "Timeout test executor"
isDaemon = true
}
}

// Thread to dump stack from, captured by testExecutor
private lateinit var testThread: Thread

override fun evaluate() {
DebugProbes.install() // Fail-fast if probes are unavailable
val latch = CountDownLatch(1)
val testFuture = CompletableFuture.runAsync(Runnable {
testThread = Thread.currentThread()
latch.countDown()
testStatement.evaluate()
}, testExecutor)

latch.await() // Await until test is started
try {
testFuture.get(testTimeoutMs, TimeUnit.MILLISECONDS)
return
} catch (e: TimeoutException) {
handleTimeout(testDescription)
} catch (e: ExecutionException) {
throw e.cause ?: e
} finally {
DebugProbes.uninstall()
testExecutor.shutdown()
}
}

private fun handleTimeout(description: Description) {
val units =
if (testTimeoutMs % 1000L == 0L)
"${testTimeoutMs / 1000} seconds"
else "$testTimeoutMs milliseconds"

val message = "Test ${description.methodName} timed out after $units"
System.err.println("\n$message\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
*/
val exception = createTimeoutException(message, testThread)
cancelIfNecessary()
// If timed out test throws an exception, we can't do much except ignoring it
throw exception
}

private fun cancelIfNecessary() {
if (cancelOnTimeout) {
DebugProbes.dumpCoroutinesState().forEach {
it.jobOrNull?.cancel()
}
}
}

private fun createTimeoutException(message: String, thread: Thread): Exception {
val stackTrace = thread.stackTrace
val exception = TestTimedOutException(testTimeoutMs, TimeUnit.MILLISECONDS)
exception.stackTrace = stackTrace
thread.interrupt()
return exception
}
}
42 changes: 42 additions & 0 deletions kotlinx-coroutines-debug/test/TestRuleExample.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

import kotlinx.coroutines.*
import kotlinx.coroutines.debug.junit4.*
import org.junit.*

@Ignore // do not run it on CI
class TestRuleExample {

@JvmField
@Rule
public val timeout = CoroutinesTimeout.seconds(1)

private suspend fun someFunctionDeepInTheStack() {
withContext(Dispatchers.IO) {
delay(Long.MAX_VALUE)
println("This line is never executed")
}

println("This line is never executed as well")
}

@Test
fun hangingTest() = runBlocking {
val job = launch {
someFunctionDeepInTheStack()
}

println("Doing some work...")
job.join()
}

@Test
fun successfulTest() = runBlocking {
launch {
delay(10)
}.join()
}

}
56 changes: 56 additions & 0 deletions kotlinx-coroutines-debug/test/junit4/CoroutinesTimeoutTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.coroutines.debug.junit4

import junit4.*
import kotlinx.coroutines.*
import org.junit.*
import org.junit.runners.model.*

class CoroutinesTimeoutTest : TestBase() {

@Rule
@JvmField
public val validation = TestFailureValidation(
1000, false,
TestResultSpec("throwingTest", error = RuntimeException::class.java),
TestResultSpec("successfulTest"),
TestResultSpec(
"hangingTest", expectedOutParts = listOf(
"Coroutines dump",
"Test hangingTest timed out after 1 seconds",
"BlockingCoroutine{Active}",
"runBlocking",
"at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutTest.suspendForever",
"at kotlinx.coroutines.debug.junit4.CoroutinesTimeoutTest\$hangingTest\$1.invokeSuspend"),
notExpectedOutParts = listOf("delay", "throwingTest"),
error = TestTimedOutException::class.java)
)

@Test
fun hangingTest() = runBlocking<Unit> {
suspendForever()
expectUnreached()
}

private suspend fun suspendForever() {
delay(Long.MAX_VALUE)
expectUnreached()
}

@Test
fun throwingTest() = runBlocking<Unit> {
throw RuntimeException()
}

@Test
fun successfulTest() = runBlocking {
val job = launch {
yield()
}

job.join()
}
}
Loading