From 034a555e4e943ca5f2ed432ff6be72e9b7a82dce Mon Sep 17 00:00:00 2001 From: joshuabrink Date: Tue, 9 Dec 2025 16:46:37 +0200 Subject: [PATCH 1/6] Fix high memory usage on apple devices --- CHANGELOG.md | 8 + core/build.gradle.kts | 3 +- gradle.properties | 2 +- internal/ktor-client-darwin/README.md | 75 +++ .../api/ktor-client-darwin.klib.api | 221 +++++++++ internal/ktor-client-darwin/build.gradle.kts | 47 ++ .../io/ktor/client/engine/darwin/Darwin.kt | 44 ++ .../engine/darwin/DarwinClientEngine.kt | 35 ++ .../engine/darwin/DarwinClientEngineConfig.kt | 165 +++++++ .../ktor/client/engine/darwin/DarwinUtils.kt | 115 +++++ .../engine/darwin/KtorNSURLSessionDelegate.kt | 155 ++++++ .../engine/darwin/ProxySupportCommon.kt | 43 ++ .../ktor/client/engine/darwin/TimeoutUtils.kt | 31 ++ .../darwin/certificates/CertificatePinner.kt | 442 ++++++++++++++++++ .../darwin/certificates/CertificatesInfo.kt | 60 +++ .../darwin/certificates/PinnedCertificate.kt | 112 +++++ .../darwin/internal/DarwinRequestUtils.kt | 33 ++ .../darwin/internal/DarwinResponseUtils.kt | 18 + .../engine/darwin/internal/DarwinSession.kt | 92 ++++ .../darwin/internal/DarwinTaskHandler.kt | 108 +++++ .../engine/darwin/internal/DarwinUrlUtils.kt | 80 ++++ .../darwin/internal/DarwinWebsocketSession.kt | 242 ++++++++++ .../client/engine/darwin/internal/issue.md | 45 ++ .../src/io/ktor/client/engine/ios/Ios.kt | 28 ++ .../ios/certificates/CertificatePinner.kt | 19 + settings.gradle.kts | 2 + 26 files changed, 2223 insertions(+), 2 deletions(-) create mode 100644 internal/ktor-client-darwin/README.md create mode 100644 internal/ktor-client-darwin/api/ktor-client-darwin.klib.api create mode 100644 internal/ktor-client-darwin/build.gradle.kts create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/Darwin.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngineConfig.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinUtils.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/KtorNSURLSessionDelegate.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/ProxySupportCommon.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/TimeoutUtils.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatePinner.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatesInfo.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/PinnedCertificate.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinRequestUtils.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinResponseUtils.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinSession.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinUrlUtils.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinWebsocketSession.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/Ios.kt create mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/certificates/CertificatePinner.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d1b945..e145edbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.10.0 + +- Add internal fork of Ktor Darwin HTTP engine with improved backpressure handling for large response bodies on Apple platforms. + This fixes out-of-memory crashes when syncing large payloads (hundreds of MB) on iOS/macOS. +- Replaces unbounded channel with bounded channel (capacity 64) to prevent unbounded memory growth +- Applies backpressure that propagates to the network layer, throttling data delivery based on processing speed +- No API changes - this is a transparent improvement to the underlying HTTP handling + ## 1.9.0 - Updated user agent string formats to allow viewing version distributions in the new PowerSync dashboard. diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 06a273a2..e672396e 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -65,7 +65,8 @@ kotlin { } appleMain.dependencies { - implementation(libs.ktor.client.darwin) + // Use the local ktor-client-darwin module instead of the maven dependency + api(projects.internal.ktorClientDarwin) // We're not using the bundled SQLite library for Apple platforms. Instead, we depend on // static-sqlite-driver to link SQLite and have our own bindings implementing the diff --git a/gradle.properties b/gradle.properties index cd92fe66..ac71b108 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ development=true RELEASE_SIGNING_ENABLED=true # Library config GROUP=com.powersync -LIBRARY_VERSION=1.9.0 +LIBRARY_VERSION=1.10.0 GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git # POM POM_URL=https://github.com/powersync-ja/powersync-kotlin/ diff --git a/internal/ktor-client-darwin/README.md b/internal/ktor-client-darwin/README.md new file mode 100644 index 00000000..17fb9afd --- /dev/null +++ b/internal/ktor-client-darwin/README.md @@ -0,0 +1,75 @@ +# Ktor Darwin Client (Internal Fork) + +This is an internal fork of the [Ktor Darwin HTTP engine](https://github.com/ktorio/ktor) with modifications to address memory issues when processing large HTTP response bodies on Apple platforms. + +## Why This Fork Exists + +### The Problem: Out of Memory (OOM) on Large Sync Payloads + +The upstream Ktor Darwin engine uses an unbounded channel to buffer incoming response data chunks from `NSURLSession`. When processing large sync payloads (hundreds of MBs), this causes: + +1. **NSURLSession delivers data faster than it can be processed** - chunks accumulate in the unbounded channel +2. **Memory usage spikes dramatically** - we observed multi-GB allocations during sync operations +3. **OOM crashes on iOS/macOS** - devices run out of memory before the response is fully processed + +This issue is specific to the Darwin engine because: + +- `NSURLSession` delivers data via delegate callbacks that cannot be paused +- The upstream implementation uses `Channel.UNLIMITED` - buffering all chunks without backpressure +- Other Ktor engines have natural backpressure mechanisms: + - **OkHttp**: Uses `BufferedSource.read()` which blocks until data is consumed + - **Apache/Apache5**: Uses `CapacityChannel` for explicit backpressure signaling + +### The Solution: Bounded Channel with Backpressure + +Our fork modifies `DarwinTaskHandler` to apply backpressure: + +```kotlin +// Bounded channel instead of unbounded +private val bodyChunks = Channel(capacity = 64) + +fun receiveData(dataTask: NSURLSessionDataTask, data: NSData) { + val result = bodyChunks.trySend(data) + when { + result.isClosed -> dataTask.cancel() + result.isFailure -> { + // Buffer full - block to apply backpressure + runBlocking { bodyChunks.send(data) } + } + } +} +``` + +Key changes: + +- **Limited channel capacity (64)** - prevents unbounded memory growth +- **`runBlocking` on buffer full** - blocks the NSURLSession delegate thread, naturally slowing data delivery +- **Backpressure propagates to NSURLSession** - the network layer throttles based on processing speed + +### Alternative Approaches Considered + +**Task Pause/Resume**: We considered using `NSURLSessionTask.suspend()` and `resume()` to pause data delivery when the buffer was full. However, this approach was rejected due to: + +- **Complexity** - managing pause/resume state across async boundaries added significant complexity +- **Concurrency issues** - race conditions between pause signals and data delivery callbacks +- **Data delivery timing** - the asynchronous nature of NSURLSession means data can still be delivered after calling `suspend()` on the task, which would require periodic draining and complex state management. +- **Error-prone implementation** - the combination of these factors made the approach fragile and difficult to test + +The simpler bounded channel with `runBlocking` approach was chosen as it provides effective backpressure with minimal complexity and maintenance burden. + +## When to Update This Fork + +This fork should be updated if: + +- Ktor releases a fix for this issue upstream (track [ktor issues](https://github.com/ktorio/ktor/issues)) +- Security vulnerabilities are found in the Ktor Darwin engine +- New Darwin-specific features are needed + +## Files Modified + +- `darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt` - Bounded channel + backpressure logic + +## References + +- [Original Ktor Darwin Engine](https://github.com/ktorio/ktor/tree/main/ktor-client/ktor-client-darwin) +- PowerSync Kotlin SDK issue: OOM during large sync operations on iOS/macOS diff --git a/internal/ktor-client-darwin/api/ktor-client-darwin.klib.api b/internal/ktor-client-darwin/api/ktor-client-darwin.klib.api new file mode 100644 index 00000000..1a48105a --- /dev/null +++ b/internal/ktor-client-darwin/api/ktor-client-darwin.klib.api @@ -0,0 +1,221 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, macosArm64, macosX64, tvosArm64, tvosSimulatorArm64, tvosX64, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] +// Alias: ios => [iosArm64, iosSimulatorArm64, iosX64] +// Alias: macos => [macosArm64, macosX64] +// Alias: tvos => [tvosArm64, tvosSimulatorArm64, tvosX64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final class io.ktor.client.engine.darwin.certificates/PinnedCertificate { // io.ktor.client.engine.darwin.certificates/PinnedCertificate|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String) // io.ktor.client.engine.darwin.certificates/PinnedCertificate.|(kotlin.String;kotlin.String;kotlin.String){}[0] + + final val hash // io.ktor.client.engine.darwin.certificates/PinnedCertificate.hash|{}hash[0] + final fun (): kotlin/String // io.ktor.client.engine.darwin.certificates/PinnedCertificate.hash.|(){}[0] + final val hashAlgorithm // io.ktor.client.engine.darwin.certificates/PinnedCertificate.hashAlgorithm|{}hashAlgorithm[0] + final fun (): kotlin/String // io.ktor.client.engine.darwin.certificates/PinnedCertificate.hashAlgorithm.|(){}[0] + + final fun component2(): kotlin/String // io.ktor.client.engine.darwin.certificates/PinnedCertificate.component2|component2(){}[0] + final fun component3(): kotlin/String // io.ktor.client.engine.darwin.certificates/PinnedCertificate.component3|component3(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin/String = ...): io.ktor.client.engine.darwin.certificates/PinnedCertificate // io.ktor.client.engine.darwin.certificates/PinnedCertificate.copy|copy(kotlin.String;kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin.certificates/PinnedCertificate.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin.certificates/PinnedCertificate.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin.certificates/PinnedCertificate.toString|toString(){}[0] + + final object Companion { // io.ktor.client.engine.darwin.certificates/PinnedCertificate.Companion|null[0] + final fun new(kotlin/String, kotlin/String): io.ktor.client.engine.darwin.certificates/PinnedCertificate // io.ktor.client.engine.darwin.certificates/PinnedCertificate.Companion.new|new(kotlin.String;kotlin.String){}[0] + } +} + +final class io.ktor.client.engine.darwin/DarwinClientEngineConfig : io.ktor.client.engine/HttpClientEngineConfig { // io.ktor.client.engine.darwin/DarwinClientEngineConfig|null[0] + constructor () // io.ktor.client.engine.darwin/DarwinClientEngineConfig.|(){}[0] + + final var challengeHandler // io.ktor.client.engine.darwin/DarwinClientEngineConfig.challengeHandler|{}challengeHandler[0] + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun (): kotlin/Function4, kotlin/Unit>? // io.ktor.client.engine.darwin/DarwinClientEngineConfig.challengeHandler.|(){}[0] + + // Targets: [watchosArm32, watchosArm64] + final fun (): kotlin/Function4, kotlin/Unit>? // io.ktor.client.engine.darwin/DarwinClientEngineConfig.challengeHandler.|(){}[0] + final var preconfiguredSession // io.ktor.client.engine.darwin/DarwinClientEngineConfig.preconfiguredSession|{}preconfiguredSession[0] + final fun (): platform.Foundation/NSURLSession? // io.ktor.client.engine.darwin/DarwinClientEngineConfig.preconfiguredSession.|(){}[0] + final var requestConfig // io.ktor.client.engine.darwin/DarwinClientEngineConfig.requestConfig|{}requestConfig[0] + final fun (): kotlin/Function1 // io.ktor.client.engine.darwin/DarwinClientEngineConfig.requestConfig.|(){}[0] + final fun (kotlin/Function1) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.requestConfig.|(kotlin.Function1){}[0] + final var sessionConfig // io.ktor.client.engine.darwin/DarwinClientEngineConfig.sessionConfig|{}sessionConfig[0] + final fun (): kotlin/Function1 // io.ktor.client.engine.darwin/DarwinClientEngineConfig.sessionConfig.|(){}[0] + final fun (kotlin/Function1) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.sessionConfig.|(kotlin.Function1){}[0] + + final fun configureRequest(kotlin/Function1) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.configureRequest|configureRequest(kotlin.Function1){}[0] + final fun configureSession(kotlin/Function1) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.configureSession|configureSession(kotlin.Function1){}[0] + final fun usePreconfiguredSession(platform.Foundation/NSURLSession, io.ktor.client.engine.darwin/KtorNSURLSessionDelegate) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.usePreconfiguredSession|usePreconfiguredSession(platform.Foundation.NSURLSession;io.ktor.client.engine.darwin.KtorNSURLSessionDelegate){}[0] + final fun usePreconfiguredSession(platform.Foundation/NSURLSession?) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.usePreconfiguredSession|usePreconfiguredSession(platform.Foundation.NSURLSession?){}[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun handleChallenge(kotlin/Function4, kotlin/Unit>) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.handleChallenge|handleChallenge(kotlin.Function4,kotlin.Unit>){}[0] + + // Targets: [watchosArm32, watchosArm64] + final fun handleChallenge(kotlin/Function4, kotlin/Unit>) // io.ktor.client.engine.darwin/DarwinClientEngineConfig.handleChallenge|handleChallenge(kotlin.Function4,kotlin.Unit>){}[0] +} + +final class io.ktor.client.engine.darwin/DarwinHttpRequestException : kotlinx.io/IOException { // io.ktor.client.engine.darwin/DarwinHttpRequestException|null[0] + constructor (platform.Foundation/NSError) // io.ktor.client.engine.darwin/DarwinHttpRequestException.|(platform.Foundation.NSError){}[0] + + final val origin // io.ktor.client.engine.darwin/DarwinHttpRequestException.origin|{}origin[0] + final fun (): platform.Foundation/NSError // io.ktor.client.engine.darwin/DarwinHttpRequestException.origin.|(){}[0] +} + +final class io.ktor.client.engine.darwin/KtorNSURLSessionDelegate : platform.Foundation/NSURLSessionDataDelegateProtocol, platform.Foundation/NSURLSessionWebSocketDelegateProtocol, platform.darwin/NSObject { // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate|null[0] + final val debugDescription // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.debugDescription|{}debugDescription[0] + final fun (): kotlin/String? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.debugDescription.|objc:debugDescription#Accessor[0] + final val description // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.description|{}description[0] + final fun (): kotlin/String? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.description.|objc:description#Accessor[0] + final val hash // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.hash|{}hash[0] + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun (): kotlin/ULong // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.hash.|objc:hash#Accessor[0] + + // Targets: [watchosArm32, watchosArm64] + final fun (): kotlin/UInt // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.hash.|objc:hash#Accessor[0] + final val superclass // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.superclass|{}superclass[0] + final fun (): kotlinx.cinterop/ObjCClass? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.superclass.|objc:superclass#Accessor[0] + + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSError?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:didBecomeInvalidWithError:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionDataTask, platform.Foundation/NSCachedURLResponse, kotlin/Function1) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:dataTask:willCacheResponse:completionHandler:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionDataTask, platform.Foundation/NSData) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:dataTask:didReceiveData:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionDataTask, platform.Foundation/NSURLSessionDownloadTask) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:dataTask:didBecomeDownloadTask:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionDataTask, platform.Foundation/NSURLSessionStreamTask) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:dataTask:didBecomeStreamTask:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:didCreateTask:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:taskIsWaitingForConnectivity:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, kotlin/Function1) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:needNewBodyStream:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, kotlin/Long, kotlin/Function1) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:needNewBodyStreamFromOffset:completionHandler:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, kotlin/Long, kotlin/Long, kotlin/Long) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSError?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:didCompleteWithError:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSHTTPURLResponse) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:didReceiveInformationalResponse:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSHTTPURLResponse, platform.Foundation/NSURLRequest, kotlin/Function1) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLSessionTaskMetrics) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:didFinishCollectingMetrics:[0] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionWebSocketTask, kotlin/String?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:webSocketTask:didOpenWithProtocol:[0] + final fun URLSessionDidFinishEventsForBackgroundURLSession(platform.Foundation/NSURLSession) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSessionDidFinishEventsForBackgroundURLSession|objc:URLSessionDidFinishEventsForBackgroundURLSession:[0] + final fun class(): kotlinx.cinterop/ObjCClass? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.class|objc:class[0] + final fun conformsToProtocol(objcnames.classes/Protocol?): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.conformsToProtocol|objc:conformsToProtocol:[0] + final fun copy(): kotlin/Any? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.copy|objc:copy[0] + final fun debugDescription(): kotlin/String? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.debugDescription|objc:debugDescription[0] + final fun description(): kotlin/String? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.description|objc:description[0] + final fun doesNotRecognizeSelector(kotlinx.cinterop/CPointer?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.doesNotRecognizeSelector|objc:doesNotRecognizeSelector:[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.equals|equals(other:kotlin.Any?){}[0] + final fun finalize() // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.finalize|objc:finalize[0] + final fun forwardInvocation(objcnames.classes/NSInvocation?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.forwardInvocation|objc:forwardInvocation:[0] + final fun forwardingTargetForSelector(kotlinx.cinterop/CPointer?): kotlin/Any? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.forwardingTargetForSelector|objc:forwardingTargetForSelector:[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.hashCode|hashCode(){}[0] + final fun init(): platform.darwin/NSObject? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.init|objc:init[0] + final fun isEqual(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.isEqual|objc:isEqual:[0] + final fun isKindOfClass(kotlinx.cinterop/ObjCClass?): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.isKindOfClass|objc:isKindOfClass:[0] + final fun isMemberOfClass(kotlinx.cinterop/ObjCClass?): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.isMemberOfClass|objc:isMemberOfClass:[0] + final fun isProxy(): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.isProxy|objc:isProxy[0] + final fun methodForSelector(kotlinx.cinterop/CPointer?): kotlinx.cinterop/CPointer>>? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.methodForSelector|objc:methodForSelector:[0] + final fun methodSignatureForSelector(kotlinx.cinterop/CPointer?): objcnames.classes/NSMethodSignature? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.methodSignatureForSelector|objc:methodSignatureForSelector:[0] + final fun mutableCopy(): kotlin/Any? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.mutableCopy|objc:mutableCopy[0] + final fun performSelector(kotlinx.cinterop/CPointer?): kotlin/Any? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.performSelector|objc:performSelector:[0] + final fun performSelector(kotlinx.cinterop/CPointer?, kotlin/Any?): kotlin/Any? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.performSelector|objc:performSelector:withObject:[0] + final fun performSelector(kotlinx.cinterop/CPointer?, kotlin/Any?, kotlin/Any?): kotlin/Any? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.performSelector|objc:performSelector:withObject:withObject:[0] + final fun respondsToSelector(kotlinx.cinterop/CPointer?): kotlin/Boolean // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.respondsToSelector|objc:respondsToSelector:[0] + final fun superclass(): kotlinx.cinterop/ObjCClass? // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.superclass|objc:superclass[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.toString|toString(){}[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + constructor (kotlin/Function4, kotlin/Unit>?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.|(challengeHandler:kotlin.Function4,kotlin.Unit>?){}[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLAuthenticationChallenge, kotlin/Function2) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:didReceiveChallenge:completionHandler:[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionDataTask, platform.Foundation/NSURLResponse, kotlin/Function1) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:dataTask:didReceiveResponse:completionHandler:[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLAuthenticationChallenge, kotlin/Function2) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:didReceiveChallenge:completionHandler:[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLRequest, kotlin/Function2) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:willBeginDelayedRequest:completionHandler:[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionWebSocketTask, kotlin/Long, platform.Foundation/NSData?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:webSocketTask:didCloseWithCode:reason:[0] + + // Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] + final fun hash(): kotlin/ULong // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.hash|objc:hash[0] + + // Targets: [watchosArm32, watchosArm64] + constructor (kotlin/Function4, kotlin/Unit>?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.|(challengeHandler:kotlin.Function4,kotlin.Unit>?){}[0] + + // Targets: [watchosArm32, watchosArm64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLAuthenticationChallenge, kotlin/Function2) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:didReceiveChallenge:completionHandler:[0] + + // Targets: [watchosArm32, watchosArm64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionDataTask, platform.Foundation/NSURLResponse, kotlin/Function1) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:dataTask:didReceiveResponse:completionHandler:[0] + + // Targets: [watchosArm32, watchosArm64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLAuthenticationChallenge, kotlin/Function2) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:didReceiveChallenge:completionHandler:[0] + + // Targets: [watchosArm32, watchosArm64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLRequest, kotlin/Function2) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:task:willBeginDelayedRequest:completionHandler:[0] + + // Targets: [watchosArm32, watchosArm64] + final fun URLSession(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionWebSocketTask, kotlin/Int, platform.Foundation/NSData?) // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.URLSession|objc:URLSession:webSocketTask:didCloseWithCode:reason:[0] + + // Targets: [watchosArm32, watchosArm64] + final fun hash(): kotlin/UInt // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate.hash|objc:hash[0] +} + +final object io.ktor.client.engine.darwin/Darwin : io.ktor.client.engine/HttpClientEngineFactory { // io.ktor.client.engine.darwin/Darwin|null[0] + final fun create(kotlin/Function1): io.ktor.client.engine/HttpClientEngine // io.ktor.client.engine.darwin/Darwin.create|create(kotlin.Function1){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin/Darwin.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin/Darwin.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin/Darwin.toString|toString(){}[0] +} + +final fun io.ktor.client.engine.darwin/KtorNSURLSessionDelegate(): io.ktor.client.engine.darwin/KtorNSURLSessionDelegate // io.ktor.client.engine.darwin/KtorNSURLSessionDelegate|KtorNSURLSessionDelegate(){}[0] + +// Targets: [ios, macos, tvos, watchosDeviceArm64, watchosSimulatorArm64, watchosX64] +final class io.ktor.client.engine.darwin.certificates/CertificatePinner : kotlin/Function4, kotlin/Unit> { // io.ktor.client.engine.darwin.certificates/CertificatePinner|null[0] + constructor (kotlin.collections/Set, kotlin/Boolean) // io.ktor.client.engine.darwin.certificates/CertificatePinner.|(kotlin.collections.Set;kotlin.Boolean){}[0] + + final fun copy(kotlin.collections/Set = ..., kotlin/Boolean = ...): io.ktor.client.engine.darwin.certificates/CertificatePinner // io.ktor.client.engine.darwin.certificates/CertificatePinner.copy|copy(kotlin.collections.Set;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin.certificates/CertificatePinner.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin.certificates/CertificatePinner.hashCode|hashCode(){}[0] + final fun invoke(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLAuthenticationChallenge, kotlin/Function2) // io.ktor.client.engine.darwin.certificates/CertificatePinner.invoke|invoke(platform.Foundation.NSURLSession;platform.Foundation.NSURLSessionTask;platform.Foundation.NSURLAuthenticationChallenge;kotlin.Function2){}[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin.certificates/CertificatePinner.toString|toString(){}[0] + + final class Builder { // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder|null[0] + constructor (kotlin.collections/MutableList = ..., kotlin/Boolean = ...) // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.|(kotlin.collections.MutableList;kotlin.Boolean){}[0] + + final fun add(kotlin/String, kotlin/Array...): io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.add|add(kotlin.String;kotlin.Array...){}[0] + final fun build(): io.ktor.client.engine.darwin.certificates/CertificatePinner // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.build|build(){}[0] + final fun copy(kotlin.collections/MutableList = ..., kotlin/Boolean = ...): io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.copy|copy(kotlin.collections.MutableList;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.toString|toString(){}[0] + final fun validateTrust(kotlin/Boolean): io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.validateTrust|validateTrust(kotlin.Boolean){}[0] + } +} + +// Targets: [watchosArm32, watchosArm64] +final class io.ktor.client.engine.darwin.certificates/CertificatePinner : kotlin/Function4, kotlin/Unit> { // io.ktor.client.engine.darwin.certificates/CertificatePinner|null[0] + constructor (kotlin.collections/Set, kotlin/Boolean) // io.ktor.client.engine.darwin.certificates/CertificatePinner.|(kotlin.collections.Set;kotlin.Boolean){}[0] + + final fun copy(kotlin.collections/Set = ..., kotlin/Boolean = ...): io.ktor.client.engine.darwin.certificates/CertificatePinner // io.ktor.client.engine.darwin.certificates/CertificatePinner.copy|copy(kotlin.collections.Set;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin.certificates/CertificatePinner.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin.certificates/CertificatePinner.hashCode|hashCode(){}[0] + final fun invoke(platform.Foundation/NSURLSession, platform.Foundation/NSURLSessionTask, platform.Foundation/NSURLAuthenticationChallenge, kotlin/Function2) // io.ktor.client.engine.darwin.certificates/CertificatePinner.invoke|invoke(platform.Foundation.NSURLSession;platform.Foundation.NSURLSessionTask;platform.Foundation.NSURLAuthenticationChallenge;kotlin.Function2){}[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin.certificates/CertificatePinner.toString|toString(){}[0] + + final class Builder { // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder|null[0] + constructor (kotlin.collections/MutableList = ..., kotlin/Boolean = ...) // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.|(kotlin.collections.MutableList;kotlin.Boolean){}[0] + + final fun add(kotlin/String, kotlin/Array...): io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.add|add(kotlin.String;kotlin.Array...){}[0] + final fun build(): io.ktor.client.engine.darwin.certificates/CertificatePinner // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.build|build(){}[0] + final fun copy(kotlin.collections/MutableList = ..., kotlin/Boolean = ...): io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.copy|copy(kotlin.collections.MutableList;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.toString|toString(){}[0] + final fun validateTrust(kotlin/Boolean): io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder // io.ktor.client.engine.darwin.certificates/CertificatePinner.Builder.validateTrust|validateTrust(kotlin.Boolean){}[0] + } +} diff --git a/internal/ktor-client-darwin/build.gradle.kts b/internal/ktor-client-darwin/build.gradle.kts new file mode 100644 index 00000000..c14bb285 --- /dev/null +++ b/internal/ktor-client-darwin/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +plugins { + alias(libs.plugins.kotlinMultiplatform) +} + +kotlin { + // Darwin targets + iosArm64() + iosSimulatorArm64() + iosX64() + + macosX64() + macosArm64() + + tvosArm64() + tvosSimulatorArm64() + tvosX64() + + watchosArm32() + watchosArm64() + watchosSimulatorArm64() + watchosX64() + + // Use the default hierarchy template - appleMain covers all Darwin targets + sourceSets { + // appleMain is automatically created by the default hierarchy template + // and includes all Apple/Darwin targets + appleMain { + kotlin.srcDir("darwin/src") + dependencies { + api(libs.ktor.client.core) + implementation(libs.kotlinx.coroutines.core) + } + } + + appleTest { + kotlin.srcDir("darwin/test") + dependencies { + implementation(libs.kotlin.test) + implementation(libs.ktor.client.logging) + } + } + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/Darwin.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/Darwin.kt new file mode 100644 index 00000000..0b34fa10 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/Darwin.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin + +import io.ktor.client.engine.* +import io.ktor.utils.io.* + +@Suppress("DEPRECATION") +@OptIn(ExperimentalStdlibApi::class) +@EagerInitialization +private val initHook = Darwin + +/** + * A Kotlin/Native client engine that targets Darwin-based operating systems + * (such as macOS, iOS, tvOS, and so on) and uses `NSURLSession` internally. + * + * To create the client with this engine, pass it to the `HttpClient` constructor: + * ```kotlin + * val client = HttpClient(Darwin) + * ``` + * To configure the engine, pass settings exposed by [DarwinClientEngineConfig] to the `engine` method: + * ```kotlin + * val client = HttpClient(Darwin) { + * engine { + * // this: DarwinClientEngineConfig + * } + * } + * ``` + * + * You can learn more about client engines from [Engines](https://ktor.io/docs/http-client-engines.html). + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.Darwin) + */ +@OptIn(InternalAPI::class) +public data object Darwin : HttpClientEngineFactory { + init { + engines.append(this) + } + + override fun create(block: DarwinClientEngineConfig.() -> Unit): HttpClientEngine = + DarwinClientEngine(DarwinClientEngineConfig().apply(block)) +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt new file mode 100644 index 00000000..f32f6820 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin + +import io.ktor.client.engine.* +import io.ktor.client.engine.darwin.internal.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.sse.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.utils.io.* +import kotlinx.coroutines.* +import platform.Foundation.* + +@OptIn(InternalAPI::class) +internal class DarwinClientEngine(override val config: DarwinClientEngineConfig) : HttpClientEngineBase("ktor-darwin") { + + private val requestQueue: NSOperationQueue? = when (val queue = NSOperationQueue.currentQueue()) { + NSOperationQueue.mainQueue -> NSOperationQueue() + else -> queue + } + + override val dispatcher = Dispatchers.Unconfined + + override val supportedCapabilities = setOf(HttpTimeoutCapability, WebSocketCapability, SSECapability) + + private val session = DarwinSession(config, requestQueue) + + override suspend fun execute(data: HttpRequestData): HttpResponseData { + val callContext = callContext() + return session.execute(data, callContext) + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngineConfig.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngineConfig.kt new file mode 100644 index 00000000..c6ed3c31 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngineConfig.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin + +import io.ktor.client.engine.* +import io.ktor.client.engine.darwin.internal.* +import kotlinx.cinterop.* +import platform.Foundation.* + +/** + * A challenge handler type for [NSURLSession]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.ChallengeHandler) + */ +@OptIn(UnsafeNumber::class) +public typealias ChallengeHandler = ( + session: NSURLSession, + task: NSURLSessionTask, + challenge: NSURLAuthenticationChallenge, + completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit +) -> Unit + +/** + * A configuration for the [Darwin] client engine. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig) + */ +public class DarwinClientEngineConfig : HttpClientEngineConfig() { + /** + * A request configuration. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.requestConfig) + */ + public var requestConfig: NSMutableURLRequest.() -> Unit = {} + @Deprecated( + "[requestConfig] property is deprecated. Consider using [configureRequest] instead", + replaceWith = ReplaceWith("this.configureRequest(value)"), + level = DeprecationLevel.ERROR + ) + set + + /** + * A session configuration. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.sessionConfig) + */ + public var sessionConfig: NSURLSessionConfiguration.() -> Unit = {} + @Deprecated( + "[sessionConfig] property is deprecated. Consider using [configureSession] instead", + replaceWith = ReplaceWith("this.configureSession(value)"), + level = DeprecationLevel.ERROR + ) + set + + /** + * Handles the challenge of HTTP responses [NSURLSession]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.challengeHandler) + */ + @OptIn(UnsafeNumber::class) + public var challengeHandler: ChallengeHandler? = null + private set + + /** + * Specifies a session to use for making HTTP requests. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.preconfiguredSession) + */ + public var preconfiguredSession: NSURLSession? = null + private set + + /** + * Specifies a session to use for making HTTP requests. + */ + internal var sessionAndDelegate: Pair? = null + + /** + * Appends a block with the [NSMutableURLRequest] configuration to [requestConfig]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.configureRequest) + */ + public fun configureRequest(block: NSMutableURLRequest.() -> Unit) { + val old = requestConfig + + @Suppress("DEPRECATION_ERROR") + requestConfig = { + old() + block() + } + } + + /** + * Appends a block with the [NSURLSessionConfiguration] configuration to [sessionConfig]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.configureSession) + */ + public fun configureSession(block: NSURLSessionConfiguration.() -> Unit) { + val old = sessionConfig + + @Suppress("DEPRECATION_ERROR") + sessionConfig = { + old() + block() + } + } + + /** + * Set a [session] to be used to make HTTP requests, [null] to create default session. + * If the preconfigured session is set, [configureSession] block will be ignored. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.usePreconfiguredSession) + */ + @Deprecated("Please use method with delegate parameter", level = DeprecationLevel.ERROR) + public fun usePreconfiguredSession(session: NSURLSession?) { + preconfiguredSession = session + } + + /** + * Set a [session] to be used to make HTTP requests. + * If the preconfigured session is set, [configureSession] and [handleChallenge] blocks will be ignored. + * + * The [session] must be created with [KtorNSURLSessionDelegate] as a delegate. + * + * ``` + * val delegate = KtorNSURLSessionDelegate() + * val session = NSURLSession.sessionWithConfiguration( + * NSURLSessionConfiguration.defaultSessionConfiguration(), + * delegate, + * delegateQueue = null + * ) + * + * usePreconfiguredSession(session, delegate) + * ``` + * + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.usePreconfiguredSession) + * + * @see [KtorNSURLSessionDelegate] for details. + */ + public fun usePreconfiguredSession(session: NSURLSession, delegate: KtorNSURLSessionDelegate) { + requireNotNull(session.delegate) { + """ + Invalid session: delegate field is null + Possible solutions: + + 1. Ensure that you set a valid delegate when creating the `session`. For more details, see `KtorNSURLSessionDelegate`. + + 2. If you're only modifying session configuration, consider using `configureSession` instead of `usePreconfiguredSession`. + """.trimIndent() + } + sessionAndDelegate = session to delegate + } + + /** + * Sets the [block] as an HTTP request challenge handler replacing the old one. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.DarwinClientEngineConfig.handleChallenge) + */ + @OptIn(UnsafeNumber::class) + public fun handleChallenge(block: ChallengeHandler) { + challengeHandler = block + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinUtils.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinUtils.kt new file mode 100644 index 00000000..611474c8 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinUtils.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin + +import io.ktor.client.call.* +import io.ktor.client.engine.* +import io.ktor.http.content.* +import io.ktor.utils.io.* +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import kotlinx.io.IOException +import platform.Foundation.* +import platform.posix.* + +@OptIn( + DelicateCoroutinesApi::class, + UnsafeNumber::class, + InternalAPI::class, + ExperimentalForeignApi::class, + BetaInteropApi::class +) +internal suspend fun OutgoingContent.toDataOrStream(): Any? { + if (this is OutgoingContent.ContentWrapper) return delegate().toDataOrStream() + if (this is OutgoingContent.ByteArrayContent) return bytes().toNSData() + if (this is OutgoingContent.NoContent) return null + if (this is OutgoingContent.ProtocolUpgrade) throw UnsupportedContentTypeException(this) + + val outputStreamPtr = nativeHeap.alloc>() + val inputStreamPtr = nativeHeap.alloc>() + + NSStream.getBoundStreamsWithBufferSize(4096.convert(), inputStreamPtr.ptr, outputStreamPtr.ptr) + + val context = callContext() + context[Job]!!.invokeOnCompletion { + nativeHeap.free(inputStreamPtr) + nativeHeap.free(outputStreamPtr) + } + + val inputStream = inputStreamPtr.value ?: throw IllegalStateException("Failed to create input stream") + val outputStream = outputStreamPtr.value ?: throw IllegalStateException("Failed to create output stream") + + val channel = when (this) { + is OutgoingContent.WriteChannelContent -> GlobalScope.writer(context) { writeTo(channel) }.channel + is OutgoingContent.ReadChannelContent -> readFrom() + else -> throw UnsupportedContentTypeException(this) + } + + CoroutineScope(context).launch { + try { + outputStream.open() + memScoped { + val buffer = allocArray(4096) + while (!channel.isClosedForRead) { + var offset = 0 + val read = channel.readAvailable(buffer, 0, 4096) + while (offset < read) { + while (!outputStream.hasSpaceAvailable) { + yield() + } + @Suppress("UNCHECKED_CAST") + val written = outputStream + .write(buffer.plus(offset) as CPointer, (read - offset).convert()) + .convert() + offset += written + if (written < 0) { + throw outputStream.streamError?.let { DarwinHttpRequestException(it) } + ?: inputStream.streamError?.let { DarwinHttpRequestException(it) } + ?: IOException("Failed to write to the network") + } + } + } + } + } finally { + outputStream.close() + } + } + return inputStream +} + +@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class) +internal fun ByteArray.toNSData(): NSData = NSMutableData().apply { + if (isEmpty()) return@apply + this@toNSData.usePinned { + appendBytes(it.addressOf(0), size.convert()) + } +} + +@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class) +internal fun NSData.toByteArray(): ByteArray { + val result = ByteArray(length.toInt()) + if (result.isEmpty()) return result + + result.usePinned { + memcpy(it.addressOf(0), bytes, length) + } + + return result +} + +/** + * Executes the given block function on this resource and then releases it correctly whether an + * exception is thrown or not. + */ +@OptIn(ExperimentalForeignApi::class) +internal inline fun CPointer.use(block: (CPointer) -> R): R { + try { + return block(this) + } finally { + CFBridgingRelease(this) + } +} + +public class DarwinHttpRequestException(public val origin: NSError) : IOException("Exception in http request: $origin") diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/KtorNSURLSessionDelegate.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/KtorNSURLSessionDelegate.kt new file mode 100644 index 00000000..b963965b --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/KtorNSURLSessionDelegate.kt @@ -0,0 +1,155 @@ +/* + * 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.engine.darwin + +import io.ktor.client.engine.darwin.internal.* +import io.ktor.client.request.* +import io.ktor.util.collections.* +import kotlinx.cinterop.UnsafeNumber +import kotlinx.coroutines.CompletableDeferred +import platform.Foundation.* +import platform.darwin.NSObject +import kotlin.collections.set +import kotlin.coroutines.CoroutineContext + +private const val HTTP_REQUESTS_INITIAL_CAPACITY = 32 +private const val WS_REQUESTS_INITIAL_CAPACITY = 16 + +/** + * Creates an instance of [KtorNSURLSessionDelegate] + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.KtorNSURLSessionDelegate) + */ +@OptIn(UnsafeNumber::class) +public fun KtorNSURLSessionDelegate(): KtorNSURLSessionDelegate { + return KtorNSURLSessionDelegate(null) +} + +/** + * A delegate for [NSURLSession] that bridges it to Ktor. + * If users set custom session in [DarwinClientEngineConfig.sessionAndDelegate], + * they need to register this delegate in their session. + * This can be done by registering it directly, + * extending their custom delegate from it + * or by calling required methods from their custom delegate. + * + * For HTTP requests to work property, it's important that users call these functions: + * * URLSession:dataTask:didReceiveData: + * * URLSession:task:didCompleteWithError: + * * URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler: + * + * For WebSockets to work, it's important that users call these functions: + * * URLSession:webSocketTask:didOpenWithProtocol: + * * URLSession:webSocketTask:didCloseWithCode:reason: + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.KtorNSURLSessionDelegate) + */ +@OptIn(UnsafeNumber::class) +public class KtorNSURLSessionDelegate( + internal val challengeHandler: ChallengeHandler? +) : NSObject(), NSURLSessionDataDelegateProtocol, NSURLSessionWebSocketDelegateProtocol { + + private val taskHandlers: ConcurrentMap = + ConcurrentMap(HTTP_REQUESTS_INITIAL_CAPACITY) + + private val webSocketSessions: ConcurrentMap = + ConcurrentMap(WS_REQUESTS_INITIAL_CAPACITY) + + override fun URLSession(session: NSURLSession, dataTask: NSURLSessionDataTask, didReceiveData: NSData) { + val taskHandler = taskHandlers[dataTask] ?: return + taskHandler.receiveData(dataTask, didReceiveData) + } + + override fun URLSession(session: NSURLSession, taskIsWaitingForConnectivity: NSURLSessionTask) { + } + + override fun URLSession(session: NSURLSession, task: NSURLSessionTask, didCompleteWithError: NSError?) { + taskHandlers[task]?.let { + it.complete(task, didCompleteWithError) + taskHandlers.remove(task) + } + + webSocketSessions[task]?.let { + it.didComplete(didCompleteWithError) + } + } + + override fun URLSession( + session: NSURLSession, + webSocketTask: NSURLSessionWebSocketTask, + didOpenWithProtocol: String? + ) { + val wsSession = webSocketSessions[webSocketTask] ?: return + wsSession.didOpen(didOpenWithProtocol) + } + + override fun URLSession( + session: NSURLSession, + webSocketTask: NSURLSessionWebSocketTask, + didCloseWithCode: NSURLSessionWebSocketCloseCode, + reason: NSData? + ) { + val wsSession = webSocketSessions[webSocketTask] ?: return + wsSession.didClose(didCloseWithCode, reason, webSocketTask) + } + + internal fun read( + task: NSURLSessionWebSocketTask, + callContext: CoroutineContext + ): CompletableDeferred { + val taskHandler = DarwinWebsocketSession(callContext, task) + webSocketSessions[task] = taskHandler + return taskHandler.response + } + + internal fun read( + request: HttpRequestData, + callContext: CoroutineContext, + task: NSURLSessionTask + ): CompletableDeferred { + val taskHandler = DarwinTaskHandler(request, callContext) + taskHandlers[task] = taskHandler + return taskHandler.response + } + + /** + * Disable embedded redirects. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.KtorNSURLSessionDelegate.URLSession) + */ + override fun URLSession( + session: NSURLSession, + task: NSURLSessionTask, + willPerformHTTPRedirection: NSHTTPURLResponse, + newRequest: NSURLRequest, + completionHandler: (NSURLRequest?) -> Unit + ) { + completionHandler(null) + } + + /** + * Handle challenge. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.KtorNSURLSessionDelegate.URLSession) + */ + override fun URLSession( + session: NSURLSession, + task: NSURLSessionTask, + didReceiveChallenge: NSURLAuthenticationChallenge, + completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit + ) { + val handler = challengeHandler ?: run { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, didReceiveChallenge.proposedCredential) + return + } + + try { + handler(session, task, didReceiveChallenge, completionHandler) + } catch (cause: Throwable) { + taskHandlers[task]?.saveFailure(cause) + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null) + } + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/ProxySupportCommon.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/ProxySupportCommon.kt new file mode 100644 index 00000000..75fa0c3d --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/ProxySupportCommon.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin + +import io.ktor.http.* +import platform.Foundation.* + +private const val HTTP_ENABLE_KEY = "HTTPEnable" +private const val HTTP_PROXY_KEY = "HTTPProxy" +private const val HTTP_PORT_KEY = "HTTPPort" +private const val SOCKS_ENABLE_KEY = "SOCKSEnable" +private const val SOCKS_PROXY_KEY = "SOCKSProxy" +private const val SOCKS_PORT_KEY = "SOCKSPort" + +internal fun NSURLSessionConfiguration.setupProxy(config: DarwinClientEngineConfig) { + val proxy = config.proxy ?: return + val url = proxy.url + + when (url.protocol) { + URLProtocol.HTTP -> setupHttpProxy(url) + URLProtocol.HTTPS -> setupHttpProxy(url) + URLProtocol.SOCKS -> setupSocksProxy(url) + else -> error("Proxy type ${url.protocol.name} is unsupported by Darwin client engine.") + } +} + +private fun NSURLSessionConfiguration.setupHttpProxy(url: Url) { + connectionProxyDictionary = mapOf( + HTTP_ENABLE_KEY to 1, + HTTP_PROXY_KEY to url.host, + HTTP_PORT_KEY to url.port + ) +} + +private fun NSURLSessionConfiguration.setupSocksProxy(url: Url) { + connectionProxyDictionary = mapOf( + SOCKS_ENABLE_KEY to 1, + SOCKS_PROXY_KEY to url.host, + SOCKS_PORT_KEY to url.port + ) +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/TimeoutUtils.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/TimeoutUtils.kt new file mode 100644 index 00000000..6060de5f --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/TimeoutUtils.kt @@ -0,0 +1,31 @@ +/* +* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. +*/ + +package io.ktor.client.engine.darwin + +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import kotlinx.cinterop.* +import platform.Foundation.* + +/** + * Update [NSMutableURLRequest] and setup timeout interval that equal to socket interval specified by [HttpTimeout]. + */ +internal fun NSMutableURLRequest.setupSocketTimeout(requestData: HttpRequestData) { + // Darwin timeout works like a socket timeout. + requestData.getCapabilityOrNull(HttpTimeoutCapability)?.socketTimeoutMillis?.let { + if (it != HttpTimeoutConfig.INFINITE_TIMEOUT_MS) { + // Timeout should be specified in seconds. + setTimeoutInterval(it / 1000.0) + } else { + setTimeoutInterval(Double.MAX_VALUE) + } + } +} + +@OptIn(UnsafeNumber::class) +internal fun handleNSError(requestData: HttpRequestData, error: NSError): Throwable = when (error.code) { + NSURLErrorTimedOut -> SocketTimeoutException(requestData) + else -> DarwinHttpRequestException(error) +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatePinner.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatePinner.kt new file mode 100644 index 00000000..79993c80 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatePinner.kt @@ -0,0 +1,442 @@ +/* + * 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.engine.darwin.certificates + +import io.ktor.client.engine.darwin.* +import io.ktor.network.tls.* +import io.ktor.util.logging.* +import kotlinx.cinterop.* +import platform.CoreCrypto.CC_SHA1 +import platform.CoreCrypto.CC_SHA1_DIGEST_LENGTH +import platform.CoreCrypto.CC_SHA256 +import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH +import platform.CoreFoundation.CFDictionaryGetValue +import platform.CoreFoundation.CFStringCreateWithCString +import platform.CoreFoundation.kCFStringEncodingUTF8 +import platform.Foundation.* +import platform.Security.* + +private val LOG = KtorSimpleLogger("io.ktor.client.engine.darwin.certificates.CertificatePinner") + +/** + * Constrains which certificates are trusted. Pinning certificates defends against attacks on + * certificate authorities. It also prevents connections through man-in-the-middle certificate + * authorities either known or unknown to the application's user. + * This class currently pins a certificate's Subject Public Key Info as described on + * [Adam Langley's Weblog](http://goo.gl/AIx3e5). Pins are either base64 SHA-256 hashes as in + * [HTTP Public Key Pinning (HPKP)](http://tools.ietf.org/html/rfc7469) or SHA-1 base64 hashes as + * in Chromium's [static certificates](http://goo.gl/XDh6je). + * + * ## Setting up Certificate Pinning + * + * The easiest way to pin a host is to turn on pinning with a broken configuration and read the + * expected configuration when the connection fails. Be sure to do this on a trusted network, and + * without man-in-the-middle tools like [Charles](http://charlesproxy.com) or + * [Fiddler](http://fiddlertool.com). + * + * For example, to pin `https://publicobject.com`, start with a broken configuration: + * + * ``` + * HttpClient(Darwin) { + * + * // ... + * + * engine { + * val builder = CertificatePinner.Builder() + * .add("publicobject.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + * handleChallenge(builder.build()) + * } + * } + * ``` + * + * As expected, this fails with an exception, see the logs: + * + * ``` + * HttpClient: Certificate pinning failure! + * Peer certificate chain: + * sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=: publicobject.com + * sha256/klO23nT2ehFDXCfx3eHTDRESMz3asj1muO+4aIdjiuY=: COMODO RSA Secure Server CA + * sha256/grX4Ta9HpZx6tSHkmCrvpApTQGo67CYDnvprLg5yRME=: COMODO RSA Certification Authority + * sha256/lCppFqbkrlJ3EcVFAkeip0+44VaoJUymbnOaEUk7tEU=: AddTrust External CA Root + * Pinned certificates for publicobject.com: + * sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + * ``` + * + * Follow up by pasting the public key hashes from the logs into the + * certificate pinner's configuration: + * + * ``` + * val builder = CertificatePinner.Builder() + * .add("publicobject.com", "sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=") + * .add("publicobject.com", "sha256/klO23nT2ehFDXCfx3eHTDRESMz3asj1muO+4aIdjiuY=") + * .add("publicobject.com", "sha256/grX4Ta9HpZx6tSHkmCrvpApTQGo67CYDnvprLg5yRME=") + * .add("publicobject.com", "sha256/lCppFqbkrlJ3EcVFAkeip0+44VaoJUymbnOaEUk7tEU=") + * handleChallenge(builder.build()) + * ``` + * + * ## Domain Patterns + * + * Pinning is per-hostname and/or per-wildcard pattern. To pin both `publicobject.com` and + * `www.publicobject.com` you must configure both hostnames. Or you may use patterns to match + * sets of related domain names. The following forms are permitted: + * + * * **Full domain name**: you may pin an exact domain name like `www.publicobject.com`. It won't + * match additional prefixes (`us-west.www.publicobject.com`) or suffixes (`publicobject.com`). + * + * * **Any number of subdomains**: Use two asterisks to like `**.publicobject.com` to match any + * number of prefixes (`us-west.www.publicobject.com`, `www.publicobject.com`) including no + * prefix at all (`publicobject.com`). For most applications this is the best way to configure + * certificate pinning. + * + * * **Exactly one subdomain**: Use a single asterisk like `*.publicobject.com` to match exactly + * one prefix (`www.publicobject.com`, `api.publicobject.com`). Be careful with this approach as + * no pinning will be enforced if additional prefixes are present, or if no prefixes are present. + * + * Note that any other form is unsupported. You may not use asterisks in any position other than + * the leftmost label. + * + * If multiple patterns match a hostname, any match is sufficient. For example, suppose pin A + * applies to `*.publicobject.com` and pin B applies to `api.publicobject.com`. Handshakes for + * `api.publicobject.com` are valid if either A's or B's certificate is in the chain. + * + * ## Warning: Certificate Pinning is Dangerous! + * + * Pinning certificates limits your server team's abilities to update their TLS certificates. By + * pinning certificates, you take on additional operational complexity and limit your ability to + * migrate between certificate authorities. Do not use certificate pinning without the blessing of + * your server's TLS administrator! + * + * See also [OWASP: Certificate and Public Key Pinning](https://www.owasp.org/index + * .php/Certificate_and_Public_Key_Pinning). + * + * This class was heavily inspired by OkHttp, which is a great Http library for Android + * https://square.github.io/okhttp/4.x/okhttp/okhttp3/-certificate-pinner/ + * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/CertificatePinner.kt + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.CertificatePinner) + */ +@OptIn(UnsafeNumber::class) +public data class CertificatePinner( + private val pinnedCertificates: Set, + private val validateTrust: Boolean +) : ChallengeHandler { + + override fun invoke( + session: NSURLSession, + task: NSURLSessionTask, + challenge: NSURLAuthenticationChallenge, + completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit + ) { + if (applyPinning(challenge)) { + completionHandler(NSURLSessionAuthChallengeUseCredential, challenge.proposedCredential) + } else { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null) + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun applyPinning(challenge: NSURLAuthenticationChallenge): Boolean { + val hostname = challenge.protectionSpace.host + val matchingPins = findMatchingPins(hostname) + + if (matchingPins.isEmpty()) { + LOG.trace { "No pins found for host" } + return false + } + + if (challenge.protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust) { + LOG.trace { "Authentication method not suitable for pinning" } + return false + } + + val trust = challenge.protectionSpace.serverTrust + ?: throw TlsPeerUnverifiedException("Server trust is not available") + + if (validateTrust) { + val hostCFString = CFStringCreateWithCString(null, hostname, kCFStringEncodingUTF8) + hostCFString?.use { + SecPolicyCreateSSL(true, hostCFString)?.use { policy -> + SecTrustSetPolicies(trust, policy) + } + } + if (!trust.trustIsValid()) throw TlsPeerUnverifiedException("Server trust is invalid") + } + + val certCount = SecTrustGetCertificateCount(trust) + val certificates = (0 until certCount).mapNotNull { index -> + SecTrustGetCertificateAtIndex(trust, index) + } + + if (certificates.size != certCount.toInt()) { + throw TlsPeerUnverifiedException("Unknown certificates") + } + + if (!hasOnePinnedCertificate(certificates)) { + val message = buildErrorMessage(certificates, hostname) + throw TlsPeerUnverifiedException(message) + } + return true + } + + /** + * Confirms that at least one of the certificates is pinned + */ + @OptIn(ExperimentalForeignApi::class) + private fun hasOnePinnedCertificate( + certificates: List + ): Boolean = certificates.any { certificate -> + val publicKey = certificate.getPublicKeyBytes() ?: return@any false + // Lazily compute the hashes for each public key. + var sha1: String? = null + var sha256: String? = null + + pinnedCertificates.any { pin -> + when (pin.hashAlgorithm) { + CertificatesInfo.HASH_ALGORITHM_SHA_256 -> { + if (sha256 == null) { + sha256 = publicKey.toSha256String() + } + + pin.hash == sha256 + } + + CertificatesInfo.HASH_ALGORITHM_SHA_1 -> { + if (sha1 == null) { + sha1 = publicKey.toSha1String() + } + + pin.hash == sha1 + } + + else -> throw IllegalArgumentException("Unsupported hashAlgorithm: ${pin.hashAlgorithm}") + } + } + } + + /** + * Build an error string to display + */ + @OptIn(ExperimentalForeignApi::class) + private fun buildErrorMessage( + certificates: List, + hostname: String + ): String = buildString { + append("Certificate pinning failure!") + append("\n Peer certificate chain:") + for (certificate in certificates) { + append("\n ") + val publicKeyStr = certificate.getPublicKeyBytes()?.toSha256String() + append("${CertificatesInfo.HASH_ALGORITHM_SHA_256}$publicKeyStr") + append(": ") + val summaryRef = SecCertificateCopySubjectSummary(certificate) + val summary = CFBridgingRelease(summaryRef) as NSString + append("$summary") + } + append("\n Pinned certificates for ") + append(hostname) + append(":") + for (pin in pinnedCertificates) { + append("\n ") + append(pin) + } + } + + /** + * Returns list of matching certificates' pins for the hostname. Returns an empty list if the + * hostname does not have pinned certificates. + */ + private fun findMatchingPins(hostname: String): List { + return pinnedCertificates.filter { it.matches(hostname) } + } + + /** + * Evaluates trust for the specified certificate and policies. + */ + @OptIn(ExperimentalForeignApi::class) + private fun SecTrustRef.trustIsValid(): Boolean { + var isValid = false + + val version = cValue { + majorVersion = 12 + minorVersion = 0 + patchVersion = 0 + } + if (NSProcessInfo().isOperatingSystemAtLeastVersion(version)) { + // https://developer.apple.com/documentation/security/2980705-sectrustevaluatewitherror + isValid = SecTrustEvaluateWithError(this, null) + } else { + // https://developer.apple.com/documentation/security/1394363-sectrustevaluate + memScoped { + val result = alloc() + result.value = kSecTrustResultInvalid + val status = SecTrustEvaluate(this@trustIsValid, result.ptr) + if (status == errSecSuccess) { + isValid = result.value == kSecTrustResultUnspecified || + result.value == kSecTrustResultProceed + } + } + } + + return isValid + } + + /** + * Gets the public key from the SecCertificate + */ + @OptIn(ExperimentalForeignApi::class) + private fun SecCertificateRef.getPublicKeyBytes(): ByteArray? { + val publicKeyRef = SecCertificateCopyKey(this) ?: return null + + return publicKeyRef.use { + val publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) + val publicKeyTypePointer = CFDictionaryGetValue(publicKeyAttributes, kSecAttrKeyType) + val publicKeyType = CFBridgingRelease(publicKeyTypePointer) as NSString + val publicKeySizePointer = CFDictionaryGetValue(publicKeyAttributes, kSecAttrKeySizeInBits) + val publicKeySize = CFBridgingRelease(publicKeySizePointer) as NSNumber + + CFBridgingRelease(publicKeyAttributes) + + if (!checkValidKeyType(publicKeyType, publicKeySize)) { + LOG.trace { "Public Key not supported type or size" } + return null + } + + val publicKeyDataRef = SecKeyCopyExternalRepresentation(publicKeyRef, null) + val publicKeyData = CFBridgingRelease(publicKeyDataRef) as NSData + val publicKeyBytes = publicKeyData.toByteArray() + val headerInts = getAsn1HeaderBytes(publicKeyType, publicKeySize) + + val header = headerInts.foldIndexed(ByteArray(headerInts.size)) { i, a, v -> + a[i] = v.toByte() + a + } + header + publicKeyBytes + } + } + + /** + * Checks that we support the key type and size + */ + @OptIn(ExperimentalForeignApi::class) + private fun checkValidKeyType(publicKeyType: NSString, publicKeySize: NSNumber): Boolean { + val keyTypeRSA = CFBridgingRelease(kSecAttrKeyTypeRSA) as NSString + val keyTypeECSECPrimeRandom = CFBridgingRelease(kSecAttrKeyTypeECSECPrimeRandom) as NSString + + val size: Int = publicKeySize.intValue.toInt() + val keys = when (publicKeyType) { + keyTypeRSA -> CertificatesInfo.rsa + keyTypeECSECPrimeRandom -> CertificatesInfo.ecdsa + else -> return false + } + + return keys.containsKey(size) + } + + /** + * Get the [IntArray] of Asn1 headers needed to prepend to the public key to create the + * encoding [ASN1Header](https://docs.oracle.com/middleware/11119/opss/SCRPJ/oracle/security/crypto/asn1/ASN1Header.html) + */ + @OptIn(ExperimentalForeignApi::class) + private fun getAsn1HeaderBytes(publicKeyType: NSString, publicKeySize: NSNumber): IntArray { + val keyTypeRSA = CFBridgingRelease(kSecAttrKeyTypeRSA) as NSString + val keyTypeECSECPrimeRandom = CFBridgingRelease(kSecAttrKeyTypeECSECPrimeRandom) as NSString + + val size: Int = publicKeySize.intValue.toInt() + val keys = when (publicKeyType) { + keyTypeRSA -> CertificatesInfo.rsa + keyTypeECSECPrimeRandom -> CertificatesInfo.ecdsa + else -> return intArrayOf() + } + + return keys[size] ?: intArrayOf() + } + + /** + * Converts a [ByteArray] into sha256 base 64 encoded string + */ + @OptIn(ExperimentalUnsignedTypes::class, ExperimentalForeignApi::class) + private fun ByteArray.toSha256String(): String { + val digest = UByteArray(CC_SHA256_DIGEST_LENGTH) + + usePinned { inputPinned -> + digest.usePinned { digestPinned -> + CC_SHA256(inputPinned.addressOf(0), this.size.convert(), digestPinned.addressOf(0)) + } + } + + return digest.toByteArray().toNSData().base64EncodedStringWithOptions(0u) + } + + /** + * Converts a [ByteArray] into sha1 base 64 encoded string + */ + @OptIn(ExperimentalUnsignedTypes::class, ExperimentalForeignApi::class) + private fun ByteArray.toSha1String(): String { + val digest = UByteArray(CC_SHA1_DIGEST_LENGTH) + + usePinned { inputPinned -> + digest.usePinned { digestPinned -> + CC_SHA1(inputPinned.addressOf(0), this.size.convert(), digestPinned.addressOf(0)) + } + } + return digest.toByteArray().toNSData().base64EncodedStringWithOptions(0u) + } + + /** + * Builds a configured [CertificatePinner]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.CertificatePinner.Builder) + */ + public data class Builder( + private val pinnedCertificates: MutableList = mutableListOf(), + private var validateTrust: Boolean = true + ) { + /** + * Pins certificates for `pattern`. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.CertificatePinner.Builder.add) + * + * @param pattern lower-case host name or wildcard pattern such as `*.example.com`. + * @param pins SHA-256 or SHA-1 hashes. Each pin is a hash of a certificate's + * Subject Public Key Info, base64-encoded and prefixed with either `sha256/` or `sha1/`. + * @return The [Builder] so calls can be chained + */ + public fun add(pattern: String, vararg pins: String): Builder = apply { + pins.forEach { pin -> + this.pinnedCertificates.add( + PinnedCertificate.new( + pattern, + pin + ) + ) + } + } + + /** + * Whether to valid the trust of the server + * https://developer.apple.com/documentation/security/2980705-sectrustevaluatewitherror + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.CertificatePinner.Builder.validateTrust) + * + * @param validateTrust + * @return The [Builder] so calls can be chained + */ + public fun validateTrust(validateTrust: Boolean): Builder = apply { + this.validateTrust = validateTrust + } + + /** + * Build into a [CertificatePinner] + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.CertificatePinner.Builder.build) + * + * @return [CertificatePinner] + */ + public fun build(): CertificatePinner = CertificatePinner( + pinnedCertificates.toSet(), + validateTrust + ) + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatesInfo.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatesInfo.kt new file mode 100644 index 00000000..66083260 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/CertificatesInfo.kt @@ -0,0 +1,60 @@ +/* +* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. +*/ + +package io.ktor.client.engine.darwin.certificates + +private val rsa1024Asn1Header: IntArray = intArrayOf( + 0x30, 0x81, 0x9F, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, + 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x81, 0x8D, 0x00 +) + +private val rsa2048Asn1Header: IntArray = intArrayOf( + 0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00 +) + +private val rsa3072Asn1Header: IntArray = intArrayOf( + 0x30, 0x82, 0x01, 0xA2, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x8F, 0x00 +) + +private val rsa4096Asn1Header: IntArray = intArrayOf( + 0x30, 0x82, 0x02, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x02, 0x0F, 0x00 +) + +private val ecdsaSecp256r1Asn1Header: IntArray = intArrayOf( + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, + 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, + 0x42, 0x00 +) + +private val ecdsaSecp384r1Asn1Header: IntArray = intArrayOf( + 0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, + 0x01, 0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00 +) + +/** + * Certificate headers + * + * Sources for values: + * https://github.com/datatheorem/TrustKit/blob/master/TrustKit/Pinning/TSKSPKIHashCache.m + * https://github.com/IBM-Swift/BlueRSA/blob/master/Sources/CryptorRSA/CryptorRSAUtilities.swift + */ +internal object CertificatesInfo { + val rsa = mapOf( + 1024 to rsa1024Asn1Header, + 2048 to rsa2048Asn1Header, + 3072 to rsa3072Asn1Header, + 4096 to rsa4096Asn1Header + ) + + val ecdsa = mapOf( + 256 to ecdsaSecp256r1Asn1Header, + 384 to ecdsaSecp384r1Asn1Header + ) + + internal const val HASH_ALGORITHM_SHA_256 = "sha256/" + internal const val HASH_ALGORITHM_SHA_1 = "sha1/" +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/PinnedCertificate.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/PinnedCertificate.kt new file mode 100644 index 00000000..5e7714b6 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/certificates/PinnedCertificate.kt @@ -0,0 +1,112 @@ +/* +* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. +*/ + +package io.ktor.client.engine.darwin.certificates + +import io.ktor.client.engine.darwin.certificates.CertificatePinner.* +import io.ktor.client.engine.darwin.certificates.CertificatesInfo.HASH_ALGORITHM_SHA_1 +import io.ktor.client.engine.darwin.certificates.CertificatesInfo.HASH_ALGORITHM_SHA_256 + +/** + * Represents a pinned certificate. Recommended using [Builder.add] to construct + * [CertificatePinner] + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.PinnedCertificate) + */ +public data class PinnedCertificate( + /** + * A hostname like `example.com` or a pattern like `*.example.com` (canonical form). + */ + private val pattern: String, + /** + * Either `sha1/` or `sha256/`. + */ + val hashAlgorithm: String, + /** + * The hash of the pinned certificate using [hashAlgorithm]. + */ + val hash: String +) { + /** + * Checks whether the given [hostname] matches the [pattern] of this [PinnedCertificate] + * @param hostname The hostname to check + * @return Boolean TRUE if it matches + */ + internal fun matches(hostname: String): Boolean = when { + pattern.startsWith("**.") -> { + // With ** empty prefixes match so exclude the dot from regionMatches(). + val suffixLength = pattern.length - 3 + val prefixLength = hostname.length - suffixLength + hostname.regionMatches( + thisOffset = hostname.length - suffixLength, + other = pattern, + otherOffset = 3, + length = suffixLength + ) && + (prefixLength == 0 || hostname[prefixLength - 1] == '.') + } + + pattern.startsWith("*.") -> { + // With * there must be a prefix so include the dot in regionMatches(). + val suffixLength = pattern.length - 1 + val prefixLength = hostname.length - suffixLength + hostname.regionMatches( + thisOffset = hostname.length - suffixLength, + other = pattern, + otherOffset = 1, + length = suffixLength + ) && + hostname.lastIndexOf('.', prefixLength - 1) == -1 + } + + else -> hostname == pattern + } + + override fun toString(): String = hashAlgorithm + hash + + public companion object { + /** + * Create a new Pin + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.PinnedCertificate.Companion.new) + * + * @param pattern The hostname pattern + * @param pin The hash to pin + * @return [PinnedCertificate] The new pin + */ + public fun new(pattern: String, pin: String): PinnedCertificate { + require( + (pattern.startsWith("*.") && pattern.indexOf("*", 1) == -1) || + (pattern.startsWith("**.") && pattern.indexOf("*", 2) == -1) || + pattern.indexOf("*") == -1 + ) { + "Unexpected pattern: $pattern" + } + val canonicalPattern = pattern.lowercase() + return when { + pin.startsWith(HASH_ALGORITHM_SHA_1) -> { + val hash = pin.substring(HASH_ALGORITHM_SHA_1.length) + PinnedCertificate( + pattern = canonicalPattern, + hashAlgorithm = HASH_ALGORITHM_SHA_1, + hash = hash + ) + } + + pin.startsWith(HASH_ALGORITHM_SHA_256) -> { + val hash = pin.substring(HASH_ALGORITHM_SHA_256.length) + PinnedCertificate( + pattern = canonicalPattern, + hashAlgorithm = HASH_ALGORITHM_SHA_256, + hash = hash + ) + } + + else -> throw IllegalArgumentException( + "Pins must start with '$HASH_ALGORITHM_SHA_256' or '$HASH_ALGORITHM_SHA_1': $pin" + ) + } + } + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinRequestUtils.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinRequestUtils.kt new file mode 100644 index 00000000..c7cd8b0b --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinRequestUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2014-2022 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin.internal + +import io.ktor.client.engine.darwin.* +import io.ktor.client.request.* +import io.ktor.utils.io.* +import kotlinx.cinterop.UnsafeNumber +import platform.Foundation.* + +@OptIn(InternalAPI::class, UnsafeNumber::class) +internal suspend fun HttpRequestData.toNSUrlRequest(): NSMutableURLRequest { + val url = url.toNSUrl() + val nativeRequest = NSMutableURLRequest.requestWithURL(url).apply { + setupSocketTimeout(this@toNSUrlRequest) + body.toDataOrStream()?.let { + if (it is NSInputStream) { + setHTTPBodyStream(it) + } else if (it is NSData) { + setHTTPBody(it) + } + } + + forEachHeader { key, value -> setValue(value, key) } + + setCachePolicy(NSURLRequestReloadIgnoringCacheData) + setHTTPMethod(method.value) + } + + return nativeRequest +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinResponseUtils.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinResponseUtils.kt new file mode 100644 index 00000000..90f246d6 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinResponseUtils.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2014-2022 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin.internal + +import io.ktor.client.utils.* +import io.ktor.http.* +import io.ktor.util.Attributes +import io.ktor.utils.io.InternalAPI +import platform.Foundation.* + +@OptIn(InternalAPI::class) +internal fun NSHTTPURLResponse.readHeaders(method: HttpMethod, attributes: Attributes): Headers = buildHeaders { + allHeaderFields.mapKeys { (key, value) -> append(key as String, value as String) } + + dropCompressionHeaders(method, attributes) +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinSession.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinSession.kt new file mode 100644 index 00000000..ff816b0c --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinSession.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(io.ktor.utils.io.InternalAPI::class) + +package io.ktor.client.engine.darwin.internal + +import io.ktor.client.engine.darwin.* +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.* +import io.ktor.utils.io.* +import io.ktor.utils.io.core.* +import kotlinx.atomicfu.* +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import platform.Foundation.* +import kotlin.coroutines.* + +@OptIn(UnsafeNumber::class) +internal class DarwinSession( + private val config: DarwinClientEngineConfig, + requestQueue: NSOperationQueue? +) : Closeable { + private val closed = atomic(false) + + private val sessionAndDelegate = config.sessionAndDelegate ?: createSession(config, requestQueue) + private val session = sessionAndDelegate.first + private val delegate = sessionAndDelegate.second + + @OptIn(InternalAPI::class, ExperimentalForeignApi::class) + internal suspend fun execute(request: HttpRequestData, callContext: CoroutineContext): HttpResponseData { + val nativeRequest = request.toNSUrlRequest() + .apply(config.requestConfig) + val (task, response) = if (request.isUpgradeRequest()) { + val task = session.webSocketTaskWithRequest(nativeRequest) + val response = delegate.read(task, callContext) + // Get maxFrameSize from WebSockets plugin config, or use default + val maxFrameSize = request.attributes.getOrNull(WebSockets.key)?.maxFrameSize ?: Long.MAX_VALUE + // Fields MUST be assigned on the task BEFORE starting it. + // The "maximum message size" actually refers to the underlying buffer, + // so it will allow >= maxFrameSize, depending on how quickly our bytes are read to the buffer. + task.setMaximumMessageSize(maxFrameSize.convert()) + + task to response + } else { + val task = session.dataTaskWithRequest(nativeRequest) + val response = delegate.read(request, callContext, task) + task to response + } + + callContext.job.invokeOnCompletion { cause -> + if (cause != null) { + task.cancel() + } + } + + task.resume() + + try { + return response.await() + } catch (cause: Throwable) { + if (task.state == NSURLSessionTaskStateRunning) task.cancel() + throw cause + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + session.finishTasksAndInvalidate() + } +} + +@OptIn(UnsafeNumber::class) +internal fun createSession( + config: DarwinClientEngineConfig, + requestQueue: NSOperationQueue? +): Pair { + val configuration = NSURLSessionConfiguration.defaultSessionConfiguration().apply { + setupProxy(config) + setHTTPCookieStorage(null) + + config.sessionConfig(this) + } + val delegate = KtorNSURLSessionDelegate(config.challengeHandler) + + return NSURLSession.sessionWithConfiguration( + configuration, + delegate, + delegateQueue = requestQueue + ) to delegate +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt new file mode 100644 index 00000000..8189e87f --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin.internal + +import io.ktor.client.engine.darwin.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.util.date.* +import io.ktor.utils.io.* +import io.ktor.utils.io.CancellationException +import kotlin.coroutines.* +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import platform.Foundation.* + +@OptIn(DelicateCoroutinesApi::class) +internal class DarwinTaskHandler( + private val requestData: HttpRequestData, + private val callContext: CoroutineContext, +) { + val response: CompletableDeferred = CompletableDeferred() + + private val requestTime: GMTDate = GMTDate() + private val bodyChunks = Channel(capacity = 64) + + private var pendingFailure: Throwable? = null + get() = field?.also { field = null } + + private val body: ByteReadChannel = + GlobalScope.writer(callContext, autoFlush = true) { + try { + bodyChunks.consumeEach { nsData -> + val bytes = nsData.toByteArray() + channel.writeFully(bytes) + } + } catch (cause: CancellationException) { + bodyChunks.cancel(cause) + throw cause + } + } + .channel + + fun receiveData(dataTask: NSURLSessionDataTask, data: NSData) { + if (!response.isCompleted) { + val result = dataTask.response as NSHTTPURLResponse + response.complete(result.toResponseData(requestData)) + } + + val result = bodyChunks.trySend(data) + when { + result.isClosed -> dataTask.cancel() + result.isFailure -> { + // Buffer full, block to apply backpressure + try { + runBlocking { bodyChunks.send(data) } + } catch (_: CancellationException) { + dataTask.cancel() + } catch (_: ClosedSendChannelException) { + dataTask.cancel() + } + } + } + } + + fun saveFailure(cause: Throwable) { + pendingFailure = cause + } + + fun complete(task: NSURLSessionTask, didCompleteWithError: NSError?) { + if (didCompleteWithError != null) { + val exception = pendingFailure ?: handleNSError(requestData, didCompleteWithError) + bodyChunks.close(exception) + response.completeExceptionally(exception) + return + } + + if (!response.isCompleted) { + val result = task.response as NSHTTPURLResponse + response.complete(result.toResponseData(requestData)) + } + + bodyChunks.close() + } + + @OptIn(UnsafeNumber::class, ExperimentalForeignApi::class, InternalAPI::class) + fun NSHTTPURLResponse.toResponseData(requestData: HttpRequestData): HttpResponseData { + val status = HttpStatusCode.fromValue(statusCode.convert()) + val headers = readHeaders(requestData.method, requestData.attributes) + val responseBody: Any = + requestData + .attributes + .getOrNull(ResponseAdapterAttributeKey) + ?.adapt(requestData, status, headers, body, requestData.body, callContext) + ?: body + + return HttpResponseData( + status, + requestTime, + headers, + HttpProtocolVersion.HTTP_1_1, + responseBody, + callContext + ) + } +} diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinUrlUtils.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinUrlUtils.kt new file mode 100644 index 00000000..82bdacf0 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinUrlUtils.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2014-2022 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.darwin.internal + +import io.ktor.http.* +import io.ktor.util.* +import platform.Foundation.* + +internal fun Url.toNSUrl(): NSURL { + val userEncoded = encodedUser.orEmpty().isEncoded(NSCharacterSet.URLUserAllowedCharacterSet) + val passwordEncoded = encodedPassword.orEmpty().isEncoded(NSCharacterSet.URLUserAllowedCharacterSet) + val hostEncoded = host.isEncoded(NSCharacterSet.URLHostAllowedCharacterSet) + val pathEncoded = encodedPath.isEncoded(NSCharacterSet.URLPathAllowedCharacterSet) + val queryEncoded = encodedQuery.isEncoded(NSCharacterSet.URLQueryAllowedCharacterSet) + val fragmentEncoded = encodedFragment.isEncoded(NSCharacterSet.URLFragmentAllowedCharacterSet) + if (userEncoded && passwordEncoded && hostEncoded && pathEncoded && queryEncoded && fragmentEncoded) { + return NSURL(string = toString()) + } + + val components = NSURLComponents() + + components.scheme = protocol.name + + components.percentEncodedUser = when { + userEncoded -> encodedUser + else -> user?.sanitize(NSCharacterSet.URLUserAllowedCharacterSet) + } + components.percentEncodedPassword = when { + passwordEncoded -> encodedPassword + else -> password?.sanitize(NSCharacterSet.URLUserAllowedCharacterSet) + } + + components.percentEncodedHost = when { + hostEncoded -> host + else -> host.sanitize(NSCharacterSet.URLHostAllowedCharacterSet) + } + if (port != DEFAULT_PORT && port != protocol.defaultPort) { + components.port = NSNumber(int = port) + } + + components.percentEncodedPath = when { + pathEncoded -> encodedPath + else -> rawSegments.joinToString("/").sanitize(NSCharacterSet.URLPathAllowedCharacterSet) + } + + when { + encodedQuery.isEmpty() -> components.percentEncodedQuery = null + queryEncoded -> components.percentEncodedQuery = encodedQuery + else -> components.percentEncodedQueryItems = parameters.toMap() + .flatMap { (key, value) -> if (value.isEmpty()) listOf(key to null) else value.map { key to it } } + .map { NSURLQueryItem(it.first.encodeQueryKey(), it.second?.encodeQueryValue()) } + } + + components.percentEncodedFragment = when { + encodedFragment.isEmpty() -> null + fragmentEncoded -> encodedFragment + else -> fragment.sanitize(NSCharacterSet.URLFragmentAllowedCharacterSet) + } + + return components.URL ?: error("Invalid url: $this") +} + +private fun String.sanitize(allowed: NSCharacterSet): String = + asNSString().stringByAddingPercentEncodingWithAllowedCharacters(allowed)!! + +private fun String.encodeQueryKey(): String = + encodeQueryValue().replace("=", "%3D") + +private fun String.encodeQueryValue(): String = + asNSString().stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLQueryAllowedCharacterSet)!! + .replace("&", "%26") + .replace(";", "%3B") + +private fun String.isEncoded(allowed: NSCharacterSet) = + all { it == '%' || allowed.characterIsMember(it.code.toUShort()) } + +@Suppress("CAST_NEVER_SUCCEEDS") +private fun String.asNSString(): NSString = this as NSString diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinWebsocketSession.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinWebsocketSession.kt new file mode 100644 index 00000000..0e187e1c --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinWebsocketSession.kt @@ -0,0 +1,242 @@ +/* + * 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.engine.darwin.internal + +import io.ktor.client.engine.darwin.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.util.date.* +import io.ktor.utils.io.core.* +import io.ktor.websocket.* +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UnsafeNumber +import kotlinx.cinterop.convert +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.io.readByteArray +import platform.Foundation.* +import platform.darwin.NSInteger +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class) +internal class DarwinWebsocketSession( + callContext: CoroutineContext, + private val task: NSURLSessionWebSocketTask, +) : WebSocketSession { + + private val requestTime: GMTDate = GMTDate() + val response = CompletableDeferred() + + private val _incoming = Channel(Channel.UNLIMITED) + private val _outgoing = Channel(Channel.UNLIMITED) + private val socketJob = Job(callContext[Job]) + override val coroutineContext: CoroutineContext = callContext + socketJob + + override var masking: Boolean + get() = true + set(_) {} + + @OptIn(ExperimentalForeignApi::class) + override var maxFrameSize: Long + get() = task.maximumMessageSize.convert() + set(value) { + task.setMaximumMessageSize(value.convert()) + } + + override val incoming: ReceiveChannel + get() = _incoming + + override val outgoing: SendChannel + get() = _outgoing + + override val extensions: List> + get() = emptyList() + + init { + launch { + receiveMessages() + } + launch { + sendMessages() + } + coroutineContext[Job]!!.invokeOnCompletion { cause -> + if (cause != null) { + val code = CloseReason.Codes.INTERNAL_ERROR.code.convert() + task.cancelWithCloseCode(code, "Client failed".toByteArray().toNSData()) + } + _incoming.close() + _outgoing.cancel() + } + } + + private suspend fun receiveMessages() { + while (true) { + val message = task.receiveMessage() + val frame = when (message.type) { + NSURLSessionWebSocketMessageTypeData -> + Frame.Binary(true, message.data()!!.toByteArray()) + + NSURLSessionWebSocketMessageTypeString -> + Frame.Text(true, message.string()!!.toByteArray()) + + else -> throw IllegalArgumentException("Unknown message $message") + } + _incoming.send(frame) + } + } + + private suspend fun sendMessages() { + _outgoing.consumeEach { frame -> + when (frame.frameType) { + FrameType.TEXT -> { + suspendCancellableCoroutine { continuation -> + task.sendMessage( + NSURLSessionWebSocketMessage( + frame.data.decodeToString( + 0, + 0 + frame.data.size + ) + ) + ) { error -> + if (error == null) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(DarwinHttpRequestException(error)) + } + } + } + } + + FrameType.BINARY -> { + suspendCancellableCoroutine { continuation -> + task.sendMessage(NSURLSessionWebSocketMessage(frame.data.toNSData())) { error -> + if (error == null) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(DarwinHttpRequestException(error)) + } + } + } + } + + FrameType.CLOSE -> { + val data = buildPacket { writeFully(frame.data) } + val code = data.readShort().convert() + val reason = data.readByteArray() + task.cancelWithCloseCode(code, reason.toNSData()) + return@sendMessages + } + + FrameType.PING -> { + val payload = frame.readBytes() + task.sendPingWithPongReceiveHandler { error -> + if (error != null) { + cancel("Error receiving pong", DarwinHttpRequestException(error)) + return@sendPingWithPongReceiveHandler + } + _incoming.trySend(Frame.Pong(payload)) + } + } + + else -> { + throw IllegalArgumentException("Unknown frame type: $frame") + } + } + } + } + + override suspend fun flush() {} + + @Deprecated( + "Use cancel() instead.", + ReplaceWith("cancel()", "kotlinx.coroutines.cancel"), + DeprecationLevel.ERROR + ) + override fun terminate() { + task.cancelWithCloseCode(CloseReason.Codes.NORMAL.code.convert(), null) + coroutineContext.cancel() + } + + fun didOpen(protocol: String?) { + val headers = if (protocol != null) headersOf(HttpHeaders.SecWebSocketProtocol, protocol) else Headers.Empty + + val response = HttpResponseData( + task.getStatusCode()?.let { HttpStatusCode.fromValue(it) } ?: HttpStatusCode.SwitchingProtocols, + requestTime, + headers, + HttpProtocolVersion.HTTP_1_1, + this, + coroutineContext + ) + this.response.complete(response) + } + + fun didComplete(error: NSError?) { + if (error == null) { + socketJob.cancel() + return + } + + // KTOR-7363 We want to proceed with the request if we get 401 Unauthorized status code + if (task.getStatusCode() == HttpStatusCode.Unauthorized.value) { + didOpen(protocol = null) + socketJob.complete() + return + } + + val exception = DarwinHttpRequestException(error) + response.completeExceptionally(exception) + socketJob.completeExceptionally(exception) + } + + @OptIn(DelicateCoroutinesApi::class) + fun didClose( + code: NSURLSessionWebSocketCloseCode, + reason: NSData?, + webSocketTask: NSURLSessionWebSocketTask + ) { + val closeReason = + CloseReason(code.toShort(), reason?.toByteArray()?.let { it.decodeToString(0, 0 + it.size) } ?: "") + if (!_incoming.isClosedForSend) { + _incoming.trySend(Frame.Close(closeReason)) + } + socketJob.cancel() + webSocketTask.cancelWithCloseCode(code, reason) + } +} + +@OptIn(UnsafeNumber::class) +private suspend fun NSURLSessionWebSocketTask.receiveMessage(): NSURLSessionWebSocketMessage = + suspendCancellableCoroutine { + receiveMessageWithCompletionHandler { message, error -> + if (error != null) { + // KTOR-7363 We want to proceed with the request if we get 401 Unauthorized status code + // KTOR-6198 We want to set correct close code and reason on URLSession:webSocketTask:didCloseWithCode:reason: + if ((getStatusCode() == HttpStatusCode.Unauthorized.value) || + (this.closeCode != NSURLSessionWebSocketCloseCodeInvalid) + ) { + it.cancel() + return@receiveMessageWithCompletionHandler + } + + it.resumeWithException(DarwinHttpRequestException(error)) + return@receiveMessageWithCompletionHandler + } + if (message == null) { + it.resumeWithException(IllegalArgumentException("Received null message")) + return@receiveMessageWithCompletionHandler + } + + it.resume(message) + } + } + +@OptIn(UnsafeNumber::class) +internal fun NSURLSessionTask.getStatusCode() = (response() as NSHTTPURLResponse?)?.statusCode?.toInt() diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md new file mode 100644 index 00000000..a221cfd9 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md @@ -0,0 +1,45 @@ +# OOM Issue Diagnosis - Ktor Darwin Engine + +## Problem Summary + +Out of Memory (OOM) error occurring during PowerSync sync operations when using the Kotlin Multiplatform SDK from Swift. + +## Root Cause + +The Ktor Darwin engine's `toByteArray()` function is loading entire HTTP response bodies into memory before processing them. This happens in two steps: + +1. **NSURLSession accumulates all response chunks** into a single `NSData` object +2. **Ktor converts the entire NSData to ByteArray** - allocating another full copy + +For large sync payloads (hundreds of MBs), this results in massive memory usage (1.6 GB in the reported case). + +## Stack Trace Analysis + +``` +kfun:io.ktor.client.engine.darwin.internal.DarwinTaskHandler#receiveData(...) + └─ kfun:io.ktor.client.engine.darwin#toByteArray__at__platform.Foundation.NSData(){}kotlin.ByteArray + └─ kotlin::mm::AllocateArray(...) // 1.3 GB allocated here! +``` + +This shows the entire response is being converted to a ByteArray in one allocation. + +## Why This Works in Pure Kotlin + +When running the same code in pure Kotlin (JVM/Android), Ktor can use: +- OkHttp engine with streaming support +- Direct memory access without NSData conversion +- Incremental processing of response chunks + +## Additional Context + +The issue is specific to: +- **Platform:** iOS/macOS (Darwin) +- **Engine:** Ktor Darwin engine using NSURLSession +- **Operation:** Large HTTP response body handling +- **Not affected:** Pure Kotlin (JVM/Native) using other engines + +## References + +- Ktor Darwin Engine: [GitHub - Ktor Darwin](https://github.com/ktorio/ktor) +- PowerSync Kotlin SDK: v1.7.0 (from Package.swift) +- PowerSync Rust Client: Introduced in Swift SDK v1.2.0 \ No newline at end of file diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/Ios.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/Ios.kt new file mode 100644 index 00000000..d8145b30 --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/Ios.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.ios + +import io.ktor.client.engine.darwin.* + +@Deprecated( + "Please use 'Darwin' engine instead", + replaceWith = ReplaceWith("Darwin", "io.ktor.client.engine.darwin.Darwin"), + level = DeprecationLevel.ERROR +) +public typealias Ios = Darwin + +@Deprecated( + "Please use 'Darwin' engine instead", + replaceWith = ReplaceWith("DarwinClientEngineConfig", "io.ktor.client.engine.darwin.DarwinClientEngineConfig"), + level = DeprecationLevel.ERROR +) +public typealias IosClientEngineConfig = DarwinClientEngineConfig + +@Deprecated( + "Please use 'Darwin' engine instead", + replaceWith = ReplaceWith("DarwinHttpRequestException", "io.ktor.client.engine.darwin.DarwinHttpRequestException"), + level = DeprecationLevel.ERROR +) +public typealias IosHttpRequestException = DarwinHttpRequestException diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/certificates/CertificatePinner.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/certificates/CertificatePinner.kt new file mode 100644 index 00000000..ee9fa5ab --- /dev/null +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/ios/certificates/CertificatePinner.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.engine.ios.certificates + +@Deprecated( + "Please use 'Darwin' engine instead", + replaceWith = ReplaceWith("CertificatePinner", "io.ktor.client.engine.darwin.certificates.CertificatePinner"), + level = DeprecationLevel.ERROR +) +public typealias CertificatePinner = io.ktor.client.engine.darwin.certificates.CertificatePinner + +@Deprecated( + "Please use 'Darwin' engine instead", + replaceWith = ReplaceWith("PinnedCertificate", "io.ktor.client.engine.darwin.certificates.PinnedCertificate"), + level = DeprecationLevel.ERROR +) +public typealias PinnedCertificate = io.ktor.client.engine.darwin.certificates.PinnedCertificate diff --git a/settings.gradle.kts b/settings.gradle.kts index 02a22b4b..9df5860b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,8 @@ include(":integrations:supabase") include(":compose") +include(":internal:ktor-client-darwin") + include(":demos:android-supabase-todolist") include(":demos:supabase-todolist") include(":demos:supabase-todolist:androidApp") From 5106cac4a3256df2f09379fc6476b93bd077b363 Mon Sep 17 00:00:00 2001 From: joshuabrink Date: Wed, 10 Dec 2025 12:26:30 +0200 Subject: [PATCH 2/6] Include ktor license --- internal/ktor-client-darwin/LICENSE | 201 ++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 internal/ktor-client-darwin/LICENSE diff --git a/internal/ktor-client-darwin/LICENSE b/internal/ktor-client-darwin/LICENSE new file mode 100644 index 00000000..8cd4d998 --- /dev/null +++ b/internal/ktor-client-darwin/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2000-2023 JetBrains s.r.o. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file From 8acd358c63a03a5ed94c0787437f4be1c933deb7 Mon Sep 17 00:00:00 2001 From: joshuabrink Date: Wed, 10 Dec 2025 12:28:56 +0200 Subject: [PATCH 3/6] Unconditional queue initialization --- .../src/io/ktor/client/engine/darwin/DarwinClientEngine.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt index f32f6820..92edb3d7 100644 --- a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/DarwinClientEngine.kt @@ -17,10 +17,7 @@ import platform.Foundation.* @OptIn(InternalAPI::class) internal class DarwinClientEngine(override val config: DarwinClientEngineConfig) : HttpClientEngineBase("ktor-darwin") { - private val requestQueue: NSOperationQueue? = when (val queue = NSOperationQueue.currentQueue()) { - NSOperationQueue.mainQueue -> NSOperationQueue() - else -> queue - } + private val requestQueue: NSOperationQueue = NSOperationQueue() override val dispatcher = Dispatchers.Unconfined From 75fd6f0367b5e883df6921ae27b4d2387c29d09d Mon Sep 17 00:00:00 2001 From: joshuabrink Date: Wed, 10 Dec 2025 12:30:25 +0200 Subject: [PATCH 4/6] Reduce channel capacity + move byte array conversion --- .../darwin/internal/DarwinTaskHandler.kt | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt index 8189e87f..620ec917 100644 --- a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt +++ b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt @@ -24,7 +24,7 @@ internal class DarwinTaskHandler( val response: CompletableDeferred = CompletableDeferred() private val requestTime: GMTDate = GMTDate() - private val bodyChunks = Channel(capacity = 64) + private val bodyChunks = Channel(capacity = 1) private var pendingFailure: Throwable? = null get() = field?.also { field = null } @@ -32,9 +32,8 @@ internal class DarwinTaskHandler( private val body: ByteReadChannel = GlobalScope.writer(callContext, autoFlush = true) { try { - bodyChunks.consumeEach { nsData -> - val bytes = nsData.toByteArray() - channel.writeFully(bytes) + bodyChunks.consumeEach { + channel.writeFully(it) } } catch (cause: CancellationException) { bodyChunks.cancel(cause) @@ -48,20 +47,14 @@ internal class DarwinTaskHandler( val result = dataTask.response as NSHTTPURLResponse response.complete(result.toResponseData(requestData)) } - - val result = bodyChunks.trySend(data) - when { - result.isClosed -> dataTask.cancel() - result.isFailure -> { - // Buffer full, block to apply backpressure - try { - runBlocking { bodyChunks.send(data) } - } catch (_: CancellationException) { - dataTask.cancel() - } catch (_: ClosedSendChannelException) { - dataTask.cancel() - } - } + + val bytes = data.toByteArray() + try { + runBlocking { bodyChunks.send(bytes) } + } catch (_: CancellationException) { + dataTask.cancel() + } catch (_: ClosedSendChannelException) { + dataTask.cancel() } } From 45d8d2da52d6be7a016dbc9bb8196b9a821f8e3c Mon Sep 17 00:00:00 2001 From: joshuabrink Date: Wed, 10 Dec 2025 12:30:41 +0200 Subject: [PATCH 5/6] Remove deprecated doc --- .../client/engine/darwin/internal/issue.md | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md diff --git a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md b/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md deleted file mode 100644 index a221cfd9..00000000 --- a/internal/ktor-client-darwin/darwin/src/io/ktor/client/engine/darwin/internal/issue.md +++ /dev/null @@ -1,45 +0,0 @@ -# OOM Issue Diagnosis - Ktor Darwin Engine - -## Problem Summary - -Out of Memory (OOM) error occurring during PowerSync sync operations when using the Kotlin Multiplatform SDK from Swift. - -## Root Cause - -The Ktor Darwin engine's `toByteArray()` function is loading entire HTTP response bodies into memory before processing them. This happens in two steps: - -1. **NSURLSession accumulates all response chunks** into a single `NSData` object -2. **Ktor converts the entire NSData to ByteArray** - allocating another full copy - -For large sync payloads (hundreds of MBs), this results in massive memory usage (1.6 GB in the reported case). - -## Stack Trace Analysis - -``` -kfun:io.ktor.client.engine.darwin.internal.DarwinTaskHandler#receiveData(...) - └─ kfun:io.ktor.client.engine.darwin#toByteArray__at__platform.Foundation.NSData(){}kotlin.ByteArray - └─ kotlin::mm::AllocateArray(...) // 1.3 GB allocated here! -``` - -This shows the entire response is being converted to a ByteArray in one allocation. - -## Why This Works in Pure Kotlin - -When running the same code in pure Kotlin (JVM/Android), Ktor can use: -- OkHttp engine with streaming support -- Direct memory access without NSData conversion -- Incremental processing of response chunks - -## Additional Context - -The issue is specific to: -- **Platform:** iOS/macOS (Darwin) -- **Engine:** Ktor Darwin engine using NSURLSession -- **Operation:** Large HTTP response body handling -- **Not affected:** Pure Kotlin (JVM/Native) using other engines - -## References - -- Ktor Darwin Engine: [GitHub - Ktor Darwin](https://github.com/ktorio/ktor) -- PowerSync Kotlin SDK: v1.7.0 (from Package.swift) -- PowerSync Rust Client: Introduced in Swift SDK v1.2.0 \ No newline at end of file From 8e556f4be624906cf34f1523cb050f3e46fd712d Mon Sep 17 00:00:00 2001 From: joshuabrink Date: Wed, 10 Dec 2025 12:31:06 +0200 Subject: [PATCH 6/6] Fix tls dependancy issue --- gradle/libs.versions.toml | 1 + internal/ktor-client-darwin/build.gradle.kts | 1 + 2 files changed, 2 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9863a0e2..a0c3cec9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,6 +91,7 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ktor-network-tls = { module = "io.ktor:ktor-network-tls", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/internal/ktor-client-darwin/build.gradle.kts b/internal/ktor-client-darwin/build.gradle.kts index c14bb285..59df343e 100644 --- a/internal/ktor-client-darwin/build.gradle.kts +++ b/internal/ktor-client-darwin/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { kotlin.srcDir("darwin/src") dependencies { api(libs.ktor.client.core) + api(libs.ktor.network.tls) implementation(libs.kotlinx.coroutines.core) } }