Skip to content

Commit b6e1839

Browse files
authored
Explain the test framework behavior in the withTimeout message (#3623)
Fixes #3588
1 parent f538af6 commit b6e1839

File tree

6 files changed

+52
-9
lines changed

6 files changed

+52
-9
lines changed

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

+13
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ public interface Delay {
5656
DefaultDelay.invokeOnTimeout(timeMillis, block, context)
5757
}
5858

59+
/**
60+
* Enhanced [Delay] interface that provides additional diagnostics for [withTimeout].
61+
* Is going to be removed once there is proper JVM-default support.
62+
* Then we'll be able put this function into [Delay] without breaking binary compatibility.
63+
*/
64+
@InternalCoroutinesApi
65+
internal interface DelayWithTimeoutDiagnostics : Delay {
66+
/**
67+
* Returns a string that explains that the timeout has occurred, and explains what can be done about it.
68+
*/
69+
fun timeoutMessage(timeout: Duration): String
70+
}
71+
5972
/**
6073
* Suspends until cancellation, in which case it will throw a [CancellationException].
6174
*

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

+11-6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kotlin.coroutines.*
1313
import kotlin.coroutines.intrinsics.*
1414
import kotlin.jvm.*
1515
import kotlin.time.*
16+
import kotlin.time.Duration.Companion.milliseconds
1617

1718
/**
1819
* Runs a given suspending [block] of code inside a coroutine with a specified [timeout][timeMillis] and throws
@@ -135,9 +136,9 @@ public suspend fun <T> withTimeoutOrNull(timeMillis: Long, block: suspend Corout
135136
* > Implementation note: how the time is tracked exactly is an implementation detail of the context's [CoroutineDispatcher].
136137
*/
137138
public suspend fun <T> withTimeoutOrNull(timeout: Duration, block: suspend CoroutineScope.() -> T): T? =
138-
withTimeoutOrNull(timeout.toDelayMillis(), block)
139+
withTimeoutOrNull(timeout.toDelayMillis(), block)
139140

140-
private fun <U, T: U> setupTimeout(
141+
private fun <U, T : U> setupTimeout(
141142
coroutine: TimeoutCoroutine<U, T>,
142143
block: suspend CoroutineScope.() -> T
143144
): Any? {
@@ -150,12 +151,12 @@ private fun <U, T: U> setupTimeout(
150151
return coroutine.startUndispatchedOrReturnIgnoreTimeout(coroutine, block)
151152
}
152153

153-
private class TimeoutCoroutine<U, in T: U>(
154+
private class TimeoutCoroutine<U, in T : U>(
154155
@JvmField val time: Long,
155156
uCont: Continuation<U> // unintercepted continuation
156157
) : ScopeCoroutine<T>(uCont.context, uCont), Runnable {
157158
override fun run() {
158-
cancelCoroutine(TimeoutCancellationException(time, this))
159+
cancelCoroutine(TimeoutCancellationException(time, context.delay, this))
159160
}
160161

161162
override fun nameString(): String =
@@ -173,7 +174,6 @@ public class TimeoutCancellationException internal constructor(
173174
* Creates a timeout exception with the given message.
174175
* This constructor is needed for exception stack-traces recovery.
175176
*/
176-
@Suppress("UNUSED")
177177
internal constructor(message: String) : this(message, null)
178178

179179
// message is never null in fact
@@ -183,5 +183,10 @@ public class TimeoutCancellationException internal constructor(
183183

184184
internal fun TimeoutCancellationException(
185185
time: Long,
186+
delay: Delay,
186187
coroutine: Job
187-
) : TimeoutCancellationException = TimeoutCancellationException("Timed out waiting for $time ms", coroutine)
188+
) : TimeoutCancellationException {
189+
val message = (delay as? DelayWithTimeoutDiagnostics)?.timeoutMessage(time.milliseconds)
190+
?: "Timed out waiting for $time ms"
191+
return TimeoutCancellationException(message, coroutine)
192+
}

kotlinx-coroutines-core/common/test/flow/operators/TimeoutTest.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ class TimeoutTest : TestBase() {
9797
fun testUpstreamError() = testUpstreamError(TestException())
9898

9999
@Test
100-
fun testUpstreamErrorTimeoutException() = testUpstreamError(TimeoutCancellationException(0, Job()))
100+
fun testUpstreamErrorTimeoutException() =
101+
testUpstreamError(TimeoutCancellationException("Timed out waiting for ${0} ms", Job()))
101102

102103
@Test
103104
fun testUpstreamErrorCancellationException() = testUpstreamError(CancellationException(""))

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,12 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
9595
public static final fun runCurrent (Lkotlinx/coroutines/test/TestCoroutineScope;)V
9696
}
9797

98-
public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay {
98+
public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/DelayWithTimeoutDiagnostics {
9999
public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
100100
public abstract fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
101101
public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle;
102102
public fun scheduleResumeAfterDelay (JLkotlinx/coroutines/CancellableContinuation;)V
103+
public synthetic fun timeoutMessage-LRDsOJo (J)Ljava/lang/String;
103104
}
104105

105106
public final class kotlinx/coroutines/test/TestDispatchers {

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package kotlinx.coroutines.test
77
import kotlinx.coroutines.*
88
import kotlin.coroutines.*
99
import kotlin.jvm.*
10+
import kotlin.time.*
1011

1112
/**
1213
* A test dispatcher that can interface with a [TestCoroutineScheduler].
@@ -17,7 +18,8 @@ import kotlin.jvm.*
1718
* the virtual time.
1819
*/
1920
@ExperimentalCoroutinesApi
20-
public abstract class TestDispatcher internal constructor() : CoroutineDispatcher(), Delay {
21+
@Suppress("INVISIBLE_REFERENCE")
22+
public abstract class TestDispatcher internal constructor() : CoroutineDispatcher(), Delay, DelayWithTimeoutDiagnostics {
2123
/** The scheduler that this dispatcher is linked to. */
2224
@ExperimentalCoroutinesApi
2325
public abstract val scheduler: TestCoroutineScheduler
@@ -44,6 +46,13 @@ public abstract class TestDispatcher internal constructor() : CoroutineDispatche
4446
/** @suppress */
4547
override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle =
4648
scheduler.registerEvent(this, timeMillis, block, context) { false }
49+
50+
/** @suppress */
51+
@Suppress("CANNOT_OVERRIDE_INVISIBLE_MEMBER")
52+
@Deprecated("Is only needed internally", level = DeprecationLevel.HIDDEN)
53+
public override fun timeoutMessage(timeout: Duration): String =
54+
"Timed out after $timeout of _virtual_ (kotlinx.coroutines.test) time. " +
55+
"To use the real time, wrap 'withTimeout' in 'withContext(Dispatchers.Default.limitedParallelism(1))'"
4756
}
4857

4958
/**

kotlinx-coroutines-test/common/test/TestScopeTest.kt

+14
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,20 @@ class TestScopeTest {
476476
}
477477
}
478478

479+
/**
480+
* Tests that [TestScope.withTimeout] notifies the programmer about using the virtual time.
481+
*/
482+
@Test
483+
fun testTimingOutWithVirtualTimeMessage() = runTest {
484+
try {
485+
withTimeout(1_000_000) {
486+
Channel<Unit>().receive()
487+
}
488+
} catch (e: TimeoutCancellationException) {
489+
assertContains(e.message!!, "virtual")
490+
}
491+
}
492+
479493
companion object {
480494
internal val invalidContexts = listOf(
481495
Dispatchers.Default, // not a [TestDispatcher]

0 commit comments

Comments
 (0)