kotlincoroutinekotlinx.coroutines

CoroutineExceptionHandler not executed when provided as launch context


When I run this:

fun f() = runBlocking {
    val eh = CoroutineExceptionHandler { _, e -> trace("exception handler: $e") }
    val j1 = launch(eh) {
        trace("launched")
        delay(1000)
        throw RuntimeException("error!")
    }
    trace("joining")
    j1.join()
    trace("after join")
}
f()

This is output:

[main @coroutine#1]: joining
[main @coroutine#2]: launched
java.lang.RuntimeException: error!
    at ExceptionHandling$f9$1$j1$1.invokeSuspend(ExceptionHandling.kts:164)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)
    at kotlinx.coroutines.ResumeModeKt.resumeMode(ResumeMode.kt:67)

According to the documentation for CoroutineExceptionHandler, the eh handler I provided should be executed. But it's not. Why is that?


Solution

  • I believe the answer lies in this section from the official coroutines docs:

    If a coroutine encounters exception other than CancellationException, it cancels its parent with that exception. This behaviour cannot be overridden and is used to provide stable coroutines hierarchies for structured concurrency which do not depend on CoroutineExceptionHandler implementation. The original exception is handled by the parent when all its children terminate.

    This also a reason why, in these examples, CoroutineExceptionHandler is always installed to a coroutine that is created in GlobalScope. It does not make sense to install an exception handler to a coroutine that is launched in the scope of the main runBlocking, since the main coroutine is going to be always cancelled when its child completes with exception despite the installed handler.

    (emphasis mine)

    What's described here applies not just to runBlocking and GlobalScope, but any non-top-level coroutine builder and custom scope.

    To illustrate (using kotlinx.coroutines v1.0.0):

    fun f() = runBlocking {
        val h1 = CoroutineExceptionHandler { _, e ->
            trace("handler 1 e: $e")
        }
        val h2 = CoroutineExceptionHandler { _, e ->
            trace("handler 2 e: $e")
        }
        val cs = CoroutineScope(newSingleThreadContext("t1"))
        trace("launching j1")
        val j1 = cs.launch(h1) {
            delay(1000)
            trace("launching j2")
            val j2 = launch(h2) {
                delay(500)
                trace("throwing exception")
                throw RuntimeException("error!")
            }
            j2.join()
        }
        trace("joining j1")
        j1.join()
        trace("exiting f")
    }
    f()
    

    Output:

    [main @coroutine#1]: launching j1
    [main @coroutine#1]: joining j1
    [t1 @coroutine#2]: launching j2
    [t1 @coroutine#3]: throwing exception
    [t1 @coroutine#2]: handler 1 e: java.lang.RuntimeException: error!
    [main @coroutine#1]: exiting f
    

    Note that handler h1 is executed, but h2 isn't. This is analogous to the handler on GlobalScope#launch executing, but not the handler provided to any launch inside runBlocking.

    TLDR

    Handlers provided to non-root coroutines of a scope will be ignored. A handler provided to a root coroutine that is created by launch will be executed. Root coroutines created by async or produce will ignore any handler.

    This behavior makes sense. If a child coroutine's handler handled an exception, all that coroutine's ancestors up to the root coroutine would have no clear way to continue execution, since they all depend on the success of the child, directly or indirectly.

    Similar logic applies to async. If a root coroutine started with async could capture exceptions with an installed handler, how would code waiting for a result from the coroutine via await know how to proceed with execution after an exception was handled? As with launch, here too, the code cannot proceed unless the coroutine succeeds.

    As such, it only makes sense for launch root coroutines to have the ability to handle exceptions.