Suspend Fun Swallowed Cancellation
ID |
kotlin.suspend_fun_swallowed_cancellation |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Kotlin |
Tags |
concurrency, reliability |
Description
Reports runCatching { … } calls whose lambda runs in a coroutine context
(a suspend function body or a coroutine builder lambda).
runCatching wraps the block in a try/catch that catches every Throwable,
including CancellationException. Inside coroutines that turns into silent
cancellation suppression: the parent coroutine asks the child to stop, the
child catches and ignores the cancellation signal, and the
structured-concurrency contract is broken.
Rationale
// Bad — swallows CancellationException
suspend fun fetch(): String {
return runCatching { fetchOverNetwork() }
.getOrDefault("fallback")
}
When the surrounding scope is cancelled, the coroutine running fetch should
stop. Instead, runCatching catches the CancellationException, the result
becomes Result.failure(CancellationException), the call returns
"fallback", and the work continues. The cancellation deadline is missed and
upstream code that awaits the cancellation never sees it.
Remediation
Inside coroutines, use try { … } catch (e: Exception) { … }. Kotlin
coroutines specifically rely on the fact that CancellationException is a
Throwable but not an Exception in the typical sense — catching Exception
lets cancellation propagate.
suspend fun fetch(): String = try {
fetchOverNetwork()
} catch (e: Exception) {
"fallback"
}
If runCatching must be used, rethrow CancellationException explicitly:
suspend fun fetch(): String = runCatching { fetchOverNetwork() }
.onFailure { if (it is CancellationException) throw it }
.getOrDefault("fallback")