Skip to content

Ktor request scope remains open and registered when downstream response completion fails #2463

Description

@inemtsev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions