Cooperative cancellation limits in NonCancellable cleanup blocks
0 reputation · 26 Jul 2023, 01:15 UTC
0 reputation · 26 Jul 2023, 01:15 UTC
In Kotlin coroutines, cancellation is cooperative, requiring the coroutine to check its activation state or hit a suspension point. While the NonCancellable context is designed to ensure critical cleanup code executes after a job has been cancelled, it introduces specific constraints regarding resource management and timeouts.
The primary concern arises when a cleanup block within NonCancellable contains a long-running operation or a suspension function that never completes. Since the context ignores cancellation signals, these operations can block the coroutine's completion indefinitely, even if the parent scope has been destroyed or a timeout was previously applied to the original coroutine block.
How can one safely limit the execution time of code inside a Non-Cancellable block without risking a permanent leak of the job? Is there a mechanism to propagate a secondary timeout specifically to NonCancellable blocks while still ensuring the primary cancellation logic is respected?
32275 reputation · 26 Jul 2023, 02:17 UTC
Inside a NonCancellable block the coroutine ignores CancellationException that would normally terminate the block. Because a withTimeout throws a TimeoutCancellationException (a subclass of CancellationException), the timeout is swallowed and the cleanup keeps running until it finishes on its own. The only way to enforce a hard limit is to move the timeout into a child coroutine that is *not* running in the NonCancellable context.
CancellationException but does not suppress other exceptions.CancellationException, so it is ignored inside NonCancellable.withContext(NonCancellable) {
coroutineScope { // creates a new child scope
withTimeoutOrNull(5_000L) { // 5‑second limit
cleanup() // may block or suspend
}
// optional: handle null (timed out) here
}
}
This launches a new child coroutine that will be cancelled by the timeout. The parent NonCancellable block finishes as soon as the child completes or times out.
withContext(NonCancellable) {
val job = launch {
withTimeoutOrNull(5_000L) { cleanup() }
}
job.join() // returns immediately if the job timed out
}
The job is a sibling of the NonCancellable block and is cancelled by the timeout. The parent does not wait for the child to finish beyond the timeout.
val supervisor = SupervisorJob()
withContext(NonCancellable + supervisor) {
launch {
withTimeoutOrNull(5_000L) { cleanup() }
}
// parent continues; any failure in the child is ignored by SupervisorJob
}
SupervisorJob prevents a failing child from cancelling the parent, but you still need the explicit timeout.
If cleanup() performs blocking I/O that cannot be cancelled, run it on a dedicated thread:
withContext(NonCancellable) {
withContext(Dispatchers.IO + SupervisorJob()) {
withTimeoutOrNull(5_000L) { blockingCleanup() }
}
}
Run a quick test:
runBlocking {
val start = System.currentTimeMillis()
launch {
withContext(NonCancellable) {
coroutineScope {
withTimeoutOrNull(200L) {
delay(10_000L) // pretend long work
}
}
}
}
println("finished after ${System.currentTimeMillis() - start} ms")
}
You should see the outer coroutine finish in roughly 200 ms, confirming the timeout was honored.
Does the cleanup perform blocking I/O (e.g., FileChannel.flush()) that cannot be cancelled by a coroutine timeout? If so, consider moving it to a separate thread or using a withContext(Dispatchers.IO) block with a timeout.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.