| [ Web Proxy ] |
| Viewing: https://github-redirect.dependabot.com/getsentry/sentry-cocoa/issues/7155 | [Back] [Original] |
SentryFramesTracker.removeListener - #7155Mutate listners in `SentryFramesTracker` only on main queue.
|
@philprime Due to the comment in the code, moved the last remaining lock to main thread dispatch instead. |
Codecov Report All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## main #7155 +/- ##
=============================================
+ Coverage 85.062% 85.091% +0.029%
=============================================
Files 468 468
Lines 28305 28320 +15
Branches 12532 12547 +15
=============================================
+ Hits 24077 24098 +21
+ Misses 4183 4181 -2
+ Partials 45 41 -4
... and 9 files with indirect coverage changes Continue to review full report in Codecov by Sentry.
|
There was a problem hiding this comment.
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?
Performance metrics
Baseline results on branch: mainStartup times
App size
Previous results on branch: fix/frames-tracker-remove-listner-crashStartup times
App size
|
|
@philprime Not because of locking the main thread, but the code base was moving away from locks, with the reasoning that 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. |
SentryFramesTracker.removeListener(_:)
Jan 12, 2026
Semver Impact of This PRPatch (bug fixes) Changelog PreviewThis is how your changes will appear in the changelog. New FeaturesMetrics
Other
Bug Fixes
Build / dependencies / internalDeps
Other
Other
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.
|
@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]
|
There was a problem hiding this comment.
LGTM, this also seems to be more consistent. Maybe it makes sense if @itaybre or @philipphofmann also gives this a second review
SentryFramesTracker.removeListener(_:)
fix: Crash in SentryFramesTracker.removeListener(_:)
Jan 13, 2026
SentryFramesTracker.removeListener(_:)
fix: Crash in SentryFramesTracker.removeListener
Jan 13, 2026
There was a problem hiding this comment.
Honestly, I'm unsure about the dispatchSyncOnMainQueue in deinit.
There was a problem hiding this comment.
LGTM, thanks
* fix: race in SentryFramesTracker listeners Replace NSHashTable.weakObjects() with a Swift array of weakly-boxed listeners. NSHashTable's internal bucket slots can be zeroed by the ObjC runtime on the thread that deallocates a stored listener while another thread is iterating the table, causing NSConcreteHashTable removeItem: to objc_msgSend a freed entry. Ensuring all mutations run on the main thread (fixed in #7155) is not sufficient: listener objects can still be deallocated on any thread, and the weak-zeroing races with the table's iteration. Reading our own weak-boxed listeners uses objc_loadWeak and is safe against concurrent deallocation. * fix: add changelog entry for SentryFramesTracker listener race (#7839) * Use WeakReference * Fix tests * Simplify listenersCount calculation in SentryFramesTracker
* fix: FramesTracker listener-capture deinit race Both `addListener` and `removeListener` strongly captured `listener` in the closure passed to `dispatchAsyncOnMainQueueIfNotMainThread`. When a caller invoked either method from inside its own `init` / `deinit` on a background thread, the dispatched main-queue block tried to retain a freed (or mid-dealloc) object once it reached main. Under iOS 26's hardened ARC this is fatal: `swift_unknownObjectRetain` `EXC_BREAKPOINT`. The `removeListener`-from-`deinit` shape is the documented motivating crash (lldb-confirmed in a Capacitor + WKWebView host); the `addListener` mirror is theoretical but the fix is symmetric and cheap. Capture `ObjectIdentifier(listener)` (a value type) and a pre-built `WeakReference(value: listener)` instead, so the closures never hold the listener directly. The main-queue hop no longer extends the listener's lifetime, and identity matching against still-alive entries (read via `WeakReference.value` / `objc_loadWeak`) keeps working. Adds `[weak self]` to match the lifetime-safety theme of the rest of the file (precedent: `SentryUIDeviceWrapper.start`). This is the third crash class in the same method area. #7155 fixed the original Obj-C `removeListener` crash; #7839 fixed the `NSConcreteHashTable` race that the comment on the `listeners` property records. The remaining race lived in the Swift closure-capture path, exposed when iOS 26 hardened ARC's retain-on-mid-dealloc check. Adds `testAddAndRemoveListener_DoNotRetainListenerAcrossMainQueueHop`, a deterministic regression test using `TestSentryDispatchQueueWrapper` to defer block execution. The test verifies the structural invariant ("closure must not strongly retain the listener") via a `weak` reference assertion, which fails loudly on the pre-fix code with a clear error message: XCTAssertNil failed: "<FrameTrackerListener: 0x...>" - Closures dispatched by addListener/removeListener must not retain the listener across the main-queue hop * Update CHANGELOG.md --------- Co-authored-by: Itay Brenner <itaybrenner@hotmail.com> Co-authored-by: Itay Brenner <itay.brenner@sentry.io>
* fix: FramesTracker listener-capture deinit race Both `addListener` and `removeListener` strongly captured `listener` in the closure passed to `dispatchAsyncOnMainQueueIfNotMainThread`. When a caller invoked either method from inside its own `init` / `deinit` on a background thread, the dispatched main-queue block tried to retain a freed (or mid-dealloc) object once it reached main. Under iOS 26's hardened ARC this is fatal: `swift_unknownObjectRetain` `EXC_BREAKPOINT`. The `removeListener`-from-`deinit` shape is the documented motivating crash (lldb-confirmed in a Capacitor + WKWebView host); the `addListener` mirror is theoretical but the fix is symmetric and cheap. Capture `ObjectIdentifier(listener)` (a value type) and a pre-built `WeakReference(value: listener)` instead, so the closures never hold the listener directly. The main-queue hop no longer extends the listener's lifetime, and identity matching against still-alive entries (read via `WeakReference.value` / `objc_loadWeak`) keeps working. Adds `[weak self]` to match the lifetime-safety theme of the rest of the file (precedent: `SentryUIDeviceWrapper.start`). This is the third crash class in the same method area. #7155 fixed the original Obj-C `removeListener` crash; #7839 fixed the `NSConcreteHashTable` race that the comment on the `listeners` property records. The remaining race lived in the Swift closure-capture path, exposed when iOS 26 hardened ARC's retain-on-mid-dealloc check. Adds `testAddAndRemoveListener_DoNotRetainListenerAcrossMainQueueHop`, a deterministic regression test using `TestSentryDispatchQueueWrapper` to defer block execution. The test verifies the structural invariant ("closure must not strongly retain the listener") via a `weak` reference assertion, which fails loudly on the pre-fix code with a clear error message: XCTAssertNil failed: "<FrameTrackerListener: 0x...>" - Closures dispatched by addListener/removeListener must not retain the listener across the main-queue hop * Update CHANGELOG.md --------- Co-authored-by: Itay Brenner <itaybrenner@hotmail.com> Co-authored-by: Itay Brenner <itay.brenner@sentry.io>
| Web Proxy Viewer | New URL | Original Page |
Description
Mutate listeners in
SentryFramesTrackeronly 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.
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:
sendDefaultPIIis enabled.