Skip to content

Commit

Permalink
fix: cpu and memory leaks (see discussion in #117)
Browse files Browse the repository at this point in the history
  • Loading branch information
lwouis committed Mar 10, 2020
1 parent b3fb222 commit 52626aa
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 25 deletions.
32 changes: 29 additions & 3 deletions src/api-wrappers/AXUIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,40 @@ extension AXUIElement {
return attribute(kAXSubroleAttribute, String.self)
}

func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ previousResult: AXError? = nil) {
func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ attemptsCount: Int = 0) {
if attemptsCount == 0 || attemptsCount % 1000 == 0 {
if let runningApplication = runningApplication {
// debugPrint("attempt pid", attemptsCount, pid, notification, Applications.appsInSubscriptionRetryLoop.filter { $0.starts(with: String(pid)) })
}
if let wid = wid {
// debugPrint("attempt wid", attemptsCount, wid, notification, Windows.windowsInSubscriptionRetryLoop.filter { $0.starts(with: String(wid)) })
}
}
if let runningApplication = runningApplication, Applications.appsInSubscriptionRetryLoop.first(where: { $0 == String(runningApplication.processIdentifier) + String(notification) }) == nil {
// debugPrint("early quit pid", attemptsCount, pid, notification)
return
}
if let wid = wid, Windows.windowsInSubscriptionRetryLoop.first(where: { $0 == String(wid) + String(notification) }) == nil {
// debugPrint("early quit wid", attemptsCount, wid, notification)
return
}
let result = AXObserverAddNotification(axObserver, self, notification as CFString, pointer)
if result == .success || result == .notificationUnsupported || result == .notificationAlreadyRegistered {
debugPrint("subbed", attemptsCount, runningApplication, wid, Applications.list.first(where: { $0.runningApplication.processIdentifier == runningApplication?.processIdentifier }))
callback?()
if let runningApplication = runningApplication {
Application.stopSubscriptionRetries(notification, runningApplication)
debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop)
}
if let wid = wid {
Window.stopSubscriptionRetries(notification, wid)
debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop)
}
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10), execute: {
self.subscribeWithRetry(axObserver, notification, pointer, callback, result)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10), execute: { [weak self] in
guard let self = self else { return }
self.subscribeWithRetry(axObserver, notification, pointer, callback, runningApplication, wid, attemptsCount + 1)
})
}

Expand Down
38 changes: 29 additions & 9 deletions src/logic/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ class Application: NSObject {
var axObserver: AXObserver?
var isReallyFinishedLaunching = false

static let notifications = [
kAXApplicationActivatedNotification,
kAXFocusedWindowChangedNotification,
kAXWindowCreatedNotification,
kAXApplicationHiddenNotification,
kAXApplicationShownNotification,
]

// some apps never finish their subscription retry loop; they should be stopped to avoid infinite loop
static func stopSubscriptionRetries(_ notification: String, _ runningApplication: NSRunningApplication) {
debugPrint("removeObservers", runningApplication.processIdentifier, runningApplication.bundleIdentifier)
Applications.appsInSubscriptionRetryLoop.removeAll { $0 == String(runningApplication.processIdentifier) + String(notification) }
}

init(_ runningApplication: NSRunningApplication) {
self.runningApplication = runningApplication
super.init()
Expand All @@ -16,6 +30,14 @@ class Application: NSObject {
}
}

deinit {
debugPrint("deinit", runningApplication.processIdentifier, runningApplication.bundleIdentifier)
// some apps never finish launching; subscription retries should be stopped to avoid infinite loops
Application.notifications.forEach { Application.stopSubscriptionRetries($0, runningApplication) }
// some apps never finish launching; observer should be removed to avoid leak
removeObserver()
}

func removeObserver() {
runningApplication.safeRemoveObserver(self, "isFinishedLaunching")
}
Expand Down Expand Up @@ -53,22 +75,20 @@ class Application: NSObject {
private func observeEvents() {
guard let axObserver = axObserver else { return }
let selfPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
for notification in [
kAXApplicationActivatedNotification,
kAXFocusedWindowChangedNotification,
kAXWindowCreatedNotification,
kAXApplicationHiddenNotification,
kAXApplicationShownNotification,
] {
axUiElement!.subscribeWithRetry(axObserver, notification, selfPointer, {
for notification in Application.notifications {
debugPrint("subscribeWithRetry app", runningApplication.processIdentifier, notification, runningApplication.bundleIdentifier)
Applications.appsInSubscriptionRetryLoop.append(String(runningApplication.processIdentifier) + String(notification))
axUiElement!.subscribeWithRetry(axObserver, notification, selfPointer, { [weak self] in
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
// we consider them ready when the first subscription succeeds, and list their windows again at that point
guard let self = self else { return }
if !self.isReallyFinishedLaunching {
self.isReallyFinishedLaunching = true
self.observeNewWindows()
}
})
}, runningApplication)
}
debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop)
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode)
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/logic/Applications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Cocoa
class Applications {
static var list = [Application]()
static var appsObserver = RunningApplicationsObserver()
static var appsInSubscriptionRetryLoop = [String]()

static func observeNewWindows() {
for app in list {
Expand Down Expand Up @@ -53,12 +54,11 @@ class Applications {

static func removeRunningApplications(_ runningApps: [NSRunningApplication]) {
for runningApp in runningApps {
guard let app = Applications.list.first(where: { $0.runningApplication.isEqual(runningApp) }) else { continue }
Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) })
// some apps never finish launching; the observer leaks for them without this
app.removeObserver()
Applications.list.removeAll(where: { $0.runningApplication.isEqual(runningApp) })
Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) })
}
debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop)
debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop)
guard Windows.list.count > 0 else { (App.shared as! App).hideUi(); return }
// TODO: implement of more sophisticated way to decide which thumbnail gets focused on app quit
Windows.focusedWindowIndex = 1
Expand Down
35 changes: 27 additions & 8 deletions src/logic/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ class Window {
var application: Application
var axObserver: AXObserver?

static let notifications = [
kAXUIElementDestroyedNotification,
kAXTitleChangedNotification,
kAXWindowMiniaturizedNotification,
kAXWindowDeminiaturizedNotification,
]

static func stopSubscriptionRetries(_ notification: String, _ cgWindowId: CGWindowID) {
debugPrint("removeObservers", cgWindowId)
Windows.windowsInSubscriptionRetryLoop.removeAll { $0 == (String(cgWindowId) + String(notification)) }
}

init(_ axUiElement: AXUIElement, _ application: Application) {
// TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values
self.axUiElement = axUiElement
Expand All @@ -30,17 +42,22 @@ class Window {
observeEvents()
}

deinit {
debugPrint("deinit", cgWindowId, title)
// some windows never finish launching; subscription retries should be stopped to avoid infinite loops
Window.notifications.forEach { Window.stopSubscriptionRetries($0, cgWindowId) }
}

private func observeEvents() {
AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver)
guard let axObserver = axObserver else { return }
for notification in [
kAXUIElementDestroyedNotification,
kAXTitleChangedNotification,
kAXWindowMiniaturizedNotification,
kAXWindowDeminiaturizedNotification,
] {
axUiElement.subscribeWithRetry(axObserver, notification, nil)
for notification in Window.notifications {
debugPrint("subscribeWithRetry win", cgWindowId, notification, title)
Windows.windowsInSubscriptionRetryLoop.append(String(cgWindowId) + String(notification))
axUiElement.subscribeWithRetry(axObserver, notification, nil, nil, nil, cgWindowId)
}
debugPrint("app sub list", Applications.appsInSubscriptionRetryLoop)
debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop)
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode)
}

Expand All @@ -56,7 +73,8 @@ class Window {
// macOS bug: when switching to a System Preferences window in another space, it switches to that space,
// but quickly switches back to another window in that space
// You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug
DispatchQueues.focusActions.async {
DispatchQueues.focusActions.async { [weak self] in
guard let self = self else { return }
var elementConnection = UInt32(0)
CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection)
var psn = ProcessSerialNumber()
Expand Down Expand Up @@ -115,6 +133,7 @@ private func axObserverCallback(observer: AXObserver, element: AXUIElement, noti
private func eventWindowDestroyed(_ app: App, _ element: AXUIElement) {
guard let existingIndex = Windows.list.firstIndexThatMatches(element) else { return }
Windows.list.remove(at: existingIndex)
debugPrint("win sub list", Windows.windowsInSubscriptionRetryLoop)
guard Windows.list.count > 0 else { app.hideUi(); return }
Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex)
app.refreshOpenUi()
Expand Down
1 change: 1 addition & 0 deletions src/logic/Windows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Windows {
// order in the array is important: most-recently-used elements are first
static var list = [Window]()
static var focusedWindowIndex = Array<Window>.Index(0)
static var windowsInSubscriptionRetryLoop = [String]()

static func focusedWindow() -> Window? {
return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil
Expand Down
3 changes: 2 additions & 1 deletion src/ui/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ class App: NSApplication, NSApplicationDelegate {
Windows.refreshAllThumbnails()
Windows.focusedWindowIndex = 0
Windows.cycleFocusedWindowIndex(step)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: { [weak self] in
guard let self = self else { return }
self.refreshOpenUi()
if self.uiWorkShouldBeDone { self.thumbnailsPanel?.show() }
})
Expand Down

0 comments on commit 52626aa

Please sign in to comment.