Skip to content

Commit 6228b6a

Browse files
committed
⚡ Harden event tap lifecycle during shutdown
1 parent 4c8c5b0 commit 6228b6a

6 files changed

Lines changed: 220 additions & 31 deletions

File tree

Loop/App/AppDelegate.swift

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
139139
return .terminateLater
140140
}
141141

142+
// LoopManager and WindowDragManager are explicitly shut down so that their
143+
// event monitors are stopped immediately (in case they are active)
144+
LoopManager.shared.shutdown()
145+
WindowDragManager.shared.shutdown()
146+
142147
shutdownTask = Task { @MainActor in
143-
await StashManager.shared.shutdown()
148+
let didFinishStashShutdown = await runStashShutdownWithTimeout(.seconds(3))
149+
if !didFinishStashShutdown {
150+
log.warn("Timed out while restoring stashed windows during termination. Continuing shutdown.")
151+
}
152+
144153
self.shutdownTask = nil
145154
sender.reply(toApplicationShouldTerminate: true)
146155
}
@@ -153,4 +162,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
153162
urlCommandHandler.handle(url)
154163
}
155164
}
165+
166+
private func runStashShutdownWithTimeout(_ duration: Duration) async -> Bool {
167+
return await withCheckedContinuation { continuation in
168+
let reply = OneShotContinuation(continuation)
169+
170+
let shutdownTask = Task { @MainActor in
171+
await StashManager.shared.shutdown()
172+
reply.resume(returning: true)
173+
}
174+
175+
Task {
176+
try? await Task.sleep(for: duration)
177+
shutdownTask.cancel()
178+
reply.resume(returning: false)
179+
}
180+
}
181+
}
182+
}
183+
184+
private final class OneShotContinuation<T>: @unchecked Sendable {
185+
private let lock = NSLock()
186+
private var didResume = false
187+
private let continuation: CheckedContinuation<T, Never>
188+
189+
init(_ continuation: CheckedContinuation<T, Never>) {
190+
self.continuation = continuation
191+
}
192+
193+
func resume(returning result: T) {
194+
lock.lock()
195+
defer { lock.unlock() }
196+
197+
guard !didResume else { return }
198+
didResume = true
199+
continuation.resume(returning: result)
200+
}
156201
}

Loop/Core/LoopManager.swift

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import Defaults
9+
import os
910
import Scribe
1011
import SwiftUI
1112

@@ -25,7 +26,22 @@ final class LoopManager {
2526

2627
private var accessibilityCheckerTask: Task<(), Never>?
2728

28-
private(set) var isLoopActive: Bool = false
29+
private(set) var isLoopActive: Bool = false {
30+
didSet {
31+
let value = isLoopActive
32+
isLoopActiveMirror.withLock { $0 = value }
33+
}
34+
}
35+
36+
private let isLoopActiveMirror = OSAllocatedUnfairLock<Bool>(initialState: false)
37+
nonisolated var isLoopActiveAtomic: Bool {
38+
isLoopActiveMirror.withLock { $0 }
39+
}
40+
41+
private let hasParentCycleActionMirror = OSAllocatedUnfairLock<Bool>(initialState: false)
42+
nonisolated var hasParentCycleActionAtomic: Bool {
43+
hasParentCycleActionMirror.withLock { $0 }
44+
}
2945

3046
private lazy var triggerKeyTimeoutTimer = TriggerKeyTimeoutTimer(
3147
closeCallback: { [weak self] forceClose in
@@ -46,7 +62,7 @@ final class LoopManager {
4662
}
4763
},
4864
checkIfLoopOpen: { [weak self] in
49-
self?.isLoopActive ?? false
65+
self?.isLoopActiveAtomic ?? false
5066
}
5167
)
5268

@@ -61,7 +77,7 @@ final class LoopManager {
6177
await self?.closeLoop(forceClose: forceClose)
6278
}
6379
},
64-
checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false }
80+
checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false }
6581
)
6682

6783
private(set) lazy var mouseInteractionObserver = MouseInteractionObserver(
@@ -81,9 +97,9 @@ final class LoopManager {
8197
}
8298
},
8399
canSelectNextCycleitem: { [weak self] in
84-
self?.resizeContext.parentAction != nil
100+
self?.hasParentCycleActionAtomic ?? false
85101
},
86-
checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false }
102+
checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false }
87103
)
88104

89105
func start() {
@@ -103,6 +119,19 @@ final class LoopManager {
103119
}
104120
}
105121
}
122+
123+
func shutdown() {
124+
accessibilityCheckerTask?.cancel()
125+
accessibilityCheckerTask = nil
126+
127+
keybindTrigger.stop()
128+
middleClickTrigger.stop()
129+
mouseInteractionObserver.stop()
130+
triggerKeyTimeoutTimer.cancel()
131+
132+
isLoopActive = false
133+
hasParentCycleActionMirror.withLock { $0 = false }
134+
}
106135
}
107136

108137
// MARK: - Opening/Closing Loop
@@ -134,6 +163,9 @@ extension LoopManager {
134163
return
135164
}
136165

166+
isLoopActive = true
167+
hasParentCycleActionMirror.withLock { $0 = false }
168+
137169
log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")")
138170

139171
// Refresh accent colors in case user has enabled the wallpaper processor
@@ -163,7 +195,6 @@ extension LoopManager {
163195

164196
indicatorService.openAndUpdate(context: resizeContext)
165197

166-
isLoopActive = true
167198
await changeAction(startingAction, disableHapticFeedback: true)
168199

169200
triggerKeyTimeoutTimer.start()
@@ -175,6 +206,7 @@ extension LoopManager {
175206

176207
indicatorService.closeAll()
177208
isLoopActive = false
209+
hasParentCycleActionMirror.withLock { $0 = false }
178210

179211
triggerKeyTimeoutTimer.cancel()
180212
mouseInteractionObserver.stop()
@@ -312,7 +344,7 @@ extension LoopManager {
312344
if let lastAction = await WindowRecords.shared.getCurrentAction(for: targetWindow),
313345
lastAction.getName() != screenSwitchingCustomActionName,
314346
!lastAction.forceProportionalFrameOnScreenChange {
315-
resizeContext.setAction(to: lastAction, parent: nil)
347+
setResizeAction(to: lastAction, parent: nil)
316348
} else {
317349
let currentFrame = targetWindow.frame
318350

@@ -327,7 +359,7 @@ extension LoopManager {
327359
height: currentFrame.height / adjustedBounds.height
328360
)
329361

330-
resizeContext.setAction(
362+
setResizeAction(
331363
to: .init(
332364
.custom,
333365
keybind: [],
@@ -344,15 +376,15 @@ extension LoopManager {
344376
)
345377
}
346378
} else {
347-
resizeContext.setAction(to: .init(.center), parent: nil)
379+
setResizeAction(to: .init(.center), parent: nil)
348380
}
349381
}
350382

351383
resizeContext.setScreen(to: newScreen)
352384
indicatorService.openAndUpdate(context: resizeContext)
353385

354386
if let parent = newParentAction {
355-
resizeContext.setAction(to: newAction, parent: newParentAction)
387+
setResizeAction(to: newAction, parent: newParentAction)
356388
await changeAction(parent, triggeredFromScreenChange: true)
357389
} else {
358390
if !Defaults[.previewVisibility] {
@@ -377,7 +409,7 @@ extension LoopManager {
377409

378410
if newAction != resizeContext.action || newAction.canRepeat {
379411
let previousActionWasNoOp = resizeContext.action.direction.isNoOp
380-
resizeContext.setAction(to: newAction, parent: newParentAction)
412+
setResizeAction(to: newAction, parent: newParentAction)
381413
if !Defaults[.previewVisibility], !previousActionWasNoOp {
382414
await resizeContext.refreshResolvedState()
383415
}
@@ -459,6 +491,11 @@ extension LoopManager {
459491
}
460492
}
461493

494+
private func setResizeAction(to newAction: WindowAction, parent newParentAction: WindowAction?) {
495+
resizeContext.setAction(to: newAction, parent: newParentAction)
496+
hasParentCycleActionMirror.withLock { $0 = newParentAction != nil }
497+
}
498+
462499
/// Resolves the target screen for `screenToResizeOn`.
463500
///
464501
/// By default, this uses the user's `useScreenWithCursor` setting.

Loop/Core/Observers/MouseInteractionObserver.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ final class MouseInteractionObserver {
5454
}
5555

5656
func start(initialMousePosition: CGPoint) {
57+
stop()
58+
5759
screenBounds = NSScreen.screens.first(where: { $0.frame.contains(initialMousePosition) })?.frame
5860

5961
if let screenBounds {

Loop/Core/WindowDragManager.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,17 @@ final class WindowDragManager {
5656
}
5757
}
5858

59+
func shutdown() {
60+
accessibilityCheckerTask?.cancel()
61+
accessibilityCheckerTask = nil
62+
removeListeners()
63+
resetDragState()
64+
previewController.close()
65+
}
66+
5967
private func setupListeners() {
68+
removeListeners()
69+
6070
let leftMouseDraggedMonitor = PassiveEventMonitor(
6171
"snapping_left_mouse_dragged_monitor",
6272
events: [.leftMouseDragged],

Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,32 +21,19 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable {
2121
private(set) var isEnabled: Bool = false
2222

2323
deinit {
24-
if isEnabled {
25-
stop()
26-
}
27-
28-
// Clean up run loop source and event tap
29-
if let runLoop, let runLoopSource {
30-
CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes)
31-
self.runLoopSource = nil
32-
}
33-
34-
if let eventTap {
35-
CFMachPortInvalidate(eventTap)
36-
self.eventTap = nil
37-
}
24+
tearDownEventTap()
3825
}
3926

4027
func setupRunLoopSource(eventTap: CFMachPort, readableIdentifier: String) {
41-
// Runloop is already running here. In the future, we can investigate running the mach port on another thread.
42-
let runLoop = CFRunLoopGetMain()
28+
let runLoop = EventTapThread.shared.runLoop
4329
self.readableIdentifier = readableIdentifier
4430

4531
if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) {
4632
self.eventTap = eventTap
4733
self.runLoop = runLoop
4834
self.runLoopSource = runLoopSource
4935
CFRunLoopAddSource(runLoop, runLoopSource, .commonModes)
36+
CFRunLoopWakeUp(runLoop)
5037
}
5138
}
5239

@@ -64,19 +51,66 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable {
6451
}
6552

6653
func stop() {
67-
guard let eventTap else { return }
54+
guard eventTap != nil else { return }
6855

6956
if let readableIdentifier {
7057
log.info("Stopping BaseEventTapMonitor '\(readableIdentifier)'")
7158
} else {
7259
log.info("Stopping BaseEventTapMonitor with ID \(id)")
7360
}
7461

75-
CGEvent.tapEnable(tap: eventTap, enable: false)
76-
isEnabled = false
62+
tearDownEventTap()
7763
}
7864

7965
static func == (lhs: BaseEventTapMonitor, rhs: BaseEventTapMonitor) -> Bool {
8066
lhs.id == rhs.id
8167
}
68+
69+
private func tearDownEventTap() {
70+
guard eventTap != nil || runLoopSource != nil else { return }
71+
72+
let eventTap = eventTap
73+
let runLoop = runLoop
74+
let runLoopSource = runLoopSource
75+
76+
self.eventTap = nil
77+
self.runLoop = nil
78+
self.runLoopSource = nil
79+
isEnabled = false
80+
81+
guard let runLoop else {
82+
if let eventTap {
83+
CGEvent.tapEnable(tap: eventTap, enable: false)
84+
CFMachPortInvalidate(eventTap)
85+
}
86+
return
87+
}
88+
89+
let cleanup = {
90+
if let eventTap {
91+
CGEvent.tapEnable(tap: eventTap, enable: false)
92+
}
93+
94+
if let runLoopSource {
95+
CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes)
96+
}
97+
98+
if let eventTap {
99+
CFMachPortInvalidate(eventTap)
100+
}
101+
}
102+
103+
if CFRunLoopGetCurrent() == runLoop {
104+
cleanup()
105+
return
106+
}
107+
108+
let finished = DispatchSemaphore(value: 0)
109+
CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue) {
110+
cleanup()
111+
finished.signal()
112+
}
113+
CFRunLoopWakeUp(runLoop)
114+
finished.wait()
115+
}
82116
}

0 commit comments

Comments
 (0)