fix: improve processing of accessibility events (closes #5665)
This commit is contained in:
1 parent
9a9994bf28
commit
e0b1778db7
17 files changed
+554
-210
No files matched your search
@@ -39,6 +39,12 @@
|
||||
5E1EC110000000000000C002 /* SelectionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C001 /* SelectionResolver.swift */; };
|
||||
5E1EC110000000000000C003 /* SelectionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C001 /* SelectionResolver.swift */; };
|
||||
5E1EC110000000000000C102 /* SelectionResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1EC110000000000000C101 /* SelectionResolverTests.swift */; };
|
||||
5EA8E110000000000000F002 /* AxEventRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA8E110000000000000F001 /* AxEventRouting.swift */; };
|
||||
5EA8E110000000000000F003 /* AxEventRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA8E110000000000000F001 /* AxEventRouting.swift */; };
|
||||
5EA8E110000000000000F102 /* AxEventRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA8E110000000000000F101 /* AxEventRoutingTests.swift */; };
|
||||
5EA8E110000000000000F202 /* SchedulingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA8E110000000000000F201 /* SchedulingPolicy.swift */; };
|
||||
5EA8E110000000000000F203 /* SchedulingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA8E110000000000000F201 /* SchedulingPolicy.swift */; };
|
||||
5EA8E110000000000000F212 /* SchedulingPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA8E110000000000000F211 /* SchedulingPolicyTests.swift */; };
|
||||
5E5EA12C0000000000000002 /* SearchModeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5EA12C0000000000000001 /* SearchModeResolver.swift */; };
|
||||
5E5EA12C0000000000000003 /* SearchModeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5EA12C0000000000000001 /* SearchModeResolver.swift */; };
|
||||
5E5EA12C0000000000000102 /* SearchModeResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5EA12C0000000000000101 /* SearchModeResolverTests.swift */; };
|
||||
@@ -378,6 +384,10 @@
|
||||
5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadEvents.swift; sourceTree = "<group>"; };
|
||||
5E1EC110000000000000C001 /* SelectionResolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectionResolver.swift; sourceTree = "<group>"; };
|
||||
5E1EC110000000000000C101 /* SelectionResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionResolverTests.swift; sourceTree = "<group>"; };
|
||||
5EA8E110000000000000F001 /* AxEventRouting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AxEventRouting.swift; sourceTree = "<group>"; };
|
||||
5EA8E110000000000000F101 /* AxEventRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AxEventRoutingTests.swift; sourceTree = "<group>"; };
|
||||
5EA8E110000000000000F201 /* SchedulingPolicy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchedulingPolicy.swift; sourceTree = "<group>"; };
|
||||
5EA8E110000000000000F211 /* SchedulingPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulingPolicyTests.swift; sourceTree = "<group>"; };
|
||||
5E5EA12C0000000000000001 /* SearchModeResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModeResolver.swift; sourceTree = "<group>"; };
|
||||
5E5EA12C0000000000000101 /* SearchModeResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModeResolverTests.swift; sourceTree = "<group>"; };
|
||||
5F117E40000000000000F001 /* WindowFilterResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFilterResolver.swift; sourceTree = "<group>"; };
|
||||
@@ -760,6 +770,8 @@
|
||||
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
|
||||
AA0C8B000000000000000010 /* MoveToApplicationsFolder.swift */,
|
||||
BF0C8A1E7D3F94B6E8C52A01 /* Throttler.swift */,
|
||||
5EA8E110000000000000F201 /* SchedulingPolicy.swift */,
|
||||
5EA8E110000000000000F211 /* SchedulingPolicyTests.swift */,
|
||||
AA0C8B000000000000000002 /* UsageStats.swift */,
|
||||
AA0C8B000000000000000004 /* UsageStatsTestable.swift */,
|
||||
5FA004000000000000000006 /* UsageStatsMessageTests.swift */,
|
||||
@@ -819,6 +831,8 @@
|
||||
BF0C81DD40D5AAD9B7C09028 /* CursorEvents.swift */,
|
||||
BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */,
|
||||
D04BA8FDCF137D892114F5F3 /* AccessibilityEvents.swift */,
|
||||
5EA8E110000000000000F001 /* AxEventRouting.swift */,
|
||||
5EA8E110000000000000F101 /* AxEventRoutingTests.swift */,
|
||||
D04BA76AEE37D6656EB80126 /* RunningApplicationsEvents.swift */,
|
||||
BF0C8DF008F1EB5F23291A13 /* SpacesEvents.swift */,
|
||||
BF0C822D3BEDAB1F1C9F775E /* ScreensEvents.swift */,
|
||||
@@ -1818,6 +1832,10 @@
|
||||
files = (
|
||||
5F882F382EC3C63400110A9F /* CustomRecorderControlTestable.swift in Sources */,
|
||||
BF0C81FA62D810342F59291A /* AppearanceTests.swift in Sources */,
|
||||
5EA8E110000000000000F003 /* AxEventRouting.swift in Sources */,
|
||||
5EA8E110000000000000F102 /* AxEventRoutingTests.swift in Sources */,
|
||||
5EA8E110000000000000F203 /* SchedulingPolicy.swift in Sources */,
|
||||
5EA8E110000000000000F212 /* SchedulingPolicyTests.swift in Sources */,
|
||||
5F4753072CEE5A2E002C6C5E /* AppearanceTestable.swift in Sources */,
|
||||
BF0C8B787C76D9B961CC2F45 /* KeyboardEventsTests.swift in Sources */,
|
||||
BF0CAAAA0000000000001001 /* SearchTests.swift in Sources */,
|
||||
@@ -1977,6 +1995,7 @@
|
||||
D04BA8757C9F20F25EB50785 /* TextField.swift in Sources */,
|
||||
5F21C9E02C6E94240091F72F /* ShortcutsWhenActiveSheet.swift in Sources */,
|
||||
D04BAE8B16A06A10E2FA94DE /* AccessibilityEvents.swift in Sources */,
|
||||
5EA8E110000000000000F002 /* AxEventRouting.swift in Sources */,
|
||||
5883F0A829A5182C0071DB65 /* TrackpadEvents.swift in Sources */,
|
||||
D04BAAAF5CFA991D3B078DB8 /* KeyboardEvents.swift in Sources */,
|
||||
D04BA1766FBCB2E941D081A5 /* RunningApplicationsEvents.swift in Sources */,
|
||||
@@ -2044,6 +2063,7 @@
|
||||
5FC5980000000000000C0002 /* CGSSymbolicHotKey.swift in Sources */,
|
||||
BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */,
|
||||
BF0C8A2F6E4C85A7D9B63B12 /* Throttler.swift in Sources */,
|
||||
5EA8E110000000000000F202 /* SchedulingPolicy.swift in Sources */,
|
||||
AA0C8A100000000000000001 /* AXCallScheduler.swift in Sources */,
|
||||
BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */,
|
||||
BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */,
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ class App: AppCenterApplication {
|
||||
static var updaterController: SPUStandardUpdaterController?
|
||||
// don't queue multiple delayed rebuildUi() calls
|
||||
private static var delayedDisplayScheduled = 0
|
||||
private static let refreshOpenUiThrottler = Throttler(delayInMs: 200)
|
||||
private static let switcherUiRefreshThrottler = Throttler(delayInMs: 200)
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -283,7 +283,7 @@ class App: AppCenterApplication {
|
||||
|
||||
static func refreshOpenUiAfterExternalEvent(_ windowsToScreenshot: [Window], windowRemoved: Bool = false) {
|
||||
WindowThumbnails.refreshAsync(windowsToScreenshot, .refreshUiAfterExternalEvent, windowRemoved: windowRemoved)
|
||||
refreshOpenUiThrottler.throttleOrProceed {
|
||||
switcherUiRefreshThrottler.throttleOrProceed {
|
||||
guard SwitcherSession.isActive else { return }
|
||||
if !Windows.updatesBeforeShowing() { hideUi(); return }
|
||||
refreshUi(true)
|
||||
|
||||
@@ -10,6 +10,10 @@ class AccessibilityEvents {
|
||||
// AX round-trip. Window-level subs carry a real wid; app-level subs carry wid 0 (their subject
|
||||
// window varies per event, so focus/main/created still resolve it from `element`).
|
||||
let (pid, wid) = decodeSubscriptionRefcon(refcon)
|
||||
// Fast path: focus/activation are edge-triggered — they carry MRU-ordering info that can't be
|
||||
// re-queried and must land before the next switcher summon. Update order on a dedicated serial
|
||||
// queue, IPC-free, so it's never stuck behind the bulk window-refresh on the AX-query pools.
|
||||
updateFocusOrderFastLane(type, element, pid, wid)
|
||||
AXCallScheduler.shared.submit {
|
||||
do { try handleEvent(type, element, pid, wid) }
|
||||
catch { Logger.debug { "handleEvent threw for \(type): stale element" } }
|
||||
@@ -28,14 +32,26 @@ class AccessibilityEvents {
|
||||
return (pid_t(bitPattern: UInt32(truncatingIfNeeded: packed >> 32)), CGWindowID(truncatingIfNeeded: packed))
|
||||
}
|
||||
|
||||
/// events that hit the same branch in `handleEventWindow` are interchangeable, so they share a throttle bucket.
|
||||
/// keeping focus events in their own bucket prevents geometry/title bursts from clobbering the focus side-effect
|
||||
/// that updates `Window.lastFocusOrder` (root cause of #5492 / #5580).
|
||||
private static func bucket(for type: String) -> String {
|
||||
/// IPC-free MRU-order update for the edge-triggered focus events. Window focus/main → bump the
|
||||
/// (already-known) window to lastFocusOrder 0. App activation → refresh `frontmostPid`. Unknown
|
||||
/// windows are left to the `handleEvent` pipeline below (which discovers them with an AX read).
|
||||
private static func updateFocusOrderFastLane(_ type: String, _ element: AXUIElement, _ refconPid: pid_t, _ refconWid: CGWindowID) {
|
||||
switch type {
|
||||
case kAXMainWindowChangedNotification, kAXFocusedWindowChangedNotification: return "focus"
|
||||
case kAXWindowResizedNotification, kAXWindowMovedNotification: return "geometry"
|
||||
default: return "generic"
|
||||
case kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification:
|
||||
BackgroundWork.focusOrderQueue.addOperation {
|
||||
let wid = refconWid != 0 ? refconWid : ((try? element.cgWindowId()) ?? 0)
|
||||
guard wid != 0 else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard let window = Windows.byWindowId[wid],
|
||||
window.application.runningApplication.isActive else { return }
|
||||
focusedWindowChanged(window)
|
||||
}
|
||||
}
|
||||
case kAXApplicationActivatedNotification:
|
||||
guard refconPid != 0 else { return }
|
||||
DispatchQueue.main.async { Applications.frontmostPid = refconPid }
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +60,10 @@ class AccessibilityEvents {
|
||||
// carry it — pid for legacy/nil-refcon subs, wid for the app-level focus/main/created events.
|
||||
let pid = refconPid != 0 ? refconPid : (try element.pid())
|
||||
Logger.debug { "\(type) pid:\(pid)" }
|
||||
if [kAXApplicationActivatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification].contains(type) {
|
||||
AXCallScheduler.shared.schedule(key: "pid-\(pid)", context: "(pid:\(pid))", pid: pid) {
|
||||
if AxEventRouting.isAppEvent(type) {
|
||||
// dedupKey separates non-interchangeable app work (activate vs visibility) so the scheduler's
|
||||
// in-flight de-dup can't drop one; manuallyUpdateWindows' bare "pid-…" key never collides.
|
||||
AXCallScheduler.shared.schedule(key: AxEventRouting.dedupKey(type, pid: pid, wid: 0), context: "(pid:\(pid))", pid: pid) {
|
||||
try handleEventApp(type, pid, element)
|
||||
}
|
||||
} else {
|
||||
@@ -59,8 +77,17 @@ class AccessibilityEvents {
|
||||
}
|
||||
return
|
||||
}
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)-\(bucket(for: type))", context: "(pid:\(pid) wid:\(wid))", pid: pid) {
|
||||
try handleEventWindow(type, wid, pid, element)
|
||||
let read = {
|
||||
AXCallScheduler.shared.schedule(key: AxEventRouting.dedupKey(type, pid: pid, wid: wid), context: "(pid:\(pid) wid:\(wid))", pid: pid) {
|
||||
try handleEventWindow(type, wid, pid, element)
|
||||
}
|
||||
}
|
||||
// coalesce the self-flooding events to ≤1 attribute read per window per 200ms (before the read);
|
||||
// edge-triggered events (created/minimized/deminimized/focus/main) run promptly.
|
||||
if AxEventRouting.coalesces(type) {
|
||||
Applications.windowAttributesThrottler.throttleOrProceed(key: "\(wid)", read)
|
||||
} else {
|
||||
read()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,14 +96,12 @@ class AccessibilityEvents {
|
||||
let appFocusedWindow = try element.attributes([kAXFocusedWindowAttribute]).focusedWindow
|
||||
let wid = try appFocusedWindow?.cgWindowId()
|
||||
DispatchQueue.main.async {
|
||||
Applications.appListUpdateThrottler.throttleOrProceed(key: "\(pid)") {
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) app:\(app.debugId)" }
|
||||
if type == kAXApplicationActivatedNotification {
|
||||
applicationActivated(app, pid, type, appFocusedWindow, wid)
|
||||
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
|
||||
applicationHiddenOrShown(app, pid, type)
|
||||
}
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) app:\(app.debugId)" }
|
||||
if type == kAXApplicationActivatedNotification {
|
||||
applicationActivated(app, pid, type, appFocusedWindow, wid)
|
||||
} else if type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification {
|
||||
applicationHiddenOrShown(app, pid, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,32 +146,30 @@ class AccessibilityEvents {
|
||||
let a = try element.attributes(keys)
|
||||
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
|
||||
DispatchQueue.main.async {
|
||||
Applications.windowListUpdateThrottler.throttleOrProceed(key: "\(wid)-\(bucket(for: type))") {
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" }
|
||||
let findOrCreate = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else {
|
||||
// we don't know this window, but it got focused, so let's update app.focusedWindow with nil
|
||||
if type == kAXFocusedWindowChangedNotification && a.role != kAXSheetRole {
|
||||
app.focusedWindow = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
Logger.debug { "\(type) win:\(window.debugId)" }
|
||||
var tabStateChanged = false
|
||||
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
|
||||
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
|
||||
}
|
||||
if findOrCreate.1 || (tabStateChanged && SwitcherSession.isActive) {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {
|
||||
focusedWindowChanged(window)
|
||||
} else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
|
||||
windowResizedOrMoved(window)
|
||||
} else if !findOrCreate.1 {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
guard let app = Applications.findOrCreate(pid, false) else { return }
|
||||
Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" }
|
||||
let findOrCreate = Windows.findOrCreate(element, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else {
|
||||
// we don't know this window, but it got focused, so let's update app.focusedWindow with nil
|
||||
if type == kAXFocusedWindowChangedNotification && a.role != kAXSheetRole {
|
||||
app.focusedWindow = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
Logger.debug { "\(type) win:\(window.debugId)" }
|
||||
var tabStateChanged = false
|
||||
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
|
||||
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
|
||||
}
|
||||
if findOrCreate.1 || (tabStateChanged && SwitcherSession.isActive) {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {
|
||||
focusedWindowChanged(window)
|
||||
} else if type == kAXWindowResizedNotification || type == kAXWindowMovedNotification {
|
||||
windowResizedOrMoved(window)
|
||||
} else if !findOrCreate.1 {
|
||||
App.refreshOpenUiAfterExternalEvent([window])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Cocoa
|
||||
import ApplicationServices.HIServices.AXNotificationConstants
|
||||
|
||||
/// Pure routing/classification for incoming AX events — extracted from `AccessibilityEvents` and
|
||||
/// `AXCallScheduler` so the design's decisions are unit-testable without queues, threads, or timing
|
||||
/// (same idea as `SelectionResolver`). The wrappers turn these decisions into actual scheduling.
|
||||
enum AxEventRouting {
|
||||
/// the bounded pool an outgoing AX query runs on (see `AXCallScheduler`).
|
||||
enum Pool: Equatable { case firstTry, scan, retry }
|
||||
|
||||
/// A timed-out (unresponsive) app is quarantined to `retry` regardless of caller; otherwise the bursty
|
||||
/// periodic inventory is isolated on `scan`, and everything else is an event-driven `firstTry`.
|
||||
static func pool(unresponsive: Bool, scan: Bool) -> Pool {
|
||||
if unresponsive { return .retry }
|
||||
return scan ? .scan : .firstTry
|
||||
}
|
||||
|
||||
/// The ONLY events we coalesce: resize/move/title self-flood during a live drag (60–120/s), each needing
|
||||
/// a fresh attribute read. Every other AX event is edge-triggered and must run promptly (no coalescing).
|
||||
static func coalesces(_ type: String) -> Bool {
|
||||
type == kAXWindowResizedNotification || type == kAXWindowMovedNotification || type == kAXTitleChangedNotification
|
||||
}
|
||||
|
||||
/// app-level notifications (subscribed on the app element).
|
||||
static func isAppEvent(_ type: String) -> Bool {
|
||||
type == kAXApplicationActivatedNotification || type == kAXApplicationHiddenNotification || type == kAXApplicationShownNotification
|
||||
}
|
||||
|
||||
/// focus/main/activation carry MRU-order info that can't be re-queried; they take the prompt, IPC-free
|
||||
/// fast path (`focusOrderQueue`) rather than going through an attribute read.
|
||||
static func updatesFocusOrder(_ type: String) -> Bool {
|
||||
type == kAXFocusedWindowChangedNotification || type == kAXMainWindowChangedNotification || type == kAXApplicationActivatedNotification
|
||||
}
|
||||
|
||||
/// window-event bucket: non-interchangeable handling must land in different buckets.
|
||||
static func windowBucket(_ type: String) -> String {
|
||||
switch type {
|
||||
case kAXMainWindowChangedNotification, kAXFocusedWindowChangedNotification: return "focus"
|
||||
case kAXWindowResizedNotification, kAXWindowMovedNotification: return "geometry"
|
||||
default: return "generic"
|
||||
}
|
||||
}
|
||||
|
||||
/// `AXCallScheduler` de-dups in-flight calls per key, so non-interchangeable work must NOT share a key
|
||||
/// (else one is dropped while the other is running). App events split activation vs visibility;
|
||||
/// window events split focus / geometry / generic. (The bare `pid-<n>` scan key is owned by
|
||||
/// `manuallyUpdateWindows`, so it never collides with these.)
|
||||
static func dedupKey(_ type: String, pid: pid_t, wid: CGWindowID) -> String {
|
||||
if isAppEvent(type) {
|
||||
let bucket = type == kAXApplicationActivatedNotification ? "activate" : "visibility"
|
||||
return "pid-\(pid)-\(bucket)"
|
||||
}
|
||||
return "wid-\(wid)-\(windowBucket(type))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# AxEventRouting — Specs
|
||||
|
||||
## Summary
|
||||
|
||||
`AxEventRouting` is the pure decision layer for incoming AX events: **which bounded pool** an outgoing
|
||||
AX query runs on, **which events are coalesced** (throttled), whether an event takes the **prompt
|
||||
MRU-order fast path**, and the **de-dup key** per event. It holds no state and touches no queues,
|
||||
threads, or timing — `AccessibilityEvents` and `AXCallScheduler` turn its decisions into real
|
||||
scheduling. Extracted so the routing/throttling design is unit-testable (same pattern as
|
||||
`SelectionResolver`).
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Pool**: an unresponsive app's call is quarantined to `retry`; otherwise the bursty periodic inventory
|
||||
is isolated on `scan`; everything else is an event-driven `firstTry`. `AXCallScheduler` has no throttle —
|
||||
it is a pure executor, so this is purely about *which lane*, never *when*.
|
||||
- **Coalescing**: only resize / move / title self-flood (a live drag fires 60–120/s) and are coalesced to
|
||||
≤1 attribute read per window. Every other event is edge-triggered and runs promptly.
|
||||
- **Fast path**: focus / main / activation carry MRU-order info that can't be re-queried, so they update
|
||||
order on `focusOrderQueue` (IPC-free) rather than via an attribute read.
|
||||
- **De-dup key**: `AXCallScheduler` de-dups in-flight calls per key, so non-interchangeable work must not
|
||||
share a key. App events split `activate` vs `visibility`; window events split `focus` / `geometry` /
|
||||
`generic`. The bare `pid-<n>` scan key is owned by `manuallyUpdateWindows` and never collides.
|
||||
|
||||
## Test scenarios
|
||||
|
||||
Mirrors `AxEventRoutingTests.swift` 1:1.
|
||||
|
||||
### A. Pool routing (queue selection)
|
||||
- **testPoolFirstTryForResponsiveEvent** — responsive, non-scan → `firstTry`.
|
||||
- **testPoolScanIsolatesBulkInventory** — scan work → `scan` (isolated from event reads).
|
||||
- **testPoolUnresponsiveQuarantinesToRetry** — unresponsive → `retry`, even for scan work (unresponsive wins).
|
||||
|
||||
### B. Throttle scoping (which events coalesce)
|
||||
- **testOnlyResizeMoveTitleCoalesce** — resize / move / title self-flood → coalesced.
|
||||
- **testEdgeEventsAreNeverCoalesced** — focus / main / activation / created / destroyed / min / demin / hidden / shown run promptly.
|
||||
|
||||
### C. Fast-path classification (MRU order)
|
||||
- **testFocusMainActivationTakeFastPath** — focus / main / activation update order via the fast path.
|
||||
- **testNonOrderEventsDoNotTakeFastPath** — resize / created / hidden / title do not.
|
||||
|
||||
### D. De-dup keys (the collision fixes)
|
||||
- **testWindowDedupKeysByBucket** — focus/main → `wid-…-focus`; resize/move → `wid-…-geometry`; title/created → `wid-…-generic`.
|
||||
- **testAppDedupKeysSeparateActivationFromVisibility** — activation → `pid-…-activate`; hidden/shown → `pid-…-visibility`.
|
||||
- **testActivationAndVisibilityNeverShareAKey** — activation ≠ hidden, and neither equals the bare `pid-<n>` scan key.
|
||||
- **testFocusAndGeometryNeverShareAKey** — focus and resize on the same wid get different keys (#5492/#5580).
|
||||
|
||||
### E. Use-case integration (deterministic routing of the discussed scenarios)
|
||||
- **testUseCaseManualRefreshIsolatesOnScanPool** — the 60-app inventory routes entirely to `scan`.
|
||||
- **testUseCaseRapidFocusSwitchIsFastAndUncoalesced** — focus/main take the fast path and are never coalesced.
|
||||
- **testUseCaseResizeDragCoalesces** — a resize drag coalesces, on `firstTry`.
|
||||
- **testUseCaseUnresponsiveAppQuarantines** — calls to a beach-balling app (incl. its scan) land on `retry`.
|
||||
@@ -0,0 +1,111 @@
|
||||
import XCTest
|
||||
import ApplicationServices.HIServices.AXNotificationConstants
|
||||
|
||||
/// Pins the AX event routing/throttling design as pure, deterministic decisions — no queues, threads,
|
||||
/// or timing. Covers pool selection, throttle scoping, the MRU-order fast path, the de-dup-key collision
|
||||
/// fixes, and the use-cases we walked through. Future refactors of the AX pipeline must keep this green.
|
||||
final class AxEventRoutingTests: XCTestCase {
|
||||
|
||||
// MARK: - A. Pool routing (queue selection)
|
||||
|
||||
func testPoolFirstTryForResponsiveEvent() {
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: false, scan: false), .firstTry)
|
||||
}
|
||||
|
||||
func testPoolScanIsolatesBulkInventory() {
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: false, scan: true), .scan)
|
||||
}
|
||||
|
||||
func testPoolUnresponsiveQuarantinesToRetry() {
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: true, scan: false), .retry)
|
||||
// unresponsive wins over scan — a beach-balling app's scan call must not clog the scan pool
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: true, scan: true), .retry)
|
||||
}
|
||||
|
||||
// MARK: - B. Throttle scoping (which events coalesce)
|
||||
|
||||
func testOnlyResizeMoveTitleCoalesce() {
|
||||
XCTAssertTrue(AxEventRouting.coalesces(kAXWindowResizedNotification))
|
||||
XCTAssertTrue(AxEventRouting.coalesces(kAXWindowMovedNotification))
|
||||
XCTAssertTrue(AxEventRouting.coalesces(kAXTitleChangedNotification))
|
||||
}
|
||||
|
||||
func testEdgeEventsAreNeverCoalesced() {
|
||||
for type in [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification, kAXApplicationActivatedNotification,
|
||||
kAXWindowCreatedNotification, kAXUIElementDestroyedNotification, kAXWindowMiniaturizedNotification,
|
||||
kAXWindowDeminiaturizedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification] {
|
||||
XCTAssertFalse(AxEventRouting.coalesces(type), "\(type) is edge-triggered and must run promptly")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - C. Fast-path classification (MRU order)
|
||||
|
||||
func testFocusMainActivationTakeFastPath() {
|
||||
for type in [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification, kAXApplicationActivatedNotification] {
|
||||
XCTAssertTrue(AxEventRouting.updatesFocusOrder(type))
|
||||
}
|
||||
}
|
||||
|
||||
func testNonOrderEventsDoNotTakeFastPath() {
|
||||
for type in [kAXWindowResizedNotification, kAXWindowCreatedNotification, kAXApplicationHiddenNotification, kAXTitleChangedNotification] {
|
||||
XCTAssertFalse(AxEventRouting.updatesFocusOrder(type))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - D. De-dup keys (the collision fixes)
|
||||
|
||||
func testWindowDedupKeysByBucket() {
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXFocusedWindowChangedNotification, pid: 7, wid: 42), "wid-42-focus")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXMainWindowChangedNotification, pid: 7, wid: 42), "wid-42-focus")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXWindowResizedNotification, pid: 7, wid: 42), "wid-42-geometry")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXWindowMovedNotification, pid: 7, wid: 42), "wid-42-geometry")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXTitleChangedNotification, pid: 7, wid: 42), "wid-42-generic")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXWindowCreatedNotification, pid: 7, wid: 42), "wid-42-generic")
|
||||
}
|
||||
|
||||
func testAppDedupKeysSeparateActivationFromVisibility() {
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXApplicationActivatedNotification, pid: 7, wid: 0), "pid-7-activate")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXApplicationHiddenNotification, pid: 7, wid: 0), "pid-7-visibility")
|
||||
XCTAssertEqual(AxEventRouting.dedupKey(kAXApplicationShownNotification, pid: 7, wid: 0), "pid-7-visibility")
|
||||
}
|
||||
|
||||
func testActivationAndVisibilityNeverShareAKey() {
|
||||
let activate = AxEventRouting.dedupKey(kAXApplicationActivatedNotification, pid: 7, wid: 0)
|
||||
let hidden = AxEventRouting.dedupKey(kAXApplicationHiddenNotification, pid: 7, wid: 0)
|
||||
XCTAssertNotEqual(activate, hidden)
|
||||
// ...and neither collides with the bare manuallyUpdateWindows scan key "pid-7"
|
||||
XCTAssertNotEqual(activate, "pid-7")
|
||||
XCTAssertNotEqual(hidden, "pid-7")
|
||||
}
|
||||
|
||||
func testFocusAndGeometryNeverShareAKey() {
|
||||
XCTAssertNotEqual(AxEventRouting.dedupKey(kAXFocusedWindowChangedNotification, pid: 7, wid: 42),
|
||||
AxEventRouting.dedupKey(kAXWindowResizedNotification, pid: 7, wid: 42))
|
||||
}
|
||||
|
||||
// MARK: - E. Use-case integration (deterministic routing of the discussed scenarios)
|
||||
|
||||
func testUseCaseManualRefreshIsolatesOnScanPool() {
|
||||
// manuallyRefreshAllWindows → 60-app inventory: every call routes to the isolated scan pool
|
||||
for _ in 0..<60 {
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: false, scan: true), .scan)
|
||||
}
|
||||
}
|
||||
|
||||
func testUseCaseRapidFocusSwitchIsFastAndUncoalesced() {
|
||||
for type in [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification] {
|
||||
XCTAssertTrue(AxEventRouting.updatesFocusOrder(type))
|
||||
XCTAssertFalse(AxEventRouting.coalesces(type))
|
||||
}
|
||||
}
|
||||
|
||||
func testUseCaseResizeDragCoalesces() {
|
||||
XCTAssertTrue(AxEventRouting.coalesces(kAXWindowResizedNotification))
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: false, scan: false), .firstTry)
|
||||
}
|
||||
|
||||
func testUseCaseUnresponsiveAppQuarantines() {
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: true, scan: false), .retry)
|
||||
XCTAssertEqual(AxEventRouting.pool(unresponsive: true, scan: true), .retry)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Cocoa
|
||||
|
||||
class ScreensEvents {
|
||||
private static let throttler = Throttler(delayInMs: 200)
|
||||
private static let screenChangeThrottler = Throttler(delayInMs: 200)
|
||||
|
||||
static func observe() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleEvent), name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||
@@ -9,7 +9,7 @@ class ScreensEvents {
|
||||
|
||||
@objc private static func handleEvent(_ notification: Notification) {
|
||||
// screen notifications often arrive in groups (e.g. 2 in a row in a short time)
|
||||
throttler.throttleOrProceed {
|
||||
screenChangeThrottler.throttleOrProceed {
|
||||
Logger.debug { notification.name.rawValue }
|
||||
Spaces.refresh()
|
||||
Screens.refresh()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Cocoa
|
||||
|
||||
class SpacesEvents {
|
||||
private static let throttler = Throttler(delayInMs: 200)
|
||||
private static let spaceChangeThrottler = Throttler(delayInMs: 200)
|
||||
|
||||
static func observe() {
|
||||
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(handleEvent), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private static func handleEvent(_ notification: Notification) {
|
||||
throttler.throttleOrProceed {
|
||||
spaceChangeThrottler.throttleOrProceed {
|
||||
Logger.debug { notification.name.rawValue }
|
||||
// Workaround for Safari full-screen videos
|
||||
// when full-screening a video, Safari spawns a second full-screen window called "Safari"
|
||||
|
||||
@@ -94,7 +94,7 @@ class WindowCaptureScreenshots {
|
||||
let size = request.size
|
||||
let scaleFactor = request.scaleFactor
|
||||
// [weak window] avoids keeping a closed Window alive while the capture is queued or in-flight with the OS
|
||||
Applications.captureThrottler.throttleOrProceed(key: "capture-wid-\(scWindow.windowID)", queue: BackgroundWork.screenshotsQueue, priority: isPrioritized ? .high : .normal) { [weak window = request.window] in
|
||||
Applications.screenshotThrottler.throttleOrProceed(key: "capture-wid-\(scWindow.windowID)", queue: BackgroundWork.screenshotsQueue, priority: isPrioritized ? .high : .normal) { [weak window = request.window] in
|
||||
guard !App.isTerminating, let window else { return }
|
||||
let config = SCStreamConfiguration.forWindow(scWindow, size, scaleFactor, false)
|
||||
let filter = SCContentFilter(desktopIndependentWindow: scWindow)
|
||||
@@ -126,7 +126,7 @@ class WindowCaptureScreenshotsPrivateApi {
|
||||
for window in sorted {
|
||||
guard let wid = window.cgWindowId else { continue }
|
||||
let isPrioritized = prioritized.contains(wid)
|
||||
Applications.captureThrottler.throttleOrProceed(key: "capture-wid-\(wid)", queue: BackgroundWork.screenshotsQueue, priority: isPrioritized ? .high : .normal) { [weak window] in
|
||||
Applications.screenshotThrottler.throttleOrProceed(key: "capture-wid-\(wid)", queue: BackgroundWork.screenshotsQueue, priority: isPrioritized ? .high : .normal) { [weak window] in
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || SwitcherSession.isActive else { return }
|
||||
guard let wid = window?.cgWindowId, let cgImage = oneTimeCapture(wid) else { return }
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || SwitcherSession.isActive else { return }
|
||||
|
||||
+51
-101
@@ -1,91 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure executor for outgoing AX calls. Two jobs, on purpose nothing else:
|
||||
/// 1. don't explode threads — calls run on bounded pools (a blocked AX call ties a worker for the 1s
|
||||
/// messaging timeout, and the process has a ~64-thread hard limit).
|
||||
/// 2. don't hang on unresponsive apps — a call that times out is retried with backoff on a quarantine
|
||||
/// pool, so one beach-balling app can't starve calls to the responsive ones.
|
||||
///
|
||||
/// It does NOT throttle/coalesce. Coalescing self-flooding inputs (resize/move/title) is the job of an
|
||||
/// explicit `Throttler` at the call site (e.g. `Applications.windowAttributesThrottler`). The only
|
||||
/// dedup here is per-key in-flight: a second call for a key already running is held as `pendingBlock`
|
||||
/// and run once the current one finishes — never two concurrent calls for the same key.
|
||||
class AXCallScheduler {
|
||||
static let shared = AXCallScheduler()
|
||||
|
||||
let fastQueue: LabeledOperationQueue
|
||||
let retryQueue: LabeledOperationQueue
|
||||
// first-try: event-driven reads · scan: the bursty periodic inventory, isolated · retry: quarantine for timed-out apps
|
||||
let axQueryFirstTryQueue: LabeledOperationQueue
|
||||
let axQueryScanQueue: LabeledOperationQueue
|
||||
let axQueryRetryQueue: LabeledOperationQueue
|
||||
|
||||
private let lock = NSLock()
|
||||
private var keyStates = [String: KeyState]()
|
||||
private var unresponsivePids = Set<pid_t>()
|
||||
|
||||
private static let throttleDelayNs: UInt64 = 200_000_000
|
||||
private static let giveUpAfterSeconds: Float = 60.0
|
||||
private static let backoffStepsNs: [UInt64] = [200_000_000, 1_000_000_000, 2_000_000_000, 5_000_000_000]
|
||||
|
||||
private enum Phase {
|
||||
case idle
|
||||
case throttled
|
||||
case executing
|
||||
case retrying
|
||||
}
|
||||
|
||||
private struct KeyState {
|
||||
var phase: Phase = .idle
|
||||
var lastExecutionTime: UInt64 = 0
|
||||
var retryStartTime: UInt64 = 0
|
||||
var retryCount = 0
|
||||
var scan = false
|
||||
var pendingBlock: (() throws -> Void)?
|
||||
var pendingPid: pid_t?
|
||||
var pendingContext: String?
|
||||
var pendingScan = false
|
||||
var cancelRetries = false
|
||||
}
|
||||
|
||||
private init() {
|
||||
fastQueue = LabeledOperationQueue("axCallsFast", .userInteractive, 16)
|
||||
retryQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8)
|
||||
axQueryFirstTryQueue = LabeledOperationQueue("axQueryFirstTry", .userInteractive, 10)
|
||||
axQueryScanQueue = LabeledOperationQueue("axQueryScan", .userInteractive, 6)
|
||||
axQueryRetryQueue = LabeledOperationQueue("axQueryRetry", .userInteractive, 8)
|
||||
}
|
||||
|
||||
func schedule(key: String, file: String = #file, function: String = #function, line: Int = #line, context: String = "", pid: pid_t? = nil, block: @escaping () throws -> Void) {
|
||||
/// Run an outgoing AX call, retrying with backoff if the app is unresponsive. `scan: true` routes the
|
||||
/// first attempt to the isolated scan pool so a bulk re-scan can't starve event-driven reads. No
|
||||
/// throttling — coalesce at the call site if the input self-floods.
|
||||
func schedule(key: String, file: String = #file, function: String = #function, line: Int = #line, context: String = "", pid: pid_t? = nil, scan: Bool = false, block: @escaping () throws -> Void) {
|
||||
lock.lock()
|
||||
var state = keyStates[key] ?? KeyState()
|
||||
switch state.phase {
|
||||
case .idle:
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let elapsed = now >= state.lastExecutionTime ? (now - state.lastExecutionTime) : Self.throttleDelayNs
|
||||
if elapsed >= Self.throttleDelayNs {
|
||||
state.phase = .executing
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
submitToQueue(key: key, pid: pid, file: file, function: function, line: line, context: context, block: block)
|
||||
} else {
|
||||
state.phase = .throttled
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
let remaining = Self.throttleDelayNs - elapsed
|
||||
lock.unlock()
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [self] in
|
||||
fireThrottled(key: key, file: file, function: function, line: line)
|
||||
}
|
||||
}
|
||||
case .throttled:
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
state.phase = .executing
|
||||
state.scan = scan
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
case .executing:
|
||||
submitToQueue(key: key, pid: pid, scan: scan, file: file, function: function, line: line, context: context, block: block)
|
||||
case .executing, .retrying:
|
||||
// a call for this key is already in flight: hold the latest, run it when the current one finishes
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
case .retrying:
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
state.cancelRetries = true
|
||||
state.pendingScan = scan
|
||||
if state.phase == .retrying { state.cancelRetries = true }
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func submit(_ block: @escaping () -> Void) {
|
||||
fastQueue.addOperation(block)
|
||||
axQueryFirstTryQueue.addOperation(block)
|
||||
}
|
||||
|
||||
func removeEntry(key: String) {
|
||||
@@ -108,32 +94,17 @@ class AXCallScheduler {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func queueForPid(_ pid: pid_t?) -> LabeledOperationQueue {
|
||||
if let pid, unresponsivePids.contains(pid) {
|
||||
return retryQueue
|
||||
private func queueForPid(_ pid: pid_t?, scan: Bool) -> LabeledOperationQueue {
|
||||
let unresponsive = pid.map { unresponsivePids.contains($0) } ?? false
|
||||
switch AxEventRouting.pool(unresponsive: unresponsive, scan: scan) {
|
||||
case .firstTry: return axQueryFirstTryQueue
|
||||
case .scan: return axQueryScanQueue
|
||||
case .retry: return axQueryRetryQueue
|
||||
}
|
||||
return fastQueue
|
||||
}
|
||||
|
||||
private func fireThrottled(key: String, file: String, function: String, line: Int) {
|
||||
lock.lock()
|
||||
guard var state = keyStates[key], state.phase == .throttled, let block = state.pendingBlock else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
let pid = state.pendingPid
|
||||
let context = state.pendingContext ?? ""
|
||||
state.pendingBlock = nil
|
||||
state.pendingPid = nil
|
||||
state.pendingContext = nil
|
||||
state.phase = .executing
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block)
|
||||
}
|
||||
|
||||
private func submitToQueue(key: String, pid: pid_t?, file: String, function: String, line: Int, context: String, block: @escaping () throws -> Void) {
|
||||
let queue = queueForPid(pid)
|
||||
private func submitToQueue(key: String, pid: pid_t?, scan: Bool, file: String, function: String, line: Int, context: String, block: @escaping () throws -> Void) {
|
||||
let queue = queueForPid(pid, scan: scan)
|
||||
queue.addOperation { [self] in
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block)
|
||||
}
|
||||
@@ -167,9 +138,8 @@ class AXCallScheduler {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
let elapsed = Float(DispatchTime.now().uptimeNanoseconds - retryStartTime) / 1_000_000_000
|
||||
if elapsed >= Self.giveUpAfterSeconds {
|
||||
Logger.info { "AX call timed out after \(Int(Self.giveUpAfterSeconds))s. \(Self.logContext(file, function, line, context))" }
|
||||
if RetryPolicy.shouldGiveUp(elapsedSinceStartNs: DispatchTime.now().uptimeNanoseconds - retryStartTime) {
|
||||
Logger.info { "AX call timed out after \(RetryPolicy.giveUpAfterNs / 1_000_000_000)s. \(Self.logContext(file, function, line, context))" }
|
||||
if let pid {
|
||||
lock.lock()
|
||||
unresponsivePids.remove(pid)
|
||||
@@ -184,17 +154,16 @@ class AXCallScheduler {
|
||||
lock.lock()
|
||||
if var state = keyStates[key] {
|
||||
state.phase = .retrying
|
||||
let step = min(state.retryCount, Self.backoffStepsNs.count - 1)
|
||||
delayNs = Self.backoffStepsNs[step]
|
||||
delayNs = RetryPolicy.backoffDelayNs(retryCount: state.retryCount)
|
||||
state.retryCount += 1
|
||||
keyStates[key] = state
|
||||
} else {
|
||||
delayNs = Self.backoffStepsNs[0]
|
||||
delayNs = RetryPolicy.backoffDelayNs(retryCount: 0)
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
Logger.debug { "Retrying AX call in \(delayNs / 1_000_000)ms. \(Self.logContext(file, function, line, context))" }
|
||||
retryQueue.addOperationAfter(deadline: .now() + .nanoseconds(Int(delayNs))) { [self] in
|
||||
axQueryRetryQueue.addOperationAfter(deadline: .now() + .nanoseconds(Int(delayNs))) { [self] in
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: retryStartTime, block: block)
|
||||
}
|
||||
}
|
||||
@@ -202,7 +171,6 @@ class AXCallScheduler {
|
||||
private func onComplete(key: String, file: String, function: String, line: Int) {
|
||||
lock.lock()
|
||||
if var state = keyStates[key] {
|
||||
state.lastExecutionTime = DispatchTime.now().uptimeNanoseconds
|
||||
state.phase = .idle
|
||||
state.cancelRetries = false
|
||||
state.retryCount = 0
|
||||
@@ -225,35 +193,17 @@ class AXCallScheduler {
|
||||
}
|
||||
let pid = state.pendingPid
|
||||
let context = state.pendingContext ?? ""
|
||||
let scan = state.pendingScan
|
||||
state.pendingBlock = nil
|
||||
state.pendingPid = nil
|
||||
state.pendingContext = nil
|
||||
state.pendingScan = false
|
||||
state.cancelRetries = false
|
||||
|
||||
// apply throttle check before executing pending block
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let elapsed = now >= state.lastExecutionTime ? (now - state.lastExecutionTime) : Self.throttleDelayNs
|
||||
if elapsed >= Self.throttleDelayNs {
|
||||
state.phase = .executing
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperation { [self] in
|
||||
attemptBlock(key: key, pid: pid, file: file, function: function, line: line, context: context, retryStartTime: DispatchTime.now().uptimeNanoseconds, block: block)
|
||||
}
|
||||
} else {
|
||||
state.phase = .throttled
|
||||
state.pendingBlock = block
|
||||
state.pendingPid = pid
|
||||
state.pendingContext = context
|
||||
keyStates[key] = state
|
||||
let remaining = Self.throttleDelayNs - elapsed
|
||||
lock.unlock()
|
||||
let queue = queueForPid(pid)
|
||||
queue.addOperationAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [self] in
|
||||
fireThrottled(key: key, file: file, function: function, line: line)
|
||||
}
|
||||
}
|
||||
state.phase = .executing
|
||||
state.scan = scan
|
||||
keyStates[key] = state
|
||||
lock.unlock()
|
||||
submitToQueue(key: key, pid: pid, scan: scan, file: file, function: function, line: line, context: context, block: block)
|
||||
}
|
||||
|
||||
private static func logContext(_ file: String, _ function: String, _ line: Int, _ context: String) -> String {
|
||||
|
||||
@@ -4,15 +4,16 @@ import ApplicationServices
|
||||
class Applications {
|
||||
static var list = [Application]()
|
||||
static var frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||
// Layer 0: global throttle on manuallyRefreshAllWindows (panel show full-sync)
|
||||
static let manualRefreshThrottler = Throttler(delayInMs: 1000)
|
||||
// Layer 1 (AX IPC throttle + retry + concurrency) is handled by AXCallScheduler.shared
|
||||
// Layer 2: throttle mutations to Applications.list / Windows.list on main thread
|
||||
static let appListUpdateThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
static let windowListUpdateThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
static let badgesThrottler = Throttler(delayInMs: 1000)
|
||||
// Layer 3: throttle per-WID screenshot captures (gates SCScreenshotManager / CGSHWCaptureWindowList)
|
||||
static let captureThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
// Throttlers coalesce redundant work. They are SEPARATE from AXCallScheduler, which is a pure executor
|
||||
// (bounded pools + retry, no throttle). Each one below states what it coalesces and why:
|
||||
// A — suppress redundant inbound events: coalesce resize/move/title bursts to ≤1 attribute read per window
|
||||
static let windowAttributesThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
// B — suppress redundant recompute: ≤1 full window-inventory scan per second (on switcher show)
|
||||
static let fullRescanThrottler = Throttler(delayInMs: 1000)
|
||||
// B — ≤1 Dock-badge fetch per second
|
||||
static let dockBadgeThrottler = Throttler(delayInMs: 1000)
|
||||
// C — cap a resource: ≤1 thumbnail capture per window per 200ms
|
||||
static let screenshotThrottler = ThrottlerWithKey(delayInMs: 200)
|
||||
|
||||
static func initialDiscovery() {
|
||||
addInitialRunningApplications()
|
||||
@@ -24,7 +25,7 @@ class Applications {
|
||||
}
|
||||
|
||||
static func manuallyRefreshAllWindows() {
|
||||
manualRefreshThrottler.throttleOrProceed {
|
||||
fullRescanThrottler.throttleOrProceed {
|
||||
removeZombieWindows()
|
||||
addMissingWindows()
|
||||
reviewExistingWindows()
|
||||
@@ -44,7 +45,7 @@ class Applications {
|
||||
}
|
||||
|
||||
static func manuallyUpdateWindows(_ app: Application) {
|
||||
AXCallScheduler.shared.schedule(key: "pid-\(app.pid)", context: app.debugId, pid: app.pid) { [weak app] in
|
||||
AXCallScheduler.shared.schedule(key: "pid-\(app.pid)", context: app.debugId, pid: app.pid, scan: true) { [weak app] in
|
||||
guard let app, let axUiElement = app.axUiElement else { return }
|
||||
let axWindows = try axUiElement.allWindows(app.pid)
|
||||
guard !axWindows.isEmpty else {
|
||||
@@ -68,7 +69,7 @@ class Applications {
|
||||
/// Unified window attribute fetch + main-thread update. Used by both manual sync and reviewExistingWindows.
|
||||
/// Uses the "generic" bucket so a real focus event (which lives in the "focus" bucket) is never clobbered.
|
||||
static func updateWindowAttributes(_ axWindow: AXUIElement, _ wid: CGWindowID, _ app: Application) {
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)-generic", context: app.debugId, pid: app.pid) { [weak app] in
|
||||
AXCallScheduler.shared.schedule(key: "wid-\(wid)-generic", context: app.debugId, pid: app.pid, scan: true) { [weak app] in
|
||||
guard let app else { return }
|
||||
guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return }
|
||||
let level = wid.level()
|
||||
@@ -78,7 +79,7 @@ class Applications {
|
||||
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
|
||||
DispatchQueue.main.async { [weak app] in
|
||||
guard let app else { return }
|
||||
windowListUpdateThrottler.throttleOrProceed(key: "\(wid)-generic") {
|
||||
windowAttributesThrottler.throttleOrProceed(key: "\(wid)-generic") {
|
||||
let findOrCreate = Windows.findOrCreate(axWindow, wid, app, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
|
||||
guard let window = findOrCreate.0 else { return }
|
||||
var tabStateChanged = false
|
||||
@@ -209,15 +210,15 @@ class Applications {
|
||||
for tApp in terminatingApps {
|
||||
let pid = tApp.processIdentifier
|
||||
AXCallScheduler.shared.removeEntry(key: "pid-\(pid)")
|
||||
AXCallScheduler.shared.removeEntries(withPrefix: "pid-\(pid)-")
|
||||
AXCallScheduler.shared.removeUnresponsivePid(pid)
|
||||
appListUpdateThrottler.removeEntry(withKey: "\(pid)")
|
||||
}
|
||||
App.refreshOpenUiAfterExternalEvent([])
|
||||
}
|
||||
|
||||
static func refreshBadgesAsync() {
|
||||
guard SwitcherSession.isActive else { return }
|
||||
badgesThrottler.throttleOrProceed {
|
||||
dockBadgeThrottler.throttleOrProceed {
|
||||
let dockPid = list.first { $0.bundleIdentifier == "com.apple.dock" }?.pid
|
||||
AXCallScheduler.shared.schedule(key: "badges", context: "badges", pid: dockPid) {
|
||||
guard let dockPid,
|
||||
|
||||
@@ -460,8 +460,8 @@ class Windows {
|
||||
for w in windows {
|
||||
if let wid = w.cgWindowId {
|
||||
AXCallScheduler.shared.removeEntries(withPrefix: "wid-\(wid)-")
|
||||
Applications.windowListUpdateThrottler.removeEntries(withPrefix: "\(wid)-")
|
||||
Applications.captureThrottler.removeEntry(withKey: "capture-wid-\(wid)")
|
||||
Applications.windowAttributesThrottler.removeEntries(withPrefix: "\(wid)-")
|
||||
Applications.screenshotThrottler.removeEntry(withKey: "capture-wid-\(wid)")
|
||||
}
|
||||
// Detach the per-window AX observer's runloop source. Without this the AX events
|
||||
// thread's runloop accumulates one orphaned source per window-ever-opened (leak #1,
|
||||
|
||||
@@ -13,6 +13,7 @@ class BackgroundWork {
|
||||
static var repeatingKeyQueue: LabeledOperationQueue!
|
||||
static var screenshotsQueue: LabeledOperationQueue!
|
||||
static var accessibilityCommandsQueue: LabeledOperationQueue!
|
||||
static var focusOrderQueue: LabeledOperationQueue!
|
||||
static var crashReportsQueue: LabeledOperationQueue!
|
||||
static var permissionsCheckOnTimerQueue: LabeledOperationQueue!
|
||||
static var permissionsSystemCallsQueue: LabeledOperationQueue!
|
||||
@@ -33,6 +34,10 @@ class BackgroundWork {
|
||||
// calls to focus/close/minimize/etc windows
|
||||
// They are tried once and if they timeout we don't retry. The OS seems to still execute them even if the call timed out
|
||||
accessibilityCommandsQueue = LabeledOperationQueue("axCommands", .userInteractive, 4)
|
||||
// focus/activation order updates run here, isolated from the axQuery* pools (which the bulk
|
||||
// window-refresh floods) and un-throttled, so the MRU order is fresh before the next switcher summon.
|
||||
// serial preserves OS delivery order; the work is IPC-free (just a wid lookup + reorder) so it never blocks.
|
||||
focusOrderQueue = LabeledOperationQueue("focusOrder", .userInteractive, 1)
|
||||
// we time key repeat on a background queue. We handle their consequence on the main-thread
|
||||
repeatingKeyQueue = LabeledOperationQueue("repeatingKey", .userInteractive, 1)
|
||||
// we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially
|
||||
@@ -62,7 +67,7 @@ class BackgroundWork {
|
||||
// useful during development to inspect how many threads are used by AltTab
|
||||
private static func logThreadsAndQueuesOnRepeat() {
|
||||
// if Logger.decideLevel() == .debug {
|
||||
debugMenu = DebugMenu([screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.fastQueue, AXCallScheduler.shared.retryQueue])
|
||||
debugMenu = DebugMenu([screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.axQueryFirstTryQueue, AXCallScheduler.shared.axQueryScanQueue, AXCallScheduler.shared.axQueryRetryQueue])
|
||||
debugMenu.orderFront(nil)
|
||||
debugMenu.start()
|
||||
// Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
@@ -73,7 +78,7 @@ class BackgroundWork {
|
||||
}
|
||||
|
||||
private static func logQueues() -> Void {
|
||||
let queues = [screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.fastQueue, AXCallScheduler.shared.retryQueue, crashReportsQueue].compactMap { $0 }
|
||||
let queues = [screenshotsQueue, accessibilityCommandsQueue, AXCallScheduler.shared.axQueryFirstTryQueue, AXCallScheduler.shared.axQueryScanQueue, AXCallScheduler.shared.axQueryRetryQueue, crashReportsQueue].compactMap { $0 }
|
||||
var map = [String:Int]()
|
||||
for queue in queues {
|
||||
map[queue.underlyingQueue!.label] = queue.operations.reduce(0) { $1.isExecuting ? $0 + 1 : $0 }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure scheduling decisions extracted from `Throttler` / `ThrottlerWithKey` and `AXCallScheduler`, so the
|
||||
/// timing logic is unit-testable without real clocks or queues (same pattern as `SelectionResolver` /
|
||||
/// `AxEventRouting`). The owners keep the actual clock reads + queue dispatch; they just branch on these.
|
||||
|
||||
/// The coalescing decision for one `throttleOrProceed` call: leading-edge runs immediately; calls within
|
||||
/// the window collapse to a single trailing run.
|
||||
enum ThrottleDecision: Equatable {
|
||||
case runNow // leading edge (or window already elapsed): run now, (re)start the window
|
||||
case scheduleTail(remainingNs: UInt64) // within window, no trailing run pending yet: schedule one after `remaining`
|
||||
case coalesce // within window, a trailing run is already pending: drop (latest runs on the tail)
|
||||
|
||||
static func decide(lastFireNs: UInt64?, nowNs: UInt64, delayNs: UInt64, tailScheduled: Bool) -> ThrottleDecision {
|
||||
// first call ever, or a (practically impossible) backwards clock → treat as a fresh leading edge
|
||||
guard let last = lastFireNs, nowNs >= last else { return .runNow }
|
||||
let elapsed = nowNs - last
|
||||
if elapsed >= delayNs { return .runNow }
|
||||
return tailScheduled ? .coalesce : .scheduleTail(remainingNs: delayNs - elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backoff + give-up policy for retrying an AX call against an unresponsive app.
|
||||
enum RetryPolicy {
|
||||
static let backoffStepsNs: [UInt64] = [200_000_000, 1_000_000_000, 2_000_000_000, 5_000_000_000] // 200ms, 1s, 2s, 5s…
|
||||
static let giveUpAfterNs: UInt64 = 60_000_000_000 // 60s
|
||||
|
||||
/// retry N uses step N, clamped to the last (so it stays at 5s); negative counts floor to the first step.
|
||||
static func backoffDelayNs(retryCount: Int) -> UInt64 {
|
||||
backoffStepsNs[min(max(0, retryCount), backoffStepsNs.count - 1)]
|
||||
}
|
||||
|
||||
static func shouldGiveUp(elapsedSinceStartNs: UInt64) -> Bool {
|
||||
elapsedSinceStartNs >= giveUpAfterNs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# SchedulingPolicy — Specs
|
||||
|
||||
## Summary
|
||||
|
||||
`SchedulingPolicy` holds the pure timing decisions behind the AX scheduling layer, extracted so they're
|
||||
testable without real clocks or queues (same pattern as `SelectionResolver` / `AxEventRouting`):
|
||||
|
||||
- **`ThrottleDecision`** — for one `throttleOrProceed` call: run on the leading edge, or (within the
|
||||
window) schedule a single trailing run and coalesce the rest. Used by `Throttler` and `ThrottlerWithKey`.
|
||||
- **`RetryPolicy`** — backoff schedule (200ms → 1s → 2s → 5s, then 5s) and the 60s give-up, for retrying
|
||||
an AX call against an unresponsive app. Used by `AXCallScheduler`.
|
||||
|
||||
## Test scenarios
|
||||
|
||||
Mirrors `SchedulingPolicyTests.swift` 1:1.
|
||||
|
||||
### A. ThrottleDecision
|
||||
- **testThrottleFirstCallRunsNow** — no prior fire → `runNow`.
|
||||
- **testThrottleAfterWindowRunsNow** — elapsed ≥ delay → `runNow` (window reset).
|
||||
- **testThrottleWithinWindowSchedulesTail** — within window, no tail pending → `scheduleTail(remaining)`.
|
||||
- **testThrottleWithinWindowWithPendingTailCoalesces** — within window, tail already pending → `coalesce`.
|
||||
- **testThrottleClockGoingBackwardsRunsNow** — now < last (monotonic-clock guard) → `runNow`.
|
||||
- **testThrottleBurstCoalescesAfterOneTail** — a burst yields one leading run, one `scheduleTail`, then `coalesce` for the rest.
|
||||
|
||||
### B. RetryPolicy
|
||||
- **testRetryBackoffSequence** — retry 0/1/2/3/4… → 200ms / 1s / 2s / 5s / 5s.
|
||||
- **testRetryBackoffClampsAndFloors** — counts past the last step clamp to 5s; negative counts floor to the first step.
|
||||
- **testRetryGivesUpAtThreshold** — elapsed ≥ 60s → give up.
|
||||
- **testRetryDoesNotGiveUpEarly** — elapsed < 60s → keep retrying.
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
|
||||
/// Pins the pure timing decisions of the AX scheduling layer — coalescing (leading + trailing + drop) and
|
||||
/// retry backoff/give-up — deterministically, with no clocks or queues. The owners (`Throttler`,
|
||||
/// `ThrottlerWithKey`, `AXCallScheduler`) branch on these, so keeping them green keeps that behavior fixed.
|
||||
final class SchedulingPolicyTests: XCTestCase {
|
||||
|
||||
// MARK: - A. ThrottleDecision
|
||||
|
||||
func testThrottleFirstCallRunsNow() {
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: nil, nowNs: 1000, delayNs: 200, tailScheduled: false), .runNow)
|
||||
}
|
||||
|
||||
func testThrottleAfterWindowRunsNow() {
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 1000, nowNs: 1200, delayNs: 200, tailScheduled: false), .runNow) // elapsed == delay
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 1000, nowNs: 5000, delayNs: 200, tailScheduled: true), .runNow) // well past, even with a stale tail flag
|
||||
}
|
||||
|
||||
func testThrottleWithinWindowSchedulesTail() {
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 1000, nowNs: 1050, delayNs: 200, tailScheduled: false), .scheduleTail(remainingNs: 150))
|
||||
}
|
||||
|
||||
func testThrottleWithinWindowWithPendingTailCoalesces() {
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 1000, nowNs: 1050, delayNs: 200, tailScheduled: true), .coalesce)
|
||||
}
|
||||
|
||||
func testThrottleClockGoingBackwardsRunsNow() {
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 1000, nowNs: 500, delayNs: 200, tailScheduled: false), .runNow)
|
||||
}
|
||||
|
||||
func testThrottleBurstCoalescesAfterOneTail() {
|
||||
// a live drag: leading call runs; the first follower schedules the single trailing run; the rest coalesce
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: nil, nowNs: 0, delayNs: 200, tailScheduled: false), .runNow)
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 0, nowNs: 10, delayNs: 200, tailScheduled: false), .scheduleTail(remainingNs: 190))
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 0, nowNs: 20, delayNs: 200, tailScheduled: true), .coalesce)
|
||||
XCTAssertEqual(ThrottleDecision.decide(lastFireNs: 0, nowNs: 30, delayNs: 200, tailScheduled: true), .coalesce)
|
||||
}
|
||||
|
||||
// MARK: - B. RetryPolicy
|
||||
|
||||
func testRetryBackoffSequence() {
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: 0), 200_000_000)
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: 1), 1_000_000_000)
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: 2), 2_000_000_000)
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: 3), 5_000_000_000)
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: 4), 5_000_000_000)
|
||||
}
|
||||
|
||||
func testRetryBackoffClampsAndFloors() {
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: 999), 5_000_000_000) // clamp to last step
|
||||
XCTAssertEqual(RetryPolicy.backoffDelayNs(retryCount: -1), 200_000_000) // floor to first step
|
||||
}
|
||||
|
||||
func testRetryGivesUpAtThreshold() {
|
||||
XCTAssertTrue(RetryPolicy.shouldGiveUp(elapsedSinceStartNs: 60_000_000_000))
|
||||
XCTAssertTrue(RetryPolicy.shouldGiveUp(elapsedSinceStartNs: 120_000_000_000))
|
||||
}
|
||||
|
||||
func testRetryDoesNotGiveUpEarly() {
|
||||
XCTAssertFalse(RetryPolicy.shouldGiveUp(elapsedSinceStartNs: 0))
|
||||
XCTAssertFalse(RetryPolicy.shouldGiveUp(elapsedSinceStartNs: 59_999_999_999))
|
||||
}
|
||||
}
|
||||
+37
-38
@@ -12,17 +12,18 @@ class Throttler {
|
||||
func throttleOrProceed(_ block: @escaping () -> Void) {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
let (elapsed, overflow) = now.subtractingReportingOverflow(lastTimeInNanoseconds)
|
||||
if !overflow, elapsed >= delayInNanoseconds {
|
||||
lastTimeInNanoseconds = now
|
||||
block()
|
||||
return
|
||||
}
|
||||
guard !nextScheduled else { return }
|
||||
nextScheduled = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .nanoseconds(Int(delayInNanoseconds) + 10_000_000)) { [self] in
|
||||
nextScheduled = false
|
||||
throttleOrProceed(block)
|
||||
switch ThrottleDecision.decide(lastFireNs: lastTimeInNanoseconds, nowNs: now, delayNs: delayInNanoseconds, tailScheduled: nextScheduled) {
|
||||
case .runNow:
|
||||
lastTimeInNanoseconds = now
|
||||
block()
|
||||
case .coalesce:
|
||||
break
|
||||
case .scheduleTail:
|
||||
nextScheduled = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .nanoseconds(Int(delayInNanoseconds) + 10_000_000)) { [self] in
|
||||
nextScheduled = false
|
||||
throttleOrProceed(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,39 +62,37 @@ class ThrottlerWithKey {
|
||||
}
|
||||
|
||||
func throttleOrProceed(key: String, queue: LabeledOperationQueue? = nil, priority: Operation.QueuePriority = .normal, _ block: @escaping () -> Void) {
|
||||
let shouldThrottle = map.withLock { map in
|
||||
let shouldThrottle = map.withLock { map -> Bool in
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
if let state = map[key] {
|
||||
let elapsed = now >= state.time ? (now - state.time) : delayInNanoseconds
|
||||
if elapsed < delayInNanoseconds {
|
||||
if !state.tailScheduled {
|
||||
map[key] = ThrottleState(time: state.time, tailScheduled: true)
|
||||
let remaining = delayInNanoseconds - elapsed
|
||||
let tailBlock = {
|
||||
let shouldExecute = self.map.withLock { map -> Bool in
|
||||
guard let state = map[key], state.tailScheduled else { return false }
|
||||
map[key] = ThrottleState(time: DispatchTime.now().uptimeNanoseconds, tailScheduled: false)
|
||||
return true
|
||||
}
|
||||
if shouldExecute { block() }
|
||||
switch ThrottleDecision.decide(lastFireNs: map[key]?.time, nowNs: now, delayNs: delayInNanoseconds, tailScheduled: map[key]?.tailScheduled ?? false) {
|
||||
case .runNow:
|
||||
map[key] = ThrottleState(time: now, tailScheduled: false)
|
||||
return false
|
||||
case .coalesce:
|
||||
return true
|
||||
case .scheduleTail(let remaining):
|
||||
map[key] = ThrottleState(time: map[key]!.time, tailScheduled: true)
|
||||
let tailBlock = {
|
||||
let shouldExecute = self.map.withLock { map -> Bool in
|
||||
guard let state = map[key], state.tailScheduled else { return false }
|
||||
map[key] = ThrottleState(time: DispatchTime.now().uptimeNanoseconds, tailScheduled: false)
|
||||
return true
|
||||
}
|
||||
if let queue {
|
||||
queue.strongUnderlyingQueue.asyncAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [weak queue] in
|
||||
guard let queue else { return }
|
||||
let op = BlockOperation(block: tailBlock)
|
||||
op.queuePriority = priority
|
||||
queue.addOperation(op)
|
||||
}
|
||||
} else {
|
||||
let callerQueue = OperationQueue.current?.underlyingQueue ?? DispatchQueue.main
|
||||
callerQueue.asyncAfter(deadline: .now() + .nanoseconds(Int(remaining)), execute: tailBlock)
|
||||
if shouldExecute { block() }
|
||||
}
|
||||
if let queue {
|
||||
queue.strongUnderlyingQueue.asyncAfter(deadline: .now() + .nanoseconds(Int(remaining))) { [weak queue] in
|
||||
guard let queue else { return }
|
||||
let op = BlockOperation(block: tailBlock)
|
||||
op.queuePriority = priority
|
||||
queue.addOperation(op)
|
||||
}
|
||||
} else {
|
||||
let callerQueue = OperationQueue.current?.underlyingQueue ?? DispatchQueue.main
|
||||
callerQueue.asyncAfter(deadline: .now() + .nanoseconds(Int(remaining)), execute: tailBlock)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
map[key] = ThrottleState(time: now, tailScheduled: false)
|
||||
return false
|
||||
}
|
||||
if !shouldThrottle {
|
||||
if let queue {
|
||||
|
||||
Reference in new issue
Block a user