Skip to content

Commit 6a11a76

Browse files
authored
KTOR-8989 Close or cancel engine only when the client reference count… (#5525)
1 parent 3acb8ea commit 6a11a76

4 files changed

Lines changed: 122 additions & 20 deletions

File tree

ktor-client/ktor-client-core/api/ktor-client-core.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ public final class io/ktor/client/engine/HttpClientEngine$DefaultImpls {
165165
}
166166

167167
public abstract class io/ktor/client/engine/HttpClientEngineBase : io/ktor/client/engine/HttpClientEngine {
168+
public static final synthetic field clientRefCount$FU$internal Ljava/util/concurrent/atomic/AtomicIntegerFieldUpdater;
169+
public synthetic field clientRefCount$internal I
168170
public fun <init> (Ljava/lang/String;)V
169171
public fun close ()V
170172
public fun getCoroutineContext ()Lkotlin/coroutines/CoroutineContext;

ktor-client/ktor-client-core/common/src/io/ktor/client/HttpClient.kt

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import io.ktor.util.*
1515
import io.ktor.utils.io.*
1616
import io.ktor.utils.io.core.*
1717
import kotlinx.atomicfu.atomic
18+
import kotlinx.coroutines.CancellationException
1819
import kotlinx.coroutines.CompletableJob
1920
import kotlinx.coroutines.CoroutineScope
2021
import kotlinx.coroutines.Job
@@ -647,15 +648,7 @@ public fun <T : HttpClientEngineConfig> HttpClient(
647648
): HttpClient {
648649
val config: HttpClientConfig<T> = HttpClientConfig<T>().apply(block)
649650
val engine = engineFactory.create(config.engineConfig)
650-
val client = HttpClient(engine, config, manageEngine = true)
651-
652-
// If the engine was created using factory Ktor is responsible for its lifecycle management. Otherwise user has to
653-
// close engine by themself.
654-
client.coroutineContext[Job]!!.invokeOnCompletion {
655-
engine.close()
656-
}
657-
658-
return client
651+
return HttpClient(engine, config, manageEngine = true)
659652
}
660653

661654
/**
@@ -1292,6 +1285,26 @@ public class HttpClient(
12921285
manageEngine: Boolean
12931286
) : this(engine, userConfig) {
12941287
this.manageEngine = manageEngine
1288+
1289+
if (this.manageEngine) {
1290+
if (engine is HttpClientEngineBase) {
1291+
engine.clientRefCount.incrementAndGet()
1292+
}
1293+
1294+
coroutineContext[Job]!!.invokeOnCompletion { cause ->
1295+
val shouldClose = engine !is HttpClientEngineBase || engine.clientRefCount.decrementAndGet() <= 0
1296+
1297+
if (shouldClose) {
1298+
if (cause == null) {
1299+
engine.close()
1300+
} else {
1301+
engine.cancel(
1302+
cause as? CancellationException ?: CancellationException("Client scope is canceled", cause)
1303+
)
1304+
}
1305+
}
1306+
}
1307+
}
12951308
}
12961309

12971310
private val closed = atomic(false)
@@ -1352,14 +1365,6 @@ public class HttpClient(
13521365
internal val config = HttpClientConfig<HttpClientEngineConfig>()
13531366

13541367
init {
1355-
if (manageEngine) {
1356-
clientJob.invokeOnCompletion {
1357-
if (it != null) {
1358-
engine.cancel()
1359-
}
1360-
}
1361-
}
1362-
13631368
engine.install(this)
13641369

13651370
sendPipeline.intercept(HttpSendPipeline.Receive) { call ->
@@ -1483,9 +1488,6 @@ public class HttpClient(
14831488
}
14841489

14851490
clientJob.complete()
1486-
if (manageEngine) {
1487-
engine.close()
1488-
}
14891491
}
14901492

14911493
override fun toString(): String = "HttpClient[$engine]"

ktor-client/ktor-client-core/common/src/io/ktor/client/engine/HttpClientEngineBase.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ import kotlin.coroutines.*
4040
public abstract class HttpClientEngineBase(private val engineName: String) : HttpClientEngine {
4141
private val closed = atomic(false)
4242

43+
/** Number of [io.ktor.client.HttpClient] instances sharing this engine */
44+
internal val clientRefCount: AtomicInt = atomic(0)
45+
4346
override val dispatcher: CoroutineDispatcher by lazy { config.dispatcher ?: ioDispatcher() }
4447

4548
override val coroutineContext: CoroutineContext by lazy {

ktor-client/ktor-client-core/common/test/HttpClientConfigTest.kt

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
*/
44

55
import io.ktor.client.*
6+
import io.ktor.client.engine.*
67
import io.ktor.client.plugins.api.*
8+
import io.ktor.client.request.*
9+
import io.ktor.http.*
10+
import io.ktor.util.date.*
11+
import io.ktor.utils.io.*
12+
import kotlinx.coroutines.Job
13+
import kotlinx.coroutines.test.runTest
14+
import kotlin.coroutines.EmptyCoroutineContext
715
import kotlin.test.Test
816
import kotlin.test.assertEquals
17+
import kotlin.test.assertFalse
18+
import kotlin.test.assertSame
19+
import kotlin.test.assertTrue
920

1021
class HttpClientConfigTest {
1122

@@ -41,4 +52,88 @@ class HttpClientConfigTest {
4152
assertEquals(1, first)
4253
assertEquals(1, second)
4354
}
55+
56+
@Test
57+
fun closingChildDoesNotCloseEngine() = runTest {
58+
var engineClosed = false
59+
val client = HttpClient(createTestEngineFactory { engineClosed = true })
60+
val child = client.config {}
61+
62+
assertSame(client.engine, child.engine)
63+
64+
child.close()
65+
66+
assertFalse(engineClosed)
67+
client.close()
68+
assertTrue(engineClosed)
69+
}
70+
71+
@Test
72+
fun closingParentDoesNotCloseEngineWhileChildIsOpen() = runTest {
73+
var engineClosed = false
74+
val client = HttpClient(createTestEngineFactory { engineClosed = true })
75+
val child = client.config {}
76+
77+
assertSame(client.engine, child.engine)
78+
79+
client.close()
80+
81+
assertFalse(engineClosed)
82+
child.close()
83+
assertTrue(engineClosed)
84+
}
85+
86+
@Test
87+
fun cancellingChildDoesNotCancelEngine() = runTest {
88+
val client = HttpClient(createTestEngineFactory {})
89+
val child = client.config {}
90+
91+
assertSame(client.engine, child.engine)
92+
93+
child.coroutineContext[Job]!!.cancel()
94+
95+
assertFalse(client.engine.coroutineContext[Job]!!.isCancelled)
96+
client.coroutineContext[Job]!!.cancel()
97+
assertTrue(client.engine.coroutineContext[Job]!!.isCancelled)
98+
}
99+
100+
@Test
101+
fun cancellingParentDoesNotCancelEngineWhileChildIsOpen() = runTest {
102+
val client = HttpClient(createTestEngineFactory {})
103+
val child = client.config {}
104+
105+
assertSame(client.engine, child.engine)
106+
107+
client.coroutineContext[Job]!!.cancel()
108+
109+
assertFalse(child.engine.coroutineContext[Job]!!.isCancelled)
110+
child.coroutineContext[Job]!!.cancel()
111+
assertTrue(child.engine.coroutineContext[Job]!!.isCancelled)
112+
}
113+
114+
private fun createTestEngineFactory(onClose: () -> Unit): HttpClientEngineFactory<HttpClientEngineConfig> {
115+
val engine = object : HttpClientEngineBase("test-engine") {
116+
override val config: HttpClientEngineConfig = HttpClientEngineConfig()
117+
118+
@InternalAPI
119+
override suspend fun execute(data: HttpRequestData): HttpResponseData {
120+
return HttpResponseData(
121+
HttpStatusCode.OK,
122+
GMTDate.START,
123+
Headers.Empty,
124+
HttpProtocolVersion.HTTP_1_1,
125+
Unit,
126+
EmptyCoroutineContext
127+
)
128+
}
129+
130+
override fun close() {
131+
super.close()
132+
onClose()
133+
}
134+
}
135+
return object : HttpClientEngineFactory<HttpClientEngineConfig> {
136+
override fun create(block: HttpClientEngineConfig.() -> Unit) = engine
137+
}
138+
}
44139
}

0 commit comments

Comments
 (0)