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")