1. Coroutine cancellation model
File operations on the JVM are usually blocking: the thread is genuinely busy until the OS returns control. That’s why a “Cancel” button for copying/processing large files isn’t a luxury—it’s a basic part of UX and stability: the user should be able to stop a long task in a civilized way, without killing the process and without corrupting data.
Cancellation is an agreement, not magic
When a “Cancel” button appears in an app, a beginner programmer often has a dream: I’ll call cancel(), and somewhere deep inside the computer a little gnome will neatly stop everything that was happening. Reality is more mundane—and honestly more useful: coroutine cancellation in Kotlin is cooperative. That means the code itself must “notice” that it was cancelled and finish correctly.
Cooperativeness isn’t a drawback—it’s a predictability guarantee. Imagine if you could stop a coroutine at any microsecond, anywhere at all. Then it could stop “between” two logically related actions, leave data half-done, and you’d end up chasing bugs like “sometimes the file is corrupted, but only on Tuesdays.”
With coroutines it’s simpler: cancellation is a signal, and your job is to check that signal regularly during long work and exit “like a human.”
Why file operations often “don’t hear” cancel()
Files aren’t “memory in a variable”—they’re interaction with the OS and disk. Many operations at the File, InputStream, OutputStream level are blocking: the thread is truly busy until the OS returns control. And here is the important nuance: a coroutine can’t “squeeze into” the middle of a blocking call and cancel it instantly.
The scenario usually looks like this: you cancelled a Job, but inside the operation right now input.read(...) is executing. The coroutine can react to cancellation only when read() returns control (that is, returns the number of bytes, or -1, or throws an exception).
Therefore, in file tasks cancellation is almost always done by the principle “stop at the nearest convenient point,” and the convenient point is between iterations of the read/write loop.
That leads to the lecture’s practical rule: to make a big file copy/processing operation cancellable, we write it as a chunk loop (reading in chunks), and inside the loop we regularly check whether we’ve been cancelled.
2. The chunk loop as a “steering wheel and brakes”
Copying a file in chunks
First, let’s understand the chunk loop without coroutine cancellation—purely as a technique for correct copying. The idea is simple: we take a buffer (for example, 8 KB), read into it, then write exactly as much as we read, and repeat until the end of the file.
Psychologically it’s like carrying water with a bucket rather than trying to lift a lake with your hands. The lake might not fit (memory), but a bucket is usually fine.
import java.io.File
fun copyByChunks(from: File, to: File) {
from.inputStream().use { input ->
to.outputStream().use { output ->
val buffer = ByteArray(8 * 1024) // 8 KB
while (true) {
val read = input.read(buffer)
if (read < 0) break // EOF
output.write(buffer, 0, read) // write only what we actually read
}
}
}
}
There’s one detail here that generations of people have stumbled on, and I’m not kidding. In the last step, read is often smaller than the buffer size. If you do output.write(buffer) without specifying read, you’ll write the “tail” from the previous read, and the file will be corrupted. It’s like copying a text and then accidentally appending a piece of your previous chat at the end—awkward and suspicious.
The chunk loop does not make code asynchronous
After the word “chunks,” some people expect that everything automatically became “asynchronous” and “non-blocking.” Alas: InputStream.read() still blocks the thread. But the chunk loop gives us two superpowers.
The first is control: we manage the process ourselves in a clear loop, can compute progress, log, measure speed (if we really want), and most importantly—we can stop.
The second is places for cancellation: between loop iterations we can check isActive (or call ensureActive()), and if we were cancelled—finish correctly.
So the chunk loop is not an “accelerator,” but a “steering wheel and brakes.” And as human history has shown, brakes are usually more useful than they seem at the beginning.
3. Cancellation points: isActive and ensureActive()
Checking via isActive
Now we add coroutines. Let me remind you of an important thing: inside withContext(...) the lambda has a CoroutineScope receiver, which means we can access isActive. If isActive == false, it means the Job is cancelled, and it’s time to stop working.
The first “honestly” cancellable version is as straightforward as possible: if we were cancelled, we throw CancellationException. This is the standard cancellation signal.
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.io.File
suspend fun copyCancellable(from: File, to: File) {
withContext(Dispatchers.IO) {
from.inputStream().use { input ->
to.outputStream().use { output ->
val buffer = ByteArray(8 * 1024)
while (true) {
if (!isActive) throw CancellationException("Copy cancelled")
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
}
}
}
}
Notice: the check is placed before read(). It’s not the only option, but it usually yields pleasant behavior: if cancellation arrives between iterations, we don’t start an extra read/write.
At the same time, we don’t promise miracles: if cancellation arrives during a blocking read(), the coroutine will learn about it only when read() returns.
Checking via ensureActive()
The check if (!isActive) is fine, but in coroutines there’s another style that often looks cleaner: ensureActive(). It does two things: checks whether the coroutine is active, and if not—throws CancellationException for you.
This is convenient because you write less “noisy” code, and the intent is clearer: “before each step, make sure we haven’t been cancelled.”
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
suspend fun copyCancellable2(from: File, to: File) {
withContext(Dispatchers.IO) {
val buffer = ByteArray(8 * 1024)
from.inputStream().use { input ->
to.outputStream().use { output ->
while (true) {
ensureActive() // will throw CancellationException if cancelled
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
}
}
}
}
Here it’s important to understand the philosophy: we don’t “catch cancellation,” we cooperate with it. Cancellation is a normal scenario, not a “disk error.”
4. CancellationException and error handling
Why you must not “silence” cancellation
If you previously viewed exceptions only as “something broke,” then CancellationException slightly breaks your worldview (in a good way). This exception means: “the operation was stopped intentionally.”
In coroutines this is a standard mechanism for completion due to cancellation. That’s why CancellationException has a special status: usually you should not turn it into an ‘error’ for the user, you shouldn’t print a scary stack trace, and you definitely shouldn’t pretend “nothing happened.”
The most dangerous beginner mistake looks like this: “I’m a good developer, I catch all errors.”
import kotlinx.coroutines.CancellationException
fun riskyCatchAll(block: () -> Unit) {
try {
block()
} catch (e: Exception) {
println("Oops, error: ${e.message}") // this can accidentally swallow cancellation
}
}
In real coroutine code, this habit turns into a bug: you cancelled a Job, but somewhere inside you caught CancellationException as a regular Exception and… kept working. It’s roughly like pressing “Stop” on a microwave, but inside the microwave someone said: “no-no, we ignore your buttons.”
The correct style: if you catch “all exceptions,” then CancellationException must be rethrown.
import kotlinx.coroutines.CancellationException
fun safeCatchAll(block: () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e // cancellation is not an "error"; it must not be silenced
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
In suspend functions the principle is the same: if you do a general catch somewhere, you must respect cancellation.
5. Cancelling a task from the outside and an execution scheme
A cancellable copy command
Let’s imagine we continue developing a training console tool for file processing (let’s call it FileWorker). We want the copy command to be cancellable.
Let’s start with a small utility function: copy one file in a cancellable way. Note: we are not discussing “what to do with a partial file”—today the goal is precisely to stop, not to clean up the consequences (that’s a separate task).
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
suspend fun copyFileCommand(fromPath: String, toPath: String) {
val from = File(fromPath)
val to = File(toPath)
withContext(Dispatchers.IO) {
val buffer = ByteArray(8 * 1024)
from.inputStream().use { input ->
to.outputStream().use { output ->
while (true) {
ensureActive()
val n = input.read(buffer)
if (n < 0) break
output.write(buffer, 0, n)
}
}
}
}
}
The point of this example is not that it’s an “ideal copy utility for all cases,” but that it shows correct responsibility boundaries: we already know how to isolate blocking I/O in Dispatchers.IO, we know how to close resources, and now we know how to add points where cancellation becomes “real.”
Job.cancel() and waiting for completion
Cancellation becomes useful only when we can cancel the task from the outside: for example, by a timer, by a user command, or by a “the program is closing” signal.
The simplest scenario for a console app: start copying in a coroutine, wait a bit, and cancel it (in real life, “wait” is replaced by “the user clicked Cancel”).
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val job = launch {
copyFileCommand("data/big.bin", "data/big_copy.bin")
}
delay(50) // simulate "the user changed their mind"
job.cancelAndJoin() // cancel and wait for completion
println("Copy stopped") // Copy stopped
}
Two things matter here.
First, cancelAndJoin() is not “just cancel,” but “cancel and wait until the code actually exits,” meaning until resources in use {} are closed and finally blocks (if any) finish.
Second, if there were no ensureActive()/isActive inside copyFileCommand, then cancellation might “not be felt”: the coroutine would keep looping until the end of the file (or until the next accidental suspension point, which may not exist in blocking I/O).
Mini diagram: where exactly we “notice” cancellation
Sometimes it’s useful to see the process as a small algorithm so your brain stops thinking cancellation is a magic button. It’s just a check in the right place.
flowchart TD
A[Start copy operation] --> B[Open input/output via use]
B --> C{ensureActive / isActive?}
C -- cancelled --> D[Throw CancellationException]
C -- active --> E["read(buffer)"]
E --> F{EOF?}
F -- yes --> G[Exit the loop]
F -- no --> H["write(buffer, 0, read)"]
H --> C
The key idea is visible here: without checking ensureActive() or isActive we have no “built-in” reaction to cancel() in a blocking loop. And with the check, cancellation becomes a clear, repeatable scenario.
6. Typical mistakes
Mistake #1: “Cancellation doesn’t work” because there are no cancellation checks inside.
If your file operation is a long while (true) without isActive/ensureActive(), then job.cancel() is not required to stop everything instantly. In blocking I/O, cancellation doesn’t “paste itself in” automatically. So the rule is simple: in a long loop, add a regular coroutine-activity check, usually once per iteration.
Mistake #2: reading “all at once” via readBytes() and hoping for cancellation.
When you read a file all at once, you lose controlled points where you can stop, and you risk memory on large files. Even if you already moved it to Dispatchers.IO, cancellation in that style remains awkward: until the read finishes, you have very little control. The chunk loop solves both problems at once.
Mistake #3: output.write(buffer) instead of output.write(buffer, 0, read).
This is a genre classic. The last chunk of data is almost always smaller than the buffer. If you write the whole buffer, you add extra bytes and get a corrupted result. In file tasks, bugs like this are especially nasty: “the file seems to exist,” but it contains garbage, and you often won’t discover it until much later.
Mistake #4: catching Exception and accidentally “swallowing” CancellationException.
Cancellation in coroutines is often implemented via CancellationException. If you write catch (e: Exception) and don’t rethrow CancellationException, you can break cancellation: the operation will keep running even though it was cancelled. The correct style is to handle CancellationException separately and always rethrow it.
Mistake #5: expecting instant cancellation inside a blocking read().
Even with ensureActive(), cancellation is checked only between your steps. If the thread is blocked on read() right now, the coroutine cannot “teleport inside” and stop that call immediately. In typical disk operations this is usually tolerable, but it’s important to understand the model: cancellation will take effect when control returns to your Kotlin code and you reach the next check.