Skip to content

declare doesn't properly clean up "displaced" InstanceFactory, so state leaks across stopKoin/startKoin when a Module is reused; Kotest v6 upgrade makes it more likely to trigger this #2412

@glasser

Description

@glasser

Describe the bug

tl;dr: Koin has long-standing surprising behavior where calling declare on a Koin object results in state being saved across calls to stopKoin and startKoin which otherwise would not be persisted when a Module is reused. Without the declare call, Module reuse doesn't lead to this state leak, so it seems that this loophole is a bug. The Kotest testing library ships with a KoinExtension that makes it easy to use Koin modules in your tests and wraps each test function in startKoin/stopKoin calls which should provide each test function with distinct state (and it's designed to be used along with KoinTest which uses the Koin global context). Kotest v6 changed the way extensions are loaded so that they are defined once in a val instead of on each call to a fun, which means you are much more likely to share Koin Modules across test functions when you try to upgrade to Kotest v6, and thus much more likely to trigger this bug. I have a repository with a pure-Koin reproduction as well as showing how a passing Kotest v5 test becomes a failing Kotest v6 test. There are workarounds in Kotest v6 but they are either brittle or have performance impact; I think this should be fixed in Koin and will file a small PR to fix it. (For transparency, I wrote this paragraph myself from scratch; the rest of this issue was drafted with Claude though I've edited it.)

When a binding is overridden via koin.declare(...) (or any InstanceRegistry.saveMapping call with allowOverride = true), the displaced InstanceFactory is replaced in InstanceRegistry._instances but its cached value is never dropped. Because Module.mappings still holds the original factory by reference, that factory survives stopKoin() with its old singleton intact. The next startKoin { modules(sameModuleInstance) } re-registers the still-cached factory, and the next get<T>() returns a stale instance — including any state mutations from the previous Koin lifecycle.

This is a long-standing latent bug present in every released version I checked (3.x, 4.0, 4.1, 4.2.1, current main). It happens whenever (a) the same Module instance is loaded across multiple stopKoin/startKoin cycles and (b) one of its bindings is overridden with declare. The leak is observable — i.e. the user notices that they got back the previous lifecycle's singleton — when the singleton has identity-distinguishable state: mutated fields, recorded mock interactions, captured spy state, etc. Otherwise the same instance is silently returned but looks functionally fresh. That combination is uncommon in production code but routine in test setups — and the population of users hitting this just expanded sharply with kotest 6 (see "Additional context" below).

Issue #2001/#2210 are very related: one is avoiding eagerly initializing a factory that may be displaced, and this is cleaning up an already-sed factory that is being replaced.

To Reproduce

See below for a Git repository with self-contained reproductions. The steps that trigger this:

  1. Build a Module with a single { ... } binding and store it in a val that outlives a single Koin lifecycle (e.g., a top-level property).
  2. startKoin { modules(sharedModule) }.
  3. get<T>() and mutate the resulting singleton.
  4. koin.declare<T>(SomethingElse()) to override the binding mid-lifecycle.
  5. stopKoin().
  6. startKoin { modules(sharedModule) } (the same Module instance).
  7. get<T>().

Observed: step 7 returns the same instance from step 3 with the mutation from step 3 still present. (It doesn't return the instance from the declare itself — Koin properly resets back to the right Module. But the Module itself isn't properly cleaned up.)

Expected behavior

After step 5 (stopKoin()), the cached value of every SingleInstanceFactory in sharedModule.mappings should be dropped — including the one that was displaced by declare. So step 7 should return a fresh instance whose state is whatever the single { ... } definition produces, not the mutated value from step 3.

Koin module and version:

io.insert-koin:koin-core:4.2.1 (also reproducible against main at commit 7fe8c527, and against every earlier version I checked back to 3.x — not a recent regression).

Snippet or Sample project to help reproduce

A self-contained reproduction repo with three Gradle subprojects is available at:

https://github.com/glasser/koin-declare-reproduction

Subproject Versions ./gradlew :sub:test
pure-koin koin-core 4.2.1 fails — direct repro, no test framework
kotest-5 kotest 5.9.1 + io.kotest.extensions:kotest-extensions-koin 1.3.0 + koin 3.5.6 passes — fun extensions() masks the bug
kotest-6 kotest 6.1.11 + io.kotest:kotest-extensions-koin 6.1.11 + koin 4.2.1 fails for Kotest6DeclareSpec; passes for Kotest6InstancePerRootSpec (workaround)

The same logical test scenario passes on kotest 5 and fails on kotest 6 — demonstrating that the user-observable behavior changed between kotest versions with no koin code change. The Kotest6InstancePerRootSpec shows that IsolationMode.InstancePerRoot is a viable spec-level workaround, but see below for why this isn't ideal.

The koin-only repro inlined for convenience:

import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.mp.KoinPlatform

interface Repo { var state: Int; val tag: String }
class RealRepo(override val tag: String) : Repo { override var state: Int = 0 }

fun main() {
    val sharedModule = module {
        single<Repo> { RealRepo("from-module") }
    }

    // Cycle 1: get the singleton, mutate it, then declare-override and stop.
    startKoin { modules(sharedModule) }
    val first = KoinPlatform.getKoin().get<Repo>()
    first.state = 999

    KoinPlatform.getKoin().declare<Repo>(RealRepo("from-declare"))

    stopKoin()

    // Cycle 2: reload the same module instance and read the binding.
    startKoin { modules(sharedModule) }
    val second = KoinPlatform.getKoin().get<Repo>()

    println("tag=${second.tag} state=${second.state} same=${first === second}")
    // Actual:   tag=from-module state=999 same=true
    // Expected: tag=from-module state=0   same=false
}

Without the declare step, stopKoin/startKoin resets the singleton correctly. The leak is specifically introduced by the override.

Why do we call declare in our tests after already calling get? A lot of our tests do something like this (calling KoinTest.declare):

val fooSpy = declare { spy(get<Foo>()) }

ie, they use the properly initialized Koin module to extract a Foo object, wrap it in a "spy" implementation that implements the same interface with the ability to spy on what it's doing, and then re-declare that as the main implementation in the Koin context. (We make sure to do this at the very beginning of the test before anything else has a chance to fetch a Foo.)


Additional context

Why kotest 6 newly exposes this

In kotest 5.x, Spec exposed extensions via a function:

abstract class Spec : TestConfiguration() {
    open fun extensions(): List<Extension> = listOf()
}

A user overriding it as override fun extensions() = listOf(KoinExtension(module { ... })) got a fresh module { } (and therefore fresh InstanceFactory objects) on every call — and kotest's engine calls spec.extensions() at multiple points per spec lifecycle. The koin bug existed, but the displaced factory's stale state was thrown away with the old module each iteration, so it didn't surface.

Kotest 6 (PR kotest/kotest#4640, commit 02573516e, merged 2025-01-12, released in v6.0.0) changed it to a property:

abstract class Spec : TestConfiguration() {
    open val extensions: List<Extension> = emptyList()
}

A val — so the natural override val extensions = listOf(KoinExtension(module { ... })) is evaluated once at spec construction, and under the default IsolationMode.SingleInstance the spec is constructed once per spec class. The KoinExtension and its captured Module are now reused across every test in the spec, which is exactly the input shape that triggers the latent koin bug.

So: anyone using kotest-extensions-koin with kotest 6, with declare/declareMock to override a binding mid-test, will see test-to-test state pollution where they didn't before. The koin bug is the same bug that's always been there; kotest 6 just removed the accidental masking.

There are Kotest-level workarounds, but they're not ideal:

  • You can put a get()ter on your override val extensions but that only works because Kotest happens to re-read this property often enough rather than caching it, as you might think it might do with a val!
  • You can use IsolationMode.InstancePerRoot which will require the whole Spec object somewhat more often, though this will prevent you from sharing other setup intentionally across test functions, and only fixes the issue across root test functions, not other ones (the related IsolationMode values that are more precise are deprecated in v6).

It would be better to fix this in Koin so that stopKoin/startKoin is more effective.

Root cause

InstanceRegistry.saveMapping (projects/core/koin-core/src/commonMain/kotlin/org/koin/core/registry/InstanceRegistry.kt:81) overwrites the registry mapping but does not drop the displaced factory:

fun saveMapping(
    allowOverride: Boolean,
    mapping: IndexKey,
    factory: InstanceFactory<*>,
    logWarning: Boolean = true,
) {
    _instances[mapping]?.let {
        if (!allowOverride) {
            throwOverrideError(factory, mapping)
        } else if (logWarning) {
            _koin.logger.warn("(+) override index '$mapping' -> '${factory.beanDefinition}'")
            // remove previous eager isntance too
            val existingFactory = eagerInstances.values.firstOrNull { it.beanDefinition == factory.beanDefinition }
            if (existingFactory != null) {
                eagerInstances.remove(factory.beanDefinition.hashCode())
            }
        }
        // <-- no dropAll() on the displaced factory
    }
    _koin.logger.debug("(+) index '$mapping' -> '${factory.beanDefinition}'")
    _instances[mapping] = factory
}

After declare:

  • _instances[K] points at the new declare-owned factory.
  • module.mappings[K] still points at the original factory, which still has value = <pre-declare singleton>.

Koin.close()instanceRegistry.close() iterates _instances and calls dropAll() on what it finds — i.e., only the declare-owned factory. The orphaned original is never reached, so its SingleInstanceFactory.value (and any onClose hook) is preserved.

startKoin { modules(sameModule) } then calls loadModule (InstanceRegistry.kt:71), which re-registers module.mappings's factories — including the orphan with its stale value. The next resolution returns it.

Proposed fix

Two lines: when saveMapping displaces a different factory, drop the displaced one's cached state before replacing it.

fun saveMapping(
    allowOverride: Boolean,
    mapping: IndexKey,
    factory: InstanceFactory<*>,
    logWarning: Boolean = true,
) {
    _instances[mapping]?.let { displaced ->
        if (!allowOverride) {
            throwOverrideError(factory, mapping)
        } else {
            if (logWarning) {
                _koin.logger.warn("(+) override index '$mapping' -> '${factory.beanDefinition}'")
                val existingFactory = eagerInstances.values.firstOrNull { it.beanDefinition == factory.beanDefinition }
                if (existingFactory != null) {
                    eagerInstances.remove(factory.beanDefinition.hashCode())
                }
            }
            if (displaced !== factory) {
                displaced.dropAll()
            }
        }
    }
    _koin.logger.debug("(+) index '$mapping' -> '${factory.beanDefinition}'")
    _instances[mapping] = factory
}

The displaced !== factory guard preserves the no-op behavior when the same factory is re-registered (e.g., loading a module twice in the same lifecycle).

Verification

I will open a PR with my fix and a regression test. With the patch:

  • The new test passes; without the patch, the new test fails on the assertNotSame(first, second) and assertEquals(0, second.value) assertions (i.e., the bug is observable).
  • ./gradlew :core:koin-core:jvmTest runs 284/284 tests passing.
  • All existing DeclareInstanceTest tests still pass — no behavior change for the documented declare/override semantics, only for the latent cache leak.

Versions affected

Confirmed by reading source at:

Version saveMapping drops displaced factory?
main (commit 7fe8c527) no
origin/4.2.2 no
4.2.1 (latest release) no
4.0.0 no
3.5.0 no
3.0.1 (older API shape, same behavior) no

So this is not a regression — it has always been this way.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions