diff --git a/Readme.md b/Readme.md index b4fd63b..a70e492 100644 --- a/Readme.md +++ b/Readme.md @@ -84,7 +84,7 @@ class KVOExample { init() { let progress = Progress(totalUnitCount: 42) - progress.values(for: \.fractionCompleted) { fraction in + progress.monitorValues(for: \.fractionCompleted) { fraction in print("Progress is \(fraction.formatted(.percent))%") }.store(in: &cancellables) } @@ -112,7 +112,7 @@ When you're integrating this into an app with Xcode then go to your project's Pa When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file: ```swift -.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.2")) +.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.2.1")) ``` and then add `"AsyncMonitor"` to the list of dependencies in your target as well. diff --git a/Sources/AsyncMonitor/NSObject+AsyncKVO.swift b/Sources/AsyncMonitor/NSObject+AsyncKVO.swift index 3d31fff..203528f 100644 --- a/Sources/AsyncMonitor/NSObject+AsyncKVO.swift +++ b/Sources/AsyncMonitor/NSObject+AsyncKVO.swift @@ -9,22 +9,33 @@ public extension NSObjectProtocol where Self: NSObject { /// - keyPath: The key path to observe on this object. The value must be `Sendable`. /// - options: KVO options to use for observation. Defaults to an empty set. /// - changeHandler: A closure that's executed with each new value. - func values( + func monitorValues( for keyPath: KeyPath, options: NSKeyValueObservingOptions = [], changeHandler: @escaping (Value) -> Void ) -> any AsyncCancellable { + values(for: keyPath, options: options) + .monitor(changeHandler) + } + + /// Returns an `AsyncSequence` of `Value`s for all changes to the given key path on this object. + /// + /// - Parameters: + /// - keyPath: The key path to observe on this object. The value must be `Sendable`. + /// - options: KVO options to use for observation. Defaults to an empty set. + func values( + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [] + ) -> some AsyncSequence { let (stream, continuation) = AsyncStream.makeStream() - let token = self.observe(keyPath, options: options) { object, _ in + let token: NSKeyValueObservation? = self.observe(keyPath, options: options) { object, _ in continuation.yield(object[keyPath: keyPath]) } - let locker = TokenLocker(token: token) + // A nice side-effect of this is that the stream retains the token automatically. + let locker = ValueLocker(value: token) continuation.onTermination = { _ in - locker.clear() - } - return stream.monitor { value in - _ = locker // keep this alive - changeHandler(value) + locker.modify { $0 = nil } } + return stream } } diff --git a/Sources/AsyncMonitor/TokenLocker.swift b/Sources/AsyncMonitor/TokenLocker.swift deleted file mode 100644 index 684d856..0000000 --- a/Sources/AsyncMonitor/TokenLocker.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -final class TokenLocker: @unchecked Sendable { - private let lock = NSLock() - private var unsafeToken: NSKeyValueObservation? - - init(token: NSKeyValueObservation) { - unsafeToken = token - } - - func clear() { - lock.withLock { - unsafeToken = nil - } - } -} diff --git a/Sources/AsyncMonitor/ValueLocker.swift b/Sources/AsyncMonitor/ValueLocker.swift new file mode 100644 index 0000000..1f648ba --- /dev/null +++ b/Sources/AsyncMonitor/ValueLocker.swift @@ -0,0 +1,20 @@ +import Foundation + +final class ValueLocker: @unchecked Sendable { + private let lock = NSLock() + private var unsafeValue: Value + + init(value: Value) { + unsafeValue = value + } + + var value: Value { + lock.withLock { unsafeValue } + } + + func modify(_ f: (inout Value) -> Void) { + lock.withLock { + f(&unsafeValue) + } + } +} diff --git a/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift b/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift index 711f938..77d0185 100644 --- a/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift +++ b/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift @@ -8,7 +8,9 @@ import Testing let cancellable = TestCancellable() subject = AnyAsyncCancellable(cancellable: cancellable) #expect(!cancellable.isCancelled) + subject = nil + #expect(cancellable.isCancelled) } @@ -16,7 +18,9 @@ import Testing let cancellable = TestCancellable() subject = AnyAsyncCancellable(cancellable: cancellable) #expect(!cancellable.isCancelled) + subject.cancel() + #expect(cancellable.isCancelled) } } diff --git a/Tests/AsyncMonitorTests/AsyncCancellableTests.swift b/Tests/AsyncMonitorTests/AsyncCancellableTests.swift index 360b1d4..5bcb47d 100644 --- a/Tests/AsyncMonitorTests/AsyncCancellableTests.swift +++ b/Tests/AsyncMonitorTests/AsyncCancellableTests.swift @@ -11,7 +11,9 @@ import Testing #expect(cancellables.count == 1) subject = nil #expect(weakSubject != nil) + cancellables.removeAll() + #expect(weakSubject == nil) } } diff --git a/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift b/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift index 6bc5a01..cca9eb8 100644 --- a/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift +++ b/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift @@ -6,16 +6,48 @@ class AsyncKVOTests { var subject: Progress? = Progress(totalUnitCount: 42) var cancellable: (any AsyncCancellable)? - @Test func yieldsChanges() async throws { + @Test(.timeLimit(.minutes(1))) + func monitorValuesYieldsChanges() async throws { let subject = try #require(subject) var values = [Double]() - cancellable = subject.values(for: \.fractionCompleted) { progress in - values.append(progress) - } - for _ in 1...3 { + let total = 3 + cancellable = subject.values(for: \.fractionCompleted) + .prefix(total) + .monitor { progress in + values.append(progress) + } + + for n in 1...total { subject.completedUnitCount += 1 - await Task.yield() + while values.count < n { + try await Task.sleep(for: .microseconds(2)) + } } - #expect(values.count == 3) + + #expect(values.count == total) + } + + // It's important that the test or the progress-observing task are not on the same actor, so + // we make the test @MainActor and observe progress values on another actor. Otherwise it's a + // deadlock. + @Test(.timeLimit(.minutes(1))) + @MainActor func valuesYieldsChanges() async throws { + let subject = try #require(subject) + let total = 3 + let task = Task { + var values = [Double]() + for await progress in subject.values(for: \.fractionCompleted).prefix(total) { + values.append(progress) + } + return values + } + await Task.yield() + + for _ in 1...total { + subject.completedUnitCount += 1 + } + let values = await task.value + + #expect(values.count == total) } } diff --git a/Tests/AsyncMonitorTests/ReadmeExamples.swift b/Tests/AsyncMonitorTests/ReadmeExamples.swift index d50e453..76d5754 100644 --- a/Tests/AsyncMonitorTests/ReadmeExamples.swift +++ b/Tests/AsyncMonitorTests/ReadmeExamples.swift @@ -54,7 +54,7 @@ class KVOExample { init() { let progress = Progress(totalUnitCount: 42) - progress.values(for: \.fractionCompleted) { fraction in + progress.monitorValues(for: \.fractionCompleted) { fraction in print("Progress is \(fraction.formatted(.percent))%") }.store(in: &cancellables) }