Skip to content

Commit 430f320

Browse files
authored
Follow-up: KTOR-9497 Preventing a fatal crash in DarwinSession on close (#5533)
* Throw CancellationException when session closed * Port session synchronization to Darwin legacy
1 parent 078f2c4 commit 430f320

4 files changed

Lines changed: 91 additions & 42 deletions

File tree

ktor-client/ktor-client-darwin-legacy/darwin/src/io/ktor/client/engine/darwin/internal/legacy/DarwinLegacySession.kt

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ import io.ktor.client.engine.darwin.*
88
import io.ktor.client.request.*
99
import io.ktor.utils.io.*
1010
import io.ktor.utils.io.core.*
11-
import kotlinx.atomicfu.*
12-
import kotlinx.cinterop.*
11+
import kotlinx.atomicfu.atomic
12+
import kotlinx.atomicfu.locks.SynchronizedObject
13+
import kotlinx.atomicfu.locks.synchronized
14+
import kotlinx.cinterop.UnsafeNumber
1315
import kotlinx.coroutines.job
14-
import platform.Foundation.*
15-
import kotlin.coroutines.*
16+
import platform.Foundation.NSOperationQueue
17+
import platform.Foundation.NSURLSession
18+
import platform.Foundation.NSURLSessionConfiguration
19+
import platform.Foundation.NSURLSessionTaskStateRunning
20+
import kotlin.coroutines.CoroutineContext
1621

1722
@OptIn(UnsafeNumber::class)
1823
@Suppress("DEPRECATION")
@@ -21,6 +26,7 @@ internal class DarwinLegacySession(
2126
requestQueue: NSOperationQueue?
2227
) : Closeable {
2328
private val closed = atomic(false)
29+
private val sessionLock = SynchronizedObject()
2430

2531
private val sessionAndDelegate = config.sessionAndDelegate ?: createSession(config, requestQueue)
2632
private val session = sessionAndDelegate.first
@@ -30,7 +36,7 @@ internal class DarwinLegacySession(
3036
internal suspend fun execute(request: HttpRequestData, callContext: CoroutineContext): HttpResponseData {
3137
val nativeRequest = request.toNSUrlRequest()
3238
.apply(config.requestConfig)
33-
val task = session.dataTaskWithRequest(nativeRequest)
39+
val task = withSession { dataTaskWithRequest(nativeRequest) }
3440
val response = delegate.read(request, callContext, task)
3541

3642
callContext.job.invokeOnCompletion { cause ->
@@ -49,9 +55,24 @@ internal class DarwinLegacySession(
4955
}
5056
}
5157

58+
private inline fun <T> withSession(block: NSURLSession.() -> T): T {
59+
cancelIfClosed()
60+
return synchronized(sessionLock) {
61+
cancelIfClosed()
62+
block(session)
63+
}
64+
}
65+
5266
override fun close() {
53-
if (!closed.compareAndSet(expect = false, update = true)) return
54-
session.finishTasksAndInvalidate()
67+
if (closed.value) return
68+
synchronized(sessionLock) {
69+
if (!closed.compareAndSet(expect = false, update = true)) return
70+
session.finishTasksAndInvalidate()
71+
}
72+
}
73+
74+
private fun cancelIfClosed() {
75+
if (closed.value) throw CancellationException("Darwin session is closed")
5576
}
5677
}
5778

ktor-client/ktor-client-darwin-legacy/darwin/test/DarwinLegacyEngineTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import io.ktor.client.request.*
99
import io.ktor.client.statement.*
1010
import io.ktor.client.test.base.*
1111
import io.ktor.http.*
12+
import io.ktor.utils.io.*
1213
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.launch
1315
import kotlinx.coroutines.runBlocking
16+
import kotlinx.coroutines.test.runTest
1417
import kotlinx.coroutines.withTimeout
1518
import platform.Foundation.NSHTTPCookieStorage.Companion.sharedHTTPCookieStorage
1619
import platform.Foundation.NSOperationQueue
@@ -20,6 +23,7 @@ import platform.Foundation.setValue
2023
import kotlin.test.Test
2124
import kotlin.test.assertEquals
2225
import kotlin.test.assertTrue
26+
import kotlin.test.fail
2327
import kotlin.time.Duration.Companion.seconds
2428

2529
@Suppress("DEPRECATION")
@@ -138,6 +142,38 @@ class DarwinLegacyEngineTest : ClientEngineTest<DarwinLegacyClientEngineConfig>(
138142
}
139143
}
140144

145+
@Test
146+
fun testExecuteAfterSessionClose() = runTest {
147+
val config = DarwinLegacyClientEngineConfig()
148+
val session = DarwinLegacySession(config, null)
149+
150+
session.close()
151+
launch {
152+
val request = request { url(TEST_SERVER) }.build()
153+
session.execute(request, coroutineContext)
154+
fail("Execution expected to be cancelled")
155+
}
156+
}
157+
158+
@OptIn(InternalAPI::class)
159+
@Test
160+
fun testWebSocketExecuteAfterSessionClose() = runTest {
161+
val config = DarwinLegacyClientEngineConfig()
162+
val session = DarwinLegacySession(config, null)
163+
164+
session.close()
165+
launch {
166+
val request = request {
167+
url(TEST_WEBSOCKET_SERVER)
168+
body = object : ClientUpgradeContent() {
169+
override fun verify(headers: Headers) = Unit
170+
}
171+
}.build()
172+
session.execute(request, coroutineContext)
173+
fail("Execution expected to be cancelled")
174+
}
175+
}
176+
141177
private fun stringToNSUrlString(value: String): String {
142178
return Url(value).toNSUrl().absoluteString!!
143179
}

ktor-client/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinSession.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ import io.ktor.client.engine.darwin.*
88
import io.ktor.client.request.*
99
import io.ktor.utils.io.*
1010
import io.ktor.utils.io.core.*
11-
import kotlinx.atomicfu.*
11+
import kotlinx.atomicfu.atomic
1212
import kotlinx.atomicfu.locks.SynchronizedObject
1313
import kotlinx.atomicfu.locks.synchronized
14-
import kotlinx.cinterop.*
15-
import kotlinx.coroutines.*
16-
import platform.Foundation.*
17-
import kotlin.coroutines.*
14+
import kotlinx.cinterop.ExperimentalForeignApi
15+
import kotlinx.cinterop.UnsafeNumber
16+
import kotlinx.coroutines.job
17+
import platform.Foundation.NSOperationQueue
18+
import platform.Foundation.NSURLSession
19+
import platform.Foundation.NSURLSessionConfiguration
20+
import platform.Foundation.NSURLSessionTaskStateRunning
21+
import kotlin.coroutines.CoroutineContext
1822

1923
@OptIn(UnsafeNumber::class)
2024
internal class DarwinSession(
@@ -75,7 +79,7 @@ internal class DarwinSession(
7579
}
7680

7781
private fun cancelIfClosed() {
78-
check(!closed.value) { "Darwin session is closed" }
82+
if (closed.value) throw CancellationException("Darwin session is closed")
7983
}
8084
}
8185

ktor-client/ktor-client-darwin/darwin/test/DarwinEngineTest.kt

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,8 @@ import io.ktor.utils.io.*
1616
import io.ktor.websocket.*
1717
import kotlinx.cinterop.ExperimentalForeignApi
1818
import kotlinx.cinterop.UnsafeNumber
19-
import kotlinx.coroutines.Dispatchers
20-
import kotlinx.coroutines.TimeoutCancellationException
21-
import kotlinx.coroutines.runBlocking
22-
import kotlinx.coroutines.withTimeout
19+
import kotlinx.coroutines.*
20+
import kotlinx.coroutines.test.runTest
2321
import platform.Foundation.NSHTTPCookieStorage.Companion.sharedHTTPCookieStorage
2422
import platform.Foundation.NSOperationQueue
2523
import platform.Foundation.NSURLSession
@@ -277,45 +275,35 @@ class DarwinEngineTest : ClientEngineTest<DarwinClientEngineConfig>(Darwin) {
277275
}
278276

279277
@Test
280-
fun testExecuteAfterSessionCloseThrowsCatchableException() = runBlocking {
278+
fun testExecuteAfterSessionClose() = runTest {
281279
val config = DarwinClientEngineConfig()
282280
val session = DarwinSession(config, null)
283-
session.close()
284-
285-
val request = HttpRequestBuilder().apply {
286-
url(TEST_SERVER)
287-
}.build()
288281

289-
// Executing on a closed session must throw a catchable Kotlin exception,
290-
// not crash with SIGABRT from an NSException on the invalidated NSURLSession
291-
assertFailsWith<IllegalStateException> {
282+
session.close()
283+
launch {
284+
val request = request { url(TEST_SERVER) }.build()
292285
session.execute(request, coroutineContext)
286+
fail("Execution expected to be cancelled")
293287
}
294-
return@runBlocking
295288
}
296289

297290
@OptIn(InternalAPI::class)
298291
@Test
299-
fun testWebSocketExecuteAfterSessionCloseThrowsCatchableException() = runBlocking {
292+
fun testWebSocketExecuteAfterSessionClose() = runTest {
300293
val config = DarwinClientEngineConfig()
301294
val session = DarwinSession(config, null)
302-
session.close()
303295

304-
// ClientUpgradeContent body is needed to trigger the webSocketTaskWithRequest branch
305-
// in DarwinSession.execute(), since isUpgradeRequest() checks `body is ClientUpgradeContent`
306-
val request = HttpRequestBuilder().apply {
307-
url(TEST_WEBSOCKET_SERVER)
308-
body = object : ClientUpgradeContent() {
309-
override fun verify(headers: Headers) = Unit
310-
}
311-
}.build()
312-
313-
// WebSocket execute on a closed session must throw a catchable Kotlin exception,
314-
// not crash with SIGABRT from an NSException on the invalidated NSURLSession
315-
assertFailsWith<IllegalStateException> {
296+
session.close()
297+
launch {
298+
val request = request {
299+
url(TEST_WEBSOCKET_SERVER)
300+
body = object : ClientUpgradeContent() {
301+
override fun verify(headers: Headers) = Unit
302+
}
303+
}.build()
316304
session.execute(request, coroutineContext)
305+
fail("Execution expected to be cancelled")
317306
}
318-
return@runBlocking
319307
}
320308

321309
private fun stringToNSUrlString(value: String): String {

0 commit comments

Comments
 (0)