-
Notifications
You must be signed in to change notification settings - Fork 10.6k
Description
Description
Observations Framework: Property changes are missed when observation handler is blocked
Problem
When using Observations.untilFinished
, if the observation handler performs a blocking operation (like Thread.sleep
), any property changes that occur during that blocking period are not observed.
What Happens
Timeline:
0ms → count1 = 1
0ms → Handler executes: "count1 changed, sleeping 2 seconds..."
100ms → count2 = 2 (❌ THIS CHANGE IS NEVER OBSERVED)
2000ms → Sleep ends
2000ms → Observation finishes WITHOUT seeing count2 change
Expected: Handler should be called again for count2
change
Actual: Observation can't finish since can't catch count2
change
Why This Happens
The Observations
framework appears to drop or ignore property change notifications when the observation handler is busy executing. When the handler is blocked (sleeping for 2 seconds), the count2
change happens but is not queued or re-delivered.
Environment
- Swift 6.2
- macOS 15.0+
- Frameworks:
Observation
,ObservationRegistrar
Notes
Looks like it's just limitation of Observations and withObservationTracking function.
For now, Observable macro can only be applied main-actor isolated class as Observations closure is sendable. so to be class sendable using Observable macro is using main-actor isolated.
This means, reading and writing happen on only main context. It's processing in serial. this kind of issue never happen in this situation.
So SwiftUI site is also fine as SwiftUI expects runs on main-actor.
But what if Observations captures changes of model that's sendable and nonisolated, that would be problematic in some cases.
Reproduction
import Testing
import Observation
import Foundation
@testable import ObservationsIssue
@Suite
struct IssuesObservationsNonisolatedSendable {
// Manual ObservationRegistrar implementation with @unchecked Sendable
final class ManualObservableModel: @unchecked Sendable, Observable {
private let lock = NSLock()
private let _$observationRegistrar = ObservationRegistrar()
private var _count1: Int = 0
var count1: Int {
get {
_$observationRegistrar.access(self, keyPath: \.count1)
lock.lock()
defer { lock.unlock() }
return _count1
}
set {
_$observationRegistrar.withMutation(of: self, keyPath: \.count1) {
lock.lock()
defer { lock.unlock() }
_count1 = newValue
}
}
}
private var _count2: Int = 0
var count2: Int {
get {
_$observationRegistrar.access(self, keyPath: \.count2)
lock.lock()
defer { lock.unlock() }
return _count2
}
set {
_$observationRegistrar.withMutation(of: self, keyPath: \.count2) {
lock.lock()
defer { lock.unlock() }
_count2 = newValue
}
}
}
}
@available(macOS 26, iOS 26, *)
@Test("Observation")
func observation() async {
let model = ManualObservableModel()
await confirmation { c in
Task {
let s = Observations<Void, Never>.untilFinished {
if model.count2 == 2 {
print("Sleep", Thread.current)
return .finish
}
if model.count1 == 1 {
print("Sleep", Thread.current)
Thread.sleep(forTimeInterval: 2)
return .next(())
}
return .next(())
}
var eventCount: Int = 0
for await e in s {
eventCount += 1
}
c.confirm()
}
Task.detached {
print("Update count1")
model.count1 = 1
Task.detached {
try? await Task.sleep(for: .milliseconds(100))
print("Update count2")
model.count2 = 2
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}
Expected behavior
Properly, all of changes emit events.
Environment
swift-driver version: 1.127.14.1 Apple Swift version 6.2 (swiftlang-6.2.0.19.9 clang-1700.3.19.1)
Target: arm64-apple-macosx26.0
Additional information
https://github.com/muukii/swift-observations-issue
I found this issue while I make this library
VergeGroup/swift-state-graph#79