Classes with Delegates and @Sendable closures in Swift 6

I keep struggling with Swift 6. Currently I don't understand how are we supposed to create a class with a delegate that's able to do async work in @Sendable closures.

Small example:

I have a class Service that has a method to trigger its implementation. It also has a delegate to notify that the triggered method finished.

protocol ServiceDelegate: AnyObject {
  func serviceDidSomething(withData: Data)
  func somethingWentWrong()
}

final class Service {
  weak var delegate: ServiceDelegate?
  
  func doSomething() {
    guard let url = URL(string: "www.foo.com") else { return }
    Task {
      do {
        let (data, _) = try await URLSession.shared.data(for: .init(url: url))
        delegate?.serviceDidSomething(withData: data)
      } catch {
        delegate?.somethingWentWrong()
      }
    }
  }
}

Building with complete concurrency this throws a warning "Task-isolated value of type '() async -> ()' passed as a strongly transferred parameter; later accesses could race; this is an error in the Swift 6 language mode".

I don't quite understand how to approach this when it comes to @Sendable closures for several reasons:

  • I can make the method async and have it return instead of notifying the delegate but that's avoiding the situation I want to solve. The one triggering the method is not necessarily the one interested in the result.
  • I cannot switch this class to an actor or make it conform to Sendable. The delegate variable is mutable by anyone referencing this class, which is pretty much what I want and what I believe to be a common pattern.
  • What if doSomething doesn't have an alternative with async await and I have to use a @Sendable closure? For example what if I want to use Timer.scheduledTimer(..., block: @escaping @Sendable (Timer) -> Void?

I tried looking at something similar from Apple and went with URLSession since it also has a delegate variable and does async work.

open class URLSession : NSObject, @unchecked Sendable {
    ....
    open var delegate: (any URLSessionDelegate)? { get }
}

Yes, adding @unchecked Sendable does make the warning go away. Is it really the correct approach? I feel like I am cheating here.

4 Likes

If you want your Service to be able to trigger the methods of delegate in any context, than perhaps you should build some synchronization mechanism around the property delegate.

The easiest way, just as you've figured out, is to totally bypass the checking by writing final class Service: @unchecked Sendable. This is OK for the code above, because loading and storing a weak variable is thread safe. However, it leaves a burden to the maintainer of this Service class to manually guarantee concurrency safety in the future.

My way is to limit such unsafe escapes to the minimum scope:

final class Service {

  @propertyWrapper
  class ServiceDelegate: @unchecked Sendable {
    private weak var core: ServiceDelegate?
    var wrappedValue: ServiceDelegate? {
      get {
        core
      }
      set {
        core = newValue
      }
    }
  }

  @ServiceDelegate
  var delegate

  func doSomething() {
    guard let url = URL(string: "www.foo.com") else { return }

    Task { [_delegate] in
      do {
        let (data, _) = try await URLSession.shared.data(for: .init(url: url))
        _delegate.wrappedValue?.serviceDidSomething(withData: data)
      } catch {
        _delegate.wrappedValue?.somethingWentWrong()
      }
    }
  }
}
2 Likes

Hi! There are issues in your code from the concurrency perspective, and several ways to address this, depending on details.

Your class interacts with concurrency and spawns unstructured task, it is unsafe by design to have this, because it can easily race: just call doSomething several times and there it is. That's why Swift warns you here. As for the warning itself,

Task-isolated value of type '() async -> ()' passed as a strongly transferred parameter; later accesses could race; this is an error in the Swift 6 language mode.

It clearly needs an improvement, I think there are already few issues on the matter, so hopefully with the next updates there will be an improvement.

Now, to the solutions:

When it comes to @Sendable closures, it simply requires code within the closure to be safe to pass across isolations, because closure itself can be called from anywhere.

That is actually the best way to start: in that way you are not introducing an unstructured task, allowing, for instance, easy to propagate cancellation. This won't solve all the issues still, because then you'll have to call doSomething as part of a Task later, and Service not being isolated or Sendable still will give you a warning/error.

And that brings us to the next point:

You need to take a look from isolation perspective on your code. The most common thing, I guess, is having this part of an UI flow, so you can mark Service to be a @MainActor-isolated type. In this way it will be safe to call async methods on it, and many will be able to set a delegate.

If your case not includes UI here, but you still want to make it safe and potentially accessible from any isolation, you can do one of three more things:

  1. Actually, make it an actor. Setting a delegate will require an async context and a setter method, but you'll be able to mutate by anyone, just do this safely.
  2. Make method isolated using isolated parameter: func doSomething(isolation: isolated (any Actor)? = #isolation). This allows to inherit whatever isolation it is called from, and rest will do the compiler: it will ensure that Service actually called from only one isolation.
  3. Make it Sendable / @unchecked Sendable. In case you want to avoid additional suspension points, but still make it safe in concurrent world, you can ensure that your state mutations are protected on your own. For instance, you can utilize Mutex from Synchronization module if it is available for you, or just use NSLock.

So addressing this point, ONLY adding @unchecked Sendable isn't a correct approach, it means you have to ensure safety on your own, not just turn off compiler checks.


There is a one more approach: require ServiceDelegate to be Sendable and avoid capturing self:

Snippet
protocol ServiceDelegate: AnyObject, Sendable {
    // ...
}

final class Service {
    // ...

    func doSomething() {
        // ...
        let delegate = delegate
        Task { /* ... */ }
    }
}

Then implementations of ServiceDelegate have to satisfy this Sendable requirement. I do not recommend this as go-to strategy, this can be useful in certain situations, but not as default way to solve the issues, mostly because putting Sendable requirement on a delegate implementation part is a huge complication.


To sum-up, I personally would go with MainActor isolation if we assume this is part of a UI app in Apple ecosystem:

protocol ServiceDelegate: AnyObject {
  func serviceDidSomething(withData: Data)
  func somethingWentWrong()
}

@MainActor
final class Service {
  weak var delegate: ServiceDelegate?
  
  func doSomething() async {
    guard let url = URL(string: "www.foo.com") else { return }
    do {
      let (data, _) = try await URLSession.shared.data(for: .init(url: url))
      delegate?.serviceDidSomething(withData: data)
    } catch {
      delegate?.somethingWentWrong()
    }
  }
}

This gives you most of the options in the simplest way.

There are good responses already, but a bit off topic—imho structrured concurrency works better with functional code, rather than oop patterns. If it's not much to refactor—what you can do instead of delegates is to use closures and wrapping structs, like:

final class Service: Sendable {
  
  struct Delegate: Sendable {
    let serviceDidSomething: @Sendable (_ withData: Data) -> ()
    let somethingWentWrong: @Sendable (any Error) -> ()
  }
  
  let delegate: Delegate
  
  init(delegate: Delegate) {
    self.delegate = delegate
  }
  
  func doSomething() {
    Task {
      guard let url = URL(string: "www.foo.com") else { return }
      do {
        let (data, _) = try await URLSession.shared.data(for: .init(url: url))
        self.delegate.serviceDidSomething(data)
      } catch {
        self.delegate.somethingWentWrong(error)
      }
    }
  }
}

it also gives a bit more structuring, cause usually people do:

let service = Service()
service.delegate = self

and if it's not set directly after calling—it could lead to different problems. So in the end, with closure approach—you're forced to provide implementation on init. Which of course debatable, but again think it's actually a better way to structure code:

let service = Service(
  delegate: Service.Delegate(
    serviceDidSomething: { [weak self] data in
      // do something
    },
    somethingWentWrong: { [weak self] error in
      // do something
    }
  )
)

Also, this could be an example, but is delegate even needed? Code could be just:

class Service { // or even better struct?
  
  enum Error: Swift.Error {
    case invalidURL
  }
  
  func doSomething() async throws -> Data {
    guard let url = URL(string: "www.foo.com") else { throw Error.invalidURL }
    return try await URLSession.shared.data(for: .init(url: url)).0
  }
}

and whoever calling doSomething then should do something with it.

1 Like