RizTech Academy logo
RizTech Academy
NetworkingLesson 2 of 635 min

Coroutines, scopes and cancellation

The Kotlin course taught the three dispatchers and the rule that goes with them: UI on Main, waiting on IO, computing on Default. That rule leaves the Android questions open. Which thread does a Retrofit call actually run on? Does Mandi Bhav need a single withContext? What happens to a download when the farmer presses Home halfway through, or when a screen that started it goes away? This lesson answers each one by logging it on the emulator, then shows the two cancellation bugs that turn up in real Android code, both measured, and the one scope Mandi Bhav adds because of them.

Where a refresh runs

A scratch build logged Thread.currentThread().name at each step of a first download: in PricesViewModel.refresh(), at the top of the repository's download, in an OkHttp interceptor, after the response was parsed, and when refresh() returned.

D/M9 (23075): refresh() called on main
D/M9 (23075): download() on DefaultDispatcher-worker-1
D/M9 (23075): request /api/v1/prices.json on OkHttp http://10.0.2.2:8765/...
D/M9 (23075): request /api/v1/history.json on OkHttp http://10.0.2.2:8765/...
D/M9 (23075): parsed 9 prices, now on DefaultDispatcher-worker-3
D/M9 (23075): refresh() returned 9 on main
Step Thread Who chose it
The tap, the ViewModel main viewModelScope, which uses Dispatchers.Main.immediate
The download's own code DefaultDispatcher-worker-… The app's scope, below
The HTTP request, and parsing the JSON OkHttp … Retrofit: a suspend function hands the call to OkHttp's own threads and suspends until it answers
The database write One of Room's threads Room, as module 8's crash trace showed (LimitedDispatcher$Worker)
Back in the ViewModel main The coroutine resumes where it was launched

Nobody in that table wrote withContext. Retrofit's suspend functions and Room's DAO functions are main-safe: you can call them from the main thread, because they move their own blocking work elsewhere and suspend until it is done. That is the contract every suspend function in the data layer should keep, and it is why the Kotlin course said to put the switch inside the function, not in every caller.

So Mandi Bhav has no withContext at all, and that is correct rather than lucky. You need one when you call something that blocks the thread it is called on:

Work Blocks? Where it belongs
api.latestPrices() (suspend), any Room DAO call No; main-safe Anywhere, including viewModelScope
call.execute(), File.readText(), BitmapFactory.decodeFile(), an old SDK's synchronous call Yes withContext(Dispatchers.IO) { … }
Sorting or filtering thousands of items, parsing a large file yourself Uses CPU withContext(Dispatchers.Default) { … }
MutableStateFlow.update No Any thread; StateFlow is thread-safe

When a class does need a dispatcher, take it as a constructor parameter, class X @Inject constructor(@IoDispatcher private val io: CoroutineDispatcher), rather than writing Dispatchers.IO inside it, so a test can pass a test dispatcher and control time. Google's coroutine guidance calls this out first. Mandi Bhav injects the one thing it does need: a scope.

When a coroutine is cancelled

viewModelScope is cancelled when its ViewModel is cleared: when the back stack entry it belongs to is popped, as on Back from the detail screen, or when the activity finishes for good. Not on rotation; not when the app goes to the background. LaunchedEffect and rememberCoroutineScope are cancelled sooner, when their composable leaves the screen, which on a tab switch is every time.

Scope Cancelled when Use it for
LaunchedEffect, rememberCoroutineScope The composable leaves the composition UI work: a snackbar, a scroll, an animation
repeatOnLifecycle(STARTED), collectAsStateWithLifecycle The screen stops (Home, another app); restarted when it starts again Watching something only while it is visible
viewModelScope The ViewModel is cleared Work for this screen that should survive rotation
An application scope (below) Never, until the process dies Work that must finish even if the screen goes
GlobalScope Never, and you cannot replace it in a test Nothing
WorkManager (module 10) Survives the process dying Work that must happen even if the app is closed

Bug one: work that carries on after the user has gone

A reasonable feature request: "refresh every ten seconds while the screen is open, so the prices are always current". The obvious code, in a scratch copy of PricesViewModel:

// The trap: a loop in viewModelScope polls for as long as the ViewModel lives.
viewModelScope.launch {
    while (true) {
        try {
            refreshQuietly("poll")
        } catch (e: Exception) {
            android.util.Log.d("M9", "poll: failed, $e")
        }
        kotlinx.coroutines.delay(10_000)
    }
}

Open the app, wait twelve seconds, and press Home, at 22:56:39 by the computer's clock:

11-08 22:56:27.535 I/OkHttp  (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:56:37.786 I/OkHttp  (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:56:47.828 I/OkHttp  (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:56:58.103 I/OkHttp  (24110): <-- HTTP FAILED: java.net.SocketTimeoutException: failed to connect to /10.0.2.2 (port 8765) from /10.0.2.16 (port 48308) after 10000ms. http://10.0.2.2:8765/api/v1/prices.json (10273ms)
11-08 22:57:08.124 I/OkHttp  (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:57:18.149 I/OkHttp  (24110): <-- HTTP FAILED: java.net.SocketTimeoutException: failed to connect to /10.0.2.2 (port 8765) from /10.0.2.16 (port 33602) after 10000ms. http://10.0.2.2:8765/api/v1/prices.json (10024ms)

The ViewModel is not cleared when the user presses Home, so the loop went on asking for prices nobody could see, every ten seconds, and in the background each attempt hung for ten seconds before failing to connect. On a farmer's phone that is mobile data and battery spent on nothing, and Android works hard to stop it, which is module 10's subject. A ViewModel outlives the screen being visible; work that is only useful while the user is looking must stop when the screen stops.

The fix is to tie the work to what the screen collects. Mandi Bhav's one piece of "while you are looking" work, lesson 6's sync when the connection returns, is part of uiState's flow, and uiState uses WhileSubscribed(5_000) from module 5: five seconds after the screen stops collecting, the upstream stops, and the connection callback is unregistered with it. A test pins it down, nobodyWatchingNobodySyncs: the connection comes back while nothing collects the state, and the repository's refresh count stays at 1.

Bug two: a catch that swallows the cancellation

Tie the poll to the lifecycle instead, in the route, and keep the same habit of catching everything:

// The trap: catch (e: Exception) also catches the cancellation.
LaunchedEffect(Unit) {
    lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
        var caught = 0
        while (true) {
            try {
                kotlinx.coroutines.delay(10_000)
                viewModel.refreshQuietly("visible poll")
            } catch (e: Exception) {
                caught++
                if (caught <= 3 || caught % 100_000 == 0) android.util.Log.d("M9", "visible poll: caught #$caught $e")
            }
        }
    }
}

repeatOnLifecycle cancels the block when the screen stops. Press Home four seconds after opening:

11-08 22:57:32.041 D/M9      (24180): visible poll: caught #1 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:32.041 D/M9      (24180): visible poll: caught #2 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:32.041 D/M9      (24180): visible poll: caught #3 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:32.778 D/M9      (24180): visible poll: caught #100000 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:34.668 D/M9      (24180): visible poll: caught #400000 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15

and top at the same moment:

24180 u0_a237  20  0  16G 199M  98M R  123  10.0  0:05.35 com.riztechacademy.mandibhav.verify

Cancellation in coroutines is an exception, CancellationException, thrown from the next suspension point. catch (e: Exception) caught it, so the coroutine never finished being cancelled; the loop went round, delay saw the coroutine was cancelled and threw again at once, and the loop caught it again. About 160,000 times a second, on the main thread, at 123% CPU, with the app in the background. runCatching { } does the same thing, because it catches Throwable.

The rules that follow:

  • Catch the exceptions you mean. Mandi Bhav's ViewModel catches RefreshException and nothing else: lesson 4's data layer turns every network failure into one, and a CancellationException is not one.
  • If you must catch broadly, at a boundary such as a logging wrapper, rethrow cancellation first: catch (e: CancellationException) { throw e } above the general catch, or call currentCoroutineContext().ensureActive() inside it.
  • while (isActive) instead of while (true) would have stopped this loop too. It does not fix a swallowed cancellation elsewhere; catching the right type does.

The network layer has a test for its side of this, cancellingTheCallerIsNotAFailure: a slow response, the caller cancelled after 200 ms, and what comes out is a CancellationException, not a RefreshException saying "timeout".

Work that should finish anyway

The opposite case matters too. A first download takes several seconds on weak data. If the screen that asked for it goes away halfway, the bytes already spent should not be thrown away, and the prices should still be saved for next time. Work like that should not live in the screen's scope. Mandi Bhav adds one application-wide scope, in di/CoroutinesModule.kt:

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope

@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

SupervisorJob so that one failed download does not cancel the scope and everything else in it; Dispatchers.Default so that anything started there is off the main thread. The repository starts each download in it, and lets callers wait for the result:

/** The refresh that is running now, if any; a second caller waits for it. */
private var running: Deferred<Int>? = null

override suspend fun refresh(): Int {
    val download = synchronized(this) {
        running?.takeIf { it.isActive } ?: externalScope.async { download() }.also { running = it }
    }
    return download.await()
}

Two things happen here. If the caller is cancelled, only its await() is cancelled; the async in the application scope carries on and saves. And if a refresh is already running, a second caller waits for the same one: a tap on "Refresh" while lesson 6's reconnect sync is already downloading costs one download, not two. Two repository tests check exactly that, with a test scope standing in for the application's: aDownloadFinishesEvenIfTheScreenThatAskedIsGone cancels the caller a second into a two-second download and still finds the 7 November prices saved, and twoRefreshesAtOnceShareOneDownload counts one network call for two refreshes.

What it costs: work in this scope is never cancelled for you, so it must be short and bounded (one download, with lesson 4's timeouts), and it must not touch any screen. An application scope is for finishing what was started, not for starting things nobody asked for. It also dies with the process, which is why module 10 uses WorkManager for the daily refresh instead.

Check your work

Which thread a Retrofit suspend call does its network work on. One of OkHttp's own threads; the caller only suspends.

Why Mandi Bhav has no withContext. Retrofit's suspend functions and Room's DAOs are main-safe; nothing it calls blocks.

When to write withContext(Dispatchers.IO). Around a call that blocks the thread: execute(), file reads, old synchronous APIs.

Why the poll kept going after Home. It ran in viewModelScope, and the ViewModel is not cleared when the app goes to the background.

Why the second poll spun at 123% CPU. catch (e: Exception) caught the CancellationException; delay threw it again at once, round and round.

What the application scope gives the refresh. It finishes and saves even if the caller is cancelled, and two callers share one download.

Practice 4. On the emulator, with the server waiting five seconds: back on the Prices tab eight seconds later, the snackbar said "Prices are up to date." and the label "Updated today at 11:18 PM". Switching tabs took the Prices screen out of the composition, but not its ViewModel, which belongs to the tab's back stack; the refresh ran on in viewModelScope, and its message waited in the UI state, module 7's pattern, until a screen was there to show it. Had the refresh been launched from rememberCoroutineScope in the screen, it would have been cancelled at the tab switch, and only the application scope would have saved the download.

Practice

  1. Add CoroutinesModule and the single-flight refresh(), and pass @ApplicationScope to RoomPriceRepository. Run the repository tests.
  2. In a scratch copy, log the thread name at each step of a refresh as above, and match your log to the table.
  3. Build the viewModelScope poll, press Home, and watch the requests in logcat with the tag OkHttp. Then build the repeatOnLifecycle version with catch (e: Exception), press Home, and watch top. Force-stop the app afterwards.
  4. With the server told to wait five seconds, tap "Refresh" and switch to the Markets tab at once. Predict the snackbar and the prices you see when you come back, then check.
  5. Fix the spinning poll three ways: rethrow CancellationException, call ensureActive(), and catch only RefreshException. Confirm each stops after Home.
  6. Harder: write a function withTimeoutOrSaved(block) that returns the saved prices if a refresh has not finished in three seconds, without cancelling the refresh. Decide which scope each part runs in, and write the test with virtual time.

Official documentation

Next: the main thread itself, what freezes it, and how Android tells you before your users do.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship