CancellationException in Coroutines
CancellationException in Coroutines
Kotlin Coroutines introduced structured concurrency as a fundamental principle, ensuring that coroutines are properly scoped and cancelled when their parent scope completes. At the heart of this mechanism lies CancellationException, a special exception that signals cancellation and must be handled with care. While most developers know they shouldn't catch this exception, the deeper question remains: why is CancellationException special, and what happens when you accidentally swallow it?
In this article, you'll dive deep into the internal mechanisms of CancellationException, exploring why it must be re-thrown, how runCatching can break structured concurrency, the proposals for safer alternatives, and the design decisions that make cancellation propagation both correct and performant.
The fundamental problem: Catching cancellation breaks structured concurrency
Consider this seemingly innocent code:
suspend fun processData(): Result<Data> = runCatching {
val user = fetchUser()
val profile = fetchProfile(user.id)
Data(user, profile)
}
This looks reasonable. You're wrapping a suspend operation in runCatching to convert exceptions into Result values for safer error handling. But there's a subtle bug: if the coroutine is cancelled during fetchUser() or fetchProfile(), the CancellationException is caught by runCatching and wrapped in Result.failure(). The cancellation signal never propagates to the parent scope, breaking structured concurrency.
The core issue is that runCatching is implemented like this:
public inline fun <R> runCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
Notice the catch (e: Throwable) clause. This catches everything, including CancellationException. When cancellation occurs, instead of propagating up the coroutine hierarchy, it's captured in a Result object, and the coroutine continues executing as if nothing happened.
Understanding CancellationException: Not just another exception
CancellationException is fundamentally different from other exceptions in Kotlin Coroutines. Let's examine its definition:
public actual open class CancellationException(
message: String?,
cause: Throwable?
) : IllegalStateException(message, cause)
It extends IllegalStateException, but its purpose is not to signal an error, it's to signal intentional cancellation. This distinction is crucial for understanding why it must be handled specially.
The cancellation contract
When a coroutine is cancelled, the cancellation mechanism works through these steps:
- Cancellation signal: The parent scope or job calls
cancel()on the coroutine'sJob - CancellationException thrown: At the next suspension point, the coroutine throws a
CancellationException - Propagation: The exception propagates up the coroutine hierarchy
- Cleanup: Each coroutine in the chain can run cleanup logic in
finallyblocks - Parent notification: The parent scope is notified that the child completed due to cancellation
If you catch CancellationException and don't re-throw it, steps 3-5 never happen. The parent scope thinks the child is still running, resource cleanup might not occur, and the entire structured concurrency guarantee breaks down.
The invisibility principle
CancellationException is treated specially by the coroutines library in several ways:
// From kotlinx.coroutines source
internal fun Throwable.isCancellation(): Boolean =
this is CancellationException
// Cancellation exceptions don't trigger exception handlers
internal fun handleCoroutineException(context: CoroutineContext, exception: Throwable) {
if (exception is CancellationException) return // Normal cancellation
// ... handle other exceptions
}
When a coroutine completes with a CancellationException, it's not treated as an error. The CoroutineExceptionHandler doesn't receive it, crash reporting doesn't log it, and parent jobs don't fail because of it. This is the invisibility principle: cancellation is a normal control flow mechanism, not an exceptional condition.
But this principle only works if CancellationException is allowed to propagate. If you catch it, you've converted an invisible signal into visible state, breaking the abstraction.
The runCatching problem: Breaking coroutine contracts in standard library code
The challenge with runCatching is that it's a standard library function, not a coroutines-specific function. It was designed before coroutines were stabilized, and it operates on any () -> R block, suspending or not:
// Works for both suspend and non-suspend code
val result1: Result<String> = runCatching { "Hello" }
val result2: Result<String> = runCatching { suspendFunction() }
This dual nature creates a problem. For non-suspending code, catching all Throwable instances is reasonable. For suspending code, catching CancellationException is dangerous.
The GitHub issue discussion
The kotlinx.coroutines issue #1814 discusses this exact problem. The original proposal suggested introducing a new function:
public inline suspend fun <R> runSuspendCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (c: CancellationException) {
throw c
} catch (e: Throwable) {
Result.failure(e)
}
}
This variant explicitly re-throws CancellationException while catching all other exceptions. The implementation is straightforward: add an explicit catch clause for CancellationException before the general Throwable catch.
Why not just fix runCatching?
You might wonder: why not just change runCatching to re-throw CancellationException? The answer is backwards compatibility. Existing code might depend on the current behavior:
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor