Skip to content

Commit 9da1fcf

Browse files
committed
Don't use Dispatchers.DEFAULT for the test runner
1 parent 38f3bab commit 9da1fcf

File tree

4 files changed

+113
-49
lines changed

4 files changed

+113
-49
lines changed

kotlinx-coroutines-test/build.gradle.kts

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import org.jetbrains.kotlin.gradle.plugin.mpp.*
2-
31
/*
42
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
53
*/
64

5+
import org.jetbrains.kotlin.gradle.plugin.mpp.*
6+
77
val experimentalAnnotations = listOf(
88
"kotlin.Experimental",
99
"kotlinx.coroutines.ExperimentalCoroutinesApi",
@@ -19,4 +19,12 @@ kotlin {
1919
binaryOptions["memoryModel"] = "experimental"
2020
}
2121
}
22+
23+
sourceSets {
24+
jvmTest {
25+
dependencies {
26+
implementation(project(":kotlinx-coroutines-debug"))
27+
}
28+
}
29+
}
2230
}

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

+49-46
Original file line numberDiff line numberDiff line change
@@ -303,67 +303,70 @@ public fun runTest(
303303
public fun TestScope.runTest(
304304
timeout: Duration = DEFAULT_TIMEOUT,
305305
testBody: suspend TestScope.() -> Unit
306-
): TestResult = asSpecificImplementation().let {
307-
it.enter()
306+
): TestResult = asSpecificImplementation().let { scope ->
307+
scope.enter()
308308
createTestResult {
309309
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
310-
it.start(CoroutineStart.UNDISPATCHED, it) {
310+
scope.start(CoroutineStart.UNDISPATCHED, scope) {
311+
yield()
311312
testBody()
312313
}
313-
/**
314-
* We run the tasks in the test coroutine using [Dispatchers.Default]. On JS, this does nothing particularly,
315-
* but on the JVM and Native, this means that the timeout can be processed even while the test runner is busy
316-
* doing some synchronous work.
317-
*/
318-
val workRunner = launch(Dispatchers.Default + CoroutineName("kotlinx.coroutines.test runner")) {
319-
while (true) {
320-
val executedSomething = testScheduler.tryRunNextTaskUnless { !isActive }
321-
if (executedSomething) {
322-
/** yield to check for cancellation. On JS, we can't use [ensureActive] here, as the cancellation
323-
* procedure needs a chance to run concurrently. */
324-
yield()
325-
} else {
326-
// no more tasks, we should suspend until there are some more
327-
testScheduler.receiveDispatchEvent()
328-
}
329-
}
330-
}
331314
var timeoutError: Throwable? = null
315+
var cancellationException: CancellationException? = null
332316
try {
333317
withTimeout(timeout) {
334-
it.join()
335-
workRunner.cancelAndJoin()
318+
coroutineContext.job.invokeOnCompletion(onCancelling = true) { exception ->
319+
if (exception is TimeoutCancellationException) {
320+
dumpCoroutines()
321+
val activeChildren = scope.children.filter(Job::isActive).toList()
322+
val completionCause = if (scope.isCancelled) scope.tryGetCompletionCause() else null
323+
var message = "After waiting for $timeout"
324+
if (completionCause == null)
325+
message += ", the test coroutine is not completing"
326+
if (activeChildren.isNotEmpty())
327+
message += ", there were active child jobs: $activeChildren"
328+
if (completionCause != null && activeChildren.isEmpty()) {
329+
message += if (scope.isCompleted)
330+
", the test coroutine completed"
331+
else
332+
", the test coroutine was not completed"
333+
}
334+
timeoutError = UncompletedCoroutinesError(message)
335+
cancellationException = CancellationException("The test timed out")
336+
(scope as Job).cancel(cancellationException!!)
337+
}
338+
}
339+
while (scope.isActive) {
340+
val executedSomething = testScheduler.tryRunNextTaskUnless { !scope.isActive }
341+
if (executedSomething) {
342+
/** yield to check for cancellation. On JS, we can't use [ensureActive] here, as the cancellation
343+
* procedure needs a chance to run concurrently. */
344+
yield()
345+
} else {
346+
// no tasks, we should suspend until there are some more or the test coroutine is completed
347+
select {
348+
scope.onJoin {
349+
// do nothing, just exit the select and the `while` loop.
350+
}
351+
testScheduler.onDispatchEvent {
352+
// do nothing, just exit the select and run the next iteration
353+
}
354+
}
355+
}
356+
}
336357
}
337358
} catch (_: TimeoutCancellationException) {
338-
val activeChildren = it.children.filter(Job::isActive).toList()
339-
val completionCause = if (it.isCancelled) it.tryGetCompletionCause() else null
340-
var message = "After waiting for $timeout"
341-
if (completionCause == null)
342-
message += ", the test coroutine is not completing"
343-
if (activeChildren.isNotEmpty())
344-
message += ", there were active child jobs: $activeChildren"
345-
if (completionCause != null && activeChildren.isEmpty()) {
346-
message += if (it.isCompleted)
347-
", the test coroutine completed"
348-
else
349-
", the test coroutine was not completed"
350-
}
351-
timeoutError = UncompletedCoroutinesError(message)
352-
dumpCoroutines()
353-
val cancellationException = CancellationException("The test timed out")
354-
(it as Job).cancel(cancellationException)
355359
// we can't abandon the work we're doing, so if it hanged, we'll still hang, despite the timeout.
356-
it.join()
357-
val completion = it.getCompletionExceptionOrNull()
360+
scope.join()
361+
val completion = scope.getCompletionExceptionOrNull()
358362
if (completion != null && completion !== cancellationException) {
359-
timeoutError.addSuppressed(completion)
363+
timeoutError!!.addSuppressed(completion)
360364
}
361-
workRunner.cancelAndJoin()
362365
} finally {
363366
backgroundScope.cancel()
364367
testScheduler.advanceUntilIdleOr { false }
365-
val uncaughtExceptions = it.leave()
366-
throwAll(timeoutError ?: it.getCompletionExceptionOrNull(), uncaughtExceptions)
368+
val uncaughtExceptions = scope.leave()
369+
throwAll(timeoutError ?: scope.getCompletionExceptionOrNull(), uncaughtExceptions)
367370
}
368371
}
369372
}

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

+6-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
7777
val time = addClamping(currentTime, timeDeltaMillis)
7878
val event = TestDispatchEvent(dispatcher, count, time, marker as Any, isForeground) { isCancelled(marker) }
7979
events.addLast(event)
80-
/** can't be moved above: otherwise, [onDispatchEventForeground] or [receiveDispatchEvent] could consume the
80+
/** can't be moved above: otherwise, [onDispatchEventForeground] or [onDispatchEvent] could consume the
8181
* token sent here before there's actually anything in the event queue. */
8282
sendDispatchEvent(context)
8383
DisposableHandle {
@@ -214,6 +214,11 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
214214
*/
215215
internal suspend fun receiveDispatchEvent() = dispatchEvents.receive()
216216

217+
/**
218+
* Consumes the knowledge that a dispatch event happened recently.
219+
*/
220+
internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
221+
217222
/**
218223
* Consumes the knowledge that a foreground work dispatch event happened recently.
219224
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2016-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.test
6+
7+
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.debug.*
9+
import org.junit.Test
10+
import java.io.*
11+
import kotlin.test.*
12+
import kotlin.time.Duration.Companion.milliseconds
13+
14+
class DumpOnTimeoutTest {
15+
/**
16+
* Tests that the dump on timeout contains the correct stacktrace.
17+
*/
18+
@Test
19+
fun testDumpOnTimeout() {
20+
val oldErr = System.err
21+
val baos = ByteArrayOutputStream()
22+
try {
23+
System.setErr(PrintStream(baos, true))
24+
DebugProbes.withDebugProbes {
25+
try {
26+
runTest(timeout = 100.milliseconds) {
27+
uniquelyNamedFunction()
28+
}
29+
throw IllegalStateException("unreachable")
30+
} catch (e: UncompletedCoroutinesError) {
31+
// do nothing
32+
}
33+
}
34+
baos.toString().let {
35+
assertTrue(it.contains("uniquelyNamedFunction"), "Actual trace:\n$it")
36+
}
37+
} finally {
38+
System.setErr(oldErr)
39+
}
40+
}
41+
42+
fun CoroutineScope.uniquelyNamedFunction() {
43+
while (true) {
44+
ensureActive()
45+
Thread.sleep(10)
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)