Skip to content

Observations - Property changes are missed when observation handler is blocked #84954

@muukii

Description

@muukii

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

Metadata

Metadata

Assignees

No one assigned

    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