-
-
Notifications
You must be signed in to change notification settings - Fork 390
fix: Eliminate binary image cache lock contention during SDK startup #7766
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
Changes from all commits
4471f48
098ef35
ecf864b
67de31c
6a7b9b9
b7b092a
4f2cffe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,40 +27,45 @@ import Foundation | |
| */ | ||
| @objc(SentryBinaryImageCache) | ||
| @_spi(Private) public final class SentryBinaryImageCache: NSObject { | ||
| @objc public internal(set) var cache: [SentryBinaryImageInfo]? | ||
| private var cache: [SentryBinaryImageInfo]? | ||
| private var isDebug: Bool = false | ||
| // Use a recursive lock to allow the same thread to enter again | ||
| private let lock = NSRecursiveLock() | ||
| private let lock = NSLock() | ||
| /// When true, `cache` has unsorted appends and must be sorted before binary search. | ||
| private var needsSort: Bool = false | ||
|
|
||
| @objc public func start(_ isDebug: Bool) { | ||
| lock.synchronized { | ||
| self.isDebug = isDebug | ||
| self.cache = [] | ||
| sentrycrashbic_registerAddedCallback { imagePtr in | ||
| guard let imagePtr else { | ||
| SentrySDKLog.warning("The image is NULL. Can't add NULL to cache.") | ||
| return | ||
| } | ||
| let image = imagePtr.pointee | ||
| SentryDependencyContainer.sharedInstance().binaryImageCache.binaryImageAdded( | ||
| imageName: image.name, vmAddress: image.vmAddress, address: image.address, size: image.size, uuid: image.uuid) | ||
| self.needsSort = false | ||
| } | ||
|
|
||
| // Register callbacks outside the lock β they acquire it independently. | ||
| sentrycrashbic_registerAddedCallback { imagePtr in | ||
| guard let imagePtr else { | ||
| SentrySDKLog.warning("The image is NULL. Can't add NULL to cache.") | ||
| return | ||
| } | ||
| sentrycrashbic_registerRemovedCallback { imagePtr in | ||
| guard let imagePtr else { | ||
| SentrySDKLog.warning("The image is NULL. Can't add NULL to cache.") | ||
| return | ||
| } | ||
| let image = imagePtr.pointee | ||
| SentryDependencyContainer.sharedInstance().binaryImageCache.binaryImageRemoved(image.address) | ||
| let image = imagePtr.pointee | ||
| SentryDependencyContainer.sharedInstance().binaryImageCache.binaryImageAdded( | ||
| imageName: image.name, vmAddress: image.vmAddress, address: image.address, size: image.size, uuid: image.uuid) | ||
| } | ||
| sentrycrashbic_registerRemovedCallback { imagePtr in | ||
| guard let imagePtr else { | ||
| SentrySDKLog.warning("The image is NULL. Can't add NULL to cache.") | ||
| return | ||
| } | ||
| let image = imagePtr.pointee | ||
| SentryDependencyContainer.sharedInstance().binaryImageCache.binaryImageRemoved(image.address) | ||
| } | ||
| } | ||
|
|
||
| @objc public func stop() { | ||
| sentrycrashbic_registerAddedCallback(nil) | ||
| sentrycrashbic_registerRemovedCallback(nil) | ||
| lock.synchronized { | ||
| sentrycrashbic_registerAddedCallback(nil) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-atomic start/stop creates callback-cache state mismatchMedium Severity Splitting callback registration out of Additional Locations (1) |
||
| sentrycrashbic_registerRemovedCallback(nil) | ||
| self.cache = nil | ||
| self.needsSort = false | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -88,27 +93,17 @@ import Foundation | |
| size: size | ||
| ) | ||
|
|
||
| var shouldValidate = false | ||
| lock.synchronized { | ||
| guard let cache = self.cache else { return } | ||
|
|
||
| // Binary search insertion to maintain sorted order by address | ||
| var left = 0 | ||
| var right = cache.count | ||
| guard self.cache != nil else { return } | ||
|
|
||
| while left < right { | ||
| let mid = (left + right) / 2 | ||
| let compareImage = cache[mid] | ||
| if newImage.address < compareImage.address { | ||
| right = mid | ||
| } else { | ||
| left = mid + 1 | ||
| } | ||
| } | ||
|
|
||
| self.cache?.insert(newImage, at: left) | ||
| // O(1) append β sorting is deferred until a lookup or removal needs it. | ||
| self.cache?.append(newImage) | ||
| self.needsSort = true | ||
| shouldValidate = self.isDebug | ||
| } | ||
|
|
||
| if isDebug { | ||
| if shouldValidate { | ||
| // This validation adds some overhead with each class present in the image, so we only | ||
| // run this when debug is enabled. A non main queue is used to avoid affecting the UI. | ||
| LoadValidator.checkForDuplicatedSDK(imageName: nameString, | ||
|
|
@@ -131,6 +126,7 @@ import Foundation | |
| @objc | ||
| public func binaryImageRemoved(_ imageAddress: UInt64) { | ||
| lock.synchronized { | ||
| sortIfNeeded() | ||
| guard let index = indexOfImage(address: imageAddress) else { return } | ||
| self.cache?.remove(at: index) | ||
| } | ||
|
|
@@ -139,11 +135,20 @@ import Foundation | |
| @objc | ||
| public func imageByAddress(_ address: UInt64) -> SentryBinaryImageInfo? { | ||
| lock.synchronized { | ||
| sortIfNeeded() | ||
| guard let index = indexOfImage(address: address) else { return nil } | ||
| return cache?[index] | ||
| } | ||
| } | ||
|
|
||
| /// Sorts the cache if new elements have been appended since the last sort. | ||
| /// Must be called while holding `lock`. | ||
| private func sortIfNeeded() { | ||
| guard needsSort else { return } | ||
| self.cache?.sort { $0.address < $1.address } | ||
| needsSort = false | ||
| } | ||
|
|
||
| private func indexOfImage(address: UInt64) -> Int? { | ||
| guard let cache = self.cache else { return nil } | ||
|
|
||
|
|
@@ -169,6 +174,7 @@ import Foundation | |
| @objc(imagePathsForInAppInclude:) | ||
| public func imagePathsFor(inAppInclude: String) -> Set<String> { | ||
| lock.synchronized { | ||
| // No sort needed β this iterates all entries regardless of order. | ||
| var imagePaths = Set<String>() | ||
|
|
||
| guard let cache = self.cache else { return imagePaths } | ||
|
|
@@ -185,6 +191,7 @@ import Foundation | |
| @objc | ||
| public func getAllBinaryImages() -> [SentryBinaryImageInfo] { | ||
| lock.synchronized { | ||
| sortIfNeeded() | ||
| return cache ?? [] | ||
| } | ||
| } | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use-after-free when startCache frees nodes during iteration
Medium Severity
The
registerAddedCallbackiteration now runs outside the mutex, whilestartCachefreespendingFreeListalso outside the mutex. IfstopCachedefers nodes, thenstartCacheis called on another thread (freeing those nodes), while the iteration is still traversing them, this is a use-after-free. Critically,startCachesetstailNodeback to non-NULL, so the iteration'swhileloop condition (tailNode != NULL) passes again after the restart, causing it to continue following freednode->nextpointers. The comment "Defer freeing β readers outside the mutex may still be traversing" indicates awareness of readers, butstartCachedoesn't respect that invariant.Additional Locations (1)
Sources/SentryCrash/Recording/SentryCrashBinaryImageCache.c#L262-L273