1
1
@_implementationOnly import _SentryPrivate
2
-
3
- protocol ThreadInspector {
4
- func getCurrentThreadsWithStackTrace( ) -> [ SentryThread ]
5
- }
6
-
7
- @objc @_spi ( Private) public protocol DebugImageCache {
8
- func getDebugImagesFromCache( for threads: [ SentryThread ] ? ) -> [ DebugMeta ]
9
- }
10
-
11
- protocol ThreadInspectorProviding {
12
- var threadInspector : ThreadInspector { get }
13
- }
14
-
15
- protocol SentryCurrentDateProviding {
16
- var dateProvider : SentryCurrentDateProvider { get }
17
- }
18
-
19
- protocol DebugImageCacheProviding {
20
- var debugImageCache : DebugImageCache { get }
21
- }
22
-
23
- typealias RunLoopObserverDependencies = SentryCurrentDateProviding & ThreadInspectorProviding & DebugImageCacheProviding
24
-
25
- final class RunloopObserver {
26
- let dependencies : RunLoopObserverDependencies
27
- init ( dependencies: RunLoopObserverDependencies , minHangTime: TimeInterval ) {
28
- self . dependencies = dependencies
2
+ #if canImport(UIKit) && !SENTRY_NO_UIKIT
3
+ import UIKit
4
+ #endif
5
+
6
+ final class RunLoopObserver {
7
+
8
+ private let dateProvider : SentryCurrentDateProvider
9
+ private let threadInspector : ThreadInspector
10
+ private let debugImageCache : DebugImageCache
11
+
12
+ init (
13
+ dateProvider: SentryCurrentDateProvider ,
14
+ threadInspector: ThreadInspector ,
15
+ debugImageCache: DebugImageCache ,
16
+ minHangTime: TimeInterval ) {
17
+ self . dateProvider = dateProvider
18
+ self . threadInspector = threadInspector
19
+ self . debugImageCache = debugImageCache
29
20
self . lastFrameTime = 0
30
21
self . minHangTime = minHangTime
22
+ #if canImport(UIKit) && !SENTRY_NO_UIKIT
23
+ var maxFPS = 60.0
24
+ if #available( iOS 13 . 0 , * ) {
25
+ let window = UIApplication . shared. connectedScenes. flatMap { ( $0 as? UIWindowScene ) ? . windows ?? [ ] } . first { $0. isKeyWindow }
26
+ maxFPS = Double ( window? . screen. maximumFramesPerSecond ?? 60 )
27
+ } else {
28
+ maxFPS = Double ( UIScreen . main. maximumFramesPerSecond)
29
+ }
30
+ #else
31
+ let maxFPS : Double = 60.0
32
+ #endif
33
+ expectedFrameDuration = 1.0 / maxFPS
34
+ thresholdForFrameStacktrace = expectedFrameDuration * 0.5
31
35
}
32
36
33
37
// This queue is used to detect main thread hangs, they need to be detected on a background thread
34
38
// since the main thread is hanging.
35
- let queue = DispatchQueue ( label: " io.sentry.runloop-observer-checker " )
36
- var semaphore = DispatchSemaphore ( value: 0 )
37
- let minHangTime : TimeInterval
39
+ private let queue = DispatchQueue ( label: " io.sentry.runloop-observer-checker " )
40
+ private let minHangTime : TimeInterval
41
+ private let expectedFrameDuration : TimeInterval
42
+ private let thresholdForFrameStacktrace : TimeInterval
38
43
39
44
// MARK: Main queue
40
45
41
- var lastFrameTime : TimeInterval
42
- var running = false
43
- var frameStatistics = [ ( startTime: TimeInterval, delayTime: TimeInterval) ] ( )
46
+ private var semaphore = DispatchSemaphore ( value: 0 )
47
+ private var lastFrameTime : TimeInterval
48
+ private var running = false
49
+ private var frameStatistics = [ ( startTime: TimeInterval, delayTime: TimeInterval) ] ( )
50
+ // Keeps track of how long the current hang has been running for
51
+ // Set to nil after the current hang ends
52
+ private var maxHangTime : TimeInterval ?
44
53
45
54
func start( ) {
46
55
let observer = CFRunLoopObserverCreateWithHandler ( nil , CFRunLoopActivity . beforeWaiting. rawValue | CFRunLoopActivity . afterWaiting. rawValue | CFRunLoopActivity . beforeSources. rawValue, true , CFIndex ( INT_MAX) ) { [ weak self] _, activity in
@@ -54,7 +63,7 @@ final class RunloopObserver {
54
63
updateFrameStatistics ( )
55
64
semaphore = DispatchSemaphore ( value: 0 )
56
65
running = true
57
- let timeout = DispatchTime . now ( ) + DispatchTimeInterval. milliseconds ( Int ( minHangTime * 1_000 ) )
66
+ let timeout = DispatchTime . now ( ) + DispatchTimeInterval. milliseconds ( Int ( ( expectedFrameDuration + thresholdForFrameStacktrace ) * 1_000 ) )
58
67
let localSemaphore = semaphore
59
68
queue. async { [ weak self] in
60
69
let result = localSemaphore. wait ( timeout: timeout)
@@ -66,18 +75,17 @@ final class RunloopObserver {
66
75
break
67
76
}
68
77
}
69
- // print("[HANG] Woken up")
70
78
default :
71
79
fatalError ( )
72
80
}
73
81
}
74
82
CFRunLoopAddObserver ( CFRunLoopGetMain ( ) , observer, . commonModes)
75
83
}
76
84
77
- func updateFrameStatistics( ) {
85
+ private func updateFrameStatistics( ) {
78
86
dispatchPrecondition ( condition: . onQueue( . main) )
79
87
80
- let currentTime = dependencies . dateProvider. systemUptime ( )
88
+ let currentTime = dateProvider. systemUptime ( )
81
89
defer {
82
90
lastFrameTime = currentTime
83
91
}
@@ -86,41 +94,54 @@ final class RunloopObserver {
86
94
87
95
semaphore. signal ( )
88
96
if running {
89
- let expectedFrameTime = lastFrameTime + 1.0 / 60.0
90
- let frameDelay = currentTime - expectedFrameTime
91
- if frameDelay > minHangTime {
92
- print ( " [HANG] Hang detected \( frameDelay) s " )
93
- queue. async { [ weak self] in
94
- self ? . recordHang ( duration: frameDelay)
95
- }
96
- frameStatistics. removeAll ( )
97
- } else if frameDelay > 0 {
97
+ let frameDuration = currentTime - lastFrameTime
98
+ let frameDelay = frameDuration - expectedFrameDuration
99
+ // A hang is characterized by the % of a time period that the app is rendering late frames
100
+ // We use 50% of `minHangTime * 2` as the threshold for reporting a hang.
101
+ // Once this threshold is crossed, any frame that was > 50% late is considered a hanging frame.
102
+ // If a single frames delay is > minHangTime, it is considered a "fullyBlocking" hang.
103
+ if frameDelay > 0 {
98
104
frameStatistics. append ( ( startTime: lastFrameTime, delayTime: frameDelay) )
99
105
}
100
106
let totalTime = frameStatistics. map ( { $0. delayTime } ) . reduce ( 0 , + )
101
- if totalTime > minHangTime * 0.99 {
102
- print ( " [HANG] Detected non-blocking hang " )
103
- // TODO: Keep on recording until blocking period is over (or some max time)
104
- // TODO: Get stacktraces from when the individual blocking events occured
105
- // TODO: Send each event
107
+ if totalTime > minHangTime {
108
+ print ( " [HANG] Hang detected \( totalTime) " )
109
+ maxHangTime = max ( maxHangTime ?? 0 , totalTime)
110
+ // print("[HANG] Hang max \(maxHangTime ?? 0)")
111
+ } else {
112
+ if let maxHangTime {
113
+ // The hang has ended
114
+ print ( " [HANG] Hang reporting \( maxHangTime) " )
115
+ // Note: A non fully blocking hang always has multiple stacktraces
116
+ // because it is composed of multpile delayed frames. Each delayed frame has a stacktrace.
117
+ // We only support sending one stacktrace per event so we take the most recent one.
118
+ // Another option would be to generate one event for each delayed frame in the
119
+ // non fully blocking hang. Maybe we will eventually support something like
120
+ // "scroll hitches" and report each time a frame is dropped rather than an
121
+ // overal hang event with just one stacktrace.
122
+ let type : SentryANRType = frameStatistics. count > 0 ? . nonFullyBlocking : . fullyBlocking
123
+ queue. async { [ weak self] in
124
+ self ? . recordHang ( duration: maxHangTime, type: type)
125
+ }
126
+ }
127
+ maxHangTime = nil
106
128
}
107
129
}
108
130
}
109
131
110
132
// MARK: Background queue
111
133
112
- var threads : [ SentryThread ] ?
134
+ private var threads : [ SentryThread ] ?
113
135
114
- func hangStarted( ) {
136
+ private func hangStarted( ) {
115
137
dispatchPrecondition ( condition: . onQueue( queue) )
116
138
117
- // TODO: Write to disk to record fatal hangs on app start
118
-
119
- // Record threads at start of hang
120
- threads = dependencies. threadInspector. getCurrentThreadsWithStackTrace ( )
139
+ // TOD: Write to disk to record fatal hangs on app start
140
+ // Record threads when the hang is first detected
141
+ threads = threadInspector. getCurrentThreadsWithStackTrace ( )
121
142
}
122
143
123
- func recordHang( duration: TimeInterval ) {
144
+ private func recordHang( duration: TimeInterval , type : SentryANRType ) {
124
145
dispatchPrecondition ( condition: . onQueue( queue) )
125
146
126
147
guard let threads, !threads. isEmpty else {
@@ -129,8 +150,8 @@ final class RunloopObserver {
129
150
130
151
let event = Event ( )
131
152
SentryLevelBridge . setBreadcrumbLevelOn ( event, level: SentryLevel . error. rawValue)
132
- let exceptionType = SentryAppHangTypeMapper . getExceptionType ( anrType: . fullyBlocking )
133
- let exception = Exception ( value: " App hanging for \( duration ) seconds. " , type: exceptionType)
153
+ let exceptionType = SentryAppHangTypeMapper . getExceptionType ( anrType: type )
154
+ let exception = Exception ( value: String ( format : " App hanging for %.3f seconds. " , duration ) , type: exceptionType)
134
155
let mechanism = Mechanism ( type: " AppHang " )
135
156
exception. mechanism = mechanism
136
157
exception. stacktrace = threads [ 0 ] . stacktrace
@@ -142,31 +163,34 @@ final class RunloopObserver {
142
163
event. exceptions = [ exception]
143
164
event. threads = threads
144
165
145
- event. debugMeta = dependencies . debugImageCache. getDebugImagesFromCache ( for : event. threads)
166
+ event. debugMeta = debugImageCache. getDebugImagesFromCacheFor ( threads : event. threads)
146
167
SentrySDK . capture ( event: event)
147
168
}
148
169
}
149
170
150
171
@objc
151
172
@_spi ( Private) public final class RunLoopObserverObjcBridge : NSObject {
152
- @_spi ( Private) @objc public init ( dependencies: SentryDependencyScope ) {
153
- observer = RunloopObserver ( dependencies: dependencies, minHangTime: 2 )
154
- observer. start ( )
173
+
174
+ private let observer : RunLoopObserver
175
+
176
+ @objc public init (
177
+ dateProvider: SentryCurrentDateProvider ,
178
+ threadInspector: ThreadInspector ,
179
+ debugImageCache: DebugImageCache ) {
180
+ observer = RunLoopObserver ( dateProvider: dateProvider,
181
+ threadInspector: threadInspector,
182
+ debugImageCache: debugImageCache, minHangTime: 2 )
155
183
}
156
- let observer : RunloopObserver
157
184
158
- }
159
-
160
- @objc
161
- @_spi ( Private) public class SentryDependencyScope : NSObject , SentryCurrentDateProviding , DebugImageCacheProviding , ThreadInspectorProviding {
162
- @objc @_spi ( Private) public init ( options: Options , debugImageCache: DebugImageCache ) {
163
- self . threadInspector = SentryThreadInspector ( options: options)
164
- self . debugImageCache = debugImageCache
185
+ @objc public func start( ) {
186
+ observer. start ( )
165
187
}
188
+ }
166
189
167
- @_spi ( Private) @objc public let dateProvider : SentryCurrentDateProvider = SentryDefaultCurrentDateProvider ( )
168
- let threadInspector : ThreadInspector
169
- let debugImageCache : DebugImageCache
190
+ @objc @_spi ( Private) public protocol ThreadInspector {
191
+ func getCurrentThreadsWithStackTrace( ) -> [ SentryThread ]
170
192
}
171
193
172
- extension SentryThreadInspector : ThreadInspector { }
194
+ @objc @_spi ( Private) public protocol DebugImageCache {
195
+ func getDebugImagesFromCacheFor( threads: [ SentryThread ] ? ) -> [ DebugMeta ]
196
+ }
0 commit comments