-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
CancellationException can be thrown not only to indicate cancellation #3658
Comments
Need to dive deep into the implementation to see if this is possible, but it looks like modifying Consider this simplified scenario: runBlocking {
val result = async(Dispatchers.Default) {
delay(5.seconds)
}
launch(Dispatchers.Unconfined) {
result.await()
}
delay(1.seconds)
cancel() // cancel the common scope
} Here, we possibly have a race: the global scope is cancelled, which causes the coroutines in
So, the code can fail with one exception or rarely with another. This is just one simplified example of a broader class of races that we could introduce. |
|
The above could be implemented as an extension function:
|
This is a robust solution, but non-Kotlin-style. Kotlin tries to keep hierarchies of return types as thin as possible to save the users the boilerplate of unpacking the results. For example, |
Well, this is as minimal as |
Just a |
But then you lose info about the origin of the |
Another approach is to expose a subclass of CE, which will mean "another coroutine was cancelled":
|
What should happen if the code in |
If you are using IMO, the great takeaway from the original post on the "The Silent Killer" is this pattern:
That should be the recommended pattern to use instead of trying to catch However, there is also one thing that can be fixed in the coroutines library itself in a backwards compatible way. The current state of affairs is that the coroutine that dies by a rogue suspend fun main() {
val cancelledDeferred = CompletableDeferred<Int>()
cancelledDeferred.cancel()
supervisorScope {
// This coroutine fails in a regular way
launch(CoroutineName("one")) {
error("Regular failure")
}
// This coroutine fails while waiting on cancelled coroutine
launch(CoroutineName("two")) {
cancelledDeferred.await()
}
}
} Neither coroutine "one" nor coroutine "two" is cancelled, both fail with exception, yet only the exception for the failure of the first coroutine is shown. We can consider changing this logic to report that the second coroutine has failed with |
It can be handled by treating CE as failure if the current coroutine was not actually cancelled. Treating CEs in a non-cancelled coroutine as failure would imply that the manual cancellation should be done differently:
Now, a function for |
Please don't make that the recommended pattern. The point of The proper solution is to fix the APIs where possible and introduce new fixed APIs where backwards compatibility is needed. The |
I was debugging something like this for two days(in a production environment, had to add many log/counter and deploy and wait a reproducer, do that again for at least 20 times, VERY bad experience), even suspected at one point that it was a compiler BUG... // simplified reproducer, the original is more complicated
// thus it's even harder to debug such kind of control-flow issue
suspend fun structuredConcurrency(toBeProcessedData:Collection<Any>){
coroutineScope{
for(data in toBeProcessedData)
launch{handleData(data)}
}
println("all data was processed")
} the well, this is because deep down in the
Anyway I diverge, I agree with @elizarov "We can consider changing this logic to report that the second coroutine has failed with CancelletationException, too", and here's my advice:
|
Here are my 5 cents. When you are waiting for coroutine you might face two different states:
Should I expect the latter?
But from the other hand:
See: coroutines are like processes. Sometimes you do not expect them to die (if this process I'd say that CE shouldn't cross the scope boundaries. If you are waiting for Job from another scope -- your own scope shouldn't be affected by foreign CE. I wish we had
|
@throwable-one, it seems your problem is not with how exceptions are handled in the library overall but with the specific behavior of @OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T> Deferred<T>.newAwait(): AwaitResult<T> {
join()
return when (val exception = getCompletionExceptionOrNull()) {
null -> AwaitResult.Ok(getCompleted())
is CancellationException -> AwaitResult.Stopped(exception)
else -> throw exception
}
} |
Fixes two issues: * It is surprising for some users that the same exception can be thrown several times. Clarified this point explicitly. * Due to #3658, `await` can throw `CancellationException` in several cases: when the `await` call is cancelled, or when the `Deferred` is cancelled. This is clarified with an example of how to handle this. Fixes #3937
Fixes two issues: * It is surprising for some users that the same exception can be thrown several times. Clarified this point explicitly. * Due to #3658, `await` can throw `CancellationException` in several cases: when the `await` call is cancelled, or when the `Deferred` is cancelled. This is clarified with an example of how to handle this. Fixes #3937
2 cents on the design, please destroy everything I say. AFAIU
fun main() = runBlocking {
launch {
throw CancellationException()
}
println("Hello, world!")
} Kotlinx coroutines also encodes three possible states:
with a single If the above makes sense, then kotlinx coroutines could:
In an ideal world, Instead of interface Job {
public fun cancel(cause: CancellationException? = null)
} we could have internal class CancellationException: Throwable
interface Job {
public fun cancel(message: String? = null, cause: Throwable? = null)
}
fun Throwable.rethrowIfCancellationSignal() {
if (this is CancellationException) { throw this }
} The state of the coroutine conceptually would be: sealed class State {
data class Completed(val value: Any?): State()
data class Failed(val cause: Throwable): State()
data class Cancelled(val message: String, val cause: Throwable?): State()
} and fun await() = when(state) {
is State.Cancelled -> throw CoroutineCancelledException(state.message, state.cause)
is State.Completed -> state.value
is State.Failed -> throw state.cause
} This is just to say that IMHO this should not be "solved" by the developer using the
This is not a great pattern IMHO, and should be handled by the library if possible, even if getting there with backwards compatibility could be painful. |
Overall, I agree with your points.
This is the main problem. Yes, this would be painful, to the point I personally would call hopeless. If there ever is a
I agree that the pattern proposed above is suboptimal. Here's a better one: try {
// do anything
} catch (e: Throwable) {
if (e is CancellationException) currentCoroutineContext().ensureActive()
handleException(e)
} The pattern without |
Fixes two issues: * It is surprising for some users that the same exception can be thrown several times. Clarified this point explicitly. * Due to #3658, `await` can throw `CancellationException` in several cases: when the `await` call is cancelled, or when the `Deferred` is cancelled. This is clarified with an example of how to handle this. Fixes #3937
…lling Coroutines { https://betterprogramming.pub/the-silent-killer-thats-crashing-your-coroutines-9171d1e8f79b Kotlin/kotlinx.coroutines#3658 (comment) https://kotlinlang.org/docs/exception-handling.html } - Create the "Neutral" state for when CancellationExceptions are wrongfully launched (Refer to the above links to know when and how that happens) - Add an Architecture that uses both By Layer and By Feature separation of concerns (https://www.youtube.com/watch?v=16SwTvzDO0A) - Refactor dependencies between modules - Remove datasource dependency from midfield (a.k.a domain) - Create specific feature modules inside each base layer (ui, midfield (domain) and datasource (data/model)) - Bump dependency versions to newer ones - Turn common dependencies into bundles - Replace multiple dependency references with Bundles - Refactor code - Remove all occurences of suspend functions being executed using runBlocking from the project - Remove runBlocking from the project - Remove fundamental features (Pagination, Upper views management etc...) (Temporarily until I implement them back, but in Compose) - Create specific modules for both JVM and Integrated/UI tests (The old way, JVM tests had access to Integrated/UI test dependencies and vice-versa, now that's sorted out) - Split Remote datasource models from Local datasource models (Then, there would be only one class having both kotlin.serialization and Room annotations) - Create mapping from Remote datasource model to Local datasource model (Entity) - Create mapping from Local datasource model to clean model (No external dependencies referenced) (Plain Old Kotlin Object) KNOWN ISSUES: 1- There's no pagination anymore (It will be re-implemented in the future using Paging Compose) 2- There may be UI inconsistencies when CancellationExceptions are launched intentionally or not (Will be addressed in the future) FEATURES: No new features have been introduced
https://betterprogramming.pub/the-silent-killer-thats-crashing-your-coroutines-9171d1e8f79b raises a good point:
CancellationException
should indicate that the coroutine in which it is raised is cancelled, but it's not always actually the case:Here, the programmer didn't make the mistake of catching a
CancellationException
and rethrowing it somewhere else, but still, theCancellationException
leaked from a different scope.The text was updated successfully, but these errors were encountered: