Skip to content

Commit f869427

Browse files
committed
Indicate the use of virtual time in withTimeout in TestScope
Fixes #3588
1 parent eb21974 commit f869427

File tree

3 files changed

+107
-1
lines changed

3 files changed

+107
-1
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ private class TimeoutCoroutine<U, in T: U>(
165165
/**
166166
* This exception is thrown by [withTimeout] to indicate timeout.
167167
*/
168-
public class TimeoutCancellationException internal constructor(
168+
public class TimeoutCancellationException @PublishedApi internal constructor(
169169
message: String,
170170
@JvmField @Transient internal val coroutine: Job?
171171
) : CancellationException(message), CopyableThrowable<TimeoutCancellationException> {

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

+92
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
88
import kotlinx.coroutines.internal.*
9+
import kotlinx.coroutines.selects.*
910
import kotlinx.coroutines.test.internal.*
11+
import kotlin.contracts.*
1012
import kotlin.coroutines.*
1113
import kotlin.time.*
1214

@@ -296,3 +298,93 @@ internal class UncaughtExceptionsBeforeTest : IllegalStateException(
296298
*/
297299
@ExperimentalCoroutinesApi
298300
internal class UncompletedCoroutinesError(message: String) : AssertionError(message)
301+
302+
/**
303+
* This is an override of [kotlinx.coroutines.withTimeout] for [TestScope] that appends a note to
304+
* [TimeoutCancellationException] saying that the timeout was affected by the virtual time.
305+
* Below is the documentation for the original function.
306+
*
307+
* Runs a given suspending [block] of code inside a coroutine with a specified [timeout][timeMillis] and throws
308+
* a [TimeoutCancellationException] if the timeout was exceeded.
309+
* If the given [timeMillis] is non-positive, [TimeoutCancellationException] is thrown immediately.
310+
*
311+
* The code that is executing inside the [block] is cancelled on timeout and the active or next invocation of
312+
* the cancellable suspending function inside the block throws a [TimeoutCancellationException].
313+
*
314+
* The sibling function that does not throw an exception on timeout is [withTimeoutOrNull].
315+
* Note that the timeout action can be specified for a [select] invocation with [onTimeout][SelectBuilder.onTimeout] clause.
316+
*
317+
* **The timeout event is asynchronous with respect to the code running in the block** and may happen at any time,
318+
* even right before the return from inside the timeout [block]. Keep this in mind if you open or acquire some
319+
* resource inside the [block] that needs closing or release outside the block.
320+
* See the
321+
* [Asynchronous timeout and resources][https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html#asynchronous-timeout-and-resources]
322+
* section of the coroutines guide for details.
323+
*
324+
* > Implementation note: how the time is tracked exactly is an implementation detail of the context's [CoroutineDispatcher].
325+
*
326+
* @param timeMillis timeout time in milliseconds.
327+
*/
328+
@OptIn(ExperimentalStdlibApi::class, ExperimentalContracts::class)
329+
public suspend fun <T> TestScope.withTimeout(timeMillis: Long, block: suspend CoroutineScope.() -> T): T {
330+
contract {
331+
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
332+
}
333+
try {
334+
return kotlinx.coroutines.withTimeout(timeMillis, block)
335+
} catch (e: TimeoutCancellationException) {
336+
// TODO: check that the virtual time is not disabled, when such an option is introduced
337+
if (currentCoroutineContext()[CoroutineDispatcher] is TestDispatcher) {
338+
// TODO: explain the proper solution when we have the option to disable time controls.
339+
val message = "Timed out after $timeMillis ms of _virtual_ (kotlinx.coroutines.test) time. " +
340+
"To use the real time, wrap 'withTimeout' in 'withContext(Dispatchers.Default.limitedParallelism(1))'"
341+
@Suppress("INVISIBLE_MEMBER")
342+
throw TimeoutCancellationException(message, null).also { it.addSuppressed(e) }
343+
}
344+
throw e
345+
}
346+
}
347+
348+
/**
349+
* This is an override of [kotlinx.coroutines.withTimeout] for [TestScope] that appends a note to
350+
* [TimeoutCancellationException] saying that the timeout was affected by the virtual time.
351+
* Below is the documentation for the original function.
352+
*
353+
* Runs a given suspending [block] of code inside a coroutine with the specified [timeout] and throws
354+
* a [TimeoutCancellationException] if the timeout was exceeded.
355+
* If the given [timeout] is non-positive, [TimeoutCancellationException] is thrown immediately.
356+
*
357+
* The code that is executing inside the [block] is cancelled on timeout and the active or next invocation of
358+
* the cancellable suspending function inside the block throws a [TimeoutCancellationException].
359+
*
360+
* The sibling function that does not throw an exception on timeout is [withTimeoutOrNull].
361+
* Note that the timeout action can be specified for a [select] invocation with [onTimeout][SelectBuilder.onTimeout] clause.
362+
*
363+
* **The timeout event is asynchronous with respect to the code running in the block** and may happen at any time,
364+
* even right before the return from inside the timeout [block]. Keep this in mind if you open or acquire some
365+
* resource inside the [block] that needs closing or release outside the block.
366+
* See the
367+
* [Asynchronous timeout and resources][https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html#asynchronous-timeout-and-resources]
368+
* section of the coroutines guide for details.
369+
*
370+
* > Implementation note: how the time is tracked exactly is an implementation detail of the context's [CoroutineDispatcher].
371+
*/
372+
@OptIn(ExperimentalStdlibApi::class, ExperimentalContracts::class)
373+
public suspend fun <T> TestScope.withTimeout(timeout: Duration, block: suspend CoroutineScope.() -> T): T {
374+
contract {
375+
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
376+
}
377+
try {
378+
return kotlinx.coroutines.withTimeout(timeout, block)
379+
} catch (e: TimeoutCancellationException) {
380+
// TODO: check that the virtual time is not disabled, when such an option is introduced
381+
if (currentCoroutineContext()[CoroutineDispatcher] is TestDispatcher) {
382+
// TODO: explain the proper solution when we have the option to disable time controls.
383+
val message = "Timed out after $timeout of _virtual_ (kotlinx.coroutines.test) time. " +
384+
"To use the real time, wrap 'withTimeout' in 'withContext(Dispatchers.Default.limitedParallelism(1))'"
385+
@Suppress("INVISIBLE_MEMBER")
386+
throw TimeoutCancellationException(message, null).also { it.addSuppressed(e) }
387+
}
388+
throw e
389+
}
390+
}

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)