Using external types that make delegate calls on a specified dispatch queue

I am trying to migrate some code to Swift 6 and I'm struggling with how to use a type from an external library that makes calls back to a delegate on a queue specified by the caller. This is an Apple-provided library but for this discussion I will provide a stand-in.

import Foundation

// These types are from a separate module that has not yet adopted concurrency
@preconcurrency
final class ThingWithDelegate {
    private weak var delegate: ThingWithDelegateDelegate?

    private var delegateQueue: DispatchQueue?

    init() {}

    func setDelegate(delegate: ThingWithDelegateDelegate, queue: DispatchQueue) {
        self.delegate = delegate
        delegateQueue = queue
    }

    func callDelegate() {
        delegateQueue?.async {
            // This has a warning because `self` is captured and it's not sendable, but this is just an example and presumably the external library is doing this safely.
            self.delegate?.doAThing(notSendableType: NotSendableType())
        }
    }
}

final class NotSendableType: NSObject {}

@available(*, unavailable)
extension NotSendableType: Sendable {}

@preconcurrency
protocol ThingWithDelegateDelegate: AnyObject {
    func doAThing(notSendableType: NotSendableType)
}

I wish to then use this object and be notified of events on the main queue and have by type be bound to the main actor.

@MainActor
final class MainActorObject: ThingWithDelegateDelegate {
    private let thingWithDelegate: ThingWithDelegate
    private var thingWithDelegateDelegateWrapper: ThingWithDelegateDelegateWrapper?

    init() {
        thingWithDelegate = ThingWithDelegate()

        // This is the ideal usage.
        thingWithDelegate.setDelegate(delegate: self, queue: .main)

        let thingWithDelegateDelegateWrapper = ThingWithDelegateDelegateWrapper { [weak self] notSendableType in
            // The compiler does not complain about this, despite `handleNotSendableType(_:` being
            // isolated to the main actor and this closure not being called on the main actor.
            self?.handleNotSendableType(notSendableType)
        }
        self.thingWithDelegateDelegateWrapper = thingWithDelegateDelegateWrapper
        thingWithDelegate.setDelegate(delegate: thingWithDelegateDelegateWrapper, queue: .global())
    }

    nonisolated func doAThing(notSendableType: NotSendableType) {
//        handleNotSendableType(notSendableType) // ❌ Call to main actor-isolated instance method 'handleNotSendableType' in a synchronous nonisolated context
        MainActor.assumeIsolated {
//            self.handleNotSendableType(notSendableType) // ❌ Sending 'notSendableType' risks causing data races
        }
    }

    private func handleNotSendableType(_ notSendableType: NotSendableType) {

    }
}

final class ThingWithDelegateDelegateWrapper: ThingWithDelegateDelegate {
    private let notSendableTypeHandler: (_ notSendableType: NotSendableType) -> Void

    init(notSendableTypeHandler: @escaping (_ notSendableType: NotSendableType) -> Void) {
        self.notSendableTypeHandler = notSendableTypeHandler
    }

    func doAThing(notSendableType: NotSendableType) {
        notSendableTypeHandler(notSendableType)
    }
}

There are 2 main pain points here:

  1. The delegate method must be marked nonisolated but I cannot use the notSendableType value inside the closure passed to MainActor.assumeIsolated with extra (hacky) workarounds
  2. The compiler does not know the actor that the ThingWithDelegateDelegateWrapper's closure will be called on, but I am assuming that because it's created inside a function that's on the main actor it is be erroneously inheriting the actor.

Is there a correct way to do this?

Is there a better/correct explanation for what is happening with the closure that allows it to call a function bound to the main actor despite not being called on the main actor?

But it is explicitly not called on the main actor, as defined in the (preconcurrency) implementation of ThingWithDelegate. I believe that was @josephduffy's point, shouldn't there be a warning?

I haven't had problems with this myself in production code, but have wondered about this pattern myself, as it is encountered when working with some Apple frameworks: You specify a dispatch queue and pass an escaping closure. The closure will then be called on that queue, but if you "wrap it too deep" any potential actor isolation becomes an issue.

Could the lack of a warning be rather due to the fact that types that don't adopt Sendable are not necessarily never allowed to be passed over isolation context boundaries?

This does not work. I thought maybe if it were a struct it would make a difference but it produces the same error.

    nonisolated func doAThing(notSendableType: NotSendableType) {
        let localNotSendableType = notSendableType
        MainActor.assumeIsolated {
            self.handleNotSendableType(localNotSendableType) // ❌ Sending 'localNotSendableType' risks causing data races
        }
    }

It is not safe though and it crashes at runtime.

As Gero points out:

I would expect the opposite here: if the compiler knows that a type that doesn't adopt Sendable could be passed over isolation context boundaries then it should know that it could be called from a different one, making it not isolated to the current actor (in this example the main actor).

Maybe there's a way for me to tell the compiler this, but to be safe I think the compiler should not allow this code without at least some kind of warning.

P.S. I assume it is somewhat trivial to recreate this example without dispatch queues, e.g. using a Mutex to pass the closure across actor boundries. I used dispatch queues in this because I ran in to when using an Apple framework (AVFoundation) and I assume others will too.