|
| 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 |
| 29 | + self.lastFrameTime = 0 |
| 30 | + self.minHangTime = minHangTime |
| 31 | + } |
| 32 | + |
| 33 | + // This queue is used to detect main thread hangs, they need to be detected on a background thread |
| 34 | + // 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 |
| 38 | + |
| 39 | + // MARK: Main queue |
| 40 | + |
| 41 | + var lastFrameTime: TimeInterval |
| 42 | + var running = false |
| 43 | + var frameStatistics = [(startTime: TimeInterval, delayTime: TimeInterval)]() |
| 44 | + |
| 45 | + func start() { |
| 46 | + let observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.afterWaiting.rawValue | CFRunLoopActivity.beforeSources.rawValue, true, CFIndex(INT_MAX)) { [weak self] _, activity in |
| 47 | + guard let self else { return } |
| 48 | + |
| 49 | + switch activity { |
| 50 | + case .beforeWaiting: |
| 51 | + updateFrameStatistics() |
| 52 | + running = false |
| 53 | + case .afterWaiting, .beforeSources: |
| 54 | + updateFrameStatistics() |
| 55 | + semaphore = DispatchSemaphore(value: 0) |
| 56 | + running = true |
| 57 | + let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(minHangTime * 1_000)) |
| 58 | + let localSemaphore = semaphore |
| 59 | + queue.async { [weak self] in |
| 60 | + let result = localSemaphore.wait(timeout: timeout) |
| 61 | + switch result { |
| 62 | + case .timedOut: |
| 63 | + print("[HANG] Timeout, hang detected") |
| 64 | + self?.hangStarted() |
| 65 | + case .success: |
| 66 | + break |
| 67 | + } |
| 68 | + } |
| 69 | + // print("[HANG] Woken up") |
| 70 | + default: |
| 71 | + fatalError() |
| 72 | + } |
| 73 | + } |
| 74 | + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) |
| 75 | + } |
| 76 | + |
| 77 | + func updateFrameStatistics() { |
| 78 | + dispatchPrecondition(condition: .onQueue(.main)) |
| 79 | + |
| 80 | + let currentTime = dependencies.dateProvider.systemUptime() |
| 81 | + defer { |
| 82 | + lastFrameTime = currentTime |
| 83 | + } |
| 84 | + // Only consider frames that were within 2x the minHangTime |
| 85 | + frameStatistics = frameStatistics.filter { $0.startTime > currentTime - minHangTime * 2 } |
| 86 | + |
| 87 | + semaphore.signal() |
| 88 | + 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 { |
| 98 | + frameStatistics.append((startTime: lastFrameTime, delayTime: frameDelay)) |
| 99 | + } |
| 100 | + 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 |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // MARK: Background queue |
| 111 | + |
| 112 | + var threads: [SentryThread]? |
| 113 | + |
| 114 | + func hangStarted() { |
| 115 | + dispatchPrecondition(condition: .onQueue(queue)) |
| 116 | + |
| 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() |
| 121 | + } |
| 122 | + |
| 123 | + func recordHang(duration: TimeInterval) { |
| 124 | + dispatchPrecondition(condition: .onQueue(queue)) |
| 125 | + |
| 126 | + guard let threads, !threads.isEmpty else { |
| 127 | + return |
| 128 | + } |
| 129 | + |
| 130 | + let event = Event() |
| 131 | + 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) |
| 134 | + let mechanism = Mechanism(type: "AppHang") |
| 135 | + exception.mechanism = mechanism |
| 136 | + exception.stacktrace = threads[0].stacktrace |
| 137 | + exception.stacktrace?.snapshot = true |
| 138 | + |
| 139 | + threads.forEach { $0.current = false } |
| 140 | + threads[0].current = true |
| 141 | + |
| 142 | + event.exceptions = [exception] |
| 143 | + event.threads = threads |
| 144 | + |
| 145 | + event.debugMeta = dependencies.debugImageCache.getDebugImagesFromCache(for: event.threads) |
| 146 | + SentrySDK.capture(event: event) |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +@objc |
| 151 | +@_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() |
| 155 | + } |
| 156 | + let observer: RunloopObserver |
| 157 | + |
| 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 |
| 165 | + } |
| 166 | + |
| 167 | + @_spi(Private) @objc public let dateProvider: SentryCurrentDateProvider = SentryDefaultCurrentDateProvider() |
| 168 | + let threadInspector: ThreadInspector |
| 169 | + let debugImageCache: DebugImageCache |
| 170 | +} |
| 171 | + |
| 172 | +extension SentryThreadInspector: ThreadInspector { } |
0 commit comments