perf: throttle all window/app ax calls
This commit is contained in:
1 parent
4f5e32593b
commit
337e2fbfec
5 files changed
+89
-89
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in new issue
Block a user