Skip to content

Commit a71838e

Browse files
committed
Cancel the child coroutines if the test body has thrown
Fixes #1910
1 parent bef199c commit a71838e

File tree

3 files changed

+78
-11
lines changed

3 files changed

+78
-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 [UncompletedCoroutinesError],
146158
* whereas on JS, the `Promise` will fail with it).
@@ -151,8 +163,6 @@ public expect class TestResult
151163
* idle before throwing [UncompletedCoroutinesError]. 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
@@ -177,7 +187,23 @@ public fun runTest(
177187
}
178188
var completed = false
179189
while (!completed) {
180-
scheduler.advanceUntilIdle()
190+
while (scheduler.tryRunNextTask()) {
191+
if (deferred.isCompleted && deferred.getCompletionExceptionOrNull() != null && testScope.isActive) {
192+
/**
193+
* Here, we already know how the test will finish: it will throw
194+
* [Deferred.getCompletionExceptionOrNull]. Therefore, we won't care if there are uncompleted jobs,
195+
* and may as well just exit right here. However, in order to lower the surprise factor, we
196+
* cancel the child jobs here and wait for them to finish instead of dropping them: there could be
197+
* some cleanup procedures involved, and not having finalizers run could mean leaking resources.
198+
*
199+
* Another approach to take if this turns out not to be enough and some child jobs still fail is to
200+
* only make at most a fixed number of [TestCoroutineScheduler.tryRunNextTask] once we detect the
201+
* failure with which the test will finish. This has the downside that there is still some
202+
* negligible risk of not running the finalizers.
203+
*/
204+
testScope.cancel()
205+
}
206+
}
181207
if (deferred.isCompleted) {
182208
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
183209
non-trivial dispatches. */

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

+16-8
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,21 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti
8080
}
8181
}
8282

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

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

+33
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,37 @@ class RunTestTest {
172172
assertTrue(collectedError)
173173
}
174174

175+
/** Tests that, once the test body has thrown, the child coroutines are cancelled. */
176+
@Test
177+
fun testChildrenCancellationOnTestBodyFailure() {
178+
var job: Job? = null
179+
testResultMap({
180+
assertFailsWith<AssertionError> { it() }
181+
assertTrue(job!!.isCancelled)
182+
}, {
183+
runTest {
184+
job = launch {
185+
while (true) {
186+
delay(1000)
187+
}
188+
}
189+
throw AssertionError()
190+
}
191+
})
192+
}
193+
194+
/** Tests that [runTest] reports [TimeoutCancellationException]. */
195+
@Test
196+
fun testTimeout() = testResultMap({
197+
assertFailsWith<TimeoutCancellationException> { it() }
198+
}, {
199+
runTest {
200+
withTimeout(50) {
201+
launch {
202+
delay(1000)
203+
}
204+
}
205+
}
206+
})
207+
175208
}

0 commit comments

Comments
 (0)