How to tell the compiler that the nonisolated object is safe to pass into an actor's domain?

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!!! :sweat_smile:)

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 so much for the help!

3 Likes

Perhaps this is being covered by SE-0430 with the new sending annotation?

The topic of ObjC delegates is addressed in the WWDC video "Migrate your app to Swift 6": Migrate your app to Swift 6 - WWDC24 - Videos - Apple Developer

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.

2 Likes

btw, all of this would work if I used a global actor, but that isn't the correct isolation domain since each of the OldObjCObjects are independent.

The primary issue is Region based Isolation.
You can see the detail from 0421-generalize-async-sequence

AynscStream.Iterator now has following interface

extension AsyncStream.Iterator {
     // plain old fashion iterator
     mutating func next() async -> Element?
     // new fully featured iteterator
     @available(macOS 15.0, *)
     mutating func next(isolation actor: isolated (any Actor)? = #isolation) async throws(Never) -> Element?
}

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.

1 Like

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.

So is there a way to make this work pre macOS 15?

OK, I do see it work correctly once I set the package to v15. It's a bit tough that these additions aren't backdeployed if swift 6 is being used.

(Edited because I didn't realize that a non isolated asyncalgorithm was tacked on to the end of the stream.)

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.