Skip to content

Commit 143bdfa

Browse files
authored
Add a scope for launching background work in tests (#3348)
Fixes #3287
1 parent 562902b commit 143bdfa

19 files changed

+738
-60
lines changed

kotlinx-coroutines-core/api/kotlinx-coroutines-core.api

+6
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ public final class kotlinx/coroutines/Job$DefaultImpls {
381381
public final class kotlinx/coroutines/Job$Key : kotlin/coroutines/CoroutineContext$Key {
382382
}
383383

384+
public class kotlinx/coroutines/JobImpl : kotlinx/coroutines/JobSupport, kotlinx/coroutines/CompletableJob {
385+
public fun <init> (Lkotlinx/coroutines/Job;)V
386+
public fun complete ()Z
387+
public fun completeExceptionally (Ljava/lang/Throwable;)Z
388+
}
389+
384390
public final class kotlinx/coroutines/JobKt {
385391
public static final fun Job (Lkotlinx/coroutines/Job;)Lkotlinx/coroutines/CompletableJob;
386392
public static final synthetic fun Job (Lkotlinx/coroutines/Job;)Lkotlinx/coroutines/Job;

kotlinx-coroutines-core/common/src/JobSupport.kt

+1
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,7 @@ private class Empty(override val isActive: Boolean) : Incomplete {
13121312
override fun toString(): String = "Empty{${if (isActive) "Active" else "New" }}"
13131313
}
13141314

1315+
@PublishedApi // for a custom job in the test module
13151316
internal open class JobImpl(parent: Job?) : JobSupport(true), CompletableJob {
13161317
init { initParentJob(parent) }
13171318
override val onCancelComplete get() = true

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +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
43+
@PublishedApi // published for the multiplatform implementation of kotlinx-coroutines-test
4444
internal expect fun <E: Throwable> unwrap(exception: E): E
4545

4646
internal expect class StackTraceElement

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

+10
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ public open class ThreadSafeHeap<T> : SynchronizedObject() where T: ThreadSafeHe
3737
_size.value = 0
3838
}
3939

40+
public fun find(
41+
predicate: (value: T) -> Boolean
42+
): T? = synchronized(this) block@{
43+
for (i in 0 until size) {
44+
val value = a?.get(i)!!
45+
if (predicate(value)) return@block value
46+
}
47+
null
48+
}
49+
4050
public fun peek(): T? = synchronized(this) { firstImpl() }
4151

4252
public fun removeFirstOrNull(): T? = synchronized(this) {

kotlinx-coroutines-test/api/kotlinx-coroutines-test.api

+1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ public final class kotlinx/coroutines/test/TestDispatchers {
105105
}
106106

107107
public abstract interface class kotlinx/coroutines/test/TestScope : kotlinx/coroutines/CoroutineScope {
108+
public abstract fun getBackgroundScope ()Lkotlinx/coroutines/CoroutineScope;
108109
public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
109110
}
110111

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

+49-11
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,19 @@ public fun TestScope.runTest(
164164
): TestResult = asSpecificImplementation().let {
165165
it.enter()
166166
createTestResult {
167-
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) { it.leave() }
167+
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) {
168+
backgroundScope.cancel()
169+
testScheduler.advanceUntilIdleOr { false }
170+
it.leave()
171+
}
168172
}
169173
}
170174

171175
/**
172176
* Runs [testProcedure], creating a [TestResult].
173177
*/
174178
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
175-
internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult
179+
internal expect fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit): TestResult
176180

177181
/** A coroutine context element indicating that the coroutine is running inside `runTest`. */
178182
internal object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
@@ -195,7 +199,7 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
195199
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
196200
* return a list of uncaught exceptions that should be reported at the end of the test.
197201
*/
198-
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
202+
internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutine(
199203
coroutine: T,
200204
dispatchTimeoutMs: Long,
201205
tryGetCompletionCause: T.() -> Throwable?,
@@ -207,6 +211,27 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
207211
coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) {
208212
testBody()
209213
}
214+
/**
215+
* The general procedure here is as follows:
216+
* 1. Try running the work that the scheduler knows about, both background and foreground.
217+
*
218+
* 2. Wait until we run out of foreground work to do. This could mean one of the following:
219+
* * The main coroutine is already completed. This is checked separately; then we leave the procedure.
220+
* * It's switched to another dispatcher that doesn't know about the [TestCoroutineScheduler].
221+
* * Generally, it's waiting for something external (like a network request, or just an arbitrary callback).
222+
* * The test simply hanged.
223+
* * The main coroutine is waiting for some background work.
224+
*
225+
* 3. We await progress from things that are not the code under test:
226+
* the background work that the scheduler knows about, the external callbacks,
227+
* the work on dispatchers not linked to the scheduler, etc.
228+
*
229+
* When we observe that the code under test can proceed, we go to step 1 again.
230+
* If there is no activity for [dispatchTimeoutMs] milliseconds, we consider the test to have hanged.
231+
*
232+
* The background work is not running on a dedicated thread.
233+
* Instead, the test thread itself is used, by spawning a separate coroutine.
234+
*/
210235
var completed = false
211236
while (!completed) {
212237
scheduler.advanceUntilIdle()
@@ -216,16 +241,29 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
216241
completed = true
217242
continue
218243
}
219-
select<Unit> {
220-
coroutine.onJoin {
221-
completed = true
222-
}
223-
scheduler.onDispatchEvent {
224-
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
244+
// in case progress depends on some background work, we need to keep spinning it.
245+
val backgroundWorkRunner = launch(CoroutineName("background work runner")) {
246+
while (true) {
247+
scheduler.tryRunNextTaskUnless { !isActive }
248+
// yield so that the `select` below has a chance to check if its conditions are fulfilled
249+
yield()
225250
}
226-
onTimeout(dispatchTimeoutMs) {
227-
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
251+
}
252+
try {
253+
select<Unit> {
254+
coroutine.onJoin {
255+
// observe that someone completed the test coroutine and leave without waiting for the timeout
256+
completed = true
257+
}
258+
scheduler.onDispatchEvent {
259+
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
260+
}
261+
onTimeout(dispatchTimeoutMs) {
262+
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
263+
}
228264
}
265+
} finally {
266+
backgroundWorkRunner.cancelAndJoin()
229267
}
230268
}
231269
coroutine.getCompletionExceptionOrNull()?.let { exception ->

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

+2-3
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ private class UnconfinedTestDispatcherImpl(
9696
@Suppress("INVISIBLE_MEMBER")
9797
override fun dispatch(context: CoroutineContext, block: Runnable) {
9898
checkSchedulerInContext(scheduler, context)
99-
scheduler.sendDispatchEvent()
99+
scheduler.sendDispatchEvent(context)
100100

101101
/** copy-pasted from [kotlinx.coroutines.Unconfined.dispatch] */
102102
/** It can only be called by the [yield] function. See also code of [yield] function. */
@@ -151,8 +151,7 @@ private class StandardTestDispatcherImpl(
151151
) : TestDispatcher() {
152152

153153
override fun dispatch(context: CoroutineContext, block: Runnable) {
154-
checkSchedulerInContext(scheduler, context)
155-
scheduler.registerEvent(this, 0, block) { false }
154+
scheduler.registerEvent(this, 0, block, context) { false }
156155
}
157156

158157
override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]"

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

+42-20
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,20 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
6262
dispatcher: TestDispatcher,
6363
timeDeltaMillis: Long,
6464
marker: T,
65+
context: CoroutineContext,
6566
isCancelled: (T) -> Boolean
6667
): DisposableHandle {
6768
require(timeDeltaMillis >= 0) { "Attempted scheduling an event earlier in time (with the time delta $timeDeltaMillis)" }
69+
checkSchedulerInContext(this, context)
6870
val count = count.getAndIncrement()
71+
val isForeground = context[BackgroundWork] === null
6972
return synchronized(lock) {
7073
val time = addClamping(currentTime, timeDeltaMillis)
71-
val event = TestDispatchEvent(dispatcher, count, time, marker as Any) { isCancelled(marker) }
74+
val event = TestDispatchEvent(dispatcher, count, time, marker as Any, isForeground) { isCancelled(marker) }
7275
events.addLast(event)
7376
/** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's
7477
* actually anything in the event queue. */
75-
sendDispatchEvent()
78+
sendDispatchEvent(context)
7679
DisposableHandle {
7780
synchronized(lock) {
7881
events.remove(event)
@@ -82,10 +85,12 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
8285
}
8386

8487
/**
85-
* Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening.
88+
* Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening,
89+
* unless [condition] holds.
8690
*/
87-
private fun tryRunNextTask(): Boolean {
91+
internal fun tryRunNextTaskUnless(condition: () -> Boolean): Boolean {
8892
val event = synchronized(lock) {
93+
if (condition()) return false
8994
val event = events.removeFirstOrNull() ?: return false
9095
if (currentTime > event.time)
9196
currentTimeAheadOfEvents()
@@ -105,9 +110,15 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
105110
* functionality, query [currentTime] before and after the execution to achieve the same result.
106111
*/
107112
@ExperimentalCoroutinesApi
108-
public fun advanceUntilIdle() {
109-
while (!synchronized(lock) { events.isEmpty }) {
110-
tryRunNextTask()
113+
public fun advanceUntilIdle(): Unit = advanceUntilIdleOr { events.none(TestDispatchEvent<*>::isForeground) }
114+
115+
/**
116+
* [condition]: guaranteed to be invoked under the lock.
117+
*/
118+
internal fun advanceUntilIdleOr(condition: () -> Boolean) {
119+
while (true) {
120+
if (!tryRunNextTaskUnless(condition))
121+
return
111122
}
112123
}
113124

@@ -169,24 +180,19 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
169180
/**
170181
* Checks that the only tasks remaining in the scheduler are cancelled.
171182
*/
172-
internal fun isIdle(strict: Boolean = true): Boolean {
183+
internal fun isIdle(strict: Boolean = true): Boolean =
173184
synchronized(lock) {
174-
if (strict)
175-
return events.isEmpty
176-
// TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap]
177-
val presentEvents = mutableListOf<TestDispatchEvent<*>>()
178-
while (true) {
179-
presentEvents += events.removeFirstOrNull() ?: break
180-
}
181-
return presentEvents.all { it.isCancelled() }
185+
if (strict) events.isEmpty else events.none { !it.isCancelled() }
182186
}
183-
}
184187

185188
/**
186189
* Notifies this scheduler about a dispatch event.
190+
*
191+
* [context] is the context in which the task will be dispatched.
187192
*/
188-
internal fun sendDispatchEvent() {
189-
dispatchEvents.trySend(Unit)
193+
internal fun sendDispatchEvent(context: CoroutineContext) {
194+
if (context[BackgroundWork] !== BackgroundWork)
195+
dispatchEvents.trySend(Unit)
190196
}
191197

192198
/**
@@ -216,6 +222,8 @@ private class TestDispatchEvent<T>(
216222
private val count: Long,
217223
@JvmField val time: Long,
218224
@JvmField val marker: T,
225+
@JvmField val isForeground: Boolean,
226+
// TODO: remove once the deprecated API is gone
219227
@JvmField val isCancelled: () -> Boolean
220228
) : Comparable<TestDispatchEvent<*>>, ThreadSafeHeapNode {
221229
override var heap: ThreadSafeHeap<*>? = null
@@ -224,7 +232,7 @@ private class TestDispatchEvent<T>(
224232
override fun compareTo(other: TestDispatchEvent<*>) =
225233
compareValuesBy(this, other, TestDispatchEvent<*>::time, TestDispatchEvent<*>::count)
226234

227-
override fun toString() = "TestDispatchEvent(time=$time, dispatcher=$dispatcher)"
235+
override fun toString() = "TestDispatchEvent(time=$time, dispatcher=$dispatcher${if (isForeground) "" else ", background"})"
228236
}
229237

230238
// works with positive `a`, `b`
@@ -238,3 +246,17 @@ internal fun checkSchedulerInContext(scheduler: TestCoroutineScheduler, context:
238246
}
239247
}
240248
}
249+
250+
/**
251+
* A coroutine context key denoting that the work is to be executed in the background.
252+
* @see [TestScope.backgroundScope]
253+
*/
254+
internal object BackgroundWork : CoroutineContext.Key<BackgroundWork>, CoroutineContext.Element {
255+
override val key: CoroutineContext.Key<*>
256+
get() = this
257+
258+
override fun toString(): String = "BackgroundWork"
259+
}
260+
261+
private fun<T> ThreadSafeHeap<T>.none(predicate: (T) -> Boolean) where T: ThreadSafeHeapNode, T: Comparable<T> =
262+
find(predicate) == null

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

+5-8
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import kotlin.jvm.*
1010

1111
/**
1212
* A test dispatcher that can interface with a [TestCoroutineScheduler].
13-
*
13+
*
1414
* The available implementations are:
1515
* * [StandardTestDispatcher] is a dispatcher that places new tasks into a queue.
1616
* * [UnconfinedTestDispatcher] is a dispatcher that behaves like [Dispatchers.Unconfined] while allowing to control
1717
* the virtual time.
1818
*/
1919
@ExperimentalCoroutinesApi
20-
public abstract class TestDispatcher internal constructor(): CoroutineDispatcher(), Delay {
20+
public abstract class TestDispatcher internal constructor() : CoroutineDispatcher(), Delay {
2121
/** The scheduler that this dispatcher is linked to. */
2222
@ExperimentalCoroutinesApi
2323
public abstract val scheduler: TestCoroutineScheduler
@@ -30,16 +30,13 @@ public abstract class TestDispatcher internal constructor(): CoroutineDispatcher
3030

3131
/** @suppress */
3232
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
33-
checkSchedulerInContext(scheduler, continuation.context)
3433
val timedRunnable = CancellableContinuationRunnable(continuation, this)
35-
scheduler.registerEvent(this, timeMillis, timedRunnable, ::cancellableRunnableIsCancelled)
34+
scheduler.registerEvent(this, timeMillis, timedRunnable, continuation.context, ::cancellableRunnableIsCancelled)
3635
}
3736

3837
/** @suppress */
39-
override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle {
40-
checkSchedulerInContext(scheduler, context)
41-
return scheduler.registerEvent(this, timeMillis, block) { false }
42-
}
38+
override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle =
39+
scheduler.registerEvent(this, timeMillis, block, context) { false }
4340
}
4441

4542
/**

0 commit comments

Comments
 (0)