Skip to content

Commit 4fe30ec

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

File tree

4 files changed

+101
-38
lines changed

4 files changed

+101
-38
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

+37-35
Original file line numberDiff line numberDiff line change
@@ -303,67 +303,69 @@ 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+
/* we're using `UNDISPATCHED` to avoid the event loop, but we do want to set up the timeout machinery
312+
before any code executes, so we have to park here. */
313+
yield()
311314
testBody()
312315
}
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")) {
316+
var timeoutError: Throwable? = null
317+
var cancellationException: CancellationException? = null
318+
val workRunner = launch(CoroutineName("kotlinx.coroutines.test runner")) {
319319
while (true) {
320320
val executedSomething = testScheduler.tryRunNextTaskUnless { !isActive }
321321
if (executedSomething) {
322322
/** yield to check for cancellation. On JS, we can't use [ensureActive] here, as the cancellation
323323
* procedure needs a chance to run concurrently. */
324324
yield()
325325
} else {
326-
// no more tasks, we should suspend until there are some more
326+
// waiting for the next task to be scheduled, or for the test runner to be cancelled
327327
testScheduler.receiveDispatchEvent()
328328
}
329329
}
330330
}
331-
var timeoutError: Throwable? = null
332331
try {
333332
withTimeout(timeout) {
334-
it.join()
333+
coroutineContext.job.invokeOnCompletion(onCancelling = true) { exception ->
334+
if (exception is TimeoutCancellationException) {
335+
dumpCoroutines()
336+
val activeChildren = scope.children.filter(Job::isActive).toList()
337+
val completionCause = if (scope.isCancelled) scope.tryGetCompletionCause() else null
338+
var message = "After waiting for $timeout"
339+
if (completionCause == null)
340+
message += ", the test coroutine is not completing"
341+
if (activeChildren.isNotEmpty())
342+
message += ", there were active child jobs: $activeChildren"
343+
if (completionCause != null && activeChildren.isEmpty()) {
344+
message += if (scope.isCompleted)
345+
", the test coroutine completed"
346+
else
347+
", the test coroutine was not completed"
348+
}
349+
timeoutError = UncompletedCoroutinesError(message)
350+
cancellationException = CancellationException("The test timed out")
351+
(scope as Job).cancel(cancellationException!!)
352+
}
353+
}
354+
scope.join()
335355
workRunner.cancelAndJoin()
336356
}
337357
} 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)
355-
// 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()
358+
scope.join()
359+
val completion = scope.getCompletionExceptionOrNull()
358360
if (completion != null && completion !== cancellationException) {
359-
timeoutError.addSuppressed(completion)
361+
timeoutError!!.addSuppressed(completion)
360362
}
361363
workRunner.cancelAndJoin()
362364
} finally {
363365
backgroundScope.cancel()
364366
testScheduler.advanceUntilIdleOr { false }
365-
val uncaughtExceptions = it.leave()
366-
throwAll(timeoutError ?: it.getCompletionExceptionOrNull(), uncaughtExceptions)
367+
val uncaughtExceptions = scope.leave()
368+
throwAll(timeoutError ?: scope.getCompletionExceptionOrNull(), uncaughtExceptions)
367369
}
368370
}
369371
}

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)