Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions MiddleDrag/Managers/DeviceMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ import os
}
unsafe os_unfair_lock_unlock(&gCallbackLock)

// Additional safety check: Verify the monitor instance is still valid and running.
unsafe monitor.stateLock.lock()
let monitorIsRunning = unsafe monitor.isRunning
unsafe monitor.stateLock.unlock()

guard monitorIsRunning else { return 0 }

#if DEBUG
unsafe touchCount += 1
// Log sparingly to avoid performance impact
Expand Down Expand Up @@ -90,11 +97,7 @@ class DeviceMonitor: TouchDeviceProviding {
/// Delay between unregistering callbacks and stopping devices.
/// This allows the MultitouchSupport framework's internal thread (mt_ThreadedMTEntry)
/// to complete any in-flight callback processing before we stop devices.
/// Value determined empirically: 500ms is sufficient to avoid CFRelease(NULL) crashes
/// and EXC_BREAKPOINT exceptions during rapid connectivity changes (wifi ↔ none).
/// Increased from 100ms to handle rapid connectivity toggling that triggers multiple
/// restart cycles in quick succession.
static let frameworkCleanupDelay: TimeInterval = 0.5
static let frameworkCleanupDelay: TimeInterval = 1.0

// MARK: - Properties

Expand All @@ -103,10 +106,10 @@ class DeviceMonitor: TouchDeviceProviding {

nonisolated(unsafe) private var device: MTDeviceRef?
nonisolated(unsafe) private var registeredDevices: Set<UnsafeMutableRawPointer> = unsafe []
private var isRunning = false
fileprivate var isRunning = false

/// Lock to protect concurrent access to device state during stop/start operations
private let stateLock = NSLock()
fileprivate let stateLock = NSLock()

/// Tracks whether this instance owns the global callback reference
private var ownsGlobalReference = false
Expand Down
8 changes: 2 additions & 6 deletions MiddleDrag/Managers/MultitouchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,14 @@ public final class MultitouchManager: @unchecked Sendable {
/// Delay after stopping before restarting devices during wake-from-sleep.
/// This allows the MultitouchSupport framework's internal thread (mt_ThreadedMTEntry)
/// to fully complete cleanup before we start new devices.
/// Value determined empirically: 500ms is sufficient to avoid CFRelease(NULL) crashes
/// and EXC_BREAKPOINT exceptions during rapid connectivity changes (wifi ↔ none).
/// Increased from 250ms to handle rapid connectivity toggling that triggers multiple
/// restart cycles in quick succession.
static let restartCleanupDelay: TimeInterval = 0.5
static let restartCleanupDelay: TimeInterval = 1.0

/// Minimum delay between restart operations to prevent race conditions.
/// When multiple restart triggers occur in rapid succession (e.g., rapid connectivity
/// changes wifi ↔ none), we debounce them by waiting at least this long after the
/// last restart completed. This prevents overlapping restart attempts that can expose
/// race conditions in the MultitouchSupport framework's internal thread.
static let minimumRestartInterval: TimeInterval = 0.6
static let minimumRestartInterval: TimeInterval = 2.5

/// Initial interval between polling attempts when no multitouch device is found at launch.
/// This handles Bluetooth trackpads that connect after login (common during boot).
Expand Down
2 changes: 1 addition & 1 deletion MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ import XCTest
DeviceMonitor.frameworkCleanupDelay, 0,
"frameworkCleanupDelay should be positive")
unsafe XCTAssertLessThanOrEqual(
DeviceMonitor.frameworkCleanupDelay, 0.5,
DeviceMonitor.frameworkCleanupDelay, 1.0,
"frameworkCleanupDelay should not be excessive")
}

Expand Down
26 changes: 13 additions & 13 deletions MiddleDrag/MiddleDragTests/MultitouchManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 2.0)

XCTAssertFalse(manager.isMonitoring)
XCTAssertFalse(manager.isEnabled)
Expand Down Expand Up @@ -303,7 +303,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 3.0)

// We expect:
// 1 initial creation
Expand Down Expand Up @@ -339,7 +339,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 3.0)

// Verify manager is still stopped
XCTAssertFalse(manager.isMonitoring, "Manager should remain stopped")
Expand Down Expand Up @@ -1688,7 +1688,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 3.0)

// After restart, monitoring should still be active
XCTAssertTrue(manager.isMonitoring)
Expand Down Expand Up @@ -1722,7 +1722,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 3.0)

XCTAssertFalse(manager.isEnabled)
XCTAssertTrue(manager.isMonitoring)
Expand Down Expand Up @@ -1767,7 +1767,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
restartExpectation.fulfill()
}
wait(for: [restartExpectation], timeout: 1.0)
wait(for: [restartExpectation], timeout: 3.0)

XCTAssertTrue(manager.isMonitoring)

Expand Down Expand Up @@ -1806,7 +1806,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 3.0)

unsafe XCTAssertEqual(mockDevice.startCallCount, 2) // Now restarted

Expand All @@ -1819,7 +1819,7 @@ final class MultitouchManagerTests: XCTestCase {
MultitouchManager.restartCleanupDelay, 0,
"restartCleanupDelay should be positive")
XCTAssertLessThanOrEqual(
MultitouchManager.restartCleanupDelay, 0.5,
MultitouchManager.restartCleanupDelay, 1.0,
"restartCleanupDelay should not be excessive")
}

Expand All @@ -1829,7 +1829,7 @@ final class MultitouchManagerTests: XCTestCase {
MultitouchManager.minimumRestartInterval, 0,
"minimumRestartInterval should be positive")
XCTAssertLessThanOrEqual(
MultitouchManager.minimumRestartInterval, 1.0,
MultitouchManager.minimumRestartInterval, 2.5,
"minimumRestartInterval should not be excessive")
}

Expand Down Expand Up @@ -1863,7 +1863,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 2.0)
wait(for: [expectation], timeout: 5.0)

// We expect far fewer than 10 device creations due to:
// 1. The first restart being in progress blocks subsequent ones
Expand Down Expand Up @@ -1903,7 +1903,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
wait(for: [expectation], timeout: 3.0)

// Only 2 device creations: initial start + one restart
// The duplicate restart calls while in progress should have been skipped
Expand Down Expand Up @@ -1933,7 +1933,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 3.0)
wait(for: [expectation], timeout: 5.0)

// Should still be monitoring after all restarts
XCTAssertTrue(manager.isMonitoring)
Expand Down Expand Up @@ -1980,7 +1980,7 @@ final class MultitouchManagerTests: XCTestCase {
) {
restartExpectation.fulfill()
}
wait(for: [restartExpectation], timeout: 1.0)
wait(for: [restartExpectation], timeout: 3.0)

XCTAssertTrue(manager.isMonitoring)

Expand Down
Loading