Reputation: 105
When reading the kdocs of yield()
, I read the following:
Yields the thread (or thread pool) of the current coroutine dispatcher to other coroutines on the same dispatcher to run if possible. This suspending function is cancellable. If the Job of the current coroutine is canceled or completed when this suspending function is invoked or while it is waiting for dispatch, it resumes with a CancellationException. There is a prompt cancellation guarantee. If the job was canceled while this function was suspended, it will not resume successfully. See suspendCancellableCoroutine documentation for low-level details. Note: This function always checks for cancellation even when it does not suspend.
I've set up the following test. Which halts with a TimeoutException after 10 seconds even though this test should never be running for 10 seconds? I'd expect the job
to be canceled after 1000ms.
@Test
fun `should cancel`() = runTest {
val job = backgroundScope.launch {
while(isActive) {
yield()
}
}
delay(1000)
job.cancelAndJoin()
assertTrue { job.isCancelled }
} // Runs until the test times out
Using the delay function with a timeout of 1 ms does deliver my expected outcome.
@Test
fun `should cancel`() = runTest {
val job = backgroundScope.launch {
while(isActive) {
delay(1)
}
}
delay(1000)
job.cancelAndJoin()
assertTrue { job.isCancelled }
} // Works as expected
Could anyone explain why yield()
does not seem to cause our while loop to be broken by cancellation?
Upvotes: 4
Views: 813
Reputation: 1159
I think this yield conflicts with another endless loop on the runTest function. You can find this in TestBuilders.kt
:
while (true) {
val executedSomething = testScheduler.tryRunNextTaskUnless { !isActive }
if (executedSomething) {
/** yield to check for cancellation. On JS, we can't use [ensureActive] here, as the cancellation
* procedure needs a chance to run concurrently. */
yield()
} else {
// waiting for the next task to be scheduled, or for the test runner to be cancelled
testScheduler.receiveDispatchEvent()
}
}
If you put a breakpoint anywhere in this while (line 320 onwards on Kotlin 1.9.22), you'll see that the endless loop lasts until the DEFAULT_TIMEOUT
of 10 seconds.
This makes sense because runTest, uses a StandardTestDispatcher
which under the hood probably uses a single thread mechanism to guarantee the order of execution when performing tests. So with yield
used this way, you are likely just bouncing the control of the thread between the runTest
context and the backgroundScope
context, which means that this process never ends until it times out. Just put a break point also in your program and you'll see this bouncing happening back and forth.
This is a great catch by the way. Nicely observed and indeed using runBlocking
and this
instead will not generate this problem. I have not seen any yields inside the runBlocking function which would explain this phenomenon you've found.
Upvotes: 2