Describe the bug
koin-ktor creates a request scope during CallSetup and closes it only from its ResponseSent handler.
Current implementation:
https://github.com/InsertKoinIO/koin/blob/4.2.2/projects/ktor/koin-ktor/src/commonMain/kotlin/org/koin/ktor/plugin/KoinPlugin.kt
on(CallSetup) { call ->
val scopeComponent = RequestScope(koinApplication.koin, call)
call.attributes.put(KOIN_SCOPE_ATTRIBUTE_KEY, scopeComponent.scope)
}
on(ResponseSent) { call ->
call.attributes[KOIN_SCOPE_ATTRIBUTE_KEY].close()
}
Ktor's ResponseSent hook calls proceed() before invoking its handler. If a later response hook or downstream send operation throws, execution never returns to Koin's handler and Scope.close() is skipped.
RequestScope stores the ApplicationCall as its source:
https://github.com/InsertKoinIO/koin/blob/4.2.2/projects/ktor/koin-ktor/src/commonMain/kotlin/org/koin/ktor/plugin/RequestScope.kt
The resulting scope remains open and registered in ScopeRegistry, retaining the complete ApplicationCall graph.
Observed impact
We found this while analyzing a 1+ GB heap from a long-running Ktor service.
The heap contained approximately 18,000 Koin request scopes. Their GC-root path was:
Koin
-> ScopeRegistry
-> request Scope
-> ApplicationCall
The retained calls included HTTP headers, cookies, coroutine continuations, request models, and request-scoped dependencies.
To Reproduce
I added the following test to KoinPluginRunTest on current Koin main/4.2.2:
@Test
fun `closes request scope when a later response completion hook fails`() = testApplication {
lateinit var requestScope: Scope
val responseCompletionHookRan = AtomicBoolean()
application {
install(Koin)
// A hook installed later runs first after proceed() in the
// ResponseSent phase.
install(
createApplicationPlugin(
name = "FailResponseCompletionBeforeKoinCleanup",
) {
on(ResponseSent) {
responseCompletionHookRan.set(true)
error("response completion failed before Koin cleanup")
}
},
)
routing {
get("/scope") {
requestScope = call.scope
call.respond(HttpStatusCode.OK)
}
}
}
runCatching {
client.get("/scope")
}
assertTrue(
responseCompletionHookRan.get(),
"response completion failure hook should run",
)
val scopeClosed = requestScope.closed
val scopeStillRegistered =
requestScope.getKoin().getScopeOrNull(requestScope.id) != null
assertTrue(
scopeClosed && !scopeStillRegistered,
"Koin-created request scope should be closed and removed " +
"(closed=$scopeClosed, registered=$scopeStillRegistered)",
)
}
Run it with:
./gradlew :ktor:koin-ktor:jvmTest \
--tests 'org.koin.ktor.ext.KoinPluginRunTest.closes request scope when a later response completion hook fails'
The test compiles and fails with:
Koin-created request scope should be closed and removed
(closed=false, registered=true)
The preceding assertion confirms that the injected failure hook ran. The final assertion directly observes that the Koin-created scope remains both open and registered.
Expected behavior
The request scope should close and be removed from ScopeRegistry whenever the Ktor call terminates, including when:
- response processing succeeds
- response processing throws
- response sending fails
- the call is cancelled
Cleanup should remain safe if Koin's normal ResponseSent handler has already closed the scope.
Koin module and version
koin-ktor:4.2.2
- Ktor 3.4.0
- JVM 21
- Reproduced on commit
dc86ef8dd8fbe8564fb7453c03f5b738da3450bb
Possible fix
Request-scope cleanup could use a lifecycle path that runs after successful completion, failure, and cancellation. For example, Koin could combine its existing ResponseSent handling with failed-call cleanup or wrap downstream call processing in try/finally.
The cleanup should use the scope already stored in KOIN_SCOPE_ATTRIBUTE_KEY and avoid closing it twice.
Describe the bug
koin-ktorcreates a request scope duringCallSetupand closes it only from itsResponseSenthandler.Current implementation:
https://github.com/InsertKoinIO/koin/blob/4.2.2/projects/ktor/koin-ktor/src/commonMain/kotlin/org/koin/ktor/plugin/KoinPlugin.kt
Ktor's
ResponseSenthook callsproceed()before invoking its handler. If a later response hook or downstream send operation throws, execution never returns to Koin's handler andScope.close()is skipped.RequestScopestores theApplicationCallas its source:https://github.com/InsertKoinIO/koin/blob/4.2.2/projects/ktor/koin-ktor/src/commonMain/kotlin/org/koin/ktor/plugin/RequestScope.kt
The resulting scope remains open and registered in
ScopeRegistry, retaining the completeApplicationCallgraph.Observed impact
We found this while analyzing a 1+ GB heap from a long-running Ktor service.
The heap contained approximately 18,000 Koin request scopes. Their GC-root path was:
The retained calls included HTTP headers, cookies, coroutine continuations, request models, and request-scoped dependencies.
To Reproduce
I added the following test to
KoinPluginRunTeston current Koinmain/4.2.2:Run it with:
./gradlew :ktor:koin-ktor:jvmTest \ --tests 'org.koin.ktor.ext.KoinPluginRunTest.closes request scope when a later response completion hook fails'The test compiles and fails with:
The preceding assertion confirms that the injected failure hook ran. The final assertion directly observes that the Koin-created scope remains both open and registered.
Expected behavior
The request scope should close and be removed from
ScopeRegistrywhenever the Ktor call terminates, including when:Cleanup should remain safe if Koin's normal
ResponseSenthandler has already closed the scope.Koin module and version
koin-ktor:4.2.2dc86ef8dd8fbe8564fb7453c03f5b738da3450bbPossible fix
Request-scope cleanup could use a lifecycle path that runs after successful completion, failure, and cancellation. For example, Koin could combine its existing
ResponseSenthandling with failed-call cleanup or wrap downstream call processing intry/finally.The cleanup should use the scope already stored in
KOIN_SCOPE_ATTRIBUTE_KEYand avoid closing it twice.