From b686d9be696296d571e00647e119e80d464fc445 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:12:03 -0800 Subject: [PATCH 1/5] fix: prevent stuck middle-drag when events are swallowed Fixes #77 - Middle button gets stuck (no MIDDLE_UP) when: - App focus changes mid-gesture - Windows with nil/empty names (Find My, Finder, Wine apps) - Rapid/overlapping gestures cause orphaned MIDDLE_DOWN Changes: - Add double-start guard: cancel existing drag before starting new one - Add watchdog timer: auto-release after 10s of inactivity - Add activity tracking: record last updateDrag() timestamp - Add forceReleaseStuckDrag() API for manual recovery - Add 'Force Release Stuck Drag' menu item in Advanced menu The watchdog sends multiple MIDDLE_UP events (sync + async) to ensure release even if one is swallowed by problematic windows. --- MiddleDrag/Core/MouseEventGenerator.swift | 127 ++++++++++++++++++++ MiddleDrag/Managers/MultitouchManager.swift | 20 +++ MiddleDrag/UI/MenuBarController.swift | 15 +++ 3 files changed, 162 insertions(+) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 6584abb..235354d 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -15,6 +15,10 @@ final class MouseEventGenerator: @unchecked Sendable { /// Minimum movement threshold in pixels to prevent jitter var minimumMovementThreshold: CGFloat = 0.5 + + /// Timeout in seconds for stuck drag detection (no activity = stuck) + /// After this many seconds without updateDrag calls, the drag is auto-released + var stuckDragTimeout: TimeInterval = 10.0 // State tracking - protected by stateLock for thread safety // isMiddleMouseDown is read from multiple threads (updateDrag on gesture queue, @@ -30,6 +34,12 @@ final class MouseEventGenerator: @unchecked Sendable { // Event generation queue for thread safety private let eventQueue = DispatchQueue(label: "com.middledrag.mouse", qos: .userInitiated) + + // Watchdog timer for stuck drag detection + private var watchdogTimer: DispatchSourceTimer? + private let watchdogQueue = DispatchQueue(label: "com.middledrag.watchdog", qos: .utility) + private var lastActivityTime: CFTimeInterval = 0 + private let activityLock = NSLock() // Smoothing state for EMA (exponential moving average) private var previousDeltaX: CGFloat = 0 @@ -53,6 +63,15 @@ final class MouseEventGenerator: @unchecked Sendable { /// Start a middle mouse drag operation /// - Parameter screenPosition: Starting position (used for reference, actual position from current cursor) func startDrag(at screenPosition: CGPoint) { + // CRITICAL: If already in a drag state, cancel it first to prevent stuck drags + // This handles the case where a second MIDDLE_DOWN arrives before the first MIDDLE_UP + if isMiddleMouseDown { + Log.warning("startDrag called while already dragging - canceling existing drag first", category: .gesture) + // Send mouse up for the existing drag immediately (synchronously) + let currentPos = currentMouseLocationQuartz + sendMiddleMouseUp(at: currentPos) + } + // Initialize position synchronously to prevent race conditions with updateDrag let quartzPos = currentMouseLocationQuartz positionLock.lock() @@ -62,6 +81,11 @@ final class MouseEventGenerator: @unchecked Sendable { // Reset smoothing state previousDeltaX = 0 previousDeltaY = 0 + + // Record activity time for watchdog + activityLock.lock() + lastActivityTime = CACurrentMediaTime() + activityLock.unlock() // CRITICAL: Both flag AND mouse-down event must be set/sent SYNCHRONOUSLY. // This prevents two race conditions: @@ -72,6 +96,9 @@ final class MouseEventGenerator: @unchecked Sendable { // and takes microseconds. No need for async dispatch here. isMiddleMouseDown = true sendMiddleMouseDown(at: quartzPos) + + // Start watchdog timer to detect stuck drags + startWatchdog() } /// Magic number to identify our own events (0x4D44 = 'MD') @@ -83,6 +110,11 @@ final class MouseEventGenerator: @unchecked Sendable { /// - deltaY: Vertical movement delta func updateDrag(deltaX: CGFloat, deltaY: CGFloat) { guard isMiddleMouseDown else { return } + + // Record activity time for watchdog (drag is still active) + activityLock.lock() + lastActivityTime = CACurrentMediaTime() + activityLock.unlock() // Apply consistent smoothing to both horizontal and vertical movement // Uses the user's configured smoothing factor for both axes @@ -213,6 +245,9 @@ final class MouseEventGenerator: @unchecked Sendable { /// End the drag operation func endDrag() { guard isMiddleMouseDown else { return } + + // Stop watchdog since drag is ending normally + stopWatchdog() // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag // This prevents race conditions with rapid start/end cycles and ensures @@ -295,6 +330,9 @@ final class MouseEventGenerator: @unchecked Sendable { /// Cancel any active drag operation func cancelDrag() { guard isMiddleMouseDown else { return } + + // Stop watchdog since drag is being cancelled + stopWatchdog() // CRITICAL: Set isMiddleMouseDown = false SYNCHRONOUSLY to match startDrag // This prevents race conditions with rapid cancel/start cycles and ensures @@ -411,4 +449,93 @@ final class MouseEventGenerator: @unchecked Sendable { event.flags = [] event.post(tap: .cghidEventTap) } + + // MARK: - Watchdog Timer + + /// Start the watchdog timer to detect stuck drags + private func startWatchdog() { + stopWatchdog() // Cancel any existing timer + + let timer = DispatchSource.makeTimerSource(queue: watchdogQueue) + timer.schedule(deadline: .now() + 1.0, repeating: 1.0) // Check every second + + timer.setEventHandler { [weak self] in + self?.checkForStuckDrag() + } + + watchdogTimer = timer + timer.resume() + } + + /// Stop the watchdog timer + private func stopWatchdog() { + watchdogTimer?.cancel() + watchdogTimer = nil + } + + /// Check if the drag has become stuck (no activity for too long) + private func checkForStuckDrag() { + guard isMiddleMouseDown else { + // Drag already ended, stop checking + stopWatchdog() + return + } + + activityLock.lock() + let lastActivity = lastActivityTime + activityLock.unlock() + + let timeSinceActivity = CACurrentMediaTime() - lastActivity + + if timeSinceActivity > stuckDragTimeout { + // Drag appears to be stuck - auto-release + Log.warning( + "Stuck drag detected - no activity for \(String(format: "%.1f", timeSinceActivity))s, auto-releasing", + category: .gesture + ) + + // Log to Sentry if telemetry is enabled + if CrashReporter.shared.anyTelemetryEnabled { + let attributes: [String: Any] = [ + "category": "gesture", + "event": "stuck_drag_auto_release", + "time_since_activity": timeSinceActivity, + "timeout_threshold": stuckDragTimeout, + "session_id": Log.sessionID, + ] + SentrySDK.logger.warn("Stuck drag auto-released after timeout", attributes: attributes) + } + + // Force release the stuck drag + forceReleaseDrag() + } + } + + /// Force release a stuck drag without normal cleanup flow + /// This is called by the watchdog when a drag appears stuck + private func forceReleaseDrag() { + stopWatchdog() + + // Set flag to false immediately to stop any further processing + isMiddleMouseDown = false + + // Send mouse up event synchronously to ensure it gets through + let currentPos = currentMouseLocationQuartz + sendMiddleMouseUp(at: currentPos) + + // Also try sending via async queue in case the sync one gets swallowed + eventQueue.async { [weak self] in + guard let self = self else { return } + // Reset smoothing state + self.previousDeltaX = 0 + self.previousDeltaY = 0 + self.positionLock.lock() + self.lastSentPosition = nil + self.positionLock.unlock() + + // Send another mouse up as a fallback + let pos = self.currentMouseLocationQuartz + self.sendMiddleMouseUp(at: pos) + } + } } diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index 459b8e9..ccd01a4 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -306,6 +306,26 @@ final class MultitouchManager: @unchecked Sendable { configuration = config applyConfiguration() } + + /// Force release any stuck middle-drag state + /// This can be called manually by the user (e.g., from menu bar) if they notice + /// the middle button is stuck. It sends a MIDDLE_UP event regardless of current state. + func forceReleaseStuckDrag() { + Log.info("Force releasing stuck drag (user triggered)", category: .gesture) + + // Reset all internal state + isActivelyDragging = false + isInThreeFingerGesture = false + gestureEndTime = CACurrentMediaTime() + lastGestureWasActive = false + + // Cancel any active drag in the mouse generator + // This also sends a MIDDLE_UP event + mouseGenerator.cancelDrag() + + // Also reset the gesture recognizer to ensure clean state + gestureRecognizer.reset() + } // MARK: - Event Tap diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index ccc4d06..0d06a79 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -260,6 +260,17 @@ class MenuBarController: NSObject { )) submenu.addItem(NSMenuItem.separator()) + + // Emergency release for stuck drags + let forceReleaseItem = NSMenuItem( + title: "Force Release Stuck Drag", + action: #selector(forceReleaseStuckDrag), + keyEquivalent: "" + ) + forceReleaseItem.target = self + submenu.addItem(forceReleaseItem) + + submenu.addItem(NSMenuItem.separator()) // Telemetry section header let telemetryHeader = NSMenuItem( @@ -594,6 +605,10 @@ class MenuBarController: NSObject { buildMenu() NotificationCenter.default.post(name: .preferencesChanged, object: preferences) } + + @objc func forceReleaseStuckDrag() { + multitouchManager?.forceReleaseStuckDrag() + } @objc func toggleLaunchAtLogin() { preferences.launchAtLogin.toggle() From bd2086955dd66b0cb64274ecea4175c76b9b2092 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:27:44 -0800 Subject: [PATCH 2/5] test: add tests for stuck drag prevention Add comprehensive tests for new stuck-drag prevention features: MouseEventGeneratorTests (14 new tests): - testDefaultStuckDragTimeout - testStuckDragTimeoutCanBeModified - testDoubleStartDragCancelsExistingDrag - testTripleStartDragHandledGracefully - testRapidStartEndCycles - testStartDragWhileDraggingThenCancel - testUpdateDragResetsActivityTime - testWatchdogTimerStartsAndStopsWithDrag - testCancelDragStopsWatchdog - testVeryShortStuckDragTimeout - testActivityPreventsWatchdogTimeout - testDoubleStartWithUpdatesInBetween MultitouchManagerTests (8 new tests): - testForceReleaseStuckDragResetsGestureState - testForceReleaseStuckDragResetsDraggingState - testForceReleaseStuckDragWhenNotDragging - testForceReleaseStuckDragMultipleTimes - testForceReleaseStuckDragResetsGestureRecognizer - testForceReleaseStuckDragWhileDisabled - testForceReleaseStuckDragWhenStopped - testForceReleaseAfterNormalEndDrag --- .../MouseEventGeneratorTests.swift | 271 ++++++++++++++++++ .../MultitouchManagerTests.swift | 210 ++++++++++++++ 2 files changed, 481 insertions(+) diff --git a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift index 492002c..7b345a6 100644 --- a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift +++ b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift @@ -537,4 +537,275 @@ final class MouseEventGeneratorTests: XCTestCase { generator.endDrag() } + + // MARK: - Stuck Drag Prevention Tests + + func testDefaultStuckDragTimeout() { + // Default timeout should be 10 seconds + XCTAssertEqual(generator.stuckDragTimeout, 10.0, accuracy: 0.001) + } + + func testStuckDragTimeoutCanBeModified() { + generator.stuckDragTimeout = 5.0 + XCTAssertEqual(generator.stuckDragTimeout, 5.0, accuracy: 0.001) + } + + func testDoubleStartDragCancelsExistingDrag() { + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let firstStartExpectation = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + firstStartExpectation.fulfill() + } + wait(for: [firstStartExpectation], timeout: 0.5) + + // Start second drag without ending first - should not crash + // The existing drag should be cancelled automatically + XCTAssertNoThrow(generator.startDrag(at: CGPoint(x: 200, y: 200))) + + let secondStartExpectation = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + secondStartExpectation.fulfill() + } + wait(for: [secondStartExpectation], timeout: 0.5) + + // Updates should still work after double-start + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + + generator.endDrag() + } + + func testTripleStartDragHandledGracefully() { + // Start three drags in quick succession + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let exp1 = XCTestExpectation(description: "First start") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + exp1.fulfill() + } + wait(for: [exp1], timeout: 0.5) + + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + let exp2 = XCTestExpectation(description: "Second start") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + exp2.fulfill() + } + wait(for: [exp2], timeout: 0.5) + + generator.startDrag(at: CGPoint(x: 300, y: 300)) + + let exp3 = XCTestExpectation(description: "Third start") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + exp3.fulfill() + } + wait(for: [exp3], timeout: 0.5) + + // Should be able to update and end normally + XCTAssertNoThrow(generator.updateDrag(deltaX: 5.0, deltaY: 5.0)) + XCTAssertNoThrow(generator.endDrag()) + } + + func testRapidStartEndCycles() { + // Test rapid start/end cycles to stress test the double-start guard + for i in 1...5 { + generator.startDrag(at: CGPoint(x: CGFloat(i * 100), y: CGFloat(i * 100))) + + let startExp = XCTestExpectation(description: "Start \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + generator.endDrag() + + let endExp = XCTestExpectation(description: "End \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + endExp.fulfill() + } + wait(for: [endExp], timeout: 0.5) + } + } + + func testStartDragWhileDraggingThenCancel() { + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Start second drag (should auto-cancel first) + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + let secondExpectation = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + secondExpectation.fulfill() + } + wait(for: [secondExpectation], timeout: 0.5) + + // Now cancel - should only cancel the second drag + XCTAssertNoThrow(generator.cancelDrag()) + + let cancelExpectation = XCTestExpectation(description: "Drag cancelled") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + cancelExpectation.fulfill() + } + wait(for: [cancelExpectation], timeout: 0.5) + + // Updates should be ignored after cancel + XCTAssertNoThrow(generator.updateDrag(deltaX: 50.0, deltaY: 50.0)) + } + + func testUpdateDragResetsActivityTime() { + // This test verifies that updateDrag updates activity tracking + // (the watchdog won't trigger if updates keep happening) + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Perform multiple updates with small delays + for i in 1...3 { + XCTAssertNoThrow(generator.updateDrag(deltaX: CGFloat(i), deltaY: CGFloat(i))) + + let updateExp = XCTestExpectation(description: "Update \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + updateExp.fulfill() + } + wait(for: [updateExp], timeout: 0.5) + } + + generator.endDrag() + } + + func testWatchdogTimerStartsAndStopsWithDrag() { + // Start drag - watchdog should start + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started with watchdog") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // End drag - watchdog should stop + generator.endDrag() + + let endExpectation = XCTestExpectation(description: "Drag ended, watchdog stopped") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + endExpectation.fulfill() + } + wait(for: [endExpectation], timeout: 0.5) + } + + func testCancelDragStopsWatchdog() { + // Start drag - watchdog should start + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Cancel drag - watchdog should stop + generator.cancelDrag() + + let cancelExpectation = XCTestExpectation(description: "Drag cancelled, watchdog stopped") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + cancelExpectation.fulfill() + } + wait(for: [cancelExpectation], timeout: 0.5) + } + + func testVeryShortStuckDragTimeout() { + // Set a very short timeout for testing (not recommended in production) + generator.stuckDragTimeout = 0.5 + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Wait longer than the timeout without any updates + // The watchdog should auto-release the drag + let timeoutExpectation = XCTestExpectation(description: "Watchdog timeout") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + timeoutExpectation.fulfill() + } + wait(for: [timeoutExpectation], timeout: 3.0) + + // After auto-release, updates should be ignored + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + + // endDrag should be a no-op since already released + XCTAssertNoThrow(generator.endDrag()) + } + + func testActivityPreventsWatchdogTimeout() { + // Set a short timeout + generator.stuckDragTimeout = 1.5 + + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Keep sending updates synchronously with small delays to prevent timeout + // Total time: 8 * 0.15 = 1.2s which is less than 1.5s timeout + for i in 1...8 { + generator.updateDrag(deltaX: CGFloat(i), deltaY: CGFloat(i)) + + let delayExp = XCTestExpectation(description: "Delay \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + delayExp.fulfill() + } + wait(for: [delayExp], timeout: 0.5) + } + + // Should still be able to end normally (not auto-released) + XCTAssertNoThrow(generator.endDrag()) + } + + func testDoubleStartWithUpdatesInBetween() { + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExpectation = XCTestExpectation(description: "First drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 0.5) + + // Do some updates + generator.updateDrag(deltaX: 10.0, deltaY: 10.0) + generator.updateDrag(deltaX: 20.0, deltaY: 20.0) + + // Start second drag (should cancel first and reset state) + generator.startDrag(at: CGPoint(x: 500, y: 500)) + + let secondExpectation = XCTestExpectation(description: "Second drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + secondExpectation.fulfill() + } + wait(for: [secondExpectation], timeout: 0.5) + + // New updates should work from the new starting position + XCTAssertNoThrow(generator.updateDrag(deltaX: 5.0, deltaY: 5.0)) + + generator.endDrag() + } } diff --git a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift index 3c8f3b3..c2c9755 100644 --- a/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift +++ b/MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift @@ -2143,4 +2143,214 @@ final class MultitouchManagerTests: XCTestCase { wait(for: [expectation], timeout: 10.0) manager.stop() } + + // MARK: - Force Release Stuck Drag Tests + + func testForceReleaseStuckDragResetsGestureState() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Enter gesture state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + + let startExpectation = XCTestExpectation(description: "Gesture started") + DispatchQueue.main.async { + XCTAssertTrue(manager.isInThreeFingerGesture) + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 1.0) + + // Force release + manager.forceReleaseStuckDrag() + + let releaseExpectation = XCTestExpectation(description: "Force release complete") + DispatchQueue.main.async { + XCTAssertFalse(manager.isInThreeFingerGesture) + releaseExpectation.fulfill() + } + wait(for: [releaseExpectation], timeout: 1.0) + + manager.stop() + } + + func testForceReleaseStuckDragResetsDraggingState() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + var config = GestureConfiguration() + config.middleDragEnabled = true + manager.updateConfiguration(config) + + manager.start() + + // Enter dragging state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let startExpectation = XCTestExpectation(description: "Dragging started") + DispatchQueue.main.async { + XCTAssertTrue(manager.isActivelyDragging) + startExpectation.fulfill() + } + wait(for: [startExpectation], timeout: 1.0) + + // Force release + manager.forceReleaseStuckDrag() + + let releaseExpectation = XCTestExpectation(description: "Force release complete") + DispatchQueue.main.async { + XCTAssertFalse(manager.isActivelyDragging) + XCTAssertFalse(manager.isInThreeFingerGesture) + releaseExpectation.fulfill() + } + wait(for: [releaseExpectation], timeout: 1.0) + + manager.stop() + } + + func testForceReleaseStuckDragWhenNotDragging() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + + // Force release when not in any gesture state - should not crash + XCTAssertFalse(manager.isInThreeFingerGesture) + XCTAssertFalse(manager.isActivelyDragging) + + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + + // State should still be false + XCTAssertFalse(manager.isInThreeFingerGesture) + XCTAssertFalse(manager.isActivelyDragging) + + manager.stop() + } + + func testForceReleaseStuckDragMultipleTimes() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Enter gesture state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let setupExpectation = XCTestExpectation(description: "Setup complete") + DispatchQueue.main.async { + setupExpectation.fulfill() + } + wait(for: [setupExpectation], timeout: 1.0) + + // Force release multiple times - should not crash + for _ in 1...3 { + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + } + + manager.stop() + } + + func testForceReleaseStuckDragResetsGestureRecognizer() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Enter gesture state + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let setupExpectation = XCTestExpectation(description: "Setup complete") + DispatchQueue.main.async { + setupExpectation.fulfill() + } + wait(for: [setupExpectation], timeout: 1.0) + + // Force release + manager.forceReleaseStuckDrag() + + // Should be able to start a new gesture after force release + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.6, y: 0.6)) + + let newGestureExpectation = XCTestExpectation(description: "New gesture started") + DispatchQueue.main.async { + XCTAssertTrue(manager.isInThreeFingerGesture) + newGestureExpectation.fulfill() + } + wait(for: [newGestureExpectation], timeout: 1.0) + + manager.stop() + } + + func testForceReleaseStuckDragWhileDisabled() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + manager.start() + manager.toggleEnabled() // Disable + + XCTAssertFalse(manager.isEnabled) + + // Force release when disabled - should not crash + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + + manager.stop() + } + + func testForceReleaseStuckDragWhenStopped() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + + // Don't start the manager + + // Force release when stopped - should not crash + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + } + + func testForceReleaseAfterNormalEndDrag() { + let mockDevice = unsafe MockDeviceMonitor() + let manager = MultitouchManager( + deviceProviderFactory: { unsafe mockDevice }, eventTapSetup: { true }) + let recognizer = GestureRecognizer() + + manager.start() + + // Start and end drag normally + manager.gestureRecognizerDidStart(recognizer, at: MTPoint(x: 0.5, y: 0.5)) + manager.gestureRecognizerDidBeginDragging(recognizer) + + let setupExpectation = XCTestExpectation(description: "Setup") + DispatchQueue.main.async { + setupExpectation.fulfill() + } + wait(for: [setupExpectation], timeout: 1.0) + + manager.gestureRecognizerDidEndDragging(recognizer) + + let endExpectation = XCTestExpectation(description: "Normal end") + DispatchQueue.main.async { + XCTAssertFalse(manager.isActivelyDragging) + endExpectation.fulfill() + } + wait(for: [endExpectation], timeout: 1.0) + + // Force release after normal end - should be no-op but not crash + XCTAssertNoThrow(manager.forceReleaseStuckDrag()) + + manager.stop() + } } From 2e69081696dac6105281fc1dda72859c1f1cc923 Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:10:22 -0800 Subject: [PATCH 3/5] fix: address thread safety issues in stuck drag prevention Address Copilot-identified race conditions in watchdog and force release: MouseEventGenerator.swift: - Add dragGeneration token to prevent async cleanup from affecting new drags - Synchronize all watchdogTimer access on watchdogQueue - Add stopWatchdogLocked() for internal calls already on watchdogQueue - Add forceMiddleMouseUp() for unconditional MIDDLE_UP (always sends) - Mark Sentry logging as unsafe for Swift 6 concurrency MultitouchManager.swift: - Wrap forceReleaseStuckDrag() in DispatchQueue.main.async to match other gesture state updates and avoid data races - Use forceMiddleMouseUp() instead of cancelDrag() to ensure UP is sent even when internal state shows drag already ended Fixes: 1. watchdogTimer data race from multi-thread access 2. Async force-release interfering with newly started drags 3. forceReleaseStuckDrag not sending UP when state already false 4. forceReleaseStuckDrag mutating state off main thread --- MiddleDrag/Core/MouseEventGenerator.swift | 100 +++++++++++++++++--- MiddleDrag/Managers/MultitouchManager.swift | 34 ++++--- 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 235354d..e0ad111 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -40,6 +40,10 @@ final class MouseEventGenerator: @unchecked Sendable { private let watchdogQueue = DispatchQueue(label: "com.middledrag.watchdog", qos: .utility) private var lastActivityTime: CFTimeInterval = 0 private let activityLock = NSLock() + + /// Drag session generation counter to prevent async operations from affecting new drags + /// Incremented each time a new drag starts; async cleanup checks this to avoid interfering + private var dragGeneration: UInt64 = 0 // Smoothing state for EMA (exponential moving average) private var previousDeltaX: CGFloat = 0 @@ -95,6 +99,7 @@ final class MouseEventGenerator: @unchecked Sendable { // sendMiddleMouseDown() just creates and posts a CGEvent, which is thread-safe // and takes microseconds. No need for async dispatch here. isMiddleMouseDown = true + dragGeneration &+= 1 // Increment generation for new drag session sendMiddleMouseDown(at: quartzPos) // Start watchdog timer to detect stuck drags @@ -354,6 +359,44 @@ final class MouseEventGenerator: @unchecked Sendable { self.sendMiddleMouseUp(at: currentPos) } } + + /// Force send a MIDDLE_UP event regardless of internal state + /// Used for manual recovery when system state may be out of sync with our tracking + /// Unlike cancelDrag(), this ALWAYS sends an UP event even if isMiddleMouseDown is false + func forceMiddleMouseUp() { + Log.warning("Force sending MIDDLE_UP (unconditional)", category: .gesture) + + // Stop watchdog if running + stopWatchdog() + + // Reset internal state unconditionally + isMiddleMouseDown = false + + // Capture generation to check in async block + let currentGeneration = dragGeneration + + // Always send the UP event synchronously + let pos = currentMouseLocationQuartz + sendMiddleMouseUp(at: pos) + + // Also send async as fallback (in case sync was swallowed) + eventQueue.async { [weak self] in + guard let self = self else { return } + + // Only proceed if no new drag started + guard self.dragGeneration == currentGeneration else { return } + + // Reset state and send another UP + self.previousDeltaX = 0 + self.previousDeltaY = 0 + self.positionLock.lock() + self.lastSentPosition = nil + self.positionLock.unlock() + + let pos = self.currentMouseLocationQuartz + self.sendMiddleMouseUp(at: pos) + } + } // MARK: - Coordinate Conversion @@ -453,31 +496,45 @@ final class MouseEventGenerator: @unchecked Sendable { // MARK: - Watchdog Timer /// Start the watchdog timer to detect stuck drags + /// All watchdogTimer access is synchronized on watchdogQueue to prevent data races private func startWatchdog() { - stopWatchdog() // Cancel any existing timer - - let timer = DispatchSource.makeTimerSource(queue: watchdogQueue) - timer.schedule(deadline: .now() + 1.0, repeating: 1.0) // Check every second - - timer.setEventHandler { [weak self] in - self?.checkForStuckDrag() + watchdogQueue.async { [weak self] in + guard let self = self else { return } + + // Cancel any existing timer first + self.stopWatchdogLocked() + + let timer = DispatchSource.makeTimerSource(queue: self.watchdogQueue) + timer.schedule(deadline: .now() + 1.0, repeating: 1.0) // Check every second + + timer.setEventHandler { [weak self] in + self?.checkForStuckDrag() + } + + self.watchdogTimer = timer + timer.resume() } - - watchdogTimer = timer - timer.resume() } - /// Stop the watchdog timer + /// Stop the watchdog timer (thread-safe wrapper) private func stopWatchdog() { + watchdogQueue.async { [weak self] in + self?.stopWatchdogLocked() + } + } + + /// Stop the watchdog timer - must be called only on watchdogQueue + private func stopWatchdogLocked() { watchdogTimer?.cancel() watchdogTimer = nil } /// Check if the drag has become stuck (no activity for too long) + /// Called on watchdogQueue private func checkForStuckDrag() { guard isMiddleMouseDown else { // Drag already ended, stop checking - stopWatchdog() + stopWatchdogLocked() return } @@ -496,14 +553,14 @@ final class MouseEventGenerator: @unchecked Sendable { // Log to Sentry if telemetry is enabled if CrashReporter.shared.anyTelemetryEnabled { - let attributes: [String: Any] = [ + let attributes: [String: Any] = unsafe [ "category": "gesture", "event": "stuck_drag_auto_release", "time_since_activity": timeSinceActivity, "timeout_threshold": stuckDragTimeout, "session_id": Log.sessionID, ] - SentrySDK.logger.warn("Stuck drag auto-released after timeout", attributes: attributes) + unsafe SentrySDK.logger.warn("Stuck drag auto-released after timeout", attributes: attributes) } // Force release the stuck drag @@ -513,8 +570,13 @@ final class MouseEventGenerator: @unchecked Sendable { /// Force release a stuck drag without normal cleanup flow /// This is called by the watchdog when a drag appears stuck + /// Called on watchdogQueue private func forceReleaseDrag() { - stopWatchdog() + stopWatchdogLocked() + + // Capture generation before releasing to prevent async block from + // interfering with a new drag that might start immediately after + let releasedGeneration = dragGeneration // Set flag to false immediately to stop any further processing isMiddleMouseDown = false @@ -524,8 +586,16 @@ final class MouseEventGenerator: @unchecked Sendable { sendMiddleMouseUp(at: currentPos) // Also try sending via async queue in case the sync one gets swallowed + // Only proceeds if no new drag has started (same generation) eventQueue.async { [weak self] in guard let self = self else { return } + + // Check if a new drag started - don't interfere with it + guard self.dragGeneration == releasedGeneration else { + Log.info("Skipping async force-release cleanup - new drag session started", category: .gesture) + return + } + // Reset smoothing state self.previousDeltaX = 0 self.previousDeltaY = 0 diff --git a/MiddleDrag/Managers/MultitouchManager.swift b/MiddleDrag/Managers/MultitouchManager.swift index ccd01a4..77a61cf 100644 --- a/MiddleDrag/Managers/MultitouchManager.swift +++ b/MiddleDrag/Managers/MultitouchManager.swift @@ -311,20 +311,26 @@ final class MultitouchManager: @unchecked Sendable { /// This can be called manually by the user (e.g., from menu bar) if they notice /// the middle button is stuck. It sends a MIDDLE_UP event regardless of current state. func forceReleaseStuckDrag() { - Log.info("Force releasing stuck drag (user triggered)", category: .gesture) - - // Reset all internal state - isActivelyDragging = false - isInThreeFingerGesture = false - gestureEndTime = CACurrentMediaTime() - lastGestureWasActive = false - - // Cancel any active drag in the mouse generator - // This also sends a MIDDLE_UP event - mouseGenerator.cancelDrag() - - // Also reset the gesture recognizer to ensure clean state - gestureRecognizer.reset() + // Dispatch to main thread to avoid data races with gesture state updates + // which are also dispatched to main thread (see GestureRecognizerDelegate methods) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + Log.info("Force releasing stuck drag (user triggered)", category: .gesture) + + // Reset all internal state + self.isActivelyDragging = false + self.isInThreeFingerGesture = false + self.gestureEndTime = CACurrentMediaTime() + self.lastGestureWasActive = false + + // Force send MIDDLE_UP unconditionally + // Unlike cancelDrag(), this always sends UP even if internal state is already false + self.mouseGenerator.forceMiddleMouseUp() + + // Also reset the gesture recognizer to ensure clean state + self.gestureRecognizer.reset() + } } // MARK: - Event Tap From 5a693e07e36c6967ae99a3cc7b05d6c16da483fc Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:21:10 -0800 Subject: [PATCH 4/5] fix: protect dragGeneration with stateLock to prevent race condition AI-identified bug: dragGeneration was accessed from multiple threads without synchronization, creating a race between startDrag() on the gesture thread and forceReleaseDrag() on watchdogQueue. Race scenario: 1. startDrag() increments generation and sets isMiddleMouseDown=true 2. forceReleaseDrag() reads NEW generation, sets isMiddleMouseDown=false 3. Result: forceReleaseDrag's async cleanup matches generation and sends MIDDLE_UP to the NEW drag, breaking it Fix: - Move dragGeneration to be protected by stateLock (as _dragGeneration) - Make increment + flag set atomic in startDrag() - Make read + flag clear atomic in forceReleaseDrag() - Update forceMiddleMouseUp() with same atomic pattern This ensures the generation token and drag state are always consistent. --- MiddleDrag/Core/MouseEventGenerator.swift | 46 +++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index e0ad111..984f2f3 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -30,6 +30,10 @@ final class MouseEventGenerator: @unchecked Sendable { set { stateLock.withLock { _isMiddleMouseDown = newValue } } } + /// Drag session generation counter - protected by stateLock + /// Must be accessed atomically with isMiddleMouseDown to prevent race conditions + private var _dragGeneration: UInt64 = 0 + private var eventSource: CGEventSource? // Event generation queue for thread safety @@ -40,10 +44,6 @@ final class MouseEventGenerator: @unchecked Sendable { private let watchdogQueue = DispatchQueue(label: "com.middledrag.watchdog", qos: .utility) private var lastActivityTime: CFTimeInterval = 0 private let activityLock = NSLock() - - /// Drag session generation counter to prevent async operations from affecting new drags - /// Incremented each time a new drag starts; async cleanup checks this to avoid interfering - private var dragGeneration: UInt64 = 0 // Smoothing state for EMA (exponential moving average) private var previousDeltaX: CGFloat = 0 @@ -98,8 +98,13 @@ final class MouseEventGenerator: @unchecked Sendable { // // sendMiddleMouseDown() just creates and posts a CGEvent, which is thread-safe // and takes microseconds. No need for async dispatch here. - isMiddleMouseDown = true - dragGeneration &+= 1 // Increment generation for new drag session + // + // CRITICAL: isMiddleMouseDown and dragGeneration must be set ATOMICALLY + // to prevent race with forceReleaseDrag() on watchdogQueue + stateLock.withLock { + _isMiddleMouseDown = true + _dragGeneration &+= 1 + } sendMiddleMouseDown(at: quartzPos) // Start watchdog timer to detect stuck drags @@ -369,11 +374,11 @@ final class MouseEventGenerator: @unchecked Sendable { // Stop watchdog if running stopWatchdog() - // Reset internal state unconditionally - isMiddleMouseDown = false - - // Capture generation to check in async block - let currentGeneration = dragGeneration + // Atomically reset state and capture generation + let currentGeneration: UInt64 = stateLock.withLock { + _isMiddleMouseDown = false + return _dragGeneration + } // Always send the UP event synchronously let pos = currentMouseLocationQuartz @@ -384,7 +389,8 @@ final class MouseEventGenerator: @unchecked Sendable { guard let self = self else { return } // Only proceed if no new drag started - guard self.dragGeneration == currentGeneration else { return } + let newGeneration = self.stateLock.withLock { self._dragGeneration } + guard newGeneration == currentGeneration else { return } // Reset state and send another UP self.previousDeltaX = 0 @@ -574,12 +580,13 @@ final class MouseEventGenerator: @unchecked Sendable { private func forceReleaseDrag() { stopWatchdogLocked() - // Capture generation before releasing to prevent async block from - // interfering with a new drag that might start immediately after - let releasedGeneration = dragGeneration - - // Set flag to false immediately to stop any further processing - isMiddleMouseDown = false + // CRITICAL: Read generation AND clear flag atomically + // This prevents race with startDrag() on gesture thread + let releasedGeneration: UInt64 = stateLock.withLock { + let gen = _dragGeneration + _isMiddleMouseDown = false + return gen + } // Send mouse up event synchronously to ensure it gets through let currentPos = currentMouseLocationQuartz @@ -591,7 +598,8 @@ final class MouseEventGenerator: @unchecked Sendable { guard let self = self else { return } // Check if a new drag started - don't interfere with it - guard self.dragGeneration == releasedGeneration else { + let currentGeneration = self.stateLock.withLock { self._dragGeneration } + guard currentGeneration == releasedGeneration else { Log.info("Skipping async force-release cleanup - new drag session started", category: .gesture) return } From 573f76ffa618ed39246ca194cb55354771bc973d Mon Sep 17 00:00:00 2001 From: NullPointerDepressiveDisorder <96403086+NullPointerDepressiveDisorder@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:52:33 -0800 Subject: [PATCH 5/5] fix: prevent watchdog from clobbering new drag during double-start race AI-identified bug: When a second startDrag() occurs ~10s after first (during watchdog timeout check), a race condition causes the old watchdog's forceReleaseDrag() to interfere with the new drag. Race scenario: 1. Old watchdog's checkForStuckDrag() sees isMiddleMouseDown=true (old drag) 2. New startDrag() runs: sends UP, increments generation to N+1, sends DOWN 3. Old watchdog's forceReleaseDrag() runs: - Reads generation=N+1 (the NEW one!) - Sets isMiddleMouseDown=false (clobbers new drag!) - Sends MIDDLE_UP (kills new drag!) Result: UP -> DOWN -> UP = stuck button Fix: - checkForStuckDrag() now atomically captures BOTH isMiddleMouseDown AND generation together, passing the generation to forceReleaseDrag() - forceReleaseDrag() now takes expectedGeneration parameter and verifies current generation matches before clearing state or sending events - If generation doesn't match, a new drag started and we abort silently This ensures the watchdog only affects the drag session it was checking. --- MiddleDrag/Core/MouseEventGenerator.swift | 36 +++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/MiddleDrag/Core/MouseEventGenerator.swift b/MiddleDrag/Core/MouseEventGenerator.swift index 984f2f3..e79993e 100644 --- a/MiddleDrag/Core/MouseEventGenerator.swift +++ b/MiddleDrag/Core/MouseEventGenerator.swift @@ -538,7 +538,13 @@ final class MouseEventGenerator: @unchecked Sendable { /// Check if the drag has become stuck (no activity for too long) /// Called on watchdogQueue private func checkForStuckDrag() { - guard isMiddleMouseDown else { + // CRITICAL: Atomically read both isMiddleMouseDown AND generation + // This captures which drag session we're checking, preventing race with startDrag() + let (isDragging, capturedGeneration): (Bool, UInt64) = stateLock.withLock { + (_isMiddleMouseDown, _dragGeneration) + } + + guard isDragging else { // Drag already ended, stop checking stopWatchdogLocked() return @@ -569,23 +575,35 @@ final class MouseEventGenerator: @unchecked Sendable { unsafe SentrySDK.logger.warn("Stuck drag auto-released after timeout", attributes: attributes) } - // Force release the stuck drag - forceReleaseDrag() + // Force release the stuck drag, passing the generation we captured + // This ensures we don't interfere with a new drag that started since our check + forceReleaseDrag(forGeneration: capturedGeneration) } } /// Force release a stuck drag without normal cleanup flow /// This is called by the watchdog when a drag appears stuck /// Called on watchdogQueue - private func forceReleaseDrag() { + /// - Parameter forGeneration: The generation that was captured when deciding to release. + /// If current generation doesn't match, a new drag has started and we abort. + private func forceReleaseDrag(forGeneration expectedGeneration: UInt64) { stopWatchdogLocked() - // CRITICAL: Read generation AND clear flag atomically - // This prevents race with startDrag() on gesture thread - let releasedGeneration: UInt64 = stateLock.withLock { - let gen = _dragGeneration + // CRITICAL: Verify generation still matches before clearing state + // This prevents race where a new drag started between checkForStuckDrag() and now + let releasedGeneration: UInt64? = stateLock.withLock { + guard _dragGeneration == expectedGeneration else { + // A new drag has started - don't interfere with it! + Log.info("forceReleaseDrag aborted - new drag session started (expected gen \(expectedGeneration), current \(_dragGeneration))", category: .gesture) + return nil + } _isMiddleMouseDown = false - return gen + return _dragGeneration + } + + // If generation didn't match, abort without sending any events + guard let releasedGeneration = releasedGeneration else { + return } // Send mouse up event synchronously to ensure it gets through