Skip to content

Commit 5fe1b73

Browse files
Refactor refreshConnectedDevices to tear down old device handles and re-register current devices to prevent orphaned callbacks
1 parent 9f6f54a commit 5fe1b73

2 files changed

Lines changed: 101 additions & 20 deletions

File tree

MiddleDrag/Managers/DeviceMonitor.swift

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,13 @@ class DeviceMonitor: TouchDeviceProviding {
352352
timer.cancel()
353353
}
354354

355-
/// Finds newly connected devices and registers callbacks for them.
355+
/// Tears down old device handles and re-registers the current device set.
356+
///
357+
/// `MTDeviceCreateList()` may return different pointer addresses for the same
358+
/// physical device on every call, so we cannot correlate old handles with new
359+
/// ones. A full unregister-stop-then-register-start cycle on every refresh
360+
/// prevents orphaned callbacks that would otherwise accumulate and deliver
361+
/// duplicate touch events.
356362
func refreshConnectedDevices() {
357363
unsafe DeviceMonitor.lifecycleLock.lock()
358364
defer { unsafe DeviceMonitor.lifecycleLock.unlock() }
@@ -362,21 +368,41 @@ class DeviceMonitor: TouchDeviceProviding {
362368

363369
guard unsafe isRunning else { return }
364370

365-
// Do not skip refreshes based only on the number of devices.
366-
// A disconnect/connect swap can keep the count unchanged while the
367-
// actual device set changes, which would otherwise leave the newly
368-
// connected device unregistered.
369371
let devices = unsafe enumerator.enumerateDevices()
372+
let previousCount = lastKnownDeviceCount
373+
374+
// --- tear down every old handle ---
375+
let oldDevices: Set<UnsafeMutableRawPointer> =
376+
unsafe Set(registeredDevicesByID.values.flatMap { unsafe $0 })
377+
if !oldDevices.isEmpty {
378+
// Unregister callbacks first (same ordering as stop()).
379+
for unsafe deviceRef in unsafe oldDevices {
380+
unsafe enumerator.unregisterContactCallback(deviceRef, deviceContactCallback)
381+
}
382+
// Stop each device under gCallbackLock so the framework's internal
383+
// thread cannot access the handle mid-teardown.
384+
for unsafe deviceRef in unsafe oldDevices {
385+
unsafe os_unfair_lock_lock(&gCallbackLock)
386+
unsafe enumerator.stopDevice(deviceRef)
387+
unsafe os_unfair_lock_unlock(&gCallbackLock)
388+
}
389+
unsafe registeredDevicesByID.removeAll()
390+
unsafe device = nil
391+
}
370392

393+
// --- register fresh handles ---
371394
let registrationResult = unsafe registerAvailableDevices(
372395
logDeviceCount: false, prefetchedDevices: devices)
373-
let addedCount = registrationResult.addedCount
374396

375-
if addedCount > 0 {
397+
// Only log when the set of devices actually changed, not on every
398+
// routine re-registration cycle.
399+
if devices.count != previousCount {
376400
Log.info(
377-
"Registered \(addedCount) newly connected multitouch device(s)",
401+
"Multitouch device count changed: \(previousCount)\(devices.count)",
378402
category: .device)
379403
}
404+
405+
_ = registrationResult // suppress unused-result warning
380406
}
381407

382408
/// Enumerates currently available devices and registers any that are not already active.
@@ -433,8 +459,10 @@ class DeviceMonitor: TouchDeviceProviding {
433459

434460
/// Registers callbacks and starts a device if we are not already monitoring it.
435461
///
436-
/// Note: Device identity is pointer-based and not stable across MTDeviceCreateList() calls.
437-
/// Cross-refresh deduplication is handled by the count-based check in refreshConnectedDevices().
462+
/// Device identity is pointer-based and not stable across `MTDeviceCreateList()` calls.
463+
/// `refreshConnectedDevices()` handles this by tearing down all old handles before
464+
/// calling `registerAvailableDevices`, so within a single registration pass the
465+
/// pointer-based IDs are consistent.
438466
private func registerDeviceIfNeeded(
439467
_ deviceRef: MTDeviceRef?,
440468
deviceID: Int64?

MiddleDrag/MiddleDragTests/DeviceMonitorTests.swift

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,9 @@ import Synchronization
556556
unsafe XCTAssertFalse(monitor.hasActiveRefreshTimer)
557557
}
558558

559-
// MARK: - Refresh Skip / Register
559+
// MARK: - Refresh Teardown / Re-register
560560

561-
func testRefreshSkipsWhenDeviceCountUnchanged() {
561+
func testRefreshTearsDownOldHandlesBeforeRegistering() {
562562
let enumerator = unsafe MockDeviceEnumerator()
563563
let device = unsafe makeFakeDevice()
564564
defer { unsafe device.deallocate() }
@@ -569,12 +569,62 @@ import Synchronization
569569
defer { unsafe monitor.stop() }
570570
unsafe monitor.start()
571571

572-
let callsBefore = unsafe enumerator.registeredCallbackDevices.count
572+
// After start: 1 register, 1 start
573+
unsafe XCTAssertEqual(enumerator.registeredCallbackDevices.count, 1)
574+
575+
// Refresh should unregister the old handle, then re-register it
573576
unsafe monitor.refreshConnectedDevices()
574-
let callsAfter = unsafe enumerator.registeredCallbackDevices.count
575577

576-
XCTAssertEqual(callsBefore, callsAfter,
577-
"Refresh should skip registration when device count is unchanged")
578+
unsafe XCTAssertTrue(enumerator.unregisteredCallbackDevices.contains(device),
579+
"Old handle should be unregistered during refresh")
580+
// Total registrations: 1 (start) + 1 (refresh re-register)
581+
unsafe XCTAssertEqual(enumerator.registeredCallbackDevices.count, 2)
582+
}
583+
584+
func testRefreshWithUnstablePointersDoesNotLeakCallbacks() {
585+
// Simulates the core pointer-instability bug:
586+
// MTDeviceCreateList() returns a different pointer for the same
587+
// physical device on each call. Without the teardown step, this
588+
// would orphan the old callback and register a duplicate.
589+
let enumerator = unsafe MockDeviceEnumerator()
590+
let ptr1 = unsafe makeFakeDevice()
591+
let ptr2 = unsafe makeFakeDevice()
592+
let ptr3 = unsafe makeFakeDevice()
593+
defer {
594+
unsafe ptr1.deallocate()
595+
unsafe ptr2.deallocate()
596+
unsafe ptr3.deallocate()
597+
}
598+
unsafe enumerator.devices = [ptr1]
599+
unsafe enumerator.defaultDevice = ptr1
600+
601+
let monitor = unsafe makeMonitor(enumerator: enumerator)
602+
defer { unsafe monitor.stop() }
603+
unsafe monitor.start()
604+
605+
// Simulate framework returning different pointer for same device
606+
unsafe enumerator.devices = [ptr2]
607+
unsafe enumerator.defaultDevice = ptr2
608+
unsafe monitor.refreshConnectedDevices()
609+
610+
// ptr1 must have been torn down
611+
unsafe XCTAssertTrue(enumerator.unregisteredCallbackDevices.contains(ptr1),
612+
"Old pointer should have its callback unregistered")
613+
unsafe XCTAssertTrue(enumerator.stoppedDevices.contains(ptr1),
614+
"Old pointer should be stopped")
615+
616+
// Do it again with a third pointer
617+
unsafe enumerator.devices = [ptr3]
618+
unsafe enumerator.defaultDevice = ptr3
619+
unsafe monitor.refreshConnectedDevices()
620+
621+
unsafe XCTAssertTrue(enumerator.unregisteredCallbackDevices.contains(ptr2))
622+
unsafe XCTAssertTrue(enumerator.stoppedDevices.contains(ptr2))
623+
624+
// Total unregisters should match: ptr1, ptr2 (not ptr3 — still active)
625+
let unregCount = unsafe enumerator.unregisteredCallbackDevices.count
626+
XCTAssertEqual(unregCount, 2,
627+
"Exactly the old handles should be unregistered, no leaks")
578628
}
579629

580630
func testRefreshRegistersWhenDeviceCountIncreases() {
@@ -591,12 +641,12 @@ import Synchronization
591641

592642
// Simulate connecting a second device
593643
unsafe enumerator.devices = [device1, device2]
594-
let callsBefore = unsafe enumerator.registeredCallbackDevices.count
595644
unsafe monitor.refreshConnectedDevices()
596-
let callsAfter = unsafe enumerator.registeredCallbackDevices.count
597645

598-
XCTAssertGreaterThan(callsAfter, callsBefore,
599-
"Refresh should register devices when count increases")
646+
// Both devices should have been registered in the refresh pass
647+
unsafe XCTAssertEqual(monitor.lastKnownDeviceCount, 2)
648+
unsafe XCTAssertTrue(enumerator.registeredCallbackDevices.contains(device2),
649+
"Newly connected device should be registered")
600650
}
601651

602652
func testRefreshHandlesDeviceDisconnect() {
@@ -618,6 +668,9 @@ import Synchronization
618668

619669
unsafe XCTAssertEqual(monitor.lastKnownDeviceCount, 1,
620670
"Count should update after device disconnect")
671+
// device2 should have been torn down
672+
unsafe XCTAssertTrue(enumerator.unregisteredCallbackDevices.contains(device2))
673+
unsafe XCTAssertTrue(enumerator.stoppedDevices.contains(device2))
621674
}
622675

623676
func testRefreshUpdatesLastKnownDeviceCount() {

0 commit comments

Comments
 (0)