SugarLite KMP in Production: A 3-Second-After-Launch Crash and the Cost of Uncaught viewModelScope Exceptions
When we closed Part 3, we said the post-migration routine is “new features default to KMP-first.” That’s true — but it hides a premise: shared-layer code does not run in equal environments on the two platforms. The same Kotlin code that survives as a catchable exception on Android can kill the entire process on iOS.
This post documents a real incident: the iOS build of SugarLite crashed with SIGABRT exactly 3 seconds after every cold start, with a stack pointing at an unfamiliar symbol — propagateExceptionFinalResort. The root cause wasn’t a single line of Swift. It was a handful of innocent-looking Flow subscriptions in our shared ViewModels — their exceptions were never caught, and on Kotlin/Native that means the process dies.
💥 The Incident: 3 Seconds After Launch, the Process Vanishes
The symptom was eerily consistent: cold-launch the iOS app, the home page starts loading data, and roughly 3 seconds in, the app disappears. Every device, every OS version, every single time.
What the crash reports told us:
- The signal was SIGABRT — not the usual SIGSEGV / SIGTRAP;
- The bottom of the stack showed
propagateExceptionFinalResort— not our code, not the Swift runtime, but the Kotlin/Native coroutine runtime’s last resort; - The same version on Android, with the same data, never crashed.
“Crashes on iOS, fine on Android” rules out ordinary logic bugs (null pointers, index overflows) and points at a platform-runtime behavioral difference. And the name propagateExceptionFinalResort already confesses everything: an exception propagated all the way to the top of a coroutine, nobody caught it, and the runtime killed the process.
🔍 Root Cause: A viewModelScope Without a CoroutineExceptionHandler
Recall the KMP ViewModel pattern from Part 3:
class HomeViewModel : ViewModel() {
private val _uiState = MutableStateFlow(viewModelScope, HomeUiState())
init {
viewModelScope.launch { observeDailyData() } // daily data
viewModelScope.launch { observeWeekData() } // weekly data
viewModelScope.launch { observeBloodSugarUnit() } // unit preference
}
}Inside observeDailyData() sits a textbook reactive chain: combine over 5 database Flows, flatMapLatest for date switching, and a final collect into UiState. The problem hides in that chain — if any upstream stage throws during collection (a failed database query, a network hiccup, a deserialization error), the exception propagates up the coroutine tree.
On Android, such an exception eventually reaches Thread.uncaughtExceptionHandler — an ordinary JVM crash, fully captured by your crash SDK, with the occasional lucky fallback. But two fatal differences apply here:
- KMP-ObservableViewModel’s
viewModelScopeinstalls noCoroutineExceptionHandler. It’s a bareSupervisorJob + Dispatchers.Main; an exception reaching the root has no handler at all. - Kotlin/Native’s default response to “uncaught exception at coroutine root” is
propagateExceptionFinalResort— the name says it literally: callabort()on the main thread, deliver SIGABRT, process dies instantly. No JVM stack trace, no graceful crash-SDK reporting. Execution by firing squad.
The full chain of events:
Room / network layer throws
→ nobody catches it inside Flow collection
→ exception climbs the Job tree to the viewModelScope root
→ no CoroutineExceptionHandler installed at the root
→ Kotlin/Native: propagateExceptionFinalResort
→ abort() → SIGABRT → process goneWhy exactly 3 seconds after launch? Because HomeViewModel fires all three Flow subscriptions in init, and database queries and cache reads cluster in the startup window — the first round of Flow emissions lands around the 3-second mark, and that’s when the exception detonates.
This also explains why the incident only appeared after the migration: before, these subscriptions lived in iOS-side StateHolders, wrapped in Swift do/catch around the for await loops. When the logic sank into shared, the safety nets didn’t sink with it.
🔧 The Fix: Give Every Subscription a Floor
The fix is deliberately unglamorous: wrap every Flow collection and business call inside viewModelScope.launch with try/catch. Using observeDailyData() as the example:
private suspend fun observeDailyData() {
// viewModelScope has no CoroutineExceptionHandler; any exception escaping
// a Flow collection aborts the iOS process via propagateExceptionFinalResort
try {
combine(
_uiState.map { it.selectedDate }.distinctUntilChanged(),
currentUserIdFlow(),
) { date, uid -> date to uid }
.flatMapLatest { (date, uid) ->
combine(
getDailyBloodSugarRecordsFlow(dateTs, uid),
getDailyExerciseRecordsFlow(dateTs, uid),
getDailyFoodRecordsWithItemsFlow(dateTs, uid),
getDailyMedicationRecordsFlow(dateTs, uid),
getDailyInsulinRecordDtosFlow(dateTs, uid),
) { bsDtos, exDtos, foodDtos, medDtos, insulinDtos ->
enrichExerciseReferences(exDtos)
assembleStateChunk(bsDtos, exDtos, foodDtos, medDtos, insulinDtos)
}
}
.collect { chunk ->
_uiState.update { state -> chunk(state) }
}
} catch (e: CancellationException) {
throw e // cancellation semantics must propagate unchanged
} catch (e: Exception) {
AppLogger.e("HomeViewModel", e) { "Failed to observe daily data" }
_uiState.update { it.copy(isLoading = false, errorMessage = "Load failed: ${e.message}") }
}
}Three points, none of them optional:
CancellationExceptionmust be rethrown. Coroutine cancellation works by throwingCancellationException; swallowing it incatch (e: Exception)means subscriptions keep running after the view dies — precisely the leak we eliminated in Part 3. It gets its own catch clause, rethrown verbatim, preserving cancellation semantics.- Log it, and write it into UiState. The exception no longer escapes, but it must not be silently swallowed either —
AppLogger.epreserves the debugging trail, whileisLoading = falsepluserrorMessageturn the failure into UI state. The user sees “load failed” instead of the entire app vanishing. That single catch is the entire distance between a crash and an error message. - The catch belongs outside
collect, not around every upstream Flow. Any stage of thecombine/flatMapLatestchain propagates its exception to the collecting coroutine, so one outertry/catchcatches everything — no need to wrap each upstream Flow individually.
The fix covered 5 ViewModels in total:
| File | Where the catch was added | Diff size |
|---|---|---|
HomeViewModel.kt | Three startup subscriptions: daily data, weekly data, unit preference | +67 -44 |
ProfileViewModel.kt | User-access subscription, profile subscription | +40 -23 |
AIFoodScanViewModel.kt | AI recipe-scan suspend call | +32 -22 |
AddFoodRecordViewModel.kt | AI meal-recognition submission | +28 -19 |
AddMedicationRecordViewModel.kt | Medication-name subscription | +17 -8 |
The pattern is identical everywhere: try wraps the entire subscription or call, CancellationException is rethrown, everything else is logged and written to errorMessage. No exceptions, no variants.
🤔 Why Not a Global CoroutineExceptionHandler?
The obvious first idea: if the root lacks a handler, just install a global CoroutineExceptionHandler on viewModelScope and be done with it.
// Looks elegant — we didn't choose this path
val handler = CoroutineExceptionHandler { _, throwable ->
AppLogger.e("VM", throwable) { "Uncaught exception" }
}Because CoroutineExceptionHandler solves “the process survives,” but not “what the user sees”:
- It has no context. By the time an exception reaches the root handler, all you get is a
Throwableand aCoroutineContext— no idea which subscription died, which page the user is on, or which UiState should receive anerrorMessage. The process survives, but the page spins onloadingforever. - It does nothing for
async. Exceptions inasyncare captured into theDeferredand only surface atawait()— they never pass through the root handler. - It’s a last resort, not business logic. Pushing “load failed, please retry” — state that belongs in UiState — into a global handler detaches error handling from single-track state management, the very foundation of our architecture: UiState is the only source of truth for the UI.
The value of local try/catch is precisely that it sits at the scene of the failure: it knows which operation failed, can translate the exception into that page’s errorMessage, and can reset isLoading. A global handler is the safety net for “in case we missed one”; local catches answer “what happens when this specific operation fails.” The two don’t conflict — but the latter is non-negotiable.
📌 What We Codified: Three Rules for Shared-Layer Exceptions
After the fix, we turned the lessons into coding conventions for the shared layer:
- Every
viewModelScope.launchthat collects a Flow must have atry/catchfloor. This was the direct root cause. Flows sit atop databases, networks, and platform bridges — all untrusted boundaries. Exceptions will happen; the only question is on whose device. catch (e: CancellationException) { throw e }is always the first catch clause. Cancellation semantics are the lifeblood of coroutines; swallowing them creates leaks. This goes first, every time, until it’s muscle memory.- Exceptions have exactly two exits:
AppLoggerandUiState.errorMessage. Never silently swallowed, never allowed to escape the coroutine. User-visible errors ride the UiState single track; debugging details go to the log — consistent with the architecture-wide principle that UiState is the single source of truth.
In hindsight, this class of bug is a systemic trap in KMP shared layers: the fault-tolerance habits Kotlin code acquires on Android do not transfer to iOS. The JVM world has a mature crash-capture apparatus underneath, so plenty of “never caught, never mattered” code survives there. Kotlin/Native’s propagateExceptionFinalResort has zero tolerance for that code — uncaught means executed. Every layer that sinks into shared deserves one more degree of respect for these platform behavior differences.
🍬 About SugarLite
SugarLite is a blood-glucose management app that helps people with diabetes and anyone tracking their glucose record and analyze blood sugar, meals, exercise, medication, and insulin — with intelligent features like AI meal recognition, AI recipes, and statistical reports. Built with Kotlin Multiplatform on both iOS and Android, with business logic and state management fully shared.
- Website: https://sugarlite.top
- App Store: Download SugarLite
📚 Series Posts & References
- Part 1: SugarLite’s Incremental KMP Migration — Sinking the Data Layer & Adapting CI/CD
- Part 2: ViewModels, Local Storage & Platform Abstractions
- Part 3: Direct Observation with KMP-ObservableViewModel
- KMP-ObservableViewModel
This post is based on a real production incident and its investigation in the SugarLite project.
Related Content
If you feel that this article has been helpful to you, your appreciation would be greatly welcomed.
Sponsor