Description
Given this code:
val job = Job()
val scope = CoroutineScope(job)
val handler = CoroutineExceptionHandler { coroCtx, e ->
println("Handler caught $e")
}
fun CoroutineScope.handlerTest() = this.launch(handler) {
throw Exception("Launched coroutine failed")
}
When I run it like this (using the scope constructed above):
scope.handlerTest()
I get the expected output:
Handler caught java.lang.Exception: Launched coroutine failed
However, when I run it like this (using the runBlocking
scope):
runBlocking { this.handlerTest() }
the handler doesn't observe the exception and I get the default handler's output instead:
Exception in thread "main" java.lang.Exception: Launched coroutine failed
at org.mtopol.TestingKt$handlerTest$1.invokeSuspend(testing.kt:14)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
...
at org.mtopol.TestingKt.main(testing.kt:18)
at org.mtopol.TestingKt.main(testing.kt)
Even when I add the handler to runBlocking
:
runBlocking(handler) { this.handlerTest() }
it still crashes without the handler observing the exception.
The behavior is not specific to runBlocking
, I can write the code as follows:
GlobalScope.launch { this.handlerTest() }
and I still get just a crash (now inside the Default
thread pool).
Finally, if I write it as follows (adding the handler to the top-level launch
):
GlobalScope.launch(handler) { this.handlerTest() }
this time it gets handled.
After studying the documentation on CoroutineExceptionHandler
in detail, I found the above behaviors surprising. The documentation explicitly specifies only what happens when a coroutine exception handler is not installed, and I could only indirectly guess at what it was supposed to do when there is one. Yet my guesses were all wrong. Paraphrasing, the docs seem to claim the following:
- Never handle a
CancellationException
. - If there is a
Job
in the context, invokeJob.cancel
. - Otherwise pass the exception to the exception handler.
The actual behavior is more like the following:
- Always invoke
coroutineContext[Job]!!.cancel()
(since there is always aJob
in any coroutine's context) - To decide whether to pass the exception to the handler, follow these rules:
a. Never handle aCancellationException
.
b. If the current coroutine has a parent coroutine, don't handle the exception. it was passed to the parent viajob.cancel()
.
c. Otherwise pass the exception to the exception handler.
Furthermore, the behavior with runBlocking
seems like a bug to fix.