fix: potential crash in very rare data-race scenario
This commit is contained in:
1 parent
b9fef455b6
commit
4ff5d89429
5 files changed
+29
-23
No files matched your search
@@ -47,7 +47,7 @@ extension Array {
|
||||
let group = DispatchGroup()
|
||||
for element in self {
|
||||
BackgroundWork.globalSemaphore.wait()
|
||||
BackgroundWork.uiDisplayQueue.async(group: group) {
|
||||
BackgroundWork.mainQueueConcurrentWorkQueue.async(group: group) {
|
||||
group.enter()
|
||||
fn(element)
|
||||
BackgroundWork.globalSemaphore.signal()
|
||||
|
||||
@@ -50,7 +50,7 @@ class Application: NSObject {
|
||||
|
||||
func observeNewWindows() {
|
||||
if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited {
|
||||
retryUntilTimeout({ [weak self] in
|
||||
retryAxCallUntilTimeout({ [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let windows_ = try self.axUiElement!.windows(), windows_.count > 0 {
|
||||
// bug in macOS: sometimes the OS returns multiple duplicate windows (e.g. Mail.app starting at login)
|
||||
@@ -92,15 +92,17 @@ class Application: NSObject {
|
||||
private func observeEvents() {
|
||||
guard let axObserver = axObserver else { return }
|
||||
for notification in Application.notifications(runningApplication) {
|
||||
retryUntilTimeout({ [weak self] in
|
||||
retryAxCallUntilTimeout({ [weak self] in
|
||||
guard let self = self else { return }
|
||||
try self.axUiElement!.subscribeToNotification(axObserver, notification, { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
|
||||
// we consider them ready when the first subscription succeeds, and list their windows again at that point
|
||||
if !self.isReallyFinishedLaunching {
|
||||
self.isReallyFinishedLaunching = true
|
||||
self.observeNewWindows()
|
||||
try self.axUiElement!.subscribeToNotification(axObserver, notification, {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
|
||||
// we consider them ready when the first subscription succeeds, and list their windows again at that point
|
||||
if !self.isReallyFinishedLaunching {
|
||||
self.isReallyFinishedLaunching = true
|
||||
self.observeNewWindows()
|
||||
}
|
||||
}
|
||||
}, self.runningApplication)
|
||||
})
|
||||
|
||||
@@ -2,13 +2,16 @@ import Foundation
|
||||
|
||||
// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events
|
||||
class BackgroundWork {
|
||||
static let uiDisplayQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWork", .userInteractive)
|
||||
static let accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommands", .userInteractive)
|
||||
static let accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEvents")
|
||||
static let keyboardEventsThread = BackgroundThreadWithRunLoop("keyboardEvents")
|
||||
static let mainQueueConcurrentWorkQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWorkQueue", .userInteractive)
|
||||
static let accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommandsQueue", .userInteractive)
|
||||
static let axCallsQueue = DispatchQueue.globalConcurrent("axCallsQueue", .userInteractive)
|
||||
static let accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread")
|
||||
static let keyboardEventsThread = BackgroundThreadWithRunLoop("keyboardEventsThread")
|
||||
|
||||
// we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue
|
||||
static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount)
|
||||
// AX calls may block for a long time. We use a big semaphore to avoid blocking the main thread in case the window server is busy
|
||||
static let axCallsGlobalSemaphore = DispatchSemaphore(value: 100)
|
||||
|
||||
// swift static variables are lazy; we artificially force the threads to init
|
||||
static func start() {
|
||||
@@ -22,12 +25,12 @@ extension DispatchQueue {
|
||||
return DispatchQueue(label: label, target: .global(qos: qos.qosClass))
|
||||
}
|
||||
|
||||
func asyncWithCap(_ deadline: DispatchTime? = nil, _ fn: @escaping () -> Void) {
|
||||
func asyncWithCap(_ deadline: DispatchTime? = nil, semaphore: DispatchSemaphore = BackgroundWork.globalSemaphore, _ fn: @escaping () -> Void) {
|
||||
let block = {
|
||||
fn()
|
||||
BackgroundWork.globalSemaphore.signal()
|
||||
semaphore.signal()
|
||||
}
|
||||
BackgroundWork.globalSemaphore.wait()
|
||||
semaphore.wait()
|
||||
if let deadline = deadline {
|
||||
asyncAfter(deadline: deadline, execute: block)
|
||||
} else {
|
||||
|
||||
@@ -55,7 +55,7 @@ class Window {
|
||||
AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver)
|
||||
guard let axObserver = axObserver else { return }
|
||||
for notification in Window.notifications {
|
||||
retryUntilTimeout({ [weak self] in
|
||||
retryAxCallUntilTimeout({ [weak self] in
|
||||
guard let self = self else { return }
|
||||
try self.axUiElement.subscribeToNotification(axObserver, notification, nil, nil, self.cgWindowId)
|
||||
})
|
||||
|
||||
@@ -2,18 +2,19 @@ import Cocoa
|
||||
|
||||
func axObserverCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, _: UnsafeMutableRawPointer?) -> Void {
|
||||
let type = notificationName as String
|
||||
retryUntilTimeout({ try handleEvent(type, element) })
|
||||
retryAxCallUntilTimeout({ try handleEvent(type, element) })
|
||||
}
|
||||
|
||||
func retryUntilTimeout(_ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) {
|
||||
DispatchQueue.global(qos: .userInteractive).async {
|
||||
// if the window server is busy, it may not reply to AX calls. We retry right before the call times-out and returns a bogus value
|
||||
func retryAxCallUntilTimeout(_ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) {
|
||||
BackgroundWork.axCallsQueue.asyncWithCap(semaphore: BackgroundWork.axCallsGlobalSemaphore) {
|
||||
do {
|
||||
try fn()
|
||||
} catch {
|
||||
let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000
|
||||
if timePassedInSeconds < Double(AXUIElement.globalTimeoutInSeconds) {
|
||||
DispatchQueue.global(qos: .userInteractive).asyncAfter(deadline: .now() + .milliseconds(10)) {
|
||||
retryUntilTimeout(fn, startTime)
|
||||
BackgroundWork.axCallsQueue.asyncWithCap(.now() + .milliseconds(10), semaphore: BackgroundWork.axCallsGlobalSemaphore) {
|
||||
retryAxCallUntilTimeout(fn, startTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user