⚠️ Swallowing cancellation exception prevents cancellation ⚠️

Cancellation is cooperative co-routines MUST call Cancellation Cooperative Functions/suspension point to receive CancellationException and when they do receive Cancellation Exception they must rethrow it.

Example we explicitly swallow Cancellation

runBlocking - cancelled co-routine immorally "swallows" Cancellation exception, and keeps going - 😈🐛😈

Dont do this!

This is clear example of what you should NOT be doing.

In this example we have 2 co-routines:

  • First co-routine is going to throw exception after 2 seconds.
  • Second co-routine is processing some unrelated messages every half second.

Once 1st co-routine throws exception:

  • Second co-routine gets Cancellation Exception on invocation of delay() (delay is Cancellation Cooperative Functions/suspension point)
    • BUT in this case second co-routine does something 😈evil😈 and it logs Cancellation exception WITHOUT rethrow, and keeps going. Allowing second co-routine to execute its functionality back-to-back WITHOUT delay.
  • After second co-routine immorally finishes all of its messages without delay, the original exception is rethrown to the parent. Where it's caught in the catch block at main() level.

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Out
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

suspend fun main(): kotlin.Unit {
  val out = Out.standard()

  out.info("START")

  try {
    val runBlocking = runBlocking {
      launch(CoroutineName("WillFail")) {
        val timeMillis = 2000L
        out.info("This one will throw in $timeMillis ms")
        delay(timeMillis)
        out.warn("OK I am about to throw an exception!")
        throw MyExceptionWillThrowFromCoroutine("I-Failed-In-CoRoutine")
      }

      launch(CoroutineName("JustPrints")) {
        (0..10)
          .map { "a-${it}" }
          .forEach {
            out.info(it)

            try {
              delay(500)
            } catch (e: CancellationException) {
              val excMsg = e.message ?: e.toString()
              val emoji = "\uD83D\uDE08"
              out.warn("$emoji I caught and swallowed [${e::class.simpleName}/$excMsg] thrown from delay() $emoji")
            }
          }
      }
    }
  } catch (e: Exception) {
    out.error("runBlocking threw an exception! of type=[${e::class.simpleName}] with msg=[${e.message}]")
  }


  out.info("DONE")
}

class MyExceptionWillThrowFromCoroutine(msg: String) : RuntimeException(msg)

Command to reproduce:

gt.sandbox.checkout.commit 7b37b9e9e1061e7d56c7 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   17ms][🥇][🧵][tname:main/tid:1] START
[INFO][elapsed:   47ms][🥇][1️⃣][coroutname:@WillFail#2][tname:main/tid:1] This one will throw in 2000 ms
[INFO][elapsed:   54ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-0
[INFO][elapsed:  555ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-1
[INFO][elapsed: 1056ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-2
[INFO][elapsed: 1556ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-3
[WARN][elapsed: 2052ms][🥇][1️⃣][coroutname:@WillFail#2][tname:main/tid:1] OK I am about to throw an exception!
[WARN][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-4
[WARN][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-5
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-6
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-7
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-8
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-9
[WARN][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-10
[WARN][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[ERROR][elapsed: 2104ms][🥇][🧵][tname:main/tid:1] runBlocking threw an exception! of type=[MyExceptionWillThrowFromCoroutine] with msg=[I-Failed-In-CoRoutine]
[INFO][elapsed: 2105ms][🥇][🧵][tname:main/tid:1] DONE

Can inadvertently swallow CancellationException

While the above example quite clearly does something evil explicitly swallowing the CancellationException, we can very easily run into this issue by inadvertently swallowing cancellation exception. Let's take a look at example pseudo-code to illustrate.

Example inadvertently swallowing CancellationException

// In this example you parse some lines and it's ok for some lines to fail parsing.
// So you try{}catch()/log and keep going.
// 
// But you inadvertently end up swallowing cancellation exception and hence not respecting cancellation.
parseLines(){
    for (lines):{
        try {
            accumulator.add(parseVisitHistoryLine())
        } catch(e: Exception){
            // Let's say its ok for some lines to not be parsable so you log and keep going.
            // The problem though is that CancellationException is descendent of Exception 
            // So what you are not respecting cancellation here.
    
            log.dataError(e)
        }
    }

    return accumulator
}

Must Rethrow CancellationException

// What you should do in this case is to have a separate catch for CancellationException and rethrow it.
parseLines(){
    try{
        accumulator.add(parseVisitHistoryLine())
     } catch(cancelExc: CancellationException){
        // Log cancellation with some context
        log.cancel(cancelExc)

        // Re-throw cancellation exception so that you cooperate with cancellation.
        throw cancelExc
    } catch(e: Exception){
        // Log other exceptions and keep going.
        log.dataError(e)
    }

    return accumulator
}


Backlinks