From 915a74089860bb4aba0dad767ca0bd4d7c93518f Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Mon, 22 Sep 2025 10:14:42 -0400 Subject: [PATCH] feat: Hang tracker updates --- CHANGELOG.md | 6 + Sentry.xcodeproj/project.pbxproj | 18 +- Sources/Sentry/SentryBaseIntegration.m | 19 +- Sources/Sentry/SentryDependencyContainer.m | 3 + ...ryWatchdogTerminationTrackingIntegration.m | 29 ++- .../HybridPublic/SentryDependencyContainer.h | 2 + Sources/Sentry/include/SentryPrivate.h | 1 + Sources/Swift/HangTracker.swift | 216 ++++++++++++++++++ ...gTerminationTrackingIntegrationSwift.swift | 50 ++++ .../Integrations/ANR/HangTrackerTests.swift | 118 ++++++++++ ...inationTrackingIntegrationSwiftTests.swift | 103 +++++++++ ...gTerminationTrackingIntegrationTests.swift | 4 +- 12 files changed, 532 insertions(+), 37 deletions(-) create mode 100644 Sources/Swift/HangTracker.swift create mode 100644 Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwift.swift create mode 100644 Tests/SentryTests/Integrations/ANR/HangTrackerTests.swift create mode 100644 Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a069475916..008e888a437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improvements + +- The watchdog termination integration uses a runloop observer instead of fixed interval main thread work to avoid creating a busy runloop (#6237) + ## 8.56.1 ### Fixes diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 1dc5fb2d1a3..c145c38029e 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -1110,11 +1110,15 @@ FACEED132E3179A10007B4AC /* SentyOptionsInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = FACEED122E3179A10007B4AC /* SentyOptionsInternal.m */; }; FAE2DAB82E1F317900262307 /* SentryProfilingSwiftHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = FAE2DAB72E1F317900262307 /* SentryProfilingSwiftHelpers.m */; }; FAE2DABA2E1F318900262307 /* SentryProfilingSwiftHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = FAE2DAB92E1F318900262307 /* SentryProfilingSwiftHelpers.h */; }; + FAE579842E7CF21800B710F9 /* SentryMigrateSessionInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE5797E2E7CF21300B710F9 /* SentryMigrateSessionInit.swift */; }; FAE5798D2E7D9D4C00B710F9 /* SentrySysctl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579872E7D9D4900B710F9 /* SentrySysctl.swift */; }; FAE579BA2E7DBE9900B710F9 /* SentryGlobalEventProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579B42E7DBE9400B710F9 /* SentryGlobalEventProcessor.swift */; }; FAE579C22E7DDDE700B710F9 /* SentryThreadWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579BC2E7DDDE400B710F9 /* SentryThreadWrapper.swift */; }; - FAE579842E7CF21800B710F9 /* SentryMigrateSessionInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE5797E2E7CF21300B710F9 /* SentryMigrateSessionInit.swift */; }; FAE579CC2E7DE14900B710F9 /* SentryFrameRemover.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579C62E7DE14400B710F9 /* SentryFrameRemover.swift */; }; + FAE579D52E7F238100B710F9 /* HangTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579CF2E7F237A00B710F9 /* HangTracker.swift */; }; + FAE57A3C2E81C55900B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE57A362E81C54F00B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwift.swift */; }; + FAE57C142E831A7700B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE57C0E2E831A7700B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift */; }; + FAE57C232E832A9400B710F9 /* HangTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE57C1D2E832A8F00B710F9 /* HangTrackerTests.swift */; }; FAE80C242E4695B40010A595 /* SentryEvent+Serialize.h in Headers */ = {isa = PBXBuildFile; fileRef = FAE80C232E4695AE0010A595 /* SentryEvent+Serialize.h */; }; FAEC270E2DF3526000878871 /* SentryUserFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEC270D2DF3526000878871 /* SentryUserFeedback.swift */; }; FAEC273D2DF3933A00878871 /* NSData+Unzip.m in Sources */ = {isa = PBXBuildFile; fileRef = FAEC273C2DF3933200878871 /* NSData+Unzip.m */; }; @@ -2459,6 +2463,10 @@ FAE579B42E7DBE9400B710F9 /* SentryGlobalEventProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryGlobalEventProcessor.swift; sourceTree = ""; }; FAE579BC2E7DDDE400B710F9 /* SentryThreadWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryThreadWrapper.swift; sourceTree = ""; }; FAE579C62E7DE14400B710F9 /* SentryFrameRemover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryFrameRemover.swift; sourceTree = ""; }; + FAE579CF2E7F237A00B710F9 /* HangTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HangTracker.swift; sourceTree = ""; }; + FAE57A362E81C54F00B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryWatchdogTerminationTrackingIntegrationSwift.swift; sourceTree = ""; }; + FAE57C0E2E831A7700B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift; sourceTree = ""; }; + FAE57C1D2E832A8F00B710F9 /* HangTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HangTrackerTests.swift; sourceTree = ""; }; FAE80C232E4695AE0010A595 /* SentryEvent+Serialize.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryEvent+Serialize.h"; path = "include/SentryEvent+Serialize.h"; sourceTree = ""; }; FAEC270D2DF3526000878871 /* SentryUserFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUserFeedback.swift; sourceTree = ""; }; FAEC273C2DF3933200878871 /* NSData+Unzip.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSData+Unzip.m"; sourceTree = ""; }; @@ -3355,6 +3363,7 @@ 7B2A70D627D5F06C008B0D15 /* ANR */ = { isa = PBXGroup; children = ( + FAE57C1D2E832A8F00B710F9 /* HangTrackerTests.swift */, 7B2A70D727D5F07F008B0D15 /* SentryANRTrackerV1Tests.swift */, 621D22002DBB7E09006F9C48 /* SentryANRTrackerV1IntegrationTests.swift */, 621AE74E2C626CF70012E730 /* SentryANRTrackerV2Tests.swift */, @@ -3763,6 +3772,7 @@ children = ( D452FE6B2DDC873400AFF56F /* Processors */, 7BFE7A0927A1B6B000D2B66E /* SentryWatchdogTerminationTrackingIntegrationTests.swift */, + FAE57C0E2E831A7700B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift */, 0A2D7BB929152CBF008727AF /* SentryWatchdogTerminationScopeObserverTests.swift */, 7B98D7DF25FB73B900C5A389 /* SentryWatchdogTerminationTrackerTests.swift */, D452FE742DDC8DC400AFF56F /* TestSentryWatchdogTerminationBreadcrumbProcessor.swift */, @@ -4227,6 +4237,7 @@ D452FCBC2DDB6FA800AFF56F /* WatchdogTerminations */ = { isa = PBXGroup; children = ( + FAE57A362E81C54F00B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwift.swift */, D452FCBD2DDB6FC200AFF56F /* Processors */, ); path = WatchdogTerminations; @@ -4324,6 +4335,7 @@ D800942328F82E8D005D3943 /* Swift */ = { isa = PBXGroup; children = ( + FAE579CF2E7F237A00B710F9 /* HangTracker.swift */, F4FE9E062E6248BB0014FED5 /* SentryCrash */, FABB48B22E59310D0071397E /* Transaction */, FAAB29F02E3D252000ACD577 /* SentrySession.swift */, @@ -5865,6 +5877,7 @@ FA01BCB22E69352A00968DFA /* SentryDiscardedEvent.swift in Sources */, 7BAF3DCE243DCBFE008A5414 /* SentryTransportFactory.m in Sources */, F4E3DCCB2E1579240093CB80 /* SentryScopePersistentStore.swift in Sources */, + FAE57A3C2E81C55900B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwift.swift in Sources */, 7D65260E237F649E00113EA2 /* SentryScope.m in Sources */, D4EDF9842D0B2A210071E7B3 /* Data+SentryTracing.swift in Sources */, 84281C472A57905700EE88F2 /* SentrySample.m in Sources */, @@ -5888,6 +5901,7 @@ 0A2D8DA9289BC905008720F6 /* SentryViewHierarchyProviderHelper.m in Sources */, D84D2CDD2C2BF7370011AF8A /* SentryReplayEvent.swift in Sources */, D8BC28CC2BFF78220054DA4D /* SentryRRWebTouchEvent.swift in Sources */, + FAE579D52E7F238100B710F9 /* HangTracker.swift in Sources */, D452FC592DDB4B1700AFF56F /* SentryWatchdogTerminationBreadcrumbProcessor.m in Sources */, FAE2DAB82E1F317900262307 /* SentryProfilingSwiftHelpers.m in Sources */, 8EA1ED0B2668F8C400E62B98 /* SentryUIViewControllerSwizzling.m in Sources */, @@ -6034,6 +6048,7 @@ 62278CA82E30B21A0022ABC6 /* SentryHttpTransportFlushIntegrationTests.swift in Sources */, 0A5370A128A3EC2400B2DCDE /* SentryViewHierarchyProviderTests.swift in Sources */, D8FFE50C2703DBB400607131 /* SwizzlingCallTests.swift in Sources */, + FAE57C142E831A7700B710F9 /* SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift in Sources */, 7BFAA6E7297AA16A00E7E02E /* SentryCrashMonitor_CppException_Tests.mm in Sources */, 9286059929A50BAB00F96038 /* SentryGeoTests.swift in Sources */, 621655662DB12A8900810504 /* SentryCrashMach-OTests.m in Sources */, @@ -6241,6 +6256,7 @@ D884A20527C80F6300074664 /* SentryCoreDataTrackerTest.swift in Sources */, 8E70B10125CB8695002B3155 /* SentrySpanIdTests.swift in Sources */, 62E2119A2DAE99FC007D7262 /* SentryAsyncSafeLog.m in Sources */, + FAE57C232E832A9400B710F9 /* HangTrackerTests.swift in Sources */, 84EB21962BF01CEA00EDDA28 /* SentryCrashInstallationTests.swift in Sources */, 7BFE7A0A27A1B6B000D2B66E /* SentryWatchdogTerminationTrackingIntegrationTests.swift in Sources */, FA6614FC2E4B8E1A00657755 /* TestSentryUIApplication.swift in Sources */, diff --git a/Sources/Sentry/SentryBaseIntegration.m b/Sources/Sentry/SentryBaseIntegration.m index ee0b984d65b..3b6d7fa7d5f 100644 --- a/Sources/Sentry/SentryBaseIntegration.m +++ b/Sources/Sentry/SentryBaseIntegration.m @@ -186,16 +186,8 @@ - (BOOL)shouldBeEnabledWithOptions:(SentryOptions *)options BOOL performanceDisabled = !options.enableAutoPerformanceTracing || !options.isTracingEnabled; BOOL appHangsV2Disabled = options.isAppHangTrackingV2Disabled; -# if SDK_V9 - // The V9 watchdog tracker uses the frames tracker, so frame tracking - // must be enabled if watchdog tracking is enabled. - BOOL watchdogDisabled = !options.enableWatchdogTerminationTracking; -# else - // Before V9 this should have no effect so set it to YES - BOOL watchdogDisabled = YES; -# endif // SDK_V9 - if (performanceDisabled && appHangsV2Disabled && watchdogDisabled) { + if (performanceDisabled && appHangsV2Disabled) { if (appHangsV2Disabled) { SENTRY_LOG_DEBUG(@"Not going to enable %@ because enableAppHangTrackingV2 is " @"disabled or the appHangTimeoutInterval is 0.", @@ -208,15 +200,6 @@ - (BOOL)shouldBeEnabledWithOptions:(SentryOptions *)options self.integrationName); } -# if SDK_V9 - if (watchdogDisabled) { - SENTRY_LOG_DEBUG( - @"Not going to enable %@ because enableWatchdogTerminationTracking " - @"is disabled.", - self.integrationName); - } -# endif // SKD_V9 - return NO; } #endif // SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index 06c556c0a93..87815a32ae0 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -139,6 +139,9 @@ - (instancetype)init if (self = [super init]) { isInitialializingDependencyContainer = YES; + _hangTracker = [[SentryHangTrackerObjcBridge alloc] + initWithDateProvider:SentryDependencies.dateProvider]; + _dispatchQueueWrapper = SentryDependencies.dispatchQueueWrapper; _random = [[SentryRandom alloc] init]; _threadWrapper = [[SentryThreadWrapper alloc] init]; diff --git a/Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m b/Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m index 0ed1399cbdf..ab60e759071 100644 --- a/Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m +++ b/Sources/Sentry/SentryWatchdogTerminationTrackingIntegration.m @@ -18,12 +18,13 @@ # import NS_ASSUME_NONNULL_BEGIN -@interface SentryWatchdogTerminationTrackingIntegration () +@interface SentryWatchdogTerminationTrackingIntegration () @property (nonatomic, strong) SentryWatchdogTerminationTracker *tracker; -@property (nonatomic, strong) id anrTracker; @property (nullable, nonatomic, copy) NSString *testConfigurationFilePath; @property (nonatomic, strong) SentryAppStateManager *appStateManager; +@property (nonatomic, strong) SentryWatchdogTerminationTrackingIntegrationSwift *swiftImpl; +@property (nonatomic, strong) SentryDispatchQueueWrapper *queue; @end @@ -56,6 +57,12 @@ - (BOOL)installWithOptions:(SentryOptions *)options [[SentryDispatchQueueWrapper alloc] initWithName:"io.sentry.watchdog-termination-tracker" attributes:attributes]; + self.swiftImpl = [[SentryWatchdogTerminationTrackingIntegrationSwift alloc] + initWithHangTrackerBridge:SentryDependencyContainer.sharedInstance.hangTracker + timeoutInterval:options.appHangTimeoutInterval + hangStarted:^{ [dispatchQueueWrapper dispatchAsyncWithBlock:^{ [self hangStarted]; }]; } + hangStopped:^{ [dispatchQueueWrapper dispatchAsyncWithBlock:^{ [self hangStopped]; }]; }]; + SentryFileManager *fileManager = [[[SentrySDKInternal currentHub] getClient] fileManager]; SentryAppStateManager *appStateManager = [SentryDependencyContainer sharedInstance].appStateManager; @@ -75,17 +82,7 @@ - (BOOL)installWithOptions:(SentryOptions *)options scopePersistentStore:scopeStore]; [self.tracker start]; - -# if SDK_V9 - BOOL isV2Enabled = YES; -# else - BOOL isV2Enabled = options.enableAppHangTrackingV2; -# endif // SDK_V9 - - self.anrTracker = - [SentryDependencyContainer.sharedInstance getANRTracker:options.appHangTimeoutInterval - isV2Enabled:isV2Enabled]; - [self.anrTracker addListener:self]; + [self.swiftImpl start]; self.appStateManager = appStateManager; @@ -125,16 +122,16 @@ - (void)uninstall if (nil != self.tracker) { [self.tracker stop]; } - [self.anrTracker removeListener:self]; + [self.swiftImpl stop]; } -- (void)anrDetectedWithType:(enum SentryANRType)type +- (void)hangStarted { [self.appStateManager updateAppState:^(SentryAppState *appState) { appState.isANROngoing = YES; }]; } -- (void)anrStoppedWithResult:(SentryANRStoppedResult *_Nullable)result +- (void)hangStopped { [self.appStateManager updateAppState:^(SentryAppState *appState) { appState.isANROngoing = NO; }]; diff --git a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h index 59500c116bf..baa3fa36480 100644 --- a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h +++ b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h @@ -25,6 +25,7 @@ @class SentryOptions; @class SentrySessionTracker; @class SentryGlobalEventProcessor; +@class SentryHangTrackerObjcBridge; @protocol SentryANRTracker; @protocol SentryRandomProtocol; @@ -95,6 +96,7 @@ SENTRY_NO_INIT @property (nonatomic, strong) id rateLimits; @property (nonatomic, strong) id application; @property (nonatomic, strong) SentryThreadsafeApplication *threadsafeApplication; +@property (nonatomic, strong) SentryHangTrackerObjcBridge *hangTracker; #if SENTRY_HAS_REACHABILITY @property (nonatomic, strong) SentryReachability *reachability; diff --git a/Sources/Sentry/include/SentryPrivate.h b/Sources/Sentry/include/SentryPrivate.h index acc0ab640c1..aa408091383 100644 --- a/Sources/Sentry/include/SentryPrivate.h +++ b/Sources/Sentry/include/SentryPrivate.h @@ -21,6 +21,7 @@ // Headers that also import SentryDefines should be at the end of this list // otherwise it wont compile +#import "SentryAppStateManager.h" #import "SentryAsyncLog.h" #import "SentryClient+Logs.h" #import "SentryCrash.h" diff --git a/Sources/Swift/HangTracker.swift b/Sources/Swift/HangTracker.swift new file mode 100644 index 00000000000..cf85d567ae5 --- /dev/null +++ b/Sources/Swift/HangTracker.swift @@ -0,0 +1,216 @@ +@_implementationOnly import _SentryPrivate +#if canImport(UIKit) && !SENTRY_NO_UIKIT +import UIKit +#endif + +struct RunLoopIteration { + let startTime: TimeInterval + let endTime: TimeInterval +} + +#if SENTRY_TEST || SENTRY_TEST_CI || DEBUG +protocol HangTracker { + // The callback must be called on a background thread, because the main thread is blocked + func addLateRunLoopObserver(handler: @escaping (UUID, TimeInterval) -> Void) -> UUID + + func removeLateRunLoopObserver(id: UUID) + + // The callback is always called on the main thread + func addFinishedRunLoopObserver(handler: @escaping (RunLoopIteration) -> Void) -> UUID + + func removeFinishedRunLoopObserver(id: UUID) +} +protocol RunLoopObserver { } + +extension DefaultHangTracker: HangTracker { } +extension CFRunLoopObserver: RunLoopObserver { } +#else +typealias HangTracker = DefaultHangTracker +typealias RunLoopObserver = CFRunLoopObserver +#endif + +typealias CreateObserverFunc = (_ allocator: CFAllocator?, _ activities: CFOptionFlags, _ repeats: Bool, _ order: CFIndex, _ block: ((T?, CFRunLoopActivity) -> Void)?) -> T? +typealias AddObserverFunc = (_ rl: CFRunLoop?, _ observer: T?, _ mode: CFRunLoopMode?) -> Void +typealias RemoveObserverFunc = (_ rl: CFRunLoop?, _ observer: T?, _ mode: CFRunLoopMode?) -> Void + +final class DefaultHangTracker { + + init( + dateProvider: SentryCurrentDateProvider, + createObserver: @escaping CreateObserverFunc, + addObserver: @escaping AddObserverFunc, + removeObserver: @escaping RemoveObserverFunc, + queue: DispatchQueue = DispatchQueue(label: "io.sentry.runloop-observer-checker") + ) { + self.dateProvider = dateProvider + self.createObserver = createObserver + self.addObserver = addObserver + self.removeObserver = removeObserver + self.queue = queue +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (!swift(>=5.9) || !os(visionOS)) && !os(watchOS) + var maxFPS = 60.0 + if #available(iOS 13.0, tvOS 13.0, *) { + let window = UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow } + maxFPS = Double(window?.screen.maximumFramesPerSecond ?? 60) + } else { + maxFPS = Double(UIScreen.main.maximumFramesPerSecond) + } +#else + let maxFPS: Double = 60.0 + #endif + let expectedFrameDuration = 1.0 / maxFPS + hangNotifyThreshold = expectedFrameDuration * 1.5 + } + + func addLateRunLoopObserver(handler: @escaping (UUID, TimeInterval) -> Void) -> UUID { + let id = UUID() + queue.async { [weak self] in + self?.lateRunLoop[id] = handler + DispatchQueue.main.async { + self?.startIfNecessary() + } + } + return id + } + + func removeLateRunLoopObserver(id: UUID) { + queue.async { [weak self] in + guard let self else { return } + lateRunLoop.removeValue(forKey: id) + if lateRunLoop.isEmpty { + DispatchQueue.main.async { [weak self] in + if self?.finishedRunLoop.isEmpty ?? false { + self?.stop() + } + } + } + } + } + + func addFinishedRunLoopObserver(handler: @escaping (RunLoopIteration) -> Void) -> UUID { + let id = UUID() + finishedRunLoop[id] = handler + startIfNecessary() + return id + } + + func removeFinishedRunLoopObserver(id: UUID) { + finishedRunLoop.removeValue(forKey: id) + if finishedRunLoop.isEmpty { + queue.async { [weak self] in + if self?.lateRunLoop.isEmpty ?? false { + DispatchQueue.main.async { + if self?.finishedRunLoop.isEmpty ?? false { + self?.stop() + } + } + } + } + } + } + + // This queue is used to detect main thread hangs, they need to be detected on a background thread + // since the main thread is hanging. + private let queue: DispatchQueue + private let hangNotifyThreshold: TimeInterval + private let dateProvider: SentryCurrentDateProvider + private let createObserver: CreateObserverFunc + private let addObserver: AddObserverFunc + private let removeObserver: RemoveObserverFunc + + // MARK: Main queue + + private var observer: T? + private var finishedRunLoop = [UUID: (RunLoopIteration) -> Void]() + private var semaphore: DispatchSemaphore? + private var loopStartTime: TimeInterval? + + private func startIfNecessary() { + guard observer == nil else { + // Already running + return + } + + let observer = createObserver(nil, CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.afterWaiting.rawValue, true, CFIndex(INT_MAX)) { [weak self] _, activity in + guard let self else { return } + + let currentTime = dateProvider.systemUptime() + switch activity { + case .beforeWaiting: + semaphore?.signal() + if let loopStartTime { + for handler in finishedRunLoop.values { + handler(RunLoopIteration(startTime: loopStartTime, endTime: currentTime)) + } + } + case .afterWaiting: + let started = currentTime + loopStartTime = currentTime + let localSemaphore = DispatchSemaphore(value: 0) + semaphore = localSemaphore + queue.async { [weak self] in + self?.waitForHang(semaphore: localSemaphore, started: started, isStarting: true) + } + default: + fatalError() + } + } + self.observer = observer + addObserver(CFRunLoopGetMain(), observer, .commonModes) + } + + private func stop() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard let observer else { + return + } + removeObserver(CFRunLoopGetMain(), observer, .commonModes) + self.observer = nil + } + + // MARK: Background queue + + private var lateRunLoop = [UUID: (UUID, TimeInterval) -> Void]() + private var hangId = UUID() + + private func waitForHang(semaphore: DispatchSemaphore, started: TimeInterval, isStarting: Bool) { + dispatchPrecondition(condition: .onQueue(queue)) + + let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(hangNotifyThreshold * 1_000)) + let result = semaphore.wait(timeout: timeout) + switch result { + case .timedOut: + if isStarting { + hangId = UUID() + } + lateRunLoop.values.forEach { $0(hangId, dateProvider.systemUptime() - started) } + waitForHang(semaphore: semaphore, started: started, isStarting: false) + case .success: + break + } + } +} + +extension DefaultHangTracker where T == CFRunLoopObserver { + convenience init(dateProvider: SentryCurrentDateProvider) { + self.init( + dateProvider: dateProvider, + createObserver: CFRunLoopObserverCreateWithHandler, + addObserver: CFRunLoopAddObserver, + removeObserver: CFRunLoopRemoveObserver) + } +} + +@objc +@_spi(Private) public final class SentryHangTrackerObjcBridge: NSObject { + + let tracker: HangTracker + + @objc public init( + dateProvider: SentryCurrentDateProvider + ) { + tracker = DefaultHangTracker( + dateProvider: dateProvider) + } +} diff --git a/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwift.swift b/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwift.swift new file mode 100644 index 00000000000..484bdc8aae4 --- /dev/null +++ b/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwift.swift @@ -0,0 +1,50 @@ +@_spi(Private) @objc public final class SentryWatchdogTerminationTrackingIntegrationSwift: NSObject { + + private let tracker: HangTracker + private let timeoutInterval: TimeInterval + private let hangStarted: () -> Void + private let hangStopped: () -> Void + + private var callbackId: (late: UUID, finishedRunLoop: UUID)? + private var finishedRunLoopId: UUID? + private var currentHangId: UUID? + + @objc public convenience init(hangTrackerBridge: SentryHangTrackerObjcBridge, timeoutInterval: TimeInterval, hangStarted: @escaping () -> Void, hangStopped: @escaping () -> Void) { + self.init(hangTracker: hangTrackerBridge.tracker, timeoutInterval: timeoutInterval, hangStarted: hangStarted, hangStopped: hangStopped) + } + + init(hangTracker: HangTracker, timeoutInterval: TimeInterval, hangStarted: @escaping () -> Void, hangStopped: @escaping () -> Void) { + self.tracker = hangTracker + self.timeoutInterval = timeoutInterval + self.hangStarted = hangStarted + self.hangStopped = hangStopped + } + + @objc public func start() { + dispatchPrecondition(condition: .onQueue(.main)) + + let late = tracker.addLateRunLoopObserver { [weak self] id, interval in + guard let self, id != currentHangId, interval > timeoutInterval else { + return + } + + currentHangId = id + hangStarted() + } + + let finished = tracker.addFinishedRunLoopObserver { [weak self] _ in + self?.hangStopped() + } + callbackId = (late, finished) + } + + @objc public func stop() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard let callbackId else { + return + } + tracker.removeLateRunLoopObserver(id: callbackId.late) + tracker.removeFinishedRunLoopObserver(id: callbackId.finishedRunLoop) + } +} diff --git a/Tests/SentryTests/Integrations/ANR/HangTrackerTests.swift b/Tests/SentryTests/Integrations/ANR/HangTrackerTests.swift new file mode 100644 index 00000000000..f8a44eb7e60 --- /dev/null +++ b/Tests/SentryTests/Integrations/ANR/HangTrackerTests.swift @@ -0,0 +1,118 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +struct TestRunLoopObserver: RunLoopObserver { } + +final class HangTrackerTests: XCTestCase { + + private var observationBlock: ((TestRunLoopObserver?, CFRunLoopActivity) -> Void)? + private var testObserver = TestRunLoopObserver() + private var calledRemoveObserver = false + private var calledAddObserver = false + private let queue = DispatchQueue(label: "io.sentry.test-queue") + + override func setUp() { + super.setUp() + observationBlock = nil + calledRemoveObserver = false + calledAddObserver = false + } + + private func createObserver(_ allocator: CFAllocator?, _ activities: CFOptionFlags, _ repeats: Bool, _ order: CFIndex, _ block: ((TestRunLoopObserver?, CFRunLoopActivity) -> Void)?) -> TestRunLoopObserver { + observationBlock = block + return testObserver + } + + private func addObserver(_ rl: CFRunLoop?, _ observer: TestRunLoopObserver?, _ mode: CFRunLoopMode?) { + calledAddObserver = true + } + + private func removeObserver(_ rl: CFRunLoop?, _ observer: TestRunLoopObserver?, _ mode: CFRunLoopMode?) { + calledRemoveObserver = true + } + + func testHangTrackerCallsFinished() { + let sut = DefaultHangTracker( + dateProvider: TestCurrentDateProvider(), + createObserver: createObserver, + addObserver: addObserver, + removeObserver: removeObserver, + queue: queue) + + var observerCalls = 0 + let id = sut.addFinishedRunLoopObserver { _ in + observerCalls += 1 + } + XCTAssertTrue(calledAddObserver, "Expected add observer to be called") + + observationBlock?(testObserver, .afterWaiting) + observationBlock?(testObserver, .beforeWaiting) + XCTAssertEqual(1, observerCalls, "Expected run loop to finish exactly once") + sut.removeFinishedRunLoopObserver(id: id) + + // Observers are removed after the bg thread runs + let expectation = XCTestExpectation() + queue.async { + DispatchQueue.main.async { + expectation.fulfill() + } + } + wait(for: [expectation]) + + XCTAssertTrue(calledRemoveObserver, "Expected observer to be removed") + } + + func testHangTrackerCallsLateRunLoop() { + let dateProvider = TestCurrentDateProvider() + dateProvider.setSystemUptime(0) + let sut = DefaultHangTracker( + dateProvider: dateProvider, + createObserver: createObserver, + addObserver: addObserver, + removeObserver: removeObserver, + queue: queue) + + var observerIds = Set() + var observerLastInterval: TimeInterval = 0 + let id = sut.addLateRunLoopObserver { id, interval in + observerIds.insert(id) + observerLastInterval = interval + } + var expectation = XCTestExpectation() + queue.async { + DispatchQueue.main.async { + expectation.fulfill() + } + } + wait(for: [expectation]) + + XCTAssertTrue(calledAddObserver, "Expected add observer to be called") + + observationBlock?(testObserver, .afterWaiting) + dateProvider.setSystemUptime(10) + // Wait 1 second for the hang detection to kick in + sleep(1) + observationBlock?(testObserver, .beforeWaiting) + + sut.removeLateRunLoopObserver(id: id) + + // Observers are removed after the bg thread runs + expectation = XCTestExpectation() + queue.async { + DispatchQueue.main.async { + expectation.fulfill() + } + } + wait(for: [expectation]) + + // Note: We are writing to these variables on a bg thread but reading them here + // on the main thread. This is safe without any locks because in our test + // environment we know that once queue is drained there will not be any more modifictions + XCTAssertEqual(1, observerIds.count, "Expected late run loop exactly once") + XCTAssertEqual(10, observerLastInterval, "Expected hang interval to be 10") + + XCTAssertTrue(calledRemoveObserver, "Expected observer to be removed") + } + +} diff --git a/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift b/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift new file mode 100644 index 00000000000..7f8774068e8 --- /dev/null +++ b/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationSwiftTests.swift @@ -0,0 +1,103 @@ +@_spi(Private) @testable import Sentry +#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) + +import XCTest + +final class TestHangTracker: HangTracker { + + var lateRunloopObserver: ((UUID, TimeInterval) -> Void)? + func addLateRunLoopObserver(handler: @escaping (UUID, TimeInterval) -> Void) -> UUID { + lateRunloopObserver = handler + return UUID() + } + + var removedLateRunloopObserver: UUID? + func removeLateRunLoopObserver(id: UUID) { + removedLateRunloopObserver = id + } + + var finishedRunloopObserver: ((Sentry.RunLoopIteration) -> Void)? + func addFinishedRunLoopObserver(handler: @escaping (Sentry.RunLoopIteration) -> Void) -> UUID { + finishedRunloopObserver = handler + return UUID() + } + + var removedFinishedRunloopObserver: UUID? + func removeFinishedRunLoopObserver(id: UUID) { + removedFinishedRunloopObserver = id + } +} + +final class SentryWatchdogTerminationTrackingIntegrationSwiftTests: XCTestCase { + + func testReceivesHangStartedCallback() { + let testHangTracker = TestHangTracker() + let expectation = XCTestExpectation(description: "Expected to received a hang") + let timeoutInterval = 1.0 + let sut = SentryWatchdogTerminationTrackingIntegrationSwift( + hangTracker: testHangTracker, + timeoutInterval: timeoutInterval) { + expectation.fulfill() + } hangStopped: { + + } + + sut.start() + + testHangTracker.lateRunloopObserver?(UUID(), timeoutInterval + 1) + + wait(for: [expectation], timeout: 10) + + sut.stop() + + XCTAssertNotNil(testHangTracker.removedLateRunloopObserver) + XCTAssertNotNil(testHangTracker.removedFinishedRunloopObserver) + } + + func testReceivesHangStoppedCallback() { + let testHangTracker = TestHangTracker() + let expectation = XCTestExpectation(description: "Expected to received hang stop") + let timeoutInterval = 1.0 + let sut = SentryWatchdogTerminationTrackingIntegrationSwift( + hangTracker: testHangTracker, + timeoutInterval: timeoutInterval) { + } hangStopped: { + expectation.fulfill() + } + + sut.start() + + testHangTracker.finishedRunloopObserver?(.init(startTime: 0, endTime: 0)) + + wait(for: [expectation], timeout: 10) + + sut.stop() + + XCTAssertNotNil(testHangTracker.removedLateRunloopObserver) + XCTAssertNotNil(testHangTracker.removedFinishedRunloopObserver) + } + + func testDoesNotReceiveHangCallbackIfShorterThanTimeout() { + let testHangTracker = TestHangTracker() + let timeoutInterval = 1.0 + let sut = SentryWatchdogTerminationTrackingIntegrationSwift( + hangTracker: testHangTracker, + timeoutInterval: timeoutInterval) { + XCTFail("Should not recieve the callback") + } hangStopped: { + + } + + sut.start() + + testHangTracker.lateRunloopObserver?(UUID(), timeoutInterval - 1) + + sut.stop() + + XCTAssertNotNil(testHangTracker.removedLateRunloopObserver) + XCTAssertNotNil(testHangTracker.removedFinishedRunloopObserver) + } + +} + +#endif diff --git a/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationTests.swift b/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationTests.swift index 1f50ca25f91..99b78ab7d33 100644 --- a/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/WatchdogTerminations/SentryWatchdogTerminationTrackingIntegrationTests.swift @@ -273,7 +273,7 @@ class SentryWatchdogTerminationIntegrationTests: XCTestCase { sut.install(with: Options()) // -- Act -- - Dynamic(sut).anrDetectedWithType(SentryANRType.unknown) + Dynamic(sut).hangStarted() // -- Assert -- let appState = try XCTUnwrap(fixture.fileManager.readAppState()) @@ -287,7 +287,7 @@ class SentryWatchdogTerminationIntegrationTests: XCTestCase { sut.install(with: Options()) // -- Act -- - Dynamic(sut).anrStopped() + Dynamic(sut).hangStopped() // -- Assert -- let appState = try XCTUnwrap(fixture.fileManager.readAppState())