Why does this coroutine keep running after its scope was cancelled?
A repository call is wrapped in runCatching so that a network failure is logged instead of crashing the screen. But after the scope is cancelled the coroutine does not stop: it logs a bogus failure and calmly walks on to the next line.
Constraints: fetchUser is a suspend function doing a cancellable network call, real network failures must still be logged, and cancellation must actually stop the coroutine.
suspend fun load(id: String): User? =
runCatching { fetchUser(id) }
.onFailure { log.warn("load failed", it) }
.getOrNull()
Find and fix the bug.
runCatching catches Throwable, so it also swallows the CancellationException that cancellation throws: the coroutine reads cancellation as an ordinary failure, logs it and carries on. Rethrow it, or catch only the exceptions you can act on.
- ✗Thinking
runCatchingcatches onlyExceptionand leavesCancellationExceptionalone - ✗Treating a caught
CancellationExceptionas a real failure worth logging and retrying - ✗Assuming
getOrNull()on a failedResultrethrows and thereby stops the coroutine
- →Why does the same bug show up with a bare
try/catch (e: Throwable)? - →What does calling
coroutineContext.ensureActive()insideonFailurechange here?
The bug
runCatching catches Throwable, not Exception. Coroutine cancellation arrives as a CancellationException, so it lands in the very same Result.failure:
suspend fun load(id: String): User? =
runCatching { fetchUser(id) } // catches CancellationException too
.onFailure { log.warn("load failed", it) } // logs cancellation as a failure
.getOrNull() // the coroutine walks on regardless
Cooperative cancellation relies on that CancellationException flying all the way up to the builder. Swallowing it breaks the chain: the scope counts as cancelled while the coroutine keeps working.
The fix
Rethrow cancellation, log only genuine failures:
suspend fun load(id: String): User? =
runCatching { fetchUser(id) }
.onFailure { if (it is CancellationException) throw it }
.onFailure { log.warn("load failed", it) }
.getOrNull()
Cleaner still — do not catch Throwable at all, catch only what you can act on:
suspend fun load(id: String): User? =
try {
fetchUser(id)
} catch (e: IOException) { // a CancellationException never lands here
log.warn("load failed", e)
null
}