Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 2 additions & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
75 changes: 75 additions & 0 deletions internal/ktor-client-darwin/README.md
Original file line number Diff line number Diff line change
@@ -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<NSData>(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
Loading
Loading