├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── Package@swift-5.10.swift ├── README.md ├── Sources ├── IdentifiableContinuation.swift ├── Mutex.swift └── MutexSwift5.swift ├── Tests ├── IdentifiableContinuationTests.swift ├── IdentifiableContinuationXCTests.swift ├── MutexTests.swift └── MutexXCTests.swift ├── codecov.yml └── docker-run-tests.sh /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | xcode_16_4: 10 | runs-on: macos-15 11 | env: 12 | DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Version 17 | run: swift --version 18 | - name: Build 19 | run: swift build --build-tests --enable-code-coverage 20 | - name: Test 21 | run: swift test --skip-build --enable-code-coverage 22 | - name: Gather code coverage 23 | run: xcrun llvm-cov export -format="lcov" .build/debug/IdentifiableContinuationPackageTests.xctest/Contents/MacOS/IdentifiableContinuationPackageTests -instr-profile .build/debug/codecov/default.profdata > coverage_report.lcov 24 | - name: Upload Coverage 25 | uses: codecov/codecov-action@v4 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} 28 | files: ./coverage_report.lcov 29 | 30 | xcode_16_2: 31 | runs-on: macos-15 32 | env: 33 | DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v4 37 | - name: Version 38 | run: swift --version 39 | - name: Build 40 | run: swift build --build-tests 41 | - name: Test 42 | run: swift test --skip-build 43 | 44 | xcode_15_4: 45 | runs-on: macos-14 46 | env: 47 | DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | - name: Version 52 | run: swift --version 53 | - name: Build 54 | run: swift build --build-tests 55 | - name: Test 56 | run: swift test --skip-build 57 | 58 | linux_swift_5_10: 59 | runs-on: ubuntu-latest 60 | container: swift:5.10 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v4 64 | - name: Version 65 | run: swift --version 66 | - name: Build 67 | run: swift build --build-tests 68 | - name: Test 69 | run: swift test --skip-build 70 | 71 | linux_swift_6_0: 72 | runs-on: ubuntu-latest 73 | container: swift:6.0.3 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v4 77 | - name: Version 78 | run: swift --version 79 | - name: Build 80 | run: swift build --build-tests 81 | - name: Test 82 | run: swift test --skip-build 83 | 84 | linux_swift_6_1: 85 | runs-on: ubuntu-latest 86 | container: swift:6.1.2 87 | steps: 88 | - name: Checkout 89 | uses: actions/checkout@v4 90 | - name: Version 91 | run: swift --version 92 | - name: Build 93 | run: swift build --build-tests 94 | - name: Test 95 | run: swift test --skip-build 96 | 97 | linux_swift_6_1_musl: 98 | runs-on: ubuntu-latest 99 | container: swift:6.1.2 100 | steps: 101 | - name: Checkout 102 | uses: actions/checkout@v4 103 | - name: Version 104 | run: swift --version 105 | - name: SDK List Pre 106 | run: swift sdk list 107 | - name: Install SDK 108 | run: swift sdk install https://download.swift.org/swift-6.1.2-release/static-sdk/swift-6.1.2-RELEASE/swift-6.1.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum df0b40b9b582598e7e3d70c82ab503fd6fbfdff71fd17e7f1ab37115a0665b3b 109 | - name: SDK List Post 110 | run: swift sdk list 111 | - name: Build 112 | run: swift build --swift-sdk x86_64-swift-linux-musl 113 | 114 | linux_swift_6_1_android: 115 | runs-on: ubuntu-latest 116 | container: swift:6.1.2 117 | steps: 118 | - name: Checkout 119 | uses: actions/checkout@v4 120 | - name: Version 121 | run: swift --version 122 | - name: Install SDK 123 | run: swift sdk install https://github.com/finagolfin/swift-android-sdk/releases/download/6.1.2/swift-6.1.2-RELEASE-android-24-0.1.artifactbundle.tar.gz --checksum 6d817c947870e8c85e6cab9a6ab6d7313b50fa5a20b890c396723c0b16ab32d9 124 | - name: Build 125 | run: swift build --swift-sdk aarch64-unknown-linux-android24 126 | 127 | windows_swift_6_1: 128 | runs-on: windows-latest 129 | steps: 130 | - name: Checkout 131 | uses: actions/checkout@v4 132 | - name: Install Swift 133 | uses: SwiftyLab/setup-swift@latest 134 | with: 135 | swift-version: "6.1.2" 136 | - name: Version 137 | run: swift --version 138 | - name: Build 139 | run: swift build --build-tests 140 | - name: Test 141 | run: swift test --skip-build 142 | -------------------------------------------------------------------------------- /.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 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Simon Whitty 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "IdentifiableContinuation", 7 | platforms: [ 8 | .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6) 9 | ], 10 | products: [ 11 | .library( 12 | name: "IdentifiableContinuation", 13 | targets: ["IdentifiableContinuation"] 14 | ) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "IdentifiableContinuation", 19 | path: "Sources", 20 | swiftSettings: .upcomingFeatures 21 | ), 22 | .testTarget( 23 | name: "IdentifiableContinuationTests", 24 | dependencies: ["IdentifiableContinuation"], 25 | path: "Tests", 26 | swiftSettings: .upcomingFeatures 27 | ) 28 | ] 29 | ) 30 | 31 | extension Array where Element == SwiftSetting { 32 | 33 | static var upcomingFeatures: [SwiftSetting] { 34 | [ 35 | .enableUpcomingFeature("ExistentialAny"), 36 | .swiftLanguageMode(.v6) 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Package@swift-5.10.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "IdentifiableContinuation", 7 | platforms: [ 8 | .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6) 9 | ], 10 | products: [ 11 | .library( 12 | name: "IdentifiableContinuation", 13 | targets: ["IdentifiableContinuation"] 14 | ) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "IdentifiableContinuation", 19 | path: "Sources", 20 | swiftSettings: .upcomingFeatures 21 | ), 22 | .testTarget( 23 | name: "IdentifiableContinuationTests", 24 | dependencies: ["IdentifiableContinuation"], 25 | path: "Tests", 26 | swiftSettings: .upcomingFeatures 27 | ) 28 | ] 29 | ) 30 | 31 | extension Array where Element == SwiftSetting { 32 | 33 | static var upcomingFeatures: [SwiftSetting] { 34 | [ 35 | .enableUpcomingFeature("ExistentialAny"), 36 | .enableExperimentalFeature("StrictConcurrency") 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/swhitty/IdentifiableContinuation/actions/workflows/build.yml/badge.svg)](https://github.com/swhitty/IdentifiableContinuation/actions/workflows/build.yml) 2 | [![Codecov](https://codecov.io/gh/swhitty/IdentifiableContinuation/graphs/badge.svg)](https://codecov.io/gh/swhitty/IdentifiableContinuation) 3 | [![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswhitty%2FIdentifiableContinuation%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swhitty/IdentifiableContinuation) 4 | [![Swift 6.0](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswhitty%2FIdentifiableContinuation%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swhitty/IdentifiableContinuation) 5 | 6 | # Introduction 7 | 8 | **IdentifiableContinuation** is a lightweight wrapper around [`CheckedContinuation`](https://developer.apple.com/documentation/swift/checkedcontinuation) that conforms to [`Identifiable`](https://developer.apple.com/documentation/swift/identifiable) and includes an easy to use cancellation handler with the id. 9 | 10 | # Installation 11 | 12 | IdentifiableContinuation can be installed by using Swift Package Manager. 13 | 14 | **Note:** IdentifiableContinuation requires Swift 5.10 on Xcode 15.4+. It runs on iOS 13+, tvOS 13+, macOS 10.15+, Linux and Windows. 15 | To install using Swift Package Manager, add this to the `dependencies:` section in your Package.swift file: 16 | 17 | ```swift 18 | .package(url: "https://github.com/swhitty/IdentifiableContinuation.git", .upToNextMajor(from: "0.4.0")) 19 | ``` 20 | 21 | # Usage 22 | 23 | With Swift 6, usage is similar to existing continuations. 24 | 25 | ```swift 26 | let val: String = await withIdentifiableContinuation { 27 | continuations[$0.id] = $0 28 | } 29 | ``` 30 | 31 | The body closure is executed syncronously within the current isolation allowing actors to mutate their isolated state. 32 | 33 | An optional cancellation handler is called when the task is cancelled. The handler is `@Sendable` and can be called at any time _after_ the body has completed. 34 | 35 | ```swift 36 | let val: String = await withIdentifiableContinuation { 37 | continuations[$0.id] = $0 38 | } onCancel: { id in 39 | // @Sendable closure executed outside of actor isolation requires `await` to mutate actor state 40 | Task { await self.cancelContinuation(with: id) } 41 | } 42 | ``` 43 | 44 | > The above is also compatible in Swift 5 language mode using a Swift 6 compiler e.g. Xcode 16 45 | 46 | ## Swift 5 47 | 48 | While behaviour is identical, Swift 5 compilers (Xcode 15) are unable to inherit actor isolation through the new `#isolation` keyword ([SE-420](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0420-inheritance-of-actor-isolation.md)) so an `isolated` reference to the current actor must always be passed. 49 | 50 | ```swift 51 | let val: String = await withIdentifiableContinuation(isolation: self) { 52 | continuations[$0.id] = $0 53 | } 54 | ``` 55 | 56 | # Credits 57 | 58 | IdentifiableContinuation is primarily the work of [Simon Whitty](https://github.com/swhitty). 59 | 60 | ([Full list of contributors](https://github.com/swhitty/IdentifiableContinuation/graphs/contributors)) 61 | -------------------------------------------------------------------------------- /Sources/IdentifiableContinuation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentifiableContinuation.swift 3 | // IdentifiableContinuation 4 | // 5 | // Created by Simon Whitty on 20/05/2023. 6 | // Copyright 2023 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/IdentifiableContinuation 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if compiler(>=6.0) 33 | /// Invokes the passed in closure with an `IdentifableContinuation` for the current task. 34 | /// 35 | /// The body of the closure executes synchronously on the calling actor. Once it returns the calling task is suspended. 36 | /// It is possible to immediately resume the task, or escape the continuation in order to complete it afterwards, which 37 | /// will then resume the suspended task. 38 | /// 39 | /// You must invoke the continuation's `resume` method exactly once. 40 | /// - Parameters: 41 | /// - function: A string identifying the declaration that is the notional 42 | /// source for the continuation, used to identify the continuation in 43 | /// runtime diagnostics related to misuse of this continuation. 44 | /// - body: A closure that takes a `IdentifiableContinuation` parameter. 45 | /// - handler: Cancellation closure executed when the current Task is cancelled. Handler is always called _after_ the body closure is compeled. 46 | /// - Returns: The value continuation is resumed with. 47 | @inlinable 48 | public func withIdentifiableContinuation( 49 | isolation: isolated (any Actor)? = #isolation, 50 | function: String = #function, 51 | body: (IdentifiableContinuation) -> Void, 52 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void 53 | ) async -> T { 54 | let id = IdentifiableContinuation.ID() 55 | let state = Mutex((isStarted: false, isCancelled: false)) 56 | nonisolated(unsafe) let body = body 57 | return await withTaskCancellationHandler { 58 | await withCheckedContinuation(isolation: isolation, function: function) { 59 | let continuation = IdentifiableContinuation(id: id, continuation: $0) 60 | body(continuation) 61 | let sendCancel = state.withLock { 62 | $0.isStarted = true 63 | return $0.isCancelled 64 | } 65 | if sendCancel { 66 | handler(id) 67 | } 68 | } 69 | } onCancel: { 70 | let sendCancel = state.withLock { 71 | $0.isCancelled = true 72 | return $0.isStarted 73 | } 74 | if sendCancel { 75 | handler(id) 76 | } 77 | } 78 | } 79 | 80 | /// Invokes the passed in closure with an `IdentifableContinuation` for the current task. 81 | /// 82 | /// The body of the closure executes synchronously on the calling actor. Once it returns the calling task is suspended. 83 | /// It is possible to immediately resume the task, or escape the continuation in order to complete it afterwards, which 84 | /// will then resume the suspended task. 85 | /// 86 | /// You must invoke the continuation's `resume` method exactly once. 87 | /// - Parameters: 88 | /// - function: A string identifying the declaration that is the notional 89 | /// source for the continuation, used to identify the continuation in 90 | /// runtime diagnostics related to misuse of this continuation. 91 | /// - body: A closure that takes a `IdentifiableContinuation` parameter. 92 | /// - handler: Cancellation closure executed when the current Task is cancelled. Handler is always called _after_ the body closure is compeled. 93 | /// - Returns: The value continuation is resumed with. 94 | @inlinable 95 | public func withIdentifiableThrowingContinuation( 96 | isolation: isolated (any Actor)? = #isolation, 97 | function: String = #function, 98 | body: (IdentifiableContinuation) -> Void, 99 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void 100 | ) async throws -> T { 101 | let id = IdentifiableContinuation.ID() 102 | let state = Mutex((isStarted: false, isCancelled: false)) 103 | nonisolated(unsafe) let body = body 104 | return try await withTaskCancellationHandler { 105 | try await withCheckedThrowingContinuation(isolation: isolation, function: function) { 106 | let continuation = IdentifiableContinuation(id: id, continuation: $0) 107 | body(continuation) 108 | let sendCancel = state.withLock { 109 | $0.isStarted = true 110 | return $0.isCancelled 111 | } 112 | if sendCancel { 113 | handler(id) 114 | } 115 | } 116 | } onCancel: { 117 | let sendCancel = state.withLock { 118 | $0.isCancelled = true 119 | return $0.isStarted 120 | } 121 | if sendCancel { 122 | handler(id) 123 | } 124 | } 125 | } 126 | #else 127 | /// Invokes the passed in closure with an `IdentifableContinuation` for the current task. 128 | /// 129 | /// The body of the closure executes synchronously on the calling actor. Once it returns the calling task is suspended. 130 | /// It is possible to immediately resume the task, or escape the continuation in order to complete it afterwards, which 131 | /// will then resume the suspended task. 132 | /// 133 | /// You must invoke the continuation's `resume` method exactly once. 134 | /// - Parameters: 135 | /// - isolation: Actor isolation used when executing the body closure. 136 | /// - function: A string identifying the declaration that is the notional 137 | /// source for the continuation, used to identify the continuation in 138 | /// runtime diagnostics related to misuse of this continuation. 139 | /// - body: A closure that takes a `IdentifiableContinuation` parameter. 140 | /// - handler: Cancellation closure executed when the current Task is cancelled. Handler is always called _after_ the body closure is compeled. 141 | /// - Returns: The value continuation is resumed with. 142 | @_unsafeInheritExecutor 143 | @inlinable 144 | public func withIdentifiableContinuation( 145 | isolation: isolated some Actor, 146 | function: String = #function, 147 | body: (IdentifiableContinuation) -> Void, 148 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void 149 | ) async -> T { 150 | let id = IdentifiableContinuation.ID() 151 | let state = Mutex((isStarted: false, isCancelled: false)) 152 | return await withTaskCancellationHandler { 153 | await withCheckedContinuation(function: function) { 154 | let continuation = IdentifiableContinuation(id: id, continuation: $0) 155 | body(continuation) 156 | let sendCancel = state.withLock { 157 | $0.isStarted = true 158 | return $0.isCancelled 159 | } 160 | if sendCancel { 161 | handler(id) 162 | } 163 | _ = isolation 164 | } 165 | } onCancel: { 166 | let sendCancel = state.withLock { 167 | $0.isCancelled = true 168 | return $0.isStarted 169 | } 170 | if sendCancel { 171 | handler(id) 172 | } 173 | } 174 | } 175 | 176 | /// Invokes the passed in closure with an `IdentifableContinuation` for the current task. 177 | /// 178 | /// The body of the closure executes synchronously on the calling actor. Once it returns the calling task is suspended. 179 | /// It is possible to immediately resume the task, or escape the continuation in order to complete it afterwards, which 180 | /// will then resume the suspended task. 181 | /// 182 | /// You must invoke the continuation's `resume` method exactly once. 183 | /// - Parameters: 184 | /// - isolation: Actor isolation used when executing the body closure. 185 | /// - function: A string identifying the declaration that is the notional 186 | /// source for the continuation, used to identify the continuation in 187 | /// runtime diagnostics related to misuse of this continuation. 188 | /// - body: A closure that takes a `IdentifiableContinuation` parameter. 189 | /// - handler: Cancellation closure executed when the current Task is cancelled. Handler is always called _after_ the body closure is compeled. 190 | /// - Returns: The value continuation is resumed with. 191 | @_unsafeInheritExecutor 192 | @inlinable 193 | public func withIdentifiableThrowingContinuation( 194 | isolation: isolated some Actor, 195 | function: String = #function, 196 | body: (IdentifiableContinuation) -> Void, 197 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void 198 | ) async throws -> T { 199 | let id = IdentifiableContinuation.ID() 200 | let state = Mutex((isStarted: false, isCancelled: false)) 201 | return try await withTaskCancellationHandler { 202 | try await withCheckedThrowingContinuation(function: function) { 203 | let continuation = IdentifiableContinuation(id: id, continuation: $0) 204 | body(continuation) 205 | let sendCancel = state.withLock { 206 | $0.isStarted = true 207 | return $0.isCancelled 208 | } 209 | if sendCancel { 210 | handler(id) 211 | } 212 | _ = isolation 213 | } 214 | } onCancel: { 215 | let sendCancel = state.withLock { 216 | $0.isCancelled = true 217 | return $0.isStarted 218 | } 219 | if sendCancel { 220 | handler(id) 221 | } 222 | } 223 | } 224 | #endif 225 | 226 | public struct IdentifiableContinuation: Sendable, Identifiable where E: Error { 227 | 228 | public let id: ID 229 | 230 | public final class ID: Hashable, Sendable { 231 | 232 | @usableFromInline 233 | init() { } 234 | 235 | public func hash(into hasher: inout Hasher) { 236 | ObjectIdentifier(self).hash(into: &hasher) 237 | } 238 | 239 | public static func == (lhs: IdentifiableContinuation.ID, rhs: IdentifiableContinuation.ID) -> Bool { 240 | lhs === rhs 241 | } 242 | } 243 | 244 | @usableFromInline 245 | init(id: ID, continuation: CheckedContinuation) { 246 | self.id = id 247 | self.continuation = continuation 248 | } 249 | 250 | private let continuation: CheckedContinuation 251 | 252 | #if compiler(>=6.0) 253 | public func resume(returning value: sending T) { 254 | continuation.resume(returning: value) 255 | } 256 | 257 | public func resume(with result: sending Result) { 258 | continuation.resume(with: result) 259 | } 260 | #else 261 | public func resume(returning value: T) { 262 | continuation.resume(returning: value) 263 | } 264 | 265 | public func resume(with result: Result) { 266 | continuation.resume(with: result) 267 | } 268 | #endif 269 | 270 | public func resume(throwing error: E) { 271 | continuation.resume(throwing: error) 272 | } 273 | 274 | public func resume() where T == () { 275 | continuation.resume() 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /Sources/Mutex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mutex.swift 3 | // swift-mutex 4 | // 5 | // Created by Simon Whitty on 07/09/2024. 6 | // Copyright 2024 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/swift-mutex 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if compiler(>=6) 33 | 34 | #if !canImport(WinSDK) 35 | 36 | // Backports the Swift 6 type Mutex to all Darwin platforms 37 | @usableFromInline 38 | struct Mutex: ~Copyable { 39 | let storage: Storage 40 | 41 | @usableFromInline 42 | init(_ initialValue: consuming sending Value) { 43 | self.storage = Storage(initialValue) 44 | } 45 | 46 | @usableFromInline 47 | borrowing func withLock( 48 | _ body: (inout sending Value) throws(E) -> sending Result 49 | ) throws(E) -> sending Result { 50 | storage.lock() 51 | defer { storage.unlock() } 52 | return try body(&storage.value) 53 | } 54 | 55 | @usableFromInline 56 | borrowing func withLockIfAvailable( 57 | _ body: (inout sending Value) throws(E) -> sending Result 58 | ) throws(E) -> sending Result? { 59 | guard storage.tryLock() else { return nil } 60 | defer { storage.unlock() } 61 | return try body(&storage.value) 62 | } 63 | } 64 | 65 | extension Mutex: @unchecked Sendable where Value: ~Copyable { } 66 | 67 | #else 68 | 69 | // Windows doesn't support ~Copyable yet 70 | 71 | @usableFromInline 72 | struct Mutex: @unchecked Sendable { 73 | let storage: Storage 74 | 75 | @usableFromInline 76 | init(_ initialValue: consuming sending Value) { 77 | self.storage = Storage(initialValue) 78 | } 79 | 80 | @usableFromInline 81 | borrowing func withLock( 82 | _ body: (inout sending Value) throws(E) -> sending Result 83 | ) throws(E) -> sending Result { 84 | storage.lock() 85 | defer { storage.unlock() } 86 | return try body(&storage.value) 87 | } 88 | 89 | @usableFromInline 90 | borrowing func withLockIfAvailable( 91 | _ body: (inout sending Value) throws(E) -> sending Result 92 | ) throws(E) -> sending Result? { 93 | guard storage.tryLock() else { return nil } 94 | defer { storage.unlock() } 95 | return try body(&storage.value) 96 | } 97 | } 98 | 99 | #endif 100 | 101 | #if canImport(Darwin) 102 | 103 | import struct os.os_unfair_lock_t 104 | import struct os.os_unfair_lock 105 | import func os.os_unfair_lock_lock 106 | import func os.os_unfair_lock_unlock 107 | import func os.os_unfair_lock_trylock 108 | 109 | final class Storage { 110 | private let _lock: os_unfair_lock_t 111 | var value: Value 112 | 113 | init(_ initialValue: consuming Value) { 114 | self._lock = .allocate(capacity: 1) 115 | self._lock.initialize(to: os_unfair_lock()) 116 | self.value = initialValue 117 | } 118 | 119 | func lock() { 120 | os_unfair_lock_lock(_lock) 121 | } 122 | 123 | func unlock() { 124 | os_unfair_lock_unlock(_lock) 125 | } 126 | 127 | func tryLock() -> Bool { 128 | os_unfair_lock_trylock(_lock) 129 | } 130 | 131 | deinit { 132 | self._lock.deinitialize(count: 1) 133 | self._lock.deallocate() 134 | } 135 | } 136 | 137 | #elseif canImport(Glibc) || canImport(Musl) || canImport(Bionic) 138 | 139 | #if canImport(Musl) 140 | import Musl 141 | #elseif canImport(Bionic) 142 | import Android 143 | #else 144 | import Glibc 145 | #endif 146 | 147 | final class Storage { 148 | private let _lock: UnsafeMutablePointer 149 | 150 | var value: Value 151 | 152 | init(_ initialValue: consuming Value) { 153 | var attr = pthread_mutexattr_t() 154 | pthread_mutexattr_init(&attr) 155 | self._lock = .allocate(capacity: 1) 156 | let err = pthread_mutex_init(self._lock, &attr) 157 | precondition(err == 0, "pthread_mutex_init error: \(err)") 158 | self.value = initialValue 159 | } 160 | 161 | func lock() { 162 | let err = pthread_mutex_lock(_lock) 163 | precondition(err == 0, "pthread_mutex_lock error: \(err)") 164 | } 165 | 166 | func unlock() { 167 | let err = pthread_mutex_unlock(_lock) 168 | precondition(err == 0, "pthread_mutex_unlock error: \(err)") 169 | } 170 | 171 | func tryLock() -> Bool { 172 | pthread_mutex_trylock(_lock) == 0 173 | } 174 | 175 | deinit { 176 | let err = pthread_mutex_destroy(self._lock) 177 | precondition(err == 0, "pthread_mutex_destroy error: \(err)") 178 | self._lock.deallocate() 179 | } 180 | } 181 | 182 | #elseif canImport(WinSDK) 183 | 184 | import ucrt 185 | import WinSDK 186 | 187 | final class Storage { 188 | private let _lock: UnsafeMutablePointer 189 | 190 | var value: Value 191 | 192 | init(_ initialValue: Value) { 193 | self._lock = .allocate(capacity: 1) 194 | InitializeSRWLock(self._lock) 195 | self.value = initialValue 196 | } 197 | 198 | func lock() { 199 | AcquireSRWLockExclusive(_lock) 200 | } 201 | 202 | func unlock() { 203 | ReleaseSRWLockExclusive(_lock) 204 | } 205 | 206 | func tryLock() -> Bool { 207 | TryAcquireSRWLockExclusive(_lock) != 0 208 | } 209 | } 210 | 211 | #endif 212 | 213 | #endif 214 | -------------------------------------------------------------------------------- /Sources/MutexSwift5.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MutexSwift5.swift 3 | // swift-mutex 4 | // 5 | // Created by Simon Whitty on 03/06/2025. 6 | // Copyright 2025 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/swift-mutex 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if compiler(<6.0) 33 | 34 | // Backports the Swift 6 type Mutex to Swift 5 35 | 36 | @usableFromInline 37 | struct Mutex: @unchecked Sendable { 38 | let storage: Storage 39 | 40 | @usableFromInline 41 | init(_ initialValue: Value) { 42 | self.storage = Storage(initialValue) 43 | } 44 | 45 | @usableFromInline 46 | borrowing func withLock( 47 | _ body: (inout Value) throws -> Result 48 | ) rethrows -> Result { 49 | storage.lock() 50 | defer { storage.unlock() } 51 | return try body(&storage.value) 52 | } 53 | 54 | @usableFromInline 55 | borrowing func withLockIfAvailable( 56 | _ body: (inout Value) throws -> Result 57 | ) rethrows -> Result? { 58 | guard storage.tryLock() else { return nil } 59 | defer { storage.unlock() } 60 | return try body(&storage.value) 61 | } 62 | } 63 | 64 | #if canImport(Darwin) 65 | 66 | import struct os.os_unfair_lock_t 67 | import struct os.os_unfair_lock 68 | import func os.os_unfair_lock_lock 69 | import func os.os_unfair_lock_unlock 70 | import func os.os_unfair_lock_trylock 71 | 72 | final class Storage { 73 | private let _lock: os_unfair_lock_t 74 | var value: Value 75 | 76 | init(_ initialValue: consuming Value) { 77 | self._lock = .allocate(capacity: 1) 78 | self._lock.initialize(to: os_unfair_lock()) 79 | self.value = initialValue 80 | } 81 | 82 | func lock() { 83 | os_unfair_lock_lock(_lock) 84 | } 85 | 86 | func unlock() { 87 | os_unfair_lock_unlock(_lock) 88 | } 89 | 90 | func tryLock() -> Bool { 91 | os_unfair_lock_trylock(_lock) 92 | } 93 | 94 | deinit { 95 | self._lock.deinitialize(count: 1) 96 | self._lock.deallocate() 97 | } 98 | } 99 | #elseif canImport(Glibc) || canImport(Musl) || canImport(Bionic) 100 | 101 | #if canImport(Musl) 102 | import Musl 103 | #elseif canImport(Bionic) 104 | import Android 105 | #else 106 | import Glibc 107 | #endif 108 | 109 | final class Storage { 110 | private let _lock: UnsafeMutablePointer 111 | var value: Value 112 | 113 | init(_ initialValue: consuming Value) { 114 | var attr = pthread_mutexattr_t() 115 | pthread_mutexattr_init(&attr) 116 | self._lock = .allocate(capacity: 1) 117 | let err = pthread_mutex_init(self._lock, &attr) 118 | precondition(err == 0, "pthread_mutex_init error: \(err)") 119 | self.value = initialValue 120 | } 121 | 122 | func lock() { 123 | let err = pthread_mutex_lock(_lock) 124 | precondition(err == 0, "pthread_mutex_lock error: \(err)") 125 | } 126 | 127 | func unlock() { 128 | let err = pthread_mutex_unlock(_lock) 129 | precondition(err == 0, "pthread_mutex_unlock error: \(err)") 130 | } 131 | 132 | func tryLock() -> Bool { 133 | pthread_mutex_trylock(_lock) == 0 134 | } 135 | 136 | deinit { 137 | let err = pthread_mutex_destroy(self._lock) 138 | precondition(err == 0, "pthread_mutex_destroy error: \(err)") 139 | self._lock.deallocate() 140 | } 141 | } 142 | #elseif canImport(WinSDK) 143 | 144 | import ucrt 145 | import WinSDK 146 | 147 | final class Storage { 148 | private let _lock: UnsafeMutablePointer 149 | 150 | var value: Value 151 | 152 | init(_ initialValue: Value) { 153 | self._lock = .allocate(capacity: 1) 154 | InitializeSRWLock(self._lock) 155 | self.value = initialValue 156 | } 157 | 158 | func lock() { 159 | AcquireSRWLockExclusive(_lock) 160 | } 161 | 162 | func unlock() { 163 | ReleaseSRWLockExclusive(_lock) 164 | } 165 | 166 | func tryLock() -> Bool { 167 | TryAcquireSRWLockExclusive(_lock) != 0 168 | } 169 | } 170 | 171 | #endif 172 | 173 | #endif 174 | -------------------------------------------------------------------------------- /Tests/IdentifiableContinuationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentifiableContinuationTests.swift 3 | // IdentifiableContinuation 4 | // 5 | // Created by Simon Whitty on 20/05/2023. 6 | // Copyright 2023 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/IdentifiableContinuation 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if canImport(Testing) 33 | @testable import IdentifiableContinuation 34 | import Foundation 35 | import Testing 36 | 37 | struct IdentifiableContinuationTests { 38 | 39 | @Test 40 | func resumesWithValue() async { 41 | let waiter = Waiter() 42 | let val = await waiter.identifiableContinuation { 43 | $0.resume(returning: "Fish") 44 | } 45 | 46 | #expect(val == "Fish") 47 | } 48 | 49 | @Test 50 | func resumesWithVoid() async { 51 | let waiter = Waiter() 52 | await waiter.identifiableContinuation { 53 | $0.resume() 54 | } 55 | } 56 | 57 | @Test 58 | func resumesWithResult() async { 59 | let waiter = Waiter() 60 | let val = await waiter.identifiableContinuation { 61 | $0.resume(with: .success("Chips")) 62 | } 63 | 64 | #expect(val == "Chips") 65 | } 66 | 67 | @Test 68 | func cancels_After_Created() async { 69 | let waiter = Waiter() 70 | 71 | let task = await waiter.makeTask(onCancel: nil) 72 | try? await Task.sleep(seconds: 0.1) 73 | var isEmpty = await waiter.isEmpty 74 | #expect(!isEmpty) 75 | task.cancel() 76 | 77 | let val = await task.value 78 | #expect(val == nil) 79 | 80 | isEmpty = await waiter.isEmpty 81 | #expect(isEmpty) 82 | } 83 | 84 | @Test 85 | func cancels_Before_Created() async { 86 | let waiter = Waiter() 87 | 88 | let task = await waiter.makeTask(delay: 1.0, onCancel: nil) 89 | let isEmpty = await waiter.isEmpty 90 | #expect(isEmpty) 91 | task.cancel() 92 | 93 | let val = await task.value 94 | #expect(val == nil) 95 | } 96 | 97 | @Test 98 | func throwingResumesWithValue() async throws { 99 | let waiter = Waiter() 100 | let task = Task { 101 | try await waiter.throwingIdentifiableContinuation { 102 | $0.resume(returning: "Fish") 103 | } 104 | } 105 | 106 | let result = await task.result 107 | #expect(try result.get() == "Fish") 108 | } 109 | 110 | @Test 111 | func throwingResumesWithError() async { 112 | let waiter = Waiter() 113 | let task = Task { 114 | try await waiter.throwingIdentifiableContinuation { 115 | $0.resume(throwing: CancellationError()) 116 | } 117 | } 118 | 119 | let result = await task.result 120 | #expect(throws: CancellationError.self) { 121 | try result.get() 122 | } 123 | } 124 | 125 | @Test 126 | func throwingResumesWithResult() async throws { 127 | let waiter = Waiter() 128 | let task = Task { 129 | try await waiter.throwingIdentifiableContinuation { 130 | $0.resume(with: .success("Fish")) 131 | } 132 | } 133 | 134 | let result = await task.result 135 | #expect(try result.get() == "Fish") 136 | } 137 | 138 | @Test 139 | func throwingCancels_After_Created() async { 140 | let waiter = Waiter() 141 | 142 | let task = await waiter.makeTask(onCancel: .failure(CancellationError())) 143 | try? await Task.sleep(seconds: 0.1) 144 | var isEmpty = await waiter.isEmpty 145 | #expect(!isEmpty) 146 | task.cancel() 147 | 148 | let result = await task.result 149 | #expect(throws: CancellationError.self) { 150 | try result.get() 151 | } 152 | 153 | isEmpty = await waiter.isEmpty 154 | #expect(isEmpty) 155 | } 156 | 157 | @Test 158 | func throwingCancels_Before_Created() async { 159 | let waiter = Waiter() 160 | 161 | let task = await waiter.makeTask(delay: 1.0, onCancel: .failure(CancellationError())) 162 | let isEmpty = await waiter.isEmpty 163 | #expect(isEmpty) 164 | task.cancel() 165 | 166 | let result = await task.result 167 | #expect(throws: CancellationError.self) { 168 | try result.get() 169 | } 170 | } 171 | } 172 | 173 | private actor Waiter { 174 | typealias Continuation = IdentifiableContinuation 175 | 176 | private var waiting = [Continuation.ID: Continuation]() 177 | 178 | var isEmpty: Bool { 179 | waiting.isEmpty 180 | } 181 | 182 | func makeTask(delay: TimeInterval, onCancel: T) -> Task where E == Never { 183 | Task { 184 | try? await Task.sleep(seconds: delay) 185 | return await withIdentifiableContinuation { 186 | addContinuation($0) 187 | } onCancel: { id in 188 | Task { await self.resumeID(id, returning: onCancel) } 189 | } 190 | } 191 | } 192 | 193 | func makeTask(onCancel: T) async -> Task where E == Never { 194 | nonisolated(unsafe) var continuation: CheckedContinuation! 195 | let task = Task { 196 | return await withIdentifiableContinuation { 197 | addContinuation($0) 198 | continuation.resume() 199 | } onCancel: { id in 200 | Task { await self.resumeID(id, returning: onCancel) } 201 | } 202 | } 203 | await withCheckedContinuation { 204 | continuation = $0 205 | } 206 | return task 207 | } 208 | 209 | func makeTask(delay: TimeInterval, onCancel: Result) -> Task where E == any Error { 210 | Task { 211 | try? await Task.sleep(seconds: delay) 212 | return try await withIdentifiableThrowingContinuation { 213 | addContinuation($0) 214 | } onCancel: { id in 215 | Task { await self.resumeID(id, with: onCancel) } 216 | } 217 | } 218 | } 219 | 220 | func makeTask(onCancel: Result) async -> Task where E == any Error { 221 | nonisolated(unsafe) var continuation: CheckedContinuation! 222 | let task = Task { 223 | try await withIdentifiableThrowingContinuation { 224 | addContinuation($0) 225 | continuation.resume() 226 | } onCancel: { id in 227 | Task { await self.resumeID(id, with: onCancel) } 228 | } 229 | } 230 | await withCheckedContinuation { 231 | continuation = $0 232 | } 233 | return task 234 | } 235 | 236 | private func addContinuation(_ continuation: Continuation) { 237 | assertIsolated() 238 | waiting[continuation.id] = continuation 239 | } 240 | 241 | private func resumeID(_ id: Continuation.ID, returning value: T) { 242 | assertIsolated() 243 | if let continuation = waiting.removeValue(forKey: id) { 244 | continuation.resume(returning: value) 245 | } 246 | } 247 | 248 | private func resumeID(_ id: Continuation.ID, throwing error: E) { 249 | assertIsolated() 250 | if let continuation = waiting.removeValue(forKey: id) { 251 | continuation.resume(throwing: error) 252 | } 253 | } 254 | 255 | private func resumeID(_ id: Continuation.ID, with result: Result) { 256 | assertIsolated() 257 | if let continuation = waiting.removeValue(forKey: id) { 258 | continuation.resume(with: result) 259 | } 260 | } 261 | } 262 | 263 | private extension Task where Success == Never, Failure == Never { 264 | static func sleep(seconds: TimeInterval) async throws { 265 | try await sleep(nanoseconds: UInt64(1_000_000_000 * seconds)) 266 | } 267 | } 268 | 269 | private extension Actor { 270 | 271 | func identifiableContinuation( 272 | body: @Sendable (IdentifiableContinuation) -> Void, 273 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void = { _ in } 274 | ) async -> T { 275 | await withIdentifiableContinuation(body: body, onCancel: handler) 276 | } 277 | 278 | func throwingIdentifiableContinuation( 279 | body: @Sendable (IdentifiableContinuation) -> Void, 280 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void = { _ in } 281 | ) async throws -> T { 282 | try await withIdentifiableThrowingContinuation(body: body, onCancel: handler) 283 | 284 | } 285 | } 286 | #endif 287 | -------------------------------------------------------------------------------- /Tests/IdentifiableContinuationXCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentifiableContinuationTests.swift 3 | // IdentifiableContinuation 4 | // 5 | // Created by Simon Whitty on 20/05/2023. 6 | // Copyright 2023 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/IdentifiableContinuation 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if !canImport(Testing) 33 | import IdentifiableContinuation 34 | import XCTest 35 | 36 | final class IdentifiableContinuationXCTests: XCTestCase { 37 | 38 | func testResumesWithValue() async { 39 | let waiter = Waiter() 40 | let val = await waiter.identifiableContinuation { 41 | $0.resume(returning: "Fish") 42 | } 43 | 44 | XCTAssertEqual(val, "Fish") 45 | } 46 | 47 | func testResumesWithVoid() async { 48 | let waiter = Waiter() 49 | await waiter.identifiableContinuation { 50 | $0.resume() 51 | } 52 | } 53 | 54 | func testResumesWithResult() async { 55 | let waiter = Waiter() 56 | let val = await waiter.identifiableContinuation { 57 | $0.resume(with: .success("Chips")) 58 | } 59 | 60 | XCTAssertEqual(val, "Chips") 61 | } 62 | 63 | func testCancels_After_Created() async { 64 | let waiter = Waiter() 65 | 66 | let task = await waiter.makeTask(onCancel: nil) 67 | try? await Task.sleep(seconds: 0.1) 68 | var isEmpty = await waiter.isEmpty 69 | XCTAssertFalse(isEmpty) 70 | task.cancel() 71 | 72 | let val = await task.value 73 | XCTAssertNil(val) 74 | 75 | isEmpty = await waiter.isEmpty 76 | XCTAssertTrue(isEmpty) 77 | } 78 | 79 | func testCancels_Before_Created() async { 80 | let waiter = Waiter() 81 | 82 | let task = await waiter.makeTask(delay: 1.0, onCancel: nil) 83 | try? await Task.sleep(seconds: 0.1) 84 | let isEmpty = await waiter.isEmpty 85 | XCTAssertTrue(isEmpty) 86 | task.cancel() 87 | 88 | let val = await task.value 89 | XCTAssertNil(val) 90 | } 91 | 92 | func testThrowingResumesWithValue() async { 93 | let waiter = Waiter() 94 | let task = Task { 95 | try await waiter.throwingIdentifiableContinuation { 96 | $0.resume(returning: "Fish") 97 | } 98 | } 99 | 100 | let result = await task.result 101 | XCTAssertEqual(try result.get(), "Fish") 102 | } 103 | 104 | func testThrowingResumesWithError() async { 105 | let waiter = Waiter() 106 | let task = Task { 107 | try await waiter.throwingIdentifiableContinuation { 108 | $0.resume(throwing: CancellationError()) 109 | } 110 | } 111 | 112 | let result = await task.result 113 | XCTAssertThrowsError(try result.get()) 114 | } 115 | 116 | func testThrowingResumesWithResult() async { 117 | let waiter = Waiter() 118 | let task = Task { 119 | try await waiter.throwingIdentifiableContinuation { 120 | $0.resume(with: .success("Fish")) 121 | } 122 | } 123 | 124 | let result = await task.result 125 | XCTAssertEqual(try result.get(), "Fish") 126 | } 127 | 128 | func testThrowingCancels_After_Created() async { 129 | let waiter = Waiter() 130 | 131 | let task = await waiter.makeTask(onCancel: .failure(CancellationError())) 132 | try? await Task.sleep(seconds: 0.1) 133 | var isEmpty = await waiter.isEmpty 134 | XCTAssertFalse(isEmpty) 135 | task.cancel() 136 | 137 | let result = await task.result 138 | XCTAssertThrowsError(try result.get()) 139 | 140 | isEmpty = await waiter.isEmpty 141 | XCTAssertTrue(isEmpty) 142 | } 143 | 144 | func testThrowingCancels_Before_Created() async { 145 | let waiter = Waiter() 146 | 147 | let task = await waiter.makeTask(delay: 1.0, onCancel: .failure(CancellationError())) 148 | try? await Task.sleep(seconds: 0.1) 149 | let isEmpty = await waiter.isEmpty 150 | XCTAssertTrue(isEmpty) 151 | task.cancel() 152 | 153 | let result = await task.result 154 | XCTAssertThrowsError(try result.get()) 155 | } 156 | } 157 | 158 | private actor Waiter { 159 | typealias Continuation = IdentifiableContinuation 160 | 161 | private var waiting = [Continuation.ID: Continuation]() 162 | 163 | var isEmpty: Bool { 164 | waiting.isEmpty 165 | } 166 | 167 | func makeTask(delay: TimeInterval = 0, onCancel: T) -> Task where E == Never { 168 | Task { 169 | try? await Task.sleep(seconds: delay) 170 | #if compiler(>=6.0) 171 | return await withIdentifiableContinuation { 172 | addContinuation($0) 173 | } onCancel: { id in 174 | Task { await self.resumeID(id, returning: onCancel) } 175 | } 176 | #else 177 | return await withIdentifiableContinuation(isolation: self) { 178 | addContinuation($0) 179 | } onCancel: { id in 180 | Task { await self.resumeID(id, returning: onCancel) } 181 | } 182 | #endif 183 | } 184 | } 185 | 186 | func makeTask(delay: TimeInterval = 0, onCancel: Result) -> Task where E == any Error { 187 | Task { 188 | try? await Task.sleep(seconds: delay) 189 | #if compiler(>=6.0) 190 | return try await withIdentifiableThrowingContinuation { 191 | addContinuation($0) 192 | } onCancel: { id in 193 | Task { await self.resumeID(id, with: onCancel) } 194 | } 195 | #else 196 | return try await withIdentifiableThrowingContinuation(isolation: self) { 197 | addContinuation($0) 198 | } onCancel: { id in 199 | Task { await self.resumeID(id, with: onCancel) } 200 | } 201 | #endif 202 | } 203 | } 204 | 205 | private func addContinuation(_ continuation: Continuation) { 206 | safeAssertIsolated() 207 | waiting[continuation.id] = continuation 208 | } 209 | 210 | private func resumeID(_ id: Continuation.ID, returning value: T) { 211 | safeAssertIsolated() 212 | if let continuation = waiting.removeValue(forKey: id) { 213 | continuation.resume(returning: value) 214 | } 215 | } 216 | 217 | private func resumeID(_ id: Continuation.ID, throwing error: E) { 218 | safeAssertIsolated() 219 | if let continuation = waiting.removeValue(forKey: id) { 220 | continuation.resume(throwing: error) 221 | } 222 | } 223 | 224 | private func resumeID(_ id: Continuation.ID, with result: Result) { 225 | safeAssertIsolated() 226 | if let continuation = waiting.removeValue(forKey: id) { 227 | continuation.resume(with: result) 228 | } 229 | } 230 | 231 | private func safeAssertIsolated() { 232 | #if compiler(>=5.10) 233 | assertIsolated() 234 | #elseif compiler(>=5.9) 235 | if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { 236 | assertIsolated() 237 | } 238 | #endif 239 | } 240 | } 241 | 242 | private extension Task where Success == Never, Failure == Never { 243 | static func sleep(seconds: TimeInterval) async throws { 244 | try await sleep(nanoseconds: UInt64(1_000_000_000 * seconds)) 245 | } 246 | } 247 | 248 | private extension Actor { 249 | 250 | func identifiableContinuation( 251 | body: @Sendable (IdentifiableContinuation) -> Void, 252 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void = { _ in } 253 | ) async -> T { 254 | #if compiler(>=6.0) 255 | await withIdentifiableContinuation(body: body, onCancel: handler) 256 | #else 257 | await withIdentifiableContinuation(isolation: self, body: body, onCancel: handler) 258 | #endif 259 | } 260 | 261 | func throwingIdentifiableContinuation( 262 | body: @Sendable (IdentifiableContinuation) -> Void, 263 | onCancel handler: @Sendable (IdentifiableContinuation.ID) -> Void = { _ in } 264 | ) async throws -> T { 265 | #if compiler(>=6.0) 266 | try await withIdentifiableThrowingContinuation(body: body, onCancel: handler) 267 | #else 268 | try await withIdentifiableThrowingContinuation(isolation: self, body: body, onCancel: handler) 269 | #endif 270 | 271 | } 272 | } 273 | #endif 274 | -------------------------------------------------------------------------------- /Tests/MutexTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MutexTests.swift 3 | // swift-mutex 4 | // 5 | // Created by Simon Whitty on 07/09/2024. 6 | // Copyright 2024 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/swift-mutex 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if canImport(Testing) 33 | @testable import IdentifiableContinuation 34 | import Testing 35 | 36 | struct MutexTests { 37 | 38 | @Test 39 | func withLock_ReturnsValue() { 40 | let mutex = Mutex("fish") 41 | let val = mutex.withLock { 42 | $0 + " & chips" 43 | } 44 | #expect(val == "fish & chips") 45 | } 46 | 47 | @Test 48 | func withLock_ThrowsError() { 49 | let mutex = Mutex("fish") 50 | #expect(throws: CancellationError.self) { 51 | try mutex.withLock { _ -> Void in throw CancellationError() } 52 | } 53 | } 54 | 55 | @Test 56 | func lockIfAvailable_ReturnsValue() { 57 | let mutex = Mutex("fish") 58 | mutex.unsafeLock() 59 | #expect( 60 | mutex.withLockIfAvailable { _ in "chips" } == nil 61 | ) 62 | mutex.unsafeUnlock() 63 | #expect( 64 | mutex.withLockIfAvailable { _ in "chips" } == "chips" 65 | ) 66 | } 67 | 68 | @Test 69 | func withLockIfAvailable_ThrowsError() { 70 | let mutex = Mutex("fish") 71 | #expect(throws: CancellationError.self) { 72 | try mutex.withLockIfAvailable { _ -> Void in throw CancellationError() } 73 | } 74 | } 75 | } 76 | 77 | extension Mutex { 78 | func unsafeLock() { storage.lock() } 79 | func unsafeUnlock() { storage.unlock() } 80 | } 81 | #endif 82 | -------------------------------------------------------------------------------- /Tests/MutexXCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MutexXCTests.swift 3 | // swift-mutex 4 | // 5 | // Created by Simon Whitty on 07/09/2024. 6 | // Copyright 2024 Simon Whitty 7 | // 8 | // Distributed under the permissive MIT license 9 | // Get the latest version from here: 10 | // 11 | // https://github.com/swhitty/swift-mutex 12 | // 13 | // Permission is hereby granted, free of charge, to any person obtaining a copy 14 | // of this software and associated documentation files (the "Software"), to deal 15 | // in the Software without restriction, including without limitation the rights 16 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | // copies of the Software, and to permit persons to whom the Software is 18 | // furnished to do so, subject to the following conditions: 19 | // 20 | // The above copyright notice and this permission notice shall be included in all 21 | // copies or substantial portions of the Software. 22 | // 23 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | // SOFTWARE. 30 | // 31 | 32 | #if !canImport(Testing) 33 | @testable import IdentifiableContinuation 34 | import XCTest 35 | 36 | final class MutexXCTests: XCTestCase { 37 | 38 | func testWithLock_ReturnsValue() { 39 | let mutex = Mutex("fish") 40 | let val = mutex.withLock { 41 | $0 + " & chips" 42 | } 43 | XCTAssertEqual(val, "fish & chips") 44 | } 45 | 46 | func testWithLock_ThrowsError() { 47 | let mutex = Mutex("fish") 48 | XCTAssertThrowsError(try mutex.withLock { _ -> Void in throw CancellationError() }) { 49 | _ = $0 is CancellationError 50 | } 51 | } 52 | 53 | func testLockIfAvailable_ReturnsValue() { 54 | let mutex = Mutex("fish") 55 | mutex.unsafeLock() 56 | XCTAssertNil( 57 | mutex.withLockIfAvailable { _ in "chips" } 58 | ) 59 | mutex.unsafeUnlock() 60 | XCTAssertEqual( 61 | mutex.withLockIfAvailable { _ in "chips" }, 62 | "chips" 63 | ) 64 | } 65 | 66 | func testWithLockIfAvailable_ThrowsError() { 67 | let mutex = Mutex("fish") 68 | XCTAssertThrowsError(try mutex.withLockIfAvailable { _ -> Void in throw CancellationError() }) { 69 | _ = $0 is CancellationError 70 | } 71 | } 72 | } 73 | 74 | extension Mutex { 75 | func unsafeLock() { storage.lock() } 76 | func unsafeUnlock() { storage.unlock() } 77 | } 78 | #endif 79 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - Tests 3 | -------------------------------------------------------------------------------- /docker-run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | docker run -it \ 6 | --rm \ 7 | --mount src="$(pwd)",target=/package,type=bind \ 8 | swift:5.10-jammy \ 9 | /usr/bin/swift test --package-path /package 10 | --------------------------------------------------------------------------------