@@ -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