11@_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
2920 self . lastFrameTime = 0
3021 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
3135 }
3236
3337 // This queue is used to detect main thread hangs, they need to be detected on a background thread
3438 // 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
3843
3944 // MARK: Main queue
4045
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 ?
4453
4554 func start( ) {
4655 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 {
5463 updateFrameStatistics ( )
5564 semaphore = DispatchSemaphore ( value: 0 )
5665 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 ) )
5867 let localSemaphore = semaphore
5968 queue. async { [ weak self] in
6069 let result = localSemaphore. wait ( timeout: timeout)
@@ -66,18 +75,17 @@ final class RunloopObserver {
6675 break
6776 }
6877 }
69- // print("[HANG] Woken up")
7078 default :
7179 fatalError ( )
7280 }
7381 }
7482 CFRunLoopAddObserver ( CFRunLoopGetMain ( ) , observer, . commonModes)
7583 }
7684
77- func updateFrameStatistics( ) {
85+ private func updateFrameStatistics( ) {
7886 dispatchPrecondition ( condition: . onQueue( . main) )
7987
80- let currentTime = dependencies . dateProvider. systemUptime ( )
88+ let currentTime = dateProvider. systemUptime ( )
8189 defer {
8290 lastFrameTime = currentTime
8391 }
@@ -86,41 +94,54 @@ final class RunloopObserver {
8694
8795 semaphore. signal ( )
8896 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 {
98104 frameStatistics. append ( ( startTime: lastFrameTime, delayTime: frameDelay) )
99105 }
100106 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
106128 }
107129 }
108130 }
109131
110132 // MARK: Background queue
111133
112- var threads : [ SentryThread ] ?
134+ private var threads : [ SentryThread ] ?
113135
114- func hangStarted( ) {
136+ private func hangStarted( ) {
115137 dispatchPrecondition ( condition: . onQueue( queue) )
116138
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 ( )
121142 }
122143
123- func recordHang( duration: TimeInterval ) {
144+ private func recordHang( duration: TimeInterval , type : SentryANRType ) {
124145 dispatchPrecondition ( condition: . onQueue( queue) )
125146
126147 guard let threads, !threads. isEmpty else {
@@ -129,8 +150,8 @@ final class RunloopObserver {
129150
130151 let event = Event ( )
131152 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)
134155 let mechanism = Mechanism ( type: " AppHang " )
135156 exception. mechanism = mechanism
136157 exception. stacktrace = threads [ 0 ] . stacktrace
@@ -142,31 +163,34 @@ final class RunloopObserver {
142163 event. exceptions = [ exception]
143164 event. threads = threads
144165
145- event. debugMeta = dependencies . debugImageCache. getDebugImagesFromCache ( for : event. threads)
166+ event. debugMeta = debugImageCache. getDebugImagesFromCacheFor ( threads : event. threads)
146167 SentrySDK . capture ( event: event)
147168 }
148169}
149170
150171@objc
151172@_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 )
155183 }
156- let observer : RunloopObserver
157184
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 ( )
165187 }
188+ }
166189
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 ]
170192}
171193
172- extension SentryThreadInspector : ThreadInspector { }
194+ @objc @_spi ( Private) public protocol DebugImageCache {
195+ func getDebugImagesFromCacheFor( threads: [ SentryThread ] ? ) -> [ DebugMeta ]
196+ }
0 commit comments