Skip to content

Commit 17d23e6

Browse files
authored
Improve behavior on timeout in runTest (#3071)
* 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 #3066 Fixes #3069
1 parent 2005821 commit 17d23e6

File tree

6 files changed

+132
-20
lines changed

6 files changed

+132
-20
lines changed

kotlinx-coroutines-core/common/src/internal/StackTraceRecovery.common.kt

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ internal expect suspend inline fun recoverAndThrow(exception: Throwable): Nothin
4040
* The opposite of [recoverStackTrace].
4141
* It is guaranteed that `unwrap(recoverStackTrace(e)) === e`
4242
*/
43+
@PublishedApi // only published for the multiplatform tests in our own code
4344
internal expect fun <E: Throwable> unwrap(exception: E): E
4445

4546
internal expect class StackTraceElement

kotlinx-coroutines-test/common/src/TestBuilders.kt

+41-10
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public fun TestScope.runTest(
164164
): TestResult = asSpecificImplementation().let {
165165
it.enter()
166166
createTestResult {
167-
runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() }
167+
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) { it.leave() }
168168
}
169169
}
170170

@@ -190,18 +190,20 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
190190
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
191191
* [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end.
192192
*
193+
* [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
194+
*
193195
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
194196
* return a list of uncaught exceptions that should be reported at the end of the test.
195197
*/
196198
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
197199
coroutine: T,
198200
dispatchTimeoutMs: Long,
201+
tryGetCompletionCause: T.() -> Throwable?,
199202
testBody: suspend T.() -> Unit,
200203
cleanup: () -> List<Throwable>,
201204
) {
202205
val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!!
203-
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
204-
* [TestCoroutineDispatcher], because the event loop is not started. */
206+
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
205207
coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) {
206208
testBody()
207209
}
@@ -222,13 +224,7 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
222224
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
223225
}
224226
onTimeout(dispatchTimeoutMs) {
225-
try {
226-
cleanup()
227-
} catch (e: UncompletedCoroutinesError) {
228-
// we expect these and will instead throw a more informative exception just below.
229-
emptyList()
230-
}.throwAll()
231-
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
227+
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
232228
}
233229
}
234230
}
@@ -244,6 +240,41 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
244240
cleanup().throwAll()
245241
}
246242

243+
/**
244+
* Invoked on timeout in [runTest]. Almost always just builds a nice [UncompletedCoroutinesError] and throws it.
245+
* However, sometimes it detects that the coroutine completed, in which case it returns normally.
246+
*/
247+
private inline fun<T: AbstractCoroutine<Unit>> handleTimeout(
248+
coroutine: T,
249+
dispatchTimeoutMs: Long,
250+
tryGetCompletionCause: T.() -> Throwable?,
251+
cleanup: () -> List<Throwable>,
252+
) {
253+
val uncaughtExceptions = try {
254+
cleanup()
255+
} catch (e: UncompletedCoroutinesError) {
256+
// we expect these and will instead throw a more informative exception.
257+
emptyList()
258+
}
259+
val activeChildren = coroutine.children.filter { it.isActive }.toList()
260+
val completionCause = if (coroutine.isCancelled) coroutine.tryGetCompletionCause() else null
261+
var message = "After waiting for $dispatchTimeoutMs ms"
262+
if (completionCause == null)
263+
message += ", the test coroutine is not completing"
264+
if (activeChildren.isNotEmpty())
265+
message += ", there were active child jobs: $activeChildren"
266+
if (completionCause != null && activeChildren.isEmpty()) {
267+
if (coroutine.isCompleted)
268+
return
269+
// TODO: can this really ever happen?
270+
message += ", the test coroutine was not completed"
271+
}
272+
val error = UncompletedCoroutinesError(message)
273+
completionCause?.let { cause -> error.addSuppressed(cause) }
274+
uncaughtExceptions.forEach { error.addSuppressed(it) }
275+
throw error
276+
}
277+
247278
internal fun List<Throwable>.throwAll() {
248279
firstOrNull()?.apply {
249280
drop(1).forEach { addSuppressed(it) }

kotlinx-coroutines-test/common/src/TestScope.kt

+3
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ internal class TestScopeImpl(context: CoroutineContext) :
216216
}
217217
}
218218

219+
/** Throws an exception if the coroutine is not completing. */
220+
fun tryGetCompletionCause(): Throwable? = completionCause
221+
219222
override fun toString(): String =
220223
"TestScope[" + (if (finished) "test ended" else if (entered) "test started" else "test not started") + "]"
221224
}

kotlinx-coroutines-test/common/test/RunTestTest.kt

+67-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.internal.*
89
import kotlinx.coroutines.flow.*
910
import kotlin.coroutines.*
1011
import kotlin.test.*
@@ -97,6 +98,59 @@ class RunTestTest {
9798
}
9899
}
99100

101+
/** Tests that, on timeout, the names of the active coroutines are listed,
102+
* whereas the names of the completed ones are not. */
103+
@Test
104+
@NoJs
105+
@NoNative
106+
fun testListingActiveCoroutinesOnTimeout(): TestResult {
107+
val name1 = "GoodUniqueName"
108+
val name2 = "BadUniqueName"
109+
return testResultMap({
110+
try {
111+
it()
112+
fail("unreached")
113+
} catch (e: UncompletedCoroutinesError) {
114+
assertTrue((e.message ?: "").contains(name1))
115+
assertFalse((e.message ?: "").contains(name2))
116+
}
117+
}) {
118+
runTest(dispatchTimeoutMs = 10) {
119+
launch(CoroutineName(name1)) {
120+
CompletableDeferred<Unit>().await()
121+
}
122+
launch(CoroutineName(name2)) {
123+
}
124+
}
125+
}
126+
}
127+
128+
/** Tests that the [UncompletedCoroutinesError] suppresses an exception with which the coroutine is completing. */
129+
@Test
130+
fun testFailureWithPendingCoroutine() = testResultMap({
131+
try {
132+
it()
133+
fail("unreached")
134+
} catch (e: UncompletedCoroutinesError) {
135+
@Suppress("INVISIBLE_MEMBER")
136+
val suppressed = unwrap(e).suppressedExceptions
137+
assertEquals(1, suppressed.size)
138+
assertIs<TestException>(suppressed[0]).also {
139+
assertEquals("A", it.message)
140+
}
141+
}
142+
}) {
143+
runTest(dispatchTimeoutMs = 10) {
144+
launch {
145+
withContext(NonCancellable) {
146+
awaitCancellation()
147+
}
148+
}
149+
yield()
150+
throw TestException("A")
151+
}
152+
}
153+
100154
/** Tests that real delays can be accounted for with a large enough dispatch timeout. */
101155
@Test
102156
fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) {
@@ -105,14 +159,24 @@ class RunTestTest {
105159
}
106160
}
107161

108-
/** Tests uncaught exceptions taking priority over dispatch timeout in error reports. */
162+
/** Tests uncaught exceptions being suppressed by the dispatch timeout error. */
109163
@Test
110164
@NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native
111165
fun testRunTestTimingOutAndThrowing() = testResultMap({ fn ->
112-
assertFailsWith<IllegalArgumentException> { fn() }
166+
try {
167+
fn()
168+
fail("unreached")
169+
} catch (e: UncompletedCoroutinesError) {
170+
@Suppress("INVISIBLE_MEMBER")
171+
val suppressed = unwrap(e).suppressedExceptions
172+
assertEquals(1, suppressed.size)
173+
assertIs<TestException>(suppressed[0]).also {
174+
assertEquals("A", it.message)
175+
}
176+
}
113177
}) {
114178
runTest(dispatchTimeoutMs = 1) {
115-
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException())
179+
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
116180
withContext(Dispatchers.Default) {
117181
delay(10000)
118182
3

kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt

+7-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
package kotlinx.coroutines.test
99

1010
import kotlinx.coroutines.*
11-
import kotlinx.coroutines.selects.*
1211
import kotlin.coroutines.*
1312
import kotlin.jvm.*
1413

@@ -137,9 +136,9 @@ public fun runTestWithLegacyScope(
137136
): TestResult {
138137
if (context[RunningInRunTest] != null)
139138
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
140-
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest))
139+
val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
141140
return createTestResult {
142-
runTestCoroutine(testScope, dispatchTimeoutMs, testBody) {
141+
runTestCoroutine(testScope, dispatchTimeoutMs, TestBodyCoroutine::tryGetCompletionCause, testBody) {
143142
try {
144143
testScope.cleanup()
145144
emptyList()
@@ -169,9 +168,9 @@ public fun TestCoroutineScope.runTest(
169168
block: suspend TestCoroutineScope.() -> Unit
170169
): TestResult = runTestWithLegacyScope(coroutineContext, dispatchTimeoutMs, block)
171170

172-
private class TestBodyCoroutine<T>(
171+
private class TestBodyCoroutine(
173172
private val testScope: TestCoroutineScope,
174-
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {
173+
) : AbstractCoroutine<Unit>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {
175174

176175
override val testScheduler get() = testScope.testScheduler
177176

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

189188
fun cleanup() = testScope.cleanupTestCoroutines()
189+
190+
/** Throws an exception if the coroutine is not completing. */
191+
fun tryGetCompletionCause(): Throwable? = completionCause
190192
}

kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt

+13-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
88
import kotlinx.coroutines.flow.*
9+
import kotlinx.coroutines.internal.*
910
import kotlin.coroutines.*
1011
import kotlin.test.*
1112

@@ -100,10 +101,20 @@ class RunTestLegacyScopeTest {
100101

101102
@Test
102103
fun testRunTestTimingOutAndThrowing() = testResultMap({ fn ->
103-
assertFailsWith<IllegalArgumentException> { fn() }
104+
try {
105+
fn()
106+
fail("unreached")
107+
} catch (e: UncompletedCoroutinesError) {
108+
@Suppress("INVISIBLE_MEMBER")
109+
val suppressed = unwrap(e).suppressedExceptions
110+
assertEquals(1, suppressed.size)
111+
assertIs<TestException>(suppressed[0]).also {
112+
assertEquals("A", it.message)
113+
}
114+
}
104115
}) {
105116
runTestWithLegacyScope(dispatchTimeoutMs = 1) {
106-
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException())
117+
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
107118
withContext(Dispatchers.Default) {
108119
delay(10000)
109120
3

0 commit comments

Comments
 (0)