From 573001d79815a579e72422004342271556f7e161 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 12 Sep 2022 16:26:11 +0200 Subject: [PATCH 1/8] Initial implementation attempt --- .../api/kotlinx-coroutines-core.api | 7 ++ .../common/src/CoroutineExceptionHandler.kt | 86 ++++++++++++++++++- .../js/src/CoroutineExceptionHandlerImpl.kt | 2 +- .../jvm/src/CoroutineExceptionHandlerImpl.kt | 2 +- .../src/CoroutineExceptionHandlerImpl.kt | 2 +- .../common/src/TestScope.kt | 14 +++ .../TestCoroutineExceptionHandler.kt | 2 +- 7 files changed, 108 insertions(+), 7 deletions(-) diff --git a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api index 3a2d08f428..09117cc10f 100644 --- a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api +++ b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api @@ -312,6 +312,13 @@ public final class kotlinx/coroutines/EventLoopKt { public static final fun processNextEventInCurrentThread ()J } +public final class kotlinx/coroutines/ExceptionCollector { + public static final field INSTANCE Lkotlinx/coroutines/ExceptionCollector; + public final fun addOnExceptionCallback (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V + public final fun handleException (Ljava/lang/Throwable;)Z + public final fun removeOnExceptionCallback (Ljava/lang/Object;)V +} + public final class kotlinx/coroutines/ExceptionsKt { public static final fun CancellationException (Ljava/lang/String;Ljava/lang/Throwable;)Ljava/util/concurrent/CancellationException; } diff --git a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt index 49923a92e7..949996fbf5 100644 --- a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt @@ -4,9 +4,89 @@ package kotlinx.coroutines +import kotlinx.coroutines.internal.* import kotlin.coroutines.* -internal expect fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) +internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) + +/** + * 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, it tries. + * + * The callbacks in this object are the last resort before relying on platform-dependent + * ways to report uncaught exceptions from coroutines. + */ +@PublishedApi +internal object ExceptionCollector { + private val lock = SynchronizedObject() + private var enabled = false + private var unprocessedExceptions = mutableListOf() + private val callbacks = mutableMapOf 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) + assert { previousValue === null } + // try to process the exceptions using the newly-registered callback + unprocessedExceptions.forEach { reportException(it) } + unprocessedExceptions = mutableListOf() + } + + /** + * Unregisters the callback associated with [owner]. + */ + fun removeOnExceptionCallback(owner: Any) = synchronized(lock) { + val existingValue = callbacks.remove(owner) + assert { 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 + } +} + +internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) { + // TODO: if ANDROID_DETECTED + if (!ExceptionCollector.handleException(exception)) + propagateExceptionToPlatform(context, exception) +} /** * Helper function for coroutine builder implementations to handle uncaught and unexpected exceptions in coroutines, @@ -26,11 +106,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/js/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt index 54a65e10a6..9c246ed417 100644 --- a/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt @@ -6,7 +6,7 @@ package kotlinx.coroutines import kotlin.coroutines.* -internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) { +internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { // log exception console.error(exception) } diff --git a/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt index 0d68b047f4..217dd4502d 100644 --- a/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt @@ -41,7 +41,7 @@ private class DiagnosticCoroutineContextException(@Transient private val context } } -internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) { +internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { // use additional extension handlers for (handler in handlers) { try { diff --git a/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt index 434813dc29..cfd976c41a 100644 --- a/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt @@ -8,7 +8,7 @@ import kotlin.coroutines.* import kotlin.native.* @OptIn(ExperimentalStdlibApi::class) -internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) { +internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { // log exception processUnhandledException(exception) } diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index 15d48a2ae2..a51d371bf8 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -220,6 +220,15 @@ 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 { + ExceptionCollector.addOnExceptionCallback(lock, this::reportException) + } uncaughtExceptions } if (exceptions.isNotEmpty()) { @@ -234,6 +243,11 @@ internal class TestScopeImpl(context: CoroutineContext) : fun leave(): List { val exceptions = synchronized(lock) { check(entered && !finished) + /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */ + @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + run { + ExceptionCollector.removeOnExceptionCallback(lock) + } finished = true uncaughtExceptions } diff --git a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt index aeb0f35882..5330cd3ddf 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 } From df029ff58d201f75774bd10ad51feefcc9f0b068 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 12 Sep 2022 16:53:32 +0200 Subject: [PATCH 2/8] Use ANDROID_DETECTED --- .../common/src/CoroutineExceptionHandler.kt | 81 ---------------- .../internal/CoroutineExceptionHandlerImpl.kt | 94 +++++++++++++++++++ .../CoroutineExceptionHandlerImpl.kt | 6 +- .../CoroutineExceptionHandlerImplJvm.kt} | 5 +- .../jvm/src/internal/FastServiceLoader.kt | 2 +- .../CoroutineExceptionHandlerImpl.kt | 6 +- .../common/src/TestScope.kt | 4 +- 7 files changed, 108 insertions(+), 90 deletions(-) create mode 100644 kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt rename kotlinx-coroutines-core/js/src/{ => internal}/CoroutineExceptionHandlerImpl.kt (60%) rename kotlinx-coroutines-core/jvm/src/{CoroutineExceptionHandlerImpl.kt => internal/CoroutineExceptionHandlerImplJvm.kt} (95%) rename kotlinx-coroutines-core/native/src/{ => internal}/CoroutineExceptionHandlerImpl.kt (67%) diff --git a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt index 949996fbf5..e26b374b8e 100644 --- a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt @@ -7,87 +7,6 @@ package kotlinx.coroutines import kotlinx.coroutines.internal.* import kotlin.coroutines.* -internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) - -/** - * 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, it tries. - * - * The callbacks in this object are the last resort before relying on platform-dependent - * ways to report uncaught exceptions from coroutines. - */ -@PublishedApi -internal object ExceptionCollector { - private val lock = SynchronizedObject() - private var enabled = false - private var unprocessedExceptions = mutableListOf() - private val callbacks = mutableMapOf 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) - assert { previousValue === null } - // try to process the exceptions using the newly-registered callback - unprocessedExceptions.forEach { reportException(it) } - unprocessedExceptions = mutableListOf() - } - - /** - * Unregisters the callback associated with [owner]. - */ - fun removeOnExceptionCallback(owner: Any) = synchronized(lock) { - val existingValue = callbacks.remove(owner) - assert { 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 - } -} - -internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) { - // TODO: if ANDROID_DETECTED - if (!ExceptionCollector.handleException(exception)) - propagateExceptionToPlatform(context, exception) -} - /** * 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 diff --git a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt new file mode 100644 index 0000000000..61528eb750 --- /dev/null +++ b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt @@ -0,0 +1,94 @@ +/* + * 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.* + +internal expect val ANDROID_DETECTED: Boolean + +internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) + +/** + * 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, it tries. + * + * The callbacks in this object are the last resort before relying on platform-dependent + * ways to report uncaught exceptions from coroutines. + */ +@PublishedApi +internal object ExceptionCollector { + private val lock = SynchronizedObject() + private var enabled = false + private var unprocessedExceptions = mutableListOf() + private val callbacks = mutableMapOf 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) + assert { previousValue === null } + // try to process the exceptions using the newly-registered callback + unprocessedExceptions.forEach { reportException(it) } + unprocessedExceptions = mutableListOf() + } + + /** + * Unregisters the callback associated with [owner]. + */ + fun removeOnExceptionCallback(owner: Any) = synchronized(lock) { + val existingValue = callbacks.remove(owner) + assert { 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 + } +} + +internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) { + if (ANDROID_DETECTED) { + propagateExceptionToPlatform(context, exception) + } else { + if (!ExceptionCollector.handleException(exception)) + propagateExceptionToPlatform(context, exception) + } +} diff --git a/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt similarity index 60% rename from kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt rename to kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt index 9c246ed417..b86d34556f 100644 --- a/kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt @@ -1,11 +1,13 @@ /* - * 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 +package kotlinx.coroutines.internal import kotlin.coroutines.* +internal actual val ANDROID_DETECTED = false + internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { // log exception console.error(exception) diff --git a/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt similarity index 95% rename from kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt rename to kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt index 217dd4502d..1c8ef437e4 100644 --- a/kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt @@ -1,11 +1,12 @@ /* - * 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 +package kotlinx.coroutines.internal import java.util.* import kotlin.coroutines.* +import kotlinx.coroutines.* /** * A list of globally installed [CoroutineExceptionHandler] instances. diff --git a/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt b/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt index e93de2aad7..3c6f1c2933 100644 --- a/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt +++ b/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt @@ -14,7 +14,7 @@ import kotlin.collections.ArrayList /** * Don't use JvmField here to enable R8 optimizations via "assumenosideeffects" */ -internal val ANDROID_DETECTED = runCatching { Class.forName("android.os.Build") }.isSuccess +internal actual val ANDROID_DETECTED = runCatching { Class.forName("android.os.Build") }.isSuccess /** * A simplified version of [ServiceLoader]. diff --git a/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt similarity index 67% rename from kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt rename to kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt index cfd976c41a..08a5069a03 100644 --- a/kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt @@ -1,12 +1,14 @@ /* - * 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 +package kotlinx.coroutines.internal import kotlin.coroutines.* import kotlin.native.* +internal actual val ANDROID_DETECTED = false + @OptIn(ExperimentalStdlibApi::class) internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { // log exception diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index a51d371bf8..089ab9ffd1 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -227,7 +227,7 @@ internal class TestScopeImpl(context: CoroutineContext) : * after the previous one, and learning about such exceptions as soon is possible is nice. */ @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") run { - ExceptionCollector.addOnExceptionCallback(lock, this::reportException) + kotlinx.coroutines.internal.ExceptionCollector.addOnExceptionCallback(lock, this::reportException) } uncaughtExceptions } @@ -246,7 +246,7 @@ internal class TestScopeImpl(context: CoroutineContext) : /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */ @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") run { - ExceptionCollector.removeOnExceptionCallback(lock) + kotlinx.coroutines.internal.ExceptionCollector.removeOnExceptionCallback(lock) } finished = true uncaughtExceptions From 30e1f0261d26d389b9d63c753569fd05b41dbcf7 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 13 Sep 2022 11:44:03 +0200 Subject: [PATCH 3/8] Improve comments --- .../common/src/internal/CoroutineExceptionHandlerImpl.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt index 61528eb750..106f8b52da 100644 --- a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt @@ -18,7 +18,7 @@ internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exce * * 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, it tries. + * 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. @@ -85,6 +85,7 @@ internal object ExceptionCollector { } internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) { + /** this check is purely for the whole [ExceptionCollector] to be eliminated when an Android app is minified. */ if (ANDROID_DETECTED) { propagateExceptionToPlatform(context, exception) } else { From fbc50da1663e061ffa4e9de0cf5f5f3c45283e09 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 13 Sep 2022 13:36:47 +0200 Subject: [PATCH 4/8] Fix apiDump --- kotlinx-coroutines-core/api/kotlinx-coroutines-core.api | 7 ------- 1 file changed, 7 deletions(-) diff --git a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api index 09117cc10f..3a2d08f428 100644 --- a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api +++ b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api @@ -312,13 +312,6 @@ public final class kotlinx/coroutines/EventLoopKt { public static final fun processNextEventInCurrentThread ()J } -public final class kotlinx/coroutines/ExceptionCollector { - public static final field INSTANCE Lkotlinx/coroutines/ExceptionCollector; - public final fun addOnExceptionCallback (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V - public final fun handleException (Ljava/lang/Throwable;)Z - public final fun removeOnExceptionCallback (Ljava/lang/Object;)V -} - public final class kotlinx/coroutines/ExceptionsKt { public static final fun CancellationException (Ljava/lang/String;Ljava/lang/Throwable;)Ljava/util/concurrent/CancellationException; } From 7c627da88ad1aabdbd07a2bbefe371d879d3e140 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 13 Sep 2022 14:33:44 +0200 Subject: [PATCH 5/8] Update a test to reflect a class moving between packages --- .../kotlin/ListAllCoroutineThrowableSubclassesTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt index 21fe496bd3..0f65390dd0 100644 --- a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt +++ b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt @@ -27,7 +27,7 @@ class ListAllCoroutineThrowableSubclassesTest { "kotlinx.coroutines.JobCancellationException", "kotlinx.coroutines.internal.UndeliveredElementException", "kotlinx.coroutines.CompletionHandlerException", - "kotlinx.coroutines.DiagnosticCoroutineContextException", + "kotlinx.coroutines.internal.DiagnosticCoroutineContextException", "kotlinx.coroutines.CoroutinesInternalError", "kotlinx.coroutines.channels.ClosedSendChannelException", "kotlinx.coroutines.channels.ClosedReceiveChannelException", From c4345c5610bf076edec9fb8b2f8cca530684a34b Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 19 Sep 2022 13:54:03 +0200 Subject: [PATCH 6/8] Add tests --- .../common/test/Helpers.kt | 27 ++++++++- .../common/test/TestScopeTest.kt | 56 +++++++++++++++++++ kotlinx-coroutines-test/js/test/Helpers.kt | 13 ++--- .../jvm/test/HelpersJvm.kt | 9 ++- .../native/test/Helpers.kt | 9 ++- 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt index 98375b0905..345c66f91a 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -31,9 +31,32 @@ inline fun assertRunsFast(timeout: Duration, block: () -> T): T { inline fun 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) -> TestResult): TestResult + +fun testResultChain(vararg chained: (Result) -> 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 45f7f3ef80..ced8c5d781 100644 --- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt @@ -476,6 +476,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 5f19d1ac58..5fd0291c3b 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) -> 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/test/HelpersJvm.kt b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt index e9aa3ff747..8d40b078a3 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) -> 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 ef478b7eb1..be615fb022 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) -> TestResult): TestResult { + try { + block() + after(Result.success(Unit)) + } catch (e: Throwable) { + after(Result.failure(e)) } } From 769586365e811a97371846588288d27abb590934 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 16 Dec 2022 15:27:43 +0100 Subject: [PATCH 7/8] Move `ExceptionCollector` to the test module --- ...ListAllCoroutineThrowableSubclassesTest.kt | 1 + .../internal/CoroutineExceptionHandlerImpl.kt | 129 +++++++----------- .../internal/CoroutineExceptionHandlerImpl.kt | 16 ++- .../CoroutineExceptionHandlerImplJvm.kt | 50 +++---- .../jvm/src/internal/FastServiceLoader.kt | 2 +- .../internal/CoroutineExceptionHandlerImpl.kt | 19 ++- .../common/src/TestScope.kt | 10 +- .../common/src/internal/ExceptionCollector.kt | 98 +++++++++++++ ...tlinx.coroutines.CoroutineExceptionHandler | 1 + 9 files changed, 206 insertions(+), 120 deletions(-) create mode 100644 kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt create mode 100644 kotlinx-coroutines-test/jvm/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler diff --git a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt index 0f65390dd0..7253658e9b 100644 --- a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt +++ b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt @@ -28,6 +28,7 @@ class ListAllCoroutineThrowableSubclassesTest { "kotlinx.coroutines.internal.UndeliveredElementException", "kotlinx.coroutines.CompletionHandlerException", "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/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt index 106f8b52da..02e2accd0a 100644 --- a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt @@ -7,89 +7,66 @@ package kotlinx.coroutines.internal import kotlinx.coroutines.* import kotlin.coroutines.* -internal expect val ANDROID_DETECTED: Boolean - -internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) - /** - * 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. + * The list of globally installed [CoroutineExceptionHandler] instances that will be notified of any exceptions that + * were not processed in any other manner. */ -@PublishedApi -internal object ExceptionCollector { - private val lock = SynchronizedObject() - private var enabled = false - private var unprocessedExceptions = mutableListOf() - private val callbacks = mutableMapOf Unit>() +internal expect val platformExceptionHandlers: Collection - /** - * 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) - assert { previousValue === null } - // try to process the exceptions using the newly-registered callback - unprocessedExceptions.forEach { reportException(it) } - unprocessedExceptions = mutableListOf() - } - - /** - * Unregisters the callback associated with [owner]. - */ - fun removeOnExceptionCallback(owner: Any) = synchronized(lock) { - val existingValue = callbacks.remove(owner) - assert { existingValue !== null } - } +/** + * Ensures that the given [callback] is present in the [platformExceptionHandlers] list. + */ +internal expect fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) - /** - * 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 - } +/** + * The platform-dependent global exception handler, used so that the exception is logged at least *somewhere*. + */ +internal expect fun propagateExceptionFinalResort(exception: Throwable) - /** - * 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. */ +/** + * 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)) } - return executedACallback } -} -internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) { - /** this check is purely for the whole [ExceptionCollector] to be eliminated when an Android app is minified. */ - if (ANDROID_DETECTED) { - propagateExceptionToPlatform(context, exception) - } else { - if (!ExceptionCollector.handleException(exception)) - propagateExceptionToPlatform(context, exception) + 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/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt index b86d34556f..675cc4a67a 100644 --- a/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/js/src/internal/CoroutineExceptionHandlerImpl.kt @@ -4,11 +4,23 @@ package kotlinx.coroutines.internal +import kotlinx.coroutines.* import kotlin.coroutines.* -internal actual val ANDROID_DETECTED = false +private val platformExceptionHandlers_ = mutableSetOf() -internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { +internal actual val platformExceptionHandlers: Collection + 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/internal/CoroutineExceptionHandlerImplJvm.kt b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt index 1c8ef437e4..7f11898a09 100644 --- a/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt +++ b/kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt @@ -5,8 +5,8 @@ package kotlinx.coroutines.internal import java.util.* -import kotlin.coroutines.* import kotlinx.coroutines.* +import kotlin.coroutines.* /** * A list of globally installed [CoroutineExceptionHandler] instances. @@ -18,19 +18,25 @@ import kotlinx.coroutines.* * 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 = ServiceLoader.load( - CoroutineExceptionHandler::class.java, - CoroutineExceptionHandler::class.java.classLoader +internal actual val platformExceptionHandlers: Collection = 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() { +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() } @@ -41,23 +47,3 @@ private class DiagnosticCoroutineContextException(@Transient private val context return this } } - -internal actual fun propagateExceptionToPlatform(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/FastServiceLoader.kt b/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt index 3c6f1c2933..e93de2aad7 100644 --- a/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt +++ b/kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt @@ -14,7 +14,7 @@ import kotlin.collections.ArrayList /** * Don't use JvmField here to enable R8 optimizations via "assumenosideeffects" */ -internal actual val ANDROID_DETECTED = runCatching { Class.forName("android.os.Build") }.isSuccess +internal val ANDROID_DETECTED = runCatching { Class.forName("android.os.Build") }.isSuccess /** * A simplified version of [ServiceLoader]. diff --git a/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt index 08a5069a03..43d776cb54 100644 --- a/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/native/src/internal/CoroutineExceptionHandlerImpl.kt @@ -4,13 +4,28 @@ package kotlinx.coroutines.internal +import kotlinx.coroutines.* import kotlin.coroutines.* import kotlin.native.* -internal actual val ANDROID_DETECTED = false +private val lock = SynchronizedObject() + +internal actual val platformExceptionHandlers: Collection + get() = synchronized(lock) { platformExceptionHandlers_ } + +private val platformExceptionHandlers_ = mutableSetOf() + +internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) { + synchronized(lock) { + platformExceptionHandlers_ += callback + } +} @OptIn(ExperimentalStdlibApi::class) -internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) { +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 089ab9ffd1..0a0fcc6718 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -226,9 +226,8 @@ internal class TestScopeImpl(context: CoroutineContext) : * 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 { - kotlinx.coroutines.internal.ExceptionCollector.addOnExceptionCallback(lock, this::reportException) - } + run { ensurePlatformExceptionHandlerLoaded(ExceptionCollector) } + ExceptionCollector.addOnExceptionCallback(lock, this::reportException) uncaughtExceptions } if (exceptions.isNotEmpty()) { @@ -244,10 +243,7 @@ internal class TestScopeImpl(context: CoroutineContext) : val exceptions = synchronized(lock) { check(entered && !finished) /** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */ - @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - run { - kotlinx.coroutines.internal.ExceptionCollector.removeOnExceptionCallback(lock) - } + 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 0000000000..aff1ca58f2 --- /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() + private val callbacks = mutableMapOf 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/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 0000000000..c9aaec2e60 --- /dev/null +++ b/kotlinx-coroutines-test/jvm/resources/META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler @@ -0,0 +1 @@ +kotlinx.coroutines.test.internal.ExceptionCollectorAsService From 223c578cd09ddf85ff50aa8ccb5cb41d617c1f0f Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 16 Dec 2022 16:35:23 +0100 Subject: [PATCH 8/8] Replace "handle" with "tryHandle" for uncaught exceptions I find this to be a much clearer mental model. * A handler can decide if it can process this particular exception. * A handler can be in an invalid state, and it can now pass handling the uncaught exception to the system, without making additional noise. * Before this change, there are *two* types of exception handlers: - Those that are used as coroutine context elements. If a coroutine context element processes an exception successfully, the system is not notified about them. - Those that are used as services. These are just notified about exception, they are not considered to be consuming them. I guess this was done for the sake of Android's pre-handler. If we forget about backward compatibility, I'm certain this is the way to do it, but I don't see a good way to make this change while staying backward-compatible. --- ...ListAllCoroutineThrowableSubclassesTest.kt | 1 - .../api/kotlinx-coroutines-core.api | 3 ++ .../common/src/CoroutineExceptionHandler.kt | 28 +++++++++++++++---- .../internal/CoroutineExceptionHandlerImpl.kt | 5 ++-- .../jvm/test/exceptions/Exceptions.kt | 2 +- .../common/src/internal/ExceptionCollector.kt | 9 +----- .../common/test/RunTestTest.kt | 7 ++++- .../TestCoroutineExceptionHandler.kt | 9 +++--- .../jvm/src/migration/TestCoroutineScope.kt | 6 ++-- .../test/migration/RunTestLegacyScopeTest.kt | 7 ++++- .../TestCoroutineExceptionHandlerTest.kt | 4 +-- .../src/AndroidExceptionPreHandler.kt | 3 +- 12 files changed, 52 insertions(+), 32 deletions(-) diff --git a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt index 7253658e9b..0f65390dd0 100644 --- a/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt +++ b/integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt @@ -28,7 +28,6 @@ class ListAllCoroutineThrowableSubclassesTest { "kotlinx.coroutines.internal.UndeliveredElementException", "kotlinx.coroutines.CompletionHandlerException", "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/api/kotlinx-coroutines-core.api b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api index 3a2d08f428..0ff58ae22f 100644 --- a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api +++ b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api @@ -182,13 +182,16 @@ public final class kotlinx/coroutines/CoroutineDispatcher$Key : kotlin/coroutine public abstract interface class kotlinx/coroutines/CoroutineExceptionHandler : kotlin/coroutines/CoroutineContext$Element { public static final field Key Lkotlinx/coroutines/CoroutineExceptionHandler$Key; public abstract fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V + public abstract fun tryHandleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)Z } public final class kotlinx/coroutines/CoroutineExceptionHandler$DefaultImpls { public static fun fold (Lkotlinx/coroutines/CoroutineExceptionHandler;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object; public static fun get (Lkotlinx/coroutines/CoroutineExceptionHandler;Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element; + public static fun handleException (Lkotlinx/coroutines/CoroutineExceptionHandler;Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V public static fun minusKey (Lkotlinx/coroutines/CoroutineExceptionHandler;Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext; public static fun plus (Lkotlinx/coroutines/CoroutineExceptionHandler;Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; + public static fun tryHandleException (Lkotlinx/coroutines/CoroutineExceptionHandler;Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)Z } public final class kotlinx/coroutines/CoroutineExceptionHandler$Key : kotlin/coroutines/CoroutineContext$Key { diff --git a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt index e26b374b8e..ab8f54168a 100644 --- a/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-core/common/src/CoroutineExceptionHandler.kt @@ -21,8 +21,8 @@ public fun handleCoroutineException(context: CoroutineContext, exception: Throwa // Invoke an exception handler from the context if present try { context[CoroutineExceptionHandler]?.let { - it.handleException(context, exception) - return + if (it.tryHandleException(context, exception)) + return } } catch (t: Throwable) { handleUncaughtCoroutineException(context, handlerException(exception, t)) @@ -46,8 +46,8 @@ internal fun handlerException(originalException: Throwable, thrownException: Thr @Suppress("FunctionName") public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineContext, Throwable) -> Unit): CoroutineExceptionHandler = object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { - override fun handleException(context: CoroutineContext, exception: Throwable) = - handler.invoke(context, exception) + override fun tryHandleException(context: CoroutineContext, exception: Throwable): Boolean = + true.also { handler.invoke(context, exception) } } /** @@ -104,5 +104,23 @@ public interface CoroutineExceptionHandler : CoroutineContext.Element { * Handles uncaught [exception] in the given [context]. It is invoked * if coroutine has an uncaught exception. */ - public fun handleException(context: CoroutineContext, exception: Throwable) + @Deprecated( + "Use tryHandleException instead", + replaceWith = ReplaceWith("this.tryHandleException(context, exception)"), + level = DeprecationLevel.WARNING + ) + public fun handleException(context: CoroutineContext, exception: Throwable) { + if (!tryHandleException(context, exception)) + handleUncaughtCoroutineException(context, exception) + } + + /** + * Handles uncaught [exception] in the given [context]. + * Returns `true` if the processing was successful and no other processing is needed. + */ + public fun tryHandleException(context: CoroutineContext, exception: Throwable): Boolean { + @Suppress("DEPRECATION") + handleException(context, exception) + return true + } } diff --git a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt index 02e2accd0a..56f26cab1d 100644 --- a/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt +++ b/kotlinx-coroutines-core/common/src/internal/CoroutineExceptionHandlerImpl.kt @@ -35,9 +35,8 @@ internal fun handleUncaughtCoroutineException(context: CoroutineContext, excepti // use additional extension handlers for (handler in platformExceptionHandlers) { try { - handler.handleException(context, exception) - } catch (_: ExceptionSuccessfullyProcessed) { - return + if (handler.tryHandleException(context, exception)) + return } catch (t: Throwable) { propagateExceptionFinalResort(handlerException(exception, t)) } diff --git a/kotlinx-coroutines-core/jvm/test/exceptions/Exceptions.kt b/kotlinx-coroutines-core/jvm/test/exceptions/Exceptions.kt index 4849f52071..0c96d10b26 100644 --- a/kotlinx-coroutines-core/jvm/test/exceptions/Exceptions.kt +++ b/kotlinx-coroutines-core/jvm/test/exceptions/Exceptions.kt @@ -39,7 +39,7 @@ class CapturingHandler : AbstractCoroutineContextElement(CoroutineExceptionHandl { private var unhandled: ArrayList? = ArrayList() - override fun handleException(context: CoroutineContext, exception: Throwable) = synchronized(this) { + override fun tryHandleException(context: CoroutineContext, exception: Throwable) = synchronized(this) { unhandled!!.add(exception) } diff --git a/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt b/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt index aff1ca58f2..a25cf8400d 100644 --- a/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt +++ b/kotlinx-coroutines-test/common/src/internal/ExceptionCollector.kt @@ -53,7 +53,7 @@ internal object ExceptionCollector: AbstractCoroutineContextElement(CoroutineExc * * Doesn't throw. */ - fun handleException(exception: Throwable): Boolean = synchronized(lock) { + override fun tryHandleException(context: CoroutineContext, 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 @@ -79,13 +79,6 @@ internal object ExceptionCollector: AbstractCoroutineContextElement(CoroutineExc 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 } diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 0315543d54..4a804cad30 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -160,7 +160,12 @@ class RunTestTest { } }) { runTest(dispatchTimeoutMs = 1) { - coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A")) + assertTrue( + coroutineContext[CoroutineExceptionHandler]!!.tryHandleException( + coroutineContext, + TestException("A") + ) + ) withContext(Dispatchers.Default) { delay(10000) 3 diff --git a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt index 5330cd3ddf..ee6e63f4c5 100644 --- a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt @@ -56,14 +56,13 @@ public class TestCoroutineExceptionHandler : private var _coroutinesCleanedUp = false @Suppress("INVISIBLE_MEMBER") - override fun handleException(context: CoroutineContext, exception: Throwable) { + override fun tryHandleException(context: CoroutineContext, exception: Throwable): Boolean = synchronized(_lock) { - if (_coroutinesCleanedUp) { - handleUncaughtCoroutineException(context, exception) + if (!_coroutinesCleanedUp) { + _exceptions += exception } - _exceptions += exception + !_coroutinesCleanedUp } - } public override val uncaughtExceptions: List get() = synchronized(_lock) { _exceptions.toList() } diff --git a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt index 5af83f5197..3e5b061e25 100644 --- a/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt @@ -187,10 +187,8 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo var scope: TestCoroutineScopeImpl? = null val ownExceptionHandler = object : AbstractCoroutineContextElement(CoroutineExceptionHandler), TestCoroutineScopeExceptionHandler { - override fun handleException(context: CoroutineContext, exception: Throwable) { - if (!scope!!.reportException(exception)) - throw exception // let this exception crash everything - } + override fun tryHandleException(context: CoroutineContext, exception: Throwable) = + scope!!.reportException(exception) } val exceptionHandler = when (val exceptionHandler = ctxWithDispatcher[CoroutineExceptionHandler]) { is UncaughtExceptionCaptor -> exceptionHandler diff --git a/kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt b/kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt index ed5b1577f5..1c984155ea 100644 --- a/kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt +++ b/kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt @@ -101,7 +101,12 @@ class RunTestLegacyScopeTest { } }) { runTestWithLegacyScope(dispatchTimeoutMs = 1) { - coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A")) + assertTrue( + coroutineContext[CoroutineExceptionHandler]!!.tryHandleException( + coroutineContext, + TestException("A") + ) + ) withContext(Dispatchers.Default) { delay(10000) 3 diff --git a/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineExceptionHandlerTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineExceptionHandlerTest.kt index 20da130725..6b82386c29 100644 --- a/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineExceptionHandlerTest.kt +++ b/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineExceptionHandlerTest.kt @@ -12,7 +12,7 @@ class TestCoroutineExceptionHandlerTest { fun whenExceptionsCaught_availableViaProperty() { val subject = TestCoroutineExceptionHandler() val expected = IllegalArgumentException() - subject.handleException(subject, expected) + assertTrue(subject.tryHandleException(subject, expected)) assertEquals(listOf(expected), subject.uncaughtExceptions) } -} \ No newline at end of file +} diff --git a/ui/kotlinx-coroutines-android/src/AndroidExceptionPreHandler.kt b/ui/kotlinx-coroutines-android/src/AndroidExceptionPreHandler.kt index 0bc603ea1e..013daf0a9f 100644 --- a/ui/kotlinx-coroutines-android/src/AndroidExceptionPreHandler.kt +++ b/ui/kotlinx-coroutines-android/src/AndroidExceptionPreHandler.kt @@ -30,7 +30,7 @@ internal class AndroidExceptionPreHandler : return declared } - override fun handleException(context: CoroutineContext, exception: Throwable) { + override fun tryHandleException(context: CoroutineContext, exception: Throwable): Boolean { /* * Android Oreo introduced private API for a global pre-handler for uncaught exceptions, to ensure that the * exceptions are logged even if the default uncaught exception handler is replaced by the app. The pre-handler @@ -48,5 +48,6 @@ internal class AndroidExceptionPreHandler : (preHandler()?.invoke(null) as? Thread.UncaughtExceptionHandler) ?.uncaughtException(Thread.currentThread(), exception) } + return false // let the default handler, too, handle the exception } }