Skip to content

Commit 034a555

Browse files
committed
Fix high memory usage on apple devices
1 parent 4409eb7 commit 034a555

26 files changed

+2223
-2
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 1.10.0
4+
5+
- Add internal fork of Ktor Darwin HTTP engine with improved backpressure handling for large response bodies on Apple platforms.
6+
This fixes out-of-memory crashes when syncing large payloads (hundreds of MB) on iOS/macOS.
7+
- Replaces unbounded channel with bounded channel (capacity 64) to prevent unbounded memory growth
8+
- Applies backpressure that propagates to the network layer, throttling data delivery based on processing speed
9+
- No API changes - this is a transparent improvement to the underlying HTTP handling
10+
311
## 1.9.0
412

513
- Updated user agent string formats to allow viewing version distributions in the new PowerSync dashboard.

core/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ kotlin {
6565
}
6666

6767
appleMain.dependencies {
68-
implementation(libs.ktor.client.darwin)
68+
// Use the local ktor-client-darwin module instead of the maven dependency
69+
api(projects.internal.ktorClientDarwin)
6970

7071
// We're not using the bundled SQLite library for Apple platforms. Instead, we depend on
7172
// static-sqlite-driver to link SQLite and have our own bindings implementing the

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ development=true
1919
RELEASE_SIGNING_ENABLED=true
2020
# Library config
2121
GROUP=com.powersync
22-
LIBRARY_VERSION=1.9.0
22+
LIBRARY_VERSION=1.10.0
2323
GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git
2424
# POM
2525
POM_URL=https://github.com/powersync-ja/powersync-kotlin/
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Ktor Darwin Client (Internal Fork)
2+
3+
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.
4+
5+
## Why This Fork Exists
6+
7+
### The Problem: Out of Memory (OOM) on Large Sync Payloads
8+
9+
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:
10+
11+
1. **NSURLSession delivers data faster than it can be processed** - chunks accumulate in the unbounded channel
12+
2. **Memory usage spikes dramatically** - we observed multi-GB allocations during sync operations
13+
3. **OOM crashes on iOS/macOS** - devices run out of memory before the response is fully processed
14+
15+
This issue is specific to the Darwin engine because:
16+
17+
- `NSURLSession` delivers data via delegate callbacks that cannot be paused
18+
- The upstream implementation uses `Channel.UNLIMITED` - buffering all chunks without backpressure
19+
- Other Ktor engines have natural backpressure mechanisms:
20+
- **OkHttp**: Uses `BufferedSource.read()` which blocks until data is consumed
21+
- **Apache/Apache5**: Uses `CapacityChannel` for explicit backpressure signaling
22+
23+
### The Solution: Bounded Channel with Backpressure
24+
25+
Our fork modifies `DarwinTaskHandler` to apply backpressure:
26+
27+
```kotlin
28+
// Bounded channel instead of unbounded
29+
private val bodyChunks = Channel<NSData>(capacity = 64)
30+
31+
fun receiveData(dataTask: NSURLSessionDataTask, data: NSData) {
32+
val result = bodyChunks.trySend(data)
33+
when {
34+
result.isClosed -> dataTask.cancel()
35+
result.isFailure -> {
36+
// Buffer full - block to apply backpressure
37+
runBlocking { bodyChunks.send(data) }
38+
}
39+
}
40+
}
41+
```
42+
43+
Key changes:
44+
45+
- **Limited channel capacity (64)** - prevents unbounded memory growth
46+
- **`runBlocking` on buffer full** - blocks the NSURLSession delegate thread, naturally slowing data delivery
47+
- **Backpressure propagates to NSURLSession** - the network layer throttles based on processing speed
48+
49+
### Alternative Approaches Considered
50+
51+
**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:
52+
53+
- **Complexity** - managing pause/resume state across async boundaries added significant complexity
54+
- **Concurrency issues** - race conditions between pause signals and data delivery callbacks
55+
- **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.
56+
- **Error-prone implementation** - the combination of these factors made the approach fragile and difficult to test
57+
58+
The simpler bounded channel with `runBlocking` approach was chosen as it provides effective backpressure with minimal complexity and maintenance burden.
59+
60+
## When to Update This Fork
61+
62+
This fork should be updated if:
63+
64+
- Ktor releases a fix for this issue upstream (track [ktor issues](https://github.com/ktorio/ktor/issues))
65+
- Security vulnerabilities are found in the Ktor Darwin engine
66+
- New Darwin-specific features are needed
67+
68+
## Files Modified
69+
70+
- `darwin/src/io/ktor/client/engine/darwin/internal/DarwinTaskHandler.kt` - Bounded channel + backpressure logic
71+
72+
## References
73+
74+
- [Original Ktor Darwin Engine](https://github.com/ktorio/ktor/tree/main/ktor-client/ktor-client-darwin)
75+
- PowerSync Kotlin SDK issue: OOM during large sync operations on iOS/macOS

0 commit comments

Comments
 (0)