mirror of
https://github.com/samsonjs/AsyncMonitor.git
synced 2026-03-25 08:25:47 +00:00
Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a231a40f | |||
| bb8d04b54f | |||
| 2d4c37d9da | |||
| 52b10585ab | |||
| 33abeebe52 | |||
| d622d32134 | |||
| ef4083dc64 | |||
| 77130baceb | |||
| d2b4e0e382 | |||
| e548a7534c | |||
| 7d1e4564ff | |||
| bc05e17c92 | |||
| b8eb878097 | |||
| 9a7fbdf09d |
17 changed files with 964 additions and 74 deletions
77
Changelog.md
Normal file
77
Changelog.md
Normal 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
|
||||
|
|
@ -6,7 +6,7 @@ let package = Package(
|
|||
name: "AsyncMonitor",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v15),
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
|
|
|
|||
110
Readme.md
110
Readme.md
|
|
@ -10,59 +10,35 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
[file a new issue]: https://github.com/samsonjs/AsyncMonitor/issues/new
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
This package is supported on iOS 18.0+ and macOS 15.0+.
|
||||
|
||||
### Xcode
|
||||
|
||||
When you're integrating this into an app with Xcode then go to your project's Package Dependencies and enter the URL `https://github.com/samsonjs/AsyncMonitor` and then go through the usual flow for adding packages.
|
||||
|
||||
### Swift Package Manager (SPM)
|
||||
|
||||
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.1.0"))
|
||||
```
|
||||
|
||||
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.
|
||||
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
|
||||
|
||||
The simplest example uses a closure that receives the notification:
|
||||
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
|
||||
|
||||
@MainActor class SimplestVersion {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This example uses the context parameter to avoid reference cycles with `self`:
|
||||
This example uses the context parameter to avoid reference cycles with `self`.
|
||||
|
||||
```swift
|
||||
import AsyncMonitor
|
||||
|
||||
@MainActor class WithContext {
|
||||
var cancellables = Set<AnyAsyncCancellable>()
|
||||
final class WithContext: Sendable {
|
||||
nonisolated(unsafe) var cancellables = Set<AnyAsyncCancellable>()
|
||||
|
||||
init() {
|
||||
NotificationCenter.default
|
||||
.notifications(named: .NSCalendarDayChanged).map(\.name)
|
||||
.notifications(named: .NSCalendarDayChanged)
|
||||
.map(\.name)
|
||||
.monitor(context: self) { _self, _ in
|
||||
_self.dayChanged()
|
||||
}.store(in: &cancellables)
|
||||
|
|
@ -74,7 +50,71 @@ import AsyncMonitor
|
|||
}
|
||||
```
|
||||
|
||||
The closure is async so you can await in there if you need to.
|
||||
### Combine
|
||||
|
||||
Working with Combine publishers is trivial thanks to [`AnyPublisher.values`][values].
|
||||
|
||||
```swift
|
||||
@preconcurrency 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.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { 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.
|
||||
|
||||
[file a new issue]: https://github.com/samsonjs/AsyncMonitor/issues/new
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
This package is supported on iOS 17.0+ and macOS 14.0+.
|
||||
|
||||
### Xcode
|
||||
|
||||
When you're integrating this into an app with Xcode then go to your project's Package Dependencies and enter the URL `https://github.com/samsonjs/AsyncMonitor` and then go through the usual flow for adding packages.
|
||||
|
||||
### Swift Package Manager (SPM)
|
||||
|
||||
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.3.1"))
|
||||
```
|
||||
|
||||
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
/// 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
|
||||
|
||||
init<AC: AsyncCancellable>(cancellable: AC) {
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
|
|
@ -11,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()
|
||||
}
|
||||
|
|
@ -18,10 +32,10 @@ public class AnyAsyncCancellable: AsyncCancellable {
|
|||
// MARK: Hashable conformance
|
||||
|
||||
public static func == (lhs: AnyAsyncCancellable, rhs: AnyAsyncCancellable) -> Bool {
|
||||
ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(ObjectIdentifier(self))
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
public protocol AsyncCancellable: Hashable {
|
||||
/// 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. Safe to call multiple times.
|
||||
func cancel()
|
||||
|
||||
/// 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>)
|
||||
}
|
||||
|
||||
// MARK: Default implementations
|
||||
|
||||
public extension AsyncCancellable {
|
||||
func store(in set: inout Set<AnyAsyncCancellable>) {
|
||||
set.insert(AnyAsyncCancellable(cancellable: self))
|
||||
|
|
|
|||
112
Sources/AsyncMonitor/AsyncMonitor.docc/AsyncMonitor.md
Normal file
112
Sources/AsyncMonitor/AsyncMonitor.docc/AsyncMonitor.md
Normal 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:)``
|
||||
|
|
@ -1,6 +1,27 @@
|
|||
/// A monitor that observes an asynchronous sequence and invokes the given block for each received element.
|
||||
///
|
||||
/// `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 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 with actor isolation support (iOS 18+).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - 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>,
|
||||
|
|
@ -15,12 +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. Safe to call multiple times and automatically called when deallocated.
|
||||
public func cancel() {
|
||||
task.cancel()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +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 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. 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
|
||||
|
|
@ -6,6 +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 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.
|
||||
///
|
||||
/// - 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() {
|
||||
/// // 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,
|
||||
|
|
@ -17,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,150 @@ public import Foundation
|
|||
extension KeyPath: @unchecked @retroactive Sendable where Value: Sendable {}
|
||||
|
||||
public extension NSObjectProtocol where Self: NSObject {
|
||||
/// 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 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.
|
||||
///
|
||||
/// - 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 = [],
|
||||
changeHandler: @escaping @MainActor (Value) -> Void
|
||||
) -> any AsyncCancellable {
|
||||
options: NSKeyValueObservingOptions = []
|
||||
) -> AsyncStream<Value> {
|
||||
let (stream, continuation) = AsyncStream<Value>.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])
|
||||
}
|
||||
return stream.monitor { @MainActor value in
|
||||
_ = token // keep this alive
|
||||
changeHandler(value)
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
Sources/AsyncMonitor/ValueLocker.swift
Normal file
20
Sources/AsyncMonitor/ValueLocker.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift
Normal file
26
Tests/AsyncMonitorTests/AnyAsyncCancellableTests.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
@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)
|
||||
}
|
||||
}
|
||||
19
Tests/AsyncMonitorTests/AsyncCancellableTests.swift
Normal file
19
Tests/AsyncMonitorTests/AsyncCancellableTests.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import Foundation
|
|||
import Testing
|
||||
@testable import AsyncMonitor
|
||||
|
||||
@MainActor class AsyncMonitorTests {
|
||||
class AsyncMonitorTests {
|
||||
let center = NotificationCenter()
|
||||
let name = Notification.Name("a random notification")
|
||||
|
||||
|
|
@ -10,10 +10,12 @@ import Testing
|
|||
|
||||
@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,21 +23,25 @@ import Testing
|
|||
}
|
||||
|
||||
@Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws {
|
||||
subject = center.notifications(named: name).map(\.name).monitor { receivedName in
|
||||
Issue.record("Called for irrelevant notification \(receivedName)")
|
||||
}
|
||||
Task {
|
||||
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)
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
|
||||
@Test func stopsCallingBlockWhenDeallocated() async throws {
|
||||
subject = center.notifications(named: name).map(\.name).monitor { _ in
|
||||
Issue.record("Called after deallocation")
|
||||
}
|
||||
@Test @MainActor func stopsCallingBlockWhenDeallocated() async throws {
|
||||
subject = center.notifications(named: name)
|
||||
.map(\.name)
|
||||
.monitor { _ in
|
||||
Issue.record("Called after deallocation")
|
||||
}
|
||||
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
subject = nil
|
||||
center.post(name: name, object: nil)
|
||||
}
|
||||
|
|
@ -43,15 +49,16 @@ import Testing
|
|||
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)?
|
||||
|
||||
@MainActor 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).map(\.name)
|
||||
cancellable = center.notifications(named: name)
|
||||
.map(\.name)
|
||||
.monitor(context: self) { _, _ in }
|
||||
}
|
||||
|
||||
|
|
@ -71,16 +78,36 @@ import Testing
|
|||
}
|
||||
}
|
||||
|
||||
final class SendableObject: NSObject, Sendable {}
|
||||
|
||||
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
|
||||
var context: NSObject? = NSObject()
|
||||
subject = center.notifications(named: name).map(\.name)
|
||||
var context: SendableObject? = SendableObject()
|
||||
subject = center.notifications(named: name)
|
||||
.map(\.name)
|
||||
.monitor(context: context!) { context, receivedName in
|
||||
Issue.record("Called after context was deallocated")
|
||||
}
|
||||
context = nil
|
||||
Task {
|
||||
Task { [center, name] in
|
||||
center.post(name: name, object: nil)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
Tests/AsyncMonitorTests/AsyncSequence+Just.swift
Normal file
9
Tests/AsyncMonitorTests/AsyncSequence+Just.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
53
Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift
Normal file
53
Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
@testable import AsyncMonitor
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
class AsyncKVOTests {
|
||||
var subject: Progress? = Progress(totalUnitCount: 42)
|
||||
var cancellable: (any AsyncCancellable)?
|
||||
|
||||
@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]()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,26 @@
|
|||
import Foundation
|
||||
@testable import AsyncMonitor
|
||||
|
||||
@MainActor class SimplestVersion {
|
||||
// MARK: Basics
|
||||
|
||||
extension Notification: @unchecked @retroactive Sendable {}
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor class WithContext {
|
||||
var cancellables = Set<AnyAsyncCancellable>()
|
||||
final class WithContext: Sendable {
|
||||
nonisolated(unsafe) var cancellables: Set<AnyAsyncCancellable> = []
|
||||
|
||||
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 +30,33 @@ import Foundation
|
|||
print("The date is now \(Date.now)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Combine
|
||||
|
||||
@preconcurrency 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.monitorValues(for: \.fractionCompleted, options: [.initial, .new]) { fraction in
|
||||
print("Progress is \(fraction.formatted(.percent))%")
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
Tests/AsyncMonitorTests/TestCancellable.swift
Normal file
20
Tests/AsyncMonitorTests/TestCancellable.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue