Skip to content

Conversation

@denrase
Copy link
Collaborator

@denrase denrase commented Jan 12, 2026

📜 Description

Mutate listeners in SentryFramesTracker only on main queue.

Access to listeners was moved to main thread dispatch instead of locks, but one lock was probably forgotten. The reason why this was changed is added as a comment in code, so because of this we do not use the locking anymore.

The DisplayLink callback always runs on the main thread. We dispatch this to the main thread instead to avoid using locks in the DisplayLink callback.

If this issue should remain even after this fix, we can still additionally add locks/synchronisation around listeners access.

💡 Motivation and Context

Closes https://linear.app/getsentry/issue/COCOA-869/sentryframestracker-removelistener-crash

💚 How did you test it?

Unfortunately, I could not reproduce the crash, but the sync was only in one place, suggesting it was forgotten to move this to main thread access only.

📝 Checklist

You have to check all boxes before merging:

  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

Mutate listners in `SentryFramesTracker` only on main queue.
@linear
Copy link

linear bot commented Jan 12, 2026

@denrase denrase added the ready-to-merge Use this label to trigger all PR workflows label Jan 12, 2026
@denrase denrase marked this pull request as ready for review January 12, 2026 12:50
@denrase denrase changed the title fix: [SentryFramesTracker removeListener:] crash fix [SentryFramesTracker removeListener:] crash Jan 12, 2026
@denrase denrase changed the title fix [SentryFramesTracker removeListener:] crash Fix [SentryFramesTracker removeListener:] crash Jan 12, 2026
@denrase
Copy link
Collaborator Author

denrase commented Jan 12, 2026

@philprime Due to the comment in the code, moved the last remaining lock to main thread dispatch instead.

@codecov
Copy link

codecov bot commented Jan 12, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.748%. Comparing base (2e5230b) to head (cd4ec9a).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff              @@
##              main     #7155       +/-   ##
=============================================
+ Coverage   84.745%   84.748%   +0.002%     
=============================================
  Files          461       461               
  Lines        27815     27820        +5     
  Branches     12319     12327        +8     
=============================================
+ Hits         23572     23577        +5     
+ Misses        4202      3981      -221     
- Partials        41       262      +221     
Files with missing lines Coverage Δ
...egrations/FramesTracking/SentryFramesTracker.swift 100.000% <100.000%> (ø)

... and 33 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 2e5230b...cd4ec9a. Read the comment docs.

Copy link
Member

@philprime philprime left a comment

Choose a reason for hiding this comment

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

LGTM, but just to be certain: Instead of using a lock we are going all-in on dispatch on the main thread, because we might be locking the main thread?

@github-actions
Copy link
Contributor

github-actions bot commented Jan 12, 2026

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 1227.00 ms 1257.88 ms 30.88 ms
Size 24.14 KiB 1.04 MiB 1.02 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
79e2bb8 1216.37 ms 1242.42 ms 26.05 ms
83bf9af 1213.30 ms 1234.18 ms 20.89 ms
2e5230b 1207.41 ms 1240.41 ms 33.00 ms
9f7ef2b 1213.53 ms 1250.23 ms 36.70 ms
013fd4d 1216.02 ms 1242.16 ms 26.14 ms
d29a425 1209.96 ms 1239.00 ms 29.04 ms
2f4ddaa 1227.26 ms 1260.04 ms 32.78 ms
adeec82 1220.43 ms 1254.94 ms 34.51 ms
778dadf 1207.69 ms 1246.09 ms 38.40 ms
3bf0d3f 1202.12 ms 1237.23 ms 35.11 ms

App size

Revision Plain With Sentry Diff
79e2bb8 24.14 KiB 1.04 MiB 1.02 MiB
83bf9af 24.14 KiB 1.04 MiB 1.02 MiB
2e5230b 24.14 KiB 1.04 MiB 1.02 MiB
9f7ef2b 24.14 KiB 1.04 MiB 1.02 MiB
013fd4d 24.14 KiB 1.04 MiB 1.02 MiB
d29a425 24.14 KiB 1.04 MiB 1.02 MiB
2f4ddaa 24.14 KiB 1.04 MiB 1.02 MiB
adeec82 24.14 KiB 1.04 MiB 1.02 MiB
778dadf 24.14 KiB 1.04 MiB 1.02 MiB
3bf0d3f 24.14 KiB 1.04 MiB 1.02 MiB

Previous results on branch: fix/frames-tracker-remove-listner-crash

Startup times

Revision Plain With Sentry Diff
fc7e87f 1219.17 ms 1239.91 ms 20.74 ms
c6fdfe7 1219.85 ms 1250.46 ms 30.61 ms
af0d84f 1207.13 ms 1247.29 ms 40.16 ms
f8aca10 1199.12 ms 1220.77 ms 21.64 ms

App size

Revision Plain With Sentry Diff
fc7e87f 24.14 KiB 1.04 MiB 1.02 MiB
c6fdfe7 24.14 KiB 1.04 MiB 1.02 MiB
af0d84f 24.14 KiB 1.04 MiB 1.02 MiB
f8aca10 24.14 KiB 1.04 MiB 1.02 MiB

@denrase
Copy link
Collaborator Author

denrase commented Jan 12, 2026

@philprime Not because of locking the main thread, but the code base was moving away from locks, with the reasoning that DisplayLink is always calling on the main thread anyway, so this PR completes this transition.

But there's the catch that I was not able to reproduce the crash from the issue, but basically it was possible to clear the listeners from a non-main thread, while adding/removing individual listeners was always being done on the main-thread.

@denrase denrase changed the title Fix [SentryFramesTracker removeListener:] crash Resolve crash in caused by calling SentryFramesTracker.removeListener(_:) Jan 12, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Jan 12, 2026

Semver Impact of This PR

🟢 Patch (bug fixes)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • Add isiOSAppOnVisionOS, isiOSAppOnMac, isMacCatalystApp to device context by philprime in #6939

Bug Fixes 🐛

  • (logs) Use sendDefaultPii and span_id for attributes by philprime in #7055
  • Crash in SentryFramesTracker.removeListener by denrase in #7155
  • Fix incorrect variable assignment for 'sampled' key by xjshi in #7120
  • Mark dark theme deprecated by noahsmartin in #7114
  • Update raw_description in runtime context for Mac Catalyst App by philprime in #7082
  • Use correct parsing for stackframes by noahsmartin in #6908
  • Transport correctly handling 4xx and 5xx by dfed in #6618

Build / dependencies / internal 🔧

Deps

  • Bump getsentry/craft from 2.18.1 to 2.18.3 by dependabot in #7161
  • Bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.18.1 to 2.18.3 by dependabot in #7159
  • Bump ruby/setup-ruby from 1.276.0 to 1.279.0 by dependabot in #7117
  • Bump mikepenz/action-junit-report from 6.0.1 to 6.1.0 by dependabot in #7116
  • Update swiftlint version by github-actions in #7109
  • Bump ruby/setup-ruby from 1.275.0 to 1.276.0 by dependabot in #7103
  • Bump codecov/test-results-action from 1.1.1 to 1.2.1 by itaybre in #7087
  • Bump ruby/setup-ruby from 1.270.0 to 1.275.0 by itaybre in #7088
  • Bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 by dependabot in #7084
  • Bump actions/download-artifact from 6 to 7 by dependabot in #7048
  • Bump aws-sdk-s3 from 1.205.0 to 1.208.0 by dependabot in #7074
  • Bump ruby/setup-ruby from 1.269.0 to 1.270.0 by dependabot in #7049
  • Update clang-format version by github-actions in #7056
  • Bump actions/cache from 4 to 5 by dependabot in #7052
  • Bump actions/upload-artifact from 5 to 6 by dependabot in #7050
  • Bump codecov/codecov-action from 5.5.1 to 5.5.2 by dependabot in #7051

Other

  • (dx) Add structured Makefile with usage description by philprime in #7129
  • (release) Switch from action-prepare-release to Craft (minimal) by BYK in #7153
  • Fix typos in comments in multiple files v2 by philipphofmann in #7139
  • Run visionOS tests on Cirrus Runners + Boot simulator by itaybre in #7147
  • Skip jobs/steps that require secrets for non contributors by itaybre in #7124
  • Add attributable protocol for typed attribute values by philprime in #7077
  • Allow alpha releases on RNSentry.podspec for Cross Platform Test by itaybre in #7130
  • Remove swift5.9 checks by itaybre in #7098
  • Remove duplicate file in project by itaybre in #7093
  • Convert SentryMetricKitIntegration to Swift by noahsmartin in #7076
  • Removes HybridSDK subspec by itaybre in #7019
  • Move testRemoveImageFromTail to flaky plan by itaybre in #7041
  • Use at least xcode 16 for all jobs by itaybre in #7012
  • Cleanup file filter for required files modified by itaybre in #7031
  • Remove assembly workflow files from UI test filter by itaybre in #7030
  • Bumps macOS-14 runner to macOS-15 by itaybre in #7029
  • Ensure required simulators are loaded for all platforms by itaybre in #7022

Other

  • test: Add Options Documentation Sync Tests by philipphofmann in #7075

🤖 This preview updates automatically when you update the PR.

- Updated `SentryFramesTracker` to ensure listeners are managed on the main queue, preventing potential crashes.
- Made `isStarted` property private for better encapsulation.
- Enhanced deinitialization to safely stop the tracker and avoid async dispatch issues.
@denrase denrase requested a review from philprime January 13, 2026 11:15
@denrase
Copy link
Collaborator Author

denrase commented Jan 13, 2026

@philprime Would be great if you could take another look. I tried reproducing again after the cursor bot comment and could get the frames tracker to crash using this code in the sample app. I added additional precautions to fix this, which did work. While I still could not reproduce the initial issue, this should offer more protection against potential races.

    @IBAction func useAfterFree(_ sender: UIButton) {
        highlightButton(sender)
        
        let framesTracker = SentryDependencyContainer.sharedInstance().framesTracker
        
        class TestListener: NSObject, SentryFramesTrackerListener {
            func framesTrackerHasNewFrame(_ newFrameDate: Date) {
                // Access during callback to create more contention
            }
        }
        
        var listeners: [TestListener] = []
        for _ in 0..<50 {
            let listener = TestListener()
            listeners.append(listener)
            framesTracker.addListener(listener)
        }
        for _ in 0..<20 {
            DispatchQueue.global().async {
                for listener in listeners {
                    for i in 0..<100 {
                        framesTracker.removeListener(listener)
                        if i % 2 == 0 {
                            framesTracker.stop()
                        } else {
                            framesTracker.start()
                        }
                    }
                }
            }
        }
    }
Bildschirmfoto 2026-01-13 um 11 53 31

Copy link
Member

@philprime philprime left a comment

Choose a reason for hiding this comment

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

LGTM, this also seems to be more consistent. Maybe it makes sense if @itaybre or @philipphofmann also gives this a second review

Comment on lines 234 to 240
private func willResignActive() {
pause()
}

private func unpause() {
guard !isRunning else { return }

This comment was marked as outdated.

@philipphofmann philipphofmann changed the title Resolve crash in caused by calling SentryFramesTracker.removeListener(_:) fix: Crash in SentryFramesTracker.removeListener(_:) Jan 13, 2026
@philipphofmann philipphofmann changed the title fix: Crash in SentryFramesTracker.removeListener(_:) fix: Crash in SentryFramesTracker.removeListener Jan 13, 2026
Copy link
Member

@philipphofmann philipphofmann left a comment

Choose a reason for hiding this comment

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

Honestly, I'm unsure about the dispatchSyncOnMainQueue in deinit.


deinit {
// Avoid async dispatch with self capture on deinit.
dispatchQueueWrapper.dispatchSyncOnMainQueue {
Copy link
Member

@philipphofmann philipphofmann Jan 13, 2026

Choose a reason for hiding this comment

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

h: I don't think it'a good idea to call dispatch_sync on a deinit method. Even though most of the time we're going to call this from the main thread and we won't run dispatch_sync from a BG thread, this can cause weird behaviour and hard-to-track bugs. To me, it seems this is a duct tape around a deeper problem of how we handle synchronization around the listeners. I think it would be better to come up with a different strategy. For example, I don't think we need to remove all listeners in deinit. The listeners array anyways is going to be deallocated. Maybe we should rather call a stop method that only removes the observers and calls pause, but not removing the observers.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah that makes sense, what we def need to do is invalidate the display link, as it retains the object is is reporting to. Will update, thx for the insights. 👍

Comment on lines +80 to +85
dispatchQueueWrapper.dispatchAsyncOnMainQueueIfNotMainThread {
self.startInternal()
}
}

private func startInternal() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we double check that no one calling start expects data immediately?

Comment on lines +72 to +73
// Need to invalidate so DisplayLink is releasing this object. Calling this is thread-safe.
displayLinkWrapper.invalidate()
Copy link
Member

Choose a reason for hiding this comment

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

m: @denrase I think we should also remove the observers as we do in stopInternal. I think we could add a new method that calls displayLinkWrapper.invalidate() and also removes the observers. Then we can call it from here and stopInternal

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Use this label to trigger all PR workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants