From 991b4a46200e793a720b3e0181fe0482178f25d5 Mon Sep 17 00:00:00 2001 From: zibet27 Date: Wed, 11 Mar 2026 11:12:52 +0100 Subject: [PATCH 1/6] KTOR-9398 Client Core. Cancel streaming when exception is thrown in 'execute/body' --- .../ktor-client-core/api/ktor-client-core.api | 3 +- .../api/ktor-client-core.klib.api | 2 +- .../io/ktor/client/statement/HttpStatement.kt | 24 +++++++++++++--- .../io/ktor/client/tests/HttpClientTest.kt | 28 +++++++++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/ktor-client/ktor-client-core/api/ktor-client-core.api b/ktor-client/ktor-client-core/api/ktor-client-core.api index 1eb46bcd22d..7e423226ab5 100644 --- a/ktor-client/ktor-client-core/api/ktor-client-core.api +++ b/ktor-client/ktor-client-core/api/ktor-client-core.api @@ -1622,7 +1622,8 @@ public final class io/ktor/client/statement/HttpResponsePipeline$Phases { public final class io/ktor/client/statement/HttpStatement { public fun (Lio/ktor/client/request/HttpRequestBuilder;Lio/ktor/client/HttpClient;)V - public final fun cleanup (Lio/ktor/client/statement/HttpResponse;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun cleanup (Lio/ktor/client/statement/HttpResponse;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun cleanup$default (Lio/ktor/client/statement/HttpStatement;Lio/ktor/client/statement/HttpResponse;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun execute (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun fetchResponse (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ktor-client/ktor-client-core/api/ktor-client-core.klib.api b/ktor-client/ktor-client-core/api/ktor-client-core.klib.api index 98cee781fb5..d2d5d3f23cc 100644 --- a/ktor-client/ktor-client-core/api/ktor-client-core.klib.api +++ b/ktor-client/ktor-client-core/api/ktor-client-core.klib.api @@ -1174,7 +1174,7 @@ final class io.ktor.client.statement/HttpStatement { // io.ktor.client.statement final fun (): io.ktor.client/HttpClient // io.ktor.client.statement/HttpStatement.client.|(){}[0] final fun toString(): kotlin/String // io.ktor.client.statement/HttpStatement.toString|toString(){}[0] - final suspend fun (io.ktor.client.statement/HttpResponse).cleanup() // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(){}[0] + final suspend fun (io.ktor.client.statement/HttpResponse).cleanup(kotlin/Throwable? = ...) // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(kotlin.Throwable?){}[0] final suspend fun <#A1: kotlin/Any?> execute(kotlin.coroutines/SuspendFunction1): #A1 // io.ktor.client.statement/HttpStatement.execute|execute(kotlin.coroutines.SuspendFunction1){0§}[0] final suspend fun execute(): io.ktor.client.statement/HttpResponse // io.ktor.client.statement/HttpStatement.execute|execute(){}[0] final suspend fun fetchResponse(): io.ktor.client.statement/HttpResponse // io.ktor.client.statement/HttpStatement.fetchResponse|fetchResponse(){}[0] diff --git a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt index 47d44bfdb58..9eaede80d75 100644 --- a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt +++ b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt @@ -9,6 +9,7 @@ import io.ktor.client.call.* import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.utils.io.* +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.job import kotlinx.coroutines.withContext @@ -66,6 +67,7 @@ public class HttpStatement( public suspend fun execute(block: suspend (response: HttpResponse) -> T): T = unwrapRequestTimeoutException { val response = fetchStreamingResponse() + var callFailure: Throwable? = null try { return if (useEngineDispatcher) { withContext(response.coroutineContext[ContinuationInterceptor]!!) { @@ -74,8 +76,11 @@ public class HttpStatement( } else { block(response) } + } catch (cause: Throwable) { + callFailure = cause + throw cause } finally { - response.cleanup() + response.cleanup(callFailure) } } @@ -159,6 +164,7 @@ public class HttpStatement( crossinline block: suspend (response: T) -> R ): R = unwrapRequestTimeoutException { val response: HttpResponse = fetchStreamingResponse() + var callFailure: Throwable? = null try { return if (useEngineDispatcher) { withContext(response.coroutineContext[ContinuationInterceptor]!!) { @@ -169,8 +175,11 @@ public class HttpStatement( val result = response.body() block(result) } + } catch (cause: Throwable) { + callFailure = cause + throw cause } finally { - response.cleanup() + response.cleanup(callFailure) } } @@ -206,14 +215,21 @@ public class HttpStatement( /** * Completes [HttpResponse] and releases resources. + * + * @param cause If not null, cancels the response job with this cause to immediately interrupt + * any pending network operations. */ @PublishedApi @OptIn(InternalAPI::class) - internal suspend fun HttpResponse.cleanup() { + internal suspend fun HttpResponse.cleanup(cause: Throwable? = null) { val job = coroutineContext.job as CompletableJob job.apply { - complete() + if (cause != null) { + cancel(CancellationException("Request failed", cause)) + } else { + complete() + } // If the response is saved, the underlying channel is already closed and // calling `rawContent` would create a new one if (!isSaved) { diff --git a/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt b/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt index eee5a499414..e8ee7d2f78f 100644 --- a/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt +++ b/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * 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.client.tests @@ -22,6 +22,8 @@ import io.ktor.utils.io.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.assertThrows import java.util.concurrent.ArrayBlockingQueue import kotlin.coroutines.Continuation import kotlin.coroutines.CoroutineContext @@ -205,5 +207,27 @@ abstract class HttpClientTest(private val factory: HttpClientEngineFactory<*>) : } } - private class SendException : RuntimeException("Error on write") + @Test + fun testStreamingResponseExceptionImmediatelyCancels() = runBlocking { + val client = HttpClient(factory) { + install(HttpTimeout) { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + } + } + + val exception = assertThrows { + withTimeout(2000) { + client.prepareGet("http://localhost:$serverPort/sse/delay/60000").execute { response -> + // Headers are received immediately + assertEquals(HttpStatusCode.OK, response.status) + + // Throw an exception while waiting for the body + throw IllegalStateException("Test exception from execute block") + } + } + } + assertEquals("Test exception from execute block", exception.message) + } } From 804a2e3a0268d127c44da4c04eb8d813986e26d8 Mon Sep 17 00:00:00 2001 From: zibet27 Date: Thu, 12 Mar 2026 14:05:08 +0100 Subject: [PATCH 2/6] fix TestEngineWebsocketSession --- .../ktor-client-core/api/ktor-client-core.api | 1 + .../api/ktor-client-core.klib.api | 1 + .../io/ktor/client/statement/HttpStatement.kt | 7 +++- .../io/ktor/client/tests/HttpStatementTest.kt | 34 +++++++++++++++++++ .../io/ktor/client/tests/HttpClientTest.kt | 28 ++------------- .../client/TestEngineWebsocketSession.kt | 18 +++++----- 6 files changed, 53 insertions(+), 36 deletions(-) diff --git a/ktor-client/ktor-client-core/api/ktor-client-core.api b/ktor-client/ktor-client-core/api/ktor-client-core.api index 7e423226ab5..6f5f62581e8 100644 --- a/ktor-client/ktor-client-core/api/ktor-client-core.api +++ b/ktor-client/ktor-client-core/api/ktor-client-core.api @@ -1623,6 +1623,7 @@ public final class io/ktor/client/statement/HttpResponsePipeline$Phases { public final class io/ktor/client/statement/HttpStatement { public fun (Lio/ktor/client/request/HttpRequestBuilder;Lio/ktor/client/HttpClient;)V public final fun cleanup (Lio/ktor/client/statement/HttpResponse;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final synthetic fun cleanup (Lio/ktor/client/statement/HttpResponse;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun cleanup$default (Lio/ktor/client/statement/HttpStatement;Lio/ktor/client/statement/HttpResponse;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun execute (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ktor-client/ktor-client-core/api/ktor-client-core.klib.api b/ktor-client/ktor-client-core/api/ktor-client-core.klib.api index d2d5d3f23cc..44d835749b5 100644 --- a/ktor-client/ktor-client-core/api/ktor-client-core.klib.api +++ b/ktor-client/ktor-client-core/api/ktor-client-core.klib.api @@ -1174,6 +1174,7 @@ final class io.ktor.client.statement/HttpStatement { // io.ktor.client.statement final fun (): io.ktor.client/HttpClient // io.ktor.client.statement/HttpStatement.client.|(){}[0] final fun toString(): kotlin/String // io.ktor.client.statement/HttpStatement.toString|toString(){}[0] + final suspend fun (io.ktor.client.statement/HttpResponse).cleanup() // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(){}[0] final suspend fun (io.ktor.client.statement/HttpResponse).cleanup(kotlin/Throwable? = ...) // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(kotlin.Throwable?){}[0] final suspend fun <#A1: kotlin/Any?> execute(kotlin.coroutines/SuspendFunction1): #A1 // io.ktor.client.statement/HttpStatement.execute|execute(kotlin.coroutines.SuspendFunction1){0§}[0] final suspend fun execute(): io.ktor.client.statement/HttpResponse // io.ktor.client.statement/HttpStatement.execute|execute(){}[0] diff --git a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt index 9eaede80d75..da3f9d1ea23 100644 --- a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt +++ b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt @@ -213,6 +213,11 @@ public class HttpStatement( return result } + @PublishedApi + @OptIn(InternalAPI::class) + @Deprecated("Use cleanup(cause) instead", level = DeprecationLevel.HIDDEN) + internal suspend fun HttpResponse.cleanup(): Unit = cleanup(cause = null) + /** * Completes [HttpResponse] and releases resources. * @@ -226,7 +231,7 @@ public class HttpStatement( job.apply { if (cause != null) { - cancel(CancellationException("Request failed", cause)) + cancel(CancellationException("Exception occurred during request execution", cause)) } else { complete() } diff --git a/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt b/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt index 44e10b5448d..e9824f074ec 100644 --- a/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt +++ b/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt @@ -10,9 +10,11 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.client.test.base.* import io.ktor.client.tests.utils.* +import io.ktor.utils.io.* import io.ktor.utils.io.core.* import kotlinx.coroutines.Job import kotlinx.coroutines.job +import kotlinx.coroutines.withTimeout import kotlinx.io.readByteArray import kotlin.test.* @@ -71,4 +73,36 @@ class HttpStatementTest : ClientLoader() { } } } + + @Test + fun testStreamingResponseExceptionCancelsImmediately() = clientTests { + test { client -> + val exception = assertFailsWith { + withTimeout(2000) { + client.prepareGet("$TEST_SERVER/content/stream?delay=60000").execute { response -> + // Headers are received, throw exception while waiting for body + throw IllegalStateException("Test exception from execute block") + } + } + } + assertEquals("Test exception from execute block", exception.message) + } + } + + @Test + fun testStreamingResponseExceptionInBodyCancelsImmediately() = clientTests { + test { client -> + val exception = assertFailsWith { + withTimeout(2000) { + client.prepareGet( + "$TEST_SERVER/content/stream?delay=60000" + ).body { channel -> + // Throw exception while channel is open + throw IllegalStateException("Test exception from body block") + } + } + } + assertEquals("Test exception from body block", exception.message) + } + } } diff --git a/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt b/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt index e8ee7d2f78f..eee5a499414 100644 --- a/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt +++ b/ktor-client/ktor-client-tests/jvm/src/io/ktor/client/tests/HttpClientTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ package io.ktor.client.tests @@ -22,8 +22,6 @@ import io.ktor.utils.io.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import org.junit.jupiter.api.assertThrows import java.util.concurrent.ArrayBlockingQueue import kotlin.coroutines.Continuation import kotlin.coroutines.CoroutineContext @@ -207,27 +205,5 @@ abstract class HttpClientTest(private val factory: HttpClientEngineFactory<*>) : } } - @Test - fun testStreamingResponseExceptionImmediatelyCancels() = runBlocking { - val client = HttpClient(factory) { - install(HttpTimeout) { - requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS - socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS - connectTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS - } - } - - val exception = assertThrows { - withTimeout(2000) { - client.prepareGet("http://localhost:$serverPort/sse/delay/60000").execute { response -> - // Headers are received immediately - assertEquals(HttpStatusCode.OK, response.status) - - // Throw an exception while waiting for the body - throw IllegalStateException("Test exception from execute block") - } - } - } - assertEquals("Test exception from execute block", exception.message) - } + private class SendException : RuntimeException("Error on write") } diff --git a/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt b/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt index 29ef02c0d07..6b6a467c934 100644 --- a/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt +++ b/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * 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.server.testing.client @@ -14,8 +14,7 @@ internal class TestEngineWebsocketSession( override val incoming: ReceiveChannel, override val outgoing: SendChannel ) : WebSocketSession { - private val socketJob = Job(callContext[Job]) - override val coroutineContext: CoroutineContext = callContext + socketJob + CoroutineName("test-ws") + override val coroutineContext: CoroutineContext = callContext + CoroutineName("test-ws") override var masking: Boolean get() = true @@ -30,14 +29,15 @@ internal class TestEngineWebsocketSession( override suspend fun flush() {} suspend fun run() { - outgoing.invokeOnClose { - if (it != null) { - socketJob.completeExceptionally(it) - } else { - socketJob.complete() + suspendCancellableCoroutine { cont -> + outgoing.invokeOnClose { ex -> + if (ex == null) { + cont.resume(Unit) + } else { + cont.resumeWithException(ex) + } } } - socketJob.join() } @Deprecated( From 3b1837e50b18fda432e29fa9a5252d29a281548f Mon Sep 17 00:00:00 2001 From: zibet27 Date: Fri, 13 Mar 2026 11:35:35 +0100 Subject: [PATCH 3/6] fix TestEngineWebsocketSession.run --- .../server/testing/client/TestEngineWebsocketSession.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt b/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt index 6b6a467c934..a4a6aa981a6 100644 --- a/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt +++ b/ktor-server/ktor-server-test-host/jvm/src/io/ktor/server/testing/client/TestEngineWebsocketSession.kt @@ -31,10 +31,10 @@ internal class TestEngineWebsocketSession( suspend fun run() { suspendCancellableCoroutine { cont -> outgoing.invokeOnClose { ex -> - if (ex == null) { - cont.resume(Unit) - } else { - cont.resumeWithException(ex) + when { + !cont.isActive -> return@invokeOnClose + ex == null -> cont.resume(Unit) + else -> cont.resumeWithException(ex) } } } From 2c0506a1d7162d25260342136a34ab8f47ed5b66 Mon Sep 17 00:00:00 2001 From: zibet27 Date: Fri, 13 Mar 2026 14:55:34 +0100 Subject: [PATCH 4/6] remove default cause for HttpResponse.cleanup --- ktor-client/ktor-client-core/api/ktor-client-core.api | 1 - ktor-client/ktor-client-core/api/ktor-client-core.klib.api | 2 +- .../common/src/io/ktor/client/statement/HttpStatement.kt | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ktor-client/ktor-client-core/api/ktor-client-core.api b/ktor-client/ktor-client-core/api/ktor-client-core.api index 6f5f62581e8..b0546386904 100644 --- a/ktor-client/ktor-client-core/api/ktor-client-core.api +++ b/ktor-client/ktor-client-core/api/ktor-client-core.api @@ -1624,7 +1624,6 @@ public final class io/ktor/client/statement/HttpStatement { public fun (Lio/ktor/client/request/HttpRequestBuilder;Lio/ktor/client/HttpClient;)V public final fun cleanup (Lio/ktor/client/statement/HttpResponse;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final synthetic fun cleanup (Lio/ktor/client/statement/HttpResponse;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun cleanup$default (Lio/ktor/client/statement/HttpStatement;Lio/ktor/client/statement/HttpResponse;Ljava/lang/Throwable;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun execute (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun fetchResponse (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/ktor-client/ktor-client-core/api/ktor-client-core.klib.api b/ktor-client/ktor-client-core/api/ktor-client-core.klib.api index 44d835749b5..feae493a2f8 100644 --- a/ktor-client/ktor-client-core/api/ktor-client-core.klib.api +++ b/ktor-client/ktor-client-core/api/ktor-client-core.klib.api @@ -1175,7 +1175,7 @@ final class io.ktor.client.statement/HttpStatement { // io.ktor.client.statement final fun toString(): kotlin/String // io.ktor.client.statement/HttpStatement.toString|toString(){}[0] final suspend fun (io.ktor.client.statement/HttpResponse).cleanup() // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(){}[0] - final suspend fun (io.ktor.client.statement/HttpResponse).cleanup(kotlin/Throwable? = ...) // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(kotlin.Throwable?){}[0] + final suspend fun (io.ktor.client.statement/HttpResponse).cleanup(kotlin/Throwable?) // io.ktor.client.statement/HttpStatement.cleanup|cleanup@io.ktor.client.statement.HttpResponse(kotlin.Throwable?){}[0] final suspend fun <#A1: kotlin/Any?> execute(kotlin.coroutines/SuspendFunction1): #A1 // io.ktor.client.statement/HttpStatement.execute|execute(kotlin.coroutines.SuspendFunction1){0§}[0] final suspend fun execute(): io.ktor.client.statement/HttpResponse // io.ktor.client.statement/HttpStatement.execute|execute(){}[0] final suspend fun fetchResponse(): io.ktor.client.statement/HttpResponse // io.ktor.client.statement/HttpStatement.fetchResponse|fetchResponse(){}[0] diff --git a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt index da3f9d1ea23..97058877888 100644 --- a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt +++ b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt @@ -208,7 +208,7 @@ public class HttpStatement( // Save the body again to make sure that it is replayable after pipeline execution // We need this because wrongly implemented plugins could make response body non-replayable val result = call.save().response - call.response.cleanup() + call.response.cleanup(cause = null) return result } @@ -226,7 +226,7 @@ public class HttpStatement( */ @PublishedApi @OptIn(InternalAPI::class) - internal suspend fun HttpResponse.cleanup(cause: Throwable? = null) { + internal suspend fun HttpResponse.cleanup(cause: Throwable?) { val job = coroutineContext.job as CompletableJob job.apply { From 4dd9571ba802026790ba3570304452ba0a77d36b Mon Sep 17 00:00:00 2001 From: zibet27 Date: Tue, 24 Mar 2026 18:00:12 +0100 Subject: [PATCH 5/6] skip tests on Darwin --- .../io/ktor/client/statement/HttpStatement.kt | 8 ++++---- .../io/ktor/client/tests/HttpStatementTest.kt | 16 +++++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt index 97058877888..c2c5137ef6c 100644 --- a/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt +++ b/ktor-client/ktor-client-core/common/src/io/ktor/client/statement/HttpStatement.kt @@ -230,10 +230,10 @@ public class HttpStatement( val job = coroutineContext.job as CompletableJob job.apply { - if (cause != null) { - cancel(CancellationException("Exception occurred during request execution", cause)) - } else { - complete() + when (cause) { + null -> complete() + is CancellationException -> cancel(cause) + else -> cancel(CancellationException("Exception occurred during request execution", cause)) } // If the response is saved, the underlying channel is already closed and // calling `rawContent` would create a new one diff --git a/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt b/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt index e9824f074ec..ba9d79fe6c1 100644 --- a/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt +++ b/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * 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.client.tests @@ -75,12 +75,12 @@ class HttpStatementTest : ClientLoader() { } @Test - fun testStreamingResponseExceptionCancelsImmediately() = clientTests { + fun testStreamingResponseExceptionCancelsImmediately() = clientTests(except("Darwin", "DarwinLegacy")) { test { client -> val exception = assertFailsWith { withTimeout(2000) { - client.prepareGet("$TEST_SERVER/content/stream?delay=60000").execute { response -> - // Headers are received, throw exception while waiting for body + client.prepareGet("$TEST_SERVER/content/stream?delay=60000").execute { + // Headers are received, throw exception while waiting for the body throw IllegalStateException("Test exception from execute block") } } @@ -90,14 +90,12 @@ class HttpStatementTest : ClientLoader() { } @Test - fun testStreamingResponseExceptionInBodyCancelsImmediately() = clientTests { + fun testStreamingResponseExceptionInBodyCancelsImmediately() = clientTests(except("Darwin", "DarwinLegacy")) { test { client -> val exception = assertFailsWith { withTimeout(2000) { - client.prepareGet( - "$TEST_SERVER/content/stream?delay=60000" - ).body { channel -> - // Throw exception while channel is open + client.prepareGet("$TEST_SERVER/content/stream?delay=60000").body { + // Throw exception while a channel is open throw IllegalStateException("Test exception from body block") } } From 1d446784edffd7682209d478785c0f76a497928c Mon Sep 17 00:00:00 2001 From: zibet27 Date: Fri, 27 Mar 2026 14:47:31 +0100 Subject: [PATCH 6/6] fix tests on Darwin --- .../common/test/io/ktor/client/tests/HttpStatementTest.kt | 7 +++++-- .../src/main/kotlin/test/server/tests/Content.kt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt b/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt index ba9d79fe6c1..88d60348a13 100644 --- a/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt +++ b/ktor-client/ktor-client-tests/common/test/io/ktor/client/tests/HttpStatementTest.kt @@ -74,8 +74,11 @@ class HttpStatementTest : ClientLoader() { } } + // Darwin/DarwinLegacy: NSURLSession buffers the first 512 bytes before calling didReceiveResponse/didReceiveData, + // so the test times out waiting for enough data to arrive unless the content type is octet/stream or application/json. + // See: https://developer.apple.com/forums/thread/64875 @Test - fun testStreamingResponseExceptionCancelsImmediately() = clientTests(except("Darwin", "DarwinLegacy")) { + fun testStreamingResponseExceptionCancelsImmediately() = clientTests { test { client -> val exception = assertFailsWith { withTimeout(2000) { @@ -90,7 +93,7 @@ class HttpStatementTest : ClientLoader() { } @Test - fun testStreamingResponseExceptionInBodyCancelsImmediately() = clientTests(except("Darwin", "DarwinLegacy")) { + fun testStreamingResponseExceptionInBodyCancelsImmediately() = clientTests { test { client -> val exception = assertFailsWith { withTimeout(2000) { diff --git a/ktor-test-server/src/main/kotlin/test/server/tests/Content.kt b/ktor-test-server/src/main/kotlin/test/server/tests/Content.kt index 944cfc92063..c9eb4e7230b 100644 --- a/ktor-test-server/src/main/kotlin/test/server/tests/Content.kt +++ b/ktor-test-server/src/main/kotlin/test/server/tests/Content.kt @@ -110,6 +110,9 @@ internal fun Application.contentTestServer() { val delay = call.parameters["delay"]?.toLong() ?: 0L call.respond( object : OutgoingContent.WriteChannelContent() { + override val contentType: ContentType + get() = ContentType.Application.OctetStream + override suspend fun writeTo(channel: ByteWriteChannel) { while (true) { channel.writeInt(42)