Engineering

Your dependency graph has six entry points and one of them runs twice

Application.onCreate, a Swift app init, a main function and a page load each start the graph. Two inits on one platform is a duplicate-definition crash at the worst moment.

MobileByteSensei 11 min read
kmpdiarchitecture

TL;DR

  • startKoin { } ran twice on Android — once from Application.onCreate and once from a ContentProvider a third-party SDK had installed — and crashed with KoinAppAlreadyStartedException on 0.8% of cold starts, 340 crashes a day, only on devices where that SDK's provider initialised after ours.
  • A KMP app has one graph and six entry points, each owned by a different runtime. We had written three of them. Only one of the six is guaranteed to run exactly once, and two can run concurrently with another.
  • The fix makes initialisation idempotent and single-threaded at the point where the platform cannot promise either — which is cheaper than finding every caller.
  • What reading on buys: the five entry points enumerated, which ones can double-fire, and the assertion that catches a second init in a test rather than in production.

The case this post is about

One shared module list, started per platform:

// core/di/src/commonMain/kotlin/Modules.kt
val sharedModules = listOf(networkModule, databaseModule, repositoryModule, viewModelModule)
// androidMain
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@App)
            modules(sharedModules + androidModule)
        }
    }
}
// iosApp
@main struct MyApp: App {
    init() { KoinKt.doInitKoin() }
    var body: some Scene { WindowGroup { ContentView() } }
}
// desktopMain
fun main() {
    startKoin { modules(sharedModules + desktopModule) }
    application { Window(onCloseRequest = ::exitApplication) { App() } }
}

Three entry points, hand-written, each looking like the only one on its platform. There are six, and the three we did not write are the ones this post is about.

The symptom

A crash that only happened on launch, only on Android, and only sometimes:

org.koin.core.error.KoinAppAlreadyStartedException: A Koin Application has already been started
  at org.koin.core.context.GlobalContext.startKoin(GlobalContext.kt:41)
  at com.example.App.onCreate(App.kt:12)

The stack points at our Application.onCreate — which runs once, by contract — so the crash reads as impossible:

# crashes per day, 2 weeks
  total                               340/day
  cold-start crash rate                 0.8%
  affected devices                   Android 12+, specific OEM builds
  reproducible locally                  no  (23 attempts)

Twenty-three local attempts, zero reproductions. And the thing that finally identified it was a single line in the crash breadcrumbs:

  -2ms   AnalyticsInitProvider.onCreate
  -1ms   startKoin (from AnalyticsInitProvider)      ← a ContentProvider we did not write
   0ms   App.onCreate
   0ms   startKoin → KoinAppAlreadyStartedException

A third-party SDK shipped a ContentProvider that called into our initialisation — because our own Koin setup had been helpfully extracted into a function that the SDK's integration guide told people to call from an initializer.

Three hundred and forty crashes a day on under one percent of cold starts, unreproducible in twenty-three local attempts, with breadcrumbs showing a third-party initialiser calling our startup one millisecond earlier

Why the obvious fix failed

The first fix was a guard flag:

private var started = false
fun initKoin() {
    if (started) return
    started = true
    startKoin { … }
}

Which reduced the crash rate from 0.8% to 0.1% and did not remove it. ContentProvider.onCreate and Application.onCreate both run on the main thread, so this should be safe — and on the affected OEM builds the breadcrumbs showed the two inits 1 ms apart, with a WorkManager initializer on a background thread in between. A non-volatile Boolean across two threads is a read that can miss a write.

The second fix was try/catch:

try { startKoin { … } } catch (e: KoinAppAlreadyStartedException) { /* fine */ }

Crash gone. Also: whichever init won the race defined the graph, and the two call sites passed different module lists — the SDK's path omitted androidModule, so an app that lost the race ran without its Android-specific bindings and failed later with NoBeanDefFoundException for Context. Trading a startup crash for a mid-session one is not an improvement.

The third attempt was to find and remove the other caller, which worked for that SDK and is not a fix: the exported function remains callable, the integration guide still says to call it, and the next SDK will do the same thing.

The actual mechanism

A KMP app's graph has as many entry points as it has runtimes, and the guarantees differ:

  entry point                         runs once?           can run concurrently?
  Application.onCreate (android)      yes, per process      no
  ContentProvider.onCreate (android)  once per provider —   YES — with other providers and
                                      and there are N       with Application.onCreate on some
                                                            OEM builds
  androidx Initializer (Startup)      once per component    yes, if a component is on a worker
  SwiftUI App.init (ios)              once per launch —     no — but SwiftUI may construct the
                                      documented            App VALUE more than once
  fun main (desktop)                  yes                   no
  page load / module eval (web)       once per document     no — but a hot-reloaded module in
                                                            dev re-evaluates

Three of six can fire more than once, and two can fire concurrently with another entry point. Application.onCreate is the only one with both guarantees, and it is the one the code was written against.

And a ContentProvider is an initialisation hook that other people control. Any dependency can declare one in its manifest, and the merged manifest decides the order. You do not own the list, so "find every caller" is not a stable property — it is true until the next dependency bump.

Plus the two-list problem, which is the dangerous half. startKoin is not just "start"; it defines the graph. Two callers with different module lists means the winner determines what exists. A catch that swallows the second call silently accepts whichever list arrived first, and the failure moves to the first missing binding — far from the cause, with a stack trace naming a Context rather than an initialisation race.

Six process entry points enumerated against whether each runs once and whether it can run concurrently with another, showing only one has both guarantees

The fix

Make initialisation idempotent, serialised, and list-authoritative: one place owns the module list, a second call is a no-op rather than a competitor, and a second call with a different list is a loud failure rather than a silent one.

// core/di/src/commonMain/kotlin/AppGraph.kt
/**
 * The single owner of graph startup.
 *
 * Idempotent and serialised because the PLATFORM cannot promise either: a third-party
 * ContentProvider called our init 1 ms before Application.onCreate on some OEM builds
 * (340 crashes/day, 0.8% of cold starts), and a non-volatile guard flag across the two threads
 * involved still leaked 0.1%.
 *
 * NOT a try/catch around startKoin: that silently accepts whichever module list arrived first,
 * and the two call sites passed different lists — so the app ran without androidModule and
 * failed later with NoBeanDefFoundException<Context>, a mid-session crash instead of a
 * startup one.
 */
object AppGraph {

    private val lock = SynchronizedObject()
    private var startedWith: Set<String>? = null

    fun start(modules: List<Module>, extra: KoinApplication.() -> Unit = {}) {
        val fingerprint = modules.flatMap { m -> m.mappings.keys.map { it.toString() } }.toSet()

        synchronized(lock) {
            val existing = startedWith
            if (existing != null) {
                // A second call with the SAME graph is a benign double-init — the exact
                // situation the ContentProvider created, and the one we want to absorb.
                if (existing == fingerprint) return

                // A second call with a DIFFERENT graph is the bug the catch was hiding. Fail
                // here, naming what differs, rather than at the first missing binding.
                val missing = existing - fingerprint
                val added = fingerprint - existing
                error(
                    "the DI graph was already started with a different module set.\n" +
                    "  only in the first start: ${missing.take(5)}\n" +
                    "  only in this call:      ${added.take(5)}\n" +
                    "  Both callers must use AppGraph.start(appModules) — see core/di/AppGraph.kt"
                )
            }
            startKoin { modules(modules); extra() }
            startedWith = fingerprint
            startCountForTest++
        }
    }

    /** Tests only: how many times startKoin actually ran, which is the property under test. */
    var startCountForTest: Int = 0
        private set

    /** Tests only: a fresh graph per test, since the object survives between them. */
    fun resetForTest() = synchronized(lock) {
        stopKoin(); startedWith = null; startCountForTest = 0
    }
}
// androidMain — the list lives in ONE place, and every entry point calls the same function
object AndroidGraph {
    val modules get() = sharedModules + androidModule
}

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        AppGraph.start(AndroidGraph.modules) { androidContext(this@App) }
    }
}
// androidMain — and the hook other people's SDKs reach for is now safe by construction.
// A ContentProvider is an init hook that DEPENDENCIES control: any of them can declare one,
// the merged manifest decides the order, and we never own the list. So the exported entry
// point is made idempotent rather than hidden.
class GraphInitProvider : ContentProvider() {
    override fun onCreate(): Boolean {
        AppGraph.start(AndroidGraph.modules) { androidContext(context!!.applicationContext) }
        return true
    }
    // the rest of the ContentProvider surface: not a data provider
    override fun query(…) = null
    override fun insert(…) = null
    …
}

And the test, which asserts the property rather than the absence of a crash:

// commonTest/kotlin/AppGraphTest.kt
class AppGraphTest {

    @AfterTest fun tearDown() = AppGraph.resetForTest()

    @Test
    fun aSecondStartWithTheSameModulesIsANoOp() {
        AppGraph.start(testModules)
        AppGraph.start(testModules)          // the ContentProvider case
        assertNotNull(KoinPlatformTools.defaultContext().getOrNull())
    }

    @Test
    fun aSecondStartWithDifferentModulesFails() {
        // The case try/catch swallowed, which produced a mid-session NoBeanDefFoundException.
        AppGraph.start(testModules)
        val e = assertFailsWith<IllegalStateException> {
            AppGraph.start(testModules + module { single { "extra" } })
        }
        assertContains(e.message!!, "already started with a different module set")
    }

    @Test
    fun concurrentStartsResolveToOneGraph() = runTest {
        // 1 ms apart on two threads is what the breadcrumbs showed; a non-volatile Boolean
        // leaked 0.1% of the time, so the guard is a lock rather than a flag.
        List(16) { async(Dispatchers.Default) { AppGraph.start(testModules) } }.awaitAll()
        assertEquals(1, AppGraph.startCountForTest)
    }
}

And the output it produced:

$ ./gradlew :core:di:allTests --tests '*AppGraph*'
aSecondStartWithTheSameModulesIsANoOp   PASSED × 4 targets
aSecondStartWithDifferentModulesFails   PASSED × 4 targets
concurrentStartsResolveToOneGraph       PASSED × 4 targets
$ ./tools/crash-report.sh --issue KoinAppAlreadyStarted --days 14
  before   340/day   (0.8% of cold starts)
  after      0/day
$ ./tools/crash-report.sh --issue NoBeanDefFound --days 14
  before     4/day   (the try/catch week)
  after      0/day

How we proved it

That a second caller existed. The breadcrumb that ended two weeks of "this crash is impossible":

  -2ms   AnalyticsInitProvider.onCreate        ← not our code
  -1ms   startKoin
   0ms   App.onCreate
   0ms   startKoin → KoinAppAlreadyStartedException

That the two callers passed different lists. The measurement that ruled out try/catch, and the reason it mattered more than the crash:

  caller                       modules passed              androidContext?
  App.onCreate                 shared + androidModule      yes
  AnalyticsInitProvider        shared                       no

Whichever won defined the graph. The SDK's path had no Context binding at all.

That a non-volatile flag is not enough. Measured in production, because the two inits are nominally both on the main thread:

  guard                         crash rate
  none                             0.8%
  private var started = false      0.1%     ← still leaking
  synchronized + fingerprint       0.0%

The breadcrumbs showed a WorkManager initializer on a worker thread between the two calls on the affected builds.

That we do not own the provider list. Counted, since "remove the other caller" was the tempting fix:

$ ./gradlew :app:processReleaseManifest && grep -c '<provider' app/build/.../AndroidManifest.xml
7
$ # providers we wrote
1

Six initialisation hooks installed by dependencies, in an order the manifest merger decides.

That SwiftUI can construct the App value more than once. Checked after the Android fix, because the same class of assumption was in the iOS code:

  instrumented MyApp.init(), 40 launches
    init called once      38
    init called twice      2     ← on a scene reconnection after a backgrounded launch

Two of forty. The idempotent start absorbs it; before the fix it was a latent crash on iOS that we had never seen in the wild and could not have ruled out.

That the fingerprint catches a real mistake. The error fired once during the migration, which is the only evidence worth having for an assertion:

$ ./gradlew :app:assembleDebug && adb install … && adb shell am start …
FATAL: the DI graph was already started with a different module set.
  only in this call: [androidx.work.WorkManager, com.example.SyncScheduler]
  Both callers must use AppGraph.start(appModules)

That is the entry point that put the third row in the table above. We had enumerated Application.onCreate, the ContentProvider we had just learned about, and the three non-Android ones — and missed an androidx.startup Initializer added for WorkManager, with a module list of its own. The assertion found it on the first debug launch after the migration, which is the only kind of evidence an assertion can offer.

What I cannot tell you is whether the module fingerprint is sound for every Koin feature. It hashes binding keys, so two lists that bind the same keys to different implementations compare as equal and the second call is absorbed silently. That is the right behaviour for the double-init case and would hide a genuine misconfiguration, and we have no case where two callers legitimately bind the same key differently.

And an honest consequence. AppGraph is global mutable state with a lock, which is exactly the shape DI exists to avoid. The justification is that graph startup is already global — startKoin writes a process-wide singleton — so this makes an existing global explicit rather than adding one. It also means tests need resetForTest, and a test that forgets it inherits the previous test's graph.

The two callers passing different module lists, a non-volatile guard flag still leaking a tenth of a percent, and six initialisation hooks installed by dependencies

What this generalizes to

Process initialisation has as many entry points as the platform has runtimes, and the number is not fixed by you: a dependency can add one. Code written against the entry point that does guarantee single execution reads as correct and is a race on every other one. So the robust shape is not to enumerate the callers — that list changes on the next dependency bump — but to make the initialisation itself idempotent, serialised, and loud when two callers disagree about what they are initialising, because that disagreement is the failure that outlives the crash.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS