The Stack Trace That Blamed the Wrong Screen: Making Rethrown Coroutine Exceptions Traceable on Android
You open a failing trace from a QA build or a test run. The exception is real and the message is clear, but the stack trace lists a repository, an API client, and a dispatcher boundary. It never mentions the ViewModel or the screen that triggered the work.
Three features share that repository, and none of them can be ruled out. This is a structural property of how exceptions travel between coroutines, not a bug in your code.
This post explains the mechanism, shows how the Kotlin standard library lets an exception class take part in the fix through StackTraceRecoverable, and is blunt about where it does and does not run on Android. That last part is the one most write-ups skip, so it comes early.
1. The problem: one exception object, two coroutines
A JVM exception captures its stack trace at construction time. That is fine when the code that throws is the code you want to see. Coroutines break the assumption when an exception is created in one place, parked in shared state, and rethrown somewhere else.
Here is a realistic shape. A notes app has an offline-first sync layer. Several screens can request a sync at once, so the repository de-duplicates them into a single in-flight job. The helper below is written with three production concerns in mind: it avoids Java 8 map default methods that need API 24 or desugaring, it uses a SupervisorJob so one failed sync does not cancel the owning scope, and it cancels the work when the last waiting caller leaves.
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class SingleFlight<K : Any, V>(parent: CoroutineScope) {
// A failure inside a flight must not cancel the parent scope or sibling flights.
private val scope = CoroutineScope(parent.coroutineContext + SupervisorJob(parent.coroutineContext[Job]))
private val lock = Mutex()
private val flights = HashMap<K, Flight<V>>()
private class Flight<V>(val deferred: Deferred<V>) {
var waiters = 0
}
suspend fun run(key: K, block: suspend () -> V): V {
val flight = lock.withLock {
// Do not join a flight that has already finished, failed or not.
val live = flights[key]?.takeIf { !it.deferred.isCompleted }
val chosen = live ?: Flight(scope.async { block() }).also { flights[key] = it }
chosen.also { it.waiters++ }
}
try {
return flight.deferred.await()
} finally {
withContext(NonCancellable) {
lock.withLock {
flight.waiters--
if (flight.waiters == 0) {
if (flights[key] === flight) flights.remove(key)
// Nobody is waiting and the flight is already unreachable from the map,
// so cancelling cannot affect any reader.
if (!flight.deferred.isCompleted) {
flight.deferred.cancel(CancellationException("No remaining waiters"))
}
}
}
}
}
}
}
A few semantics worth stating plainly:
- The block of the first caller is the one that runs, and it runs in the helper's scope, not in the caller's context. Later callers with the same key join it.
- The helper's lifetime is tied to the parent scope you pass. If that scope is cancelled, the helper is finished too, which is intended: construct it from a scope that lives as long as the repository.
- If parent has no
Job, the supervisor is standalone and nothing cancels it, so pass a real scope. - A cancelled parent ends the helper for good: its internal scope cannot be revived, and
run()calls made afterwards fail with cancellation. - If the repository can outlive the scope you passed (for example across a re-login that recreates scopes), create a new
SingleFlightfrom the new scope instead of reusing the old instance. - Flights are removed from the map under the same lock that counts waiters, so a caller can never join a flight that is about to be cancelled, and a finished flight is never reused.
- Every caller awaits the same
Deferred. When the sync fails, each awaiting caller receives the same exception instance, still carrying the trace from the producer coroutine. The frames of whoever calledawait()are not in it.
Multiply this by a shared StateFlow that stores a failure, a CompletableDeferred used as a one-shot result, or a cache that memoizes errors, and a lot of the interesting trace goes missing.
2. The convention: copy first, rethrow the copy
The Kotlin documentation describes a general pattern. When an exception created in one concurrent computation is received by another, the framework can first make a copy, fill it with the receiver's stack trace, and rethrow that copy, keeping the original as the cause.
In kotlinx.coroutines this exists on the JVM as an opt-in behavior called stack trace recovery. The copying itself is JVM-only, but the stdlib interface is available on every target, so exception types in common code can participate. The result reads top to bottom as "the caller's path, then Caused by the original failure."
3. The catch: it only runs in debug mode, and Android does not enable that for you
In kotlinx.coroutines, stack trace recovery is a feature of the library's debug mode. Debug mode defaults to "auto", which means it turns on when JVM assertions are enabled. That is typical for JVM unit tests, where you get it for free.
On Android, assertions are off, so recovery is off unless you turn debug mode on yourself. The library reads the setting when its debug class initializes, so it has to be set early. A debug-only switch in your Application looks like this:
class NotesApp : Application() {
override fun attachBaseContext(base: Context) {
if (BuildConfig.DEBUG) {
// Must run before kotlinx.coroutines debug classes initialize.
System.setProperty("kotlinx.coroutines.debug", "on")
}
super.attachBaseContext(base)
}
}
The attachBaseContext hook is the earliest application-level place to do this. Android installs ContentProviders, including the ones AndroidX Startup uses, after attachBaseContext and before onCreate, so it beats onCreate for this job. It is still not bulletproof: the setting is read once, so anything that touches coroutine classes earlier, such as a static initializer or a field initializer in your Application subclass, locks in the default for that process.
Do not assume it worked; probe for it. A cheap probe, using the fact that awaiting an already-failed Deferred goes through recovery when it is on:
import kotlinx.coroutines.CompletableDeferred
// suspend is required: await() is a suspending call, even on an already-failed Deferred.
suspend fun stackTraceRecoveryActive(): Boolean {
val failed = CompletableDeferred<Unit>().apply { completeExceptionally(IllegalStateException("probe")) }
return try {
failed.await()
false
} catch (e: IllegalStateException) {
e.cause is IllegalStateException // a recovered copy wraps the original
}
}
Log the result once in debug builds. Treat the probe as a convenience and confirm it on your own setup before depending on it.
Two further facts shape how you should use all of this:
- The library's own documentation states that in optimized builds using R8 1.6.0 or later, debug mode and stack trace recovery are permanently turned off. So release builds do not get recovered traces, whatever your exception classes implement.
- The documentation also names a cost: recovery trades away referential transparency, because the exception you catch is a copy.
The honest framing is therefore: this is a debugging and diagnostics tool for debug builds, QA builds, and tests, not a way to enrich production crash reports. If you do want it elsewhere, treat that as a deliberate, measured decision.
4. Which exceptions are recovered without any work
The runtime can only copy an exception it knows how to rebuild. By default that means the class exposes one of four constructor shapes: (String, Throwable?), (String), (Throwable?), or no parameters. The library documentation also says a class that declares its own extra fields is not copied automatically.
Domain exceptions rarely fit. They carry a status code, an entity id, a byte count. A class like this one is silently skipped:
class SyncRejectedException(
val statusCode: Int,
val noteId: String,
) : RuntimeException("Sync rejected with HTTP $statusCode for note $noteId")
No error, no warning, and the trace stays as it was. That silence is the first failure mode and the reason the interface exists.
5. Implementing StackTraceRecoverable on the JVM
The interface has a single method, copyForStackTraceRecovery(), returning T?. You make the class its own type argument and build the copy yourself:
import kotlin.coroutines.ExperimentalStdlibCoroutineSupportApi
import kotlin.coroutines.debug.StackTraceRecoverable
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
class SyncRejectedException(
val statusCode: Int,
val noteId: String,
) : RuntimeException("Sync rejected with HTTP $statusCode for note $noteId"), StackTraceRecoverable<SyncRejectedException> {
override fun copyForStackTraceRecovery(): SyncRejectedException = SyncRejectedException(statusCode, noteId).also { it.initCause(this) }
}
Two details matter. The copy takes every piece of state explicitly, so nothing depends on guessing a constructor. And initCause(this) keeps the original reachable through cause, which preserves the producer-side frames that tell you what failed.
There is a trap in that second point. Throwable.initCause can be called only once, and it throws IllegalStateException if the cause was already set. That includes the case where a constructor passed a cause, even null, to its superclass. Here RuntimeException(message) leaves the cause uninitialized, so initCause is safe. If your class chains to super(message, cause), use the constructor-based pattern in the next section instead.
6. Implementing it in shared Kotlin code
In a multiplatform module initCause is not available. The workable pattern is a private constructor that accepts the cause, with the public constructor delegating to it:
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
class QuotaExceededException private constructor(
val limitBytes: Long,
val usedBytes: Long,
cause: Throwable?,
) : Exception("Quota exceeded: $usedBytes of $limitBytes bytes", cause), StackTraceRecoverable<QuotaExceededException> {
constructor(limitBytes: Long, usedBytes: Long) : this(limitBytes, usedBytes, null)
override fun copyForStackTraceRecovery(): QuotaExceededException = QuotaExceededException(limitBytes, usedBytes, cause = this)
}
The public API is unchanged, callers never see the private constructor, and the copy chains to the original on every target. This version also sidesteps the initCause trap entirely.
7. Opting out on purpose
The same interface can disable recovery for a type the runtime would otherwise copy. Return null and the original object is rethrown as is. You would want that when something compares exceptions by identity, such as a retry interceptor that treats one specific instance as a terminal marker:
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
class RetryBudgetExhausted : Exception("Retry budget exhausted"), StackTraceRecoverable<RetryBudgetExhausted> {
// Callers compare against a shared instance; a copy would never match.
override fun copyForStackTraceRecovery(): RetryBudgetExhausted? = null
}
If you write this often, the better fix is to stop using === on exceptions. Treat opt-out as a bridge for code you cannot change yet.
8. Wiring it into an Android error path
The recovered trace only helps if the code that reports errors keeps it. A ViewModel should be explicit about cancellation and pass the exception itself to reporting, not just its message:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
// NotesUiState, SyncSummary, ErrorReporter and pushPendingChanges are app-specific
// types and functions that this example assumes already exist.
class NotesViewModel(
private val sync: SingleFlight<String, SyncSummary>,
private val reporter: ErrorReporter,
) : ViewModel() {
private val _state = MutableStateFlow<NotesUiState>(NotesUiState.Idle)
val state: StateFlow<NotesUiState> = _state.asStateFlow()
fun refresh(accountId: String) {
viewModelScope.launch {
_state.value = NotesUiState.Syncing
_state.value = try {
NotesUiState.Synced(sync.run(accountId) { pushPendingChanges(accountId) })
} catch (e: CancellationException) {
throw e // never turn cancellation into an error state
} catch (e: SyncRejectedException) {
// With debug mode on, this trace includes refresh().
// In an optimized release build it will not.
reporter.record(e)
NotesUiState.Conflict(e.noteId)
}
}
}
}
Catching SyncRejectedException by type keeps working with recovery on, because the copy is the same class as the original. That is why the copy method must return the same type and not a generic wrapper.
9. Testing the contract so it does not rot
A copy method is easy to break later, for example when someone adds a field and forgets the copy. A small generic helper makes the contract executable, with type bounds that mirror the interface:
@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
fun <T> assertRecoverable(orig
Comments
No comments yet. Start the discussion.