Skip to content

New runloop observer #5632

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft

New runloop observer #5632

wants to merge 5 commits into from

Conversation

noahsmartin
Copy link
Contributor

WIP because this has no tests yet, just opening it to let CI run

#skip-changelog

Copy link

codecov bot commented Jul 14, 2025

Codecov Report

Attention: Patch coverage is 0.46296% with 215 lines in your changes missing coverage. Please review.

Project coverage is 8.157%. Comparing base (102cf89) to head (0590db9).

Files with missing lines Patch % Lines
Sources/Swift/HangTracker.swift 0.000% 185 Missing ⚠️
Sources/Sentry/SentrySDK.m 0.000% 16 Missing ⚠️
Sources/Sentry/SentryDependencyContainer.m 0.000% 7 Missing ⚠️
...rces/Sentry/SentryDependencyContainerSwiftHelper.m 0.000% 7 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (102cf89) and HEAD (0590db9). Click for more details.

HEAD has 3 uploads less than BASE
Flag BASE (102cf89) HEAD (0590db9)
4 1
Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff              @@
##              main    #5632        +/-   ##
=============================================
- Coverage   86.550%   8.157%   -78.394%     
=============================================
  Files          417      386        -31     
  Lines        35429    27595      -7834     
  Branches     15353       57     -15296     
=============================================
- Hits         30664     2251     -28413     
- Misses        4725    25344     +20619     
+ Partials        40        0        -40     
Files with missing lines Coverage Δ
Sources/Swift/SentryExperimentalOptions.swift 45.454% <100.000%> (-37.879%) ⬇️
Sources/Sentry/SentryDependencyContainer.m 40.106% <0.000%> (-48.266%) ⬇️
...rces/Sentry/SentryDependencyContainerSwiftHelper.m 0.000% <0.000%> (-100.000%) ⬇️
Sources/Sentry/SentrySDK.m 8.201% <0.000%> (-80.520%) ⬇️
Sources/Swift/HangTracker.swift 0.000% <0.000%> (ø)

... and 407 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 102cf89...0590db9. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the display rate can change dynamically in a few cases like when displaying video. Not seeing any way to subscribe to that change though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I didn't see it either. I figured using maximum at least would mean we would detect any time slow code is preventing the app from running at fastest possible. I think other things can cause the OS to decide to slow it down like low power mode. But you probably want to still know when frames are going to slow even if the OS chooses not to render them all

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CADisplayLink docs actually mention this a bit https://developer.apple.com/documentation/quartzcore/cadisplaylink#Preferred-and-Actual-Frame-Rates.

You run into a risk of falsely reporting slow frames if the frame rate changes. That's why we calculate the actual frame rate here:

// Calculate the actual frame rate as pointed out by the Apple docs:
// https://developer.apple.com/documentation/quartzcore/cadisplaylink?language=objc The actual
// frame rate can change at any time by setting preferredFramesPerSecond or due to ProMotion
// display, low power mode, critical thermal state, and accessibility settings. Therefore we
// need to check the frame rate for every callback.
// targetTimestamp is only available on iOS 10.0 and tvOS 10.0 and above. We use a fallback of
// 60 fps.
_currentFrameRate = 60;
if (self.displayLinkWrapper.targetTimestamp != self.displayLinkWrapper.timestamp) {
_currentFrameRate = (uint64_t)round(
(1 / (self.displayLinkWrapper.targetTimestamp - self.displayLinkWrapper.timestamp)));
}

private func waitForHang(semaphore: DispatchSemaphore, started: TimeInterval, isStarting: Bool) {
dispatchPrecondition(condition: .onQueue(queue))

let timeout = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int((expectedFrameDuration + thresholdForFrameStacktrace) * 1_000))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for easier readability I'd expect thresholdForFrameStacktraceDurationMs = expectedFrameDuration *1.5 * 1000 on assignment instead of calculating down here.

threads?.forEach { $0.current = false }
threads?[0].current = true
}
let duration = dateProvider.systemUptime() - started
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming this works fine for the first frame edge case?

}
}

private func continueHang(started: TimeInterval, isStarting: Bool) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work if the app is backgrounded?

Copy link
Contributor Author

@noahsmartin noahsmartin Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've never seen a case where the runloop doesn't get to finish an iteration. Even if the app is backgrounded your code isn't suspended instantly (for example NSNotifications get called on the main thread). If the main thread was suspended and never got a chance to resume then we would use the event stored to disk and report a fatal exception

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, I was more thinking about whether the lastFrameTime value could get out of sync at all and report a false positive since that seems like it might have happened with the current implementation, but I'm guessing you're right that the runloop is allowed to finish its iteration.

private var maxHangTime: TimeInterval?

func start() {
let observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.afterWaiting.rawValue | CFRunLoopActivity.beforeSources.rawValue, true, CFIndex(INT_MAX)) { [weak self] _, activity in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One weird edge-case that ChatGPT claims is possible is re-entry into the current runloop via CFRunLoopPerformBlock that could result in false negatives, I guess as part of keyboard/modal interactions? I'm not very familiar with this API though so not sure if that's a real or hallucination.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll test out keyboards specifically

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants