Unexpected behaviour with multiple Observations. Possibly a bug?

I was playing around with Observations in an attempt to create a nice way to pass a sequence around and saw some unexpected behaviour. The behaviour occurred intermittently and only when there were multiple Observations to a single property. Probably easiest to show with code and the log:

Code:



@Observable
class AutoCounter {

    var value: Int = 0

    init() {
        Task {
            while true {
                try! await Task.sleep(for: .seconds(1))
                value += 1
            }
        }
    }
}


class Emitter<T> {

    let sequence: any AsyncSequence<T, Never>
    var changeHandler: ((T) -> Void)?

    init(sequence: any AsyncSequence<T, Never>) {
        self.sequence = sequence
        Task {
            for await value in sequence {
                changeHandler?(value)
            }
        }
    }
}

extension Emitter {

    convenience init(emit: @Sendable @escaping () -> T) {
        let valueObservations = Observations(emit)
        self.init(sequence: valueObservations)
    }

    convenience init<Object: Observable>(object: Object, property: KeyPath<Object, T>) {
        let valueObservations = Observations { object[keyPath: property] }
        self.init(sequence: valueObservations)
    }
}



func example() {
    let counter = AutoCounter()

    let emitterClosure = Emitter { counter.value }
    emitterClosure.changeHandler = { print("Emitter 1: \($0)") }

    let emitterKP = Emitter(object: counter, property: \.value)
    emitterKP.changeHandler = { print("Emitter 2: \($0)") }
}

In summary, AutoCounter has an observable value property that updates every second. There are two instances of the Emitter class which both observe the value of the same AutoCounter instance. When the Emitter observes a change they call their changeHandler which are configure to print the new value. I would expect each Emitter to print an ever increasing message but that doesn’t always happen. The log explains what’s actually happening …

Log:

// ...
Emitter 1: 12
Emitter 2: 12
Emitter 2: 13
Emitter 1: 13
Emitter 1: 14
Emitter 2: 14
Emitter 1: 15
Emitter 2: 14 // Unexpected, would have expected `15`
Emitter 2: 16
Emitter 1: 16
Emitter 1: 17
Emitter 2: 17
Emitter 1: 18
Emitter 2: 18
Emitter 1: 19
Emitter 2: 19 
Emitter 2: 19 // Unexpected, would have expected `20`
Emitter 1: 20 
Emitter 2: 21
Emitter 1: 21

The logs show that emitter 2 received the same value twice. The order of when the emitters change handlers’ are invoked looks to be undefined, which isn’t necessarily wrong but may be related.

Is this expected behaviour or a bug? If it’s expected then what’s the advice regarding multiple Observations?

This was ran on the iOS Simulator with Xcode 26.3.0 on MacOS 15.7.4.

This may be (i haven't looked at the exact causes fully) a limitation of some property setters; mutate in place cannot use the equality de-duplication since that would cause in the cases of CoW types dramatically bad performance for copies - it boils down to having the previous value alive while mutating will cause that to not be uniquely referenced.

This means that when you write things like value += 1 it falls into a path in which it cannot detect if the event is distinct. Practically speaking; if you NEED to ensure no duplication then you should likely consider using the .removeDuplicates() algorithm from AsyncAlgorithms.

The other issue here is that you are potentially spawning concurrent tasks which can also lead you down a path of an event duplication case.

1 Like

I’ve just realised I was in Swift 5 mode. Switching to Swift 6 forces the concurrency problems to be addressed. I’m still getting some head scratching errors but they are concurrency related and so seem distinct from the original problem.

I guess it’s fair to say that the behaviour is not technically unexpected but is still surprising. It’s a sharp corner of concurrency which in this case isn’t easy to spot because the interface I created intentionally hides the Task, which makes me wonder if that makes it a wise design.

I presume that when you use Swift 6 mode, you're forced to make AutoCounter: Sendable and protect value with a Mutex.

If you did so, you'd then likely discover `withObservationTracking` can miss concurrent/coincident updates · Issue #83359 · swiftlang/swift · GitHub, where observation doesn't work correctly with concurrent updates to Sendable Observables.