skip to content

Why is CancellationException treated specially during structured exception propagation, and what bug does swallowing it cause?

level: seniorimportance: must knowfreq 70%

answer

  1. Cancellation = thrown CancellationException at suspension points
  2. Parent treats it as normal completion, not failure
  3. Broad catch (Exception) swallows it → breaks timeouts/teardown
  4. Rethrow: catch narrow, or ensureActive(), or throw e
  5. NonCancellable for suspending cleanup in finally

basics

~20 s

Cancellation works by throwing a special CancellationException. It only stops the coroutine being cancelled, not its siblings, and the parent treats it as normal. If your catch block swallows it, the coroutine ignores cancellation and keeps running.

solid answer

~50 s

Cancellation in kotlinx.coroutines is implemented by throwing `CancellationException` at suspension points after the Job moves to the Cancelling state. It is special in two ways: (1) the parent treats a child completing with CancellationException as **normal completion**, so it does NOT cancel siblings or fail the scope — unlike any other exception which propagates and cancels the parent's children; (2) it signals 'this coroutine should stop' rather than 'something went wrong'. The classic bug is a broad `catch (e: Exception)` (or `catch (e: Throwable)`) that swallows the CancellationException, so the coroutine keeps executing after it was cancelled — breaking timeouts (`withTimeout`), scope teardown, and `coroutineScope` cancellation. The fix is to rethrow it: catch the specific exception you care about, or `if (e is CancellationException) throw e`. `ensureActive()` and the `isActive` flag let you cooperate with cancellation in CPU-bound loops that never suspend.

code

kotlin · 19 lines
kotlin
import kotlinx.coroutines.*

suspend fun main() {
    val job = launch {
        try {
            repeat(1000) { i ->
                delay(100)
                println("working $i")
            }
        } catch (e: CancellationException) {
            println("cancelled cleanly")
            throw e // rethrow so the framework completes cancellation
        } finally {
            withContext(NonCancellable) { delay(10); println("suspending cleanup ok") }
        }
    }
    delay(250)
    job.cancelAndJoin()
}

go deeper

for a junior

Knows cancellation is cooperative and that you shouldn't swallow CancellationException.

for a middle

Explains it is thrown at suspension points and that the parent treats it as normal completion.

for a senior

Diagnoses the broad-catch swallowing bug, knows ensureActive()/isActive/yield and NonCancellable for cleanup.

for a principal

Designs cancellation-safe abstractions and library code, reasoning about teardown ordering, resource safety, and timeout correctness.

## What cancellation actually is When you cancel a coroutine — via `job.cancel()`, scope cancellation, `withTimeout`, or a parent failing — the coroutine's `Job` transitions to the **Cancelling** state. The machinery then throws a `CancellationException` (a subclass of `IllegalStateException`) at the next **suspension point** (`delay`, `yield`, any `suspend` call that checks for cancellation). This is **cooperative cancellation**: code that never suspends and never checks `isActive` will not notice it. ## Why it is propagation-special Normal structured-concurrency rule: a child throwing an exception fails its parent, which cancels all siblings. `CancellationException` is the explicit exception to that rule: - A child finishing with `CancellationException` is treated by the parent as **normal/expected completion**. - It does **not** cancel siblings and does **not** fail a `coroutineScope`. - This is essential — otherwise cancelling one coroutine (or hitting a timeout) would tear down unrelated siblings and look like a crash. ## The swallowing bug ```kotlin launch { try { work() // suspends; may throw CancellationException when cancelled } catch (e: Exception) { // BUG: also catches CancellationException log.error("failed", e) // ... keeps going as if nothing happened } } ``` Because `CancellationException` is an `Exception`, this broad catch **swallows the cancellation signal**. Consequences: - `withTimeout { ... }` no longer actually stops the work. - Cancelling the scope / parent leaves this coroutine running (a leak). - The coroutine may try to use resources that were torn down. ## Correct patterns ```kotlin // 1) Catch only what you mean to handle try { work() } catch (e: IOException) { recover(e) } // 2) If you must catch broadly, rethrow cancellation try { work() } catch (e: CancellationException) { throw e // never swallow } catch (e: Exception) { log.error("failed", e) } // 3) Kotlin-idiomatic guard try { work() } catch (e: Exception) { coroutineContext.ensureActive() // rethrows if cancelled handle(e) } ``` ## Cooperating with cancellation in CPU loops If a loop never hits a suspension point, add a check: ```kotlin while (isActive) { /* or ensureActive() */ compute() } ``` `isActive` is an extension on `CoroutineScope`/`CoroutineContext`; `ensureActive()` throws `CancellationException` if the job is no longer active. `yield()` both checks cancellation and gives other coroutines a turn. ## Note on cleanup in finally A `finally` block runs during cancellation, but it runs in an already-cancelling context, so **suspending** calls there will immediately throw `CancellationException`. To run suspending cleanup, wrap it in `withContext(NonCancellable) { ... }`. ## Summary - CancellationException = 'stop', not 'error'; parents treat it as normal completion. - Never swallow it: rethrow, use `ensureActive()`, or catch narrowly. - Cooperate via `isActive` / `ensureActive()` / `yield()`; use `NonCancellable` for suspending cleanup.

  • Why might suspending code in a finally block fail to run during cancellation, and how do you fix it?
    During cancellation the job is already in Cancelling state, so any suspension point throws CancellationException immediately. Wrap the suspending cleanup in withContext(NonCancellable) { ... } so it can complete.
  • Does ensureActive() differ from isActive?
    isActive is a Boolean you check yourself; ensureActive() throws CancellationException if not active. Use isActive for loop conditions, ensureActive() to fail fast / after a broad catch.

CancellationException is a 'time to leave' tap on the shoulder, not an alarm; if you ignore the tap (swallow it), you stay in the building after everyone left.

saying these in an interview costs you the question

  • Using catch (e: Exception) around suspending work and logging it without rethrowing cancellation
  • Thinking cancellation forcibly stops a CPU-bound loop with no suspension points
  • Saying CancellationException cancels sibling coroutines
  • Calling plain suspending functions in finally and expecting them to run during cancellation
  • Believing job.cancel() guarantees immediate stop regardless of code

context