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)
// once an app is unresponsive, let's ignore other AX calls for it to avoid congestion
private static var axCallsRetriesQueueUnresponsiveAppsMap = ConcurrentMap<String, UInt64>()
// some events like window resizing trigger in quick succession. We debounce those
private static var eventsDebounceMap = ConcurrentMap<String, DispatchWorkItem>()
private static var throttleMap = ConcurrentMap<String, ThrottleState>()
private struct ThrottleState {
let time: UInt64
var tailScheduled: Bool
}
static func setGlobalTimeout() {
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
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) {
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 queue = callType == .updateAppWindows ? BackgroundWork.axCallsManualDiscoveryQueue : (retriesQueue ? BackgroundWork.axCallsRetriesQueue : BackgroundWork.axCallsFirstAttemptQueue)
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, pid: pid, wid: wid, retriesQueue: retriesQueue, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block) }
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 {
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()
}
queue.addOperationAfter(deadline: after, block: closure)
} 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
if (try? block()) != nil {
return
}
// do we already have ongoing retries for this pid? The app is likely unresponsive
// 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?
// give up?
let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000
if timePassedInSeconds >= axCallsRetriesQueueTimeoutInSeconds {
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
}
// retry
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 {
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
func subscribeToNotification(_ axObserver: AXObserver, _ notification: String, _ callback: (() -> Void)? = nil) throws -> Bool {
let result = AXObserverAddNotification(axObserver, self, notification as CFString, nil)
@@ -116,7 +117,10 @@ extension AXUIElement {
case subscribeToAppNotification
case subscribeToWindowNotification
case subscribeToDockNotification
case updateWindow
case updateWindowFromAxEvent
case updateWindowFromManualDiscovery
case updateApp
case updateAppFocusedWindow
case updateAppWindows
case updateDockBadges
case axEventEntrypoint
@@ -316,8 +320,3 @@ struct AXAttributes {
var position: CGPoint?
var size: CGSize?
}
enum DebounceType: Int {
case windowResizedOrMoved
case windowTitleChanged
}
+18 -12
View File
@@ -167,23 +167,29 @@ class Application: NSObject {
return
}
for axWindow in axWindows {
let wid = try axWindow.cgWindowId()
guard wid != 0 && wid != App.app.thumbnailsPanel.windowNumber else { continue } // 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])
}
guard let wid = try? axWindow.cgWindowId() else { continue }
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, wid: wid, callType: .updateWindowFromManualDiscovery) { [weak self] in
try self?.manuallyUpdateWindow(axWindow, wid)
}
}
}
}
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
func addWindowlessWindowIfNeeded() -> Window? {
guard runningApplication.activationPolicy == .regular && !runningApplication.isTerminated
+3 -3
View File
@@ -87,12 +87,12 @@ class Window {
private func observeEvents() {
AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver)
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 }
if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!) {
Logger.debug { "Subscribed to window: \(self.debugId)" }
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)
}
}
@@ -315,7 +315,7 @@ class Window {
/// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow
/// workaround: we check and possibly do it here
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,
let focusedWindow = try self.application.axUiElement?.attributes([kAXFocusedWindowAttribute]).focusedWindow,
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() {
let windowsOnCurrentSpace = list.filter { !$0.isWindowlessApp }
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 }
// we reuse existing code, to update .isFullscreen, as if there was a kAXWindowResizedNotification
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()
Logger.debug { "\(type) pid:\(pid)" }
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 {
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 {
let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow
let wid = try appFocusedWindow?.cgWindowId()
DispatchQueue.main.async {
guard let app = Applications.findOrCreate(pid) else { return }
Logger.info { "\(type) app:\(app.debugId)" }
if type == kAXApplicationActivatedNotification {
applicationActivated(app, pid, type, appFocusedWindow)
applicationActivated(app, pid, type, appFocusedWindow, wid)
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
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
if 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
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) {
try handleEventWindowWithDebounceIfNecessary(kAXFocusedWindowChangedNotification, pid, appFocusedWindow)
AXUIElement.retryAxCallUntilTimeout(context: "\(type) \(app.debugId))", pid: pid, wid: wid, callType: .updateWindowFromAxEvent) {
try handleEventWindow(kAXFocusedWindowChangedNotification, wid, pid, appFocusedWindow)
}
} else {
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 {
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 a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
DispatchQueue.main.async {