Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Exception thrown by deferred.await() within a runBlocking treated as unhandled even after caught

This code:

fun main() {
    runBlocking {
        try {
            val deferred = async { throw Exception() }
            deferred.await()
        } catch (e: Exception) {
            println("Caught $e")
        }
    }
    println("Completed")
}

results in this output:

Caught java.lang.Exception
Exception in thread "main" java.lang.Exception
    at org.mtopol.TestKt$main$1$deferred$1.invokeSuspend(test.kt:11)
    ...

This behavior doesn't make sense to me. The exception was caught and handled, and still it escapes to the top-level as an unhandled exception.

Is this behavior documented and expected? It violates all my intuitions on how exception handling is supposed to work.

I adapted this question from a thread on the Kotlin forum.


The Kotlin docs suggest using supervisorScope if we don't want to cancel all coroutines when one fails. So I can write

fun main() {
    runBlocking {
        supervisorScope {
            try {
                launch {
                    delay(1000)
                    println("Done after delay")
                }
                val job = launch {
                    throw Exception()
                }
                job.join()
            } catch (e: Exception) {
                println("Caught $e")
            }
        }
    }
    println("Completed")
}

The output is now

Exception in thread "main" java.lang.Exception
    at org.mtopol.TestKt$main$2$1$job$1.invokeSuspend(test.kt:16)
    ...
    at org.mtopol.TestKt.main(test.kt:8)
    ...

Done after delay
Completed

This, again, is not be the behavior I want. Here a launched coroutine failed with an unhandled exception, invalidating the work of other coroutines, but they proceed uninterrupted.

The behavior I would find reasonable is to spread cancellation when a coroutine fails in an unforeseen (i.e., unhandled) manner. Catching an exception from await means that there wasn't any global error, just a localized exception that is handled as a part of the business logic.

like image 596
Marko Topolnik Avatar asked Nov 09 '18 08:11

Marko Topolnik


3 Answers

After studying the reasons why Kotlin introduced this behavior I found that, if the exceptions weren't propagated this way, it would be complicated to write well-behaved code that gets cancelled in a timely fashion. For example:

runBlocking {
    val deferredA = async {
        Thread.sleep(10_000)
        println("Done after delay")
        1
    }
    val deferredB = async<Int> { throw Exception() }
    println(deferredA.await() + deferredB.await())
}

Because a is the first result we happen to wait for, this code would keep running for 10 seconds and then result in an error and no useful work achieved. In most cases we'd like to cancel everything as soon as one component fails. We could do it like this:

val (a, b) = awaitAll(deferredA, deferredB)
println(a + b)

This code is less elegant: we're forced to await on all results at the same place and we lose type safety because awaitAll returns a list of the common supertype of all arguments. If we have some

suspend fun suspendFun(): Int {
    delay(10_000)
    return 2
}

and we want to write

val c = suspendFun()
val (a, b) = awaitAll(deferredA, deferredB)
println(a + b + c)

We're deprived of the opportunity to bail out before suspendFun completes. We might work around like this:

val deferredC = async { suspendFun() }
val (a, b, c) = awaitAll(deferredA, deferredB, deferredC)
println(a + b + c)

but this is brittle because you must watch out to make sure you do this for each and every suspendable call. It is also against the Kotlin doctrine of "sequential by default"

In conclusion: the current design, while counterintuitive at first, does make sense as a practical solution. It additionally strengthens the rule not to use async-await unless you're doing parallel decomposition of a task.

like image 184
Marko Topolnik Avatar answered Nov 16 '22 08:11

Marko Topolnik


Though all the answers are right at there place but let me throw some more light in it that might help other users. It is documented here (Official doc) that:-

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 (In GlobalScope) when all its children terminate.

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.

Hope this will help.

like image 2
Pravin Divraniya Avatar answered Nov 16 '22 08:11

Pravin Divraniya


This can be resolved by slightly altering the code to make the deferred value be executed explicitly using the same CoroutineContext as the runBlocking scope, e.g.

runBlocking {
    try {
        val deferred = withContext(this.coroutineContext) {
            async {
                throw Exception()
            }
        }
        deferred.await()
    } catch (e: Exception) {
        println("Caught $e")
    }
}
println("Completed")

UPDATE AFTER ORIGINAL QUESTION UPDATED

Does this provide what you want:

runBlocking {
    supervisorScope {
        try {
            val a = async {
                delay(1000)
                println("Done after delay")
            }
            val b = async { throw Exception() }
            awaitAll(a, b)
        } catch (e: Exception) {
            println("Caught $e")
            // Optional next line, depending on whether you want the async with the delay in it to be cancelled.
            coroutineContext.cancelChildren()
        }
    }
}

This is taken from this comment which discusses parallel decomposition.

like image 1
Yoni Gibbs Avatar answered Nov 16 '22 08:11

Yoni Gibbs