kotlinconcurrencycoroutinecancellation

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


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()

Solution

  • 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.