@@ -8,15 +8,18 @@ final class RunLoopObserver {
8
8
private let dateProvider : SentryCurrentDateProvider
9
9
private let threadInspector : ThreadInspector
10
10
private let debugImageCache : DebugImageCache
11
+ private let fileManager : SentryFileManager
11
12
12
13
init (
13
14
dateProvider: SentryCurrentDateProvider ,
14
15
threadInspector: ThreadInspector ,
15
16
debugImageCache: DebugImageCache ,
17
+ fileManager: SentryFileManager ,
16
18
minHangTime: TimeInterval ) {
17
19
self . dateProvider = dateProvider
18
20
self . threadInspector = threadInspector
19
21
self . debugImageCache = debugImageCache
22
+ self . fileManager = fileManager
20
23
self . lastFrameTime = 0
21
24
self . minHangTime = minHangTime
22
25
#if canImport(UIKit) && !SENTRY_NO_UIKIT
@@ -32,6 +35,7 @@ final class RunLoopObserver {
32
35
#endif
33
36
expectedFrameDuration = 1.0 / maxFPS
34
37
thresholdForFrameStacktrace = expectedFrameDuration * 0.5
38
+ // TODO: Check for stored app hang
35
39
}
36
40
37
41
// 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 {
55
59
let observer = CFRunLoopObserverCreateWithHandler ( nil , CFRunLoopActivity . beforeWaiting. rawValue | CFRunLoopActivity . afterWaiting. rawValue | CFRunLoopActivity . beforeSources. rawValue, true , CFIndex ( INT_MAX) ) { [ weak self] _, activity in
56
60
guard let self else { return }
57
61
62
+ let started = updateFrameStatistics ( )
58
63
switch activity {
59
64
case . beforeWaiting:
60
- updateFrameStatistics ( )
61
65
running = false
62
66
case . afterWaiting, . beforeSources:
63
- updateFrameStatistics ( )
64
67
semaphore = DispatchSemaphore ( value: 0 )
65
68
running = true
66
- let timeout = DispatchTime . now ( ) + DispatchTimeInterval. milliseconds ( Int ( ( expectedFrameDuration + thresholdForFrameStacktrace) * 1_000 ) )
67
69
let localSemaphore = semaphore
68
70
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 )
77
72
}
78
73
default :
79
74
fatalError ( )
@@ -82,13 +77,10 @@ final class RunLoopObserver {
82
77
CFRunLoopAddObserver ( CFRunLoopGetMain ( ) , observer, . commonModes)
83
78
}
84
79
85
- private func updateFrameStatistics( ) {
80
+ private func updateFrameStatistics( ) -> Double {
86
81
dispatchPrecondition ( condition: . onQueue( . main) )
87
82
88
83
let currentTime = dateProvider. systemUptime ( )
89
- defer {
90
- lastFrameTime = currentTime
91
- }
92
84
// Only consider frames that were within 2x the minHangTime
93
85
frameStatistics = frameStatistics. filter { $0. startTime > currentTime - minHangTime * 2 }
94
86
@@ -104,10 +96,17 @@ final class RunLoopObserver {
104
96
frameStatistics. append ( ( startTime: lastFrameTime, delayTime: frameDelay) )
105
97
}
106
98
let totalTime = frameStatistics. map ( { $0. delayTime } ) . reduce ( 0 , + )
99
+ let type : SentryANRType = frameStatistics. count > 0 ? . nonFullyBlocking : . fullyBlocking
107
100
if totalTime > minHangTime {
108
101
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
+ }
111
110
} else {
112
111
if let maxHangTime {
113
112
// The hang has ended
@@ -119,52 +118,88 @@ final class RunLoopObserver {
119
118
// non fully blocking hang. Maybe we will eventually support something like
120
119
// "scroll hitches" and report each time a frame is dropped rather than an
121
120
// overal hang event with just one stacktrace.
122
- let type : SentryANRType = frameStatistics. count > 0 ? . nonFullyBlocking : . fullyBlocking
123
121
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)
125
125
}
126
126
}
127
127
maxHangTime = nil
128
128
}
129
129
}
130
+ lastFrameTime = currentTime
131
+ return currentTime
130
132
}
131
133
132
134
// MARK: Background queue
133
-
135
+
136
+ private var blockingDuration : TimeInterval ?
134
137
private var threads : [ SentryThread ] ?
135
138
136
- private func hangStarted ( ) {
139
+ private func waitForHang ( semaphore : DispatchSemaphore , started : TimeInterval , isStarting : Bool ) {
137
140
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
+ }
142
153
}
143
154
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 ) {
145
169
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) )
149
183
}
150
184
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 ( )
152
190
SentryLevelBridge . setBreadcrumbLevelOn ( event, level: SentryLevel . error. rawValue)
153
191
let exceptionType = SentryAppHangTypeMapper . getExceptionType ( anrType: type)
154
192
let exception = Exception ( value: String ( format: " App hanging for %.3f seconds. " , duration) , type: exceptionType)
155
193
let mechanism = Mechanism ( type: " AppHang " )
156
194
exception. mechanism = mechanism
157
195
exception. stacktrace = threads [ 0 ] . stacktrace
158
196
exception. stacktrace? . snapshot = true
159
-
160
- threads. forEach { $0. current = false }
161
- threads [ 0 ] . current = true
162
-
197
+ exception. stacktrace? . snapshot = true
163
198
event. exceptions = [ exception]
164
199
event. threads = threads
165
-
166
200
event. debugMeta = debugImageCache. getDebugImagesFromCacheFor ( threads: event. threads)
167
- SentrySDK . capture ( event: event)
201
+ SentryDependencyContainerSwiftHelper . applyScope ( to: event)
202
+ return event
168
203
}
169
204
}
170
205
@@ -176,10 +211,14 @@ final class RunLoopObserver {
176
211
@objc public init (
177
212
dateProvider: SentryCurrentDateProvider ,
178
213
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 )
183
222
}
184
223
185
224
@objc public func start( ) {
0 commit comments