[Feature Request] Swift 6 Concurrency Support (`actor`, `@Sendable`, ...)
Swift 6 introduces some new features no one asked for.
I can see this becoming relevant for Nitro users in one specific case already; Lambdas not being @Sendable.
Let's look at this simple Database HybridObject example where the getValues(...) function takes a single argument: a filter lambda - a method that will be called for each database entry to decide whether to include it or not.
interface Database extends HybridObject<{ ios: 'swift' }> {
getValues(filter: (key: string) => bool): string[]
}
// ...
const values = database.getValues((key) => key.includes('dummy'))
In Swift, Nitrogen generates the following spec:
protocol HybridDatabaseSpec: HybridObjectSpec {
func getValues(filter: @escaping (String) -> Bool) -> [String]
}
Now in your database-specific implementation you might use Swift 6 concurrency - that's Tasks, actors and all that.
Your database of choice might only be able to take @Sendable @escaping (String) -> Bool functions as arguments, but the filter function is @escaping (String) -> Bool - in other words; it's not @Sendable.
How do we fix this?
We can make Nitrogen generate @Sendable labels for function parameters, but then we just entered the first stage of the rabbit hole as we need to make any values we use within those functions Sendable too - here's an example on where this will become problematic:
https://github.com/mrousavy/nitro/blob/347ce3578f8603043034c43b5523b98f3e262a2e/packages/react-native-nitro-test/nitrogen/generated/ios/swift/HybridTestObjectSwiftKotlinSpec_cxx.swift#L863-L869
__promise is a reference-type (a Swift class). It itself is not Sendable.
According to the Apple Developer docs on Sendable Functions and Closures, a @Sendable function cannot capture/use non-sendable reference types like __promise:
So now when we make Promise a Sendable (or actor), we need to deprecate the .parallel API since it does not use concurrency but instead uses DispatchQueues.
Then we need to implement the same for ArrayBuffer, AnyMap, and any other class types that might be used within Swift lambdas.
Quite a refactor huh? 😅
Not only that - it's also a performance hit/overhead since access to isolated methods or properties (like Promise.state) is now asynchronous!
So for now, I think this is a very good workaround for the exact problem described above:
@available(iOS 17.0.0, *)
struct SendableWrapper<R, each A>: @unchecked Sendable {
private let closure: (repeat each A) -> R
init(_ closure: @escaping (repeat each A) -> R) {
self.closure = closure
}
@inline(__always)
func callAsFunction(_ args: repeat each A) -> R {
return self.closure(repeat each args)
}
}
@available(iOS 17.0.0, *)
func doSomeMagic() {
// 1. get your non-Sendable closures
let nonSendableClosure1 = { () in print("HI!") }
let nonSendableClosure2 = { (a: Int, b: Int) -> Int in a + b }
// 2. wrap them using the generic magic `SendableWrapper` © Marc Rousavy
let sendableClosure1 = SendableWrapper(nonSendableClosure1)
let sendableClosure2 = SendableWrapper(nonSendableClosure2)
// 3. those thingies are now Sendable! (@unchecked, but Sendable!)
sendableClosure1()
let result2 = sendableClosure2(5, 7)
}
- See https://github.com/mrousavy/nitro/pull/717
- See #718
Could we simply wrap all functions in an @unchecked Sendable to keep the current functionality and disable the errors associated with Swift 6? That way, we disable the Sendable checking, try to conform to the new feature as well as possible, avoid needing alternative hacks in people's code bases, but allow for the non-sendible types to be delivered from Promises? All things considered, I think we know that race conditions are not going to happen in the bridge, sadly Swift's compiler is oblivious to this, so disabling its checks seems sound here, as the only real race conditions that could be introduced would be from either the C++ bridge or React Native itself. Neither of which feel like a realistic possibility here. So, I would imagine the concurrency fears of the compiler are unwarranted given that it is simply panicking because it doesn't know where these functions are being executed. I guess I am assuming that the promise is ultimately a thread safe reference, regardless of what the compiler thinks, so as long as we are sure that is the case, we don't really need Swift to try to check the Sendability of a reference outside of its language, which it cannot truly do.
Update: I successfully made Swift Promise an actor in this PR: https://github.com/mrousavy/nitro/pull/756
This will have a minor performance hit for the sake of Thread-safety. There were no Threading issues with Promise before, but whatever - now it's compiler-enforced Thread-safe.
@mrousavy Awesome! Thanks! This is probably the most ideal of all the Swift solutions. It is a bummer that it causes a minor hit to performance, but I guess Swift prioritizes paranoid scrutiny over performance as a language. I hate overly restrictive compilers. I really appreciate all the work you did on this! I did not mean to put a lot on your plate. I just found a relatively naive solution, but knew that not having it persisted in the package code would cause serious branching issues for me. To your credit, you have been very proactive in fixing this and I haven't seen many package maintainers that can work this fast, so thanks so much for being awesome, caring, and understanding Swift's quirks better than I do! It is not an easy thing to work with the documentation around the Swift 6 concurrency model.