Przejdź do treści
Powrót do bloga

CancellationException: gdy catch rozwala korutyny

CancellationException

Jest w Kotlinie taki wyjątek, który tak naprawdę wyjątkiem nie jest. Klasa się zgadza, dziedziczenie się zgadza, leci przez stos dokładnie tak samo jak każdy inny błąd. Tylko że kiedy go złapiesz i grzecznie obsłużysz, robisz dokładnie to, czego robić nie należy.

Mowa o CancellationException. Prawdopodobnie masz go w projekcie kilkanaście razy połkniętego i nawet o tym nie wiesz, bo nic nie wybucha. Po prostu aplikacja czasem dziwnie mruga, w Crashlyticsie leżą śmieci, a jeden tester zgłosił buga, którego nikt nie umiał odtworzyć.

Anulowanie w korutynach jest umowne

Kluczowa rzecz, od której trzeba zacząć: anulowanie korutyny to nie jest Thread.stop(). Nikt nikogo nie zabija. Anulowanie jest kooperatywne, czyli działa tylko wtedy, gdy kod sam się na nie zgodzi.

Mechanizm jest prosty. Kiedy wołasz job.cancel(), Job przechodzi w stan „cancelling”. Od tego momentu każda funkcja suspend z biblioteki kotlinx.coroutines (delay, withContext, await, emit i cała reszta) przy pierwszej okazji rzuca CancellationException. Ten wyjątek wędruje w górę, rozwija stos, odpala bloki finally, sprząta zasoby i kończy korutynę. Wtedy Job wchodzi w stan „cancelled” i wszyscy są zadowoleni.

Do momentu, w którym ktoś ustawi na tej drodze catch (e: Exception).

Wtedy wyjątek się zatrzymuje, kod leci dalej, a korutyna, która miała umrzeć, dalej sobie żyje i robi rzeczy. Structured concurrency przestaje mieć znaczenie, bo cała gwarancja „anulowałem rodzica, więc dzieci też są martwe” opiera się właśnie na tym, że nikt tego wyjątku nie przechwyci po drodze.

Ciekawostka na JVM: kotlin.coroutines.cancellation.CancellationException to typealias na java.util.concurrent.CancellationException, które dziedziczy po IllegalStateException. Czyli twoje niewinne catch (e: IllegalStateException) też potrafi połknąć anulowanie. Miłego debugowania.

Klasyczny strzał w stopę

Najczęstszy przypadek, jaki widuję na code review. Wyszukiwarka z debounce, wygląda tak:

private var searchJob: Job? = null

fun onQueryChanged(query: String) {
    searchJob?.cancel()
    searchJob = viewModelScope.launch {
        delay(300)
        try {
            val results = repository.search(query)
            _state.value = SearchState.Success(results)
        } catch (e: Exception) {
            _state.value = SearchState.Error("Coś poszło nie tak")
            crashlytics.recordException(e)
        }
    }
}

Wygląda niewinnie. Ktoś nawet pochwali cię na review, że obsługujesz błędy.

A teraz użytkownik wpisuje słowo „kanapka”. Siedem znaków, siedem wywołań onQueryChanged, sześć anulowań poprzedniego joba. Każde z tych anulowań rzuca CancellationException z delay albo ze środka Retrofita. Każde z nich ląduje w twoim catch (e: Exception). Efekt:

  • ekran sześć razy mignie komunikatem o błędzie,
  • do Crashlyticsa poleci sześć „błędów”, które błędami nie są,
  • a jeśli w catch masz jeszcze _isLoading.value = false, to ostatnie anulowane zapytanie zgasi loader dla zapytania, które właśnie leci.

I to jest wersja optymistyczna, bo tu przynajmniej coś widać. Gorzej, kiedy połknięcie anulowania dotyczy zapisu do bazy albo wysyłki eventu analitycznego. Wtedy po prostu masz w aplikacji operacje, które wykonują się po tym, jak ekran już nie istnieje, i nadpisują dane nowszego ekranu starszymi wynikami. Powodzenia w odtwarzaniu tego na sprincie za trzy miesiące.

Jak łapać, żeby nie zabić anulowania

Są trzy sensowne podejścia i jedno, które polecam najbardziej.

Wariant pierwszy, dosłowny. Łapiesz i rzucasz dalej. Kolejność ma znaczenie, bo Kotlin wchodzi do pierwszego pasującego bloku:

try {
    val results = repository.search(query)
    _state.value = SearchState.Success(results)
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    _state.value = SearchState.Error("Coś poszło nie tak")
}

Działa, jest czytelne, każdy junior zrozumie. Ma jedną wadę: chroni tylko przed samym CancellationException, a nie przed jego przebraniami.

Wariant drugi, ten dobry. Zamiast pytać „czy to był wyjątek anulowania”, pytasz „czy ja w ogóle jeszcze żyję”:

try {
    val results = repository.search(query)
    _state.value = SearchState.Success(results)
} catch (e: Exception) {
    currentCoroutineContext().ensureActive()
    _state.value = SearchState.Error("Coś poszło nie tak")
}

ensureActive() sprawdza stan Job i jeśli kontekst jest anulowany, sam rzuca CancellationException dalej. Reszta bloku się nie wykona.

Dlaczego to jest lepsze? Bo w realnym świecie anulowanie nie zawsze dociera do ciebie jako CancellationException. OkHttp po anulowaniu połączenia rzuca IOException z komunikatem „Canceled”. Niektóre SDK łapią u siebie wszystko i opakowują w swój własny ApiException. W obu przypadkach wariant pierwszy przepuści to jak zwykły błąd sieciowy, a ensureActive() zauważy, że scope jest martwy, i zrobi swoje.

Wariant trzeci, dla leniwych i dla dużych zespołów. Jeden helper w module core i przestajesz o tym myśleć:

suspend inline fun <T> safeCall(block: () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Throwable) {
        Result.failure(e)
    }

runCatching to pułapka

Skoro już przy Result jesteśmy: nie używaj runCatching w kodzie suspend. Nigdy. Serio.

val result = runCatching { repository.search(query) } // 💣

runCatching łapie Throwable. Wszystko. Łącznie z CancellationException, łącznie z OutOfMemoryError, łącznie z rzeczami, których łapać po prostu nie wolno. To jest funkcja napisana z myślą o zwykłym, synchronicznym kodzie i w świecie korutyn zachowuje się jak worek na wszystko.

Ta sama uwaga dotyczy każdego własnego wrappera typu apiCall { }, safeApiCall { } czy withErrorHandling { }, który ktoś w zespole napisał dwa lata temu i nikt do niego nie zaglądał. Zajrzyj. Serio, zajrzyj. Statystycznie jedna trzecia takich funkcji w polskich projektach Androidowych ma tam catch (e: Exception) bez rethrow.

finally i NonCancellable

Bloki finally odpalą się przy anulowaniu, to dobra wiadomość. Zła jest taka, że korutyna jest już wtedy w stanie „cancelling”, więc każda funkcja suspend wywołana w finally natychmiast rzuci CancellationException. Twoje sprzątanie nigdy się nie wykona.

Na to jest NonCancellable:

try {
    uploadFile(file)
} finally {
    withContext(NonCancellable) {
        tempFileDao.markAsUnused(file.id)
    }
}

I teraz ostrzeżenie, bo widziałem, jak ludzie odkrywają NonCancellable i zaczynają nim leczyć wszystko. To jest narzędzie do krótkiego, deterministycznego sprzątania: zamknięcie zasobu, odblokowanie rekordu, wysłanie ostatniego eventu. Jeśli opakujesz w NonCancellable cały request sieciowy, bo „nie chcę, żeby się anulował”, to właśnie zrobiłeś sobie kod, którego nie da się zatrzymać. Będzie mielił po zamknięciu ekranu, po wylogowaniu i po tym, jak użytkownik pojedzie windą i straci zasięg.

Pętle, w których anulowanie nie działa

Anulowanie jest kooperatywne, więc kod, który nie woła żadnej funkcji suspend, nie ma jak zauważyć, że powinien się zatrzymać:

withContext(Dispatchers.Default) {
    repeat(1_000_000) { i ->
        heavyCalculation(i) // czysty CPU, zero suspendów
    }
}

Możesz anulować ten job ile chcesz. Poleci do końca, zje baterię i pewnie jeszcze zapisze wynik. Trzeba dodać punkt kontrolny:

withContext(Dispatchers.Default) {
    repeat(1_000_000) { i ->
        ensureActive()          // rzuci CancellationException, gdy trzeba
        heavyCalculation(i)
    }
}

Alternatywy to if (!isActive) return@withContext (gdy chcesz wyjść cicho, bez wyjątku) albo yield() (gdy dodatkowo chcesz oddać wątek innym korutynom). W praktyce ensureActive() załatwia dziewięćdziesiąt procent przypadków.

withTimeout, czyli jedyny raz, kiedy łapiesz świadomie

withTimeout po przekroczeniu czasu rzuca TimeoutCancellationException, które dziedziczy po CancellationException. I tu nie ma problemu z łapaniem go na zewnątrz, bo anulowany został tylko blok wewnętrzny, a nie twój scope:

val data = try {
    withTimeout(5_000) { repository.fetchConfig() }
} catch (e: TimeoutCancellationException) {
    Config.DEFAULT
}

Zwróć uwagę, że łapiemy konkretny typ, a nie CancellationException. Gdybyś złapał ten ogólniejszy, to przy prawdziwym anulowaniu ekranu w trakcie tego requestu wpadłbyś dokładnie w problem z początku artykułu.

Jeśli nie potrzebujesz rozróżniać przyczyny, jest krótsza wersja, która zwraca null zamiast rzucać:

val data = withTimeoutOrNull(5_000) { repository.fetchConfig() } ?: Config.DEFAULT

Flow akurat zachowuje się przyzwoicie

Dobra wiadomość na koniec technicznej części: operator catch na Flow jest napisany porządnie i sam sprawdza, czy wyjątek nie wynikał z anulowania kolektora. Jeśli tak, przepuszcza go dalej zamiast obsłużyć.

repository.observeUsers()
    .catch { _state.value = State.Error }  // to jest OK
    .launchIn(viewModelScope)

Ale to działa tylko dla operatora catch. Jeśli w środku map albo onEach wstawisz własny try/catch (e: Exception), wracasz do punktu wyjścia. Biblioteka cię nie uratuje przed kodem, który sama nie kontroluje.

Jak to wyłapać, zanim wyłapie ciebie

Ręczne pilnowanie tego na code review działa mniej więcej tak długo, jak długo jest zapał po przeczytaniu artykułu. Lepiej ustawić to raz:

  1. Detekt z ruleset coroutines. Reguła SuspendFunSwallowedCancellation wykrywa dokładnie ten wzorzec: runCatching i szerokie catch w funkcjach suspend. Włącz, ustaw jako błąd, dodaj do CI.
  2. Zakaz runCatching w kodzie suspend. Do listy zakazanych metod w detekcie i po sprawie.
  3. Jeden wspólny wrapper na wywołania API w module core, z porządnym rethrow w środku. Zespół i tak będzie kopiował istniejące wzorce, więc niech kopiuje dobre.
  4. Filtr w Crashlyticsie. Jeśli mimo wszystko coś przejdzie, przynajmniej to zobaczysz jako wzorzec, a nie utoniesz w szumie.

Test też da się napisać, i to zaskakująco prosty:

@Test
fun `anulowanie nie ustawia stanu bledu`() = runTest {
    val job = launch { viewModel.search("kanapka") }
    advanceTimeBy(100)
    job.cancelAndJoin()

    assertTrue(viewModel.state.value !is SearchState.Error)
}

Ściąga

Jeśli masz zapamiętać jedną rzecz z tego tekstu, to tę: CancellationException nie jest błędem, tylko wiadomością. Wiadomość brzmi „skończ robotę i wyjdź”. Twoim zadaniem jest przekazać ją dalej, a nie przeczytać, skwitować toastem i wyrzucić do kosza.

Reszta w trzech punktach:

  • W kodzie suspend nie łapiesz Exception ani Throwable bez ensureActive() albo rethrow.
  • runCatching w korutynach nie istnieje.
  • NonCancellable tylko do sprzątania, i to krótkiego.

To jest jeden z tych błędów, które nie wywalają aplikacji, tylko powoli obniżają jakość wszystkiego dookoła. Poświęć wieczór na Cmd+Shift+F po catch (e: Exception) w swoim projekcie. Obiecuję ciekawe znaleziska.

pies

O autorze

Mateusz Majka

Senior Android Developer, Co-founder

Builds production Android systems that don't corrupt their own databases at 3 AM. Co-founder at DevsCave with focus on Kotlin, Clean Architecture, and making Polish government tech suck less through PrzetargiAI. Based in Jaworzno. Speaks Polish fluently, code even more so.