Skip to content

withObservationTracking can miss concurrent/coincident updates #83359

@KeithBauerANZ

Description

@KeithBauerANZ

Description

withObservationTracking is supposed to ensure that, if called in a loop, it will reach "eventual consistency" with code modifying the observed properties. This is not currently true if the properties observed by withObservationTracking's first closure are modified during or concurrently with the execution of that closure.

Two reproductions

  • one for concurrent modification (this came up during the review of the swift-foundation "ProgressReporter" proposal)
  • one for coincident modification (the problem is similar, but doesn't require concurrency to trigger)

Although these examples use Observations, the bug is because withObservationTracking waits until after the first closure has returned, to install tracking. It needs to install tracking as soon as access is called.

Reproduction

Concurrent

import Observation
import Synchronization

final class MyObservable: Observable, Sendable {

    let registrar = ObservationRegistrar()
    private let _value = Mutex(0)

    var value: Int {
        registrar.access(self, keyPath: \.value)
        return _value.withLock { $0 }
    }

    func increment() {
        registrar.willSet(self, keyPath: \.value)
        _value.withLock { $0 += 1 }
        registrar.didSet(self, keyPath: \.value)
    }

}

var test = 0
while true {
    let object = MyObservable()
    await withDiscardingTaskGroup { [test] group in
        group.addTask {
            var received = 0
            for await _ in Observations({ object.value }).prefix(while: { $0 < 1 }) {
                received += 1
            }
            print("test \(test) completed after receiving \(received) values")
        }
        group.addTask {
            object.increment()
        }
    }
    test += 1
}
swiftc -swift-version 6 WithObservationTrackingConcurrentModification.swift && ./WithObservationTrackingConcurrentModification
test 0 completed after receiving 0 values
test 1 completed after receiving 0 values
test 2 completed after receiving 1 values
test 3 completed after receiving 0 values
test 4 completed after receiving 1 values
test 5 completed after receiving 0 values
test 6 completed after receiving 0 values
test 7 completed after receiving 0 values
test 8 completed after receiving 0 values
test 9 completed after receiving 0 values
^C

Both "0" and "1" values received are legitimate (depending on whether Observations sees the initial 0 or not), but eventually it will hang, because the increment call will run concurrently with the block passed to Observations, and the update of the value to 1 will never be observed.

Coincident

import Observation
import Synchronization

final class MyObservable: Observable {
    private let registrar = ObservationRegistrar()
    private var _value = 0

    var value: Int {
        registrar.access(self, keyPath: \.value)
        let result = _value
        registrar.willSet(self, keyPath: \.value)
        _value += 1
        registrar.didSet(self, keyPath: \.value)
        return result
    }
}

let object = MyObservable()
for await value in Observations({ object.value }).prefix(while: { $0 < 1 }) {
    print(value)
}
swiftc -swift-version 6 WithObservationTrackingCoincidentalMutation.swift && ./WithObservationTrackingCoincidentalMutation
0
^C

The sequence should observe the increment after the access, and be terminated by prefix, but it never does, because the mutation happens during Observations' closure.

Expected behavior

Both examples should terminate (reliably)

Environment

swift-driver version: 1.127.10 Apple Swift version 6.2 (swiftlang-6.2.0.14.8 clang-1700.3.14.6)
Target: arm64-apple-macosx26.0

Additional information

No response

Metadata

Metadata

Labels

bugA deviation from expected or documented behavior. Also: expected but undesirable behavior.triage neededThis issue needs more specific labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions