Skip to content

Commit 7bfac6d

Browse files
committed
Initial implementation attempt
1 parent 1fc01e7 commit 7bfac6d

File tree

7 files changed

+108
-7
lines changed

7 files changed

+108
-7
lines changed

kotlinx-coroutines-core/api/kotlinx-coroutines-core.api

+7
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,13 @@ public final class kotlinx/coroutines/EventLoopKt {
312312
public static final fun processNextEventInCurrentThread ()J
313313
}
314314

315+
public final class kotlinx/coroutines/ExceptionCollector {
316+
public static final field INSTANCE Lkotlinx/coroutines/ExceptionCollector;
317+
public final fun addOnExceptionCallback (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)V
318+
public final fun handleException (Ljava/lang/Throwable;)Z
319+
public final fun removeOnExceptionCallback (Ljava/lang/Object;)V
320+
}
321+
315322
public final class kotlinx/coroutines/ExceptionsKt {
316323
public static final fun CancellationException (Ljava/lang/String;Ljava/lang/Throwable;)Ljava/util/concurrent/CancellationException;
317324
}

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

+83-3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,89 @@
44

55
package kotlinx.coroutines
66

7+
import kotlinx.coroutines.internal.*
78
import kotlin.coroutines.*
89

9-
internal expect fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable)
10+
internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable)
11+
12+
/**
13+
* If [addOnExceptionCallback] is called, the provided callback will be evaluated each time
14+
* [handleCoroutineException] is executed and can't find a [CoroutineExceptionHandler] to
15+
* process the exception.
16+
*
17+
* When a callback is registered once, even if it's later removed, the system starts to assume that
18+
* other callbacks will eventually be registered, and so collects the exceptions.
19+
* Once a new callback is registered, it tries.
20+
*
21+
* The callbacks in this object are the last resort before relying on platform-dependent
22+
* ways to report uncaught exceptions from coroutines.
23+
*/
24+
@PublishedApi
25+
internal object ExceptionCollector {
26+
private val lock = SynchronizedObject()
27+
private var enabled = false
28+
private var unprocessedExceptions = mutableListOf<Throwable>()
29+
private val callbacks = mutableMapOf<Any, (Throwable) -> Unit>()
30+
31+
/**
32+
* Registers [callback] to be executed when an uncaught exception happens.
33+
* [owner] is a key by which to distinguish different callbacks.
34+
*/
35+
fun addOnExceptionCallback(owner: Any, callback: (Throwable) -> Unit) = synchronized(lock) {
36+
enabled = true // never becomes `false` again
37+
val previousValue = callbacks.put(owner, callback)
38+
assert { previousValue === null }
39+
// try to process the exceptions using the newly-registered callback
40+
unprocessedExceptions.forEach { reportException(it) }
41+
unprocessedExceptions = mutableListOf()
42+
}
43+
44+
/**
45+
* Unregisters the callback associated with [owner].
46+
*/
47+
fun removeOnExceptionCallback(owner: Any) = synchronized(lock) {
48+
val existingValue = callbacks.remove(owner)
49+
assert { existingValue !== null }
50+
}
51+
52+
/**
53+
* Tries to handle the exception by propagating it to an interested consumer.
54+
* Returns `true` if the exception does not need further processing.
55+
*
56+
* Doesn't throw.
57+
*/
58+
fun handleException(exception: Throwable): Boolean = synchronized(lock) {
59+
if (!enabled) return false
60+
if (reportException(exception)) return true
61+
/** we don't return the result of the `add` function because we don't have a guarantee
62+
* that a callback will eventually appear and collect the unprocessed exceptions, so
63+
* we can't consider [exception] to be properly handled. */
64+
unprocessedExceptions.add(exception)
65+
return false
66+
}
67+
68+
/**
69+
* Try to report [exception] to the existing callbacks.
70+
*/
71+
private fun reportException(exception: Throwable): Boolean {
72+
var executedACallback = false
73+
for (callback in callbacks.values) {
74+
callback(exception)
75+
executedACallback = true
76+
/** We don't leave the function here because we want to fan-out the exceptions to every interested consumer,
77+
* it's not enough to have the exception processed by one of them.
78+
* The reason is, it's less big of a deal to observe multiple concurrent reports of bad behavior than not
79+
* to observe the report in the exact callback that is connected to that bad behavior. */
80+
}
81+
return executedACallback
82+
}
83+
}
84+
85+
internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) {
86+
// TODO: if ANDROID_DETECTED
87+
if (!ExceptionCollector.handleException(exception))
88+
propagateExceptionToPlatform(context, exception)
89+
}
1090

1191
/**
1292
* 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
26106
return
27107
}
28108
} catch (t: Throwable) {
29-
handleCoroutineExceptionImpl(context, handlerException(exception, t))
109+
handleUncaughtCoroutineException(context, handlerException(exception, t))
30110
return
31111
}
32112
// If a handler is not present in the context or an exception was thrown, fallback to the global handler
33-
handleCoroutineExceptionImpl(context, exception)
113+
handleUncaughtCoroutineException(context, exception)
34114
}
35115

36116
internal fun handlerException(originalException: Throwable, thrownException: Throwable): Throwable {

kotlinx-coroutines-core/js/src/CoroutineExceptionHandlerImpl.kt

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

77
import kotlin.coroutines.*
88

9-
internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
9+
internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) {
1010
// log exception
1111
console.error(exception)
1212
}

kotlinx-coroutines-core/jvm/src/CoroutineExceptionHandlerImpl.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ private class DiagnosticCoroutineContextException(@Transient private val context
4141
}
4242
}
4343

44-
internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
44+
internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) {
4545
// use additional extension handlers
4646
for (handler in handlers) {
4747
try {

kotlinx-coroutines-core/native/src/CoroutineExceptionHandlerImpl.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import kotlin.coroutines.*
88
import kotlin.native.*
99

1010
@OptIn(ExperimentalStdlibApi::class)
11-
internal actual fun handleCoroutineExceptionImpl(context: CoroutineContext, exception: Throwable) {
11+
internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) {
1212
// log exception
1313
processUnhandledException(exception)
1414
}

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

+14
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,15 @@ internal class TestScopeImpl(context: CoroutineContext) :
220220
throw IllegalStateException("Only a single call to `runTest` can be performed during one test.")
221221
entered = true
222222
check(!finished)
223+
/** the order is important: [reportException] is only guaranteed not to throw if [entered] is `true` but
224+
* [finished] is `false`.
225+
* However, we also want [uncaughtExceptions] to be queried after the callback is registered,
226+
* because the exception collector will be able to report the exceptions that arrived before this test but
227+
* after the previous one, and learning about such exceptions as soon is possible is nice. */
228+
@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
229+
run {
230+
ExceptionCollector.addOnExceptionCallback(lock, this::reportException)
231+
}
223232
uncaughtExceptions
224233
}
225234
if (exceptions.isNotEmpty()) {
@@ -234,6 +243,11 @@ internal class TestScopeImpl(context: CoroutineContext) :
234243
fun leave(): List<Throwable> {
235244
val exceptions = synchronized(lock) {
236245
check(entered && !finished)
246+
/** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */
247+
@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
248+
run {
249+
ExceptionCollector.removeOnExceptionCallback(lock)
250+
}
237251
finished = true
238252
uncaughtExceptions
239253
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public class TestCoroutineExceptionHandler :
5858
override fun handleException(context: CoroutineContext, exception: Throwable) {
5959
synchronized(_lock) {
6060
if (_coroutinesCleanedUp) {
61-
handleCoroutineExceptionImpl(context, exception)
61+
handleUncaughtCoroutineException(context, exception)
6262
}
6363
_exceptions += exception
6464
}

0 commit comments

Comments
 (0)