From 5f9709fa83bcf3d63f71676b1e104f826b8bb001 Mon Sep 17 00:00:00 2001 From: "Aleksei.Tirman" Date: Fri, 22 May 2026 15:19:51 +0300 Subject: [PATCH 1/5] KTOR-9606 Fix KotlinxSerializationConverter deserialization of a closed with a delay empty channel --- .../jvm/test/ConverterTest.kt | 33 ++++++++++++++++ .../jvm/test/ConverterTest.kt | 39 +++++++++++++++++++ .../jvm/test/ConverterTest.kt | 39 +++++++++++++++++++ .../build.gradle.kts | 4 ++ .../kotlinx/KotlinxSerializationConverter.kt | 10 ++--- .../serialization/kotlinx/ConverterTest.kt | 35 +++++++++++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 ktor-shared/ktor-serialization/ktor-serialization-gson/jvm/test/ConverterTest.kt create mode 100644 ktor-shared/ktor-serialization/ktor-serialization-jackson/jvm/test/ConverterTest.kt create mode 100644 ktor-shared/ktor-serialization/ktor-serialization-jackson3/jvm/test/ConverterTest.kt create mode 100644 ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt diff --git a/ktor-shared/ktor-serialization/ktor-serialization-gson/jvm/test/ConverterTest.kt b/ktor-shared/ktor-serialization/ktor-serialization-gson/jvm/test/ConverterTest.kt new file mode 100644 index 00000000000..90e3bc38b2b --- /dev/null +++ b/ktor-shared/ktor-serialization/ktor-serialization-gson/jvm/test/ConverterTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +import io.ktor.serialization.gson.GsonConverter +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.charsets.Charsets +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +data class Payload(val value: String) + +class ConverterTest { + @Test + fun returnsNullForEmptyChannelWithDelayedClose() = runTest { + val channel = ByteChannel() + launch { + delay(200.milliseconds) + channel.close() + } + + val converter = GsonConverter() + val result = converter.deserialize(Charsets.UTF_8, typeInfo(), channel) + assertNull(result) + } +} diff --git a/ktor-shared/ktor-serialization/ktor-serialization-jackson/jvm/test/ConverterTest.kt b/ktor-shared/ktor-serialization/ktor-serialization-jackson/jvm/test/ConverterTest.kt new file mode 100644 index 00000000000..7d7ca0e080d --- /dev/null +++ b/ktor-shared/ktor-serialization/ktor-serialization-jackson/jvm/test/ConverterTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import io.ktor.serialization.JsonConvertException +import io.ktor.serialization.jackson.JacksonConverter +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.charsets.Charsets +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +data class Payload(val value: String) + +class ConverterTest { + @Test + fun throwsExceptionForEmptyChannel() = runTest { + val channel = ByteChannel() + launch { + delay(200.milliseconds) + channel.close() + } + + val converter = JacksonConverter() + val cause = assertFailsWith { + converter.deserialize(Charsets.UTF_8, typeInfo(), channel) + } + + assertIs(cause.cause) + } +} diff --git a/ktor-shared/ktor-serialization/ktor-serialization-jackson3/jvm/test/ConverterTest.kt b/ktor-shared/ktor-serialization/ktor-serialization-jackson3/jvm/test/ConverterTest.kt new file mode 100644 index 00000000000..38862ddea98 --- /dev/null +++ b/ktor-shared/ktor-serialization/ktor-serialization-jackson3/jvm/test/ConverterTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +import io.ktor.serialization.JsonConvertException +import io.ktor.serialization.jackson3.JacksonConverter +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.charsets.Charsets +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import tools.jackson.databind.exc.MismatchedInputException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +data class Payload(val value: String) + +class ConverterTest { + @Test + fun throwsExceptionForEmptyChannel() = runTest { + val channel = ByteChannel() + launch { + delay(200.milliseconds) + channel.close() + } + + val converter = JacksonConverter() + val cause = assertFailsWith { + converter.deserialize(Charsets.UTF_8, typeInfo(), channel) + } + + assertIs(cause.cause) + } +} diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/build.gradle.kts b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/build.gradle.kts index c320ee21e2a..8e4b343c2d7 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/build.gradle.kts +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/build.gradle.kts @@ -15,5 +15,9 @@ kotlin { api(projects.ktorSerialization) api(libs.kotlinx.serialization.core) } + commonTest.dependencies { + implementation(projects.ktorSerializationKotlinxJson) + implementation(libs.kotlinx.coroutines.test) + } } } diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt index 456ad59cc61..8ae0f271c89 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt @@ -7,8 +7,6 @@ package io.ktor.serialization.kotlinx import io.ktor.http.* import io.ktor.http.content.* import io.ktor.serialization.* -import io.ktor.util.* -import io.ktor.util.pipeline.* import io.ktor.util.reflect.* import io.ktor.utils.io.* import io.ktor.utils.io.charsets.* @@ -16,7 +14,6 @@ import io.ktor.utils.io.core.* import kotlinx.coroutines.flow.* import kotlinx.io.* import kotlinx.serialization.* -import kotlin.jvm.* /** * Creates a converter serializing with the specified string [format] @@ -58,13 +55,14 @@ public class KotlinxSerializationConverter( } override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { + val contentPacket = content.readRemaining() + val fromExtension = extensions.asFlow() .map { it.deserialize(charset, typeInfo, content) } - .firstOrNull { it != null || content.isClosedForRead } - if (extensions.isNotEmpty() && (fromExtension != null || content.isClosedForRead)) return fromExtension + .firstOrNull { it != null || contentPacket.exhausted() } + if (extensions.isNotEmpty() && (fromExtension != null || contentPacket.exhausted())) return fromExtension val serializer = format.serializersModule.serializerForTypeInfo(typeInfo) - val contentPacket = content.readRemaining() try { return when (format) { diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt new file mode 100644 index 00000000000..30102fdcf73 --- /dev/null +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.serialization.kotlinx + +import io.ktor.serialization.kotlinx.json.DefaultJson +import io.ktor.util.reflect.typeInfo +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.charsets.Charsets +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds + +@Serializable +data class Payload(val value: String) + +class ConverterTest { + @Test + fun returnsNullForEmptyChannelWithDelayedClose() = runTest { + val channel = ByteChannel() + launch { + delay(200.milliseconds) + channel.close() + } + + val converter = KotlinxSerializationConverter(DefaultJson) + val result = converter.deserialize(Charsets.UTF_8, typeInfo(), channel) + assertNull(result) + } +} From 9ccfba97130deedec4b671df285f0a3682cfaa1f Mon Sep 17 00:00:00 2001 From: "Aleksei.Tirman" Date: Mon, 25 May 2026 09:41:08 +0300 Subject: [PATCH 2/5] KTOR-9606 Fix testSequence test --- .../ktor/serialization/kotlinx/KotlinxSerializationConverter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt index 8ae0f271c89..508236cc342 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt @@ -58,7 +58,7 @@ public class KotlinxSerializationConverter( val contentPacket = content.readRemaining() val fromExtension = extensions.asFlow() - .map { it.deserialize(charset, typeInfo, content) } + .map { it.deserialize(charset, typeInfo, ByteReadChannel(contentPacket)) } .firstOrNull { it != null || contentPacket.exhausted() } if (extensions.isNotEmpty() && (fromExtension != null || contentPacket.exhausted())) return fromExtension From 70a1c98daaba60dd027180678d97a783ec5e1cb0 Mon Sep 17 00:00:00 2001 From: "Aleksei.Tirman" Date: Wed, 27 May 2026 14:22:22 +0300 Subject: [PATCH 3/5] KTOR-9606 Replace flow with a loop for extensions handling --- .../kotlinx/KotlinxSerializationConverter.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt index 508236cc342..70621bdf477 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt @@ -57,10 +57,10 @@ public class KotlinxSerializationConverter( override suspend fun deserialize(charset: Charset, typeInfo: TypeInfo, content: ByteReadChannel): Any? { val contentPacket = content.readRemaining() - val fromExtension = extensions.asFlow() - .map { it.deserialize(charset, typeInfo, ByteReadChannel(contentPacket)) } - .firstOrNull { it != null || contentPacket.exhausted() } - if (extensions.isNotEmpty() && (fromExtension != null || contentPacket.exhausted())) return fromExtension + for (ext in extensions) { + if (contentPacket.exhausted()) return null + return ext.deserialize(charset, typeInfo, ByteReadChannel(contentPacket)) ?: continue + } val serializer = format.serializersModule.serializerForTypeInfo(typeInfo) From d969d191a761e39aa7754b5d4418cf0ebcf0c2e6 Mon Sep 17 00:00:00 2001 From: "Aleksei.Tirman" Date: Thu, 28 May 2026 10:50:48 +0300 Subject: [PATCH 4/5] KTOR-9606 Add test and null check --- .../kotlinx/KotlinxSerializationConverter.kt | 2 ++ .../serialization/kotlinx/ConverterTest.kt | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt index 70621bdf477..363062877aa 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt @@ -62,6 +62,8 @@ public class KotlinxSerializationConverter( return ext.deserialize(charset, typeInfo, ByteReadChannel(contentPacket)) ?: continue } + if (contentPacket.exhausted()) return null + val serializer = format.serializersModule.serializerForTypeInfo(typeInfo) try { diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt index 30102fdcf73..3e1b36585e4 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt @@ -11,15 +11,22 @@ import io.ktor.utils.io.charsets.Charsets import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.StringFormat +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule import kotlin.test.Test import kotlin.test.assertNull +import kotlin.test.fail import kotlin.time.Duration.Companion.milliseconds @Serializable data class Payload(val value: String) class ConverterTest { + @Test fun returnsNullForEmptyChannelWithDelayedClose() = runTest { val channel = ByteChannel() @@ -32,4 +39,28 @@ class ConverterTest { val result = converter.deserialize(Charsets.UTF_8, typeInfo(), channel) assertNull(result) } + + @Test + fun `returns null for empty channel without extensions`() = runTest { + val channel = ByteChannel() + launch { + delay(200.milliseconds) + channel.close() + } + + val converter = KotlinxSerializationConverter(EmptyExtensionStringFormat) + val result = converter.deserialize(Charsets.UTF_8, typeInfo(), channel) + + assertNull(result) + } + + private object EmptyExtensionStringFormat : StringFormat { + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun encodeToString(serializer: SerializationStrategy, value: T): String = + error("Not used") + + override fun decodeFromString(deserializer: DeserializationStrategy, string: String): T = + fail("Empty content should be handled before decoding, got: '$string'") + } } From 7240236523df31828845ac1d50c908b26cbefcd9 Mon Sep 17 00:00:00 2001 From: "Aleksei.Tirman" Date: Fri, 29 May 2026 09:40:52 +0300 Subject: [PATCH 5/5] Revert "KTOR-9606 Add test and null check" This reverts commit 85ddd9e447c0899ae8fd1f4cb11539b9846ac866. --- .../kotlinx/KotlinxSerializationConverter.kt | 2 -- .../serialization/kotlinx/ConverterTest.kt | 31 ------------------- 2 files changed, 33 deletions(-) diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt index 363062877aa..70621bdf477 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/src/io/ktor/serialization/kotlinx/KotlinxSerializationConverter.kt @@ -62,8 +62,6 @@ public class KotlinxSerializationConverter( return ext.deserialize(charset, typeInfo, ByteReadChannel(contentPacket)) ?: continue } - if (contentPacket.exhausted()) return null - val serializer = format.serializersModule.serializerForTypeInfo(typeInfo) try { diff --git a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt index 3e1b36585e4..30102fdcf73 100644 --- a/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt +++ b/ktor-shared/ktor-serialization/ktor-serialization-kotlinx/common/test/io/ktor/serialization/kotlinx/ConverterTest.kt @@ -11,22 +11,15 @@ import io.ktor.utils.io.charsets.Charsets import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest -import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationStrategy -import kotlinx.serialization.StringFormat -import kotlinx.serialization.modules.EmptySerializersModule -import kotlinx.serialization.modules.SerializersModule import kotlin.test.Test import kotlin.test.assertNull -import kotlin.test.fail import kotlin.time.Duration.Companion.milliseconds @Serializable data class Payload(val value: String) class ConverterTest { - @Test fun returnsNullForEmptyChannelWithDelayedClose() = runTest { val channel = ByteChannel() @@ -39,28 +32,4 @@ class ConverterTest { val result = converter.deserialize(Charsets.UTF_8, typeInfo(), channel) assertNull(result) } - - @Test - fun `returns null for empty channel without extensions`() = runTest { - val channel = ByteChannel() - launch { - delay(200.milliseconds) - channel.close() - } - - val converter = KotlinxSerializationConverter(EmptyExtensionStringFormat) - val result = converter.deserialize(Charsets.UTF_8, typeInfo(), channel) - - assertNull(result) - } - - private object EmptyExtensionStringFormat : StringFormat { - override val serializersModule: SerializersModule = EmptySerializersModule() - - override fun encodeToString(serializer: SerializationStrategy, value: T): String = - error("Not used") - - override fun decodeFromString(deserializer: DeserializationStrategy, string: String): T = - fail("Empty content should be handled before decoding, got: '$string'") - } }