Skip to content

Commit 207fec5

Browse files
committed
StudyBundleLoader: fix potential observation deadlock
1 parent 092a912 commit 207fec5

1 file changed

Lines changed: 35 additions & 23 deletions

File tree

MyHeartCounts/Modules/StudyBundleLoader.swift

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -70,40 +70,52 @@ final class StudyBundleLoader: Module, Sendable {
7070
_ newValue: Result<StudyBundle, LoadError>,
7171
preferCachedBundleOnError: Bool
7272
) -> Result<StudyBundle, LoadError> {
73-
_studyBundle.withLock { value in
73+
enum Outcome {
74+
case unchanged(Result<StudyBundle, LoadError>)
75+
case changed(Result<StudyBundle, LoadError>)
76+
}
77+
// ISSUE: we need to be careful here wrt how we update _studyBundle, since it's
78+
// both a Mutex-protected but also an @ObservationTracked property.
79+
// the issue being that were we to simply call `withMutation` from w/in Mutex.withLock,
80+
// we'd risk deadlocks, since withMutation will trigger observers immediately.
81+
// so eg, a `withObservationTracking { loader.studyBundle } onChange: { ... }` call could
82+
// try to immediately access the study bundle (thereby trying to acquire the mutex) even though we're
83+
// still holding the mutex in here.
84+
// SOLUTION: we update _studyBundle outside of `withMutation`,
85+
// and then manually inform the ObservationRegistrar about the mutation after the fact.
86+
let outcome: Outcome = _studyBundle.withLock { value in
7487
switch (value, newValue) {
75-
case (.none, let newValue):
76-
withMutation(keyPath: \.studyBundle) {
77-
value = newValue
78-
}
79-
return newValue
80-
case (.some(.failure), let newValue):
81-
withMutation(keyPath: \.studyBundle) {
82-
value = newValue
83-
}
84-
return newValue
88+
case (.none, _), (.some(.failure), _):
89+
value = newValue
90+
return .changed(newValue)
8591
case (.some(.success(let oldBundle)), .success(let newBundle)):
8692
if newBundle != oldBundle {
87-
withMutation(keyPath: \.studyBundle) {
88-
value = .success(newBundle)
89-
}
90-
return newValue
93+
value = .success(newBundle)
94+
return .changed(.success(newBundle))
9195
} else {
92-
return .success(oldBundle)
96+
return .unchanged(.success(oldBundle))
9397
}
9498
case (.some(.success(let oldBundle)), .failure):
95-
// in this case (we successfully obtained a study bundle before, but it now has failed),
96-
// we keep the old bundle around instead of updating `_studyBundle` with the error case.
9799
if preferCachedBundleOnError {
98-
return .success(oldBundle)
100+
// in this case (we successfully obtained a study bundle before, but it now has failed),
101+
// we keep the old bundle around instead of updating `_studyBundle` with the error case.
102+
return .unchanged(.success(oldBundle))
99103
} else {
100-
withMutation(keyPath: \.studyBundle) {
101-
value = newValue
102-
}
103-
return newValue
104+
value = newValue
105+
return .changed(newValue)
104106
}
105107
}
106108
}
109+
switch outcome {
110+
case .unchanged(let result):
111+
return result
112+
case .changed(let result):
113+
// not really ideal bc we technically have already mutated the property,
114+
// but the only way we can (easily) get this working w/out riskig deadlocks.
115+
_$observationRegistrar.willSet(self, keyPath: \.studyBundle)
116+
_$observationRegistrar.didSet(self, keyPath: \.studyBundle)
117+
return result
118+
}
107119
}
108120

109121
/// Updates the study bundle.

0 commit comments

Comments
 (0)