Why does this filter keep burning CPU after the screen has been closed?
A pixel filter runs in viewModelScope. When the user leaves the screen the ViewModel is cleared and the scope is cancelled, yet the loop keeps burning CPU until the last pixel.
Constraints: the loop is pure CPU work with no suspending call inside it, the coroutine must stop within about one iteration of being cancelled, and the produced pixels must stay correct.
fun applyFilter(pixels: IntArray) = viewModelScope.launch(Dispatchers.Default) {
for (i in pixels.indices) {
pixels[i] = heavyTransform(pixels[i])
}
_result.value = pixels
}
Find and fix the bug.
Cancellation is cooperative: it lands only at a suspension point or an explicit check, and this loop has neither, so it runs to the end. Check it every iteration with ensureActive() or yield(), or guard the loop with while (isActive).
- ✗Assuming a cancelled coroutine is force-stopped at an arbitrary point
- ✗Thinking the dispatcher, not a suspension point, is what makes cancellation land
- ✗Believing a CPU loop is interrupted between its iterations for free
- →How does
ensureActive()differ from readingisActivein this loop? - →What would
yield()add here beyond the cancellation check?
The bug
Cancellation in coroutines is cooperative. cancel() only moves the Job into a cancelled state — it cannot yank the thread out of running code. The cancellation "lands" only where the coroutine suspends, or asks about its own state:
for (i in pixels.indices) {
pixels[i] = heavyTransform(pixels[i]) // no suspension point at all
}
There is no suspend call and no check here, so the loop dutifully finishes every pixel while already cancelled, and only then does the coroutine complete.
The fix
One check per iteration is enough:
fun applyFilter(pixels: IntArray) = viewModelScope.launch(Dispatchers.Default) {
for (i in pixels.indices) {
ensureActive() // throws CancellationException once cancelled
pixels[i] = heavyTransform(pixels[i])
}
_result.value = pixels
}
ensureActive() throws CancellationException as soon as the Job is cancelled, so the loop stops on the very next iteration. yield() does the same and additionally hands the thread to other coroutines (useful when the pool is busy). A while (isActive) guard ends the loop quietly, with no exception — which fits when a partial result is acceptable on its own.