Correct loop for threaded UserDefaults.standard.addObserver observeValue

I am trying to write a Swift standalone library to monitor a UserDefaults value and callback a function in Python. To do so, I need an event loop in the Swift code that allows the Observer code to run and, when needed, call the callback function.

I am monitoring the default via a KVO Observer class, which inherits a NSObject with UserDefaults.standard.addObserver in its init and implements observeValue as callback.

To keep the loop running, up to now I called RunLoop.current.run() in the end of the Swift library invoked function. As you can imagine, this works well when the library is called from a main thread, which can provide inputs to the RunLoop.

On the other hand, this fails (the library returns immediately) when the function is invoked from a secondary thread without e.g. the stdin attached.

I am looking for an alternative to RunLoop.current.run() that:

  • runs indefinitely
  • can run in a secondary thread with no inputs
  • does not block callbacks to observeValue

I tried the most obvious choices like an infinite while or DispatchQueue but so far I was not able to achieve what I need.

Further details about the issue can be found in a related StackOverflow question here: https://stackoverflow.com/questions/72982210/swift-and-python-get-a-runloop-that-can-run-in-threading

Thanks in advance for your help.

Post not yet marked as solved Up vote post of asottile Down vote post of asottile
950 views

Replies

I’m confused by your requirements here. In general KVO observers run on the thread that makes the change. Thus, you don’t need a run loop for the observer to run. Consider this program:

import Foundation

class Observer: NSObject {
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        print("did observe, thread: \(Thread.current.name ?? "-")")
    }
}

func main() {
    Thread.current.name = "main"
    let o = Observer()
    UserDefaults.standard.addObserver(o, forKeyPath: "test", context: nil)
    withExtendedLifetime(o) {
        var counter = 0
        while true {
            sleep(1)
            print("will set")
            UserDefaults.standard.set(counter, forKey: "test")
            print("did set")
            counter += 1
        }
    }
}

main()

It prints:

will set
did observe, main
did set
will set
did observe, main
did set
will set
… and so on…

Why are involve run loops here?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

I’m confused by your requirements here.

The idea of the library is to continuously observe AppleInterfaceStyle to run the callback if a user switches the Appearance of their system from Dark to Light or vice versa.

In general KVO observers run on the thread that makes the change. 

I was not aware of this. When I run my code from the Python main thread, or as a standalone CLI script, the monitoring works well even if the key change is obviously coming from System Preferences, and therefore not from the same thread.

Since I am quite inexperienced in Swift/Foundation and since there is a doubt about the actual use case, I feel attaching a MWE of what I intend to do is probably beneficial.

import Foundation

let key = "AppleInterfaceStyle"

class Observer: NSObject {
    override init() {
        super.init()
        UserDefaults.standard.addObserver(self, forKeyPath: key, options: [NSKeyValueObservingOptions.new], context: Optional<UnsafeMutableRawPointer>.none)
    }

    deinit {
        UserDefaults.standard.removeObserver(self, forKeyPath: key, context: Optional<UnsafeMutableRawPointer>.none)
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        let result = change?[.newKey] ?? ""
        var theme : String = String(describing: result)
        if (theme == "<null>") {
            theme = "Light"
        }
        print("swift: detected \(theme)")
    }
}

public func main() -> Void {
    // Begin observing standardUserDefaults.
    let observer = Observer()
    _ = observer // silence "constant never used" warning

    RunLoop.current.run()
}

//main() //run from main thread with stdin

// run from background
DispatchQueue.global(qos: .background).async {
    main()
}

When running in this version, the script returns immediately. Uncommenting the direct call to main() results in the script functioning properly and printing to the stdout whenever the Appearance of the system is changed from System Preferences.

The idea of the library is to continuously observe AppleInterfaceStyle to run the callback if a user switches the Appearance of their system from Dark to Light or vice versa.

You are heading down the wrong path here. That user default is an implementation detail. The correct way to determine the interface style is via one of our UI frameworks. It looks like you’re on the Mac, in which case the obvious choice is AppKit. I’m not really an AppKit expert but my understanding is that the standard way to be notified for appearance changes is by use KVO to observe the effectiveAppearance property property.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thank you for your reply.

the standard way to be notified for appearance changes is by use KVO to observe the effectiveAppearance property.

I am fine with that, I was not 100% sure AppKit would have worked from a Swift command-line script, that's why I started with AppleInterfaceStyle. But, switching the observable would not really solve the problem at the core of this post. I would still need something to support continuous observation of any KVO from a background thread.

Perhaps the whole approach is wrong: how would you implement a command line script (and, eventually, a library) that monitors such change on the system without blocking the main thread?

I would still need something to support continuous observation of any KVO from a background thread.

KVO works fine from a background thread. The fundamental issue here is that you’re trying to use AppKit from a background thread, and that won’t end well. My general advice is that you run your script runtime (Python in this case) on the background thread and run AppKit on the main thread. My experience is that script runtimes require that all access be confined to a single thread but don’t require that this be the main thread. In contrast, AppKit really wants to be run on the main thread.

Another option here is to fork a child process, start AppKit on its main thread, and then pass notifications back to the parent process.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"