Flesh out the readme and add more tests

This commit is contained in:
Sami Samhuri 2025-04-26 13:32:38 -07:00
parent bc05e17c92
commit 7d1e4564ff
No known key found for this signature in database
13 changed files with 289 additions and 68 deletions

121
Readme.md
View file

@ -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<AnyAsyncCancellable>()
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<AnyAsyncCancellable>()
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<AnyAsyncCancellable>()
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<AnyAsyncCancellable>()
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) <sami@samhuri.net>. Released under the terms of the [MIT License][MIT].

View file

@ -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<AC: AsyncCancellable>(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)
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,17 @@
@testable import AsyncMonitor
import Testing
@MainActor class AsyncCancellableTests {
var cancellables = Set<AnyAsyncCancellable>()
@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)
}
}

View file

@ -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<Int> = []
for subject in subjects {
hashValues.insert(subject.hashValue)
}
#expect(hashValues.count == subjects.count)
}
}

View file

@ -0,0 +1,9 @@
import Foundation
extension AsyncSequence where Element: Sendable {
static func just(_ value: Element) -> AsyncStream<Element> {
AsyncStream { continuation in
continuation.yield(value)
}
}
}

View file

@ -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)
}
}

View file

@ -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<AnyAsyncCancellable>()
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<AnyAsyncCancellable>()
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}
}

View file

@ -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)
}
}