Question

Puzzling Coroutines behavior in Android ViewModel

I am trying to explore and implement coroutine cancellation / exception recovery mechanism in ViewModel. I discovered that the following code in my ViewModel doesn't catch the exception and crashes the app:

viewModelScope.launch {
    try {
        supervisorScope {
            launch {
                throw Exception()
            }
        }
    } catch (e: Exception) {
        println("Exception caught")
    }
}

But if I replace supervisorScope with coroutineScope it gets caught. Shouldn't it get caught in both cases? Can anyone please explain why supervisorScope scope exception cancels its parent scope here?

I tried running following code in Intellij: case1 :

runBlocking {
    supervisorScope {
        launch {
            throw Exception("Supervisor launch exception")
        }
    }
}

vs case 2:

runBlocking {
    launch {
        throw Exception("Launch Exception")
    }
}

In first case the process finished with exit code 0 and in second case, finished with exit code 1. Why does it give different exit code when both propagates exception to parent?

 2  47  2
1 Jan 1970

Solution

 1

The main function of supervisorScope - in contrast to coroutineScope - is to keep running, even when a child coroutine fails. This is especially useful if there a multiple children running that shouldn't affect one another.

Instead of failing when a child coroutine fails (which is the case in your example), it propagates the exception to its own parent. In your case that is the coroutine launched in viewModelScope. That effectively bypasses your try/catch block and failing the viewModelScope directly. As a side note, would you throw the exception directly in the supervisorScope instead of a nested launch, the supervisorScope would fail and the exception would be caught by the try/catch block. Only failed children are handled specially by supervisorScope.

As an alternative to your try/catch block, exceptions in coroutines can be effectively handled by a CoroutineExceptionHandler:

val handler = CoroutineExceptionHandler { _, e ->
    println("Exception caught: $e")
}

viewModelScope.launch(handler) {
    supervisorScope {
        launch {
            throw Exception()
        }
    }
}

Now, when the supervisorScope propagates the exception to the parent launch, the exception handler that is defined there catches the exception and prevents the coroutine to be cancelled, therefore also preventing your app to crash. This will also work the same when using a coroutineScope instead of the supervisorScope.

Read more on coroutine exception handling in the documentation: https://kotlinlang.org/docs/exception-handling.html

2024-07-23
Leviathan

Solution

 0

The difference between exception handling in coroutineScope and supervisorScope is in children failure propagation.

In coroutineScope all children coroutines delegate handling of their exceptions to their parent coroutine. When one of them encounters an exception other than CancellationException, it cancels its parent with that exception. That's why try with coroutineScope works - coroutineScope just rethrows the exception from its children coroutine.

In supervisorScope child's failure does not propagate to the parent. Every child should handle its exceptions by itself via CoroutineExceptionHandler. If it doesn't, the exception becomes uncaught and goes up until it finds a parent coroutine with a CoroutineExceptionHandler installed. If no CoroutineExceptionHandler was found, the exception will be handled by Thread.defaultUncaughtExceptionHandler or, if it is not set, just printed to the error output stream.

First example:

supervisorScope doesn't know about its child coroutine fail, so try...catch won't work. To handle the exception, install a CoroutineExceptionHandler on either viewModelScope.launch or the inner launch.

Case 1:

launch in supervisorScope doesn't delegate exception handling and doesn't handle the exception itself, the exception is uncaught. runBlocking doesn't have a CoroutineExceptionHandler, so Thread's uncaught exception handling mechanism handles it.

Case 2:

launch delegates exception handling to runBlocking, because it is not supervised. CoroutineExceptionHandler wouldn't help in this case, because it is not an uncaught exception. The exception is unhandled by runBlocking, exit code is 1.

2024-07-24
Jan Itor