diff --git a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift index 75c5576..2d4a3ae 100644 --- a/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift +++ b/MiddleDrag/MiddleDragTests/MenuBarControllerTests.swift @@ -578,4 +578,36 @@ import XCTest unsafe manager.stop() } + + // MARK: - Force Release Stuck Drag Tests + + func testForceReleaseStuckDragDoesNotCrash() { + unsafe manager.start() + + // Calling force release when not dragging should be safe + unsafe XCTAssertNoThrow(controller.forceReleaseStuckDrag()) + + unsafe manager.stop() + } + + func testForceReleaseStuckDragMultipleTimes() { + unsafe manager.start() + + // Calling force release multiple times rapidly should be safe + for _ in 0..<5 { + unsafe XCTAssertNoThrow(controller.forceReleaseStuckDrag()) + } + + unsafe manager.stop() + } + + func testForceReleaseStuckDragWhenStopped() { + // Controller should handle force release even when manager is stopped + unsafe XCTAssertNoThrow(controller.forceReleaseStuckDrag()) + } + + func testForceReleaseStuckDragSelectorExists() { + let selector = #selector(MenuBarController.forceReleaseStuckDrag) + unsafe XCTAssertTrue(controller.responds(to: selector)) + } } diff --git a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift index 7b345a6..35475ce 100644 --- a/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift +++ b/MiddleDrag/MiddleDragTests/MouseEventGeneratorTests.swift @@ -808,4 +808,153 @@ final class MouseEventGeneratorTests: XCTestCase { generator.endDrag() } + + // MARK: - Thread Safety Tests + + func testConcurrentStartDragDoesNotCrash() { + // Test that rapid concurrent startDrag calls don't cause crashes + // This exercises the atomic dragGeneration increment + let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent) + let group = DispatchGroup() + + for i in 0..<10 { + group.enter() + concurrentQueue.async { + self.generator.startDrag(at: CGPoint(x: CGFloat(i * 10), y: CGFloat(i * 10))) + group.leave() + } + } + + let waitResult = group.wait(timeout: .now() + 2.0) + XCTAssertEqual(waitResult, .success, "Concurrent startDrag calls should complete without deadlock") + + // Clean up + generator.cancelDrag() + } + + func testConcurrentStartAndCancelDoesNotCrash() { + // Test thread safety of start/cancel interleaving + let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent) + let group = DispatchGroup() + + for i in 0..<20 { + group.enter() + concurrentQueue.async { + if i % 2 == 0 { + self.generator.startDrag(at: CGPoint(x: CGFloat(i), y: CGFloat(i))) + } else { + self.generator.cancelDrag() + } + group.leave() + } + } + + let waitResult = group.wait(timeout: .now() + 2.0) + XCTAssertEqual(waitResult, .success, "Concurrent start/cancel should complete without deadlock") + + // Clean up + generator.cancelDrag() + } + + func testStartDragDuringWatchdogTimeoutWindow() { + // Test that starting a new drag during the watchdog timeout window + // doesn't cause the old watchdog to interfere with the new drag + generator.stuckDragTimeout = 0.3 // Very short timeout + + // Start first drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + // Wait until just before timeout would trigger + let nearTimeoutExpectation = XCTestExpectation(description: "Near timeout") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + nearTimeoutExpectation.fulfill() + } + wait(for: [nearTimeoutExpectation], timeout: 0.5) + + // Start second drag right before timeout - this should increment generation + // and cause any pending watchdog release to abort + generator.startDrag(at: CGPoint(x: 200, y: 200)) + + // Wait past the original timeout + let pastTimeoutExpectation = XCTestExpectation(description: "Past original timeout") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + pastTimeoutExpectation.fulfill() + } + wait(for: [pastTimeoutExpectation], timeout: 0.5) + + // The new drag should still be active (updates should work) + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + + // Clean up + generator.endDrag() + } + + func testRapidDragCyclesWithWatchdog() { + // Test rapid start/end cycles with watchdog enabled + generator.stuckDragTimeout = 0.5 + + for i in 0..<5 { + generator.startDrag(at: CGPoint(x: CGFloat(i * 100), y: CGFloat(i * 100))) + generator.updateDrag(deltaX: 10.0, deltaY: 10.0) + generator.endDrag() + + // Small delay between cycles + let delayExp = XCTestExpectation(description: "Delay \(i)") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + delayExp.fulfill() + } + wait(for: [delayExp], timeout: 0.2) + } + + // All cycles should complete without crashes or stuck state + XCTAssertNoThrow(generator.cancelDrag()) + } + + func testForceMiddleMouseUpDoesNotCrash() { + // Test that forceMiddleMouseUp can be called safely + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + + // Also test during active drag + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExp = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + + // After force release, updates should be ignored + XCTAssertNoThrow(generator.updateDrag(deltaX: 10.0, deltaY: 10.0)) + } + + func testForceMiddleMouseUpMultipleTimes() { + // Test that calling forceMiddleMouseUp multiple times rapidly is safe + for _ in 0..<5 { + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + } + } + + func testForceMiddleMouseUpAfterNormalEnd() { + // Start and end a drag normally + generator.startDrag(at: CGPoint(x: 100, y: 100)) + + let startExp = XCTestExpectation(description: "Drag started") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + startExp.fulfill() + } + wait(for: [startExp], timeout: 0.5) + + generator.endDrag() + + let endExp = XCTestExpectation(description: "Drag ended") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + endExp.fulfill() + } + wait(for: [endExp], timeout: 0.5) + + // Force release after normal end should still be safe + XCTAssertNoThrow(generator.forceMiddleMouseUp()) + } } diff --git a/MiddleDrag/UI/MenuBarController.swift b/MiddleDrag/UI/MenuBarController.swift index 0d06a79..ab278d3 100644 --- a/MiddleDrag/UI/MenuBarController.swift +++ b/MiddleDrag/UI/MenuBarController.swift @@ -608,6 +608,26 @@ class MenuBarController: NSObject { @objc func forceReleaseStuckDrag() { multitouchManager?.forceReleaseStuckDrag() + + // Provide visual feedback that the action was triggered + flashStatusBarIcon() + } + + /// Flash the status bar icon to provide visual feedback for actions + /// Uses alpha animation consistent with updateStatusIcon pattern + private func flashStatusBarIcon() { + guard let button = statusItem?.button else { return } + + // Use alpha animation for visual feedback (consistent with updateStatusIcon) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + button.animator().alphaValue = 0.3 + } completionHandler: { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + button.animator().alphaValue = 1.0 + } + } } @objc func toggleLaunchAtLogin() {