Hi all, I'm having a hard time telling the compiler that an old objc delegate and an actor are using the same underlying serial queue and so it is ok to pass nonisolated objects between them.
(btw, we really need a concise list of all of the ways that you can denote and move things between isolation domains... trying to figure it all out by reading dozens of proposals that may or may not be implemented in the swift 6 compiler isn't really conducive to getting work done!!! )
Here is the pseudocode:
class Delegate: NSObject, SomeObjCDelegate, Sendable {
func OldObjCObjectDidSomethingThatReturnedAn(object: OtherObjCObject) {
// this returns on Actor's SerialExecutor
stream.yield(object)
}
var stream: AsyncStream<OtherObjCObject>
}
actor Actor {
let delegate = Delegate()
let oldObjCObject: OldObjCObject
let queue = DispatchSerialQueue("some.queue")
public nonisolated var unownedExecutor: UnownedSerialExecutor {
queue.asUnownedSerialExecutor()
}
init() {
oldObjCObject = oldObjCObject(delegate: delegate, queue: queue)
}
func usesObject() {
for await object in delegate.stream { // Error: Non-sendable type 'OtherObjCObject' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
}
}
}
Thanks! I've already watched the video a couple of times, but assumeIsolated for the actor from the delegate (passed in) doesn't prevent the error message, which makes sense because it could be a different actor of the same type.
Since you are min target is lower than Swift 6 runtime secondary new api is not used and compiler assume your code like this
func usesObject(actor: isolated Self ) async {
var iterator = delegate.stream.makeAsyncIterator()
// isolation == nil is same as normal `next()`
while let object = await iterator.next(isolation: nil) {
}
}
What this tell, is iteterator.next() is async function that doesn't have any isolation restriction. Swift compiler assume this as nonisolated. So Non-sendable type 'OtherObjCObject' is created from nonisolated region and now entering actor isolated region.
however if you're code min target is fully swift 6, compiler assume your code like below
@available(macOS 15.0, *)
func usesObject(actor: isolated Self ) async {
var iterator = delegate.stream.makeAsyncIterator()
while let object = await iterator.next(isolation: actor) {
}
}
iterator.next is referencing the same isolation, and swift compiler know everything is working in the same isolation region.
Thanks! Let me play around and see this as well. That is super interesting. I've actually run into a similar problem with trying to assert isolation while on the queue and having it fail because the checkIsolated extension was macOS 15+ as well.
The only way that I've been able to do this is to turn off concurrency checking for the entire library that contains the OtherObjCObject. This kind of sucks because the library contains lots of other things and I don't really want to turn off concurrency checking for it. I really just was the ability to tell the compiler that I know that in this situation, OtherObjCObject is safe to send.
This is the code that I have working with the least number of issues:
@preconcurrency import OtherObjCObjectContainingLib
var stream: AsyncStream<OtherObjCObject & Sendable>
The ability to declare it as:
var stream: AsyncStream<OtherObjCObject & @unchecked Sendable>
would be great, but that doesn't work.
(Note that the other useful way to handle things like this is to wrap the non-Sendable object in an @unchecked Sendable struct, but in this case, that requires exposing these details to the public api because of mapping and filtering of the stream higher up in the api call.)
You can implement Unsafe interface to the existing AsyncStream.
and implement isolated iterator on your self.
this is the Asyncstream.
let stream: AsyncStream<OtherObjCObject>
let bridge = AsyncTypedStream(base: stream)
var iterator = bridge.makeAsyncIterator()
while let element = await iterator.next(isolation: #isolation) {
}
However, you can't create concrete wrapper for AsyncSequence that had traditional rethrowing algorithnm, (e.g AsyncMapSequence, AsyncFlatMapSequence ... ).
Because compiler can not evaulate it with the typedThrow results in compiler crash.
I think that technically, your way is better because the reality of what I'm trying to do is to move the next OtherObjCObject into Actor's isolation domain. My way just says that OtherObjeCObject is actually Sendable, which it really isn't. But, as you said, there are issues with using the wrapper. :-/
It's like we are missing a dynamic way to tell the compiler that this particular object is now part of a particular isolation domain. Like an unsafeIsolated(actor) call on the object.
Perhaps there is a way to do this that I just haven't uncovered yet.