perf: throttle all window/app ax calls

This commit is contained in:
lwouis committed 2026-02-11 21:35:32 +01:00
1 parent 4f5e32593b
commit 337e2fbfec
5 files changed
+89 -89

No files matched your search

+52 -53
View File
@@ -15,8 +15,12 @@ extension AXUIElement {
private static let axCallsRetriesQueueTimeoutInSeconds = Float(6) private static let axCallsRetriesQueueTimeoutInSeconds = Float(6)
// once an app is unresponsive, let's ignore other AX calls for it to avoid congestion // once an app is unresponsive, let's ignore other AX calls for it to avoid congestion
private static var axCallsRetriesQueueUnresponsiveAppsMap = ConcurrentMap<String, UInt64>() private static var axCallsRetriesQueueUnresponsiveAppsMap = ConcurrentMap<String, UInt64>()
// some events like window resizing trigger in quick succession. We debounce those private static var throttleMap = ConcurrentMap<String, ThrottleState>()
private static var eventsDebounceMap = ConcurrentMap<String, DispatchWorkItem>()
private struct ThrottleState {
let time: UInt64
var tailScheduled: Bool
}
static func setGlobalTimeout() { static func setGlobalTimeout() {
AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds) AXUIElementSetMessagingTimeout(AXUIElementCreateSystemWide(), globalMessagingTimeoutInSeconds)
@@ -31,73 +35,70 @@ extension AXUIElement {
} }
/// 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 /// 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
static func retryAxCallUntilTimeout(file: String = #file, function: String = #function, line: Int = #line, context: String = "", after: DispatchTime? = nil, debounceType: DebounceType? = nil, pid: pid_t? = nil, wid: CGWindowID? = nil, retriesQueue: Bool = false, startTimeInNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds, callType: AXCallType, block: @escaping () throws -> Void) { static func retryAxCallUntilTimeout(file: String = #file, function: String = #function, line: Int = #line, context: String = "", after: DispatchTime? = nil, pid: pid_t? = nil, wid: CGWindowID? = nil, retriesQueue: Bool = false, startTimeInNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds, callType: AXCallType, block: @escaping () throws -> Void) {
let closure = { retryAxCallUntilTimeout_(file: file, function: function, line: line, context: context, after: after, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: retriesQueue, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) } let closure = { retryAxCallUntilTimeout_(file: file, function: function, line: line, context: context, after: after, pid: pid, wid: wid, retriesQueue: retriesQueue, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) }
let queue = callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue : (retriesQueue ? BackgroundWork.axCallsRetriesQueue : BackgroundWork.axCallsFirstAttemptQueue) let queue = (callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue
: (retriesQueue ? BackgroundWork.axCallsRetriesQueue
: BackgroundWork.axCallsFirstAttemptQueue))!
if callType == .updateWindowFromAxEvent || callType == .updateWindowFromManualDiscovery {
guard let wid else { Logger.error { (callType, context, file, function, line) }; return }
if throttleOrProceed(key: "\(callType.rawValue)-wid-\(wid)", queue: queue, closure: closure) { return }
}
if callType == .updateApp || callType == .updateAppWindows || callType == .updateAppFocusedWindow {
guard let pid else { Logger.error { (callType, context, file, function, line) }; return }
if throttleOrProceed(key: "\(callType.rawValue)-pid-\(pid)", queue: queue, closure: closure) { return }
}
if let after { if let after {
queue!.addOperationAfter(deadline: after, block: closure) queue.addOperationAfter(deadline: after, block: closure)
} else if let debounceType, let wid {
let debounceMapKey = "\(debounceType.rawValue)\(wid)"
let workItem = DispatchWorkItem {
closure()
eventsDebounceMap.withLock { $0[debounceMapKey] = nil }
}
eventsDebounceMap.withLock {
$0[debounceMapKey]?.cancel()
$0[debounceMapKey] = workItem
}
queue!.addOperationAfter(deadline: .now() + humanPerceptionDelay) {
workItem.perform()
}
} else { } else {
queue!.addOperation(closure) queue.addOperation(closure)
} }
} }
private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, debounceType: DebounceType?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, callType: AXCallType, block: @escaping () throws -> Void) { private static func retryAxCallUntilTimeout_(file: String, function: String, line: Int, context: String, after: DispatchTime?, pid: pid_t?, wid: CGWindowID?, retriesQueue: Bool, startTimeInNanoseconds: UInt64, callType: AXCallType, block: @escaping () throws -> Void) {
// attempt the AX call // attempt the AX call
if (try? block()) != nil { if (try? block()) != nil {
return return
} }
// do we already have ongoing retries for this pid? The app is likely unresponsive // give up?
// if their are common updates, we avoid congestion by only retrying the latest update
if let pid, callType == .updateWindow || callType == .updateAppWindows {
let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)"
let time = axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] }
if let time {
if startTimeInNanoseconds > time {
// new most recent call; replace and retry
axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] = startTimeInNanoseconds }
} else if startTimeInNanoseconds == time {
// most recent call; retry
} else {
// old call which has been replaced; ignore
return
}
} else {
// first call; set and retry
axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] = startTimeInNanoseconds }
}
}
// should we give up?
let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000 let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000
if timePassedInSeconds >= axCallsRetriesQueueTimeoutInSeconds { if timePassedInSeconds >= axCallsRetriesQueueTimeoutInSeconds {
Logger.info { "AX call failed for more than \(Int(axCallsRetriesQueueTimeoutInSeconds))s. Giving up on it. \(logFromContext(file, function, line, context, callType))" } Logger.info { "AX call failed for more than \(Int(axCallsRetriesQueueTimeoutInSeconds))s. Giving up on it. \(logFromContext(file, function, line, context, callType))" }
if let pid, callType == .updateWindow || callType == .updateAppWindows {
let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)"
axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0.removeValue(forKey: unresponsiveAppsMapKey) }
}
return return
} }
// retry // retry
Logger.debug { "(pid:\(pid) wid:\(wid)) \(logFromContext(file, function, line, context, callType))" } Logger.debug { "(pid:\(pid) wid:\(wid)) \(logFromContext(file, function, line, context, callType))" }
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block)
} }
private static func logFromContext(_ file: String, _ function: String, _ line: Int, _ context: String, _ callType: AXCallType) -> String { private static func logFromContext(_ file: String, _ function: String, _ line: Int, _ context: String, _ callType: AXCallType) -> String {
return "Context: \((file as NSString).lastPathComponent):\(line) \(function) \(String(describing: callType)) \(context)" return "Context: \((file as NSString).lastPathComponent):\(line) \(function) \(String(describing: callType)) \(context)"
} }
private static func throttleOrProceed(key: String, queue: LabeledOperationQueue, closure: @escaping () -> Void) -> Bool {
let windowNs: UInt64 = 1_000_000_000
return throttleMap.withLock { map in
let now = DispatchTime.now().uptimeNanoseconds
if let state = map[key] {
let elapsed = now >= state.time ? (now - state.time) : windowNs // treat “time in future” as throttled
if elapsed < windowNs {
if !state.tailScheduled {
map[key] = ThrottleState(time: state.time, tailScheduled: true)
let remaining = windowNs - elapsed
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) {
throttleMap.withLock { $0[key] = nil }
closure()
}
}
return true
}
}
map[key] = ThrottleState(time: now, tailScheduled: false)
return false
}
}
@discardableResult @discardableResult
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool { func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool {
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil) let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
@@ -116,7 +117,10 @@ extension AXUIElement {
case subscribeToAppNotification case subscribeToAppNotification
case subscribeToWindowNotification case subscribeToWindowNotification
case subscribeToDockNotification case subscribeToDockNotification
case updateWindow case updateWindowFromAxEvent
case updateWindowFromManualDiscovery
case updateApp
case updateAppFocusedWindow
case updateAppWindows case updateAppWindows
case updateDockBadges case updateDockBadges
case axEventEntrypoint case axEventEntrypoint
@@ -316,8 +320,3 @@ struct AXAttributes {
var position: CGPoint? var position: CGPoint?
var size: CGSize? var size: CGSize?
} }
enum DebounceType: Int {
case windowResizedOrMoved
case windowTitleChanged
}
+18 -12
View File
@@ -167,23 +167,29 @@ class Application: NSObject {
return return
} }
for axWindow in axWindows { for axWindow in axWindows {
let wid = try axWindow.cgWindowId() guard let wid = try? axWindow.cgWindowId() else { continue }
guard wid != 0 && wid != App.app.thumbnailsPanel.windowNumber else { continue } // some bogus "windows" have wid 0 AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, wid: wid, callType: .updateWindowFromManualDiscovery) { [weak self] in
let level = wid.level() try self?.manuallyUpdateWindow(axWindow, wid)
let a = try axWindow.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
guard let window = findOrCreate.0 else { return }
if findOrCreate.1 {
Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" }
App.app.refreshOpenUiAfterExternalEvent([window])
}
} }
} }
} }
} }
func manuallyUpdateWindow(_ axWindow: AXUIElement, _ wid: CGWindowID) throws {
guard wid != 0 && wid != App.app.thumbnailsPanel.windowNumber else { return } // some bogus "windows" have wid 0
let level = wid.level()
let a = try axWindow.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
guard let window = findOrCreate.0 else { return }
if findOrCreate.1 {
Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" }
App.app.refreshOpenUiAfterExternalEvent([window])
}
}
}
@discardableResult @discardableResult
func addWindowlessWindowIfNeeded() -> Window? { func addWindowlessWindowIfNeeded() -> Window? {
guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated
+3 -3
View File
@@ -87,12 +87,12 @@ class Window {
private func observeEvents() { private func observeEvents() {
AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver) AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver)
guard let axObserver else { return } guard let axObserver else { return }
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .subscribeToWindowNotification) { [weak self] in AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in
guard let self else { return } guard let self else { return }
if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!) { if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!) {
Logger.debug { "Subscribed to window: \(self.debugId)" } Logger.debug { "Subscribed to window: \(self.debugId)" }
for notification in Window.notifications.dropFirst() { for notification in Window.notifications.dropFirst() {
AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.application.pid, callType: .subscribeToWindowNotification) { [weak self] in AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.application.pid, wid: cgWindowId, callType: .subscribeToWindowNotification) { [weak self] in
try self?.axUiElement!.subscribeToNotification(axObserver, notification) try self?.axUiElement!.subscribeToNotification(axObserver, notification)
} }
} }
@@ -315,7 +315,7 @@ class Window {
/// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow /// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow
/// workaround: we check and possibly do it here /// workaround: we check and possibly do it here
private func checkIfFocused() { private func checkIfFocused() {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .updateWindow) { [weak self] in AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, wid: cgWindowId, callType: .updateAppFocusedWindow) { [weak self] in
guard let self, guard let self,
let focusedWindow = try self.application.axUiElement?.attributes([kAXFocusedWindowAttribute]).focusedWindow, let focusedWindow = try self.application.axUiElement?.attributes([kAXFocusedWindowAttribute]).focusedWindow,
let window = try (Windows.list.first { $0.isEqualRobust(focusedWindow, (try focusedWindow.cgWindowId())) }) else { return } let window = try (Windows.list.first { $0.isEqualRobust(focusedWindow, (try focusedWindow.cgWindowId())) }) else { return }
+1 -1
View File
@@ -10,7 +10,7 @@ class Windows {
static func updateIsFullscreenOnCurrentSpace() { static func updateIsFullscreenOnCurrentSpace() {
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp } let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
for window in windowsOnCurrentSpace { for window in windowsOnCurrentSpace {
AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, callType: .updateWindow) { [weak window] in AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, wid: window.cgWindowId, callType: .updateWindowFromAxEvent) { [weak window] in
guard let window else { return } guard let window else { return }
// we reuse existing code, to update .isFullscreen, as if there was a kAXWindowResizedNotification // we reuse existing code, to update .isFullscreen, as if there was a kAXWindowResizedNotification
try AccessibilityEvents.handleEventWindow(kAXWindowResizedNotification, window.cgWindowId!, window.application.pid, window.axUiElement!) try AccessibilityEvents.handleEventWindow(kAXWindowResizedNotification, window.cgWindowId!, window.application.pid, window.axUiElement!)
+15 -20
View File
@@ -15,34 +15,40 @@ class AccessibilityEvents {
let pid = try element.pid() let pid = try element.pid()
Logger.debug { "\(type) pid:\(pid)" } Logger.debug { "\(type) pid:\(pid)" }
if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) { if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) {
try handleEventApp(type, pid, element) AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, callType: .updateApp) {
try handleEventApp(type, pid, element)
}
} else { } else {
try handleEventWindowWithDebounceIfNecessary(type, pid, element) let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "(pid:\(pid))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) {
try handleEventWindow(type, wid, pid, element)
}
} }
} }
private static func handleEventApp(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws { private static func handleEventApp(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws {
let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow
let wid = try appFocusedWindow?.cgWindowId()
DispatchQueue.main.async { DispatchQueue.main.async {
guard let app = Applications.findOrCreate(pid) else { return } guard let app = Applications.findOrCreate(pid) else { return }
Logger.info { "\(type) app:\(app.debugId)" } Logger.info { "\(type) app:\(app.debugId)" }
if type == kAXApplicationActivatedNotification { if type == kAXApplicationActivatedNotification {
applicationActivated(app, pid, type, appFocusedWindow) applicationActivated(app, pid, type, appFocusedWindow, wid)
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification { } else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
applicationHiddenOrShown(app, pid, type) applicationHiddenOrShown(app, pid, type)
} }
} }
} }
private static func applicationActivated(_ app: Application, _ pid: pid_t, _ type: String, _ appFocusedWindow: AXUIElement?) { private static func applicationActivated(_ app: Application, _ pid: pid_t, _ type: String, _ appFocusedWindow: AXUIElement?, _ wid: CGWindowID?) {
Applications.frontmostPid = pid Applications.frontmostPid = pid
if app.hasBeenActiveOnce != true { if app.hasBeenActiveOnce != true {
app.hasBeenActiveOnce = true app.hasBeenActiveOnce = true
} }
if let appFocusedWindow { if let appFocusedWindow, let wid {
// if there is a focusedWindow, we reuse existing code to process it as if it was a kAXFocusedWindowChangedNotification // if there is a focusedWindow, we reuse existing code to process it as if it was a kAXFocusedWindowChangedNotification
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) { AXUIElement.retryAxCallUntilTimeout(context: "\(type) \(app.debugId))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) {
try handleEventWindowWithDebounceIfNecessary(kAXFocusedWindowChangedNotification, pid, appFocusedWindow) try handleEventWindow(kAXFocusedWindowChangedNotification, wid, pid, appFocusedWindow)
} }
} else { } else {
App.app.checkIfShortcutsShouldBeDisabled(nil, app) App.app.checkIfShortcutsShouldBeDisabled(nil, app)
@@ -67,20 +73,9 @@ class AccessibilityEvents {
} }
} }
private static func handleEventWindowWithDebounceIfNecessary(_ type: String, _ pid: pid_t, _ element: AXUIElement) throws {
let wid = try element.cgWindowId()
guard wid != 0 || type == kAXUIElementDestroyedNotification else { return } // some bogus "windows" have wid 0
if type == kAXTitleChangedNotification || type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: (type == kAXTitleChangedNotification ? .windowTitleChanged : .windowResizedOrMoved), pid: pid, wid: wid, callType: .updateWindow) {
try handleEventWindow(type, wid, pid, element)
}
} else {
try handleEventWindow(type, wid, pid, element)
}
}
static func handleEventWindow(_ type: String, _ wid: CGWindowID, _ pid: pid_t, _ element: AXUIElement) throws { static func handleEventWindow(_ type: String, _ wid: CGWindowID, _ pid: pid_t, _ element: AXUIElement) throws {
guard wid != App.app.thumbnailsPanel.windowNumber else { return } // don't process events for the thumbnails panel guard wid != 0 || type == kAXUIElementDestroyedNotification,
wid != App.app.thumbnailsPanel.windowNumber else { return } // don't process events for the thumbnails panel
let level = wid.level() let level = wid.level()
let a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute]) let a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
DispatchQueue.main.async { DispatchQueue.main.async {