From 271cdd18e3a65170cd4e146d32b9b8ea9ea73140 Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 11 Aug 2026 14:45:37 +0400 Subject: [PATCH] Close owned WebSocket clients after reconnects --- .../kiloparsec/adapter/websocketClient.kt | 15 ++++++---- .../adapter/WebsocketClientResourceTest.kt | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 src/jvmTest/kotlin/net/sergeych/kiloparsec/adapter/WebsocketClientResourceTest.kt diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/adapter/websocketClient.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/adapter/websocketClient.kt index 788e3d6..6ca2fcb 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/adapter/websocketClient.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/adapter/websocketClient.kt @@ -53,16 +53,18 @@ fun websocketClient( /** * Create kilopaarsec transport over websocket (ws or wss). * @param path websocket path (must start with ws:// or wss:// and contain a path part) - * @client use default [HttpClient], it installs [WebSockets] plugin + * @param client optional caller-owned client. When omitted, a client with the + * [WebSockets] plugin is created for this device and closed with it. */ fun websocketTransportDevice( path: String, useTextFrames: Boolean = false, - client: HttpClient = HttpClient { - install(WebSockets) - }, + client: HttpClient? = null, ): Transport.Device { + val ownsClient = client == null + val actualClient = client ?: HttpClient { install(WebSockets) } + val log = LogTag("WSTD") var u = Url(path) log.debug { "Creating websocket transport device at $u" } @@ -82,7 +84,7 @@ fun websocketTransportDevice( globalLaunch { val log = LogTag("KC:${counter.incrementAndGet()}") try { - client.webSocket({ + actualClient.webSocket({ url.protocol = u.protocol url.host = u.host url.port = u.port @@ -148,6 +150,8 @@ fun websocketTransportDevice( else log.warning { "unexpected IO error $x" } runCatching { output.close() } runCatching { input.close() } + } finally { + if (ownsClient) actualClient.close() } log.info { "closing connection" } } @@ -162,4 +166,3 @@ fun websocketTransportDevice( }) return device } - diff --git a/src/jvmTest/kotlin/net/sergeych/kiloparsec/adapter/WebsocketClientResourceTest.kt b/src/jvmTest/kotlin/net/sergeych/kiloparsec/adapter/WebsocketClientResourceTest.kt new file mode 100644 index 0000000..a2fb359 --- /dev/null +++ b/src/jvmTest/kotlin/net/sergeych/kiloparsec/adapter/WebsocketClientResourceTest.kt @@ -0,0 +1,29 @@ +package net.sergeych.kiloparsec.adapter + +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue + +class WebsocketClientResourceTest { + @Test + fun failedReconnectsDoNotLeakFileDescriptors() = runBlocking { + val descriptors = Path.of("/proc/self/fd") + if (!Files.isDirectory(descriptors)) return@runBlocking + + val client = websocketClient("ws://127.0.0.1:1/kp") + try { + delay(2_500) + val before = descriptorCount(descriptors) + delay(7_000) + val after = descriptorCount(descriptors) + assertTrue(after <= before + 3, "file descriptors grew from $before to $after") + } finally { + client.close() + } + } + + private fun descriptorCount(path: Path): Long = Files.list(path).use { it.count() } +}