How to cancel a kotlin coroutine and make sure it was cancelled

49 Views Asked by At

How can I make sure the coroutine was in fact cancelled, and did not just return or was cancelled by other means?

There is an obvious race-condition between checking job state and invoking cancel(). How can I know it was in fact cancelled?

val job = CoroutineScope(Dispatchers.Default).async { delay(1) }
if (!job.isCompleted)
  job.cancel()
1

There are 1 best solutions below

1
broot On BEST ANSWER

Maybe there is a better way, but one way is to cancel the job with a very specific exception and then check if it was cancelled with exactly the same exception. This is fairly easy if using Deferred as in your example:

suspend fun main(): Unit = coroutineScope {
    val job = GlobalScope.async {
        delay(1.seconds)
        println("Done")
    }
    launch {
        delay(1.seconds)
        println("Cancelled #1: " + job.cancelAndCheck())
    }
    launch {
        delay(1.seconds)
        println("Cancelled #2: " + job.cancelAndCheck())
    }
}

suspend fun Deferred<*>.cancelAndCheck(): Boolean {
    val e = CancellationException()
    cancel(e)
    join()
    return getCompletionExceptionOrNull() === e
}

At least on my machine I sometimes see it was cancelled by #1, sometimes by #2 and sometimes by none (completed successfully). As this is a race condition, it may be hard or even impossible to reproduce on other computers.

Surprisingly, I don't see a similar API for the Job. Obviously, we can't get a result from Job, but I don't see why getCompletionExceptionOrNull() couldn't be added at the Job level, not Deferred. The only way I found is by using the invokeOnCompletion, but if feels a bit hacky:

suspend fun main(): Unit = coroutineScope {
    val job = GlobalScope.launch {
        delay(1.seconds)
        println("Done")
    }
    launch {
        delay(1.seconds)
        println("Cancelled #1: " + job.cancelAndCheck())
    }
    launch {
        delay(1.seconds)
        println("Cancelled #2: " + job.cancelAndCheck())
    }
}

suspend fun Job.cancelAndCheck(): Boolean {
    val e = CancellationException()
    cancel(e)
    return suspendCancellableCoroutine { cont ->
        val handle = invokeOnCompletion { cont.resume(it === e) }
        cont.invokeOnCancellation { handle.dispose() }
    }
}

Both solutions probably require additional testing for various cases like non-cancellable jobs, etc.