Skip to content

Commit b79b552

Browse files
authored
fix: Fix UITouch background thread access in SentryTouchTracker (#6584)
1 parent 5fce94f commit b79b552

File tree

3 files changed

+244
-27
lines changed

3 files changed

+244
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- Fix crash from accessing UITouch instances from background thread in SentryTouchTracker (#6584)
78
- Disable SessionSentryReplayIntegration if the environment is unsafe [#6573]
89
- Fix crash when last replay info is missing some keys [#6577]
910

Sources/Swift/Integrations/SessionReplay/SentryTouchTracker.swift

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,36 @@ import UIKit
1919

2020
private final class TouchInfo {
2121
let id: Int
22+
let identifier: ObjectIdentifier
2223

2324
var startEvent: TouchEvent?
2425
var endEvent: TouchEvent?
2526
var moveEvents = [TouchEvent]()
2627

27-
init(id: Int) {
28+
init(id: Int, identifier: ObjectIdentifier) {
2829
self.id = id
30+
self.identifier = identifier
2931
}
3032
}
3133

34+
private struct ExtractedTouchData {
35+
let identifier: ObjectIdentifier
36+
let position: CGPoint
37+
let phase: UITouch.Phase
38+
}
39+
40+
/**
41+
* Active touches tracked by ObjectIdentifier for O(1) lookup performance.
42+
* ObjectIdentifier can collide when UITouch memory is reused.
43+
*/
44+
private var trackedTouches = [ObjectIdentifier: TouchInfo]()
45+
3246
/**
33-
* Using UITouch as a key because the touch is the same across events
34-
* for the same touch. As long we holding the reference no two UITouches
35-
* will ever have the same pointer.
47+
* Touches that were replaced in trackedTouches due to ObjectIdentifier collision.
48+
* Preserves events when UITouch memory is reused before flushFinishedEvents().
3649
*/
37-
private var trackedTouches = [UITouch: TouchInfo]()
50+
private var orphanedTouches = [TouchInfo]()
51+
3852
private let dispatchQueue: SentryDispatchQueueWrapper
3953
private var touchId = 1
4054
private let dateProvider: SentryCurrentDateProvider
@@ -57,32 +71,65 @@ import UIKit
5771
guard let touches = event.allTouches else { return }
5872
let timestamp = event.timestamp
5973

74+
// Extract touch data on the main thread before dispatching to background queue
75+
// to avoid accessing UIKit objects from a background thread
76+
let extractedTouches: [ExtractedTouchData] = touches.compactMap { touch in
77+
guard touch.phase == .began || touch.phase == .ended || touch.phase == .moved || touch.phase == .cancelled else { return nil }
78+
let position = touch.location(in: nil).applying(scale)
79+
// There is no way to uniquely identify a touch, so we use the ObjectIdentifier of the UITouch instance.
80+
// This is not a perfect solution, but it is the best we can do without holding references to UIKit objects.
81+
return ExtractedTouchData(identifier: ObjectIdentifier(touch), position: position, phase: touch.phase)
82+
}
83+
6084
dispatchQueue.dispatchAsync { [self] in
61-
for touch in touches {
62-
guard touch.phase == .began || touch.phase == .ended || touch.phase == .moved || touch.phase == .cancelled else { continue }
63-
let info = trackedTouches[touch] ?? TouchInfo(id: touchId++)
64-
let position = touch.location(in: nil).applying(scale)
65-
let newEvent = TouchEvent(x: position.x, y: position.y, timestamp: timestamp, phase: touch.phase.toRRWebTouchPhase())
66-
67-
switch touch.phase {
68-
case .began:
69-
info.startEvent = newEvent
70-
case .ended, .cancelled:
71-
info.endEvent = newEvent
72-
case .moved:
73-
// If the distance between two points is smaller than 10 points, we don't record the second movement.
74-
// iOS event polling is fast and will capture any movement; we don't need this granularity for replay.
75-
if let last = info.moveEvents.last, touchesDelta(last.point, position) < 10 { continue }
76-
info.moveEvents.append(newEvent)
77-
self.debounceEvents(in: info)
78-
default:
79-
continue
80-
}
81-
trackedTouches[touch] = info
85+
for extractedTouch in extractedTouches {
86+
processTouchEvent(extractedTouch, timestamp: timestamp)
8287
}
8388
}
8489
}
8590

91+
private func processTouchEvent(_ extractedTouch: ExtractedTouchData, timestamp: TimeInterval) {
92+
let info: TouchInfo
93+
94+
if extractedTouch.phase == .began {
95+
// Check for ObjectIdentifier collision - if touch already exists, orphan it
96+
if let existingTouch = trackedTouches[extractedTouch.identifier] {
97+
orphanedTouches.append(existingTouch)
98+
}
99+
100+
// Create new TouchInfo for .began
101+
info = TouchInfo(id: touchId++, identifier: extractedTouch.identifier)
102+
trackedTouches[extractedTouch.identifier] = info
103+
} else {
104+
// O(1) dictionary lookup for non-.began phases
105+
if let existingInfo = trackedTouches[extractedTouch.identifier] {
106+
info = existingInfo
107+
} else {
108+
// Create new if not found (shouldn't happen, but handle gracefully)
109+
info = TouchInfo(id: touchId++, identifier: extractedTouch.identifier)
110+
trackedTouches[extractedTouch.identifier] = info
111+
}
112+
}
113+
114+
let position = extractedTouch.position
115+
let newEvent = TouchEvent(x: position.x, y: position.y, timestamp: timestamp, phase: extractedTouch.phase.toRRWebTouchPhase())
116+
117+
switch extractedTouch.phase {
118+
case .began:
119+
info.startEvent = newEvent
120+
case .ended, .cancelled:
121+
info.endEvent = newEvent
122+
case .moved:
123+
// If the distance between two points is smaller than 10 points, we don't record the second movement.
124+
// iOS event polling is fast and will capture any movement; we don't need this granularity for replay.
125+
if let last = info.moveEvents.last, touchesDelta(last.point, position) < 10 { return }
126+
info.moveEvents.append(newEvent)
127+
self.debounceEvents(in: info)
128+
default:
129+
return
130+
}
131+
}
132+
86133
private func touchesDelta(_ lastTouch: CGPoint, _ newTouch: CGPoint) -> CGFloat {
87134
let dx = newTouch.x - lastTouch.x
88135
let dy = newTouch.y - lastTouch.y
@@ -121,6 +168,7 @@ import UIKit
121168
SentrySDKLog.debug("[Session Replay] Flushing finished events")
122169
dispatchQueue.dispatchSync { [self] in
123170
trackedTouches = trackedTouches.filter { $0.value.endEvent == nil }
171+
orphanedTouches = orphanedTouches.filter { $0.endEvent == nil }
124172
}
125173
}
126174

@@ -134,7 +182,8 @@ import UIKit
134182

135183
var touches = [TouchInfo]()
136184
dispatchQueue.dispatchSync { [self] in
137-
touches = Array(trackedTouches.values)
185+
// Include both active and orphaned touches to preserve all events
186+
touches = Array(trackedTouches.values) + orphanedTouches
138187
}
139188

140189
for info in touches {

Tests/SentryTests/Integrations/SessionReplay/SentryTouchTrackerTests.swift

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,5 +342,172 @@ class SentryTouchTrackerTests: XCTestCase {
342342
// Assert
343343
wait(for: [addExp, removeExp, readExp], timeout: 5)
344344
}
345+
346+
func testObjectIdentifierCollision_NewTouchGetsNewId() {
347+
// Arrange
348+
// This test simulates ObjectIdentifier collision when UITouch memory is reused.
349+
// When iOS reuses memory for a new UITouch at the same address as a previous touch,
350+
// the ObjectIdentifier will be the same, but it's actually a different touch gesture.
351+
let sut = getSut()
352+
let touch = MockUITouch(phase: .began, location: CGPoint(x: 100, y: 100))
353+
354+
// Act - First touch lifecycle (began -> moved -> ended)
355+
let event1 = MockUIEvent(timestamp: 1)
356+
event1.addTouch(touch)
357+
sut.trackTouchFrom(event: event1)
358+
359+
touch.phase = .moved
360+
touch.location = CGPoint(x: 150, y: 150)
361+
let event2 = MockUIEvent(timestamp: 2)
362+
event2.addTouch(touch)
363+
sut.trackTouchFrom(event: event2)
364+
365+
touch.phase = .ended
366+
touch.location = CGPoint(x: 200, y: 200)
367+
let event3 = MockUIEvent(timestamp: 3)
368+
event3.addTouch(touch)
369+
sut.trackTouchFrom(event: event3)
370+
371+
// Get events from first touch before they're overwritten
372+
let firstTouchEvents = sut.replayEvents(from: referenceDate, until: referenceDate.addingTimeInterval(10))
373+
374+
// In real-world usage, flushFinishedEvents would be called periodically
375+
// This removes finished touches from trackedTouches
376+
sut.flushFinishedEvents()
377+
378+
// Simulate memory reuse: same UITouch object starts a NEW touch gesture
379+
// In reality this would be a new UITouch at the same memory address,
380+
// but for testing we can use the same object with .began phase at a different location
381+
touch.phase = .began
382+
touch.location = CGPoint(x: 50, y: 50) // Different location - this is a NEW touch
383+
let event4 = MockUIEvent(timestamp: 4)
384+
event4.addTouch(touch)
385+
sut.trackTouchFrom(event: event4)
386+
387+
touch.phase = .ended
388+
touch.location = CGPoint(x: 75, y: 75)
389+
let event5 = MockUIEvent(timestamp: 5)
390+
event5.addTouch(touch)
391+
sut.trackTouchFrom(event: event5)
392+
393+
// Get second touch events
394+
let secondTouchEvents = sut.replayEvents(from: referenceDate, until: referenceDate.addingTimeInterval(10))
395+
396+
// Assert - First touch (captured before flush)
397+
XCTAssertEqual(firstTouchEvents.count, 3, "First touch should have 3 events: start, move, end")
398+
399+
let firstTouchStart = firstTouchEvents[0].data
400+
let firstTouchPointerId = firstTouchStart?["pointerId"] as? Int
401+
XCTAssertEqual(firstTouchStart?["x"] as? Float, 100)
402+
XCTAssertEqual(firstTouchStart?["y"] as? Float, 100)
403+
XCTAssertEqual(firstTouchStart?["type"] as? Int, TouchEventPhase.start.rawValue)
404+
XCTAssertEqual(firstTouchPointerId, 1, "First touch should have pointer ID 1")
405+
406+
let firstTouchMove = firstTouchEvents[1].data
407+
XCTAssertEqual(firstTouchMove?["pointerId"] as? Int, firstTouchPointerId)
408+
409+
let firstTouchEnd = firstTouchEvents[2].data
410+
XCTAssertEqual(firstTouchEnd?["x"] as? Float, 200)
411+
XCTAssertEqual(firstTouchEnd?["y"] as? Float, 200)
412+
XCTAssertEqual(firstTouchEnd?["type"] as? Int, TouchEventPhase.end.rawValue)
413+
XCTAssertEqual(firstTouchEnd?["pointerId"] as? Int, firstTouchPointerId)
414+
415+
// Assert - Second touch (after collision)
416+
XCTAssertEqual(secondTouchEvents.count, 2, "Second touch should have 2 events: start, end")
417+
418+
let secondTouchStart = secondTouchEvents[0].data
419+
let secondTouchPointerId = secondTouchStart?["pointerId"] as? Int
420+
XCTAssertEqual(secondTouchStart?["x"] as? Float, 50)
421+
XCTAssertEqual(secondTouchStart?["y"] as? Float, 50)
422+
XCTAssertEqual(secondTouchStart?["type"] as? Int, TouchEventPhase.start.rawValue)
423+
XCTAssertEqual(secondTouchPointerId, 2, "Second touch should have pointer ID 2")
424+
425+
let secondTouchEnd = secondTouchEvents[1].data
426+
XCTAssertEqual(secondTouchEnd?["x"] as? Float, 75)
427+
XCTAssertEqual(secondTouchEnd?["y"] as? Float, 75)
428+
XCTAssertEqual(secondTouchEnd?["type"] as? Int, TouchEventPhase.end.rawValue)
429+
XCTAssertEqual(secondTouchEnd?["pointerId"] as? Int, secondTouchPointerId)
430+
431+
// Critical assertion: The two touches should have DIFFERENT pointer IDs
432+
XCTAssertNotEqual(firstTouchPointerId, secondTouchPointerId,
433+
"Memory-reused touch should get a new pointer ID, not inherit the old one")
434+
}
435+
436+
func testObjectIdentifierCollision_WithoutFlush_DoesNotOrphanEvents() {
437+
// Arrange
438+
// This test covers the case where a collision happens BEFORE flushFinishedEvents()
439+
// The old touch's events should still be accessible, not orphaned
440+
let sut = getSut()
441+
let touch = MockUITouch(phase: .began, location: CGPoint(x: 100, y: 100))
442+
443+
// Act - First touch begins but doesn't end
444+
let event1 = MockUIEvent(timestamp: 1)
445+
event1.addTouch(touch)
446+
sut.trackTouchFrom(event: event1)
447+
448+
touch.phase = .moved
449+
touch.location = CGPoint(x: 150, y: 150)
450+
let event2 = MockUIEvent(timestamp: 2)
451+
event2.addTouch(touch)
452+
sut.trackTouchFrom(event: event2)
453+
454+
// Collision happens BEFORE first touch ends and BEFORE flush
455+
// Same ObjectIdentifier starts a NEW touch
456+
touch.phase = .began
457+
touch.location = CGPoint(x: 50, y: 50)
458+
let event3 = MockUIEvent(timestamp: 3)
459+
event3.addTouch(touch)
460+
sut.trackTouchFrom(event: event3)
461+
462+
touch.phase = .ended
463+
touch.location = CGPoint(x: 75, y: 75)
464+
let event4 = MockUIEvent(timestamp: 4)
465+
event4.addTouch(touch)
466+
sut.trackTouchFrom(event: event4)
467+
468+
// Assert
469+
let result = sut.replayEvents(from: referenceDate, until: referenceDate.addingTimeInterval(10))
470+
471+
// We should have events from BOTH touches, even though the first didn't end:
472+
// - Touch 1 start at (100, 100)
473+
// - Touch 1 move at (150, 150)
474+
// - Touch 2 start at (50, 50) <- collision without flush
475+
// - Touch 2 end at (75, 75)
476+
//
477+
// The first touch's events should NOT be orphaned/lost
478+
479+
XCTAssertEqual(result.count, 4, "Should have 4 events: both touches' events should be preserved")
480+
481+
// First touch events (incomplete, no end)
482+
let firstTouchStart = result[0].data
483+
let firstTouchPointerId = firstTouchStart?["pointerId"] as? Int
484+
XCTAssertEqual(firstTouchStart?["x"] as? Float, 100)
485+
XCTAssertEqual(firstTouchStart?["y"] as? Float, 100)
486+
XCTAssertEqual(firstTouchStart?["type"] as? Int, TouchEventPhase.start.rawValue)
487+
488+
let firstTouchMove = result[1].data
489+
XCTAssertEqual(firstTouchMove?["pointerId"] as? Int, firstTouchPointerId)
490+
let positions = firstTouchMove?["positions"] as? [[String: Any]]
491+
XCTAssertEqual(positions?.first?["x"] as? Float, 150)
492+
XCTAssertEqual(positions?.first?["y"] as? Float, 150)
493+
494+
// Second touch events (after collision)
495+
let secondTouchStart = result[2].data
496+
let secondTouchPointerId = secondTouchStart?["pointerId"] as? Int
497+
XCTAssertEqual(secondTouchStart?["x"] as? Float, 50)
498+
XCTAssertEqual(secondTouchStart?["y"] as? Float, 50)
499+
XCTAssertEqual(secondTouchStart?["type"] as? Int, TouchEventPhase.start.rawValue)
500+
501+
let secondTouchEnd = result[3].data
502+
XCTAssertEqual(secondTouchEnd?["x"] as? Float, 75)
503+
XCTAssertEqual(secondTouchEnd?["y"] as? Float, 75)
504+
XCTAssertEqual(secondTouchEnd?["type"] as? Int, TouchEventPhase.end.rawValue)
505+
XCTAssertEqual(secondTouchEnd?["pointerId"] as? Int, secondTouchPointerId)
506+
507+
// Critical assertion: Different IDs and NO orphaned events
508+
XCTAssertNotEqual(firstTouchPointerId, secondTouchPointerId,
509+
"Touches should have different IDs")
510+
XCTAssertNotNil(firstTouchPointerId, "First touch events should not be orphaned")
511+
}
345512
}
346513
#endif

0 commit comments

Comments
 (0)