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 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.sandboximport gt.sandbox.util.output.Outimport kotlinx.coroutines.CancellationExceptionimport kotlinx.coroutines.CoroutineNameimport kotlinx.coroutines.delayimport kotlinx.coroutines.launchimport kotlinx.coroutines.runBlockingsuspend 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.debugPicked 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}
✅ Extension methods for handling cooperative cancellation
Extension methods for handling cooperative cancellation
The following extension methods are concise way to avoid having to write separate catch for cancellation exception over and over again.
/** * Executes a suspend function, catching all exceptions except [CancellationException]. * * Does not catch [Error] or other [Throwable] types that don't extend [Exception]. * Cancellation exceptions are always rethrown to preserve structured concurrency. * * Example: * ``` * suspend { * syncData() * }.catching { e -> * logger.error("Sync failed", e) * } * ``` */suspend fun (suspend () -> Unit).catching( onError: suspend (Exception) -> Unit) { try { this() } catch (e: CancellationException) { throw e } catch (e: Exception) { onError(e) }}/** * Executes a suspend function that returns a value, catching all exceptions except [CancellationException]. * * Cancellation exceptions are always rethrown to preserve structured concurrency. * * @param onError Handler invoked when an exception occurs. Must return a fallback value of type [T]. * @return The result of the block, or the fallback value from [onError] if an exception occurs. * * Example: * ``` * val data = suspend { * fetchUserData() * }.catchingReturns { e -> * logger.error("Fetch failed", e) * emptyList() // fallback value * } * ``` */suspend fun <T> (suspend () -> T).catchingReturns( onError: suspend (Exception) -> T): T = try { this()} catch (e: CancellationException) { throw e} catch (e: Exception) { onError(e)}