Skip to content

Commit

Permalink
Improve behavior on timeout in runTest (Kotlin#3071)
Browse files Browse the repository at this point in the history
* The error is more verbose: it lists the active children.
* If the test coroutine is already finishing with an error, that error
  is added to the timeout notification as suppressed.
* The timeout is considered more important, so now, if there were
  any uncaught exceptions, they don't take precedence over it but
  are suppressed by it.

Fixes Kotlin#3066
Fixes Kotlin#3069
  • Loading branch information
dkhalanskyjb authored and pablobaxter committed Sep 14, 2022
1 parent 302a3d1 commit d5e3ae9
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ internal expect suspend inline fun recoverAndThrow(exception: Throwable): Nothin
* The opposite of [recoverStackTrace].
* It is guaranteed that `unwrap(recoverStackTrace(e)) === e`
*/
@PublishedApi // only published for the multiplatform tests in our own code
internal expect fun <E: Throwable> unwrap(exception: E): E

internal expect class StackTraceElement
Expand Down
51 changes: 41 additions & 10 deletions kotlinx-coroutines-test/common/src/TestBuilders.kt
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ public fun TestScope.runTest(
): TestResult = asSpecificImplementation().let {
it.enter()
createTestResult {
runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() }
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) { it.leave() }
}
}

Expand All @@ -190,18 +190,20 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
* [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end.
*
* [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
*
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
* return a list of uncaught exceptions that should be reported at the end of the test.
*/
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
coroutine: T,
dispatchTimeoutMs: Long,
tryGetCompletionCause: T.() -> Throwable?,
testBody: suspend T.() -> Unit,
cleanup: () -> List<Throwable>,
) {
val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!!
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
* [TestCoroutineDispatcher], because the event loop is not started. */
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) {
testBody()
}
Expand All @@ -222,13 +224,7 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
}
onTimeout(dispatchTimeoutMs) {
try {
cleanup()
} catch (e: UncompletedCoroutinesError) {
// we expect these and will instead throw a more informative exception just below.
emptyList()
}.throwAll()
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
}
}
}
Expand All @@ -244,6 +240,41 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
cleanup().throwAll()
}

/**
* Invoked on timeout in [runTest]. Almost always just builds a nice [UncompletedCoroutinesError] and throws it.
* However, sometimes it detects that the coroutine completed, in which case it returns normally.
*/
private inline fun<T: AbstractCoroutine<Unit>> handleTimeout(
coroutine: T,
dispatchTimeoutMs: Long,
tryGetCompletionCause: T.() -> Throwable?,
cleanup: () -> List<Throwable>,
) {
val uncaughtExceptions = try {
cleanup()
} catch (e: UncompletedCoroutinesError) {
// we expect these and will instead throw a more informative exception.
emptyList()
}
val activeChildren = coroutine.children.filter { it.isActive }.toList()
val completionCause = if (coroutine.isCancelled) coroutine.tryGetCompletionCause() else null
var message = "After waiting for $dispatchTimeoutMs ms"
if (completionCause == null)
message += ", the test coroutine is not completing"
if (activeChildren.isNotEmpty())
message += ", there were active child jobs: $activeChildren"
if (completionCause != null && activeChildren.isEmpty()) {
if (coroutine.isCompleted)
return
// TODO: can this really ever happen?
message += ", the test coroutine was not completed"
}
val error = UncompletedCoroutinesError(message)
completionCause?.let { cause -> error.addSuppressed(cause) }
uncaughtExceptions.forEach { error.addSuppressed(it) }
throw error
}

internal fun List<Throwable>.throwAll() {
firstOrNull()?.apply {
drop(1).forEach { addSuppressed(it) }
Expand Down
3 changes: 3 additions & 0 deletions kotlinx-coroutines-test/common/src/TestScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ internal class TestScopeImpl(context: CoroutineContext) :
}
}

/** Throws an exception if the coroutine is not completing. */
fun tryGetCompletionCause(): Throwable? = completionCause

override fun toString(): String =
"TestScope[" + (if (finished) "test ended" else if (entered) "test started" else "test not started") + "]"
}
Expand Down
70 changes: 67 additions & 3 deletions kotlinx-coroutines-test/common/test/RunTestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlinx.coroutines.internal.*
import kotlinx.coroutines.flow.*
import kotlin.coroutines.*
import kotlin.test.*
Expand Down Expand Up @@ -97,6 +98,59 @@ class RunTestTest {
}
}

/** Tests that, on timeout, the names of the active coroutines are listed,
* whereas the names of the completed ones are not. */
@Test
@NoJs
@NoNative
fun testListingActiveCoroutinesOnTimeout(): TestResult {
val name1 = "GoodUniqueName"
val name2 = "BadUniqueName"
return testResultMap({
try {
it()
fail("unreached")
} catch (e: UncompletedCoroutinesError) {
assertTrue((e.message ?: "").contains(name1))
assertFalse((e.message ?: "").contains(name2))
}
}) {
runTest(dispatchTimeoutMs = 10) {
launch(CoroutineName(name1)) {
CompletableDeferred<Unit>().await()
}
launch(CoroutineName(name2)) {
}
}
}
}

/** Tests that the [UncompletedCoroutinesError] suppresses an exception with which the coroutine is completing. */
@Test
fun testFailureWithPendingCoroutine() = testResultMap({
try {
it()
fail("unreached")
} catch (e: UncompletedCoroutinesError) {
@Suppress("INVISIBLE_MEMBER")
val suppressed = unwrap(e).suppressedExceptions
assertEquals(1, suppressed.size)
assertIs<TestException>(suppressed[0]).also {
assertEquals("A", it.message)
}
}
}) {
runTest(dispatchTimeoutMs = 10) {
launch {
withContext(NonCancellable) {
awaitCancellation()
}
}
yield()
throw TestException("A")
}
}

/** Tests that real delays can be accounted for with a large enough dispatch timeout. */
@Test
fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) {
Expand All @@ -105,14 +159,24 @@ class RunTestTest {
}
}

/** Tests uncaught exceptions taking priority over dispatch timeout in error reports. */
/** Tests uncaught exceptions being suppressed by the dispatch timeout error. */
@Test
@NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native
fun testRunTestTimingOutAndThrowing() = testResultMap({ fn ->
assertFailsWith<IllegalArgumentException> { fn() }
try {
fn()
fail("unreached")
} catch (e: UncompletedCoroutinesError) {
@Suppress("INVISIBLE_MEMBER")
val suppressed = unwrap(e).suppressedExceptions
assertEquals(1, suppressed.size)
assertIs<TestException>(suppressed[0]).also {
assertEquals("A", it.message)
}
}
}) {
runTest(dispatchTimeoutMs = 1) {
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException())
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
withContext(Dispatchers.Default) {
delay(10000)
3
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlinx.coroutines.selects.*
import kotlin.coroutines.*
import kotlin.jvm.*

Expand Down Expand Up @@ -137,9 +136,9 @@ public fun runTestWithLegacyScope(
): TestResult {
if (context[RunningInRunTest] != null)
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest))
val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
return createTestResult {
runTestCoroutine(testScope, dispatchTimeoutMs, testBody) {
runTestCoroutine(testScope, dispatchTimeoutMs, TestBodyCoroutine::tryGetCompletionCause, testBody) {
try {
testScope.cleanup()
emptyList()
Expand Down Expand Up @@ -169,9 +168,9 @@ public fun TestCoroutineScope.runTest(
block: suspend TestCoroutineScope.() -> Unit
): TestResult = runTestWithLegacyScope(coroutineContext, dispatchTimeoutMs, block)

private class TestBodyCoroutine<T>(
private class TestBodyCoroutine(
private val testScope: TestCoroutineScope,
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {
) : AbstractCoroutine<Unit>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {

override val testScheduler get() = testScope.testScheduler

Expand All @@ -187,4 +186,7 @@ private class TestBodyCoroutine<T>(
)

fun cleanup() = testScope.cleanupTestCoroutines()

/** Throws an exception if the coroutine is not completing. */
fun tryGetCompletionCause(): Throwable? = completionCause
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.internal.*
import kotlin.coroutines.*
import kotlin.test.*

Expand Down Expand Up @@ -100,10 +101,20 @@ class RunTestLegacyScopeTest {

@Test
fun testRunTestTimingOutAndThrowing() = testResultMap({ fn ->
assertFailsWith<IllegalArgumentException> { fn() }
try {
fn()
fail("unreached")
} catch (e: UncompletedCoroutinesError) {
@Suppress("INVISIBLE_MEMBER")
val suppressed = unwrap(e).suppressedExceptions
assertEquals(1, suppressed.size)
assertIs<TestException>(suppressed[0]).also {
assertEquals("A", it.message)
}
}
}) {
runTestWithLegacyScope(dispatchTimeoutMs = 1) {
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException())
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
withContext(Dispatchers.Default) {
delay(10000)
3
Expand Down

0 comments on commit d5e3ae9

Please sign in to comment.