Hey all :wave: Looking for confirmation from anyon...
# multiplatform
l
Hey all 👋 Looking for confirmation from anyone running KMP iOS in production with Firebase Crashlytics. Setup We have
NSExceptionKt
installed (the
setUnhandledExceptionHook
-based bridge to Crashlytics). On the main thread it works beautifully — Kotlin throwables get converted to
NSException
and Crashlytics captures full Kotlin frames, class names, messages, the works. The worry Our reading of the docs / source is that
setUnhandledExceptionHook
only fires for exceptions that reach specific Kotlin boundaries (main runloop, ObjC export boundary). When an unhandled exception happens on
Dispatchers.Default
/
<http://Dispatchers.IO|Dispatchers.IO>
/ a worker thread, the runtime apparently calls
konan::abort()
directly from the worker, bypassing the hook entirely. The resulting Crashlytics report looks like this:
Copy code
SIGABRT
  konan::abort
  ExceptionObjHolderImpl::terminateWithUnhandledException
  BaseContinuationImpl.resumeWith
  CoroutineScheduler$Worker.run
No exception class name, no message, no Kotlin frames — and worst of all, every kind of unhandled (NPE / ISE / OOBE / anything) collapses into a single issue group in the dashboard. Why this is scary Most of our domain/data layer (repositories, use cases, Ktor responses, DataStore I/O) runs on
<http://Dispatchers.IO|Dispatchers.IO>
. If the above is correct, a huge portion of production crashes would be effectively undebuggable, with misleading "100% of users hit this one issue" stats. Two questions: 1. _Is this actually the case in practice?_Has anyone empirically verified that unhandled exceptions on background dispatchers are captured poorly (or not at all) by Crashlytics on KMP iOS, even with NSExceptionKt installed? 2. _If yes — how are you handling it in production?_Patterns we're considering: • Installing a`CoroutineExceptionHandler`on every scope that bridges the throwable back to the main thread before re-throwing (so the hook fires) • A base class for repositories / use cases with try/catch + non-fatal reporting (
recordException
) • Switching to Sentry KMP / Kotzilla / another SDK that handles worker-thread crashes natively • Something else entirely (CrashKiOS, Kermit, custom solution…)
f
Take a look on other thread of your crash report. Even better, give the full symbolicate crash report to favorite ai agent, it’s very helpful with your source code as context.
The main paint point of native crash I have faced it’s when it occurred inside a coroutine.
FYI, kotzilla seems better as it takes another approach for handling native crash
Sentry use the same approach as nsexceptionkt
l
Got it, thanks. So it sounds like there's basically no clean solution for unhandled exceptions on background coroutines today, and we just have to work around it — does that match your experience?
f
Yes, you need to look at every thread inside your crashlog. A kotlin native crash is not as clean as a swift crash, it takes more analysis. For me, it’s like reading a crash inside a RxSwift applications.
Don't trust the line who said the crash happened on thread ##
AI agent are really smart to read crashlog when it has access to the source code; give it a try
r
This sounds strange. @Luis Daivid do you have a working example that demonstrates this behavior (unhandled exception on background thread)?
f
@Rick Clephas that’s something I was asking myself, these should be handled
(Readable is another issue)
l
@Rick Clephas My apologies — I misread the situation earlier. My case is actually identical to https://github.com/rickclephas/NSExceptionKt/issues/51. The only difference from what's described in that issue is that I'm on Kotlin 2.3.21 and CMP 1.11.0. Would it be possible to get this resolved?
r
Ah alright, yeah would love to resolve that. Unfortunately I haven’t been able to reproduce this (with the code snippets in the sample project). If you could share a small reproducer that would be really great!
f
@Rick Clephas Seems to be CMP related, can you look at this https://github.com/getsentry/sentry-kotlin-multiplatform/pull/554 ?
@Giancarlo Buenaflor?
l
Copy code
// All three buttons throw on the Compose main thread (DarwinMainDispatcher).
// With NSExceptionKt installed, I expected the same well-formed Crashlytics
// report for all three — Kotlin exception class, message, and KMP frames.
//
// What actually happens on Kotlin 2.3.21 + CMP 1.11.0:
//   1.2 / 1.3  → works as expected (Kotlin class name, message, full stack)
//   1.1        → collapses into `(anonymous namespace)::ExceptionObjHolderImpl`
//                with no Kotlin frames, identical to issue #51
//
// The only structural difference of 1.1 is that the throw happens
// synchronously inside the onClick lambda (no coroutine in between).
@Composable
fun CrashRepro() {
    val scope = rememberCoroutineScope()

    Column {
        // 1.1 — synchronous throw from onClick → BROKEN report
        Button(onClick = { fatalGeneralStep1() }) {
            Text("1.1 throw from plain function (onClick)")
        }

        // 1.2 — same main thread, but wrapped in scope.launch → OK
        Button(onClick = { scope.launch { fatalSuspendStep1() } }) {
            Text("1.2 throw from suspend function")
        }

        // 1.3 — same main thread, throw inside Flow operator → OK
        Button(onClick = { scope.launch { fatalFlowStep1() } }) {
            Text("1.3 throw inside Flow operator")
        }
    }
}

// ----- 1.1 (broken in Crashlytics) -----
private fun fatalGeneralStep1() = fatalGeneralStep2()
private fun fatalGeneralStep2() = fatalGeneralStep3()
private fun fatalGeneralStep3() {
    error("Test crash from general function")
}

// ----- 1.2 (reported correctly) -----
private suspend fun fatalSuspendStep1() {
    delay(10)
    fatalSuspendStep2()
}
private suspend fun fatalSuspendStep2() {
    delay(10)
    fatalSuspendStep3()
}
private fun fatalSuspendStep3() {
    require(false) { "Test crash from suspend function" }
}

// ----- 1.3 (reported correctly) -----
private suspend fun fatalFlowStep1() {
    flow {
        emit(1)
        emit(2)
        emit(3)
    }
        .map { fatalFlowStep2(it) }
        .collect()
}
private fun fatalFlowStep2(value: Int): Int = fatalFlowStep3(value)
private fun fatalFlowStep3(value: Int): Int {
    if (value == 3) {
        throw ArithmeticException("Test crash from flow operator")
    }
    return value
}
r
Thanks! Will take a look at this later.
👍 1
g
Regarding Sentry; I noticed this after going from CMP 1.8.x to 1.9.x and disabling our cpp monitoring in Sentry Cocoa mitigates this issue for now though its not the ideal long term solution
f
Well Sentry use NSExceptionKt, so same behaviour same issues 😄
g
the issue is mainly that on CMP these unhandled errors are intercepted by Sentry Cocoa's native exception handling (and from what I can tell also in Crashlytics) before the setUnhandledExceptionHook in NSExceptionKt can trigger so I am not entirely sure if this is fixable on a Kotlin level but maybe Rick has some ideas once he takes a look
🤔 1
🦸‍♂️ 1
cc @romtsn
r
@Luis Daivid could you share some details about the Crashlytics / NSExceptionKt setup code? I am specifically interested in the location and order of this code. Based on my tests this issue is related to the initialisation order. Both Crashlytics and Kotlin Runtime have a C++ termination handler. If Crashlytics is initialised before Kotlin the handlers have a proper order and Kotlin will handle its own unhandled exceptions before Crashlytics is able to log them. But otherwise Crashlytics will log the crash before the Kotlin unhandled exception logic runs. Since Crashlytics only stores a single fatal crash the second one (the proper Kotlin crash) is never stored.
l
@Rick Clephas Thanks for asking. Here's the relevant part of our setup: @main struct iOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { ContentView() } } }
Copy code
final class AppDelegate: NSObject, UIApplicationDelegate {
    // :warning: non-lazy stored properties that touch KMP symbols
    private let loginManager = LoginManager.instance             // Kotlin object
    private let crashlyticsLogger = FirebaseCrashlyticsLogger()  // conforms to a Kotlin protocol
    // ...a few more like this

    func application(_: UIApplication, didFinishLaunchingWithOptions _: ...) -> Bool {
        FirebaseApp.configure()
        NSExceptionKt.addReporter(.crashlytics(causedByStrategy: .append))
        // ...register KMP holders
        startKoinAndOtherKmpInit()
        return true
    }
}
The intended order in
didFinishLaunchingWithOptions
is
FirebaseApp.configure()
→
NSExceptionKt.addReporter(.crashlytics)
→ rest of KMP init, which matches the "correct" ordering you described. But based on your theory, our setup is probably broken too. The
AppDelegate
has non-lazy stored properties that reference KMP symbols (a Kotlin
object
, a class that conforms to a Kotlin protocol, etc.). Swift evaluates non-lazy stored properties when the owning instance is constructed, so the Kotlin runtime is almost certainly initialized inside
AppDelegate.init()
— before
FirebaseApp.configure()
ever runs. Net result: the Kotlin terminate handler is installed first, Crashlytics on top of it — exactly the bad ordering. Two questions: 1. If that diagnosis is right, is the recommended fix to move`FirebaseApp.configure()`earlier — e.g. into`iOSApp.init()`or an ObjC`+initialize`— so it runs before_any_Kotlin symbol is touched? 2. Or does`NSExceptionKt`expose a way to control the Kotlin-side handler registration order from the Kotlin side?
r
Thanks for the detailed response. That would indeed result in the “incorrect” order. 1. I don’t think that is recommended, the Firebase docs explicitly state the
didFinishLaunchingWithOptions
function 2. No (at least not right now). This might be quite difficult. The issue here is in the C++ exception handler set by the Kotlin runtime and Crashlytics. While the Crashlytics initialisation is know, the point at which the Kotlin runtime initialises isn’t know. E.g. in your example it would be one of those stored properties. To be able to control the order we would need to register a handler before any other handler is set. I have created an issue for this: https://youtrack.jetbrains.com/issue/KT-86576/K-N-C-terminate-handler-setup-order-issue
l
@Rick Clephas Hi Rick, just wanted to check in — any updates on this?
r
There is some activity on the Kotlin side, so this problem will hopefully be resolved in an upcoming Kotlin release kodee intensifies
l
Thanks! Where can I follow that activity?
r
There is a draft PR linked to the ticket: https://github.com/jetbrains/kotlin/pull/6362
l
Thank you so much, really appreciate it!