swiftmacosactorappkitswift-concurrency

UI Update not triggered on Main Thread despite @MainActor annotation


I am annotating my function with @MainActor to ensure that it can be called from any async location safely to trigger a UI Update. Despite, I run into an error where, somehow, the UI Update seems to be attempted on a background thread, even though (to my understanding) the function is strictly bound to the `@MainActor´.

This is my code:

/// Dismisses the popup from its presenting view controller.
@MainActor public func dismiss() {
    presentingViewController?.dismiss(self)
}

It is called from inside an NSViewController which listens to a certain event using NotificationCenter, after which it initiates dismissal in the following objc func:

class MainWindowControllerVC: NSWindowController, NSWindowDelegate {
  override func windowDidLoad() {
      NotificationCenter.default.addObserver(self, selector: #selector(self.dismissVCastSharePopup), name: .NOTIF_DISMISS_POPUP, object: nil)
  }

  @objc private func dismissPopup() {
      // some other cleanup is happening here
      popup?.dismiss()
  }
}

I get the following error:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!'

and the following warnings:

enter image description here

Can somebody please explain how this is possible? What am I misunderstanding here? If I wrap the same code into a DispatchQueue.main.async or even Task { @MainActor () -> Void in ... } I don't get this error. It is specifically tied to the annotation before the function.


Solution

  • tl;dr

    When you isolate a function to the @MainActor, that is only relevant when you call this method from a Swift concurrency context. If you call it from outside the Swift concurrency system (such as from the NotificationCenter observer), the @MainActor qualifier has no effect.

    So, either call this actor isolated function from Swift concurrency context, or rely on legacy main queue patterns.


    There are a variety of ways to fix this. First, let me refactor your example into a MCVE:

    import Cocoa
    import os.log
    
    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ViewController")
    
    extension Notification.Name {
        static let demo = Notification.Name(rawValue: Bundle.main.bundleIdentifier! + ".demo")
    }
    
    class ViewController: NSViewController {
        deinit {
            NotificationCenter.default.removeObserver(self, name: .demo, object: nil)
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            addObserver()
            postFromBackground()
        }
    
        func addObserver() {
            logger.debug(#function)
    
            NotificationCenter.default.addObserver(self, selector: #selector(notificationHandler(_:)), name: .demo, object: nil)
        }
    
        func postFromBackground() {
            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                logger.debug(#function)
                NotificationCenter.default.post(name: .demo, object: nil)
            }
        }
    
        @objc func notificationHandler(_ notification: Notification) {
            checkQueue()
        }
    
        @MainActor func checkQueue() {
            logger.debug(#function)
            dispatchPrecondition(condition: .onQueue(.main))
            logger.debug("OK")
        }
    }
    

    enter image description here

    There are a few ways of solving this:

    1. My notificationHandler (dismissVCastSharePopup in your example) is not within a Swift concurrency context. But I can bridge into Swift concurrency by wrapping the the call in a Task {…}:

      @objc nonisolated func notificationHandler(_ notification: Notification) {
          Task { await checkQueue() }
      }
      

      Note, not only did I wrap the call in a Task {…}, but I also added a nonisolated qualifier to let the compiler know that this was being called from a nonisolated context. (Methinks it should infer that from the @objc qualifier, but it does not currently.)

    2. Alternatively, you can use the block-based rendition to make sure you’re on the main thread, such as the block-based addObserver which allows you to specify the .main queue:

      private var observerToken: NSObjectProtocol?
      
      func addObserver() {
          observerToken = notificationCenter.addObserver(forName: .demo, object: nil, queue: .main) { [weak self] _ in
              MainActor.assumeIsolated {
                  self?.checkQueue()
              }
          }
      
          …
      }
      

      Note, when you manually dispatch to the main queue, that block is nonisolated. So, we use of MainActor.assumeIsolated {…} to let the compiler know (at compile-time) that we are telling it that it is safe to isolate to the main actor.

    3. In a Combine codebase, you would use the NotificationCenter publisher in lieu of the observer, but make sure to receive the notifications on the main queue:

      private var cancellable: AnyCancellable?
      
      func addObserver() {
          logger.debug(#function)
      
          cancellable = NotificationCenter.default
              .publisher(for: .demo, object: nil)
              .receive(on: DispatchQueue.main)
              .sink { [weak self] notification in
                  self?.notificationHandler(notification)
              }
      }
      

      Note, if you use this pattern, there is no need to manually cancel the notification publisher, because when cancellable falls out of scope, it will be automatically cancelled for you.

    4. A more contemporary approach is to retire observers altogether, and instead use the notifications AsyncSequence:

      private var task: Task<Void, Never>?
      
      override func viewDidAppear() {
          super.viewDidAppear()
      
          task = Task {
              for await notification in NotificationCenter.default.notifications(named: .demo) {
                  notificationHandler(notification)
              }
          }
      }
      
      override func viewDidDisappear() {
          super.viewDidDisappear()
      
          task?.cancel()
      }
      

      Note that in AppKit/UIKit, you have to manually cancel the loop when the view disappears (not in deinit). In SwiftUI, if we start the for await loop in the .task view modifier, it will be canceled for us and we do not need to manually keep a Task reference like above.