From 7d1e4564ff8dc658939f6e1e0a22d5023fc110a1 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 26 Apr 2025 13:32:38 -0700 Subject: [PATCH] Flesh out the readme and add more tests --- Readme.md | 121 ++++++++++++------ .../AsyncMonitor/AnyAsyncCancellable.swift | 12 ++ Sources/AsyncMonitor/AsyncCancellable.swift | 12 -- Sources/AsyncMonitor/AsyncMonitor.swift | 13 +- Sources/AsyncMonitor/NSObject+AsyncKVO.swift | 6 +- Sources/AsyncMonitor/TokenLocker.swift | 16 +++ .../AnyAsyncCancellableTests.swift | 22 ++++ .../AsyncCancellableTests.swift | 17 +++ .../AsyncMonitorTests/AsyncMonitorTests.swift | 49 +++++-- .../AsyncSequence+Just.swift | 9 ++ .../NSObject+AsyncKVOTests.swift | 21 +++ Tests/AsyncMonitorTests/ReadmeExamples.swift | 39 +++++- Tests/AsyncMonitorTests/TestCancellable.swift | 20 +++ 13 files changed, 289 insertions(+), 68 deletions(-) create mode 100644 Sources/AsyncMonitor/TokenLocker.swift create mode 100644 Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift create mode 100644 Tests/AsyncMonitorTests/AsyncCancellableTests.swift create mode 100644 Tests/AsyncMonitorTests/AsyncSequence+Just.swift create mode 100644 Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift create mode 100644 Tests/AsyncMonitorTests/TestCancellable.swift diff --git a/Readme.md b/Readme.md index c5ba4c0..c5bfd81 100644 --- a/Readme.md +++ b/Readme.md @@ -12,6 +12,87 @@ It uses a Swift `Task` to ensure that all resources are properly cleaned up when That's it. It's pretty trivial. I just got tired of writing it over and over, mainly for notifications. You still have to map your `Notification`s to something sendable. +## Usage + +The simplest example uses a closure that receives the notification. The closure is async so you can await in there if you need to. + +```swift +import AsyncMonitor + +class SimplestVersion { + let cancellable = NotificationCenter.default + .notifications(named: .NSCalendarDayChanged) + .map(\.name) + .monitor { _ in + print("The date is now \(Date.now)") + } +} +``` + +This example uses the context parameter to avoid reference cycles with `self`. + +```swift +class WithContext { + var cancellables = Set() + + init() { + NotificationCenter.default + .notifications(named: .NSCalendarDayChanged) + .map(\.name) + .monitor(context: self) { _self, _ in + _self.dayChanged() + }.store(in: &cancellables) + } + + func dayChanged() { + print("The date is now \(Date.now)") + } +} +``` + +### Combine + +Working with Combine publishers is trivial thanks to [`AnyPublisher.values`][values]. + +```swift +import Combine + +class CombineExample { + var cancellables = Set() + + init() { + Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .values + .monitor { date in + print("Timer fired at \(date)") + } + .store(in: &cancellables) + } +} +``` + +[values]: https://developer.apple.com/documentation/combine/anypublisher/values-3s2uy + +### Key-Value Observing (KVO) extension + +When you need to observe an object that uses [KVO][] there's an extension method you can use to monitor it: + +```swift +class KVOExample { + var cancellables = Set() + + init() { + let progress = Progress(totalUnitCount: 42) + progress.values(for: \.fractionCompleted) { fraction in + print("Progress is \(fraction.formatted(.percent))%") + }.store(in: &cancellables) + } +} +``` + +[KVO]: https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/KVO.html + ## Installation The only way to install this package is with Swift Package Manager (SPM). Please [file a new issue][] or submit a pull-request if you want to use something else. @@ -36,46 +117,6 @@ When you're integrating this using SPM on its own then add this to the list of d and then add `"AsyncMonitor"` to the list of dependencies in your target as well. -## Usage - -The simplest example uses a closure that receives the notification: - -```swift -import AsyncMonitor - -class SimplestVersion { - let cancellable = NotificationCenter.default - .notifications(named: .NSCalendarDayChanged).map(\.name) - .monitor { _ in - print("The date is now \(Date.now)") - } -} -``` - -This example uses the context parameter to avoid reference cycles with `self`: - -```swift -import AsyncMonitor - -class WithContext { - var cancellables = Set() - - init() { - NotificationCenter.default - .notifications(named: .NSCalendarDayChanged).map(\.name) - .monitor(context: self) { _self, _ in - _self.dayChanged() - }.store(in: &cancellables) - } - - func dayChanged() { - print("The date is now \(Date.now)") - } -} -``` - -The closure is async so you can await in there if you need to. - ## License Copyright © 2025 [Sami Samhuri](https://samhuri.net) . Released under the terms of the [MIT License][MIT]. diff --git a/Sources/AsyncMonitor/AnyAsyncCancellable.swift b/Sources/AsyncMonitor/AnyAsyncCancellable.swift index ca169d5..596b7b2 100644 --- a/Sources/AsyncMonitor/AnyAsyncCancellable.swift +++ b/Sources/AsyncMonitor/AnyAsyncCancellable.swift @@ -1,6 +1,8 @@ /// Type-erasing wrapper for ``AsyncCancellable`` that ties its instance lifetime to cancellation. In other words, when you release /// an instance of ``AnyAsyncCancellable`` and it's deallocated then it automatically cancels its given ``AsyncCancellable``. public class AnyAsyncCancellable: AsyncCancellable { + lazy var id = ObjectIdentifier(self) + let canceller: () -> Void public init(cancellable: AC) { @@ -16,4 +18,14 @@ public class AnyAsyncCancellable: AsyncCancellable { public func cancel() { canceller() } + + // MARK: Hashable conformance + + public static func == (lhs: AnyAsyncCancellable, rhs: AnyAsyncCancellable) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } } diff --git a/Sources/AsyncMonitor/AsyncCancellable.swift b/Sources/AsyncMonitor/AsyncCancellable.swift index ea7acfe..94dcc87 100644 --- a/Sources/AsyncMonitor/AsyncCancellable.swift +++ b/Sources/AsyncMonitor/AsyncCancellable.swift @@ -15,15 +15,3 @@ public extension AsyncCancellable { set.insert(AnyAsyncCancellable(cancellable: self)) } } - -// MARK: Hashable conformance - -public extension AsyncCancellable { - static func == (lhs: Self, rhs: Self) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} diff --git a/Sources/AsyncMonitor/AsyncMonitor.swift b/Sources/AsyncMonitor/AsyncMonitor.swift index 7d30fe0..c4a839d 100644 --- a/Sources/AsyncMonitor/AsyncMonitor.swift +++ b/Sources/AsyncMonitor/AsyncMonitor.swift @@ -5,7 +5,8 @@ /// /// ``` /// NotificationCenter.default -/// .notifications(named: .NSCalendarDayChanged).map(\.name) +/// .notifications(named: .NSCalendarDayChanged) +/// .map(\.name) /// .monitor { _ in whatever() } /// .store(in: &cancellables) /// ``` @@ -43,4 +44,14 @@ public final class AsyncMonitor: Hashable, AsyncCancellable { public func cancel() { task.cancel() } + + // MARK: Hashable conformance + + public static func == (lhs: AsyncMonitor, rhs: AsyncMonitor) -> Bool { + lhs.task == rhs.task + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(task) + } } diff --git a/Sources/AsyncMonitor/NSObject+AsyncKVO.swift b/Sources/AsyncMonitor/NSObject+AsyncKVO.swift index 9e36c26..3d31fff 100644 --- a/Sources/AsyncMonitor/NSObject+AsyncKVO.swift +++ b/Sources/AsyncMonitor/NSObject+AsyncKVO.swift @@ -18,8 +18,12 @@ public extension NSObjectProtocol where Self: NSObject { let token = self.observe(keyPath, options: options) { object, _ in continuation.yield(object[keyPath: keyPath]) } + let locker = TokenLocker(token: token) + continuation.onTermination = { _ in + locker.clear() + } return stream.monitor { value in - _ = token // keep this alive + _ = locker // keep this alive changeHandler(value) } } diff --git a/Sources/AsyncMonitor/TokenLocker.swift b/Sources/AsyncMonitor/TokenLocker.swift new file mode 100644 index 0000000..684d856 --- /dev/null +++ b/Sources/AsyncMonitor/TokenLocker.swift @@ -0,0 +1,16 @@ +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/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift b/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift new file mode 100644 index 0000000..711f938 --- /dev/null +++ b/Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift @@ -0,0 +1,22 @@ +@testable import AsyncMonitor +import Testing + +@MainActor class AnyAsyncCancellableTests { + var subject: AnyAsyncCancellable! + + @Test func cancelsWhenReleased() { + let cancellable = TestCancellable() + subject = AnyAsyncCancellable(cancellable: cancellable) + #expect(!cancellable.isCancelled) + subject = nil + #expect(cancellable.isCancelled) + } + + @Test func cancelsWhenCancelled() { + 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 new file mode 100644 index 0000000..360b1d4 --- /dev/null +++ b/Tests/AsyncMonitorTests/AsyncCancellableTests.swift @@ -0,0 +1,17 @@ +@testable import AsyncMonitor +import Testing + +@MainActor class AsyncCancellableTests { + var cancellables = Set() + + @Test func storeInsertsIntoSetAndKeepsSubjectAlive() throws { + var subject: TestCancellable? = TestCancellable() + weak var weakSubject: TestCancellable? = subject + try #require(subject).store(in: &cancellables) + #expect(cancellables.count == 1) + subject = nil + #expect(weakSubject != nil) + cancellables.removeAll() + #expect(weakSubject == nil) + } +} diff --git a/Tests/AsyncMonitorTests/AsyncMonitorTests.swift b/Tests/AsyncMonitorTests/AsyncMonitorTests.swift index c3802bb..276d6e4 100644 --- a/Tests/AsyncMonitorTests/AsyncMonitorTests.swift +++ b/Tests/AsyncMonitorTests/AsyncMonitorTests.swift @@ -10,10 +10,12 @@ class AsyncMonitorTests { @Test func callsBlockWhenNotificationsArePosted() async throws { await withCheckedContinuation { [center, name] continuation in - subject = center.notifications(named: name).map(\.name).monitor { receivedName in - #expect(name == receivedName) - continuation.resume() - } + subject = center.notifications(named: name) + .map(\.name) + .monitor { receivedName in + #expect(name == receivedName) + continuation.resume() + } Task { center.post(name: name, object: nil) } @@ -21,9 +23,11 @@ class AsyncMonitorTests { } @Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws { - subject = center.notifications(named: name).map(\.name).monitor { receivedName in - Issue.record("Called for irrelevant notification \(receivedName)") - } + subject = center.notifications(named: name) + .map(\.name) + .monitor { receivedName in + Issue.record("Called for irrelevant notification \(receivedName)") + } Task { [center] in center.post(name: Notification.Name("something else"), object: nil) } @@ -31,9 +35,11 @@ class AsyncMonitorTests { } @Test @MainActor func stopsCallingBlockWhenDeallocated() async throws { - subject = center.notifications(named: name).map(\.name).monitor { _ in - Issue.record("Called after deallocation") - } + subject = center.notifications(named: name) + .map(\.name) + .monitor { _ in + Issue.record("Called after deallocation") + } Task { @MainActor in subject = nil @@ -51,7 +57,8 @@ class AsyncMonitorTests { init(center: NotificationCenter, deinitHook: @escaping () -> Void) { self.deinitHook = deinitHook let name = Notification.Name("irrelevant name") - cancellable = center.notifications(named: name).map(\.name) + cancellable = center.notifications(named: name) + .map(\.name) .monitor(context: self) { _, _ in } } @@ -73,7 +80,8 @@ class AsyncMonitorTests { @Test func stopsCallingBlockWhenContextIsDeallocated() async throws { var context: NSObject? = NSObject() - subject = center.notifications(named: name).map(\.name) + subject = center.notifications(named: name) + .map(\.name) .monitor(context: context!) { context, receivedName in Issue.record("Called after context was deallocated") } @@ -83,4 +91,21 @@ class AsyncMonitorTests { } try await Task.sleep(for: .milliseconds(10)) } + + @Test func equatable() throws { + let subject = AsyncMonitor(sequence: AsyncStream.just(42)) { _ in } + #expect(subject == subject) + #expect(subject != AsyncMonitor(sequence: AsyncStream.just(42)) { _ in }) + } + + @Test func hashable() throws { + let subjects = (1...100).map { _ in + AsyncMonitor(sequence: AsyncStream.just(42)) { _ in } + } + var hashValues: Set = [] + for subject in subjects { + hashValues.insert(subject.hashValue) + } + #expect(hashValues.count == subjects.count) + } } diff --git a/Tests/AsyncMonitorTests/AsyncSequence+Just.swift b/Tests/AsyncMonitorTests/AsyncSequence+Just.swift new file mode 100644 index 0000000..a021b00 --- /dev/null +++ b/Tests/AsyncMonitorTests/AsyncSequence+Just.swift @@ -0,0 +1,9 @@ +import Foundation + +extension AsyncSequence where Element: Sendable { + static func just(_ value: Element) -> AsyncStream { + AsyncStream { continuation in + continuation.yield(value) + } + } +} diff --git a/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift b/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift new file mode 100644 index 0000000..6bc5a01 --- /dev/null +++ b/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift @@ -0,0 +1,21 @@ +@testable import AsyncMonitor +import Foundation +import Testing + +class AsyncKVOTests { + var subject: Progress? = Progress(totalUnitCount: 42) + var cancellable: (any AsyncCancellable)? + + @Test func yieldsChanges() async throws { + let subject = try #require(subject) + var values = [Double]() + cancellable = subject.values(for: \.fractionCompleted) { progress in + values.append(progress) + } + for _ in 1...3 { + subject.completedUnitCount += 1 + await Task.yield() + } + #expect(values.count == 3) + } +} diff --git a/Tests/AsyncMonitorTests/ReadmeExamples.swift b/Tests/AsyncMonitorTests/ReadmeExamples.swift index 43c2036..d50e453 100644 --- a/Tests/AsyncMonitorTests/ReadmeExamples.swift +++ b/Tests/AsyncMonitorTests/ReadmeExamples.swift @@ -1,9 +1,12 @@ import Foundation @testable import AsyncMonitor +// MARK: Basics + class SimplestVersion { let cancellable = NotificationCenter.default - .notifications(named: .NSCalendarDayChanged).map(\.name) + .notifications(named: .NSCalendarDayChanged) + .map(\.name) .monitor { _ in print("The date is now \(Date.now)") } @@ -14,7 +17,8 @@ class WithContext { init() { NotificationCenter.default - .notifications(named: .NSCalendarDayChanged).map(\.name) + .notifications(named: .NSCalendarDayChanged) + .map(\.name) .monitor(context: self) { _self, _ in _self.dayChanged() }.store(in: &cancellables) @@ -24,3 +28,34 @@ class WithContext { print("The date is now \(Date.now)") } } + +// MARK: - Combine + +import Combine + +class CombineExample { + var cancellables = Set() + + init() { + Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .values + .monitor { date in + print("Timer fired at \(date)") + } + .store(in: &cancellables) + } +} + +// MARK: - KVO + +class KVOExample { + var cancellables = Set() + + init() { + let progress = Progress(totalUnitCount: 42) + progress.values(for: \.fractionCompleted) { fraction in + print("Progress is \(fraction.formatted(.percent))%") + }.store(in: &cancellables) + } +} diff --git a/Tests/AsyncMonitorTests/TestCancellable.swift b/Tests/AsyncMonitorTests/TestCancellable.swift new file mode 100644 index 0000000..ac6b79d --- /dev/null +++ b/Tests/AsyncMonitorTests/TestCancellable.swift @@ -0,0 +1,20 @@ +import AsyncMonitor + +class TestCancellable: AsyncCancellable { + lazy var id = ObjectIdentifier(self) + var isCancelled = false + + func cancel() { + isCancelled = true + } + + // MARK: Hashable conformance + + public static func == (lhs: TestCancellable, rhs: TestCancellable) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +}