Allow specifying the timeout for `runTest` (#3603)

Deprecate `dispatchTimeoutMs`, as this is a confusing
implementation detail that made it to the final API.

We use the fact that the `runTest(Duration)` overload was never
published, so we can reuse it to have the `Duration` mean the
whole-test timeout in a backward-compatible manner.
diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
index 3f63364..ac9edb9 100644
--- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
+++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
@@ -22,10 +22,10 @@
 	public static final fun runTest (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
 	public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
 	public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
-	public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
 	public static final fun runTest-8Mi8wO0 (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
 	public static final fun runTest-8Mi8wO0 (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
 	public static synthetic fun runTest-8Mi8wO0$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static synthetic fun runTest-8Mi8wO0$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
 	public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
 	public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
 }
@@ -66,6 +66,7 @@
 	public static final field Key Lkotlinx/coroutines/test/TestCoroutineScheduler$Key;
 	public fun <init> ()V
 	public final fun advanceTimeBy (J)V
+	public final fun advanceTimeBy-LRDsOJo (J)V
 	public final fun advanceUntilIdle ()V
 	public final fun getCurrentTime ()J
 	public final fun getTimeSource ()Lkotlin/time/TimeSource;
@@ -117,6 +118,7 @@
 	public static final fun TestScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestScope;
 	public static synthetic fun TestScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestScope;
 	public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestScope;J)V
+	public static final fun advanceTimeBy-HG0u8IE (Lkotlinx/coroutines/test/TestScope;J)V
 	public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestScope;)V
 	public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestScope;)J
 	public static final fun getTestTimeSource (Lkotlinx/coroutines/test/TestScope;)Lkotlin/time/TimeSource;
diff --git a/kotlinx-coroutines-test/build.gradle.kts b/kotlinx-coroutines-test/build.gradle.kts
index 98d3e9f..c968fc4 100644
--- a/kotlinx-coroutines-test/build.gradle.kts
+++ b/kotlinx-coroutines-test/build.gradle.kts
@@ -1,9 +1,9 @@
-import org.jetbrains.kotlin.gradle.plugin.mpp.*
-
 /*
  * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
  */
 
+import org.jetbrains.kotlin.gradle.plugin.mpp.*
+
 val experimentalAnnotations = listOf(
     "kotlin.Experimental",
     "kotlinx.coroutines.ExperimentalCoroutinesApi",
@@ -19,4 +19,12 @@
             binaryOptions["memoryModel"] = "experimental"
         }
     }
+
+    sourceSets {
+        jvmTest {
+            dependencies {
+                implementation(project(":kotlinx-coroutines-debug"))
+            }
+        }
+    }
 }
diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt
index e9d8fec..de16967 100644
--- a/kotlinx-coroutines-test/common/src/TestBuilders.kt
+++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt
@@ -7,11 +7,14 @@
 package kotlinx.coroutines.test
 
 import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.selects.*
 import kotlin.coroutines.*
 import kotlin.jvm.*
 import kotlin.time.*
 import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.internal.*
 
 /**
  * A test result.
@@ -118,6 +121,18 @@
  *
  * If the created coroutine completes with an exception, then this exception will be thrown at the end of the test.
  *
+ * #### Timing out
+ *
+ * There's a built-in timeout of 10 seconds for the test body. If the test body doesn't complete within this time,
+ * then the test fails with an [AssertionError]. The timeout can be changed by setting the [timeout] parameter.
+ *
+ * The test finishes by the timeout procedure cancelling the test body. If the code inside the test body does not
+ * respond to cancellation, we will not be able to make the test execution stop, in which case, the test will hang
+ * despite our best efforts to terminate it.
+ *
+ * On the JVM, if `DebugProbes` from the `kotlinx-coroutines-debug` module are installed, the current dump of the
+ * coroutines' stack is printed to the console on timeout.
+ *
  * #### Reported exceptions
  *
  * Unhandled exceptions will be thrown at the end of the test.
@@ -131,12 +146,6 @@
  * Otherwise, the test will be failed (which, on JVM and Native, means that [runTest] itself will throw
  * [AssertionError], whereas on JS, the `Promise` will fail with it).
  *
- * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
- * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
- * for [dispatchTimeout] (by default, 60 seconds) from the moment when [TestCoroutineScheduler] becomes
- * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
- * task during that time, the timer gets reset.
- *
  * ### Configuration
  *
  * [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
@@ -148,12 +157,13 @@
 @ExperimentalCoroutinesApi
 public fun runTest(
     context: CoroutineContext = EmptyCoroutineContext,
-    dispatchTimeout: Duration = DEFAULT_DISPATCH_TIMEOUT,
+    timeout: Duration = DEFAULT_TIMEOUT,
     testBody: suspend TestScope.() -> Unit
 ): TestResult {
-    if (context[RunningInRunTest] != null)
-        throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
-    return TestScope(context + RunningInRunTest).runTest(dispatchTimeout, testBody)
+    check(context[RunningInRunTest] == null) {
+        "Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details."
+    }
+    return TestScope(context + RunningInRunTest).runTest(timeout, testBody)
 }
 
 /**
@@ -269,46 +279,128 @@
  *
  * @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details.
  */
-@ExperimentalCoroutinesApi
+@Deprecated(
+    "Define a total timeout for the whole test instead of using dispatchTimeoutMs. " +
+        "Warning: the proposed replacement is not identical as it uses 'dispatchTimeoutMs' as the timeout for the whole test!",
+    ReplaceWith("runTest(context, timeout = dispatchTimeoutMs.milliseconds, testBody)",
+        "kotlin.time.Duration.Companion.milliseconds"),
+    DeprecationLevel.WARNING
+)
 public fun runTest(
     context: CoroutineContext = EmptyCoroutineContext,
     dispatchTimeoutMs: Long,
     testBody: suspend TestScope.() -> Unit
-): TestResult = runTest(
-    context = context,
-    dispatchTimeout = dispatchTimeoutMs.milliseconds,
-    testBody = testBody
-)
+): TestResult {
+    if (context[RunningInRunTest] != null)
+        throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
+    @Suppress("DEPRECATION")
+    return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs = dispatchTimeoutMs, testBody)
+}
 
 /**
- * Performs [runTest] on an existing [TestScope].
+ * Performs [runTest] on an existing [TestScope]. See the documentation for [runTest] for details.
  */
 @ExperimentalCoroutinesApi
 public fun TestScope.runTest(
-    dispatchTimeout: Duration,
+    timeout: Duration = DEFAULT_TIMEOUT,
     testBody: suspend TestScope.() -> Unit
-): TestResult = asSpecificImplementation().let {
-    it.enter()
+): TestResult = asSpecificImplementation().let { scope ->
+    scope.enter()
     createTestResult {
-        runTestCoroutine(it, dispatchTimeout, TestScopeImpl::tryGetCompletionCause, testBody) {
+        /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
+        scope.start(CoroutineStart.UNDISPATCHED, scope) {
+            /* we're using `UNDISPATCHED` to avoid the event loop, but we do want to set up the timeout machinery
+            before any code executes, so we have to park here. */
+            yield()
+            testBody()
+        }
+        var timeoutError: Throwable? = null
+        var cancellationException: CancellationException? = null
+        val workRunner = launch(CoroutineName("kotlinx.coroutines.test runner")) {
+            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()
+                }
+            }
+        }
+        try {
+            withTimeout(timeout) {
+                coroutineContext.job.invokeOnCompletion(onCancelling = true) { exception ->
+                    if (exception is TimeoutCancellationException) {
+                        dumpCoroutines()
+                        val activeChildren = scope.children.filter(Job::isActive).toList()
+                        val completionCause = if (scope.isCancelled) scope.tryGetCompletionCause() else null
+                        var message = "After waiting for $timeout"
+                        if (completionCause == null)
+                            message += ", the test coroutine is not completing"
+                        if (activeChildren.isNotEmpty())
+                            message += ", there were active child jobs: $activeChildren"
+                        if (completionCause != null && activeChildren.isEmpty()) {
+                            message += if (scope.isCompleted)
+                                ", the test coroutine completed"
+                            else
+                                ", the test coroutine was not completed"
+                        }
+                        timeoutError = UncompletedCoroutinesError(message)
+                        cancellationException = CancellationException("The test timed out")
+                        (scope as Job).cancel(cancellationException!!)
+                    }
+                }
+                scope.join()
+                workRunner.cancelAndJoin()
+            }
+        } catch (_: TimeoutCancellationException) {
+            scope.join()
+            val completion = scope.getCompletionExceptionOrNull()
+            if (completion != null && completion !== cancellationException) {
+                timeoutError!!.addSuppressed(completion)
+            }
+            workRunner.cancelAndJoin()
+        } finally {
             backgroundScope.cancel()
             testScheduler.advanceUntilIdleOr { false }
-            it.leave()
+            val uncaughtExceptions = scope.leave()
+            throwAll(timeoutError ?: scope.getCompletionExceptionOrNull(), uncaughtExceptions)
         }
     }
 }
 
 /**
  * Performs [runTest] on an existing [TestScope].
+ *
+ * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
+ * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
+ * for [dispatchTimeoutMs] from the moment when [TestCoroutineScheduler] becomes
+ * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
+ * task during that time, the timer gets reset.
  */
-@ExperimentalCoroutinesApi
-public fun TestScope.runTest(
-    dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
-    testBody: suspend TestScope.() -> Unit
-): TestResult = runTest(
-    dispatchTimeout = dispatchTimeoutMs.milliseconds,
-    testBody = testBody
+@Deprecated(
+    "Define a total timeout for the whole test instead of using dispatchTimeoutMs. " +
+        "Warning: the proposed replacement is not identical as it uses 'dispatchTimeoutMs' as the timeout for the whole test!",
+    ReplaceWith("this.runTest(timeout = dispatchTimeoutMs.milliseconds, testBody)",
+        "kotlin.time.Duration.Companion.milliseconds"),
+    DeprecationLevel.WARNING
 )
+public fun TestScope.runTest(
+    dispatchTimeoutMs: Long,
+    testBody: suspend TestScope.() -> Unit
+): TestResult = asSpecificImplementation().let {
+    it.enter()
+    @Suppress("DEPRECATION")
+    createTestResult {
+        runTestCoroutineLegacy(it, dispatchTimeoutMs.milliseconds, TestScopeImpl::tryGetCompletionCause, testBody) {
+            backgroundScope.cancel()
+            testScheduler.advanceUntilIdleOr { false }
+            it.legacyLeave()
+        }
+    }
+}
 
 /**
  * Runs [testProcedure], creating a [TestResult].
@@ -327,18 +419,23 @@
 /** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
  * a [TestCoroutineScheduler]. */
 internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
-internal val DEFAULT_DISPATCH_TIMEOUT = DEFAULT_DISPATCH_TIMEOUT_MS.milliseconds
+
+/**
+ * The default timeout to use when running a test.
+ */
+internal val DEFAULT_TIMEOUT = 10.seconds
 
 /**
  * Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
- * [dispatchTimeout], and performing the [cleanup] procedure at the end.
+ * [dispatchTimeout] and performing the [cleanup] procedure at the end.
  *
  * [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
  *
  * The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
  * return a list of uncaught exceptions that should be reported at the end of the test.
  */
-internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutine(
+@Deprecated("Used for support of legacy behavior")
+internal suspend fun <T : AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutineLegacy(
     coroutine: T,
     dispatchTimeout: Duration,
     tryGetCompletionCause: T.() -> Throwable?,
@@ -351,6 +448,8 @@
         testBody()
     }
     /**
+     * This is the legacy behavior, kept for now for compatibility only.
+     *
      * The general procedure here is as follows:
      * 1. Try running the work that the scheduler knows about, both background and foreground.
      *
@@ -376,16 +475,22 @@
         scheduler.advanceUntilIdle()
         if (coroutine.isCompleted) {
             /* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
-               non-trivial dispatches. */
+           non-trivial dispatches. */
             completed = true
             continue
         }
         // in case progress depends on some background work, we need to keep spinning it.
         val backgroundWorkRunner = launch(CoroutineName("background work runner")) {
             while (true) {
-                scheduler.tryRunNextTaskUnless { !isActive }
-                // yield so that the `select` below has a chance to check if its conditions are fulfilled
-                yield()
+                val executedSomething = scheduler.tryRunNextTaskUnless { !isActive }
+                if (executedSomething) {
+                    // yield so that the `select` below has a chance to finish successfully or time out
+                    yield()
+                } else {
+                    // no more tasks, we should suspend until there are some more.
+                    // this doesn't interfere with the `select` below, because different channels are used.
+                    scheduler.receiveDispatchEvent()
+                }
             }
         }
         try {
@@ -394,11 +499,11 @@
                     // observe that someone completed the test coroutine and leave without waiting for the timeout
                     completed = true
                 }
-                scheduler.onDispatchEvent {
+                scheduler.onDispatchEventForeground {
                     // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
                 }
                 onTimeout(dispatchTimeout) {
-                    handleTimeout(coroutine, dispatchTimeout, tryGetCompletionCause, cleanup)
+                    throw handleTimeout(coroutine, dispatchTimeout, tryGetCompletionCause, cleanup)
                 }
             }
         } finally {
@@ -412,21 +517,20 @@
             // it's normal that some jobs are not completed if the test body has failed, won't clutter the output
             emptyList()
         }
-        (listOf(exception) + exceptions).throwAll()
+        throwAll(exception, exceptions)
     }
-    cleanup().throwAll()
+    throwAll(null, cleanup())
 }
 
 /**
- * Invoked on timeout in [runTest]. Almost always just builds a nice [UncompletedCoroutinesError] and throws it.
- * However, sometimes it detects that the coroutine completed, in which case it returns normally.
+ * Invoked on timeout in [runTest]. Just builds a nice [UncompletedCoroutinesError] and returns it.
  */
-private inline fun<T: AbstractCoroutine<Unit>> handleTimeout(
+private inline fun <T : AbstractCoroutine<Unit>> handleTimeout(
     coroutine: T,
     dispatchTimeout: Duration,
     tryGetCompletionCause: T.() -> Throwable?,
     cleanup: () -> List<Throwable>,
-) {
+): AssertionError {
     val uncaughtExceptions = try {
         cleanup()
     } catch (e: UncompletedCoroutinesError) {
@@ -441,20 +545,29 @@
     if (activeChildren.isNotEmpty())
         message += ", there were active child jobs: $activeChildren"
     if (completionCause != null && activeChildren.isEmpty()) {
-        if (coroutine.isCompleted)
-            return
-        // TODO: can this really ever happen?
-        message += ", the test coroutine was not completed"
+        message += if (coroutine.isCompleted)
+            ", the test coroutine completed"
+        else
+            ", the test coroutine was not completed"
     }
     val error = UncompletedCoroutinesError(message)
     completionCause?.let { cause -> error.addSuppressed(cause) }
     uncaughtExceptions.forEach { error.addSuppressed(it) }
-    throw error
+    return error
 }
 
-internal fun List<Throwable>.throwAll() {
-    firstOrNull()?.apply {
-        drop(1).forEach { addSuppressed(it) }
-        throw this
+internal fun throwAll(head: Throwable?, other: List<Throwable>) {
+    if (head != null) {
+        other.forEach { head.addSuppressed(it) }
+        throw head
+    } else {
+        with(other) {
+            firstOrNull()?.apply {
+                drop(1).forEach { addSuppressed(it) }
+                throw this
+            }
+        }
     }
 }
+
+internal expect fun dumpCoroutines()
diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
index 5f7198c..cdb669c 100644
--- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
+++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
@@ -13,6 +13,7 @@
 import kotlin.coroutines.*
 import kotlin.jvm.*
 import kotlin.time.*
+import kotlin.time.Duration.Companion.milliseconds
 
 /**
  * This is a scheduler for coroutines used in tests, providing the delay-skipping behavior.
@@ -49,6 +50,9 @@
         get() = synchronized(lock) { field }
         private set
 
+    /** A channel for notifying about the fact that a foreground work dispatch recently happened. */
+    private val dispatchEventsForeground: Channel<Unit> = Channel(CONFLATED)
+
     /** A channel for notifying about the fact that a dispatch recently happened. */
     private val dispatchEvents: Channel<Unit> = Channel(CONFLATED)
 
@@ -73,8 +77,8 @@
             val time = addClamping(currentTime, timeDeltaMillis)
             val event = TestDispatchEvent(dispatcher, count, time, marker as Any, isForeground) { isCancelled(marker) }
             events.addLast(event)
-            /** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's
-             * actually anything in the event queue. */
+            /** can't be moved above: otherwise, [onDispatchEventForeground] or [onDispatchEvent] could consume the
+             * token sent here before there's actually anything in the event queue. */
             sendDispatchEvent(context)
             DisposableHandle {
                 synchronized(lock) {
@@ -150,13 +154,22 @@
      * * Overflowing the target time used to lead to nothing being done, but will now run the tasks scheduled at up to
      *   (but not including) [Long.MAX_VALUE].
      *
-     * @throws IllegalStateException if passed a negative [delay][delayTimeMillis].
+     * @throws IllegalArgumentException if passed a negative [delay][delayTimeMillis].
      */
     @ExperimentalCoroutinesApi
-    public fun advanceTimeBy(delayTimeMillis: Long) {
-        require(delayTimeMillis >= 0) { "Can not advance time by a negative delay: $delayTimeMillis" }
+    public fun advanceTimeBy(delayTimeMillis: Long): Unit = advanceTimeBy(delayTimeMillis.milliseconds)
+
+    /**
+     * Moves the virtual clock of this dispatcher forward by [the specified amount][delayTime], running the
+     * scheduled tasks in the meantime.
+     *
+     * @throws IllegalArgumentException if passed a negative [delay][delayTime].
+     */
+    @ExperimentalCoroutinesApi
+    public fun advanceTimeBy(delayTime: Duration) {
+        require(!delayTime.isNegative()) { "Can not advance time by a negative delay: $delayTime" }
         val startingTime = currentTime
-        val targetTime = addClamping(startingTime, delayTimeMillis)
+        val targetTime = addClamping(startingTime, delayTime.inWholeMilliseconds)
         while (true) {
             val event = synchronized(lock) {
                 val timeMark = currentTime
@@ -191,16 +204,27 @@
      * [context] is the context in which the task will be dispatched.
      */
     internal fun sendDispatchEvent(context: CoroutineContext) {
+        dispatchEvents.trySend(Unit)
         if (context[BackgroundWork] !== BackgroundWork)
-            dispatchEvents.trySend(Unit)
+            dispatchEventsForeground.trySend(Unit)
     }
 
     /**
+     * Waits for a notification about a dispatch event.
+     */
+    internal suspend fun receiveDispatchEvent() = dispatchEvents.receive()
+
+    /**
      * Consumes the knowledge that a dispatch event happened recently.
      */
     internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
 
     /**
+     * Consumes the knowledge that a foreground work dispatch event happened recently.
+     */
+    internal val onDispatchEventForeground: SelectClause1<Unit> get() = dispatchEventsForeground.onReceive
+
+    /**
      * Returns the [TimeSource] representation of the virtual time of this scheduler.
      */
     @ExperimentalCoroutinesApi
diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt
index 15d48a2..a301ff9 100644
--- a/kotlinx-coroutines-test/common/src/TestScope.kt
+++ b/kotlinx-coroutines-test/common/src/TestScope.kt
@@ -52,9 +52,9 @@
      * A scope for background work.
      *
      * This scope is automatically cancelled when the test finishes.
-     * Additionally, while the coroutines in this scope are run as usual when
-     * using [advanceTimeBy] and [runCurrent], [advanceUntilIdle] will stop advancing the virtual time
-     * once only the coroutines in this scope are left unprocessed.
+     * The coroutines in this scope are run as usual when using [advanceTimeBy] and [runCurrent].
+     * [advanceUntilIdle], on the other hand, will stop advancing the virtual time once only the coroutines in this
+     * scope are left unprocessed.
      *
      * Failures in coroutines in this scope do not terminate the test.
      * Instead, they are reported at the end of the test.
@@ -124,6 +124,16 @@
 public fun TestScope.advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis)
 
 /**
+ * Moves the virtual clock of this dispatcher forward by [the specified amount][delayTime], running the
+ * scheduled tasks in the meantime.
+ *
+ * @throws IllegalStateException if passed a negative [delay][delayTime].
+ * @see TestCoroutineScheduler.advanceTimeBy
+ */
+@ExperimentalCoroutinesApi
+public fun TestScope.advanceTimeBy(delayTime: Duration): Unit = testScheduler.advanceTimeBy(delayTime)
+
+/**
  * The [test scheduler][TestScope.testScheduler] as a [TimeSource].
  * @see TestCoroutineScheduler.timeSource
  */
@@ -230,8 +240,15 @@
         }
     }
 
+    /** Called at the end of the test. May only be called once. Returns the list of caught unhandled exceptions. */
+    fun leave(): List<Throwable> = synchronized(lock) {
+        check(entered && !finished)
+        finished = true
+        uncaughtExceptions
+    }
+
     /** Called at the end of the test. May only be called once. */
-    fun leave(): List<Throwable> {
+    fun legacyLeave(): List<Throwable> {
         val exceptions = synchronized(lock) {
             check(entered && !finished)
             finished = true
diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt
index 0315543..183eb8c 100644
--- a/kotlinx-coroutines-test/common/test/RunTestTest.kt
+++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt
@@ -9,6 +9,8 @@
 import kotlinx.coroutines.flow.*
 import kotlin.coroutines.*
 import kotlin.test.*
+import kotlin.time.*
+import kotlin.time.Duration.Companion.milliseconds
 
 class RunTestTest {
 
@@ -52,7 +54,7 @@
 
     /** Tests that even the dispatch timeout of `0` is fine if all the dispatches go through the same scheduler. */
     @Test
-    fun testRunTestWithZeroTimeoutWithControlledDispatches() = runTest(dispatchTimeoutMs = 0) {
+    fun testRunTestWithZeroDispatchTimeoutWithControlledDispatches() = runTest(dispatchTimeoutMs = 0) {
         // below is some arbitrary concurrent code where all dispatches go through the same scheduler.
         launch {
             delay(2000)
@@ -71,8 +73,13 @@
 
     /** Tests that too low of a dispatch timeout causes crashes. */
     @Test
-    fun testRunTestWithSmallTimeout() = testResultMap({ fn ->
-        assertFailsWith<UncompletedCoroutinesError> { fn() }
+    fun testRunTestWithSmallDispatchTimeout() = testResultMap({ fn ->
+        try {
+            fn()
+            fail("shouldn't be reached")
+        } catch (e: Throwable) {
+            assertIs<UncompletedCoroutinesError>(e)
+        }
     }) {
         runTest(dispatchTimeoutMs = 100) {
             withContext(Dispatchers.Default) {
@@ -83,6 +90,48 @@
         }
     }
 
+    /**
+     * Tests that [runTest] times out after the specified time.
+     */
+    @Test
+    fun testRunTestWithSmallTimeout() = testResultMap({ fn ->
+        try {
+            fn()
+            fail("shouldn't be reached")
+        } catch (e: Throwable) {
+            assertIs<UncompletedCoroutinesError>(e)
+        }
+    }) {
+        runTest(timeout = 100.milliseconds) {
+            withContext(Dispatchers.Default) {
+                delay(10000)
+                3
+            }
+            fail("shouldn't be reached")
+        }
+    }
+
+    /** Tests that [runTest] times out after the specified time, even if the test framework always knows the test is
+     * still doing something. */
+    @Test
+    fun testRunTestWithSmallTimeoutAndManyDispatches() = testResultMap({ fn ->
+        try {
+            fn()
+            fail("shouldn't be reached")
+        } catch (e: Throwable) {
+            assertIs<UncompletedCoroutinesError>(e)
+        }
+    }) {
+        runTest(timeout = 100.milliseconds) {
+            while (true) {
+                withContext(Dispatchers.Default) {
+                    delay(10)
+                    3
+                }
+            }
+        }
+    }
+
     /** Tests that, on timeout, the names of the active coroutines are listed,
      * whereas the names of the completed ones are not. */
     @Test
@@ -119,26 +168,33 @@
         } catch (e: UncompletedCoroutinesError) {
             @Suppress("INVISIBLE_MEMBER")
             val suppressed = unwrap(e).suppressedExceptions
-            assertEquals(1, suppressed.size)
+            assertEquals(1, suppressed.size, "$suppressed")
             assertIs<TestException>(suppressed[0]).also {
                 assertEquals("A", it.message)
             }
         }
     }) {
-        runTest(dispatchTimeoutMs = 10) {
-            launch {
-                withContext(NonCancellable) {
-                    awaitCancellation()
+        runTest(timeout = 10.milliseconds) {
+            launch(start = CoroutineStart.UNDISPATCHED) {
+                withContext(NonCancellable + Dispatchers.Default) {
+                    delay(100.milliseconds)
                 }
             }
-            yield()
             throw TestException("A")
         }
     }
 
     /** Tests that real delays can be accounted for with a large enough dispatch timeout. */
     @Test
-    fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) {
+    fun testRunTestWithLargeDispatchTimeout() = runTest(dispatchTimeoutMs = 5000) {
+        withContext(Dispatchers.Default) {
+            delay(50)
+        }
+    }
+
+    /** Tests that delays can be accounted for with a large enough timeout. */
+    @Test
+    fun testRunTestWithLargeTimeout() = runTest(timeout = 5000.milliseconds) {
         withContext(Dispatchers.Default) {
             delay(50)
         }
@@ -153,13 +209,13 @@
         } catch (e: UncompletedCoroutinesError) {
             @Suppress("INVISIBLE_MEMBER")
             val suppressed = unwrap(e).suppressedExceptions
-            assertEquals(1, suppressed.size)
+            assertEquals(1, suppressed.size, "$suppressed")
             assertIs<TestException>(suppressed[0]).also {
                 assertEquals("A", it.message)
             }
         }
     }) {
-        runTest(dispatchTimeoutMs = 1) {
+        runTest(timeout = 1.milliseconds) {
             coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
             withContext(Dispatchers.Default) {
                 delay(10000)
@@ -324,7 +380,7 @@
         }
     }
 
-    /** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */
+    /** Tests that [TestScope.runTest] does not inherit the exception handler and works. */
     @Test
     fun testScopeRunTestExceptionHandler(): TestResult {
         val scope = TestScope()
@@ -349,7 +405,7 @@
      * The test will hang if this is not the case.
      */
     @Test
-    fun testCoroutineCompletingWithoutDispatch() = runTest(dispatchTimeoutMs = Long.MAX_VALUE) {
+    fun testCoroutineCompletingWithoutDispatch() = runTest(timeout = Duration.INFINITE) {
         launch(Dispatchers.Default) { delay(100) }
     }
 }
diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt
index 9e9c93e..280d668 100644
--- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt
+++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt
@@ -20,7 +20,7 @@
     @AfterTest
     fun cleanup() {
         scope.runCurrent()
-        assertEquals(listOf(), scope.asSpecificImplementation().leave())
+        assertEquals(listOf(), scope.asSpecificImplementation().legacyLeave())
     }
 
     /** Tests that the [StandardTestDispatcher] follows an execution order similar to `runBlocking`. */
diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt
index d050e9c..7203dbd 100644
--- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt
+++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt
@@ -8,6 +8,7 @@
 import kotlin.test.*
 import kotlin.time.*
 import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Duration.Companion.milliseconds
 
 class TestCoroutineSchedulerTest {
     /** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */
@@ -28,7 +29,7 @@
             delay(15)
             entered = true
         }
-        testScheduler.advanceTimeBy(15)
+        testScheduler.advanceTimeBy(15.milliseconds)
         assertFalse(entered)
         testScheduler.runCurrent()
         assertTrue(entered)
@@ -39,7 +40,7 @@
     fun testAdvanceTimeByWithNegativeDelay() {
         val scheduler = TestCoroutineScheduler()
         assertFailsWith<IllegalArgumentException> {
-            scheduler.advanceTimeBy(-1)
+            scheduler.advanceTimeBy((-1).milliseconds)
         }
     }
 
@@ -65,7 +66,7 @@
                         assertEquals(Long.MAX_VALUE - 1, currentTime)
                         enteredNearInfinity = true
                     }
-                    testScheduler.advanceTimeBy(Long.MAX_VALUE)
+                    testScheduler.advanceTimeBy(Duration.INFINITE)
                     assertFalse(enteredInfinity)
                     assertTrue(enteredNearInfinity)
                     assertEquals(Long.MAX_VALUE, currentTime)
@@ -95,10 +96,10 @@
             }
             assertEquals(1, stage)
             assertEquals(0, currentTime)
-            advanceTimeBy(2_000)
+            advanceTimeBy(2.seconds)
             assertEquals(3, stage)
             assertEquals(2_000, currentTime)
-            advanceTimeBy(2)
+            advanceTimeBy(2.milliseconds)
             assertEquals(4, stage)
             assertEquals(2_002, currentTime)
         }
@@ -120,11 +121,11 @@
             delay(1)
             stage += 10
         }
-        testScheduler.advanceTimeBy(1)
+        testScheduler.advanceTimeBy(1.milliseconds)
         assertEquals(0, stage)
         runCurrent()
         assertEquals(2, stage)
-        testScheduler.advanceTimeBy(1)
+        testScheduler.advanceTimeBy(1.milliseconds)
         assertEquals(2, stage)
         runCurrent()
         assertEquals(22, stage)
@@ -143,10 +144,10 @@
                     delay(SLOW)
                     stage = 3
                 }
-                scheduler.advanceTimeBy(SLOW)
+                scheduler.advanceTimeBy(SLOW.milliseconds)
                 stage = 2
             }
-            scheduler.advanceTimeBy(SLOW)
+            scheduler.advanceTimeBy(SLOW.milliseconds)
             assertEquals(1, stage)
             scheduler.runCurrent()
             assertEquals(2, stage)
@@ -249,7 +250,7 @@
             }
         }
         advanceUntilIdle()
-        asSpecificImplementation().leave().throwAll()
+        throwAll(null, asSpecificImplementation().legacyLeave())
         if (timesOut)
             assertTrue(caughtException)
         else
diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt
index d46e5a2..a21e916 100644
--- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt
+++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt
@@ -9,6 +9,7 @@
 import kotlinx.coroutines.flow.*
 import kotlin.coroutines.*
 import kotlin.test.*
+import kotlin.time.Duration.Companion.milliseconds
 
 class TestScopeTest {
     /** Tests failing to create a [TestScope] with incorrect contexts. */
@@ -95,7 +96,7 @@
         }
         assertFalse(result)
         scope.asSpecificImplementation().enter()
-        assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().leave() }
+        assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().legacyLeave() }
         assertFalse(result)
     }
 
@@ -111,7 +112,7 @@
         }
         assertFalse(result)
         scope.asSpecificImplementation().enter()
-        assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().leave() }
+        assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().legacyLeave() }
         assertFalse(result)
     }
 
@@ -128,7 +129,7 @@
         job.cancel()
         assertFalse(result)
         scope.asSpecificImplementation().enter()
-        assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().leave() }
+        assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().legacyLeave() }
         assertFalse(result)
     }
 
@@ -162,7 +163,7 @@
             launch(SupervisorJob()) { throw TestException("y") }
             launch(SupervisorJob()) { throw TestException("z") }
             runCurrent()
-            val e = asSpecificImplementation().leave()
+            val e = asSpecificImplementation().legacyLeave()
             assertEquals(3, e.size)
             assertEquals("x", e[0].message)
             assertEquals("y", e[1].message)
@@ -249,7 +250,7 @@
             assertEquals(1, j)
         }
         job.join()
-        advanceTimeBy(199) // should work the same for the background tasks
+        advanceTimeBy(199.milliseconds) // should work the same for the background tasks
         assertEquals(2, i)
         assertEquals(4, j)
         advanceUntilIdle() // once again, should do nothing
@@ -377,7 +378,7 @@
 
         }
     }) {
-        runTest(dispatchTimeoutMs = 100) {
+        runTest(timeout = 100.milliseconds) {
             backgroundScope.launch {
                 while (true) {
                     yield()
@@ -407,7 +408,7 @@
 
         }
     }) {
-        runTest(UnconfinedTestDispatcher(), dispatchTimeoutMs = 100) {
+        runTest(UnconfinedTestDispatcher(), timeout = 100.milliseconds) {
             /**
              * Having a coroutine like this will still cause the test to hang:
                  backgroundScope.launch {
diff --git a/kotlinx-coroutines-test/js/src/TestBuilders.kt b/kotlinx-coroutines-test/js/src/TestBuilders.kt
index 9da91ff..97c9da0 100644
--- a/kotlinx-coroutines-test/js/src/TestBuilders.kt
+++ b/kotlinx-coroutines-test/js/src/TestBuilders.kt
@@ -13,3 +13,5 @@
     GlobalScope.promise {
         testProcedure()
     }
+
+internal actual fun dumpCoroutines() { }
diff --git a/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt
index 06fbe81..0521fd2 100644
--- a/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt
+++ b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt
@@ -4,6 +4,7 @@
 package kotlinx.coroutines.test
 
 import kotlinx.coroutines.*
+import kotlinx.coroutines.debug.internal.*
 
 @Suppress("ACTUAL_WITHOUT_EXPECT")
 public actual typealias TestResult = Unit
@@ -13,3 +14,16 @@
         testProcedure()
     }
 }
+
+internal actual fun dumpCoroutines() {
+    @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+    if (DebugProbesImpl.isInstalled) {
+        DebugProbesImpl.install()
+        try {
+            DebugProbesImpl.dumpCoroutines(System.err)
+            System.err.flush()
+        } finally {
+            DebugProbesImpl.uninstall()
+        }
+    }
+}
diff --git a/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt
index c0d6c17..c7ef9cc 100644
--- a/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt
+++ b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt
@@ -50,10 +50,12 @@
  *        then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively.
  * @param testBody The code of the unit-test.
  */
-@Deprecated("Use `runTest` instead to support completing from other dispatchers. " +
-    "Please see the migration guide for details: " +
-    "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
-    level = DeprecationLevel.WARNING)
+@Deprecated(
+    "Use `runTest` instead to support completing from other dispatchers. " +
+        "Please see the migration guide for details: " +
+        "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
+    level = DeprecationLevel.WARNING
+)
 // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
 public fun runBlockingTest(
     context: CoroutineContext = EmptyCoroutineContext,
@@ -91,20 +93,20 @@
     val throwable = try {
         scope.getCompletionExceptionOrNull()
     } catch (e: IllegalStateException) {
-        null // the deferred was not completed yet; `scope.leave()` should complain then about unfinished jobs
+        null // the deferred was not completed yet; `scope.legacyLeave()` should complain then about unfinished jobs
     }
     scope.backgroundScope.cancel()
     scope.testScheduler.advanceUntilIdleOr { false }
     throwable?.let {
         val exceptions = try {
-            scope.leave()
+            scope.legacyLeave()
         } catch (e: UncompletedCoroutinesError) {
             listOf()
         }
-        (listOf(it) + exceptions).throwAll()
+        throwAll(it, exceptions)
         return
     }
-    scope.leave().throwAll()
+    throwAll(null, scope.legacyLeave())
     val jobs = completeContext.activeJobs() - startJobs
     if (jobs.isNotEmpty())
         throw UncompletedCoroutinesError("Some jobs were not completed at the end of the test: $jobs")
@@ -118,10 +120,12 @@
  * [migration guide](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md)
  * for an instruction on how to update the code for the new API.
  */
-@Deprecated("Use `runTest` instead to support completing from other dispatchers. " +
-    "Please see the migration guide for details: " +
-    "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
-    level = DeprecationLevel.WARNING)
+@Deprecated(
+    "Use `runTest` instead to support completing from other dispatchers. " +
+        "Please see the migration guide for details: " +
+        "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
+    level = DeprecationLevel.WARNING
+)
 // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
 public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
     runBlockingTest(coroutineContext, block)
@@ -142,10 +146,12 @@
  * [migration guide](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md)
  * for an instruction on how to update the code for the new API.
  */
-@Deprecated("Use `runTest` instead to support completing from other dispatchers. " +
-    "Please see the migration guide for details: " +
-    "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
-    level = DeprecationLevel.WARNING)
+@Deprecated(
+    "Use `runTest` instead to support completing from other dispatchers. " +
+        "Please see the migration guide for details: " +
+        "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
+    level = DeprecationLevel.WARNING
+)
 // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
 public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
     runBlockingTest(this, block)
@@ -165,7 +171,12 @@
         throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
     val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
     return createTestResult {
-        runTestCoroutine(testScope, dispatchTimeoutMs.milliseconds, TestBodyCoroutine::tryGetCompletionCause, testBody) {
+        runTestCoroutineLegacy(
+            testScope,
+            dispatchTimeoutMs.milliseconds,
+            TestBodyCoroutine::tryGetCompletionCause,
+            testBody
+        ) {
             try {
                 testScope.cleanup()
                 emptyList()
diff --git a/kotlinx-coroutines-test/jvm/test/DumpOnTimeoutTest.kt b/kotlinx-coroutines-test/jvm/test/DumpOnTimeoutTest.kt
new file mode 100644
index 0000000..814e5f0
--- /dev/null
+++ b/kotlinx-coroutines-test/jvm/test/DumpOnTimeoutTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2016-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.test
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.debug.*
+import org.junit.Test
+import java.io.*
+import kotlin.test.*
+import kotlin.time.Duration.Companion.milliseconds
+
+class DumpOnTimeoutTest {
+    /**
+     * Tests that the dump on timeout contains the correct stacktrace.
+     */
+    @Test
+    fun testDumpOnTimeout() {
+        val oldErr = System.err
+        val baos = ByteArrayOutputStream()
+        try {
+            System.setErr(PrintStream(baos, true))
+            DebugProbes.withDebugProbes {
+                try {
+                    runTest(timeout = 100.milliseconds) {
+                        uniquelyNamedFunction()
+                    }
+                    throw IllegalStateException("unreachable")
+                } catch (e: UncompletedCoroutinesError) {
+                    // do nothing
+                }
+            }
+            baos.toString().let {
+                assertTrue(it.contains("uniquelyNamedFunction"), "Actual trace:\n$it")
+            }
+        } finally {
+            System.setErr(oldErr)
+        }
+    }
+
+    fun CoroutineScope.uniquelyNamedFunction() {
+        while (true) {
+            ensureActive()
+            Thread.sleep(10)
+        }
+    }
+}
diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt
index 90a16d0..2ac577c 100644
--- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt
+++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt
@@ -99,14 +99,24 @@
         }
     }
 
-    /** Tests that [StandardTestDispatcher] is confined to the thread that interacts with the scheduler. */
+    /** Tests that [StandardTestDispatcher] is not executed in-place but confined to the thread in which the
+     * virtual time control happens. */
     @Test
-    fun testStandardTestDispatcherIsConfined() = runTest {
+    fun testStandardTestDispatcherIsConfined(): Unit = runBlocking {
+        val scheduler = TestCoroutineScheduler()
         val initialThread = Thread.currentThread()
-        withContext(Dispatchers.IO) {
-            val ioThread = Thread.currentThread()
-            assertNotSame(initialThread, ioThread)
+        val job = launch(StandardTestDispatcher(scheduler)) {
+            assertEquals(initialThread, Thread.currentThread())
+            withContext(Dispatchers.IO) {
+                val ioThread = Thread.currentThread()
+                assertNotSame(initialThread, ioThread)
+            }
+            assertEquals(initialThread, Thread.currentThread())
         }
-        assertEquals(initialThread, Thread.currentThread())
+        scheduler.advanceUntilIdle()
+        while (job.isActive) {
+            scheduler.receiveDispatchEvent()
+            scheduler.advanceUntilIdle()
+        }
     }
 }
diff --git a/kotlinx-coroutines-test/native/src/TestBuilders.kt b/kotlinx-coroutines-test/native/src/TestBuilders.kt
index a959901..607dec6 100644
--- a/kotlinx-coroutines-test/native/src/TestBuilders.kt
+++ b/kotlinx-coroutines-test/native/src/TestBuilders.kt
@@ -4,6 +4,7 @@
 
 package kotlinx.coroutines.test
 import kotlinx.coroutines.*
+import kotlin.native.concurrent.*
 
 @Suppress("ACTUAL_WITHOUT_EXPECT")
 public actual typealias TestResult = Unit
@@ -13,3 +14,5 @@
         testProcedure()
     }
 }
+
+internal actual fun dumpCoroutines() { }