JavaRush /Courses /Kotlin SELF /Cleanup on cancellation and errors — try/finally as a com...

Cleanup on cancellation and errors — try/finally as a completion guarantee

Kotlin SELF
Level 53, Lesson 4
Available

1. Why cleanup in coroutines is part of the contract

When you write sequential code, your brain really likes a simple picture: “first we do A, then B, then C”. Coroutines do not break that picture (they actually give the illusion of normal sequential flow), but real life adds a third variable: the code can end earlier, and it can end in different ways. And in coroutines this is not exotic — it is the basic reality: cancel(), an exception, an early return.

In structured concurrency this is especially important: if one part of an operation stopped, the other parts may also stop (or should stop), and you do not want to leave “garbage” behind: a raised isLoading flag, half-updated state, an “eternal spinner”, an unclosed resource, etc. Structured concurrency as a concept tells us: coroutines should be managed hierarchically and complete predictably.

Let’s imagine the lifecycle of a single coroutine as a simple diagram:

stateDiagram-v2
    [*] --> Running
    Running --> Completed: successfully reached the end
    Running --> Cancelled: cancel()
    Running --> Failed: exception
    Completed --> [*]
    Cancelled --> [*]
    Failed --> [*]

Today’s focus: learn to write code so that the final step “put everything in order” runs on any exit.

2. try/finally as a completion guarantee

finally in Kotlin: a quick refresher

Sometimes it is useful to pretend for a minute that coroutines do not exist and recall the basic idea of try/finally. In Kotlin, the finally block is where you put actions that must be executed no matter what: whether an error happened or not, whether you left try via return or reached the end.

One more important (and often forgotten) fact: in Kotlin try must have either catch, or finally (or both). That is, “just try { ... }” does not exist — the compiler will not allow it.

A mini example without coroutines, just to refresh the intuition:

fun main() {
    try {
        println("Start")            // Start
        error("boom")
    } finally {
        println("Cleanup")          // Cleanup
    }
    // println("End") // we will not reach this because of error(...)
}

And a small style note: in Kotlin it is customary to write } finally { on the same line — this is part of the formatting conventions, and such code is noticeably easier to read.

Coroutine cancellation: why finally runs

When you first hear “coroutine cancellation”, it is easy to imagine a “Kill -9” button that instantly kills execution. But we already know: cancellation is a stop request, and the coroutine “agrees” to stop at the nearest suspension points (delay(), waiting, etc.) or when it checks isActive itself.

And here is the key point of the day: cancellation is also an exit from the coroutine. And since it is an exit, finally is your guaranteed chance to “close the door and turn off the lights”.

Let’s look at a small example that demonstrates the idea as directly as possible:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(10) { i ->
                delay(50)
                println("working $i")        // working 0, working 1, ...
            }
        } finally {
            println("cleanup runs anyway")   // cleanup runs anyway
        }
    }

    delay(130)
    job.cancelAndJoin()
    println("main finished")                 // main finished
}

Here we do an important thing: not just cancel(), but cancelAndJoin(). That means we did not “request stop and go get coffee”, but requested and waited for the coroutine to actually complete, which means it had time to execute finally.

3. Where to keep cleanup: inside the coroutine and by responsibility levels

“Manage from the outside, clean up on the inside”: cancelAndJoin() + try/finally

When people first start writing coroutines, they often try to do cleanup “from the outside”: “if it was cancelled, then somewhere after cancel() I will set a flag, print a message...”. And that sometimes works while the scenario is very small. But as soon as the coroutine becomes a bit more complex, with several exit points, this approach turns into a game of “guess where the code will end”.

A healthy discipline looks like this: from the outside we manage the lifecycle (cancelAndJoin(), join()), and inside the coroutine we guarantee cleanup via try/finally. Inside the coroutine is where it is easiest to see what it “owns”: which flags, which temporary resources, which logic.

Let’s continue our hypothetical practical console project (let it be “BudgetBuddy”: expense tracking and report generation). We have application state, and one of the commands starts a “long report export”. The export can be cancelled (for example, the user changed their mind).

Let’s create the simplest state:

data class AppState(
    var exportInProgress: Boolean = false
)

Now the export start function returns a Job. Note: Job is not “the export result”, it is a “lifecycle handle”. So it makes sense to keep cleanup inside that Job.

import kotlinx.coroutines.*

fun startReportExport(scope: CoroutineScope, state: AppState): Job =
    scope.launch {
        state.exportInProgress = true
        try {
            repeat(5) { step ->
                delay(80)
                println("export step ${step + 1}/5") // export step 1/5 ...
            }
            println("export done")                   // export done
        } finally {
            state.exportInProgress = false
            println("export cleanup: flag reset")    // export cleanup: flag reset
        }
    }

Now from the outside (for example, in main()) we can cancel the export and be confident: the flag will always return to normal.

Cleanup in child coroutines: finally at every level

It is very tempting to write one “big” try/finally in the parent coroutine and think that is enough. But structured concurrency teaches a good habit: each level should clean up what it owns.

The parent owns “the whole operation”, while a child may own local things: a temporary buffer, its own status, its own piece of computation. So it is normal (and often correct) when finally exists in both the parent and the children: they clean different things.

Example: the parent launches two export “workers”: one prepares data, the other “packs” it (in reality this could be anything, but for now we simulate with delay()).

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parent = launch {
        try {
            launch {
                try {
                    delay(60)
                    println("prepare done")      // prepare done
                } finally {
                    println("prepare cleanup")   // prepare cleanup
                }
            }
            launch {
                try {
                    delay(90)
                    println("pack done")         // pack done
                } finally {
                    println("pack cleanup")      // pack cleanup
                }
            }
        } finally {
            println("parent cleanup")            // parent cleanup
        }
    }

    parent.join()
}

The idea here is not the “line order” (it may vary), but that cleanup is distributed by responsibility. The parent guarantees its part, the children guarantee theirs.

4. Cleanup on errors and the right content of finally

Cleanup on errors: try/finally does not fix it, but cleans up

There is a subtle psychological trap: since finally “always runs”, it must mean you do not have to fear errors. No, you still can (and sometimes should) fear them; it is just that errors will no longer leave a mess behind.

It is important to understand the role: finally does not catch the exception. It just runs reliably on exit — and then the exception is either handled by catch, or “bubbles up” beyond the scope boundary.

Let’s show this using our export example: on the third step we suddenly crash with an error, but cleanup still runs:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val state = AppState()

    val job = launch {
        state.exportInProgress = true
        try {
            repeat(5) { step ->
                delay(50)
                if (step == 2) error("disk is full (pretend)")
                println("step ${step + 1}")           // step 1, step 2
            }
        } finally {
            state.exportInProgress = false
            println("cleanup after error")           // cleanup after error
        }
    }

    try {
        job.join()
    } catch (e: Exception) {
        println("caught in main: ${e.message}")      // caught in main: disk is full (pretend)
    }

    println("flag = ${state.exportInProgress}")      // flag = false
}

The point of this example is not exactly where to catch the error (we discussed that in the previous lecture), but the guarantee: even on failure, the state returns to correct.

What to put into finally, and what not to

finally is very powerful, and that is exactly why it is easy to abuse. After a few successful examples you may feel like: “Oh, then I will do everything I did not manage to do in finally”. And that is how finally blocks of 80 lines appear — they can crash, they can hang, and they turn code into drama.

A practical rule for our current level: keep cleanup short, fast, and as reliable as possible. It should restore flags, print a clear completion message, close what must be closed, and avoid complex branching.

It helps to keep a small table in mind:

What is good to do in finally Why it is good What is risky to do in finally Why it is risky
Reset a state flag (exportInProgress = false) Fast, predictable Write large business logic It can crash and “overwrite” the original cause
Print a final message/log Helps debugging Do many actions that can also throw an exception Then debugging turns into a detective story
Release a “locally created” resource Return the system to normal Assume that “if it was cancelled, then cleanup is not needed” It is needed precisely because it was cancelled

It is also important to remember the basic meaning of finally: in Kotlin it is specifically for code that must run regardless of the outcome of try.

And yes, in Kotlin it is customary to format try/finally compactly and readably — this also reduces the chance of mistakes, because the code is easier to verify visually.

5. Mini scenario: export, cancellation, and guaranteed cleanup

Now we will assemble a small complete scenario: start an export, let it run a bit, then simulate “the user cancelled”, and make sure cleanup always runs. In a real CLI this could be command input, but to avoid pulling in command parsing today, we will just cancel on a timer.

import kotlinx.coroutines.*

data class AppState(var exportInProgress: Boolean = false)

fun startReportExport(scope: CoroutineScope, state: AppState): Job =
    scope.launch {
        state.exportInProgress = true
        try {
            repeat(10) { i ->
                delay(40)
                println("export tick $i")         // export tick 0, 1, 2...
            }
            println("export finished normally")   // (won't reach on cancellation)
        } finally {
            state.exportInProgress = false
            println("export cleanup (always)")    // export cleanup (always)
        }
    }

fun main() = runBlocking {
    val state = AppState()
    val exportJob = startReportExport(this, state)

    delay(130)
    println("cancel requested")                   // cancel requested
    exportJob.cancelAndJoin()

    println("flag = ${state.exportInProgress}")   // flag = false
}

If you see that flag is always false, then you have defeated one of the most insidious classes of bugs: “the working indicator is stuck on forever” and “the app state did not return to normal after cancellation”.

6. Typical mistakes with cleanup in coroutines

Mistake #1: doing cleanup “from the outside” and forgetting other exit paths.
If you reset a flag or print “done” only after join() in external code, you miss cases where the coroutine completed with an exception, was cancelled, or exited early due to return. As a result, application state diverges from reality: the work is already finished, but UI/logic thinks it is still running.

Mistake #2: calling cancel(), but not waiting for completion and expecting cleanup has already run.
Cancellation is a request, not an instant shutdown. If it matters that cleanup definitely happened (and it almost always matters), you need to wait for coroutine completion via join() or use cancelAndJoin() right away. Otherwise the coroutine may “clean up” a bit later, while your code already moved on.

Mistake #3: turning finally into a “second main program”.
When finally grows, it becomes a source of new errors. The worst case is when cleanup throws an exception and you lose the original cause that you were debugging in the first place. finally should be boring, short, and reliable — that is its superpower.

Mistake #4: not separating responsibility between parent and children.
If the parent coroutine “owns” only the overall operation, and child coroutines create their own temporary resources or states, trying to clean everything in one place is inconvenient and easy to get wrong. It is much calmer when each level has its own small try/finally that cleans up exactly what it created.

Mistake #5: confusing “cancellation is not an error” with “cleanup is not needed on cancellation”.
Yes, cancellation is a normal scenario, not necessarily a problem. But precisely because cancellation is normal, cleanup must be just as normal and mandatory: reset flags, finish the “operation”, bring the state to a consistent form. Otherwise cancellation turns into a “half-broken app” that then behaves strangely.

1
Task
Kotlin SELF, level 53, lesson 4
Locked
Cleanup on cancellation
Cleanup on cancellation
1
Task
Kotlin SELF, level 53, lesson 4
Locked
Loading flag
Loading flag
1
Task
Kotlin SELF, level 53, lesson 4
Locked
Early exit
Early exit
1
Task
Kotlin SELF, level 53, lesson 4
Locked
Export error
Export error
1
Survey/quiz
Structured concurrency, 53 level, 4 lesson
Unavailable
Structured concurrency
Structured concurrency
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION