Bryan
Bryan

Reputation: 5779

Swift: Silence "Non-Sendable Notification? cannot cross actor boundary" warning

Problem

I have a Mac app that needs to perform an action when the Mac sleeps. To do this, I'm using this "modern" approach to listen for the notification:

@MainActor
final class AppController: NSObject, ObservableObject
{
   var sleepTask: Task<Void, Never>? = nil

   override init()
   {
       sleepTask = Task { [weak self] in
          for await _ in NSWorkspace.shared.notificationCenter.notifications(named: NSWorkspace.willSleepNotification) 
          {
              self?.doSomething()
          }
       }
   }
}

Xcode 15 beta 8 has introduced a new warning on the for/await call:

Non-sendable type 'Notification?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
1. Generic enum 'Optional' does not conform to the 'Sendable' protocol (Swift.Optional)

enter image description here

But I'm not using the notification object at all; it's not crossing the actor boundary.

Question:

How can I silence this warning? (Other than forcing Optional to conform to @unchecked Sendable).

Upvotes: 9

Views: 6871

Answers (3)

Rob
Rob

Reputation: 438122

You said:

How can I silence this warning? (Other than forcing Optional to conform to @unchecked Sendable).

You can:

  1. You can declare Notification to be Sendable. E.g.,

    extension Notification: @unchecked Sendable { }
    

    This silences the warning, but generally is inadvisable. If a type is not Sendable, one should not bypass this and retroactively add Sendable conformance, effectively making promises that you might not be able to keep.

  2. You can also change your sequence to yield something other than a Notification:

    sleepTask = Task { [weak self] in
        let sequence = NSWorkspace
            .shared
            .notificationCenter
            .notifications(named: NSWorkspace.willSleepNotification)
            .map { _ in () }
    
        for await _ in sequence {
            self?.doSomething()
        }
    }
    

Upvotes: 8

Cristik
Cristik

Reputation: 32879

Let's see first where your problem comes from.

  1. AsyncSequence requires defining an iterator that has a func next() async -> Notification? method used by the runtime to know when to end the for loop. Thus, behind the scenes, the for loop behaves more like a while:
    var asyncIterator = NSWorkspace.shared.notificationCenter.notifications(named: 
    NSWorkspace.willSleepNotification).makeAsyncIterator()
    while let notification = await asyncIterator.next() {
        self?.doSomething()
    }
    
  2. The notifications are delivered on their own async execution context, while the task you create in init inherits the execution context, meaning it will execute on the main actor. This means the notification objects will have to be passed between different concurrent execution contexts, hence the warning.

Solutions to fix the warning have already been provided, but since I don't like to write an answer without also giving a possible solution, here is one from me: use a detached task.

sleepTask = Task.detached { [weak self] in
    for await _ in NSWorkspace.shared.notificationCenter.notifications(named: NSWorkspace.willSleepNotification)
    {
        await self?.doSomething()
    }
}

A detached task doesn't inherit the current actor execution context, which means the for loop will run in the same execution context as the publisher of the notifications. But, this implies that:

  1. in order to hop back on the main actor, you'll need to await the doSomething call. I don't see this as a problem, au-contraire, it might make things more obvious.
  2. you'll need to make sure to cancel the task in the deinit of AppController, otherwise the for-loop will run indifinetely. But this is also the case with the "non-detached" task, any unstructured tasks you create will need to be "manually" cancelled, otherwise they will leak resources.

Upvotes: 3

matt
matt

Reputation: 535889

This is not what you asked, but you don't need (and shouldn't use) a Task within a Task, and you don't need (and shouldn't use) a reference to your Task. The following will do what you want done:

@MainActor
final class AppController: NSObject, ObservableObject {
    override init() {
        super.init()
        Task { [weak self] in
            let center = NSWorkspace.shared.notificationCenter
            let willSleep = NSWorkspace.willSleepNotification
            for await _ in center.notifications(named: willSleep).map({ _ in }) {
                self?.doSomething()
            }
        }
    }
    func doSomething() {}
}

Properly speaking, you should also add a break that cancels the task just in case self (the AppController) goes out of existence. I take it that it will not go out of existence, but it is best to make a habit of doing things properly:

@MainActor
final class AppController: NSObject, ObservableObject {
    override init() {
        super.init()
        Task { [weak self] in
            let center = NSWorkspace.shared.notificationCenter
            let willSleep = NSWorkspace.willSleepNotification
            for await _ in center.notifications(named: willSleep).map({ _ in }) {
                guard let self else { break }
                doSomething()
            }
        }
    }
    func doSomething() {}
}

Upvotes: 0

Related Questions