Consider this attempt to integrate coroutines with cancellable callback-based API using suspendCancellableCoroutine. This example is for Retrofit, but the same issue could appear with any cancellable API:
suspend fun <T> Call<T>.await(): T =
suspendCancellableCoroutine { cont ->
enqueue(object : Callback<T> { // install callback
override fun onResponse(call: Call<T>, response: Response<T>) {
// resume normally -- omitted for clarity
}
override fun onFailure(call: Call<T>, t: Throwable) {
if (cont.isCancelled) return // LINE (1)
cont.resumeWithException(t) // LINE (2)
}
})
// cancel Call when continuation is cancelled
cont.invokeOnCancellation {
cancel()
}
}
Resuming a CancellableContinuation with exception is documented to behave in the following way (see https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-cancellable-continuation/index.html):
Invocation of resume or resumeWithException in resumed state produces IllegalStateException. Invocation of resume in cancelled state is ignored (it is a trivial race between resume from the continuation owner and outer job cancellation and cancellation wins). Invocation of resumeWithException in cancelled state triggers exception handling of passed exception.
So, this code attempts to avoid calling resumingWithException on an already cancelled continuation by doing a check in LINE (1). This creates a "check & act" race in between LINE (1) and LINE (2). In particular, if continuation gets cancelled from a different thread when this code executes between LINE (1) and LINE (2), then resumeWithException is invoked on cancelled continuation and triggers handling of "uncaught" exception.
There is no public (non-experimental, non-internal) API at the moment to work around this race.
Workarounds
W1. Use internal API on CancellableContinuation:
Replace LINE (1) and LINE(2) with the following code:
@UseExperimental(InternalCoroutinesApi::class)
cont.tryResumeWithException(t)?.let { cont.completeResume(it) }
This workaround ignores exceptions when continuation is cancelled and this is the disadvantage of this workaround at the same time. If a true exception (failure) happens concurrently with cancellation, then it is going to be ignored instead of being handled. Arguably this problem is much less severe, but still.
W2. Use internal API on Job:
- Replace
suspendCancellableCoroutine with suspendCoroutine.
- Replace
cont.invokeOnCancellation { ... } with the following code:
@UseExperimental(InternalCoroutinesApi::class)
cont.context[Job]?.invokeOnCompletion(onCancelling = true) {
cancel()
}
- Replace LINE (1) with detection of "Cancelled" state on a
Call (to see if the call had failed or was cancelled) and resume continuation with CancellationException in case of cancellation:
if (call.isCanceled) {
cont.resumeWithException(CancellationException("Cancelled"))
return
}
This workaround correctly handles exceptions that occur concurrently with cancellation (the call is either going to be cancelled or fails and we learn what had happened).
Proposed solutions
There are several possible solutions:
S1. Ignore exceptions when resuming a cancelled continuation using resumeWithException. Technically, this is a breaking change, but that is not a major problem here. The problem is that there could be a genuine exception that needs to be handled and ignoring it in resumeWithException is as bad as any other instance of ignoring an exception.
S2. Introduce new resumeWithExceptionOrIgnoreOnCancel method (name is purely provisional). It is not a breaking change, but it suffers from the same problem of ignoring a genuine exception when it happens concurrently with cancellation. This is not a severe problem, though, and this could still be one of the solutions.
S3. New API for cancellable callbacks with an intermediate cancelling state. The idea is that invocation of cancel() should not immediately result in resumption of the suspended coroutine, but shall wait until resumeWith (value or exception) is invoked. It gives a chance to see whether the operation was indeed cancelled successfully or failed while we trying to cancel it. It requires writing somewhat more code, though, similar to the code described in the second workaround.
- This can be a new mode operation for
suspendCancellableCoroutine.
- This can be a new top-level function.
Consider this attempt to integrate coroutines with cancellable callback-based API using
suspendCancellableCoroutine. This example is for Retrofit, but the same issue could appear with any cancellable API:Resuming a
CancellableContinuationwith exception is documented to behave in the following way (see https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-cancellable-continuation/index.html):So, this code attempts to avoid calling
resumingWithExceptionon an already cancelled continuation by doing a check in LINE (1). This creates a "check & act" race in between LINE (1) and LINE (2). In particular, if continuation gets cancelled from a different thread when this code executes between LINE (1) and LINE (2), thenresumeWithExceptionis invoked on cancelled continuation and triggers handling of "uncaught" exception.There is no public (non-experimental, non-internal) API at the moment to work around this race.
Workarounds
W1. Use internal API on
CancellableContinuation:Replace LINE (1) and LINE(2) with the following code:
This workaround ignores exceptions when continuation is cancelled and this is the disadvantage of this workaround at the same time. If a true exception (failure) happens concurrently with cancellation, then it is going to be ignored instead of being handled. Arguably this problem is much less severe, but still.
W2. Use internal API on
Job:suspendCancellableCoroutinewithsuspendCoroutine.cont.invokeOnCancellation { ... }with the following code:Call(to see if the call had failed or was cancelled) and resume continuation withCancellationExceptionin case of cancellation:This workaround correctly handles exceptions that occur concurrently with cancellation (the call is either going to be cancelled or fails and we learn what had happened).
Proposed solutions
There are several possible solutions:
S1. Ignore exceptions when resuming a cancelled continuation using resumeWithException. Technically, this is a breaking change, but that is not a major problem here. The problem is that there could be a genuine exception that needs to be handled and ignoring it in
resumeWithExceptionis as bad as any other instance of ignoring an exception.S2. Introduce new
resumeWithExceptionOrIgnoreOnCancelmethod (name is purely provisional). It is not a breaking change, but it suffers from the same problem of ignoring a genuine exception when it happens concurrently with cancellation. This is not a severe problem, though, and this could still be one of the solutions.S3. New API for cancellable callbacks with an intermediate cancelling state. The idea is that invocation of
cancel()should not immediately result in resumption of the suspended coroutine, but shall wait untilresumeWith(value or exception) is invoked. It gives a chance to see whether the operation was indeed cancelled successfully or failed while we trying to cancel it. It requires writing somewhat more code, though, similar to the code described in the second workaround.suspendCancellableCoroutine.