@@ -8,15 +8,18 @@ final class RunLoopObserver {
88 private let dateProvider : SentryCurrentDateProvider
99 private let threadInspector : ThreadInspector
1010 private let debugImageCache : DebugImageCache
11+ private let fileManager : SentryFileManager
1112
1213 init (
1314 dateProvider: SentryCurrentDateProvider ,
1415 threadInspector: ThreadInspector ,
1516 debugImageCache: DebugImageCache ,
17+ fileManager: SentryFileManager ,
1618 minHangTime: TimeInterval ) {
1719 self . dateProvider = dateProvider
1820 self . threadInspector = threadInspector
1921 self . debugImageCache = debugImageCache
22+ self . fileManager = fileManager
2023 self . lastFrameTime = 0
2124 self . minHangTime = minHangTime
2225#if canImport(UIKit) && !SENTRY_NO_UIKIT
@@ -32,6 +35,7 @@ final class RunLoopObserver {
3235 #endif
3336 expectedFrameDuration = 1.0 / maxFPS
3437 thresholdForFrameStacktrace = expectedFrameDuration * 0.5
38+ // TODO: Check for stored app hang
3539 }
3640
3741 // This queue is used to detect main thread hangs, they need to be detected on a background thread
@@ -55,25 +59,16 @@ final class RunLoopObserver {
5559 let observer = CFRunLoopObserverCreateWithHandler ( nil , CFRunLoopActivity . beforeWaiting. rawValue | CFRunLoopActivity . afterWaiting. rawValue | CFRunLoopActivity . beforeSources. rawValue, true , CFIndex ( INT_MAX) ) { [ weak self] _, activity in
5660 guard let self else { return }
5761
62+ let started = updateFrameStatistics ( )
5863 switch activity {
5964 case . beforeWaiting:
60- updateFrameStatistics ( )
6165 running = false
6266 case . afterWaiting, . beforeSources:
63- updateFrameStatistics ( )
6467 semaphore = DispatchSemaphore ( value: 0 )
6568 running = true
66- let timeout = DispatchTime . now ( ) + DispatchTimeInterval. milliseconds ( Int ( ( expectedFrameDuration + thresholdForFrameStacktrace) * 1_000 ) )
6769 let localSemaphore = semaphore
6870 queue. async { [ weak self] in
69- let result = localSemaphore. wait ( timeout: timeout)
70- switch result {
71- case . timedOut:
72- print ( " [HANG] Timeout, hang detected " )
73- self ? . hangStarted ( )
74- case . success:
75- break
76- }
71+ self ? . waitForHang ( semaphore: localSemaphore, started: started, isStarting: true )
7772 }
7873 default :
7974 fatalError ( )
@@ -82,13 +77,10 @@ final class RunLoopObserver {
8277 CFRunLoopAddObserver ( CFRunLoopGetMain ( ) , observer, . commonModes)
8378 }
8479
85- private func updateFrameStatistics( ) {
80+ private func updateFrameStatistics( ) -> Double {
8681 dispatchPrecondition ( condition: . onQueue( . main) )
8782
8883 let currentTime = dateProvider. systemUptime ( )
89- defer {
90- lastFrameTime = currentTime
91- }
9284 // Only consider frames that were within 2x the minHangTime
9385 frameStatistics = frameStatistics. filter { $0. startTime > currentTime - minHangTime * 2 }
9486
@@ -104,10 +96,17 @@ final class RunLoopObserver {
10496 frameStatistics. append ( ( startTime: lastFrameTime, delayTime: frameDelay) )
10597 }
10698 let totalTime = frameStatistics. map ( { $0. delayTime } ) . reduce ( 0 , + )
99+ let type : SentryANRType = frameStatistics. count > 0 ? . nonFullyBlocking : . fullyBlocking
107100 if totalTime > minHangTime {
108101 print ( " [HANG] Hang detected \( totalTime) " )
109- maxHangTime = max ( maxHangTime ?? 0 , totalTime)
110- // print("[HANG] Hang max \(maxHangTime ?? 0)")
102+ let maxTime = max ( maxHangTime ?? 0 , totalTime)
103+ maxHangTime = maxTime
104+ // Update on disk hang
105+ queue. async { [ weak self] in
106+ guard let self, let threads = threads, !threads. isEmpty else { return }
107+ let event = makeEvent ( duration: maxTime, threads: threads, type: type)
108+ fileManager. storeAppHang ( event)
109+ }
111110 } else {
112111 if let maxHangTime {
113112 // The hang has ended
@@ -119,52 +118,88 @@ final class RunLoopObserver {
119118 // non fully blocking hang. Maybe we will eventually support something like
120119 // "scroll hitches" and report each time a frame is dropped rather than an
121120 // overal hang event with just one stacktrace.
122- let type : SentryANRType = frameStatistics. count > 0 ? . nonFullyBlocking : . fullyBlocking
123121 queue. async { [ weak self] in
124- self ? . recordHang ( duration: maxHangTime, type: type)
122+ guard let self, let threads = threads, !threads. isEmpty else { return }
123+ let event = makeEvent ( duration: maxHangTime, threads: threads, type: type)
124+ SentrySDK . capture ( event: event)
125125 }
126126 }
127127 maxHangTime = nil
128128 }
129129 }
130+ lastFrameTime = currentTime
131+ return currentTime
130132 }
131133
132134 // MARK: Background queue
133-
135+
136+ private var blockingDuration : TimeInterval ?
134137 private var threads : [ SentryThread ] ?
135138
136- private func hangStarted ( ) {
139+ private func waitForHang ( semaphore : DispatchSemaphore , started : TimeInterval , isStarting : Bool ) {
137140 dispatchPrecondition ( condition: . onQueue( queue) )
138-
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 ( )
141+
142+ let timeout = DispatchTime . now ( ) + DispatchTimeInterval. milliseconds ( Int ( ( expectedFrameDuration + thresholdForFrameStacktrace) * 1_000 ) )
143+ let result = semaphore. wait ( timeout: timeout)
144+ switch result {
145+ case . timedOut:
146+ semaphore. signal ( )
147+ print ( " [HANG] Timeout, hang detected " )
148+ continueHang ( started: started, isStarting: isStarting)
149+ waitForHang ( semaphore: semaphore, started: started, isStarting: false )
150+ case . success:
151+ break
152+ }
142153 }
143154
144- private func recordHang( duration: TimeInterval , type: SentryANRType ) {
155+ // TODO: Only write hang if it's long enough
156+ // TODO: Need to clear hang details after the hang ends
157+ // Problem: If we are detecting a multiple runloop hang, which then turns into a single long hang
158+ // we might want to add the total time of that long hang to what is on disk from the multiple runloop hang
159+ // Or we could not do that and just say we only overwrite what is on disk if the hang exceeds the time
160+ // of the multiple runloop hang.
161+ // Could have two paths, fullyBlocking only used when the semaphore times out, we keep tracking in memory until
162+ // it exceeds the threshold then we write to disk.
163+ // Non fully blocking only writes when the runloop finishes if it exceeds the threshold.
164+ // Sampled stacktrace should be kept separate from time, because time for nonFullyBlocking is kep on main thread
165+ // time for fullyBlocking is kept on background thread
166+
167+ // TODO: Not using should sample
168+ private func continueHang( started: TimeInterval , isStarting: Bool ) {
145169 dispatchPrecondition ( condition: . onQueue( queue) )
146-
147- guard let threads, !threads. isEmpty else {
148- return
170+
171+ if isStarting {
172+ // A hang lasts a while, but we only support showing the stacktrace when it was first detected
173+ threads = threadInspector. getCurrentThreadsWithStackTrace ( )
174+ threads? . forEach { $0. current = false }
175+ threads ? [ 0 ] . current = true
176+ }
177+ let duration = dateProvider. systemUptime ( ) - started
178+ blockingDuration = duration
179+ if let threads, !threads. isEmpty, duration > minHangTime {
180+ // Hangs detected in the background are always fully blocking
181+ // Otherwise we'd be detecting them on the main thread.
182+ fileManager. storeAppHang ( makeEvent ( duration: duration, threads: threads, type: . fullyBlocking) )
149183 }
150184
151- let event = Event ( )
185+ }
186+
187+ // Safe to call from any thread
188+ private func makeEvent( duration: TimeInterval , threads: [ SentryThread ] , type: SentryANRType ) -> Event {
189+ var event = Event ( )
152190 SentryLevelBridge . setBreadcrumbLevelOn ( event, level: SentryLevel . error. rawValue)
153191 let exceptionType = SentryAppHangTypeMapper . getExceptionType ( anrType: type)
154192 let exception = Exception ( value: String ( format: " App hanging for %.3f seconds. " , duration) , type: exceptionType)
155193 let mechanism = Mechanism ( type: " AppHang " )
156194 exception. mechanism = mechanism
157195 exception. stacktrace = threads [ 0 ] . stacktrace
158196 exception. stacktrace? . snapshot = true
159-
160- threads. forEach { $0. current = false }
161- threads [ 0 ] . current = true
162-
197+ exception. stacktrace? . snapshot = true
163198 event. exceptions = [ exception]
164199 event. threads = threads
165-
166200 event. debugMeta = debugImageCache. getDebugImagesFromCacheFor ( threads: event. threads)
167- SentrySDK . capture ( event: event)
201+ SentryDependencyContainerSwiftHelper . applyScope ( to: event)
202+ return event
168203 }
169204}
170205
@@ -176,10 +211,14 @@ final class RunLoopObserver {
176211 @objc public init (
177212 dateProvider: SentryCurrentDateProvider ,
178213 threadInspector: ThreadInspector ,
179- debugImageCache: DebugImageCache ) {
180- observer = RunLoopObserver ( dateProvider: dateProvider,
181- threadInspector: threadInspector,
182- debugImageCache: debugImageCache, minHangTime: 2 )
214+ debugImageCache: DebugImageCache ,
215+ fileManager: SentryFileManager ) {
216+ observer = RunLoopObserver (
217+ dateProvider: dateProvider,
218+ threadInspector: threadInspector,
219+ debugImageCache: debugImageCache,
220+ fileManager: fileManager,
221+ minHangTime: 2 )
183222 }
184223
185224 @objc public func start( ) {
0 commit comments