Skip to content

Commit 6f7f838

Browse files
committed
Move ExceptionCollector to the test module
1 parent d5b506b commit 6f7f838

File tree

9 files changed

+206
-120
lines changed

9 files changed

+206
-120
lines changed

integration-testing/src/jvmCoreTest/kotlin/ListAllCoroutineThrowableSubclassesTest.kt

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class ListAllCoroutineThrowableSubclassesTest {
2828
"kotlinx.coroutines.internal.UndeliveredElementException",
2929
"kotlinx.coroutines.CompletionHandlerException",
3030
"kotlinx.coroutines.internal.DiagnosticCoroutineContextException",
31+
"kotlinx.coroutines.internal.ExceptionSuccessfullyProcessed",
3132
"kotlinx.coroutines.CoroutinesInternalError",
3233
"kotlinx.coroutines.channels.ClosedSendChannelException",
3334
"kotlinx.coroutines.channels.ClosedReceiveChannelException",

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

+53-76
Original file line numberDiff line numberDiff line change
@@ -7,89 +7,66 @@ package kotlinx.coroutines.internal
77
import kotlinx.coroutines.*
88
import kotlin.coroutines.*
99

10-
internal expect val ANDROID_DETECTED: Boolean
11-
12-
internal expect fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable)
13-
1410
/**
15-
* If [addOnExceptionCallback] is called, the provided callback will be evaluated each time
16-
* [handleCoroutineException] is executed and can't find a [CoroutineExceptionHandler] to
17-
* process the exception.
18-
*
19-
* When a callback is registered once, even if it's later removed, the system starts to assume that
20-
* other callbacks will eventually be registered, and so collects the exceptions.
21-
* Once a new callback is registered, the collected exceptions are used with it.
22-
*
23-
* The callbacks in this object are the last resort before relying on platform-dependent
24-
* ways to report uncaught exceptions from coroutines.
11+
* The list of globally installed [CoroutineExceptionHandler] instances that will be notified of any exceptions that
12+
* were not processed in any other manner.
2513
*/
26-
@PublishedApi
27-
internal object ExceptionCollector {
28-
private val lock = SynchronizedObject()
29-
private var enabled = false
30-
private var unprocessedExceptions = mutableListOf<Throwable>()
31-
private val callbacks = mutableMapOf<Any, (Throwable) -> Unit>()
14+
internal expect val platformExceptionHandlers: Collection<CoroutineExceptionHandler>
3215

33-
/**
34-
* Registers [callback] to be executed when an uncaught exception happens.
35-
* [owner] is a key by which to distinguish different callbacks.
36-
*/
37-
fun addOnExceptionCallback(owner: Any, callback: (Throwable) -> Unit) = synchronized(lock) {
38-
enabled = true // never becomes `false` again
39-
val previousValue = callbacks.put(owner, callback)
40-
assert { previousValue === null }
41-
// try to process the exceptions using the newly-registered callback
42-
unprocessedExceptions.forEach { reportException(it) }
43-
unprocessedExceptions = mutableListOf()
44-
}
45-
46-
/**
47-
* Unregisters the callback associated with [owner].
48-
*/
49-
fun removeOnExceptionCallback(owner: Any) = synchronized(lock) {
50-
val existingValue = callbacks.remove(owner)
51-
assert { existingValue !== null }
52-
}
16+
/**
17+
* Ensures that the given [callback] is present in the [platformExceptionHandlers] list.
18+
*/
19+
internal expect fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler)
5320

54-
/**
55-
* Tries to handle the exception by propagating it to an interested consumer.
56-
* Returns `true` if the exception does not need further processing.
57-
*
58-
* Doesn't throw.
59-
*/
60-
fun handleException(exception: Throwable): Boolean = synchronized(lock) {
61-
if (!enabled) return false
62-
if (reportException(exception)) return true
63-
/** we don't return the result of the `add` function because we don't have a guarantee
64-
* that a callback will eventually appear and collect the unprocessed exceptions, so
65-
* we can't consider [exception] to be properly handled. */
66-
unprocessedExceptions.add(exception)
67-
return false
68-
}
21+
/**
22+
* The platform-dependent global exception handler, used so that the exception is logged at least *somewhere*.
23+
*/
24+
internal expect fun propagateExceptionFinalResort(exception: Throwable)
6925

70-
/**
71-
* Try to report [exception] to the existing callbacks.
72-
*/
73-
private fun reportException(exception: Throwable): Boolean {
74-
var executedACallback = false
75-
for (callback in callbacks.values) {
76-
callback(exception)
77-
executedACallback = true
78-
/** We don't leave the function here because we want to fan-out the exceptions to every interested consumer,
79-
* it's not enough to have the exception processed by one of them.
80-
* The reason is, it's less big of a deal to observe multiple concurrent reports of bad behavior than not
81-
* to observe the report in the exact callback that is connected to that bad behavior. */
26+
/**
27+
* Deal with exceptions that happened in coroutines and weren't programmatically dealt with.
28+
*
29+
* First, it notifies every [CoroutineExceptionHandler] in the [platformExceptionHandlers] list.
30+
* If one of them throws [ExceptionSuccessfullyProcessed], it means that that handler believes that the exception was
31+
* dealt with sufficiently well and doesn't need any further processing.
32+
* Otherwise, the platform-dependent global exception handler is also invoked.
33+
*/
34+
internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) {
35+
// use additional extension handlers
36+
for (handler in platformExceptionHandlers) {
37+
try {
38+
handler.handleException(context, exception)
39+
} catch (_: ExceptionSuccessfullyProcessed) {
40+
return
41+
} catch (t: Throwable) {
42+
propagateExceptionFinalResort(handlerException(exception, t))
8243
}
83-
return executedACallback
8444
}
85-
}
8645

87-
internal fun handleUncaughtCoroutineException(context: CoroutineContext, exception: Throwable) {
88-
/** this check is purely for the whole [ExceptionCollector] to be eliminated when an Android app is minified. */
89-
if (ANDROID_DETECTED) {
90-
propagateExceptionToPlatform(context, exception)
91-
} else {
92-
if (!ExceptionCollector.handleException(exception))
93-
propagateExceptionToPlatform(context, exception)
46+
try {
47+
exception.addSuppressed(DiagnosticCoroutineContextException(context))
48+
} catch (e: Throwable) {
49+
// addSuppressed is never user-defined and cannot normally throw with the only exception being OOM
50+
// we do ignore that just in case to definitely deliver the exception
9451
}
52+
propagateExceptionFinalResort(exception)
9553
}
54+
55+
/**
56+
* Private exception that is added to suppressed exceptions of the original exception
57+
* when it is reported to the last-ditch current thread 'uncaughtExceptionHandler'.
58+
*
59+
* The purpose of this exception is to add an otherwise inaccessible diagnostic information and to
60+
* be able to poke the context of the failing coroutine in the debugger.
61+
*/
62+
internal expect class DiagnosticCoroutineContextException(context: CoroutineContext) : RuntimeException
63+
64+
/**
65+
* A dummy exception that signifies that the exception was successfully processed by the handler and no further
66+
* action is required.
67+
*
68+
* Would be nicer if [CoroutineExceptionHandler] could return a boolean, but that would be a breaking change.
69+
* For now, we will take solace in knowledge that such exceptions are exceedingly rare, even rarer than globally
70+
* uncaught exceptions in general.
71+
*/
72+
internal object ExceptionSuccessfullyProcessed: Exception()

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

+14-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,23 @@
44

55
package kotlinx.coroutines.internal
66

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

9-
internal actual val ANDROID_DETECTED = false
10+
private val platformExceptionHandlers_ = mutableSetOf<CoroutineExceptionHandler>()
1011

11-
internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) {
12+
internal actual val platformExceptionHandlers: Collection<CoroutineExceptionHandler>
13+
get() = platformExceptionHandlers_
14+
15+
internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) {
16+
platformExceptionHandlers_ += callback
17+
}
18+
19+
internal actual fun propagateExceptionFinalResort(exception: Throwable) {
1220
// log exception
1321
console.error(exception)
1422
}
23+
24+
internal actual class DiagnosticCoroutineContextException actual constructor(context: CoroutineContext) :
25+
RuntimeException(context.toString())
26+

kotlinx-coroutines-core/jvm/src/internal/CoroutineExceptionHandlerImplJvm.kt

+18-32
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
package kotlinx.coroutines.internal
66

77
import java.util.*
8-
import kotlin.coroutines.*
98
import kotlinx.coroutines.*
9+
import kotlin.coroutines.*
1010

1111
/**
1212
* A list of globally installed [CoroutineExceptionHandler] instances.
@@ -18,19 +18,25 @@ import kotlinx.coroutines.*
1818
* We are explicitly using the `ServiceLoader.load(MyClass::class.java, MyClass::class.java.classLoader).iterator()`
1919
* form of the ServiceLoader call to enable R8 optimization when compiled on Android.
2020
*/
21-
private val handlers: List<CoroutineExceptionHandler> = ServiceLoader.load(
22-
CoroutineExceptionHandler::class.java,
23-
CoroutineExceptionHandler::class.java.classLoader
21+
internal actual val platformExceptionHandlers: Collection<CoroutineExceptionHandler> = ServiceLoader.load(
22+
CoroutineExceptionHandler::class.java,
23+
CoroutineExceptionHandler::class.java.classLoader
2424
).iterator().asSequence().toList()
2525

26-
/**
27-
* Private exception without stacktrace that is added to suppressed exceptions of the original exception
28-
* when it is reported to the last-ditch current thread 'uncaughtExceptionHandler'.
29-
*
30-
* The purpose of this exception is to add an otherwise inaccessible diagnostic information and to
31-
* be able to poke the failing coroutine context in the debugger.
32-
*/
33-
private class DiagnosticCoroutineContextException(@Transient private val context: CoroutineContext) : RuntimeException() {
26+
internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) {
27+
// we use JVM's mechanism of ServiceLoader, so this should be a no-op on JVM.
28+
// The only thing we do is make sure that the ServiceLoader did work correctly.
29+
check(callback in platformExceptionHandlers) { "Exception handler was not found via a ServiceLoader" }
30+
}
31+
32+
internal actual fun propagateExceptionFinalResort(exception: Throwable) {
33+
// use the thread's handler
34+
val currentThread = Thread.currentThread()
35+
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
36+
}
37+
38+
// This implementation doesn't store a stacktrace, which is good because a stacktrace doesn't make sense for this.
39+
internal actual class DiagnosticCoroutineContextException actual constructor(@Transient private val context: CoroutineContext) : RuntimeException() {
3440
override fun getLocalizedMessage(): String {
3541
return context.toString()
3642
}
@@ -41,23 +47,3 @@ private class DiagnosticCoroutineContextException(@Transient private val context
4147
return this
4248
}
4349
}
44-
45-
internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) {
46-
// use additional extension handlers
47-
for (handler in handlers) {
48-
try {
49-
handler.handleException(context, exception)
50-
} catch (t: Throwable) {
51-
// Use thread's handler if custom handler failed to handle exception
52-
val currentThread = Thread.currentThread()
53-
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, handlerException(exception, t))
54-
}
55-
}
56-
57-
// use thread's handler
58-
val currentThread = Thread.currentThread()
59-
// addSuppressed is never user-defined and cannot normally throw with the only exception being OOM
60-
// we do ignore that just in case to definitely deliver the exception
61-
runCatching { exception.addSuppressed(DiagnosticCoroutineContextException(context)) }
62-
currentThread.uncaughtExceptionHandler.uncaughtException(currentThread, exception)
63-
}

kotlinx-coroutines-core/jvm/src/internal/FastServiceLoader.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import kotlin.collections.ArrayList
1414
/**
1515
* Don't use JvmField here to enable R8 optimizations via "assumenosideeffects"
1616
*/
17-
internal actual val ANDROID_DETECTED = runCatching { Class.forName("android.os.Build") }.isSuccess
17+
internal val ANDROID_DETECTED = runCatching { Class.forName("android.os.Build") }.isSuccess
1818

1919
/**
2020
* A simplified version of [ServiceLoader].

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

+17-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,28 @@
44

55
package kotlinx.coroutines.internal
66

7+
import kotlinx.coroutines.*
78
import kotlin.coroutines.*
89
import kotlin.native.*
910

10-
internal actual val ANDROID_DETECTED = false
11+
private val lock = SynchronizedObject()
12+
13+
internal actual val platformExceptionHandlers: Collection<CoroutineExceptionHandler>
14+
get() = synchronized(lock) { platformExceptionHandlers_ }
15+
16+
private val platformExceptionHandlers_ = mutableSetOf<CoroutineExceptionHandler>()
17+
18+
internal actual fun ensurePlatformExceptionHandlerLoaded(callback: CoroutineExceptionHandler) {
19+
synchronized(lock) {
20+
platformExceptionHandlers_ += callback
21+
}
22+
}
1123

1224
@OptIn(ExperimentalStdlibApi::class)
13-
internal actual fun propagateExceptionToPlatform(context: CoroutineContext, exception: Throwable) {
25+
internal actual fun propagateExceptionFinalResort(exception: Throwable) {
1426
// log exception
1527
processUnhandledException(exception)
1628
}
29+
30+
internal actual class DiagnosticCoroutineContextException actual constructor(context: CoroutineContext) :
31+
RuntimeException(context.toString())

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

+3-7
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,8 @@ internal class TestScopeImpl(context: CoroutineContext) :
226226
* because the exception collector will be able to report the exceptions that arrived before this test but
227227
* after the previous one, and learning about such exceptions as soon is possible is nice. */
228228
@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
229-
run {
230-
kotlinx.coroutines.internal.ExceptionCollector.addOnExceptionCallback(lock, this::reportException)
231-
}
229+
run { ensurePlatformExceptionHandlerLoaded(ExceptionCollector) }
230+
ExceptionCollector.addOnExceptionCallback(lock, this::reportException)
232231
uncaughtExceptions
233232
}
234233
if (exceptions.isNotEmpty()) {
@@ -244,10 +243,7 @@ internal class TestScopeImpl(context: CoroutineContext) :
244243
val exceptions = synchronized(lock) {
245244
check(entered && !finished)
246245
/** After [finished] becomes `true`, it is no longer valid to have [reportException] as the callback. */
247-
@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
248-
run {
249-
kotlinx.coroutines.internal.ExceptionCollector.removeOnExceptionCallback(lock)
250-
}
246+
ExceptionCollector.removeOnExceptionCallback(lock)
251247
finished = true
252248
uncaughtExceptions
253249
}

0 commit comments

Comments
 (0)