Kimble
Kimble

Reputation: 7564

How can I make sure all Kotlin coroutines created by a ktor websocket client are cleared up?

I'm trying to wrap my head around Kotlin coroutines and Ktors websocket support. My understanding is that runBlocking will create a scope and that it will block as long as there are coroutines living inside that scope (or child scopes), but I when the call to runBlocking in the test below returns there are still two coroutines alive..

Why am I leaking coroutines here?

package dummy

import io.ktor.client.HttpClient
import io.ktor.client.features.websocket.WebSockets
import io.ktor.client.features.websocket.wss
import io.ktor.http.HttpMethod
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.readBytes
import io.ktor.http.cio.websocket.readText
import io.ktor.util.KtorExperimentalAPI
import kotlinx.coroutines.*
import kotlinx.coroutines.debug.DebugProbes
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test

@ExperimentalCoroutinesApi
@KtorExperimentalAPI
class WebsocketTest {

    @Test
    fun tidy() {
        DebugProbes.install()

        runBlocking {
            val socketJob = Job()

            launch(CoroutineName("Websocket") + socketJob) {
                println("Connecting to websocket")
                connectWebsocket(socketJob)
                println("Websocket dead?")
            }

            launch(CoroutineName("Ninja socket killer")) {
                delay(3500)
                println("Killing websocket client")
                socketJob.cancel(message = "Time to die..")
            }
        }

        println("\n\n-------")
        DebugProbes.dumpCoroutines(System.err)
        Assertions.assertEquals(0, DebugProbes.dumpCoroutinesInfo().size, "It would be nice if all coroutines had been cleared up by now..")
    }

}


@KtorExperimentalAPI
private suspend fun connectWebsocket(socketJob: CompletableJob) {

    val client = HttpClient {
        install(WebSockets)
    }

    socketJob.invokeOnCompletion {
        println("Shutting down ktor http client")
        client.close()
    }

    client.wss(
            method = HttpMethod.Get,
            host = "echo.websocket.org",
            port = 443,
            path = "/"
    ) {

        send(Frame.Text("Hello World"))

        for (frame in incoming) {
            when (frame) {
                is Frame.Text -> println(frame.readText())
                is Frame.Binary -> println(frame.readBytes())
            }

            delay(1000)
            send(Frame.Text("Hello World"))
        }
    }

}

build.gradle.kts

import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent

plugins {
    kotlin("jvm") version "1.3.41" apply true
}

repositories {
    mavenCentral()
}

val ktorVersion = "1.2.3"
val junitVersion = "5.5.1"

dependencies {
    implementation(kotlin("stdlib-jdk8"))

    implementation("io.ktor:ktor-client-websockets:$ktorVersion")
    implementation("io.ktor:ktor-client-okhttp:$ktorVersion")

    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.3.0-RC2")


    testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
}

tasks.withType<Test> {
    useJUnitPlatform()
    testLogging {
        showExceptions = true
        showStackTraces = true
        exceptionFormat = TestExceptionFormat.FULL
        events = setOf(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
    }
}

Upvotes: 3

Views: 3158

Answers (1)

Kimble
Kimble

Reputation: 7564

Seems like I have figured it out (obviously just after ripping my hair out long enough to make this post in the first place). When I wrote the post I leaked two coroutines and one of them "solved itself" (I'm not very happy about that, but what ever I do I can't reproduce it).

The second coroutine leaked because Nonce.kt from Ktor explicitly launches a coroutine in GlobalScope.

https://github.com/ktorio/ktor/blob/master/ktor-utils/jvm/src/io/ktor/util/Nonce.kt#L30

private val nonceGeneratorJob =
GlobalScope.launch(
    context = Dispatchers.IO + NonCancellable + NonceGeneratorCoroutineName,
    start = CoroutineStart.LAZY
) { ....

Upvotes: 1

Related Questions