Skip to content

Commit 7a42b2a

Browse files
authored
Fix crash in @cached property value when computeValue was called from mutate(_:) (#3174)
1 parent 36910f1 commit 7a42b2a

File tree

3 files changed

+65
-17
lines changed

3 files changed

+65
-17
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88
- Add `reason` parameter to `flagMessage()` [#3173](https://github.com/GetStream/stream-chat-swift/issues/3173)
99
### 🐞 Fixed
1010
- Reset managed object contexts before removing all the data on logout [#3170](https://github.com/GetStream/stream-chat-swift/pull/3170)
11+
- Rare crash when `@Cached` property triggered fetching a new value with `computeValue` closure [#3174](https://github.com/GetStream/stream-chat-swift/pull/3174)
1112
### 🔄 Changed
1213
- Replace message update request to partial update on message pinning [#3166](https://github.com/GetStream/stream-chat-swift/pull/3166)
1314

Sources/StreamChat/Utils/Cached.swift

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,21 @@ class Cached<T> {
1313
/// When the cached value is reset, this closure is used to lazily get a new value which is then cached again.
1414
@Atomic var computeValue: (() -> T)!
1515

16+
/// Returns the cached value if it is set, otherwise uses the `computeValue` closure to refetch the value.
17+
///
18+
/// Since `computeValue` can trigger side-effects like CoreData to save a context which triggers database
19+
/// observers then it is important to run the `computeValue` closure outside of the locked region (e.g. `_cached.mutate(_:).
20+
/// Otherwise we might reenter the same instance while the `_cached` lock has not released the lock yet.
21+
/// Only downside is that we might need to execute `computeValue` multiple times if there are multiple threads
22+
/// accessing the `wrappedValue` at the same time while `_cached` is nil.
1623
var wrappedValue: T {
17-
var returnValue: T!
18-
19-
// We need to make the changes inside the `mutate` block to ensure `Cached` is thread-safe.
20-
__cached.mutate { value in
21-
if let value = value {
22-
returnValue = value
23-
return
24-
}
25-
26-
log.assert(computeValue != nil, "You must set the `computeValue` closure before accessing the cached value.")
27-
28-
value = computeValue()
29-
returnValue = value
24+
if let _cached {
25+
return _cached
3026
}
31-
32-
return returnValue
27+
log.assert(computeValue != nil, "You must set the `computeValue` closure before accessing the cached value.")
28+
let newValue = computeValue()
29+
_cached = newValue
30+
return newValue
3331
}
3432

3533
var projectedValue: (() -> T) {

Tests/StreamChatTests/StreamChatStressTests/Cached_StressTests.swift

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import StreamChatTestTools
77
import XCTest
88

99
final class Cached_Tests: StressTestCase {
10+
let queue = DispatchQueue(label: "io.getstream.tests.cached")
1011
var counter: Int!
1112
@Cached var value: Int!
1213

@@ -18,8 +19,11 @@ final class Cached_Tests: StressTestCase {
1819

1920
_value.computeValue = { [unowned self] in
2021
// Return the current counter value and increase the counter
21-
defer { self.counter += 1 }
22-
return self.counter
22+
queue.sync {
23+
let value = counter
24+
counter += 1
25+
return value
26+
}
2327
}
2428
}
2529

@@ -65,4 +69,49 @@ final class Cached_Tests: StressTestCase {
6569
}
6670
group.wait()
6771
}
72+
73+
func test_resetAndRead_whenRunningConcurrently() {
74+
_value.computeValue = { Int.max }
75+
DispatchQueue.concurrentPerform(iterations: 10) { _ in
76+
self._value.reset()
77+
_ = self.value
78+
}
79+
XCTAssertEqual(value, Int.max)
80+
}
81+
82+
func test_exclusiveAccess_whenComputeValueTriggersReset() throws {
83+
// 1. `EntityDatabaseObserver.item` was accessed but since the cached value was nil, `computeValue` was called
84+
// 2. Compute value for the `EntityDatabaseObserver.item` triggered CoreData object change
85+
// 3. CoreData change was picked up by the `EntityDatabaseObserver` which ended up calling `item.reset()`
86+
// Result: Crash because computeValue was called from the Atomic's mutate function and therefore all the code was
87+
// triggered from the mutate. `item.reset()` triggered another mutate and then Swift's runtime crashed the app
88+
// because it requires exclusive access (`mutate` uses inout parameters!).
89+
// This test would crash if `computeValue` is called within `mutate`.
90+
let loader = ExclusiveAccessTriggeringLoader(computeValueResult: 1)
91+
let result = loader.readValue()
92+
XCTAssertEqual(result, 1)
93+
}
94+
}
95+
96+
private extension Cached_Tests {
97+
/// Triggers crash if `computeValue` is called from `mutate(_:)`.
98+
final class ExclusiveAccessTriggeringLoader {
99+
@Cached private var value: Int?
100+
101+
init(computeValueResult: Int) {
102+
_value.computeValue = { [weak self] in
103+
self?.simulateComputeValueTriggeringCoreDataChangeWhichLeadsToCallingResetOnTheSameProperty()
104+
return computeValueResult
105+
}
106+
}
107+
108+
func readValue() -> Int? {
109+
value
110+
}
111+
112+
private func simulateComputeValueTriggeringCoreDataChangeWhichLeadsToCallingResetOnTheSameProperty() {
113+
// Simulates the case of a CoreData change which in turn ended up calling reset on the item
114+
_value.reset()
115+
}
116+
}
68117
}

0 commit comments

Comments
 (0)