Compare commits

...

9 commits
0.2 ... main

Author SHA1 Message Date
c2a231a40f
Fix a new warning for Swift 6.2 2025-11-05 08:38:35 -08:00
bb8d04b54f
Document everything (#2)
* Document everything

* Make sure ReadmeExamples are up to date
2025-06-13 11:37:02 -07:00
2d4c37d9da
Merge pull request #1 from samsonjs/xcode26
Fixes for Swift 6.2 on iOS 26 and macOS 26
2025-06-13 11:34:50 -07:00
52b10585ab
Fixes for Swift 6.2 on iOS 26 and macOS 26 2025-06-11 08:36:44 -07:00
33abeebe52
Add a changelog 2025-06-08 21:01:03 -07:00
d622d32134
Update Readme.md 2025-05-25 17:45:29 -07:00
ef4083dc64
Add support for iOS 17 and macOS 14 2025-05-25 17:43:01 -07:00
77130baceb
Update Readme.md 2025-04-29 09:26:08 -07:00
d2b4e0e382
Change the KVO monitoring API
Instead of having a values method that observes and monitors, break out
a values method that returns an AsyncStream and then a monitorValues
method that calls values(for: keyPath).monitor. That method is kind of
superfluous, not sure if it's good to keep it or not.
2025-04-26 17:47:12 -07:00
16 changed files with 717 additions and 89 deletions

77
Changelog.md Normal file
View file

@ -0,0 +1,77 @@
# Changelog
## [Unreleased]
- Your change here.
[Unreleased]: https://github.com/samsonjs/AsyncMonitor/compare/0.3.1...HEAD
## [0.3.1] - 2025-05-25
### Changed
- Updated documentation in Readme.md
[0.3.1]: https://github.com/samsonjs/AsyncMonitor/compare/0.3...0.3.1
## [0.3] - 2025-05-25
### Added
- Support for iOS 17 and macOS 14 (expanded platform compatibility)
- Legacy initializers and monitor methods with Sendable requirements for iOS 17+ compatibility
- Backward compatibility layer for actor isolation features
### Changed
- Enhanced AsyncMonitor class with dual initializer pattern for different iOS versions
- Improved AsyncSequence extensions with version-specific monitor methods
- Updated NSObject+AsyncKVO implementation for broader platform support
[0.3]: https://github.com/samsonjs/AsyncMonitor/compare/0.2.1...0.3
## [0.2.1] - 2025-04-26
### Changed
- **Breaking**: Refactored KVO monitoring API
- Split `values` method into separate `values(for:)` method that returns AsyncStream
- Added `monitorValues(for:)` convenience method that combines values observation with monitoring
- Replaced `TokenLocker` with `ValueLocker` for improved value management
### Added
- Enhanced test coverage for NSObject+AsyncKVO functionality
- Additional test cases for async cancellable behavior
[0.2.1]: https://github.com/samsonjs/AsyncMonitor/compare/0.2...0.2.1
## [0.2] - 2025-04-26
### Changed
- Version bump to 0.2
[0.2]: https://github.com/samsonjs/AsyncMonitor/compare/0.1.1...0.2
## [0.1.1] - 2025-04-25
### Changed
- Updated minimum iOS platform requirement to 18.0
- Removed main actor restrictions from public API
### Added
- Comprehensive documentation comments on public API
- Enhanced README with detailed usage examples and patterns
- Expanded test suite coverage
[0.1.1]: https://github.com/samsonjs/AsyncMonitor/compare/0.1...0.1.1
## [0.1] - 2025-04-25
### Added
- Initial release of AsyncMonitor
- Core `AsyncMonitor` class for wrapping async sequence observation in manageable Tasks
- `AsyncCancellable` protocol and `AnyAsyncCancellable` type-eraser for uniform cancellation
- AsyncSequence extensions with `.monitor()` convenience methods
- KVO integration via `NSObject+AsyncKVO` extension
- Support for context-aware monitoring to prevent reference cycles
- Swift Testing framework integration
- Comprehensive test suite
- Documentation and usage examples
[0.1]: https://github.com/samsonjs/AsyncMonitor/releases/tag/0.1

View file

@ -5,8 +5,8 @@ import PackageDescription
let package = Package(
name: "AsyncMonitor",
platforms: [
.iOS(.v18),
.macOS(.v15),
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(

View file

@ -10,7 +10,7 @@ AsyncMonitor is a Swift library that provides a simple and easy-to-use way to ma
It uses a Swift `Task` to ensure that all resources are properly cleaned up when the `AsyncMonitor` is cancelled or deallocated.
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.
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, which brings me to another point. This package pairs nicely with [NotificationSmuggler](https://github.com/samsonjs/NotificationSmuggler) for a complete notification handling system in the Swift 6 concurrency world.
## Usage
@ -32,8 +32,8 @@ class SimplestVersion {
This example uses the context parameter to avoid reference cycles with `self`.
```swift
class WithContext {
var cancellables = Set<AnyAsyncCancellable>()
final class WithContext: Sendable {
nonisolated(unsafe) var cancellables = Set<AnyAsyncCancellable>()
init() {
NotificationCenter.default
@ -55,10 +55,10 @@ class WithContext {
Working with Combine publishers is trivial thanks to [`AnyPublisher.values`][values].
```swift
import Combine
@preconcurrency import Combine
class CombineExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
Timer.publish(every: 1.0, on: .main, in: .common)
@ -66,8 +66,7 @@ class CombineExample {
.values
.monitor { date in
print("Timer fired at \(date)")
}
.store(in: &cancellables)
}.store(in: &cancellables)
}
}
```
@ -80,11 +79,11 @@ When you need to observe an object that uses [KVO][] there's an extension method
```swift
class KVOExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
progress.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}
@ -101,7 +100,7 @@ The only way to install this package is with Swift Package Manager (SPM). Please
### Supported Platforms
This package is supported on iOS 18.0+ and macOS 15.0+.
This package is supported on iOS 17.0+ and macOS 14.0+.
### Xcode
@ -112,7 +111,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.3.1"))
```
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.

View file

@ -1,10 +1,19 @@
/// 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``.
/// Type-erasing wrapper for ``AsyncCancellable`` that automatically cancels when deallocated.
///
/// `AnyAsyncCancellable` provides automatic cancellation when deallocated, making it safe to store
/// cancellables without explicitly managing their lifecycle.
///
public class AnyAsyncCancellable: AsyncCancellable {
lazy var id = ObjectIdentifier(self)
let canceller: () -> Void
/// Creates a type-erased wrapper around the provided cancellable.
///
/// The wrapper will call the cancellable's `cancel()` method when either
/// explicitly cancelled or deallocated.
///
/// - Parameter cancellable: The ``AsyncCancellable`` to wrap.
public init<AC: AsyncCancellable>(cancellable: AC) {
canceller = { cancellable.cancel() }
}
@ -15,6 +24,7 @@ public class AnyAsyncCancellable: AsyncCancellable {
// MARK: AsyncCancellable conformance
/// Cancels the wrapped cancellable. Safe to call multiple times and automatically called on deallocation.
public func cancel() {
canceller()
}

View file

@ -1,10 +1,15 @@
/// Represents an async operation that can be cancelled.
///
/// `AsyncCancellable` provides a common interface for cancelling async operations, similar to
/// Combine's `AnyCancellable` but designed for Swift concurrency patterns.
///
public protocol AsyncCancellable: AnyObject, Hashable {
/// Cancels the operation.
/// Cancels the operation. Safe to call multiple times.
func cancel()
/// Stores this cancellable in the given set, using the type-erasing wrapper ``AnyAsyncCancellable``. This method has a
/// default implementation and you typically shouldn't need to override it.
/// Stores this cancellable in the given set using ``AnyAsyncCancellable``.
///
/// - Parameter set: The set to store the wrapped cancellable in.
func store(in set: inout Set<AnyAsyncCancellable>)
}

View file

@ -0,0 +1,112 @@
# ``AsyncMonitor``
Wraps async sequence observation in manageable tasks.
## Overview
AsyncMonitor wraps async sequence observation in a `Task` that can be cancelled and stored. It preserves actor isolation on iOS 18+ and includes KVO integration.
## Basic Usage
```swift
import AsyncMonitor
// Monitor notifications
NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in print("Day changed!") }
// Store for longer lifetime
var cancellables: Set<AnyAsyncCancellable> = []
sequence.monitor { element in
// Handle element
}.store(in: &cancellables)
```
## Context-Aware Monitoring
Prevent retain cycles with weak context:
```swift
class DataController {
var cancellables: Set<AnyAsyncCancellable> = []
func startMonitoring() {
dataStream
.monitor(context: self) { controller, data in
controller.processData(data)
}.store(in: &cancellables)
}
}
```
## KVO Integration
```swift
let progress = Progress(totalUnitCount: 100)
progress.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
print("Progress: \(fraction.formatted(.percent))")
}.store(in: &cancellables)
```
## Error Handling
Both throwing and non-throwing sequences work. Errors are caught and logged automatically.
```swift
// Non-throwing
Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.values
.monitor { date in print("Timer: \(date)") }
// Throwing (errors caught automatically)
networkDataStream()
.monitor { data in processData(data) }
```
## Memory Management
Use weak captures or context to avoid retain cycles:
```swift
// Good
sequence.monitor(context: self) { controller, element in
controller.handle(element)
}
// Good
sequence.monitor { [weak self] element in
self?.handle(element)
}
// Bad - creates retain cycle
sequence.monitor { element in
self.handle(element)
}
```
## Platform Requirements
- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
## Topics
### Core Types
- ``AsyncMonitor`` - Wraps async sequence observation in a managed Task
- ``AsyncCancellable`` - Protocol for async operations that can be cancelled
- ``AnyAsyncCancellable`` - Type-erasing wrapper that auto-cancels on deallocation
### Sequence Extensions
- ``Foundation/AsyncSequence/monitor(_:)``
- ``Foundation/AsyncSequence/monitor(context:_:)``
### KVO Integration
- ``Foundation/NSObjectProtocol/monitorValues(for:options:changeHandler:)``

View file

@ -1,25 +1,27 @@
/// A monitor that observes an asynchronous sequence and invokes the given block for each received element.
///
/// The element must be `Sendable` so to use it to monitor notifications from `NotificationCenter` you'll need to map them to
/// something sendable before calling `monitor` on the sequence. e.g.
/// `AsyncMonitor` wraps the observation of an async sequence in a `Task`, providing automatic cancellation
/// and memory management. Elements must be `Sendable`. For notifications, map to something sendable:
///
/// ```
/// ```swift
/// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in whatever() }
/// .store(in: &cancellables)
/// .monitor { _ in print("Day changed!") }
/// ```
///
/// On iOS 18+, preserves the caller's actor isolation context by default.
///
public final class AsyncMonitor: Hashable, AsyncCancellable {
let task: Task<Void, Never>
/// Creates an ``AsyncMonitor`` that observes the provided asynchronous sequence.
/// Creates an ``AsyncMonitor`` that observes the provided asynchronous sequence with actor isolation support (iOS 18+).
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit.
/// Defaults to `#isolation`, preserving the caller's actor isolation.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`.
/// - sequence: The asynchronous sequence of elements to observe.
/// - block: A closure to execute for each element yielded by the sequence.
@available(iOS 18, macOS 15, *)
public init<Element: Sendable>(
isolation: isolated (any Actor)? = #isolation,
sequence: any AsyncSequence<Element, Never>,
@ -34,13 +36,62 @@ public final class AsyncMonitor: Hashable, AsyncCancellable {
}
}
/// Creates an ``AsyncMonitor`` for sequences that may throw errors (iOS 18+).
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`.
/// - sequence: The asynchronous sequence of elements to observe. May throw errors.
/// - block: A closure to execute for each element yielded by the sequence.
@available(iOS 18, macOS 15, *)
public init<Element: Sendable, Sequence: AsyncSequence>(
isolation: isolated (any Actor)? = #isolation,
sequence: Sequence,
performing block: @escaping (Element) async -> Void
) where Sequence.Element == Element {
self.task = Task {
_ = isolation // use capture trick to inherit isolation
do {
for try await element in sequence {
await block(element)
}
} catch {
guard !Task.isCancelled else { return }
}
}
}
/// Creates an ``AsyncMonitor`` for iOS 17 compatibility.
///
/// - Parameters:
/// - sequence: The asynchronous sequence of elements to observe. Must be `Sendable`.
/// - block: A `@Sendable` closure to execute for each element yielded by the sequence.
@available(iOS, introduced: 17, obsoleted: 18)
@available(macOS, introduced: 14, obsoleted: 15)
public init<Element: Sendable, Sequence>(
sequence: sending Sequence,
@_inheritActorContext performing block: @escaping @Sendable (Element) async -> Void
) where Sequence: AsyncSequence & Sendable, Sequence.Element == Element {
self.task = Task {
do {
for try await element in sequence {
await block(element)
}
} catch {
guard !Task.isCancelled else { return }
print("Error iterating over sequence: \(error)")
}
}
}
deinit {
cancel()
}
// MARK: AsyncCancellable conformance
/// Cancels the underlying task monitoring the asynchronous sequence.
/// Cancels the underlying task. Safe to call multiple times and automatically called when deallocated.
public func cancel() {
task.cancel()
}

View file

@ -1,11 +1,36 @@
@available(iOS 18, macOS 15, *)
public extension AsyncSequence where Element: Sendable, Failure == Never {
/// Observes the elements yielded by this sequence and executes the given closure with each element.
///
/// This method preserves the actor isolation of the caller by default when `isolation` is not specified.
/// This method creates an ``AsyncMonitor`` that observes the sequence and preserves the caller's
/// actor isolation context by default. When called from a `@MainActor` context, the monitoring
/// block will also run on the main actor.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`, preserving the caller's actor isolation.
/// - block: A closure that's executed with each yielded element.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - block: A closure that's executed with each yielded element. The closure runs with
/// the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// @MainActor class ViewModel {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// // Monitor runs on MainActor since caller is @MainActor
/// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in
/// self.updateUI() // Safe to call @MainActor methods
/// }.store(in: &cancellables)
/// }
/// }
/// ```
func monitor(
isolation: isolated (any Actor)? = #isolation,
_ block: @escaping (Element) async -> Void
@ -13,15 +38,40 @@ public extension AsyncSequence where Element: Sendable, Failure == Never {
AsyncMonitor(isolation: isolation, sequence: self, performing: block)
}
/// Observes the elements yielded by this sequence and executes the given closure with each element the weakly-captured
/// context object.
/// Observes the elements yielded by this sequence and executes the given closure with each element and the weakly-captured context object.
///
/// This method preserves the actor isolation of the caller by default when `isolation` is not specified.
/// This method creates an ``AsyncMonitor`` that weakly captures the provided context object, preventing retain cycles.
/// If the context object is deallocated, the monitoring block will not be executed for subsequent elements.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`, preserving the caller's actor isolation.
/// - context: The object to capture weakly for use within the closure.
/// - block: A closure that's executed with each yielded element, and the `context`.
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - context: The object to capture weakly for use within the closure. This prevents retain cycles
/// when the context holds a reference to the monitor.
/// - block: A closure that's executed with the weakly-captured context and each yielded element.
/// The closure runs with the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class DataManager {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// // Context is weakly captured, preventing retain cycle
/// dataStream
/// .monitor(context: self) { manager, data in
/// manager.process(data) // manager won't be nil here
/// }.store(in: &cancellables)
/// }
///
/// func process(_ data: Data) {
/// // Process the data
/// }
/// }
/// ```
func monitor<Context: AnyObject>(
isolation: isolated (any Actor)? = #isolation,
context: Context,
@ -33,3 +83,160 @@ public extension AsyncSequence where Element: Sendable, Failure == Never {
}
}
}
@available(iOS 18, macOS 15, *)
public extension AsyncSequence where Element: Sendable {
/// Observes the elements yielded by this sequence and executes the given closure with each element.
///
/// This method creates an ``AsyncMonitor`` that observes the sequence and preserves the caller's
/// actor isolation context by default. When called from a `@MainActor` context, the monitoring
/// block will also run on the main actor.
///
/// This version handles sequences that may throw errors. If an error is thrown, it will be logged
/// and monitoring will stop.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - block: A closure that's executed with each yielded element. The closure runs with
/// the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// NotificationCenter.default
/// .notifications(named: .NSCalendarDayChanged)
/// .map(\.name)
/// .monitor { _ in
/// print("Day changed!")
/// }.store(in: &cancellables)
/// ```
func monitor(
isolation: isolated (any Actor)? = #isolation,
_ block: @escaping (Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(isolation: isolation, sequence: self, performing: block)
}
/// Observes the elements yielded by this sequence and executes the given closure with each element and the weakly-captured context object.
///
/// This method creates an ``AsyncMonitor`` that weakly captures the provided context object, preventing retain cycles.
/// If the context object is deallocated, the monitoring block will not be executed for subsequent elements.
///
/// This version handles sequences that may throw errors. If an error is thrown, it will be logged
/// and monitoring will stop.
///
/// - Parameters:
/// - isolation: An optional actor isolation context to inherit. Defaults to `#isolation`,
/// preserving the caller's actor isolation.
/// - context: The object to capture weakly for use within the closure. This prevents retain cycles
/// when the context holds a reference to the monitor.
/// - block: A closure that's executed with the weakly-captured context and each yielded element.
/// The closure runs with the same actor isolation as the caller.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class DataManager {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// notificationStream
/// .monitor(context: self) { manager, notification in
/// manager.handleNotification(notification)
/// }.store(in: &cancellables)
/// }
/// }
/// ```
func monitor<Context: AnyObject>(
isolation: isolated (any Actor)? = #isolation,
context: Context,
_ block: @escaping (Context, Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(isolation: isolation, sequence: self) { [weak context] element in
guard let context else { return }
await block(context, element)
}
}
}
@available(iOS, introduced: 17, obsoleted: 18)
@available(macOS, introduced: 14, obsoleted: 15)
public extension AsyncSequence where Self: Sendable, Element: Sendable {
/// Observes the elements yielded by this sequence and executes the given closure with each element (iOS 17 compatibility).
///
/// This method provides backward compatibility for iOS 17. It requires both the sequence and its elements
/// to be `Sendable`, and uses a `@Sendable` closure for thread safety.
///
/// - Parameters:
/// - block: A `@Sendable` closure that's executed with each yielded element.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// let cancellable = sendableAsyncSequence.monitor { element in
/// print("Received: \(element)")
/// }
///
/// // Store for automatic cleanup
/// cancellable.store(in: &cancellables)
/// ```
///
/// - Note: This method is deprecated in iOS 18+ in favour of ``monitor(isolation:_:)``
/// which provides better actor isolation support.
func monitor(
_ block: @escaping @Sendable (Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(sequence: self, performing: block)
}
/// Observes the elements yielded by this sequence and executes the given closure with each element and the weakly-captured context object (iOS 17 compatibility).
///
/// This method provides backward compatibility for iOS 17 with weak reference handling to prevent retain cycles.
/// It requires the context to be both `AnyObject` and `Sendable` for thread safety.
///
/// - Parameters:
/// - context: The object to capture weakly for use within the closure. Must be `Sendable` and will be
/// captured weakly to prevent retain cycles.
/// - block: A `@Sendable` closure that's executed with the weakly-captured context and each yielded element.
///
/// - Returns: An ``AsyncMonitor`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class SendableDataManager: Sendable {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func startMonitoring() {
/// // Context is weakly captured, preventing retain cycle
/// sendableDataStream
/// .monitor(context: self) { manager, data in
/// manager.process(data)
/// }.store(in: &cancellables)
/// }
///
/// func process(_ data: Data) {
/// // Process the data
/// }
/// }
/// ```
///
/// - Note: This method is deprecated in iOS 18+ in favour of ``monitor(isolation:context:_:)``
/// which provides better actor isolation support.
func monitor<Context: AnyObject & Sendable>(
context: Context,
_ block: @escaping @Sendable (Context, Element) async -> Void
) -> AsyncMonitor {
AsyncMonitor(sequence: self) { [weak context] element in
guard let context else { return }
await block(context, element)
}
}
}

View file

@ -3,28 +3,150 @@ public import Foundation
extension KeyPath: @unchecked @retroactive Sendable where Value: Sendable {}
public extension NSObjectProtocol where Self: NSObject {
/// Observes changes to the specified key path on the object and asynchronously yields each value. Values must be `Sendable`.
/// Returns an `AsyncSequence` of values for all changes to the given key path on this object.
///
/// This method creates an `AsyncStream` that yields the current value of the specified key path
/// whenever it changes via Key-Value Observing (KVO). The stream automatically manages the KVO
/// observation lifecycle and cleans up when the stream is terminated.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value must be `Sendable`.
/// - keyPath: The key path to observe on this object. The value type must be `Sendable`
/// to ensure thread safety across async contexts.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// - changeHandler: A closure that's executed with each new value.
/// See `NSKeyValueObservingOptions` for available options.
///
/// - Returns: An `AsyncStream<Value>` that yields the current value of the key path
/// whenever it changes.
///
/// ## Example
///
/// ```swift
/// let progress = Progress(totalUnitCount: 100)
///
/// for await fraction in progress.values(for: \.fractionCompleted) {
/// print("Progress: \(fraction.formatted(.percent))")
/// if fraction >= 1.0 { break }
/// }
/// ```
///
/// ## Thread Safety
///
/// The returned stream is thread-safe and can be consumed from any actor context.
/// The KVO observation token is automatically retained by the stream and released
/// when the stream terminates.
///
/// - Important: The observed object must remain alive for the duration of the observation.
/// If the object is deallocated, the stream will terminate.
func values<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = []
) -> AsyncStream<Value> {
let (stream, continuation) = AsyncStream<Value>.makeStream()
let token: NSKeyValueObservation? = self.observe(keyPath, options: options) { object, _ in
continuation.yield(object[keyPath: keyPath])
}
// A nice side-effect of this is that the stream retains the token automatically.
let locker = ValueLocker(value: token)
continuation.onTermination = { _ in
locker.modify { $0 = nil }
}
return stream
}
}
@available(iOS 18, macOS 15, *)
public extension NSObjectProtocol where Self: NSObject {
/// Observes changes to the specified key path on the object and executes a handler for each change.
///
/// This method combines KVO observation with ``AsyncMonitor`` to provide a convenient way to
/// monitor object property changes. It creates an ``AsyncMonitor`` that observes the key path
/// and preserves the caller's actor isolation context.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value type must be `Sendable`
/// to ensure thread safety across async contexts.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// See `NSKeyValueObservingOptions` for available options.
/// - changeHandler: A closure that's executed with each new value. The closure runs with
/// the same actor isolation as the caller.
///
/// - Returns: An ``AsyncCancellable`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// @MainActor class ProgressView: UIView {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func observeProgress(_ progress: Progress) {
/// // Handler runs on MainActor since caller is @MainActor
/// progress.monitorValues(for: \.fractionCompleted) { [weak self] fraction in
/// self?.updateProgressBar(fraction)
/// }.store(in: &cancellables)
/// }
///
/// func updateProgressBar(_ fraction: Double) {
/// // Update UI safely on MainActor
/// }
/// }
/// ```
///
/// ## Usage with KVO Options
///
/// ```swift
/// object.monitorValues(for: \.property, options: [.initial, .new]) { newValue in
/// print("Property changed to: \(newValue)")
/// }
/// ```
func monitorValues<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = [],
changeHandler: @escaping (Value) -> Void
) -> any AsyncCancellable {
let (stream, continuation) = AsyncStream<Value>.makeStream()
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
_ = locker // keep this alive
changeHandler(value)
}
values(for: keyPath, options: options)
.monitor(changeHandler)
}
}
@available(iOS, introduced: 17, obsoleted: 18)
@available(macOS, introduced: 14, obsoleted: 15)
public extension NSObjectProtocol where Self: NSObject {
/// Observes changes to the specified key path on the object and executes a handler for each change (iOS 17 compatibility).
///
/// This method provides backward compatibility for iOS 17. It combines KVO observation with ``AsyncMonitor``
/// and requires a `@Sendable` closure for thread safety.
///
/// - Parameters:
/// - keyPath: The key path to observe on this object. The value type must be `Sendable`
/// to ensure thread safety across async contexts.
/// - options: KVO options to use for observation. Defaults to an empty set.
/// See `NSKeyValueObservingOptions` for available options.
/// - changeHandler: A `@Sendable` closure that's executed with each new value.
///
/// - Returns: An ``AsyncCancellable`` that can be stored and cancelled as needed.
///
/// ## Example
///
/// ```swift
/// class ProgressObserver {
/// var cancellables: Set<AnyAsyncCancellable> = []
///
/// func observeProgress(_ progress: Progress) {
/// progress.monitorValues(for: \.fractionCompleted) { fraction in
/// print("Progress: \(fraction.formatted(.percent))")
/// }.store(in: &cancellables)
/// }
/// }
/// ```
///
/// - Note: This method is deprecated in iOS 18+ in favour of the non-`@Sendable` version
/// which provides better actor isolation support.
func monitorValues<Value: Sendable>(
for keyPath: KeyPath<Self, Value>,
options: NSKeyValueObservingOptions = [],
changeHandler: @escaping @Sendable (Value) -> Void
) -> any AsyncCancellable {
values(for: keyPath, options: options)
.monitor(changeHandler)
}
}

View file

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

View file

@ -0,0 +1,20 @@
import Foundation
final class ValueLocker<Value>: @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)
}
}
}

View file

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

View file

@ -2,7 +2,7 @@
import Testing
@MainActor class AsyncCancellableTests {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
@Test func storeInsertsIntoSetAndKeepsSubjectAlive() throws {
var subject: TestCancellable? = TestCancellable()
@ -11,7 +11,9 @@ import Testing
#expect(cancellables.count == 1)
subject = nil
#expect(weakSubject != nil)
cancellables.removeAll()
#expect(weakSubject == nil)
}
}

View file

@ -49,12 +49,12 @@ class AsyncMonitorTests {
try await Task.sleep(for: .milliseconds(10))
}
class Owner {
let deinitHook: () -> Void
final class Owner: Sendable {
let deinitHook: @Sendable () -> Void
private var cancellable: (any AsyncCancellable)?
nonisolated(unsafe) private var cancellable: (any AsyncCancellable)?
init(center: NotificationCenter, deinitHook: @escaping () -> Void) {
init(center: NotificationCenter, deinitHook: @escaping @Sendable () -> Void) {
self.deinitHook = deinitHook
let name = Notification.Name("irrelevant name")
cancellable = center.notifications(named: name)
@ -78,8 +78,10 @@ class AsyncMonitorTests {
}
}
final class SendableObject: NSObject, Sendable {}
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
var context: NSObject? = NSObject()
var context: SendableObject? = SendableObject()
subject = center.notifications(named: name)
.map(\.name)
.monitor(context: context!) { context, receivedName in

View file

@ -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)
let values = ValueLocker(value: [Double]())
let total = 3
cancellable = subject.values(for: \.fractionCompleted)
.prefix(total)
.monitor { progress in
values.modify { $0.append(progress) }
}
for n in 1...total {
subject.completedUnitCount += 1
while values.value.count < n {
try await Task.sleep(for: .microseconds(2))
}
}
#expect(values.value.count == total)
}
// It's important that the test and 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]()
cancellable = subject.values(for: \.fractionCompleted) { progress in
for await progress in subject.values(for: \.fractionCompleted).prefix(total) {
values.append(progress)
}
for _ in 1...3 {
subject.completedUnitCount += 1
await Task.yield()
return values
}
#expect(values.count == 3)
await Task.yield()
for _ in 1...total {
subject.completedUnitCount += 1
}
let values = await task.value
#expect(values.count == total)
}
}

View file

@ -3,6 +3,8 @@ import Foundation
// MARK: Basics
extension Notification: @unchecked @retroactive Sendable {}
class SimplestVersion {
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
@ -12,8 +14,8 @@ class SimplestVersion {
}
}
class WithContext {
var cancellables = Set<AnyAsyncCancellable>()
final class WithContext: Sendable {
nonisolated(unsafe) var cancellables: Set<AnyAsyncCancellable> = []
init() {
NotificationCenter.default
@ -31,10 +33,10 @@ class WithContext {
// MARK: - Combine
import Combine
@preconcurrency import Combine
class CombineExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
Timer.publish(every: 1.0, on: .main, in: .common)
@ -42,19 +44,18 @@ class CombineExample {
.values
.monitor { date in
print("Timer fired at \(date)")
}
.store(in: &cancellables)
}.store(in: &cancellables)
}
}
// MARK: - KVO
class KVOExample {
var cancellables = Set<AnyAsyncCancellable>()
var cancellables: Set<AnyAsyncCancellable> = []
init() {
let progress = Progress(totalUnitCount: 42)
progress.values(for: \.fractionCompleted) { fraction in
progress.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
print("Progress is \(fraction.formatted(.percent))%")
}.store(in: &cancellables)
}