Skip to content

Commit 5ad2246

Browse files
committed
Fixes
1 parent a4d5061 commit 5ad2246

File tree

3 files changed

+48
-40
lines changed

3 files changed

+48
-40
lines changed

kotlinx-coroutines-core/common/src/internal/StackTraceRecovery.common.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ internal expect suspend inline fun recoverAndThrow(exception: Throwable): Nothin
4040
* The opposite of [recoverStackTrace].
4141
* It is guaranteed that `unwrap(recoverStackTrace(e)) === e`
4242
*/
43-
@PublishedApi
43+
@PublishedApi // only published for the multiplatform tests in our own code
4444
internal expect fun <E: Throwable> unwrap(exception: E): E
4545

4646
internal expect class StackTraceElement

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

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,11 @@ public fun runTest(
161161
public fun TestScope.runTest(
162162
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
163163
testBody: suspend TestScope.() -> Unit
164-
): TestResult = asSpecificImplementation().let {
165-
it.enter()
164+
): TestResult = with(asSpecificImplementation()) {
165+
enter()
166+
start(CoroutineStart.UNDISPATCHED, this, testBody)
166167
createTestResult {
167-
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) { it.leave() }
168+
runTestCoroutine(this, dispatchTimeoutMs, { tryGetCompletionCause() }) { leave() }
168169
}
169170
}
170171

@@ -190,22 +191,18 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
190191
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
191192
* [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end.
192193
*
194+
* [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
195+
*
193196
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
194197
* return a list of uncaught exceptions that should be reported at the end of the test.
195198
*/
196-
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
197-
coroutine: T,
199+
internal suspend fun runTestCoroutine(
200+
coroutine: AbstractCoroutine<Unit>,
198201
dispatchTimeoutMs: Long,
199-
tryGetCompletionCause: T.() -> Throwable?,
200-
testBody: suspend T.() -> Unit,
202+
tryGetCompletionCause: () -> Throwable?,
201203
cleanup: () -> List<Throwable>,
202204
) {
203205
val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!!
204-
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
205-
* [TestCoroutineDispatcher], because the event loop is not started. */
206-
coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) {
207-
testBody()
208-
}
209206
var completed = false
210207
while (!completed) {
211208
scheduler.advanceUntilIdle()
@@ -223,32 +220,7 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
223220
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
224221
}
225222
onTimeout(dispatchTimeoutMs) {
226-
try {
227-
cleanup()
228-
} catch (e: UncompletedCoroutinesError) {
229-
// we expect these and will instead throw a more informative exception just below.
230-
emptyList()
231-
}.throwAll()
232-
var completing: Boolean
233-
val completionCause = try {
234-
coroutine.tryGetCompletionCause().also { completing = true }
235-
} catch (e: Throwable) {
236-
completing = false
237-
null
238-
}
239-
var message = "After waiting for $dispatchTimeoutMs ms"
240-
if (!completing)
241-
message += ", the test coroutine is not completing"
242-
val activeChildren = coroutine.children.filter { it.isActive }.toList()
243-
if (activeChildren.isNotEmpty())
244-
message += ", there were active child jobs: $activeChildren"
245-
if (completing && activeChildren.isEmpty()) {
246-
// some sort of race condition? write something generic.
247-
message += ", the test coroutine was not completed"
248-
}
249-
val error = UncompletedCoroutinesError(message)
250-
completionCause?.let { cause -> error.addSuppressed(cause) }
251-
throw error
223+
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
252224
}
253225
}
254226
}
@@ -264,6 +236,41 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
264236
cleanup().throwAll()
265237
}
266238

239+
/**
240+
* Invoked on timeout in [runTest]. Almost always just builds a nice [UncompletedCoroutinesError] and throws it.
241+
* However, sometimes it detects that the coroutine completed, in which case it returns normally.
242+
*/
243+
private inline fun handleTimeout(
244+
coroutine: AbstractCoroutine<Unit>,
245+
dispatchTimeoutMs: Long,
246+
tryGetCompletionCause: () -> Throwable?,
247+
cleanup: () -> List<Throwable>,
248+
) {
249+
val uncaughtExceptions = try {
250+
cleanup()
251+
} catch (e: UncompletedCoroutinesError) {
252+
// we expect these and will instead throw a more informative exception.
253+
emptyList()
254+
}
255+
val activeChildren = coroutine.children.filter { it.isActive }.toList()
256+
val completionCause = if (coroutine.isCancelled) tryGetCompletionCause() else null
257+
var message = "After waiting for $dispatchTimeoutMs ms"
258+
if (completionCause == null)
259+
message += ", the test coroutine is not completing"
260+
if (activeChildren.isNotEmpty())
261+
message += ", there were active child jobs: $activeChildren"
262+
if (completionCause != null && activeChildren.isEmpty()) {
263+
if (coroutine.isCompleted)
264+
return
265+
// TODO: can this really ever happen?
266+
message += ", the test coroutine was not completed"
267+
}
268+
val error = UncompletedCoroutinesError(message)
269+
completionCause?.let { cause -> error.addSuppressed(cause) }
270+
uncaughtExceptions.forEach { error.addSuppressed(it) }
271+
throw error
272+
}
273+
267274
internal fun List<Throwable>.throwAll() {
268275
firstOrNull()?.apply {
269276
drop(1).forEach { addSuppressed(it) }

kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,9 @@ public fun runTestWithLegacyScope(
137137
if (context[RunningInRunTest] != null)
138138
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
139139
val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
140+
testScope.start(CoroutineStart.UNDISPATCHED, testScope, testBody)
140141
return createTestResult {
141-
runTestCoroutine(testScope, dispatchTimeoutMs, TestBodyCoroutine::tryGetCompletionCause, testBody) {
142+
runTestCoroutine(testScope, dispatchTimeoutMs, { testScope.tryGetCompletionCause() }) {
142143
try {
143144
testScope.cleanup()
144145
emptyList()

0 commit comments

Comments
 (0)