-
Notifications
You must be signed in to change notification settings - Fork 1
fix: prevent stuck middle-drag when events are swallowed #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
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.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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
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.
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.
* 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. * 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 * 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 * 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. * 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.
Fixes #77 - Middle button gets stuck (no MIDDLE_UP) when:
Changes:
The watchdog sends multiple MIDDLE_UP events (sync + async) to ensure release even if one is swallowed by problematic windows.