Skip to content

Commit 4545352

Browse files
committed
Cancel the child coroutines if the test body has thrown
Fixes #1910
1 parent 78e5855 commit 4545352

File tree

3 files changed

+95
-11
lines changed

3 files changed

+95
-11
lines changed

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

+29-3
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,18 @@ public expect class TestResult
141141
*
142142
* ### Failures
143143
*
144+
* #### Test body failures
145+
*
146+
* If the test body finishes with an exception, then this exception will be thrown at the end of the test. Additionally,
147+
* to prevent child coroutines getting stuck, the whole scope will be cancelled in this case.
148+
*
149+
* #### Reported exceptions
150+
*
151+
* Exceptions reported to the test coroutine scope via [TestCoroutineScope.reportException] will be thrown at the end.
152+
* By default (without passing an explicit [TestExceptionHandler]), this includes all unhandled exceptions.
153+
*
154+
* #### Uncompleted coroutines
155+
*
144156
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test
145157
* will be failed (which, on JVM and Native, means that [runTest] itself will throw [AssertionError],
146158
* whereas on JS, the `Promise` will fail with it).
@@ -151,8 +163,6 @@ public expect class TestResult
151163
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
152164
* task during that time, the timer gets reset.
153165
*
154-
* Unhandled exceptions thrown by coroutines in the test will be rethrown at the end of the test.
155-
*
156166
* ### Configuration
157167
*
158168
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
@@ -178,7 +188,23 @@ public fun runTest(
178188
}
179189
var completed = false
180190
while (!completed) {
181-
scheduler.advanceUntilIdle()
191+
while (scheduler.tryRunNextTask()) {
192+
if (deferred.isCompleted && deferred.getCompletionExceptionOrNull() != null && testScope.isActive) {
193+
/**
194+
* Here, we already know how the test will finish: it will throw
195+
* [Deferred.getCompletionExceptionOrNull]. Therefore, we won't care if there are uncompleted jobs,
196+
* and may as well just exit right here. However, in order to lower the surprise factor, we
197+
* cancel the child jobs here and wait for them to finish instead of dropping them: there could be
198+
* some cleanup procedures involved, and not having finalizers run could mean leaking resources.
199+
*
200+
* Another approach to take if this turns out not to be enough and some child jobs still fail is to
201+
* only make at most a fixed number of [TestCoroutineScheduler.tryRunNextTask] once we detect the
202+
* failure with which the test will finish. This has the downside that there is still some
203+
* negligible risk of not running the finalizers.
204+
*/
205+
testScope.cancel()
206+
}
207+
}
182208
if (deferred.isCompleted) {
183209
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
184210
non-trivial dispatches. */

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

+16-8
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
8181
}
8282
}
8383

84+
/**
85+
* Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening.
86+
*/
87+
internal fun tryRunNextTask(): Boolean {
88+
val event = synchronized(lock) {
89+
val event = events.removeFirstOrNull() ?: return false
90+
if (currentTime > event.time)
91+
currentTimeAheadOfEvents()
92+
currentTime = event.time
93+
event
94+
}
95+
event.dispatcher.processEvent(event.time, event.marker)
96+
return true
97+
}
98+
8499
/**
85100
* Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more
86101
* tasks associated with the dispatchers linked to this scheduler.
@@ -92,14 +107,7 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
92107
@ExperimentalCoroutinesApi
93108
public fun advanceUntilIdle() {
94109
while (!events.isEmpty) {
95-
val event = synchronized(lock) {
96-
val event = events.removeFirstOrNull() ?: return
97-
if (currentTime > event.time)
98-
currentTimeAheadOfEvents()
99-
currentTime = event.time
100-
event
101-
}
102-
event.dispatcher.processEvent(event.time, event.marker)
110+
tryRunNextTask()
103111
}
104112
}
105113

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

+50
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,54 @@ class RunTestTest {
153153
}
154154
}
155155

156+
@Test
157+
fun reproducer2405() = runTest {
158+
val dispatcher = StandardTestDispatcher(testScheduler)
159+
var collectedError = false
160+
withContext(dispatcher) {
161+
flow { emit(1) }
162+
.combine(
163+
flow<String> { throw IllegalArgumentException() }
164+
) { int, string -> int.toString() + string }
165+
.catch { emit("error") }
166+
.collect {
167+
assertEquals("error", it)
168+
collectedError = true
169+
}
170+
}
171+
assertTrue(collectedError)
172+
}
173+
174+
/** Tests that, once the test body has thrown, the child coroutines are cancelled. */
175+
@Test
176+
fun testChildrenCancellationOnTestBodyFailure() {
177+
var job: Job? = null
178+
testResultMap({
179+
assertFailsWith<AssertionError> { it() }
180+
assertTrue(job!!.isCancelled)
181+
}, {
182+
runTest {
183+
job = launch {
184+
while (true) {
185+
delay(1000)
186+
}
187+
}
188+
throw AssertionError()
189+
}
190+
})
191+
}
192+
193+
/** Tests that [runTest] reports [TimeoutCancellationException]. */
194+
@Test
195+
fun testTimeout() = testResultMap({
196+
assertFailsWith<TimeoutCancellationException> { it() }
197+
}, {
198+
runTest {
199+
withTimeout(50) {
200+
launch {
201+
delay(1000)
202+
}
203+
}
204+
}
205+
})
156206
}

0 commit comments

Comments
 (0)