aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt3
-rw-r--r--kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt7
-rw-r--r--kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.common.kt72
-rw-r--r--kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt12
-rw-r--r--kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt26
-rw-r--r--kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt62
-rw-r--r--kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt49
-rw-r--r--kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt14
-rw-r--r--kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt31
-rw-r--r--kotlinx-coroutines-test/common/src/TestScope.kt12
-rw-r--r--kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt98
-rw-r--r--kotlinx-coroutines-test/common/test/Helpers.kt27
-rw-r--r--kotlinx-coroutines-test/common/test/TestScopeTest.kt56
-rw-r--r--kotlinx-coroutines-test/js/test/Helpers.kt13
-rw-r--r--kotlinx-coroutines-test/jvm/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler1
-rw-r--r--kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt2
-rw-r--r--kotlinx-coroutines-test/jvm/src/module-info.java3
-rw-r--r--kotlinx-coroutines-test/jvm/test/HelpersJvm.kt9
-rw-r--r--kotlinx-coroutines-test/native/test/Helpers.kt9
19 files changed, 396 insertions, 110 deletions
diff --git a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt
index 21fe496b..7253658e 100644
--- a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt
+++ b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt
@@ -27,7 +27,8 @@ class ListAllCoroutineThrowableSubclassesTest {
"kotlinx.coroutines.JobCancellationException",
"kotlinx.coroutines.internal.UndeliveredElementException",
"kotlinx.coroutines.CompletionHandlerException",
- "kotlinx.coroutines.DiagnosticCoroutineContextException",
+ "kotlinx.coroutines.internal.DiagnosticCoroutineContextException",
+ "kotlinx.coroutines.internal.ExceptionSuccessfullyProcessed",
"kotlinx.coroutines.CoroutinesInternalError",
"kotlinx.coroutines.channels.ClosedSendChannelException",
"kotlinx.coroutines.channels.ClosedReceiveChannelException",
diff --git a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt
index d13ef67c..e641447b 100644
--- a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt
+++ b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt
@@ -4,10 +4,9 @@
package kotlinx.coroutines
+import kotlinx.coroutines.internal.*
import kotlin.coroutines.*
-internal expect fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable)
-
/**
* Helper function for coroutine builder implementations to handle uncaught and unexpected exceptions in coroutines,
* that could not be otherwise handled in a normal way through structured concurrency, saving them to a future, and
@@ -26,11 +25,11 @@ public fun handleCoroutineException(context: CoroutineContext, exception: Throwa
return
}
} catch (t: Throwable) {
- handleCoroutineExceptionImpl(context, handlerException(exception, t))
+ handleUncaughtCoroutineException(context, handlerException(exception, t))
return
}
// If a handler is not present in the context or an exception was thrown, fallback to the global handler
- handleCoroutineExceptionImpl(context, exception)
+ handleUncaughtCoroutineException(context, exception)
}
internal fun handlerException(originalException: Throwable, thrownException: Throwable): Throwable {
diff --git a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.common.kt b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.common.kt
new file mode 100644
index 00000000..3f5925a3
--- /dev/null
+++ b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.common.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2016-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.internal
+
+import kotlinx.coroutines.*
+import kotlin.coroutines.*
+
+/**
+ * The list of globally installed [CoroutineExceptionHandler] instances that will be notified of any exceptions that
+ * were not processed in any other manner.
+ */
+internal expect val platformExceptionHandlers: Collection<CoroutineExceptionHandler>
+
+/**
+ * Ensures that the given [callback] is present in the [platformExceptionHandlers] list.
+ */
+internal expect fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler)
+
+/**
+ * The platform-dependent global exception handler, used so that the exception is logged at least *somewhere*.
+ */
+internal expect fun propagateExceptionFinalResort(exception: Throwable)
+
+/**
+ * Deal with exceptions that happened in coroutines and weren't programmatically dealt with.
+ *
+ * First, it notifies every [CoroutineExceptionHandler] in the [platformExceptionHandlers] list.
+ * If one of them throws [ExceptionSuccessfullyProcessed], it means that that handler believes that the exception was
+ * dealt with sufficiently well and doesn't need any further processing.
+ * Otherwise, the platform-dependent global exception handler is also invoked.
+ */
+internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) {
+ // use additional extension handlers
+ for (handler in platformExceptionHandlers) {
+ try {
+ handler.handleException(context, exception)
+ } catch (_: ExceptionSuccessfullyProcessed) {
+ return
+ } catch (t: Throwable) {
+ propagateExceptionFinalResort(handlerException(exception, t))
+ }
+ }
+
+ try {
+ exception.addSuppressed(DiagnosticCoroutineContextException(context))
+ } catch (e: Throwable) {
+ // addSuppressed is never user-defined and cannot normally throw with the only exception being OOM
+ // we do ignore that just in case to definitely deliver the exception
+ }
+ propagateExceptionFinalResort(exception)
+}
+
+/**
+ * Private exception that is added to suppressed exceptions of the original exception
+ * when it is reported to the last-ditch current thread 'uncaughtExceptionHandler'.
+ *
+ * The purpose of this exception is to add an otherwise inaccessible diagnostic information and to
+ * be able to poke the context of the failing coroutine in the debugger.
+ */
+internal expect class DiagnosticCoroutineContextException(context: CoroutineContext) : RuntimeException
+
+/**
+ * A dummy exception that signifies that the exception was successfully processed by the handler and no further
+ * action is required.
+ *
+ * Would be nicer if [CoroutineExceptionHandler] could return a boolean, but that would be a breaking change.
+ * For now, we will take solace in knowledge that such exceptions are exceedingly rare, even rarer than globally
+ * uncaught exceptions in general.
+ */
+internal object ExceptionSuccessfullyProcessed : Exception()
diff --git a/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt
deleted file mode 100644
index 54a65e10..00000000
--- a/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
- */
-
-package kotlinx.coroutines
-
-import kotlin.coroutines.*
-
-internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
- // log exception
- console.error(exception)
-}
diff --git a/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt
new file mode 100644
index 00000000..675cc4a6
--- /dev/null
+++ b/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2016-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.internal
+
+import kotlinx.coroutines.*
+import kotlin.coroutines.*
+
+private val platformExceptionHandlers_ = mutableSetOf<CoroutineExceptionHandler>()
+
+internal actual val platformExceptionHandlers: Collection<CoroutineExceptionHandler>
+ get() = platformExceptionHandlers_
+
+internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) {
+ platformExceptionHandlers_ += callback
+}
+
+internal actual fun propagateExceptionFinalResort(exception: Throwable) {
+ // log exception
+ console.error(exception)
+}
+
+internal actual class DiagnosticCoroutineContextException actual constructor(context: CoroutineContext) :
+ RuntimeException(context.toString())
+
diff --git a/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt
deleted file mode 100644
index 0d68b047..00000000
--- a/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
- */
-
-package kotlinx.coroutines
-
-import java.util.*
-import kotlin.coroutines.*
-
-/**
- * A list of globally installed [CoroutineExceptionHandler] instances.
- *
- * Note that Android may have dummy [Thread.contextClassLoader] which is used by one-argument [ServiceLoader.load] function,
- * see (https://stackoverflow.com/questions/13407006/android-class-loader-may-fail-for-processes-that-host-multiple-applications).
- * So here we explicitly use two-argument `load` with a class-loader of [CoroutineExceptionHandler] class.
- *
- * We are explicitly using the `ServiceLoader.load(MyClass::class.java, MyClass::class.java.classLoader).iterator()`
- * form of the ServiceLoader call to enable R8 optimization when compiled on Android.
- */
-private val handlers: List<CoroutineExceptionHandler> = ServiceLoader.load(
- CoroutineExceptionHandler::class.java,
- CoroutineExceptionHandler::class.java.classLoader
-).iterator().asSequence().toList()
-
-/**
- * Private exception without stacktrace that is added to suppressed exceptions of the original exception
- * when it is reported to the last-ditch current thread 'uncaughtExceptionHandler'.
- *
- * The purpose of this exception is to add an otherwise inaccessible diagnostic information and to
- * be able to poke the failing coroutine context in the debugger.
- */
-private class DiagnosticCoroutineContextException(@Transient private val context: CoroutineContext) : RuntimeException() {
- override fun getLocalizedMessage(): String {
- return context.toString()
- }
-
- override fun fillInStackTrace(): Throwable {
- // Prevent Android <= 6.0 bug, #1866
- stackTrace = emptyArray()
- return this
- }
-}
-
-internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
- // use additional extension handlers
- for (handler in handlers) {
- try {
- handler.handleException(context, exception)
- } catch (t: Throwable) {
- // Use thread's handler if custom handler failed to handle exception
- val currentThread = Thread.currentThread()
- currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, handlerException(exception, t))
- }
- }
-
- // use thread's handler
- val currentThread = Thread.currentThread()
- // addSuppressed is never user-defined and cannot normally throw with the only exception being OOM
- // we do ignore that just in case to definitely deliver the exception
- runCatching { exception.addSuppressed(DiagnosticCoroutineContextException(context)) }
- currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
-}
diff --git a/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt
new file mode 100644
index 00000000..7f11898a
--- /dev/null
+++ b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImpl.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2016-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.internal
+
+import java.util.*
+import kotlinx.coroutines.*
+import kotlin.coroutines.*
+
+/**
+ * A list of globally installed [CoroutineExceptionHandler] instances.
+ *
+ * Note that Android may have dummy [Thread.contextClassLoader] which is used by one-argument [ServiceLoader.load] function,
+ * see (https://stackoverflow.com/questions/13407006/android-class-loader-may-fail-for-processes-that-host-multiple-applications).
+ * So here we explicitly use two-argument `load` with a class-loader of [CoroutineExceptionHandler] class.
+ *
+ * We are explicitly using the `ServiceLoader.load(MyClass::class.java, MyClass::class.java.classLoader).iterator()`
+ * form of the ServiceLoader call to enable R8 optimization when compiled on Android.
+ */
+internal actual val platformExceptionHandlers: Collection<CoroutineExceptionHandler> = ServiceLoader.load(
+ CoroutineExceptionHandler::class.java,
+ CoroutineExceptionHandler::class.java.classLoader
+).iterator().asSequence().toList()
+
+internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) {
+ // we use JVM's mechanism of ServiceLoader, so this should be a no-op on JVM.
+ // The only thing we do is make sure that the ServiceLoader did work correctly.
+ check(callback in platformExceptionHandlers) { "Exception handler was not found via a ServiceLoader" }
+}
+
+internal actual fun propagateExceptionFinalResort(exception: Throwable) {
+ // use the thread's handler
+ val currentThread = Thread.currentThread()
+ currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
+}
+
+// This implementation doesn't store a stacktrace, which is good because a stacktrace doesn't make sense for this.
+internal actual class DiagnosticCoroutineContextException actual constructor(@Transient private val context: CoroutineContext) : RuntimeException() {
+ override fun getLocalizedMessage(): String {
+ return context.toString()
+ }
+
+ override fun fillInStackTrace(): Throwable {
+ // Prevent Android <= 6.0 bug, #1866
+ stackTrace = emptyArray()
+ return this
+ }
+}
diff --git a/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt
deleted file mode 100644
index 434813dc..00000000
--- a/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-/*
- * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
- */
-
-package kotlinx.coroutines
-
-import kotlin.coroutines.*
-import kotlin.native.*
-
-@OptIn(ExperimentalStdlibApi::class)
-internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
- // log exception
- processUnhandledException(exception)
-}
diff --git a/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt
new file mode 100644
index 00000000..43d776cb
--- /dev/null
+++ b/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2016-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.internal
+
+import kotlinx.coroutines.*
+import kotlin.coroutines.*
+import kotlin.native.*
+
+private val lock = SynchronizedObject()
+
+internal actual val platformExceptionHandlers: Collection<CoroutineExceptionHandler>
+ get() = synchronized(lock) { platformExceptionHandlers_ }
+
+private val platformExceptionHandlers_ = mutableSetOf<CoroutineExceptionHandler>()
+
+internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) {
+ synchronized(lock) {
+ platformExceptionHandlers_ += callback
+ }
+}
+
+@OptIn(ExperimentalStdlibApi::class)
+internal actual fun propagateExceptionFinalResort(exception: Throwable) {
+ // log exception
+ processUnhandledException(exception)
+}
+
+internal actual class DiagnosticCoroutineContextException actual constructor(context: CoroutineContext) :
+ RuntimeException(context.toString())
diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt
index a0a29a95..713f5119 100644
--- a/kotlinx-coroutines-test/common/src/TestScope.kt
+++ b/kotlinx-coroutines-test/common/src/TestScope.kt
@@ -226,6 +226,14 @@ internal class TestScopeImpl(context: CoroutineContext) :
throw IllegalStateException("Only a single call to `runTest` can be performed during one test.")
entered = true
check(!finished)
+ /** the order is important: [reportException] is only guaranteed not to throw if [entered] is `true` but
+ * [finished] is `false`.
+ * However, we also want [uncaughtExceptions] to be queried after the callback is registered,
+ * because the exception collector will be able to report the exceptions that arrived before this test but
+ * after the previous one, and learning about such exceptions as soon is possible is nice. */
+ @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+ run { ensurePlatformExceptionHandlerLoaded(ExceptionCollector) }
+ ExceptionCollector.addOnExceptionCallback(lock, this::reportException)
uncaughtExceptions
}
if (exceptions.isNotEmpty()) {
@@ -239,6 +247,8 @@ internal class TestScopeImpl(context: CoroutineContext) :
/** 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)
+ /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */
+ ExceptionCollector.removeOnExceptionCallback(lock)
finished = true
uncaughtExceptions
}
@@ -247,6 +257,8 @@ internal class TestScopeImpl(context: CoroutineContext) :
fun legacyLeave(): List<Throwable> {
val exceptions = synchronized(lock) {
check(entered && !finished)
+ /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */
+ ExceptionCollector.removeOnExceptionCallback(lock)
finished = true
uncaughtExceptions
}
diff --git a/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt b/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt
new file mode 100644
index 00000000..90fa7635
--- /dev/null
+++ b/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2016-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.test.internal
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.internal.*
+import kotlin.coroutines.*
+
+/**
+ * If [addOnExceptionCallback] is called, the provided callback will be evaluated each time
+ * [handleCoroutineException] is executed and can't find a [CoroutineExceptionHandler] to
+ * process the exception.
+ *
+ * When a callback is registered once, even if it's later removed, the system starts to assume that
+ * other callbacks will eventually be registered, and so collects the exceptions.
+ * Once a new callback is registered, the collected exceptions are used with it.
+ *
+ * The callbacks in this object are the last resort before relying on platform-dependent
+ * ways to report uncaught exceptions from coroutines.
+ */
+internal object ExceptionCollector : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
+ private val lock = SynchronizedObject()
+ private var enabled = false
+ private val unprocessedExceptions = mutableListOf<Throwable>()
+ private val callbacks = mutableMapOf<Any, (Throwable) -> Unit>()
+
+ /**
+ * Registers [callback] to be executed when an uncaught exception happens.
+ * [owner] is a key by which to distinguish different callbacks.
+ */
+ fun addOnExceptionCallback(owner: Any, callback: (Throwable) -> Unit) = synchronized(lock) {
+ enabled = true // never becomes `false` again
+ val previousValue = callbacks.put(owner, callback)
+ check(previousValue === null)
+ // try to process the exceptions using the newly-registered callback
+ unprocessedExceptions.forEach { reportException(it) }
+ unprocessedExceptions.clear()
+ }
+
+ /**
+ * Unregisters the callback associated with [owner].
+ */
+ fun removeOnExceptionCallback(owner: Any) = synchronized(lock) {
+ val existingValue = callbacks.remove(owner)
+ check(existingValue !== null)
+ }
+
+ /**
+ * Tries to handle the exception by propagating it to an interested consumer.
+ * Returns `true` if the exception does not need further processing.
+ *
+ * Doesn't throw.
+ */
+ fun handleException(exception: Throwable): Boolean = synchronized(lock) {
+ if (!enabled) return false
+ if (reportException(exception)) return true
+ /** we don't return the result of the `add` function because we don't have a guarantee
+ * that a callback will eventually appear and collect the unprocessed exceptions, so
+ * we can't consider [exception] to be properly handled. */
+ unprocessedExceptions.add(exception)
+ return false
+ }
+
+ /**
+ * Try to report [exception] to the existing callbacks.
+ */
+ private fun reportException(exception: Throwable): Boolean {
+ var executedACallback = false
+ for (callback in callbacks.values) {
+ callback(exception)
+ executedACallback = true
+ /** We don't leave the function here because we want to fan-out the exceptions to every interested consumer,
+ * it's not enough to have the exception processed by one of them.
+ * The reason is, it's less big of a deal to observe multiple concurrent reports of bad behavior than not
+ * to observe the report in the exact callback that is connected to that bad behavior. */
+ }
+ return executedACallback
+ }
+
+ @Suppress("INVISIBLE_MEMBER")
+ override fun handleException(context: CoroutineContext, exception: Throwable) {
+ if (handleException(exception)) {
+ throw ExceptionSuccessfullyProcessed
+ }
+ }
+
+ override fun equals(other: Any?): Boolean = other is ExceptionCollector || other is ExceptionCollectorAsService
+}
+
+/**
+ * A workaround for being unable to treat an object as a `ServiceLoader` service.
+ */
+internal class ExceptionCollectorAsService: CoroutineExceptionHandler by ExceptionCollector {
+ override fun equals(other: Any?): Boolean = other is ExceptionCollectorAsService || other is ExceptionCollector
+ override fun hashCode(): Int = ExceptionCollector.hashCode()
+}
diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt
index 98375b09..345c66f9 100644
--- a/kotlinx-coroutines-test/common/test/Helpers.kt
+++ b/kotlinx-coroutines-test/common/test/Helpers.kt
@@ -31,9 +31,32 @@ inline fun <T> assertRunsFast(timeout: Duration, block: () -> T): T {
inline fun <T> assertRunsFast(block: () -> T): T = assertRunsFast(2.seconds, block)
/**
- * Passes [test] as an argument to [block], but as a function returning not a [TestResult] but [Unit].
+ * Runs [test], and then invokes [block], passing to it the lambda that functionally behaves
+ * the same way [test] does.
*/
-expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult
+fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult = testResultChain(
+ block = test,
+ after = {
+ block { it.getOrThrow() }
+ createTestResult { }
+ }
+)
+
+/**
+ * Chains together [block] and [after], passing the result of [block] to [after].
+ */
+expect fun testResultChain(block: () -> TestResult, after: (Result<Unit>) -> TestResult): TestResult
+
+fun testResultChain(vararg chained: (Result<Unit>) -> TestResult): TestResult =
+ if (chained.isEmpty()) {
+ createTestResult { }
+ } else {
+ testResultChain(block = {
+ chained[0](Result.success(Unit))
+ }) {
+ testResultChain(*chained.drop(1).toTypedArray())
+ }
+ }
class TestException(message: String? = null): Exception(message)
diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt
index a21e9168..433faef7 100644
--- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt
+++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt
@@ -491,6 +491,62 @@ class TestScopeTest {
}
}
+ /*
+ * Tests that the [TestScope] exception reporting mechanism will report the exceptions that happen between
+ * different tests.
+ *
+ * This test must be ran manually, because such exceptions still go through the global exception handler
+ * (as there's no guarantee that another test will happen), and the global exception handler will
+ * log the exceptions or, on Native, crash the test suite.
+ */
+ @Test
+ @Ignore
+ fun testReportingStrayUncaughtExceptionsBetweenTests() {
+ val thrown = TestException("x")
+ testResultChain({
+ // register a handler for uncaught exceptions
+ runTest { }
+ }, {
+ GlobalScope.launch(start = CoroutineStart.UNDISPATCHED) {
+ throw thrown
+ }
+ runTest {
+ fail("unreached")
+ }
+ }, {
+ // this `runTest` will not report the exception
+ runTest {
+ when (val exception = it.exceptionOrNull()) {
+ is UncaughtExceptionsBeforeTest -> {
+ assertEquals(1, exception.suppressedExceptions.size)
+ assertSame(exception.suppressedExceptions[0], thrown)
+ }
+ else -> fail("unexpected exception: $exception")
+ }
+ }
+ })
+ }
+
+ /**
+ * Tests that the uncaught exceptions that happen during the test are reported.
+ */
+ @Test
+ fun testReportingStrayUncaughtExceptionsDuringTest(): TestResult {
+ val thrown = TestException("x")
+ return testResultChain({ _ ->
+ runTest {
+ val job = launch(Dispatchers.Default + NonCancellable) {
+ throw thrown
+ }
+ job.join()
+ }
+ }, {
+ runTest {
+ assertEquals(thrown, it.exceptionOrNull())
+ }
+ })
+ }
+
companion object {
internal val invalidContexts = listOf(
Dispatchers.Default, // not a [TestDispatcher]
diff --git a/kotlinx-coroutines-test/js/test/Helpers.kt b/kotlinx-coroutines-test/js/test/Helpers.kt
index 5f19d1ac..5fd0291c 100644
--- a/kotlinx-coroutines-test/js/test/Helpers.kt
+++ b/kotlinx-coroutines-test/js/test/Helpers.kt
@@ -1,20 +1,17 @@
/*
- * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ * Copyright 2016-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/
package kotlinx.coroutines.test
import kotlin.test.*
-actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult =
- test().then(
+actual fun testResultChain(block: () -> TestResult, after: (Result<Unit>) -> TestResult): TestResult =
+ block().then(
{
- block {
- }
+ after(Result.success(Unit))
}, {
- block {
- throw it
- }
+ after(Result.failure(it))
})
actual typealias NoJs = Ignore
diff --git a/kotlinx-coroutines-test/jvm/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler b/kotlinx-coroutines-test/jvm/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler
new file mode 100644
index 00000000..c9aaec2e
--- /dev/null
+++ b/kotlinx-coroutines-test/jvm/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler
@@ -0,0 +1 @@
+kotlinx.coroutines.test.internal.ExceptionCollectorAsService
diff --git a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt
index aeb0f358..5330cd3d 100644
--- a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt
+++ b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt
@@ -59,7 +59,7 @@ public class TestCoroutineExceptionHandler :
override fun handleException(context: CoroutineContext, exception: Throwable) {
synchronized(_lock) {
if (_coroutinesCleanedUp) {
- handleCoroutineExceptionImpl(context, exception)
+ handleUncaughtCoroutineException(context, exception)
}
_exceptions += exception
}
diff --git a/kotlinx-coroutines-test/jvm/src/module-info.java b/kotlinx-coroutines-test/jvm/src/module-info.java
index f67ce6a1..40ee87d8 100644
--- a/kotlinx-coroutines-test/jvm/src/module-info.java
+++ b/kotlinx-coroutines-test/jvm/src/module-info.java
@@ -1,4 +1,6 @@
+import kotlinx.coroutines.CoroutineExceptionHandler;
import kotlinx.coroutines.internal.MainDispatcherFactory;
+import kotlinx.coroutines.test.internal.ExceptionCollectorAsService;
import kotlinx.coroutines.test.internal.TestMainDispatcherFactory;
module kotlinx.coroutines.test {
@@ -8,4 +10,5 @@ module kotlinx.coroutines.test {
exports kotlinx.coroutines.test;
provides MainDispatcherFactory with TestMainDispatcherFactory;
+ provides CoroutineExceptionHandler with ExceptionCollectorAsService;
}
diff --git a/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt
index e9aa3ff7..8d40b078 100644
--- a/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt
+++ b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt
@@ -3,8 +3,11 @@
*/
package kotlinx.coroutines.test
-actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) {
- block {
- test()
+actual fun testResultChain(block: () -> TestResult, after: (Result<Unit>) -> TestResult): TestResult {
+ try {
+ block()
+ after(Result.success(Unit))
+ } catch (e: Throwable) {
+ after(Result.failure(e))
}
}
diff --git a/kotlinx-coroutines-test/native/test/Helpers.kt b/kotlinx-coroutines-test/native/test/Helpers.kt
index ef478b7e..be615fb0 100644
--- a/kotlinx-coroutines-test/native/test/Helpers.kt
+++ b/kotlinx-coroutines-test/native/test/Helpers.kt
@@ -5,9 +5,12 @@ package kotlinx.coroutines.test
import kotlin.test.*
-actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) {
- block {
- test()
+actual fun testResultChain(block: () -> TestResult, after: (Result<Unit>) -> TestResult): TestResult {
+ try {
+ block()
+ after(Result.success(Unit))
+ } catch (e: Throwable) {
+ after(Result.failure(e))
}
}