Skip to content

Conversation

@NullPointerDepressiveDisorder
Copy link
Owner

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.

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.
Copilot AI review requested due to automatic review settings January 23, 2026 08:13
@NullPointerDepressiveDisorder NullPointerDepressiveDisorder linked an issue Jan 23, 2026 that may be closed by this pull request
@sentry
Copy link
Contributor

sentry bot commented Jan 23, 2026

Codecov Report

❌ Patch coverage is 88.20961% with 27 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
MiddleDrag/Core/MouseEventGenerator.swift 87.56% 24 Missing ⚠️
MiddleDrag/UI/MenuBarController.swift 78.57% 3 Missing ⚠️

📢 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
Copy link
Contributor

Copilot AI left a 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.

Copy link
Contributor

Copilot AI left a 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.
@NullPointerDepressiveDisorder NullPointerDepressiveDisorder merged commit 6bac192 into main Jan 23, 2026
6 checks passed
@NullPointerDepressiveDisorder NullPointerDepressiveDisorder deleted the 77-still-sticky branch January 23, 2026 10:03
middledrag-releaser bot pushed a commit that referenced this pull request Jan 23, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Still sticky..

2 participants