├── AsyncCombine.png ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Tests └── AsyncCombineTests │ ├── TestingToolbox │ ├── TestError.swift │ ├── Label.swift │ ├── CancelProbe.swift │ ├── RecordingBox.swift │ ├── AsyncBox.swift │ ├── AsyncStream+MakeStream.swift │ ├── AsyncThrowingStream+MakeStream.swift │ ├── AsyncSequence+Collect.swift │ └── CancellableTask.swift │ ├── Testing │ ├── RecordingErrorTests.swift │ ├── AsyncSequence+FirstTests.swift │ └── RecorderTests.swift │ ├── Core │ └── CurrentValueRelayTests.swift │ ├── Operators │ ├── AsyncCombine+CombineTests.swift │ ├── AsyncSequence+SinkTests.swift │ ├── AsyncSequence+AssignTests.swift │ ├── AsyncSequenceSinkOnMainTests.swift │ └── Observable+ObservedTests.swift │ └── Subscriptions │ ├── Task+StoreTests.swift │ └── Set+CancelTests.swift ├── Sources └── AsyncCombine │ ├── Types │ ├── Boxes │ │ ├── NonSendableBox.swift │ │ ├── RepeaterBox.swift │ │ └── WeakBox.swift │ ├── SubscriptionTask.swift │ └── Receive.swift │ ├── Testing │ ├── RecordingError.swift │ ├── AsyncSequence+First.swift │ └── Recorder.swift │ ├── Subscriptions │ ├── Task+Store.swift │ └── Set+Cancel.swift │ ├── Operators │ ├── Observable+CurrentValueRelay.swift │ ├── AsyncSequence+Assign.swift │ ├── AsyncSequence+Sink.swift │ ├── Observable+Observed.swift │ └── AsyncCombine+Combine.swift │ └── Core │ └── CurrentValueRelay.swift ├── LICENSE.txt ├── .github └── workflows │ ├── linux-tests.yml │ └── macos-tests.yml ├── Package.swift ├── Package.resolved ├── .gitignore ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── README.md /AsyncCombine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/will-lumley/AsyncCombine/HEAD/AsyncCombine.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/TestError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestError.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 23/9/2025. 6 | // 7 | 8 | enum TestError: Error, Hashable { 9 | case boom 10 | } 11 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Types/Boxes/NonSendableBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NonSendableBox.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | 9 | final class NonSendableBox: @unchecked Sendable { 10 | let value: T 11 | init(_ v: T) { 12 | self.value = v 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Types/Boxes/RepeaterBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepeaterBox.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | // A tiny trampoline so the @Sendable onChange closure doesn't capture a local function. 9 | final class RepeaterBox: @unchecked Sendable { 10 | var call: (@MainActor () -> Void)? 11 | } 12 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/Label.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Label.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 23/9/2025. 6 | // 7 | 8 | import Observation 9 | 10 | /// A simple UI-like target that must be mutated on the main actor. 11 | @MainActor 12 | @Observable 13 | final class Label: @unchecked Sendable { 14 | var text: String = "" 15 | var error: Error? = nil 16 | } 17 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/CancelProbe.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancelProbe.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | 9 | actor CancelProbe { 10 | private(set) var didCancel = false 11 | 12 | func mark() { 13 | self.didCancel = true 14 | } 15 | 16 | func wasCancelled() -> Bool { 17 | return self.didCancel 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Types/Boxes/WeakBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeakBox.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | // Helpers used only to make @Sendable captures safe. 9 | // We only *use* these contents on MainActor. 10 | final class WeakBox: @unchecked Sendable { 11 | weak var value: T? 12 | init(_ v: T) { 13 | self.value = v 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/RecordingBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Recorder.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | /// Thread-safe value recorder for test assertions. 9 | actor RecordingBox { 10 | 11 | private var values: [T] = [] 12 | 13 | func append(_ value: T) { 14 | self.values.append(value) 15 | } 16 | 17 | func snapshot() -> [T] { 18 | return self.values 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/AsyncBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncBox.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | /// Minimal async box to pass values across tasks in tests. 9 | actor AsyncBox { 10 | private var value: T? 11 | 12 | init(_ initial: T? = nil) { 13 | self.value = initial 14 | } 15 | 16 | func set(_ newValue: T) { 17 | self.value = newValue 18 | } 19 | 20 | func get() -> T? { 21 | return self.value 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Testing/RecordingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingError.swift 3 | // AsyncCombineAsyncCombineTesting 4 | // 5 | // Created by William Lumley on 7/10/2025. 6 | // 7 | 8 | public enum RecordingError: Error, Sendable { 9 | case timeout 10 | case sourceEnded 11 | 12 | var description: String { 13 | switch self { 14 | case .timeout: 15 | "Recorder timed out waiting for next()" 16 | case .sourceEnded: 17 | "Recorder's source ended before a value was received" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Testing/RecordingErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingError.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 7/10/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Testing 10 | 11 | @Suite("RecordingError") 12 | struct RecordingErrorTests { 13 | 14 | @Test("Description") 15 | func description() { 16 | #expect(RecordingError.timeout.description == "Recorder timed out waiting for next()") 17 | #expect(RecordingError.sourceEnded.description == "Recorder's source ended before a value was received") 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/AsyncStream+MakeStream.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStream+MakeStream.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | extension AsyncStream { 9 | 10 | /// Convenience to create a stream + continuation pair. 11 | static func makeStream() -> ( 12 | AsyncStream, 13 | AsyncStream.Continuation 14 | ) { 15 | var continuation: AsyncStream.Continuation! 16 | 17 | let stream = AsyncStream { 18 | continuation = $0 19 | } 20 | 21 | return (stream, continuation) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/AsyncThrowingStream+MakeStream.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncThrowingStream+MakeStream.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | extension AsyncThrowingStream where Failure == Error { 9 | 10 | /// Convenience to create a throwing stream + continuation pair. 11 | static func makeStream() -> ( 12 | AsyncThrowingStream, 13 | AsyncThrowingStream< 14 | Element, 15 | Failure 16 | >.Continuation 17 | ) { 18 | var continuation: AsyncThrowingStream.Continuation! 19 | let stream = AsyncThrowingStream { 20 | continuation = $0 21 | } 22 | return (stream, continuation) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Will Lumley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Subscriptions/Task+Store.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+Store.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | public extension Task where Success == Void, Failure == Never { 9 | 10 | /// Stores this subscription task in the given set, keeping it alive 11 | /// for as long as the set retains it. 12 | /// 13 | /// Use this to manage the lifetime of multiple subscriptions in one place. 14 | /// When you later call ``Set/cancelAll()`` or remove the task from the set, 15 | /// the subscription is cancelled. 16 | /// 17 | /// - Parameter set: The set to insert this subscription task into. 18 | /// 19 | /// ## Example 20 | /// ```swift 21 | /// var subscriptions = Set() 22 | /// 23 | /// relay.stream() 24 | /// .sink { value in 25 | /// print("Got:", value) 26 | /// } 27 | /// .store(in: &subscriptions) 28 | /// 29 | /// // The subscription stays active until: 30 | /// subscriptions.cancelAll() 31 | /// ``` 32 | func store(in set: inout Set) { 33 | set.insert(self) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/AsyncSequence+Collect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+Collect.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | public extension AsyncSequence where Element: Sendable { 9 | 10 | /// Collect exactly 1 elements from this sequence. 11 | /// Uses `prefix(_:)` so it won’t hang on infinite streams. 12 | /// 13 | /// - Important: This is only intended to be used in testing. 14 | @inlinable 15 | func collect() async rethrows -> Element? { 16 | var output: [Element] = [] 17 | for try await value in self.prefix(1) { 18 | output.append(value) 19 | } 20 | return output.first 21 | } 22 | 23 | /// Collect exactly `count` elements from this sequence. 24 | /// Uses `prefix(_:)` so it won’t hang on infinite streams. 25 | /// 26 | /// - Important: This is only intended to be used in testing. 27 | @inlinable 28 | func collect(count: Int) async rethrows -> [Element] { 29 | var output: [Element] = [] 30 | for try await value in self.prefix(count) { 31 | output.append(value) 32 | } 33 | return output 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Types/SubscriptionTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscriptionTask.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | /// A cancellable task representing an active subscription to an `AsyncSequence`. 9 | /// 10 | /// `SubscriptionTask` is returned by operators such as 11 | /// ``AsyncSequence/sink(catching:_:)`` and 12 | /// ``AsyncSequence/assign(to:on:catching:)``. 13 | /// Store these tasks in a collection (for example, a `Set`) 14 | /// to keep the subscription alive, and cancel them when you no longer 15 | /// need to receive values. 16 | /// 17 | /// ```swift 18 | /// let relay = CurrentValueRelay(0) 19 | /// var subscriptions = Set() 20 | /// 21 | /// relay.stream() 22 | /// .sink { value in 23 | /// print("Got:", value) 24 | /// } 25 | /// .store(in: &subscriptions) 26 | /// 27 | /// relay.send(1) // Prints "Got: 1" 28 | /// ``` 29 | /// 30 | /// Cancel the task to stop the subscription: 31 | /// 32 | /// ```swift 33 | /// subscriptions.first?.cancel() 34 | /// ``` 35 | /// 36 | /// - Note: This is a convenience alias for ``Task`` with the signature 37 | /// `Task`. 38 | public typealias SubscriptionTask = Task 39 | -------------------------------------------------------------------------------- /.github/workflows/linux-tests.yml: -------------------------------------------------------------------------------- 1 | name: "[Linux] Unit Tests" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | concurrency: 9 | group: linux-${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build-and-test: 14 | runs-on: ubuntu-24.04 15 | container: swift:6.2-jammy 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | # (Optional) You chose to skip Linux caching. You can add it back later. 22 | 23 | - name: Resolve dependencies 24 | run: swift package resolve 25 | 26 | - name: Build 27 | run: swift build -v 28 | 29 | - name: Test (parallel, with coverage) 30 | run: swift test --parallel --enable-code-coverage 31 | 32 | - name: Collect coverage (LLVM JSON) 33 | if: always() 34 | run: | 35 | mkdir -p coverage 36 | llvm-profdata merge -sparse .build/**/*.profdata -o coverage/coverage.profdata || true 37 | cp -R .build/*/codecov* coverage/ 2>/dev/null || true 38 | 39 | - name: Upload coverage artifact 40 | if: always() 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: coverage-linux-swift-6.0 44 | path: coverage 45 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.2 2 | 3 | import PackageDescription 4 | 5 | let dependencies: [PackageDescription.Package.Dependency] = [ 6 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.4"), 7 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.3") 8 | ] 9 | 10 | let targetDependencies: [Target.Dependency] = [ 11 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 12 | ] 13 | 14 | let plugins: [Target.PluginUsage] = [ 15 | 16 | ] 17 | 18 | let package = Package( 19 | name: "AsyncCombine", 20 | platforms: [ 21 | .iOS(.v17), 22 | .macOS(.v13), 23 | .watchOS(.v8), 24 | .tvOS(.v15) 25 | ], 26 | products: [ 27 | .library( 28 | name: "AsyncCombine", 29 | targets: [ 30 | "AsyncCombine" 31 | ] 32 | ) 33 | ], 34 | dependencies: dependencies, 35 | targets: [ 36 | .target( 37 | name: "AsyncCombine", 38 | dependencies: targetDependencies, 39 | plugins: plugins 40 | ), 41 | 42 | .testTarget( 43 | name: "AsyncCombineTests", 44 | dependencies: [ 45 | "AsyncCombine" 46 | ], 47 | plugins: plugins 48 | ) 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Operators/Observable+CurrentValueRelay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Observable+CurrentValueRelay.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 18/9/2025. 6 | // 7 | 8 | import Observation 9 | 10 | @available(iOS 17.0, macOS 14.0, watchOS 10, *) 11 | public extension Observable where Self: AnyObject { 12 | 13 | /// Zero-argument bridge: returns a `CurrentValueRelay` seeded from the first emission, 14 | /// plus the pumping task you should store to keep the bridge alive. 15 | /// 16 | /// This avoids “double initial” issues because your `observed(_:)` already replays 17 | /// the current value as the first element. 18 | func observedRelay( 19 | _ keyPath: KeyPath 20 | ) -> CurrentValueRelay { 21 | // Seed from the current value synchronously on the main actor 22 | let relay = CurrentValueRelay(self[keyPath: keyPath]) 23 | 24 | // Start forwarding changes; keep the task alive by attaching it to the relay 25 | let stream = observed(keyPath) 26 | let feed: SubscriptionTask = Task { 27 | for await value in stream { 28 | await relay.send(value) 29 | } 30 | } 31 | 32 | Task { 33 | await relay.attach(feed: feed) 34 | } 35 | return relay 36 | 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Subscriptions/Set+Cancel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+Cancel.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | public extension Set where Element == SubscriptionTask { 9 | 10 | /// Cancels all subscription tasks in the set and removes them. 11 | /// 12 | /// This is a convenience for tearing down multiple active subscriptions at once. 13 | /// Each task in the set is cancelled, and the set is then emptied. 14 | /// 15 | /// - Note: Cancelling a subscription task stops the underlying 16 | /// `AsyncSequence` iteration and prevents further values from being delivered. 17 | /// 18 | /// ## Example 19 | /// ```swift 20 | /// var subscriptions = Set() 21 | /// 22 | /// relay.stream() 23 | /// .sink { value in print("Got:", value) } 24 | /// .store(in: &subscriptions) 25 | /// 26 | /// relay.stream() 27 | /// .assign(to: \.text, on: label) 28 | /// .store(in: &subscriptions) 29 | /// 30 | /// // Later, when cleaning up: 31 | /// subscriptions.cancelAll() 32 | /// // All active subscriptions are cancelled and the set is empty. 33 | /// ``` 34 | mutating func cancelAll() { 35 | for task in self { 36 | task.cancel() 37 | } 38 | 39 | self.removeAll() 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "dc315f1e683edf304b0d76c185c6b34e54ede9fc39a8c2c6f3c89f514b80ab3f", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-async-algorithms", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-async-algorithms", 8 | "state" : { 9 | "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", 10 | "version" : "1.0.4" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-collections", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-collections.git", 17 | "state" : { 18 | "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", 19 | "version" : "1.2.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-docc-plugin", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-docc-plugin", 26 | "state" : { 27 | "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", 28 | "version" : "1.4.5" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-docc-symbolkit", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 35 | "state" : { 36 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 37 | "version" : "1.0.0" 38 | } 39 | } 40 | ], 41 | "version" : 3 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/macos-tests.yml: -------------------------------------------------------------------------------- 1 | name: "[macOS] Unit Tests" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | concurrency: 9 | group: macos-${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build-and-test: 14 | runs-on: macos-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Cache SPM (macOS) 21 | uses: actions/cache@v4 22 | with: 23 | path: | 24 | .build 25 | ~/.swiftpm 26 | key: ${{ runner.os }}-swift-6.2-${{ hashFiles('**/Package.swift', '**/Package.resolved') }} 27 | restore-keys: | 28 | ${{ runner.os }}-swift-6.2- 29 | ${{ runner.os }}-swift- 30 | 31 | - name: Set up Swift 6.2 32 | uses: swift-actions/setup-swift@refs/pull/771/head 33 | with: 34 | swift-version: "6.2" 35 | 36 | - name: Resolve dependencies 37 | run: swift package resolve 38 | 39 | - name: Build 40 | run: swift build -v 41 | 42 | - name: Test (parallel, with coverage) 43 | run: swift test --parallel --enable-code-coverage 44 | 45 | - name: Collect coverage (LLVM JSON) 46 | if: always() 47 | run: | 48 | mkdir -p coverage 49 | llvm-profdata merge -sparse .build/**/*.profdata -o coverage/coverage.profdata || true 50 | cp -R .build/*/codecov* coverage/ 2>/dev/null || true 51 | 52 | - name: Upload coverage artifact 53 | if: always() 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: coverage-macos-swift-6.0 57 | path: coverage 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output 63 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Testing/AsyncSequence+First.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+First.swift 3 | // AsyncCombineTesting 4 | // 5 | // Created by William Lumley on 6/10/2025. 6 | // 7 | 8 | public extension AsyncSequence where Element: Equatable & Sendable { 9 | 10 | /// Returns the first element equal to `value`, or `nil` if the sequence finishes first. 11 | @inlinable 12 | func first( 13 | equalTo value: Element 14 | ) async rethrows -> Element? { 15 | // Explicitly-typed @Sendable predicate to satisfy Swift 6. 16 | let predicate: @Sendable (Element) -> Bool = { $0 == value } 17 | return try await self.first(where: predicate) 18 | } 19 | 20 | } 21 | 22 | @available(iOS 16.0, macOS 13.0, *) 23 | public extension AsyncSequence where Element: Equatable & Sendable, Self: Sendable { 24 | /// Like above, but gives up after `timeout` and returns `nil`. 25 | @inlinable 26 | func first( 27 | equalTo value: Element, 28 | timeout: Duration, 29 | clock: ContinuousClock = .init() 30 | ) async -> Element? { 31 | let predicate: @Sendable (Element) -> Bool = { $0 == value } 32 | 33 | // Race the match against the timeout; whichever finishes first wins. 34 | return await withTaskGroup(of: Element?.self) { group in 35 | group.addTask { 36 | do { 37 | return try await self.first(where: predicate) 38 | } catch { 39 | // Swallow possible CancellationError, just end early 40 | return nil 41 | } 42 | } 43 | group.addTask { 44 | try? await clock.sleep(for: timeout) 45 | return nil 46 | } 47 | 48 | let result = await group.next() ?? nil 49 | group.cancelAll() 50 | 51 | return result 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/TestingToolbox/CancellableTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancellableTask.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | 11 | /// Owns a long-lived cancellable task that stays alive until cancelled, 12 | /// then marks the provided `CancelProbe`. 13 | actor CancellationSentinel { 14 | 15 | // MARK: - Properties 16 | 17 | private let probe: CancelProbe 18 | private var task: SubscriptionTask? 19 | 20 | // MARK: - Lifecycle 21 | 22 | init(probe: CancelProbe) { 23 | self.probe = probe 24 | } 25 | 26 | deinit { 27 | self.task?.cancel() 28 | } 29 | 30 | } 31 | 32 | // MARK: - Public 33 | 34 | extension CancellationSentinel { 35 | 36 | /// Starts (or restarts) the sentinel task. 37 | /// - Returns: The underlying `SubscriptionTask` so you can `.store(in:)` 38 | /// if desired. 39 | @discardableResult 40 | func start() -> SubscriptionTask { 41 | // Cancel any previous run before starting anew. 42 | self.task?.cancel() 43 | 44 | let newTask: SubscriptionTask = Task { 45 | await withTaskCancellationHandler { 46 | // Stay alive until cancelled, but don't spin. 47 | while Task.isCancelled == false { 48 | // ~20ms tick to avoid a tight loop. 49 | try? await Task.sleep(nanoseconds: 20_000_000) 50 | } 51 | } onCancel: { 52 | // onCancel must be synchronous — hop to an async context. 53 | Task { [probe] in 54 | await probe.mark() 55 | } 56 | } 57 | } 58 | 59 | self.task = newTask 60 | return newTask 61 | } 62 | 63 | /// Cancels the sentinel if running. 64 | func cancel() { 65 | self.task?.cancel() 66 | self.task = nil 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to AsyncCombine 2 | 3 | Thank you for your interest in contributing to AsyncCombine! We appreciate your help in making this project better. 4 | 5 | ## How to Contribute 6 | 7 | ### Reporting Issues 8 | 9 | If you've found a bug or an issue, please submit it [here](https://github.com/will-lumley/AsyncCombine/issues). When submitting an issue, include the following: 10 | - A clear and descriptive title. 11 | - Steps to reproduce the issue. 12 | - Expected vs. actual results. 13 | - Any relevant logs, screenshots, or environment details. 14 | 15 | ### Suggesting Features 16 | 17 | We welcome feature requests! When suggesting a feature, include: 18 | - A detailed description of the feature. 19 | - Why it would be useful. 20 | - Any potential drawbacks. 21 | 22 | ### Pull Requests 23 | 24 | To submit a pull request (PR), please follow these steps: 25 | 1. Fork the repo and create your branch: 26 | ```bash 27 | git checkout -b feature/my-new-feature 28 | ``` 29 | 2. Ensure your code passes all tests and doesn't raise any SwiftLint warnings. 30 | 3. Submit your PR, and link to any relevant issues or discussions. 31 | 32 | ## Coding Guidelines 33 | 34 | - **Code Style**: Please follow Swift best practices and use meaningful names for variables and functions. 35 | - **Tests**: Ensure that your code is covered by unit tests and that they all pass before submitting. 36 | - **Documentation**: Update or create documentation for any new features or changes. 37 | 38 | ## License 39 | 40 | By contributing to FaviconFinder, you agree that your contributions will be licensed under the MIT license. 41 | 42 | ## Code of Conduct 43 | 44 | The Code of Conduct governs how we behave in public or in private whenever the project will be judged by our actions. We expect it to be honored by everyone who contributes to this project. 45 | 46 | See [CONDUCT.md](https://github.com/will-lumley/AsyncCombine/CONDUCT.md) for details. 47 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The Code of Conduct governs how we behave in public or in private 4 | whenever the project will be judged by our actions. 5 | We expect it to be honored by everyone who represents the project 6 | officially or informally, 7 | claims affiliation with the project, 8 | or participates directly. 9 | 10 | We strive to: 11 | 12 | * **Be open**: We invite anybody to participate in any aspect of our projects. 13 | Our community is open, and any responsibility can be carried 14 | by any contributor who demonstrates the required capacity and competence. 15 | * **Be empathetic**: We work together to resolve conflict, 16 | assume good intentions, 17 | and do our best to act in an empathic fashion. 18 | By understanding that humanity drops a few packets in online interactions, 19 | and adjusting accordingly, 20 | we can create a comfortable environment for everyone to share their ideas. 21 | * **Be collaborative**: We prefer to work transparently 22 | and to involve interested parties early on in the process. 23 | Wherever possible, we work closely with others in the open source community 24 | to coordinate our efforts. 25 | * **Be decisive**: We expect participants in the project to resolve disagreements constructively. 26 | When they cannot, we escalate the matter to structures 27 | with designated leaders to arbitrate and provide clarity and direction. 28 | * **Be responsible**: We hold ourselves accountable for our actions. 29 | When we make mistakes, we take responsibility for them. 30 | When we need help, we reach out to others. 31 | When it comes time to move on from a project, 32 | we take the proper steps to ensure that others can pick up where we left off. 33 | 34 | This code is not exhaustive or complete. 35 | It serves to distill our common understanding of a 36 | collaborative, shared environment and goals. 37 | We expect it to be followed in spirit as much as in the letter. 38 | 39 | --- 40 | 41 | *Some of the ideas and wording for the statements above were based on work by [Alamofire](https://github.com/Alamofire/Foundation/blob/master/CONDUCT.md), [Mozilla](https://wiki.mozilla.org/Code_of_Conduct/Draft), [Ubuntu](http://www.ubuntu.com/about/about-ubuntu/conduct), and [Twitter](https://github.com/twitter/code-of-conduct). We thank them for their work and contributions to the open source community.* 42 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Types/Receive.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | /// A closure type that asynchronously handles elements emitted by an `AsyncSequence`. 9 | /// 10 | /// Used by operators like ``AsyncSequence/sink(catching:_:)`` to process 11 | /// each value produced by the sequence. 12 | /// 13 | /// - Parameter element: The element emitted by the sequence. 14 | public typealias ReceiveElement = @Sendable (Element) async -> Void 15 | 16 | /// A closure type that handles failures thrown during iteration of an `AsyncSequence`. 17 | /// 18 | /// This is generic over the failure type, mirroring Combine-style APIs so you 19 | /// can specialize error handling when you have a concrete error type. 20 | /// 21 | /// - Parameter failure: The error thrown by the sequence. 22 | public typealias ReceiveError = @Sendable (Failure) -> Void 23 | 24 | /// A closure type that is invoked when an `AsyncSequence` finishes successfully. 25 | /// 26 | /// Used by operators like ``AsyncSequence/sink(catching:finished:_:)`` to provide 27 | /// a notification once the sequence has emitted all of its elements without error. 28 | /// 29 | /// This closure is executed only once, after iteration ends normally. If the sequence 30 | /// terminates due to cancellation or an error, the closure will not be called. 31 | /// To handle errors, use ``ReceiveError`` instead. 32 | /// 33 | /// The closure is `async` so you can perform asynchronous work when responding 34 | /// to sequence completion (for example, saving state or updating UI). 35 | /// 36 | /// ## Example 37 | /// 38 | /// ```swift 39 | /// let relay = CurrentValueRelay(0) 40 | /// var subscriptions = Set() 41 | /// 42 | /// relay.stream() 43 | /// .sink( 44 | /// finished: { 45 | /// await print("Sequence finished!") 46 | /// }, 47 | /// { value in 48 | /// await print("Got value:", value) 49 | /// } 50 | /// ) 51 | /// .store(in: &subscriptions) 52 | /// ``` 53 | public typealias ReceiveFinished = @Sendable () async -> Void 54 | 55 | /// This is the same as the `ReceiveElement` alias but only executes on the 56 | /// main thread. 57 | public typealias MainActorReceiveElement = @MainActor @Sendable (Element) async -> Void 58 | 59 | /// This is the same as the `ReceiveError` alias but only executes on the 60 | /// main thread. 61 | public typealias MainActorReceiveError = @MainActor @Sendable (Failure) -> Void 62 | 63 | /// This is the same as the `ReceiveFinished` alias but only executes on the 64 | /// main thread. 65 | public typealias MainActorReceiveFinished = @MainActor @Sendable () async -> Void 66 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Core/CurrentValueRelayTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentValueRelayTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 15/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | import Testing 11 | 12 | @Suite("CurrentValueRelayTests", .timeLimit(.minutes(1))) 13 | struct CurrentValueRelayTests { 14 | 15 | @Test("Replays Initial Value to a New Subscriber") 16 | func replayInitialValue() async { 17 | // GIVEN we have a relay of 42 18 | let relay = CurrentValueRelay(42) 19 | 20 | // WHEN we subscribe to the relay 21 | let stream = relay.stream() 22 | 23 | // THEN we should immediately receive the value of 42 24 | #expect(await stream.collect() == 42) 25 | } 26 | 27 | @Test("Emits Subsequent Updates to Existing Subscribers") 28 | func emitsSubsequentUpdates() async { 29 | // GIVEN we have a relay of 0 30 | let relay = CurrentValueRelay(0) 31 | 32 | // WHEN we subscribe to the relay 33 | let stream = relay.stream() 34 | 35 | try? await Task.sleep(nanoseconds: 40_000_000) 36 | 37 | // WHEN we send through 1 and 2 38 | await relay.send(1) 39 | await relay.send(2) 40 | 41 | // THEN we should receive 1, 2, and 3 42 | let values = await stream.collect(count: 3) 43 | #expect(values == [0, 1, 2]) 44 | } 45 | 46 | @Test("Replays the Latest Value to Late Subscribers (replay 1 semantics)") 47 | func replaysLatestToLateSubscriber() async { 48 | let relay = CurrentValueRelay("A") 49 | 50 | // Advance state before anyone subscribes 51 | await relay.send("B") 52 | await relay.send("C") 53 | 54 | // New subscriber should immediately get "C" 55 | let value = await relay.stream().collect() 56 | #expect(value == "C") 57 | } 58 | 59 | @Test("Multicasts the Same Updates to Multiple Subscribers") 60 | func multicastsToMultipleSubscribers() async { 61 | let relay = CurrentValueRelay(10) 62 | 63 | // Two independent subscribers 64 | let stream1 = relay.stream() 65 | let stream2 = relay.stream() 66 | 67 | try? await Task.sleep(nanoseconds: 40_000_000) 68 | 69 | // Push two updates 70 | await relay.send(11) 71 | await relay.send(12) 72 | 73 | // Each should see: initial 10, then 11, 12 74 | let aValues = await stream1.collect(count: 3) 75 | let bValues = await stream2.collect(count: 3) 76 | 77 | #expect(aValues == [10, 11, 12]) 78 | #expect(bValues == [10, 11, 12]) 79 | } 80 | 81 | @Test("ValueStorage Reflects the Latest Sent Value") 82 | func valueStorageTracksLatest() async { 83 | let relay = CurrentValueRelay(5) 84 | #expect(await relay.valueStorage == 5) 85 | 86 | await relay.send(9) 87 | #expect(await relay.valueStorage == 9) 88 | 89 | await relay.send(13) 90 | #expect(await relay.valueStorage == 13) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Operators/AsyncCombine+CombineTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineLatestTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 29/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Testing 10 | 11 | @Suite("AsyncCombine.Combine") 12 | struct AsyncCombineCombineTests { 13 | 14 | // Small helper to hand-drive AsyncStreams from a test 15 | private struct StreamHandle { 16 | let stream: AsyncStream 17 | let yield: (Element) -> Void 18 | let finish: () -> Void 19 | 20 | init() { 21 | var continuation: AsyncStream.Continuation! 22 | self.stream = AsyncStream { c in continuation = c } 23 | self.yield = { continuation.yield($0) } 24 | self.finish = { continuation.finish() } 25 | } 26 | } 27 | 28 | // MARK: - Tests 29 | 30 | @Test("Emits only after both have an initial value, then on each subsequent change") 31 | func emitsAfterPrimingThenLatest() async throws { 32 | // GIVEN two controllable streams 33 | let stream1 = StreamHandle() 34 | let stream2 = StreamHandle() 35 | 36 | // WHEN we combine them 37 | let combined = AsyncCombine.CombineLatest(stream1.stream, stream2.stream) 38 | 39 | var it = combined.makeAsyncIterator() 40 | 41 | // (No emission yet because only one side has produced) 42 | stream1.yield(1) 43 | 44 | // Prime the second; first emission now available 45 | stream2.yield("a") 46 | let value1 = await it.next() 47 | #expect(value1?.0 == 1 && value1?.1 == "a") 48 | 49 | // Change first only -> emits (latestFirst, latestSecond) 50 | stream1.yield(2) 51 | let value2 = await it.next() 52 | #expect(value2?.0 == 2 && value2?.1 == "a") 53 | 54 | // Change second only 55 | stream2.yield("b") 56 | let value3 = await it.next() 57 | #expect(value3?.0 == 2 && value3?.1 == "b") 58 | 59 | // THEN the sequence so far is [(1,"a"), (2,"a"), (2,"b")] 60 | // (Already asserted step-by-step above) 61 | } 62 | 63 | @Test("Finishes only when both upstreams finish") 64 | func finishesWhenAllFinish() async { 65 | // GIVEN two controllable streams, already primed so we can iterate 66 | let stream1 = StreamHandle() 67 | let stream2 = StreamHandle() 68 | let combined = AsyncCombine.CombineLatest( 69 | stream1.stream, 70 | stream2.stream 71 | ) 72 | 73 | var it = combined.makeAsyncIterator() 74 | 75 | stream1.yield(10) 76 | stream2.yield(20) 77 | _ = await it.next() // consume initial (10,20) 78 | 79 | // WHEN only the first finishes 80 | stream1.finish() 81 | 82 | // THEN the combined stream should NOT finish yet 83 | // (we can still get new tuples if s2 changes) 84 | stream2.yield(21) 85 | guard let value = await it.next() else { 86 | Issue.record("Iterator should have returned a value") 87 | return 88 | } 89 | 90 | #expect(value == (10, 21)) 91 | 92 | // WHEN the second now finishes 93 | stream2.finish() 94 | 95 | // THEN the combined stream finishes (iterator returns nil) 96 | let end = await it.next() 97 | #expect(end == nil) 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Operators/AsyncSequence+Assign.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+Assign.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | public extension AsyncSequence where Element: Sendable, Self: Sendable { 9 | 10 | /// Assigns each element from the sequence to the given writable key path on an object, 11 | /// returning a cancellable task that manages the subscription’s lifetime. 12 | /// 13 | /// This mirrors Combine’s `assign(to:on:)`. Each emitted element 14 | /// updates `object[keyPath:]`. 15 | /// Updates are performed on the **main actor** to keep UI mutations safe. 16 | /// 17 | /// - Parameters: 18 | /// - keyPath: A writable reference key path on `object` to receive each element. 19 | /// - object: The target object whose property will be updated for every value. 20 | /// - receiveError: A closure invoked if the sequence throws an error (other than 21 | /// cancellation). Defaults to a no-op. 22 | /// - Returns: A ``SubscriptionTask`` you can store (eg. in 23 | /// a `Set`). and cancel to stop receiving values. 24 | /// 25 | /// - Important: Property writes occur on the main actor. This makes it suitable for 26 | /// updating UI objects like `UILabel` or `NSView` subclasses. 27 | /// - Note: Cancelling the returned task stops iteration and further assignments. 28 | /// - SeeAlso: ``AsyncSequence/sink(catching:_:)`` 29 | /// 30 | /// ## Example 31 | /// ```swift 32 | /// final class ViewHolder { 33 | /// let label = UILabel() 34 | /// } 35 | /// 36 | /// let holder = ViewHolder() 37 | /// var subscriptions = Set() 38 | /// 39 | /// relay.stream() // AsyncSequence 40 | /// .assign( 41 | /// to: \.text, // ReferenceWritableKeyPath 42 | /// on: holder.label 43 | /// ) 44 | /// .store(in: &subscriptions) 45 | /// 46 | /// relay.send("Hello") // label.text becomes "Hello" on main actor 47 | /// ``` 48 | /// 49 | /// ## Error Handling 50 | /// If the sequence throws, `receiveError` is called: 51 | /// ```swift 52 | /// stream.assign(to: \.text, on: holder.label) { error in 53 | /// print("Assignment failed:", error) 54 | /// } 55 | /// ``` 56 | func assign( 57 | to keyPath: ReferenceWritableKeyPath, 58 | on object: Root, 59 | catching receiveError: @escaping ReceiveError = { _ in } 60 | ) -> SubscriptionTask { 61 | let kp = NonSendableBox(keyPath) 62 | 63 | return self.sink(catching: receiveError) { [weak object, kp] value in 64 | guard let object else { return } 65 | await MainActor.run { 66 | object[keyPath: kp.value] = value 67 | } 68 | } 69 | } 70 | 71 | /* 72 | func assignOnMain( 73 | to keyPath: ReferenceWritableKeyPath, 74 | on object: Root, 75 | catching receiveError: @escaping MainActorReceiveError = { _ in } 76 | ) -> SubscriptionTask { 77 | let kp = NonSendableBox(keyPath) 78 | 79 | return self.sinkOnMain(catching: receiveError) { [weak object, kp] value in 80 | guard let object else { 81 | return 82 | } 83 | object[keyPath: kp.value] = value 84 | } 85 | } 86 | */ 87 | 88 | } 89 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Subscriptions/Task+StoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+StoreTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | import Testing 11 | 12 | @Suite("Task+Store Tests") 13 | struct TaskStoreInTests { 14 | 15 | @Test("Inserts Into the Set and Cancels via cancelAll()") 16 | func insertsAndCancelsAll() async { 17 | // GIVEN a sentinel task stored in a set 18 | let probe = CancelProbe() 19 | let sentinel = CancellationSentinel(probe: probe) 20 | 21 | var subscriptions = Set() 22 | await sentinel.start() 23 | .store(in: &subscriptions) 24 | #expect(subscriptions.count == 1) 25 | 26 | // WHEN we cancel everything retained by the set 27 | subscriptions.cancelAll() 28 | 29 | // THEN the set is emptied and the task was cancelled 30 | #expect(subscriptions.isEmpty) 31 | 32 | try? await Task.sleep(nanoseconds: 80_000_000) 33 | #expect(await probe.wasCancelled()) 34 | } 35 | 36 | @Test("Removing a Stored Task Simply Forgets It (no cancel)") 37 | func removingSingleTaskDoesNotCancel() async { 38 | // GIVEN a sentinel task stored in a set 39 | let probe = CancelProbe() 40 | let sentinel = CancellationSentinel(probe: probe) 41 | let task = await sentinel.start() 42 | 43 | var subscriptions = Set() 44 | task.store(in: &subscriptions) 45 | 46 | // WHEN we remove it from the set (without cancelling) 47 | let removed = subscriptions.remove(task) 48 | 49 | // THEN the set is empty, but the task is still alive 50 | #expect(removed != nil) 51 | #expect(subscriptions.isEmpty) 52 | 53 | try? await Task.sleep(nanoseconds: 80_000_000) 54 | #expect(await probe.wasCancelled() == false) 55 | 56 | // Cleanup 57 | subscriptions.cancelAll() 58 | } 59 | 60 | @Test("Storing the Same Task Twice Does Not Duplicate") 61 | func setDoesNotDuplicate() async { 62 | // GIVEN a sentinel task 63 | let probe = CancelProbe() 64 | let sentinel = CancellationSentinel(probe: probe) 65 | let task = await sentinel.start() 66 | 67 | var subscriptions = Set() 68 | 69 | // WHEN we store the same task twice 70 | task.store(in: &subscriptions) 71 | task.store(in: &subscriptions) 72 | 73 | // THEN the set contains only one instance 74 | #expect(subscriptions.count == 1) 75 | 76 | subscriptions.cancelAll() 77 | #expect(subscriptions.isEmpty) 78 | 79 | try? await Task.sleep(nanoseconds: 80_000_000) 80 | #expect(await probe.wasCancelled()) 81 | _ = sentinel 82 | } 83 | 84 | @Test("Storing a Naturally Finishing Task is Safe") 85 | func storingCompletedTaskIsSafe() async { 86 | // GIVEN a short task that finishes on its own 87 | let quick: SubscriptionTask = Task { 88 | try? await Task.sleep(nanoseconds: 10_000_000) 89 | } 90 | 91 | var subscriptions = Set() 92 | quick.store(in: &subscriptions) 93 | #expect(subscriptions.count == 1) 94 | 95 | // WHEN we give it time to finish naturally 96 | try? await Task.sleep(nanoseconds: 30_000_000) 97 | 98 | // THEN cleanup via cancelAll still works safely 99 | subscriptions.cancelAll() 100 | #expect(subscriptions.isEmpty) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Operators/AsyncSequence+Sink.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+Operators.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | public extension AsyncSequence where Element: Sendable, Self: Sendable { 9 | 10 | /// Subscribes to the sequence, calling the provided closure for each element, 11 | /// and returns a cancellable task you can store or cancel. 12 | /// 13 | /// This operator works similarly to Combine’s ``Publisher/sink(receiveCompletion:receiveValue:)``. 14 | /// It consumes values from the asynchronous sequence, invoking `receiveValue` for 15 | /// each element. If the sequence terminates with an error, the `receiveError` 16 | /// closure is invoked instead. 17 | /// 18 | /// Use this to bridge an `AsyncSequence` into your application’s side effects 19 | /// or UI updates, while holding onto the returned ``SubscriptionTask`` to manage 20 | /// the subscription’s lifetime. 21 | /// 22 | /// - Parameters: 23 | /// - receiveError: A closure to call if the sequence throws an error 24 | /// (other than cancellation). Defaults to a no-op. 25 | /// - receiveFinished: An asynchronous closure that is called once all values are emitted. 26 | /// - receiveValue: An asynchronous closure to process each emitted element. 27 | /// Executed for every value produced by the sequence. 28 | /// - Returns: A ``SubscriptionTask`` you can store in a collection 29 | /// (e.g. `Set`) or cancel to end the subscription early. 30 | /// 31 | /// - Note: Cancelling the returned task stops iteration of the sequence. 32 | /// 33 | /// ## Example 34 | /// 35 | /// ```swift 36 | /// let relay = CurrentValueRelay(0) 37 | /// var subscriptions = Set() 38 | /// 39 | /// relay.stream() 40 | /// .sink { value in 41 | /// print("Got:", value) 42 | /// } 43 | /// .store(in: &subscriptions) 44 | /// 45 | /// relay.send(1) // Prints "Got: 1" 46 | /// relay.send(2) // Prints "Got: 2" 47 | /// 48 | /// // Later, cancel the subscription 49 | /// subscriptions.first?.cancel() 50 | /// ``` 51 | func sink( 52 | catching receiveError: @escaping ReceiveError = { _ in }, 53 | finished receiveFinished: @escaping ReceiveFinished = {}, 54 | _ receiveValue: @escaping ReceiveElement 55 | ) -> SubscriptionTask { 56 | return Task { 57 | do { 58 | for try await element in self { 59 | await receiveValue(element) 60 | } 61 | await receiveFinished() 62 | } catch is CancellationError { 63 | // Expected on cancel 64 | } catch { 65 | // We received an error :( 66 | receiveError(error) 67 | } 68 | } 69 | } 70 | 71 | /// Like `sink`, but guarantees the value/finish handlers run on the main actor. 72 | func sinkOnMain( 73 | catching receiveError: @escaping MainActorReceiveError = { _ in }, 74 | finished receiveFinished: @escaping MainActorReceiveFinished = {}, 75 | _ receiveValue: @escaping MainActorReceiveElement 76 | ) -> SubscriptionTask { 77 | return Task { @MainActor in 78 | do { 79 | for try await element in self { 80 | await receiveValue(element) 81 | } 82 | await receiveFinished() 83 | } catch is CancellationError { 84 | // Expected on cancel 85 | } catch { 86 | receiveError(error) 87 | } 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Operators/Observable+Observed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Observable+Observed.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | import Observation 9 | 10 | @available(iOS 17.0, macOS 14.0, watchOS 10, *) 11 | public extension Observable where Self: AnyObject { 12 | 13 | /// Creates an `AsyncStream` that emits values of a given property whenever 14 | /// it changes, using Swift's Observation framework. 15 | /// 16 | /// This is the Async/await equivalent of Combine’s `Publisher`-based key-path 17 | /// observation, but designed to integrate seamlessly with Swift Concurrency. 18 | /// The returned stream: 19 | /// - Immediately yields the current value of the property. 20 | /// - Emits a new value each time the property changes. 21 | /// - Finishes automatically if the observed object is deallocated. 22 | /// 23 | /// - Parameter keyPath: The key path of the property to observe. 24 | /// - Returns: An `AsyncStream` that produces values whenever the property changes. 25 | /// 26 | /// ### Example 27 | /// 28 | /// ```swift 29 | /// import AsyncCombine 30 | /// import Observation 31 | /// 32 | /// @Observable @MainActor 33 | /// final class CounterViewModel { 34 | /// var count: Int = 0 35 | /// } 36 | /// 37 | /// let viewModel = CounterViewModel() 38 | /// 39 | /// Task { 40 | /// for await value in viewModel.observed(\.count) { 41 | /// print("Count changed:", value) 42 | /// } 43 | /// } 44 | /// 45 | /// viewModel.count += 1 46 | /// // Prints: "Count changed: 1" 47 | /// ``` 48 | /// 49 | /// The stream ends automatically when `viewModel` is deallocated: 50 | /// 51 | /// ```swift 52 | /// var vm = CounterViewModel() 53 | /// 54 | /// Task { 55 | /// for await _ in vm.observed(\.count) { 56 | /// print("Change observed") 57 | /// } 58 | /// print("Stream finished") // called when vm is released 59 | /// } 60 | /// 61 | /// vm = nil 62 | /// ``` 63 | /// 64 | /// - Important: The returned stream should be consumed on the main actor, 65 | /// since the Observation system requires property access and registration 66 | /// to happen on the actor that owns the model. 67 | func observed( 68 | _ keyPath: KeyPath 69 | ) -> AsyncStream { 70 | let object = WeakBox(self) 71 | let kp = NonSendableBox(keyPath) 72 | let repeater = RepeaterBox() 73 | 74 | return AsyncStream { continuation in 75 | Task { @MainActor in 76 | guard let value = object.value else { 77 | continuation.finish() 78 | return 79 | } 80 | 81 | // Replay current value 82 | continuation.yield(value[keyPath: kp.value]) 83 | 84 | // Define the tracking body without creating a 85 | // nested function to capture. 86 | repeater.call = { 87 | guard let obj = object.value else { 88 | continuation.finish() 89 | return 90 | } 91 | 92 | withObservationTracking { 93 | // Register the read 94 | _ = obj[keyPath: kp.value] 95 | } onChange: { 96 | Task { @MainActor in 97 | guard let value = object.value else { 98 | continuation.finish() 99 | return 100 | } 101 | continuation.yield(value[keyPath: kp.value]) 102 | 103 | // Re-register for next change 104 | repeater.call?() 105 | } 106 | } 107 | } 108 | 109 | // Start tracking 110 | repeater.call?() 111 | } 112 | } 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Subscriptions/Set+CancelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+CancelTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Testing 10 | 11 | @Suite( 12 | "Set+Cancel Tests", 13 | .serialized, 14 | .timeLimit(.minutes(1)) 15 | ) 16 | struct SetCancelTests { 17 | 18 | // MARK: - Helpers 19 | 20 | /// Waits until `condition` returns true or the timeout elapses. 21 | private func waitUntil( 22 | _ condition: @Sendable () async -> Bool, 23 | timeout: Duration = .seconds(1) 24 | ) async -> Bool { 25 | let clock = ContinuousClock() 26 | let deadline = clock.now.advanced(by: timeout) 27 | 28 | while clock.now < deadline { 29 | if await condition() { return true } 30 | await Task.yield() 31 | } 32 | return await condition() 33 | } 34 | 35 | // MARK: - Tests 36 | 37 | @Test("Cancels All Tasks and Empties the Set") 38 | func cancelsAndEmpties() async { 39 | // GIVEN three live sentinels whose tasks are stored in the set 40 | let probes = (0..<3).map { _ in CancelProbe() } 41 | let sentinels = probes.map { CancellationSentinel(probe: $0) } 42 | 43 | var subscriptions = Set() 44 | for sentinel in sentinels { 45 | await sentinel.start().store(in: &subscriptions) 46 | } 47 | 48 | #expect(subscriptions.count == 3) 49 | 50 | // WHEN cancelling all 51 | subscriptions.cancelAll() 52 | 53 | // THEN set is empty and all tasks reported cancellation via the probes 54 | #expect(subscriptions.isEmpty) 55 | 56 | // Wait deterministically for all probes to observe cancellation 57 | let allCancelled = await waitUntil({ 58 | for probe in probes { 59 | if !(await probe.wasCancelled()) { return false } 60 | } 61 | return true 62 | }, timeout: .seconds(1)) 63 | 64 | #expect(allCancelled) 65 | _ = sentinels 66 | } 67 | 68 | @Test("Calling cancelAll on an Empty Set is a No-Op") 69 | func emptySetNoOp() { 70 | var subscriptions = Set() 71 | #expect(subscriptions.isEmpty) 72 | 73 | subscriptions.cancelAll() 74 | #expect(subscriptions.isEmpty) 75 | } 76 | 77 | @Test("cancelAll is Idempotent") 78 | func idempotent() async { 79 | let probe = CancelProbe() 80 | let sentinel = CancellationSentinel(probe: probe) 81 | 82 | var subscriptions = Set() 83 | await sentinel.start().store(in: &subscriptions) 84 | 85 | // WHEN cancelling once 86 | subscriptions.cancelAll() 87 | #expect(subscriptions.isEmpty) 88 | 89 | // AND cancelling again — should remain empty and not crash 90 | subscriptions.cancelAll() 91 | #expect(subscriptions.isEmpty) 92 | 93 | // THEN the sentinel's probe eventually observes cancellation 94 | let cancelled = await waitUntil({ await probe.wasCancelled() }, timeout: .seconds(1)) 95 | #expect(cancelled) 96 | _ = sentinel 97 | } 98 | 99 | @Test("Removes Tasks Even if Some Already Completed") 100 | func removesAlreadyCompletedTasks() async { 101 | // GIVEN a task that finishes quickly (not cancelled) 102 | let quickTask: SubscriptionTask = Task { 103 | try? await Task.sleep(nanoseconds: 10_000_000) 104 | } 105 | 106 | // AND a long-lived task owned by a sentinel that should be cancelled 107 | let longProbe = CancelProbe() 108 | let longSentinel = CancellationSentinel(probe: longProbe) 109 | let longTask = await longSentinel.start() 110 | 111 | var subscriptions = Set() 112 | quickTask.store(in: &subscriptions) 113 | longTask.store(in: &subscriptions) 114 | 115 | #expect(subscriptions.count == 2) 116 | 117 | // Give quickTask time to finish deterministically 118 | await quickTask.value 119 | 120 | // WHEN we cancel all 121 | subscriptions.cancelAll() 122 | 123 | // THEN the set is emptied and the long task reports cancellation 124 | #expect(subscriptions.isEmpty) 125 | 126 | let longWasCancelled = await waitUntil({ await longProbe.wasCancelled() }, timeout: .seconds(1)) 127 | #expect(longWasCancelled) 128 | _ = longSentinel 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Operators/AsyncCombine+Combine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStream+Combine.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 29/9/2025. 6 | // 7 | 8 | import AsyncAlgorithms 9 | 10 | // For the namespace 11 | public enum AsyncCombine { } 12 | 13 | public extension AsyncCombine { 14 | 15 | /// Returns an AsyncStream that emits the latest pair from two AsyncSequences 16 | /// once both have produced at least one value. Subsequent emissions occur 17 | /// whenever either upstream produces a new value. The stream finishes when 18 | /// **both** upstream sequences finish. 19 | /// 20 | /// Cancellation of the returned Task cancels both upstream consumers. 21 | /// 22 | /// - Parameters: 23 | /// - stream1: First async sequence 24 | /// - stream2: Second async sequence 25 | /// - Returns: AsyncStream of `(Element1, Element2)` tuples (combineLatest) 26 | static func CombineLatest( 27 | _ stream1: Stream1, 28 | _ stream2: Stream2 29 | ) -> AsyncStream<(Stream1.Element, Stream2.Element)> 30 | where Stream1: AsyncSequence & Sendable, 31 | Stream2: AsyncSequence & Sendable, 32 | Stream1.Element: Sendable, 33 | Stream2.Element: Sendable 34 | { 35 | return AsyncStream<(Stream1.Element, Stream2.Element)> { continuation in 36 | 37 | let state = _Combine2State( 38 | continuation: continuation 39 | ) 40 | 41 | let task1 = Task { 42 | do { 43 | for try await value in stream1 { 44 | await state.updateElement1(value) 45 | } 46 | } catch { 47 | 48 | } 49 | await state.finishFirst() 50 | } 51 | 52 | let task2 = Task { 53 | do { 54 | for try await value in stream2 { 55 | await state.updateElement2(value) 56 | } 57 | } catch { 58 | 59 | } 60 | await state.finishSecond() 61 | } 62 | 63 | continuation.onTermination = { _ in 64 | task1.cancel() 65 | task2.cancel() 66 | 67 | Task { 68 | await state.cancel() 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | // MARK: - Combine2State 76 | 77 | fileprivate actor _Combine2State< 78 | Element1: Sendable, 79 | Element2: Sendable 80 | > { 81 | private var latestElement1: Element1? 82 | private var latestElement2: Element2? 83 | 84 | private var finishedFirst = false 85 | private var finishedSecond = false 86 | 87 | private var cancelled = false 88 | 89 | private let continuation: AsyncStream<(Element1, Element2)>.Continuation 90 | 91 | init( 92 | continuation: AsyncStream<( 93 | Element1, 94 | Element2 95 | )>.Continuation 96 | ) { 97 | self.continuation = continuation 98 | } 99 | 100 | func updateElement1(_ value: Element1) { 101 | guard cancelled == false else { 102 | return 103 | } 104 | 105 | self.latestElement1 = value 106 | self.yieldIfPossible() 107 | } 108 | 109 | func updateElement2(_ value: Element2) { 110 | guard cancelled == false else { 111 | return 112 | } 113 | 114 | self.latestElement2 = value 115 | self.yieldIfPossible() 116 | } 117 | 118 | func finishFirst() { 119 | self.finishedFirst = true 120 | self.finishIfPossible() 121 | } 122 | 123 | func finishSecond() { 124 | self.finishedSecond = true 125 | self.finishIfPossible() 126 | } 127 | 128 | func cancel() { 129 | self.cancelled = true 130 | self.continuation.finish() 131 | } 132 | 133 | private func yieldIfPossible() { 134 | guard 135 | let latestElement1, 136 | let latestElement2 137 | else { 138 | return 139 | } 140 | 141 | self.continuation.yield((latestElement1, latestElement2)) 142 | } 143 | 144 | private func finishIfPossible() { 145 | // Finish when *all* upstreams have finished 146 | // (CombineLatest semantics) 147 | if self.finishedFirst && self.finishedSecond { 148 | continuation.finish() 149 | } 150 | } 151 | 152 | } 153 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Testing/AsyncSequence+FirstTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+FirstTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 7/10/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Testing 10 | 11 | @Suite("AsyncSequence+First") 12 | struct AsyncSequenceFirstTests { 13 | 14 | // MARK: - Plain `first(equalTo:)` 15 | 16 | @Test("Emits First Matching Element") 17 | func emitsFirstMatch() async throws { 18 | // GIVEN a stream that emits 1,2,3 19 | let stream = stream([1, 2, 3]) 20 | 21 | // WHEN we ask for first(equalTo: 2) 22 | let result = await stream.first(equalTo: 2) 23 | 24 | // THEN we get 2 25 | #expect(result == 2) 26 | } 27 | 28 | @Test("Returns Nil If No Match Before Finish") 29 | func returnsNilWhenNoMatch() async throws { 30 | let stream = stream([1, 3, 5]) 31 | let result = await stream.first(equalTo: 2) 32 | #expect(result == nil) 33 | } 34 | 35 | @Test("Stops At The First Match Even If More Follow") 36 | func stopsAtFirstEvenIfMoreFollow() async throws { 37 | let stream = stream([2, 2, 2, 3, 4]) 38 | let result = await stream.first(equalTo: 2) 39 | #expect(result == 2) 40 | } 41 | 42 | @Test("Empty Sequence Returns Nil") 43 | func emptyReturnsNil() async throws { 44 | let stream = stream([Int]()) 45 | let result = await stream.first(equalTo: 1) 46 | #expect(result == nil) 47 | } 48 | 49 | // MARK: - Timed `first(equalTo:timeout:clock:)` 50 | 51 | @available(iOS 16.0, macOS 13.0, *) 52 | @Test("Returns Value When It Arrives Before Timeout") 53 | func valueBeatsTimeout() async { 54 | // GIVEN values every 50ms; target (7) is second element at ~100ms 55 | let stream = delayedStream([1, 7, 9], delay: .milliseconds(50)) 56 | let clock = ContinuousClock() 57 | 58 | // WHEN timeout is generous (300ms) 59 | let value = await stream.first(equalTo: 7, timeout: .milliseconds(300), clock: clock) 60 | 61 | // THEN we got it 62 | #expect(value == 7) 63 | } 64 | 65 | @available(iOS 16.0, macOS 13.0, *) 66 | @Test("Timeout Wins When Value Is Too Slow") 67 | func timeoutBeatsSlowValue() async { 68 | // GIVEN a single value after 200ms 69 | let stream = delayedStream([42], delay: .milliseconds(200)) 70 | let clock = ContinuousClock() 71 | 72 | // WHEN timeout is only 50ms 73 | let value = await stream.first(equalTo: 42, timeout: .milliseconds(50), clock: clock) 74 | 75 | // THEN we time out (nil) 76 | #expect(value == nil) 77 | } 78 | 79 | @available(iOS 16.0, macOS 13.0, *) 80 | @Test("Returns Nil When Sequence Finishes Before a Match") 81 | func finishesBeforeMatch() async { 82 | // GIVEN a short stream with no matching value 83 | let stream = delayedStream([1, 3, 5], delay: .milliseconds(20)) 84 | let clock = ContinuousClock() 85 | 86 | // WHEN timeout is long enough that finishing is the determining event 87 | let value = await stream.first( 88 | equalTo: 2, 89 | timeout: .seconds(1), 90 | clock: clock 91 | ) 92 | 93 | // THEN nil because it finished with no match 94 | #expect(value == nil) 95 | } 96 | 97 | @available(iOS 16.0, macOS 13.0, *) 98 | @Test("Match Right At The Start Still Returns Immediately") 99 | func immediateMatch() async { 100 | // GIVEN the first element is already a match 101 | let stream = stream([99, 100, 101]) 102 | let clock = ContinuousClock() 103 | 104 | let value = await stream.first(equalTo: 99, timeout: .seconds(1), clock: clock) 105 | #expect(value == 99) 106 | } 107 | 108 | @available(iOS 16.0, macOS 13.0, *) 109 | @Test("Multiple Matches: Returns The Earliest") 110 | func earliestOfMultipleMatches() async { 111 | // GIVEN multiple matches in the stream 112 | let stream = delayedStream([7, 7, 7], delay: .milliseconds(25)) 113 | let clock = ContinuousClock() 114 | 115 | let value = await stream.first( 116 | equalTo: 7, 117 | timeout: .seconds(1), 118 | clock: clock 119 | ) 120 | #expect(value == 7) 121 | } 122 | 123 | } 124 | 125 | // MARK: - Private 126 | 127 | private extension AsyncSequenceFirstTests { 128 | 129 | /// Emits the given values then finishes (no delays). 130 | func stream(_ values: [T]) -> AsyncStream { 131 | AsyncStream { cont in 132 | for v in values { 133 | cont.yield(v) 134 | } 135 | cont.finish() 136 | } 137 | } 138 | 139 | /// Emits each value separated by `delay`, then finishes. 140 | func delayedStream( 141 | _ values: [T], 142 | delay: Duration 143 | ) -> AsyncStream { 144 | AsyncStream { cont in 145 | Task.detached { 146 | for v in values { 147 | try? await Task.sleep(for: delay) 148 | cont.yield(v) 149 | } 150 | cont.finish() 151 | } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Testing/Recorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Recorder.swift 3 | // AsyncCombineTesting 4 | // 5 | // Created by William Lumley on 6/10/2025. 6 | // 7 | 8 | /// A helper that captures elements from an `AsyncSequence` and lets you pull them one-by-one. 9 | /// 10 | /// The `Recorder` continuously consumes the source sequence on a background task 11 | /// and buffers emitted elements into an `AsyncStream`, allowing callers to await 12 | /// each value via `next()`. 13 | /// 14 | /// - Important: This was designed with unit testing in mind and is not suitable for 15 | /// production use. 16 | 17 | @available(iOS 16.0, macOS 13.0, *) 18 | public actor Recorder where S.Element: Sendable { 19 | 20 | // MARK: - Types 21 | 22 | private final class IteratorBox: @unchecked Sendable { 23 | private var iterator: AsyncStream.Iterator 24 | 25 | init(_ iterator: AsyncStream.Iterator) { 26 | self.iterator = iterator 27 | } 28 | 29 | // Mutating+async happens here, away from the actor's stored state 30 | func next() async -> S.Element? { 31 | await iterator.next() 32 | } 33 | } 34 | 35 | // MARK: - Properties 36 | 37 | /// The continuation used to feed elements from the source sequence into the internal stream. 38 | private let continuation: AsyncStream.Continuation 39 | 40 | /// The internal `AsyncStream` that buffers elements emitted by the source sequence. 41 | private let stream: AsyncStream 42 | 43 | /// The iterator box used to retrieve elements from the buffered stream. 44 | private var iterator: IteratorBox 45 | 46 | /// The background task responsible for pumping values from the source sequence into the stream. 47 | private var pump: Task? 48 | 49 | // MARK: Lifecycle 50 | 51 | public init( 52 | _ sequence: S, 53 | bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded 54 | ) { 55 | var cont: AsyncStream.Continuation! 56 | let stream = AsyncStream( 57 | bufferingPolicy: bufferingPolicy 58 | ) { 59 | cont = $0 60 | } 61 | 62 | self.continuation = cont 63 | self.stream = stream 64 | self.iterator = IteratorBox(stream.makeAsyncIterator()) 65 | 66 | // Pump the source sequence into the stream. 67 | self.pump = Task { 68 | do { 69 | for try await value in sequence { 70 | cont.yield(value) 71 | } 72 | cont.finish() 73 | } catch { 74 | // Swallow errors; finish the stream 75 | cont.finish() 76 | } 77 | } 78 | } 79 | 80 | deinit { 81 | self.pump?.cancel() 82 | self.continuation.finish() 83 | } 84 | 85 | /// Returns the next element emitted after the last `next()` call, or `nil` 86 | /// if the source finishes. 87 | private func next() async -> S.Element? { 88 | // Read the reference (allowed), then await on it; we are 89 | // not holding a mutable actor-stored value across the suspension. 90 | let box = self.iterator 91 | return await box.next() 92 | } 93 | 94 | /// Like `next()`, but gives up after `timeout` and returns `nil`. 95 | public func next( 96 | timeout: Duration = .seconds(1), 97 | clock: ContinuousClock = .init(), 98 | fileID: StaticString = #fileID, 99 | filePath: StaticString = #filePath, 100 | line: UInt = #line, 101 | column: UInt = #column 102 | ) async throws -> S.Element { 103 | return try await withThrowingTaskGroup(of: S.Element?.self) { group in 104 | 105 | group.addTask { 106 | await self.next() 107 | } 108 | group.addTask { 109 | try await clock.sleep(for: timeout) 110 | throw RecordingError.timeout 111 | } 112 | 113 | // First finished child wins 114 | guard let first = try await group.next() else { 115 | // There are two children, so this should be impossible 116 | preconditionFailure("Task group returned no results") 117 | } 118 | group.cancelAll() 119 | 120 | guard let result = first else { 121 | throw RecordingError.sourceEnded 122 | } 123 | return result 124 | } 125 | 126 | } 127 | 128 | /// Stop recording early; completes the stream and cancels the pump. 129 | public func cancel() { 130 | self.pump?.cancel() 131 | self.continuation.finish() 132 | } 133 | 134 | } 135 | 136 | // MARK: - AsyncSequence 137 | 138 | @available(iOS 16.0, macOS 13.0, *) 139 | public extension AsyncSequence where Element: Sendable, Self: Sendable { 140 | 141 | /// Creates a `Recorder` that captures elements and lets you pull them one-by-one via `next()`. 142 | @inlinable 143 | func record( 144 | bufferingPolicy: AsyncStream.Continuation.BufferingPolicy = .unbounded 145 | ) -> Recorder { 146 | Recorder(self, bufferingPolicy: bufferingPolicy) 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /Sources/AsyncCombine/Core/CurrentValueRelay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentValueRelay.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 15/9/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A concurrency-friendly, replay-1 relay for broadcasting the latest value 11 | /// to multiple listeners using `AsyncStream`. 12 | /// 13 | /// `CurrentValueRelay` behaves similarly to Combine’s `CurrentValueSubject`, 14 | /// but is designed for Swift Concurrency. It stores the most recent value 15 | /// and immediately replays it to new subscribers, followed by all subsequent 16 | /// updates. 17 | /// 18 | /// This makes it useful for bridging stateful streams of values between 19 | /// domain logic and presentation layers. 20 | /// 21 | /// ```swift 22 | /// let relay = CurrentValueRelay(0) 23 | /// var subscriptions = Set() 24 | /// 25 | /// Task { 26 | /// for await value in relay.stream() { 27 | /// print("Received:", value) 28 | /// } 29 | /// } 30 | /// 31 | /// relay.send(1) // prints "Received: 1" 32 | /// relay.send(2) // prints "Received: 2" 33 | /// ``` 34 | public actor CurrentValueRelay { 35 | 36 | // MARK: - Properties 37 | 38 | /// The most recent value stored and replayed by this relay. 39 | /// 40 | /// When new listeners subscribe via ``stream()``, this value is emitted first, 41 | /// ensuring they always begin with the latest known state. 42 | public private(set) var valueStorage: Value 43 | 44 | /// The set of active continuations currently subscribed to updates from this relay. 45 | /// 46 | /// Each continuation is identified by a `UUID` and receives values through 47 | /// the `AsyncStream` produced by ``stream()``. 48 | private var continuations = [UUID: AsyncStream.Continuation]() 49 | 50 | /// Active background tasks that feed values into the relay. 51 | /// 52 | /// Each task forwards values from an external `AsyncSequence` into the relay 53 | /// via ``send(_:)``. Tasks are retained for the lifetime of the relay and 54 | /// automatically cancelled when the relay is deallocated. 55 | private var feeds = [UUID: SubscriptionTask]() 56 | 57 | // MARK: - Lifecycle 58 | 59 | /// Creates a new relay with the given initial value. 60 | /// 61 | /// - Parameter initial: The value to seed the relay with. 62 | /// This value is immediately replayed to new subscribers. 63 | public init(_ initial: Value) { 64 | self.valueStorage = initial 65 | } 66 | 67 | deinit { 68 | // Best effort, cancel any active pumps 69 | for feed in self.feeds.values { 70 | feed.cancel() 71 | } 72 | self.feeds.removeAll() 73 | } 74 | 75 | } 76 | 77 | // MARK: - Public 78 | 79 | public extension CurrentValueRelay { 80 | 81 | /// Sends a new value into the relay, updating its current value 82 | /// and broadcasting it to all active subscribers. 83 | /// 84 | /// - Parameter newValue: The value to set and propagate. 85 | /// 86 | /// Any listeners created with ``stream()`` will receive this value. 87 | func send(_ newValue: Value) { 88 | self.valueStorage = newValue 89 | for continuation in continuations.values { 90 | continuation.yield(newValue) 91 | } 92 | } 93 | 94 | /// Attaches a background task as a feed for this relay. 95 | /// 96 | /// The feed is retained for the relay’s lifetime and cancelled 97 | /// automatically when the relay is deallocated. 98 | func attach(feed: SubscriptionTask) { 99 | self.feeds[UUID()] = feed 100 | } 101 | 102 | /// Returns an `AsyncStream` that emits the relay’s current value immediately 103 | /// (replay-1), followed by all subsequent updates. 104 | /// 105 | /// - Returns: An `AsyncStream` of values from this relay. 106 | /// 107 | /// The stream terminates automatically when the caller’s task is cancelled, 108 | /// or when the continuation is explicitly terminated. 109 | /// 110 | /// ```swift 111 | /// let relay = CurrentValueRelay("initial") 112 | /// 113 | /// Task { 114 | /// for await value in relay.stream() { 115 | /// print("Got:", value) 116 | /// } 117 | /// } 118 | /// 119 | /// relay.send("update") 120 | /// // Prints: 121 | /// // "Got: initial" 122 | /// // "Got: update" 123 | /// ``` 124 | nonisolated func stream() -> AsyncStream { 125 | return AsyncStream { continuation in 126 | let id = UUID() 127 | 128 | // Register our continuation so we can broadcast to it 129 | // later on. 130 | Task { 131 | await self.register(id: id, continuation: continuation) 132 | } 133 | 134 | // If the continuation is terminated 135 | continuation.onTermination = { [weak self] _ in 136 | Task { 137 | await self?.unregister(id: id) 138 | } 139 | } 140 | } 141 | } 142 | 143 | } 144 | 145 | // MARK: - Private 146 | 147 | private extension CurrentValueRelay { 148 | 149 | /// Registers a continuation and immediately replays the current value to it. 150 | /// 151 | /// - Parameters: 152 | /// - id: A unique identifier for this continuation. 153 | /// - continuation: The continuation to register and notify. 154 | func register(id: UUID, continuation: AsyncStream.Continuation) { 155 | self.continuations[id] = continuation 156 | 157 | // Replay latest value to the continuation 158 | continuation.yield(self.valueStorage) 159 | } 160 | 161 | /// Unregisters and removes the continuation associated with the given ID. 162 | /// 163 | /// - Parameter id: The identifier of the continuation to remove. 164 | func unregister(id: UUID) { 165 | self.continuations.removeValue(forKey: id) 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Operators/AsyncSequence+SinkTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+SinkTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | import Testing 11 | 12 | @Suite("AsyncSequence+Sink Tests", .serialized, .timeLimit(.minutes(1))) 13 | @MainActor 14 | final class AsyncSequenceSinkTests { 15 | 16 | // MARK: - Properties 17 | 18 | /// A convenient place to store our tasks 19 | private var tasks = Set() 20 | 21 | // MARK: - Lifecycle 22 | 23 | deinit { 24 | // Clean up after ourselves 25 | self.tasks.cancelAll() 26 | } 27 | 28 | // MARK: - Tests 29 | 30 | @Test("Delivers Values in Order and Does Not Call receiveError on Normal Completion") 31 | func deliversInOrderNoErrorOnFinish() async { 32 | let (stream, cont) = AsyncStream.makeStream() 33 | let recorder = RecordingBox() 34 | let errorCalled = AsyncBox(false) 35 | 36 | // GIVEN we sink values, recording each one, and tracking error calls 37 | stream 38 | .sink( 39 | catching: { _ in 40 | Task { 41 | await errorCalled.set(true) 42 | } 43 | }, { value in 44 | await recorder.append(value) 45 | } 46 | ) 47 | .store(in: &tasks) 48 | 49 | // WHEN we emit some values 50 | cont.yield(1) 51 | cont.yield(2) 52 | cont.yield(3) 53 | 54 | // Give the sink a moment to process, then finish cleanly 55 | try? await Task.sleep(for: .milliseconds(40)) 56 | cont.finish() 57 | try? await Task.sleep(for: .milliseconds(40)) 58 | 59 | // THEN values were delivered in order and no error callback was invoked 60 | #expect(await recorder.snapshot() == [1, 2, 3]) 61 | #expect(await errorCalled.get() == false) 62 | } 63 | 64 | @Test("Cancelling the Returned Task Stops Further Values") 65 | func cancelStopsFurtherValues() async { 66 | let (stream, cont) = AsyncStream.makeStream() 67 | let recorder = RecordingBox() 68 | 69 | // GIVEN a sink storing into our task set 70 | stream.sink { value in 71 | await recorder.append(value) 72 | } 73 | .store(in: &tasks) 74 | 75 | // WHEN we emit an initial value 76 | cont.yield("A") 77 | try? await Task.sleep(for: .milliseconds(20)) 78 | 79 | // THEN the recorder has the first value 80 | #expect(await recorder.snapshot() == ["A"]) 81 | 82 | // WHEN we cancel all subscriptions 83 | self.tasks.cancelAll() 84 | try? await Task.sleep(for: .milliseconds(10)) 85 | 86 | // AND try to emit more values after cancellation 87 | cont.yield("B") 88 | cont.yield("C") 89 | try? await Task.sleep(for: .milliseconds(40)) 90 | 91 | // THEN no further values were recorded 92 | #expect(await recorder.snapshot() == ["A"]) 93 | 94 | cont.finish() 95 | } 96 | 97 | @Test("Propagates Errors from AsyncThrowingStream to receiveError and Stops Iteration") 98 | func errorPropagation() async { 99 | enum TestError: Error { case boom } 100 | 101 | let (stream, cont) = AsyncThrowingStream.makeStream() 102 | let recorder = RecordingBox() 103 | let capturedError = AsyncBox(nil) 104 | 105 | // GIVEN a throwing stream whose errors are surfaced via `catching` 106 | stream.sink(catching: { error in 107 | Task { await capturedError.set(error) } 108 | }) { value in 109 | await recorder.append(value) 110 | } 111 | .store(in: &tasks) 112 | 113 | // WHEN we emit one value 114 | cont.yield("ok-1") 115 | try? await Task.sleep(for: .milliseconds(20)) 116 | #expect(await recorder.snapshot() == ["ok-1"]) 117 | 118 | // AND then fail the stream 119 | cont.finish(throwing: TestError.boom) 120 | try? await Task.sleep(for: .milliseconds(40)) 121 | 122 | // THEN the error is captured 123 | let err = await capturedError.get() 124 | #expect(err is TestError) 125 | 126 | // AND further values (even if attempted) are ignored 127 | cont.yield("after-error") 128 | try? await Task.sleep(for: .milliseconds(20)) 129 | #expect(await recorder.snapshot() == ["ok-1"]) 130 | } 131 | 132 | @Test("Storing in a Set Allows Mass-Cancellation via cancelAll()") 133 | func storeThenCancelAll() async { 134 | let (stream, cont) = AsyncStream.makeStream() 135 | let recorder = RecordingBox() 136 | 137 | // GIVEN a sink stored in our shared task set 138 | stream.sink { value in 139 | await recorder.append(value) 140 | } 141 | .store(in: &tasks) 142 | 143 | #expect(tasks.isEmpty == false) 144 | 145 | // WHEN we emit values 146 | cont.yield(10) 147 | cont.yield(20) 148 | try? await Task.sleep(for: .milliseconds(30)) 149 | #expect(await recorder.snapshot() == [10, 20]) 150 | 151 | // AND cancel everything 152 | self.tasks.cancelAll() 153 | 154 | // THEN further emissions are ignored 155 | cont.yield(30) 156 | try? await Task.sleep(for: .milliseconds(30)) 157 | #expect(await recorder.snapshot() == [10, 20]) 158 | 159 | cont.finish() 160 | } 161 | 162 | @Test("receiveValue is Awaited Sequentially (preserves order even with suspension)") 163 | func receiveValueIsAwaitedSequentially() async { 164 | let (stream, cont) = AsyncStream.makeStream() 165 | let recorder = RecordingBox() 166 | 167 | // GIVEN a sink that simulates per-element work to test ordering 168 | stream.sink { value in 169 | try? await Task.sleep(for: .milliseconds(15)) // simulate work 170 | await recorder.append(value) 171 | } 172 | .store(in: &tasks) 173 | 174 | // WHEN we push a quick burst and then finish 175 | cont.yield(1) 176 | cont.yield(2) 177 | cont.yield(3) 178 | cont.finish() 179 | 180 | // THEN all three are processed in order despite suspension 181 | try? await Task.sleep(for: .seconds(1)) 182 | #expect(await recorder.snapshot() == [1, 2, 3]) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Operators/AsyncSequence+AssignTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+AssignTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | import Testing 11 | 12 | @Suite("AsyncSequence+Assign Tests", .serialized, .timeLimit(.minutes(1))) 13 | @MainActor 14 | class AssignToOnTests { 15 | 16 | // MARK: - Properties 17 | 18 | /// A convenient place to store our tasks 19 | var tasks = Set() 20 | 21 | /// A test label that holds a `String` value and is a `MainActor` 22 | let label = Label() 23 | 24 | // MARK: - Lifecycle 25 | 26 | deinit { 27 | // Clean up after ourselves 28 | self.tasks.cancelAll() 29 | } 30 | 31 | // MARK: - Tests 32 | 33 | @Test("Assigns values to the object on MainActor") 34 | func assignsValuesOnMainActor() async { 35 | let (stream, cont) = AsyncStream.makeStream() 36 | 37 | // GIVEN we assign our stream value to `.text` to our `label` 38 | stream 39 | .assign(to: \.text, on: label) 40 | .store(in: &tasks) 41 | 42 | // Wait for the label to actually receive "Three" 43 | let done = Task { 44 | return await label.observed(\.text) 45 | .first { @Sendable in $0 == "Three" } 46 | } 47 | 48 | // THEN we emit some values 49 | cont.yield("One") 50 | cont.yield("Two") 51 | cont.yield("Three") 52 | 53 | cont.finish() 54 | 55 | // Wait for the assigning to be completed 56 | _ = await done.value 57 | 58 | // THEN the last value emitted is assigned 59 | #expect(label.text == "Three") 60 | } 61 | 62 | @Test("Cancelling the Returned Task Stops Further Assignments") 63 | func cancelStopsAssignments() async throws { 64 | let (stream, cont) = AsyncStream.makeStream() 65 | 66 | // GIVEN we assign our stream value to `.text` to our `label` 67 | stream 68 | .assign(to: \.text, on: label) 69 | .store(in: &tasks) 70 | 71 | // Wait for the label to actually receive "Before Cancel" 72 | let done = Task { 73 | return await label.observed(\.text) 74 | .first { @Sendable in $0 == "Before Cancel" } 75 | } 76 | 77 | // WHEN we emit a "Before Cancel" string 78 | cont.yield("Before Cancel") 79 | 80 | // Wait for the assigning to be completed 81 | _ = await done.value 82 | 83 | // THEN our label is "Before Cancel" 84 | #expect(label.text == "Before Cancel") 85 | 86 | // WHEN we cancel our tasks 87 | self.tasks.cancelAll() 88 | 89 | // WHEN we emit an "After Cancel" string 90 | cont.yield("After Cancel") 91 | 92 | // We'll use a hacky `.sleep()` here because we don't have a 93 | // handle on the task anymore as we cancelled it 94 | try await Task.sleep(for: .milliseconds(500)) 95 | 96 | // THEN our label shouldn't be updated because we 97 | // cancelled our tasks 98 | #expect(label.text == "Before Cancel") 99 | 100 | cont.finish() 101 | } 102 | 103 | @Test("No Crash or Assignment After Target Deallocation (weak capture)") 104 | func noAssignmentAfterDeinit() async throws { 105 | weak var weakLabel: Label? 106 | var strongLabel: Label? = Label() 107 | 108 | weakLabel = strongLabel 109 | 110 | let (stream, cont) = AsyncStream.makeStream() 111 | 112 | // GIVEN we create an assignment while the object is alive 113 | stream 114 | .assign(to: \.text, on: strongLabel!) 115 | .store(in: &tasks) 116 | 117 | // Wait for the label to actually receive "Alive" 118 | let done = Task { 119 | return await strongLabel!.observed(\.text) 120 | .first { @Sendable in $0 == "Alive" } 121 | } 122 | 123 | // WHEN we emit "Alive" 124 | cont.yield("Alive") 125 | 126 | // Wait for the assigning to be completed 127 | _ = await done.value 128 | 129 | // THEN the `strongLabel` is assigned "Alive" 130 | #expect(strongLabel?.text == "Alive") 131 | 132 | // GIVEN we drop the strong reference - assignment closure captures [weak object] 133 | strongLabel = nil 134 | #expect(weakLabel == nil) 135 | 136 | // WHEN we emit more values — should be ignored (and not crash) 137 | cont.yield("Ignored") 138 | cont.yield("Also Ignored") 139 | 140 | // We'll use a hacky `.sleep()` here because we don't have a 141 | // handle on the task anymore as we cancelled it 142 | try await Task.sleep(for: .milliseconds(500)) 143 | 144 | cont.finish() 145 | 146 | // Nothing to assert on the deallocated label; test passes if no crash 147 | self.tasks.cancelAll() 148 | } 149 | 150 | @Test("Propagates Sequence Errors to the Catching Closure") 151 | func errorPropagation() async throws { 152 | // Build an AsyncThrowingStream that emits then fails 153 | let (stream, cont) = AsyncThrowingStream.makeStream() 154 | 155 | // Track the error callback 156 | let errorBox = AsyncBox(nil) 157 | 158 | // GIVEN we create an assignment while the object is alive 159 | stream 160 | .assign(to: \.text, on: label) { error in 161 | Task { 162 | await errorBox.set(error) 163 | } 164 | } 165 | .store(in: &tasks) 166 | 167 | // Wait for the label to actually receive "Hello" 168 | let done = Task { 169 | return await label.observed(\.text) 170 | .first { @Sendable in $0 == "Hello" } 171 | } 172 | 173 | // WHEN we emit one value, then fail 174 | cont.yield("Hello") 175 | 176 | // Wait for the assigning to be completed 177 | _ = await done.value 178 | 179 | #expect(label.text == "Hello") 180 | 181 | // WHEN we finish with an error 182 | cont.finish(throwing: TestError.boom) 183 | 184 | // Wait a hot minute for the error to propogate 185 | try await Task.sleep(for: .milliseconds(500)) 186 | 187 | // THEN the error we have 188 | let receivedError = await errorBox.get() 189 | #expect(receivedError is TestError) 190 | 191 | // Cleanup 192 | self.tasks.cancelAll() 193 | } 194 | 195 | } 196 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Testing/RecorderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecorderTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 7/10/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Testing 10 | 11 | @Suite("RecorderTests") 12 | struct RecorderTests { 13 | 14 | @Test("Emits Values In Order") 15 | func emitsInOrder() async throws { 16 | // GIVEN we have a stream that emits 1,2,3 17 | let stream = stream([1, 2, 3]) 18 | 19 | // WHEN we record it 20 | let recorder = stream.record() 21 | 22 | // THEN the next values are 1,2,3 23 | #expect(try await recorder.next() == 1) 24 | #expect(try await recorder.next() == 2) 25 | #expect(try await recorder.next() == 3) 26 | } 27 | 28 | @Test("Respects Timeout") 29 | func respectsTimeout() async throws { 30 | // GIVEN we have a stream that delays emitting 42 31 | // after 1 second has passed 32 | let stream = delayedStream([42], delay: .seconds(1)) 33 | let recorder = stream.record() 34 | 35 | // THEN we get the timeout error 36 | await #expect(throws: RecordingError.timeout) { 37 | // WHEN we wait for the next value but only for 38 | // 100 milliseconds 39 | try await recorder.next(timeout: .milliseconds(100)) 40 | } 41 | } 42 | 43 | @Test("Cancel Stops Further Delivery") 44 | func cancelStopsFurtherDelivery() async throws { 45 | // GIVEN we have a stream that delays emitting 1,2,3 46 | // after 50 milliseconds have passed 47 | let stream = delayedStream([1, 2, 3], delay: .milliseconds(50)) 48 | let recorder = stream.record() 49 | 50 | // WHEN we listen to just one value 51 | let first = try await recorder.next(timeout: .seconds(1)) 52 | 53 | // THEN it's the correct value 54 | #expect(first == 1) 55 | 56 | // GIVEN we cancel the recorder 57 | await recorder.cancel() 58 | 59 | // THEN the recorder throws an appropriate error 60 | await #expect(throws: RecordingError.sourceEnded) { 61 | // WHEN we request another value 62 | try await recorder.next(timeout: .milliseconds(200)) 63 | } 64 | } 65 | 66 | @Test( 67 | "Works With Throwing Upstream (errors are swallowed and stream finishes)" 68 | ) 69 | func swallowingUpstreamErrorsFinishes() async throws { 70 | enum Boom: Error { 71 | case boom 72 | } 73 | 74 | /// This sequence will emit just one value and then throw an error 75 | struct ThrowingSeq: AsyncSequence, Sendable { 76 | typealias Element = Int 77 | struct Iterator: AsyncIteratorProtocol { 78 | var emitted = false 79 | mutating func next() async throws -> Int? { 80 | // Have we emitted our value yet? 81 | if emitted == false { 82 | // We have not, emit it now 83 | emitted = true 84 | return 1 85 | } else { 86 | // We have emitted before, let's bail 87 | throw Boom.boom 88 | } 89 | } 90 | } 91 | func makeAsyncIterator() -> Iterator { Iterator() } 92 | } 93 | 94 | // WHEN we start recording our sequence that can throw 95 | let recorder = ThrowingSeq().record() 96 | 97 | // WHEN we pull the next value out of our recorder 98 | let one = try await recorder.next(timeout: .seconds(1)) 99 | 100 | // THEN the value is correct 101 | #expect(one == 1) 102 | 103 | // THEN we should get a sourceEnded error 104 | await #expect(throws: RecordingError.sourceEnded) { 105 | // WHEN we consume the next element 106 | try await recorder.next(timeout: .milliseconds(100)) 107 | } 108 | } 109 | 110 | @Test( 111 | "Buffering Policy: Bounded Still Delivers Earliest Values" 112 | ) 113 | func bufferingPolicyBounded() async throws { 114 | // Produce 5 quickly; bounded buffer should still deliver 115 | // earliest in order. 116 | let stream = AsyncStream( 117 | bufferingPolicy: .bufferingOldest(2) 118 | ) { cont in 119 | for i in 1...5 { 120 | cont.yield(i) 121 | } 122 | cont.finish() 123 | } 124 | 125 | // WHEN we record the bufferingOldest stream 126 | let recorder = Recorder( 127 | stream, 128 | bufferingPolicy: .bufferingOldest(2) 129 | ) 130 | 131 | 132 | // WHEN we pull in the first two values 133 | let a = try await recorder.next() 134 | let b = try await recorder.next() 135 | 136 | #expect([1,2].contains(a)) 137 | #expect([1,2,3].contains(b)) 138 | 139 | // Depending on buffering semantics, earliest two are retained. 140 | // #expect([1,2,3].contains(b)) // conservative check across implementations 141 | 142 | await #expect(throws: RecordingError.sourceEnded) { 143 | _ = try await recorder.next(timeout: .milliseconds(50)) // C 144 | _ = try await recorder.next(timeout: .milliseconds(50)) // D 145 | _ = try await recorder.next(timeout: .milliseconds(50)) // E 146 | 147 | // Should now finish 148 | _ = try await recorder.next(timeout: .milliseconds(50)) // ? 149 | } 150 | } 151 | 152 | @Test("No Actor-Ownership Hazards On Next()") 153 | func noActorOwnershipHazards() async throws { 154 | // This is mostly a smoke test to ensure next() reads via the 155 | // box and doesn’t hold actor state across suspension. 156 | let stream = delayedStream([10], delay: .milliseconds(10)) 157 | let recorder = stream.record() 158 | #expect(try await recorder.next() == 10) 159 | } 160 | 161 | } 162 | 163 | // MARK: - Private 164 | 165 | @available(iOS 16.0, macOS 13.0, *) 166 | private extension RecorderTests { 167 | 168 | /// Emits the given values then finishes. 169 | func stream(_ values: [T]) -> AsyncStream { 170 | AsyncStream { cont in 171 | for value in values { 172 | cont.yield(value) 173 | } 174 | cont.finish() 175 | } 176 | } 177 | 178 | /// Emits values with per-item delay, then finishes. 179 | func delayedStream( 180 | _ values: [T], 181 | delay: Duration 182 | ) -> AsyncStream { 183 | AsyncStream { cont in 184 | Task.detached { 185 | for v in values { 186 | try? await Task.sleep(for: delay) 187 | cont.yield(v) 188 | } 189 | cont.finish() 190 | } 191 | } 192 | } 193 | 194 | } 195 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Operators/AsyncSequenceSinkOnMainTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequenceSinkOnMainTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 29/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | import Testing 11 | 12 | @Suite( 13 | "AsyncSequence+SinkOnMain Tests", 14 | .serialized, 15 | .timeLimit(.minutes(1)) 16 | ) 17 | @MainActor 18 | final class AsyncSequenceSinkOnMainTests { 19 | 20 | // MARK: - Properties 21 | 22 | private var tasks = Set() 23 | 24 | deinit { 25 | self.tasks.cancelAll() 26 | } 27 | 28 | // MARK: - Tests 29 | 30 | @Test("Value and Finished handlers run on MainActor") 31 | func valueAndFinishOnMainActor() async { 32 | let (stream, cont) = AsyncStream.makeStream() 33 | let recorder = RecordingBox() 34 | let finishedOnMain = AsyncBox(false) 35 | 36 | // GIVEN a sinkOnMain that asserts main-thread execution 37 | stream 38 | .sinkOnMain( 39 | catching: { @MainActor _ in 40 | // Intentionally left blank. 41 | }, 42 | finished: { @MainActor in 43 | Task { 44 | await finishedOnMain.set(true) 45 | } 46 | }, 47 | { @MainActor value in 48 | await recorder.append(value) 49 | } 50 | ) 51 | .store(in: &tasks) 52 | 53 | // WHEN we emit and finish 54 | cont.yield(1) 55 | cont.yield(2) 56 | cont.yield(3) 57 | cont.finish() 58 | try? await Task.sleep(for: .milliseconds(60)) 59 | 60 | // THEN values are recorded and finish ran on main 61 | #expect(await recorder.snapshot() == [1, 2, 3]) 62 | #expect(await finishedOnMain.get() == true) 63 | } 64 | 65 | @Test("Error handler runs on MainActor and iteration stops") 66 | func errorOnMainActorStopsIteration() async { 67 | enum TestError: Error { case boom } 68 | 69 | let (stream, cont) = AsyncThrowingStream.makeStream() 70 | let recorder = RecordingBox() 71 | let errorCaptured = AsyncBox(false) 72 | 73 | // GIVEN a throwing stream with sinkOnMain 74 | stream 75 | .sinkOnMain( 76 | catching: { @MainActor error in 77 | #expect(error is TestError) 78 | Task { await errorCaptured.set(true) } 79 | }, 80 | finished: { @MainActor in 81 | // Should not be called on error 82 | Issue.record( 83 | "Finished should not be called after error" 84 | ) 85 | }, 86 | { @MainActor value in 87 | await recorder.append(value) 88 | } 89 | ) 90 | .store(in: &tasks) 91 | 92 | // WHEN we yield a value, then fail 93 | cont.yield("ok-1") 94 | try? await Task.sleep(for: .milliseconds(20)) 95 | #expect(await recorder.snapshot() == ["ok-1"]) 96 | 97 | cont.finish(throwing: TestError.boom) 98 | try? await Task.sleep(for: .milliseconds(40)) 99 | 100 | // THEN error was captured and no further values processed 101 | #expect(await errorCaptured.get() == true) 102 | 103 | cont.yield("after-error") 104 | try? await Task.sleep(for: .milliseconds(20)) 105 | #expect(await recorder.snapshot() == ["ok-1"]) 106 | } 107 | 108 | @Test("Cancelling Prevents Further Values") 109 | func cancelPreventsFurtherValues_NoFinished() async { 110 | let (stream, cont) = AsyncStream.makeStream() 111 | let recorder = RecordingBox() 112 | let finishedCalled = AsyncBox(false) 113 | let errorCalled = AsyncBox(false) 114 | 115 | stream 116 | .sinkOnMain( 117 | catching: { @MainActor _ in 118 | Task { await errorCalled.set(true) } 119 | }, 120 | finished: { @MainActor in 121 | Task { await finishedCalled.set(true) } 122 | }, 123 | { @MainActor value in 124 | await recorder.append(value) 125 | } 126 | ) 127 | .store(in: &tasks) 128 | 129 | // WHEN one value is delivered 130 | cont.yield("A") 131 | try? await Task.sleep(for: .milliseconds(20)) 132 | #expect(await recorder.snapshot() == ["A"]) 133 | 134 | // AND we cancel all subscriptions 135 | tasks.cancelAll() 136 | try? await Task.sleep(for: .milliseconds(10)) 137 | 138 | // AND we try to emit more and finish 139 | cont.yield("B") 140 | cont.yield("C") 141 | cont.finish() 142 | try? await Task.sleep(for: .milliseconds(40)) 143 | 144 | // THEN only the first value is present, and no error, and 145 | // finished got called. 146 | #expect(await recorder.snapshot() == ["A"]) 147 | #expect(await finishedCalled.get() == true) 148 | #expect(await errorCalled.get() == false) 149 | } 150 | 151 | @Test("receiveValue is awaited sequentially on MainActor (preserves order with suspension)") 152 | func sequentialAwaitOnMainActor() async { 153 | let (stream, cont) = AsyncStream.makeStream() 154 | let recorder = RecordingBox() 155 | 156 | // GIVEN per-element work in the main-actor value handler 157 | stream 158 | .sinkOnMain { @MainActor value in 159 | try? await Task.sleep(for: .milliseconds(15)) // simulate work 160 | await recorder.append(value) 161 | } 162 | .store(in: &tasks) 163 | 164 | // WHEN we push a quick burst and finish 165 | cont.yield(1) 166 | cont.yield(2) 167 | cont.yield(3) 168 | cont.finish() 169 | 170 | // THEN order is preserved and work is sequential 171 | try? await Task.sleep(for: .seconds(1)) 172 | #expect(await recorder.snapshot() == [1, 2, 3]) 173 | } 174 | 175 | @Test("Works with background producers but still delivers on MainActor") 176 | func backgroundProducer_DeliversOnMainActor() async { 177 | let (stream, cont) = AsyncStream.makeStream() 178 | let recorder = RecordingBox() 179 | let finishedOnMain = AsyncBox(false) 180 | 181 | stream 182 | .sinkOnMain( 183 | finished: { @MainActor in 184 | Task { await finishedOnMain.set(true) } 185 | }, 186 | { @MainActor value in 187 | await recorder.append(value) 188 | } 189 | ) 190 | .store(in: &tasks) 191 | 192 | // Produce from a background context 193 | let producer = Task.detached { 194 | cont.yield(42) 195 | cont.yield(43) 196 | cont.finish() 197 | } 198 | _ = await producer.result 199 | 200 | try? await Task.sleep(for: .milliseconds(80)) 201 | 202 | #expect(await recorder.snapshot() == [42, 43]) 203 | #expect(await finishedOnMain.get() == true) 204 | } 205 | 206 | } 207 | -------------------------------------------------------------------------------- /Tests/AsyncCombineTests/Operators/Observable+ObservedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Observable+ObservedTests.swift 3 | // AsyncCombine 4 | // 5 | // Created by William Lumley on 16/9/2025. 6 | // 7 | 8 | @testable import AsyncCombine 9 | import Foundation 10 | import Observation 11 | import Testing 12 | 13 | @Suite("Observable+Observed Tests") 14 | final class ObservedOperatorTests { 15 | 16 | // MARK: - Properties 17 | 18 | var tasks = Set() 19 | 20 | // MARK: - Types 21 | 22 | @Observable @MainActor 23 | final class Counter: @unchecked Sendable { 24 | var count: Int = 0 25 | init(_ count: Int = 0) { 26 | self.count = count 27 | } 28 | } 29 | 30 | // MARK: - Lifecycle 31 | 32 | deinit { 33 | self.tasks.cancelAll() 34 | } 35 | 36 | // MARK: - Tests 37 | 38 | @MainActor 39 | @Test("Replays Current Value and Then Emits on Subsequent Changes") 40 | func replayThenChanges() async { 41 | // Create a counter with a value of 41 42 | let counter = Counter(41) 43 | let recorder = RecordingBox() 44 | 45 | // GIVEN we listen to the value of our counter 46 | let stream = counter.observed(\.count) 47 | 48 | // Collect any values via sink 49 | stream 50 | .sink { value in 51 | await recorder.append(value) 52 | } 53 | .store(in: &tasks) 54 | 55 | // Give time for initial replay 56 | try? await Task.sleep(nanoseconds: 30_000_000) 57 | 58 | // THEN the observation first emits 41 59 | #expect(await recorder.snapshot() == [41]) 60 | 61 | // WHEN we set the `count` of `counter` to a bunch of 62 | // new values. 63 | await MainActor.run { counter.count = 42 } 64 | await MainActor.run { counter.count = 43 } 65 | await MainActor.run { counter.count = 44 } 66 | 67 | try? await Task.sleep(nanoseconds: 80_000_000) 68 | 69 | // THEN our observation records all our new values 70 | #expect(await recorder.snapshot() == [41, 42, 43, 44]) 71 | } 72 | 73 | @MainActor 74 | @Test("Does NOT Finish on Deinit; Consumer Should Cancel") 75 | func noRetainButNoAutoFinish() async { 76 | // GIVEN a stream from a short-lived Counter and a consumer that drains it 77 | var strong: Counter? = Counter(1) 78 | weak var weakRef = strong 79 | let stream = strong!.observed(\.count) 80 | 81 | let done = AsyncBox(false) 82 | let consumer: SubscriptionTask = Task { 83 | for await _ in stream { /* drain */ } 84 | await done.set(true) 85 | } 86 | var subs = Set() 87 | consumer.store(in: &subs) 88 | 89 | // THEN the source is still alive while we haven't released it 90 | try? await Task.sleep(nanoseconds: 30_000_000) 91 | #expect(weakRef != nil) 92 | 93 | // WHEN we drop the last strong reference to the source 94 | strong = nil 95 | 96 | // THEN the source deallocates but the stream doesn't auto-finish 97 | #expect(weakRef == nil) 98 | try? await Task.sleep(nanoseconds: 80_000_000) 99 | #expect(await done.get() == false) 100 | 101 | // WHEN the caller cancels the consumer 102 | subs.cancelAll() 103 | try? await Task.sleep(nanoseconds: 30_000_000) 104 | 105 | // THEN the draining task observes completion 106 | #expect(await done.get() == true) 107 | } 108 | 109 | @MainActor 110 | @Test("Re-Registers Correctly, Multiple Changes All Emit in Order") 111 | func reregisterHandlesChanges() async { 112 | // GIVEN an observed counter stream and a recorder 113 | let counter = Counter(0) 114 | let stream = counter.observed(\.count) 115 | let recorder = RecordingBox() 116 | 117 | // AND we start collecting via sink 118 | stream.sink { value in 119 | await recorder.append(value) 120 | } 121 | .store(in: &tasks) 122 | 123 | // THEN we first replay the current value (0) 124 | try? await Task.sleep(nanoseconds: 30_000_000) 125 | #expect(await recorder.snapshot() == [0]) 126 | 127 | // WHEN we mutate the counter several times on the main actor 128 | await MainActor.run { counter.count = 1 } 129 | await MainActor.run { counter.count = 2 } 130 | await MainActor.run { counter.count = 3 } 131 | await MainActor.run { counter.count = 4 } 132 | await MainActor.run { counter.count = 5 } 133 | 134 | // AND give time for the onChange → MainActor → yield loop to flush 135 | try? await Task.sleep(nanoseconds: 120_000_000) 136 | 137 | // THEN every intermediate value arrives in order 138 | #expect(await recorder.snapshot() == [0, 1, 2, 3, 4, 5]) 139 | } 140 | 141 | @MainActor 142 | @Test("Re-Registers Correctly, Multiple Rapid Changes Emit First and Last") 143 | func reregisterHandlesRapidChanges() async { 144 | // GIVEN an observed counter stream and a recorder 145 | let counter = Counter(0) 146 | let stream = counter.observed(\.count) 147 | let recorder = RecordingBox() 148 | 149 | // AND we start collecting via sink 150 | stream.sink { value in 151 | await recorder.append(value) 152 | } 153 | .store(in: &tasks) 154 | 155 | // THEN we first replay the current value (0) 156 | try? await Task.sleep(nanoseconds: 30_000_000) 157 | #expect(await recorder.snapshot() == [0]) 158 | 159 | // WHEN we apply a burst of rapid mutations within one MainActor turn 160 | await MainActor.run { 161 | counter.count = 1 162 | counter.count = 2 163 | counter.count = 3 164 | counter.count = 4 165 | counter.count = 5 166 | } 167 | 168 | // AND give time for the coalescing/re-registration path 169 | try? await Task.sleep(nanoseconds: 120_000_000) 170 | 171 | // THEN only the first replay and the final value are emitted 172 | #expect(await recorder.snapshot() == [0, 5]) 173 | } 174 | 175 | @MainActor 176 | @Test("Safe to Start Observing After Several Changes - Still Replays the Latest") 177 | func replayLatestWhenSubscribingLate() async { 178 | // GIVEN a counter that has already changed before observation 179 | let counter = Counter(10) 180 | await MainActor.run { 181 | counter.count = 11 182 | counter.count = 12 183 | } 184 | 185 | // AND we start observing after those changes 186 | let stream = counter.observed(\.count) 187 | let rec = RecordingBox() 188 | 189 | stream.sink { value in 190 | await rec.append(value) 191 | } 192 | .store(in: &tasks) 193 | 194 | // THEN the current value (12) is replayed immediately 195 | try? await Task.sleep(nanoseconds: 40_000_000) 196 | #expect(await rec.snapshot() == [12]) 197 | 198 | // WHEN a subsequent change occurs 199 | await MainActor.run { counter.count = 13 } 200 | try? await Task.sleep(nanoseconds: 40_000_000) 201 | 202 | // THEN it appends after the replay 203 | #expect(await rec.snapshot() == [12, 13]) 204 | } 205 | 206 | } 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![AsyncCombine: Combine's Syntax](https://raw.githubusercontent.com/will-lumley/AsyncCombine/main/AsyncCombine.png) 2 | 3 | # AsyncCombine 4 | 5 |

6 | Apple - CI Status 7 | Linux - CI Status 8 |

9 |

10 | SPM Compatible 11 | Swift 6.2 12 | 13 | Bluesky 14 | 15 | 16 | Mastodon 17 | 18 |

19 | 20 | AsyncCombine brings familiar Combine-style operators like `sink`, `assign`, and `store(in:)` to the world of Swift Concurrency. 21 | 22 | While Swift Concurrency has certainly been an improvement over Combine when combined (heh) with swift-async-algorithms, managing multiple subscriptions can be quite a messy process. 23 | 24 | Introducing, AsyncCombine! It’s built on top of `AsyncSequence` and integrated with Swift’s `Observation` framework, so you can react to `@Observable` model changes, bind values to UI, and manage state, all without importing Combine. Beacuse of this, it works on any platform that Swift runs on, from iOS and macOS to Linux and SwiftWasm. 25 | 26 | It also ships with CurrentValueRelay, a replay-1 async primitive inspired by Combine’s `CurrentValueSubject`, giving you a simple way to bridge stateful streams between domain logic and presentation. 27 | 28 | While async/await brought clarity and safety to Swift’s concurrency story, working directly with AsyncSequence can sometimes feel verbose and clunky, especially when compared to Combine’s elegant, declarative pipelines. With Combine, you chain operators fluently (map → filter → sink) and manage lifetimes in one place. By contrast, async/await often forces you into nested for await loops, manual task management, and boilerplate cancellation. AsyncCombine bridges this gap: it keeps the expressive syntax and ergonomics of Combine while running entirely on Swift Concurrency. You get the readability of Combine pipelines, without the overhead of pulling in Combine itself or losing portability. 29 | 30 | Let's get into the nuts and bolts of it all and look into how AsyncCombine can improve your Swift Concurrency exerience. 31 | 32 | So say you have a View Model like below. 33 | ```swift 34 | @Observable @MainActor 35 | final class CounterViewModel { 36 | var count: Int = 0 37 | } 38 | ``` 39 | 40 | In a traditional async/await setup, you would listen to the new value being published like so. 41 | ```swift 42 | let viewModel = CounterViewModel() 43 | 44 | let countChanges = Observations { 45 | self.viewModel.count 46 | } 47 | 48 | Task { 49 | for await count in countChanges.map({ "Count: \($0)" }) { 50 | print("\(count)") 51 | } 52 | } 53 | ``` 54 | 55 | However with AsyncCombine you can express the same logic in a more concise and easy to read format. 56 | ```swift 57 | var subscriptions = Set() 58 | 59 | viewModel.observed(\.count) 60 | .map { "Count: \($0)" } 61 | .sink { print($0) } 62 | .store(in: &subscriptions) 63 | ``` 64 | 65 | 66 | ## ✨ Features 67 | 68 | ### 🔗 Combine-like Syntax 69 | - Write familiar, declarative pipelines without pulling in Combine. 70 | - Use `.sink {}` to respond to values from any `AsyncSequence`. 71 | - Use `.assign(to:on:)` to bind values directly to object properties (e.g. `label.textColor`). 72 | - Manage lifetimes with `.store(in:)` on Task sets, just like `AnyCancellable`. 73 | 74 | ### 👀 Observation Integration 75 | - Seamlessly connect to Swift’s new Observation framework. 76 | - Turn `@Observable` properties into streams with `observed(\.property)`. 77 | - Automatically replay the current value, then emit fresh values whenever the property changes. 78 | - Perfect for keeping UI state in sync with your models. 79 | 80 | ### 🔁 CurrentValueSubject Replacement 81 | - Ship values through your app with a hot, replay-1 async primitive. 82 | - `CurrentValueRelay` holds the latest value and broadcasts it to all listeners. 83 | - Similar to Combine’s `CurrentValueSubject`, but actor-isolated and async-first. 84 | - Exposes an AsyncStream for easy consumption in UI or domain code. 85 | 86 | ### 🔗 Publishers.CombineLatest Replacement 87 | - Pair two AsyncSequences and emit the latest tuple whenever either side produces a new element (after both have emitted at least once). 88 | - Finishes when both upstream sequences finish (CombineLatest semantics). 89 | - Cancellation of the downstream task cancels both upstream consumers. 90 | - Plays nicely with Swift Async Algorithms (e.g. you can map, debounce, etc. before/after). 91 | 92 | ### ⚡ Async Algorithms Compatible 93 | - Compose richer pipelines using Swift Async Algorithms. 94 | - Add `.debounce`, `.throttle`, `.merge`, `.zip`, and more to your async streams. 95 | - Chain seamlessly with AsyncCombine operators (`sink`, `assign`, etc.). 96 | - Great for smoothing UI inputs, combining event streams, and building complex state machines. 97 | 98 | ### 🌍 Cross-Platform 99 | - AsyncCombine doesn’t rely on Combine or other Apple-only frameworks. 100 | - Runs anywhere Swift Concurrency works: iOS, macOS, tvOS, watchOS. 101 | - Fully portable to Linux and even SwiftWasm for server-side and web targets. 102 | - Ideal for writing platform-agnostic domain logic and unit tests. 103 | 104 | ## 🚀 Usage 105 | 106 | ### Observe @Observable properties 107 | Turn any `@Observable` property into an `AsyncStream` that replays the current value and then emits on every change. Chain standard `AsyncSequence` operators (`map`, `filter`, `compactMap`, ...) and finish with `sink` or `assign`. 108 | 109 | ```swift 110 | import AsyncCombine 111 | import Observation 112 | 113 | @Observable @MainActor 114 | final class CounterViewModel { 115 | var count: Int = 0 116 | } 117 | 118 | let viewModel = CounterViewModel() 119 | var subscriptions = Set() 120 | 121 | // $viewModel.count → viewModel.observed(\.count) 122 | viewModel.observed(\.count) 123 | .map { "Count: \($0)" } 124 | .sink { print($0) } 125 | .store(in: &subscriptions) 126 | 127 | viewModel.count += 1 // prints "Count: 1" 128 | ``` 129 | 130 | Why it works: `observed(_:)` uses `withObservationTracking` under the hood and reads on `MainActor`, so you always get the fresh value (no stale reads). 131 | 132 | ### Bind to UI (UIKit / AppKit / SpriteKit / custom objects) 133 | 134 | ```swift 135 | // UILabel example 136 | let label = UILabel() 137 | 138 | viewModel.observed(\.count) 139 | .map { 140 | UIColor( 141 | hue: CGFloat($0 % 360) / 360, 142 | saturation: 1, 143 | brightness: 1, 144 | alpha: 1 145 | ) 146 | } 147 | .assign(to: \.textColor, on: label) 148 | .store(in: &subscriptions) 149 | ``` 150 | Works the same for `NSTextField.textColor, `SKShapeNode.fillColor`, your own class properties, etc. 151 | 152 | ### Use CurrentValueRelay for hot, replay-1 state 153 | 154 | `CurrentValueRelay` holds the latest value and broadcasts it to all listeners. `stream()` yields the current value immediately, then subsequent updates. 155 | 156 | ```swift 157 | let relay = CurrentValueRelay(false) 158 | var subs = Set() 159 | 160 | relay.stream() 161 | .map { $0 ? "ON" : "OFF" } 162 | .sink { print($0) } // "OFF" immediately (replay) 163 | .store(in: &subs) 164 | 165 | Task { 166 | await relay.send(true) // prints "ON" 167 | await relay.send(false) // prints "OFF" 168 | } 169 | ``` 170 | 171 | Cancel tasks when you’re done (e.g., deinit). 172 | 173 | ```swift 174 | subs.cancelAll() 175 | ``` 176 | 177 | ### Combine multiple AsyncSequences into a single AsyncSequence 178 | 179 | ```swift 180 | import AsyncAlgorithms 181 | import AsyncCombine 182 | 183 | // Two arbitrary async streams 184 | let a = AsyncStream { cont in 185 | Task { 186 | for i in 1...3 { 187 | try? await Task.sleep(nanoseconds: 100_000_000) 188 | cont.yield(i) // 1, 2, 3 189 | } 190 | cont.finish() 191 | } 192 | } 193 | 194 | let b = AsyncStream { cont in 195 | Task { 196 | for s in ["A", "B"] { 197 | try? await Task.sleep(nanoseconds: 150_000_000) 198 | cont.yield(s) // "A", "B" 199 | } 200 | cont.finish() 201 | } 202 | } 203 | 204 | // combineLatest-style pairing 205 | var tasks = Set() 206 | 207 | AsyncCombine.CombineLatest(a, b) 208 | .map { i, s in "Pair: \(i) & \(s)" } 209 | .sink { print($0) } 210 | .store(in: &tasks) 211 | 212 | // Prints (timing-dependent, after both have emitted once): 213 | // "Pair: 2 & A" 214 | // "Pair: 3 & A" 215 | // "Pair: 3 & B" 216 | 217 | ``` 218 | 219 | ### Debounce, throttle, merge (with Swift Async Algorithms) 220 | 221 | AsyncCombine plays nicely with [Swift Async Algorithms]. Import it to get reactive operators you know from Combine. 222 | 223 | ```swift 224 | import AsyncAlgorithms 225 | 226 | viewModel.observed(\.count) 227 | .debounce(for: .milliseconds(250)) // smooth noisy inputs 228 | .map { "Count: \($0)" } 229 | .sink { print($0) } 230 | .store(in: &subscriptions) 231 | ``` 232 | You can also `merge` multiple streams, `zip` them, `removeDuplicates`, etc. 233 | 234 | ### Lifecycle patterns (Combine-style ergonomics) 235 | 236 | Keep your subscriptions alive as long as you need them: 237 | ```swift 238 | final class Monitor { 239 | private var subscriptions = Set() 240 | private let vm: CounterViewModel 241 | 242 | init(vm: CounterViewModel) { 243 | self.vm = vm 244 | 245 | vm.observed(\.count) 246 | .map(String.init) 247 | .sink { print("Count:", $0) } 248 | .store(in: &subscriptions) 249 | } 250 | 251 | deinit { 252 | subscriptions.cancelAll() 253 | } 254 | } 255 | ``` 256 | 257 | ### Handle throwing streams (works for both throwing & non-throwing) 258 | 259 | `sink(catching:_:)` uses an iterator under the hood, so you can consume throwing sequences too. If your pipeline introduces errors, add an error handler: 260 | 261 | ```swift 262 | someThrowingAsyncSequence // AsyncSequence whose iterator `next()` can throw 263 | .map { $0 } // your transforms here 264 | .sink(catching: { error in 265 | print("Stream error:", error) 266 | }) { value in 267 | print("Value:", value) 268 | } 269 | .store(in: &subscriptions) 270 | ``` 271 | If your stream is non-throwing (e.g., `AsyncStream`, `relay.stream()`), just omit `catching:`. 272 | 273 | ### Quick Reference 274 | 275 | - `observed(\.property)` → `AsyncStream` (replay-1, Observation-backed) 276 | - `sink { value in … }` → consume elements (returns Task you can cancel or `.store(in:)`) 277 | - `assign(to:on:)` → main-actor property binding 278 | - `CurrentValueRelay` → `send(_:)`, `stream(replay: true)` 279 | - `subscriptions.cancelAll()` → cancel everything (like clearing AnyCancellables) 280 | 281 | ### SwiftUI Tip 282 | SwiftUI already observes `@Observable` models. You usually don’t need `observed(_:)` inside a View for simple UI updates—bind directly to the model. Use `observed(_:)` when you need pipelines (`debounce`, `merge`, etc) or when binding to non-SwiftUI objects (eg., SpriteKit, UIKit). 283 | 284 | ## 🧪 Testing 285 | 286 | AsyncCombine ships with lightweight testing utilities that make it easy to record, inspect, and assert values emitted by AsyncSequences. 287 | This lets you write deterministic async tests without manual loops, sleeps, or boilerplate cancellation logic. 288 | 289 | ### 📹 Testing with Recorder 290 | 291 | The `Recorder` class helps you capture and assert values emitted by any `AsyncSequence`. 292 | 293 | It continuously consumes the sequence on a background task and buffers each element, allowing your test to await them one by one with predictable timing. 294 | 295 | ```swift 296 | import AsyncCombine 297 | import Testing // or XCTest 298 | 299 | @Test 300 | func testRelayEmitsExpectedValues() async throws { 301 | let relay = CurrentValueRelay(0) 302 | let recorder = relay.stream().record() 303 | 304 | await relay.send(1) 305 | await relay.send(2) 306 | 307 | let first = try await recorder.next() 308 | let second = try await recorder.next() 309 | 310 | #expect(first == 1) 311 | #expect(second == 2) 312 | } 313 | ``` 314 | 315 | `Recorder` makes it easy to verify asynchronous behaviour without juggling timers or nested loops. 316 | 317 | If the next value doesn’t arrive within the timeout window, it automatically reports a failure (via `Issue.record` or `XCTFail`, depending on your test framework). 318 | 319 | ### 🥇 Finding the First Matching Value 320 | 321 | `AsyncCombine` also extends `AsyncSequence` with a convenience helper for asserting specific values without fully consuming the sequence. 322 | 323 | ```swift 324 | import AsyncCombine 325 | import Testing 326 | 327 | @Test 328 | func testStreamEmitsSpecificValue() async throws { 329 | let stream = AsyncStream { cont in 330 | cont.yield(1) 331 | cont.yield(2) 332 | cont.yield(3) 333 | cont.finish() 334 | } 335 | 336 | // Wait for the first element equal to 2. 337 | let match = await stream.first(equalTo: 2) 338 | #expect(match == 2) 339 | } 340 | ``` 341 | 342 | This suspends until the first matching element arrives, or returns nil if the sequence finishes first. 343 | 344 | It’s ideal when you just need to confirm that a certain value appears somewhere in an async stream. 345 | 346 | ## 📦 Installation 347 | 348 | Add this to your Package.swift: 349 | ```swift 350 | dependencies: [ 351 | .package(url: "https://github.com/will-lumley/AsyncCombine.git", from: "1.0.3") 352 | ] 353 | ``` 354 | 355 | Or in Xcode: File > Add Packages... and paste the repo URL. 356 | 357 | ## Author 358 | 359 | [William Lumley](https://lumley.io/), will@lumley.io 360 | 361 | ## License 362 | 363 | AsyncCombine is available under the MIT license. See the LICENSE file for more info. 364 | --------------------------------------------------------------------------------