sdks/swift: Moss iOS SDK#259
Conversation
The Swift wrapper for the Moss iOS SDK. Consumers add the package via
SwiftPM:
.package(url: "https://github.com/usemoss/moss", from: "0.1.0")
`Package.swift` lives at the repo root (SwiftPM requires this) and wires
two targets:
- `MossC` — `.binaryTarget(url:, checksum:)` pointing at the
`Moss.xcframework.zip` GitHub Release asset on this repo.
- `Moss` — Swift sources under `sdks/swift/Sources/Moss/`.
The xcframework binary itself is not committed; it ships as a release
asset and Xcode verifies the SHA-256 checksum on download. Bump the URL
+ checksum together on every release.
Mirrors the layout of sdks/javascript and sdks/python — distribution
artifact published elsewhere (npm / PyPI for those; GitHub Releases
here), `README.md` + license stay in-tree.
There was a problem hiding this comment.
Pull request overview
Adds a Swift Package Manager (SPM) distribution and a Swift wrapper layer for the Moss iOS SDK, exposing an idiomatic async/await API over the precompiled MossC xcframework.
Changes:
- Adds a root
Package.swiftdefining aMosslibrary target that depends on a remotely-hosted binaryMossCtarget. - Introduces the Swift wrapper implementation (
MossClient), authentication protocol + trampoline, error type, and public value types. - Adds Swift SDK documentation and license files under
sdks/swift/.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Package.swift | Declares the SPM package, product, binary target URL/checksum, and Swift target path. |
| sdks/swift/Sources/Moss/MossClient.swift | Implements the Swift async API over the MossC ABI, including auth callback plumbing and helper utilities. |
| sdks/swift/Sources/Moss/MossTypes.swift | Defines public Swift structs/enums for request options and returned values. |
| sdks/swift/Sources/Moss/MossError.swift | Defines MossError surfaced from native failures. |
| sdks/swift/Sources/Moss/Authenticator.swift | Defines the Authenticator protocol and internal boxing used for the C callback. |
| sdks/swift/README.md | Installation and usage documentation for the Swift SDK. |
| sdks/swift/LICENSE | Adds BSD 2-Clause license for the Swift SDK distribution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Serializes mutations to `handle` / `authUserData` so a concurrent | ||
| /// close() can't free state out from under an in-flight operation that | ||
| /// has already captured the handle. | ||
| private let handleLock = NSLock() |
| _ body: (UnsafePointer<UnsafePointer<CChar>?>) throws -> R | ||
| ) rethrows -> R { | ||
| let copies = strings.map { strdup($0) } | ||
| defer { copies.forEach { free($0) } } | ||
| let ptrs = UnsafeMutablePointer<UnsafePointer<CChar>?>.allocate(capacity: copies.count) | ||
| defer { ptrs.deallocate() } | ||
| for (i, c) in copies.enumerated() { | ||
| ptrs[i] = UnsafePointer(c) | ||
| } | ||
| return try body(ptrs) |
| /// The native runtime calls [getAuthHeader] whenever it needs a fresh bearer | ||
| /// token. Implementations typically fetch from a server endpoint and cache. | ||
| /// | ||
| /// Tokens returned must be the raw bearer token (no `Bearer ` prefix). | ||
| /// | ||
| /// Implementations must be safe to call from any thread; the native side may | ||
| /// invoke from a background worker. | ||
| public protocol Authenticator: AnyObject, Sendable { | ||
| func getAuthHeader() async throws -> String | ||
| } |
| public struct QueryResult: Sendable { | ||
| public let id: String | ||
| public let score: Float | ||
| public let text: String |
| let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) | ||
| guard let cacheRoot = caches.first else { return } | ||
| let dir = cacheRoot.appendingPathComponent("moss-models", isDirectory: true) | ||
| do { | ||
| try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | ||
| } catch { | ||
| return | ||
| } |
|
|
||
| ## Requirements | ||
|
|
||
| - iOS 15.1+ |
| private func requireHandle() throws -> OpaquePointer { | ||
| guard let h = handle else { | ||
| throw MossError(code: -1, message: "MossClient already closed") | ||
| } | ||
| return h | ||
| } |
There was a problem hiding this comment.
🔴 Race condition: requireHandle() reads handle without holding handleLock, enabling use-after-free
The requireHandle() method at line 410-415 reads the handle property without acquiring handleLock, while close() at line 85-95 frees and nils the handle under the lock. This creates a use-after-free race: Thread A calls an operation (e.g., query()), requireHandle() returns a valid pointer h; Thread B concurrently calls close(), which acquires the lock, calls moss_client_free(h), and sets handle = nil; Thread A's Task.detached then uses the now-freed pointer, causing undefined behavior or a crash.
The class is declared @unchecked Sendable (promising thread safety), its documentation states "The underlying native client is thread-safe — you can share a single MossClient across the app," and the comment on handleLock (sdks/swift/Sources/Moss/MossClient.swift:29-30) explicitly states its purpose is to prevent concurrent close() from freeing state out from under an in-flight operation. Yet requireHandle() doesn't participate in that locking scheme.
Prompt for agents
The requireHandle() method at sdks/swift/Sources/Moss/MossClient.swift:410-415 reads self.handle without holding handleLock, while close() mutates handle under the lock. This creates a TOCTOU race where close() can free the native handle between requireHandle() returning and the Task.detached closure using it.
A minimal fix would be to acquire handleLock inside requireHandle(). However, that alone doesn't prevent close() from freeing the handle after the lock is released but before the C call executes. A more robust approach would be to use a read-write lock pattern or an in-flight operation counter: increment a counter (under lock) in requireHandle(), and have close() wait until the counter drops to zero before freeing. Alternatively, each operation could hold the lock for its entire duration (but this would serialize all operations).
Relevant locations:
- requireHandle() at MossClient.swift:410-415
- close() at MossClient.swift:85-95
- handleLock declaration at MossClient.swift:31
- All callers of requireHandle(): loadIndex, unloadIndex, query, deleteIndex, getIndex, listIndexes, refreshIndex, getJobStatus, createIndex, addDocs, getDocs, onMemoryPressure, deleteDocs
Was this helpful? React with 👍 or 👎 to provide feedback.
| fileprivate static func decodeMutationResult(_ json: String) throws -> MutationResult { | ||
| struct Wire: Decodable { | ||
| let jobId: String | ||
| let indexName: String | ||
| let docCount: Int | ||
| } | ||
| let w = try JSONDecoder().decode(Wire.self, from: Data(json.utf8)) | ||
| return MutationResult(jobId: w.jobId, indexName: w.indexName, docCount: w.docCount) | ||
| } |
There was a problem hiding this comment.
🚩 decodeMutationResult Wire struct key format depends on undocumented C FFI JSON contract
The Wire struct at line 458-462 uses camelCase property names (jobId, indexName, docCount) without setting keyDecodingStrategy = .convertFromSnakeCase. The C struct MossMutationResult uses snake_case fields (job_id, index_name, doc_count) as seen in deleteDocs at line 399-401. However, the Python SDK bindings (sdks/python/bindings/src/models.rs:126) consistently use #[serde(rename_all = "camelCase")] for JSON serialization, suggesting the _from_json C functions also produce camelCase JSON. This is likely correct, but the contract between the Swift SDK and the moss_client_create_index_from_json / moss_client_add_docs_from_json functions is undocumented and fragile — if the native layer ever changes its JSON format, this will silently break at runtime.
Was this helpful? React with 👍 or 👎 to provide feedback.
- Fix real race in MossClient: handle was read without holding the lock while close() freed it under the lock. Replaced with borrow/ return lifecycle (stateCond NSCondition + inFlight counter); close now waits for in-flight ops to drain before freeing. - Rewrite withCStringArray without strdup; allocates with traps on failure and copies UTF-8 bytes manually. - Add QueryResult.metadata so consumers can inspect document metadata from search hits (was being dropped at the FFI boundary). - ensureModelCacheDir now propagates errors with a descriptive message instead of failing silently. - Authenticator doc: worked example showing why to return the raw token (not Bearer ...). - decodeMutationResult: clear comment on the JSON contract. - README: iOS 15+ to match Package.swift's .iOS(.v15).
Adds
sdks/swift/alongside the existingsdks/javascriptandsdks/pythonso iOS consumers can install the Moss SDK via SwiftPM:Package.swiftat root (SwiftPM requires this).sdks/swift/Sources/Moss/— Swift wrapper.sdks/swift/{README.md, LICENSE}.The xcframework binary ships as a release asset, verified by SHA-256 in
Package.swift.Next
Once merged: tag
v0.1.0onmain+ cut release withMoss.xcframework.zip.