├── .github └── workflows │ └── build.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── swift-async-timeout │ └── swift_async_timeout.swift └── Tests └── swift-async-timeoutTests └── swift_async_timeoutTests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CommitChecks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: macos-14 8 | 9 | steps: 10 | - uses: maxim-lobanov/setup-xcode@v1.1 11 | with: 12 | xcode-version: "16.0" 13 | - uses: actions/checkout@v2 14 | - name: Run Test 15 | run: swift test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/swift 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift 3 | 4 | ### Swift ### 5 | # Xcode 6 | # 7 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 8 | 9 | ## User settings 10 | xcuserdata/ 11 | 12 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 13 | *.xcscmblueprint 14 | *.xccheckout 15 | 16 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 17 | build/ 18 | DerivedData/ 19 | *.moved-aside 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | 29 | ## Obj-C/Swift specific 30 | *.hmap 31 | 32 | ## App packaging 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 43 | # Packages/ 44 | # Package.pins 45 | # Package.resolved 46 | # *.xcodeproj 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # Pods/ 58 | # Add this line if you want to avoid checking in source code from the Xcode workspace 59 | # *.xcworkspace 60 | 61 | # Carthage 62 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 63 | # Carthage/Checkouts 64 | 65 | Carthage/Build/ 66 | 67 | # Accio dependency management 68 | Dependencies/ 69 | .accio/ 70 | 71 | # fastlane 72 | # It is recommended to not store the screenshots in the git repo. 73 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 74 | # For more information about the recommended setup visit: 75 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 76 | 77 | fastlane/report.xml 78 | fastlane/Preview.html 79 | fastlane/screenshots/**/*.png 80 | fastlane/test_output 81 | 82 | # Code Injection 83 | # After new code Injection tools there's a generated folder /iOSInjectionProject 84 | # https://github.com/johnno1962/injectionforxcode 85 | 86 | iOSInjectionProject/ 87 | 88 | .DS_Store 89 | # End of https://www.toptal.com/developers/gitignore/api/swift 90 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Muukii 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: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-async-timeout", 8 | platforms: [.iOS(.v13), .macOS(.v10_15)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "swift-async-timeout", 13 | targets: ["swift-async-timeout"]), 14 | ], 15 | targets: [ 16 | // Targets are the basic building blocks of a package, defining a module or a test suite. 17 | // Targets can depend on other targets in this package and products from dependencies. 18 | .target( 19 | name: "swift-async-timeout"), 20 | .testTarget( 21 | name: "swift-async-timeoutTests", 22 | dependencies: ["swift-async-timeout"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Understanding Task Cancellation 3 | 4 | It's crucial to note that canceling tasks does not equate to halting ongoing operations. Instead, it informs the tasks that they are canceled. The following code illustrates this concept by canceling a task. Notably, the "done" print statement is executed only after the longOperation has completed, unless the operation includes specific handling for cancellation. 5 | 6 | ```swift 7 | let task = Task { 8 | await longOperation() 9 | print("done") 10 | } 11 | ... 12 | 13 | task.cancel() 14 | ``` 15 | 16 | Handling Timeouts 17 | Consider the following scenario: 18 | 19 | ```swift 20 | await fetchFlag() // A timeout of 5 seconds is desired. 21 | applyFlag() 22 | ``` 23 | 24 | In this case, we require a mechanism to enforce a timeout of 5 seconds when fetching the flag, regardless of whether triggering a cancelation genuinely halts the ongoing operation and discards its progress. The behavior we aim to achieve is to time out the operation at all costs and proceed with subsequent steps. This means that the fetch request may still be in progress, but the program will move forward due to the timeout. 25 | 26 | Implementing a Timeout with withTimeout 27 | To achieve this specific timeout behavior, the withTimeout function is introduced, which utilizes unstructured concurrency and error handling. Here's how it works: 28 | 29 | ```swift 30 | await withTimeout(5) { 31 | await fetchFlag() // This is expected to time out in 5 seconds. 32 | } 33 | 34 | applyFlag() // Apply a new flag or the current flag if a timeout occurred. 35 | ``` 36 | 37 | The withTimeout function allows you to specify a time limit (in nanoseconds) and execute a block of code within that time frame. If the specified time elapses, the function cancels the associated task, ensuring that the program proceeds without waiting for the task's completion. The applied flag can then be updated based on whether a timeout occurred or not. 38 | 39 | This approach provides a powerful mechanism for managing timeouts in asynchronous code, offering fine-grained control over task cancellation and ensuring that your application remains responsive even in the face of potential delays. 40 | -------------------------------------------------------------------------------- /Sources/swift-async-timeout/swift_async_timeout.swift: -------------------------------------------------------------------------------- 1 | 2 | public enum TimeoutHandlerError: Error { 3 | case timeoutOccured 4 | } 5 | 6 | @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 7 | public func withTimeout( 8 | isolation: isolated (any Actor)? = #isolation, 9 | for duration: ContinuousClock.Instant.Duration, 10 | @_inheritActorContext _ operation: @escaping @Sendable () async throws -> Return 11 | ) async throws -> Return { 12 | return try await withTimeout( 13 | isolation: isolation, 14 | sleep: { try await Task.sleep(for: duration) }, 15 | operation 16 | ) 17 | } 18 | 19 | public func withTimeout( 20 | isolation: isolated (any Actor)? = #isolation, 21 | nanoseconds: UInt64, 22 | @_inheritActorContext _ operation: @escaping @Sendable () async throws -> Return 23 | ) async throws -> Return { 24 | return try await withTimeout( 25 | isolation: isolation, 26 | sleep: { try await Task.sleep(nanoseconds: nanoseconds) }, 27 | operation 28 | ) 29 | } 30 | 31 | private func withTimeout( 32 | isolation: isolated (any Actor)? = #isolation, 33 | sleep: @escaping @isolated(any) () async throws -> Void, 34 | @_inheritActorContext _ operation: @escaping @Sendable () async throws -> Return 35 | ) async throws -> Return { 36 | 37 | let task = Ref>(value: nil) 38 | let timeoutTask = Ref>(value: nil) 39 | 40 | let flag = Flag() 41 | 42 | return try await withTaskCancellationHandler { 43 | 44 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 45 | 46 | do { 47 | try Task.checkCancellation() 48 | } catch { 49 | continuation.resume(throwing: error) 50 | return 51 | } 52 | 53 | let _task = Task { 54 | do { 55 | let taskResult = try await operation() 56 | 57 | await flag.performIf(expected: false) { 58 | continuation.resume(returning: taskResult) 59 | return true 60 | } 61 | } catch { 62 | await flag.performIf(expected: false) { 63 | continuation.resume(throwing: error) 64 | return true 65 | } 66 | } 67 | } 68 | 69 | task.value = _task 70 | 71 | let _timeoutTask = Task { 72 | try await sleep() 73 | _task.cancel() 74 | 75 | await flag.performIf(expected: false) { 76 | continuation.resume(throwing: TimeoutHandlerError.timeoutOccured) 77 | return true 78 | } 79 | 80 | } 81 | 82 | timeoutTask.value = _timeoutTask 83 | } 84 | } onCancel: { 85 | task.value?.cancel() 86 | timeoutTask.value?.cancel() 87 | } 88 | } 89 | 90 | 91 | private final class Ref: @unchecked Sendable { 92 | var value: T? 93 | 94 | init(value: T?) { 95 | self.value = value 96 | } 97 | } 98 | 99 | private actor Flag { 100 | var value: Bool = false 101 | 102 | func set(value: Bool) { 103 | self.value = value 104 | } 105 | 106 | func performIf(expected: Bool, perform: @Sendable () -> Bool) { 107 | if value == expected { 108 | value = perform() 109 | } 110 | } 111 | } 112 | 113 | -------------------------------------------------------------------------------- /Tests/swift-async-timeoutTests/swift_async_timeoutTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import swift_async_timeout 4 | 5 | @MainActor 6 | func runUI() {} 7 | 8 | final class swift_async_timeoutTests: XCTestCase { 9 | 10 | @MainActor 11 | func test_execution() async throws { 12 | try await withTimeout(nanoseconds: 2_000_000_000) { 13 | runUI() 14 | } 15 | } 16 | 17 | func test_succeeded_on_time() async throws { 18 | 19 | try await withTimeout(nanoseconds: 2_000_000_000) { 20 | await delay(timeinterval: 1, onCancel: {}) 21 | } 22 | 23 | // no error occurs 24 | 25 | } 26 | 27 | func test_timeout_occured() async throws { 28 | 29 | let exp = expectation(description: "cancel inside") 30 | let timeoutErrorExp = expectation(description: "timeout error") 31 | 32 | do { 33 | try await withTimeout(nanoseconds: 500_000_000) { 34 | await delay( 35 | timeinterval: 10000000, 36 | onCancel: { 37 | exp.fulfill() 38 | } 39 | ) 40 | } 41 | } catch { 42 | switch error { 43 | case TimeoutHandlerError.timeoutOccured: 44 | timeoutErrorExp.fulfill() 45 | default: 46 | XCTFail() 47 | } 48 | } 49 | 50 | await fulfillment(of: [exp, timeoutErrorExp]) 51 | } 52 | 53 | func test_cancelled_before_timeout() async throws { 54 | 55 | let exp = expectation(description: "cancel inside") 56 | 57 | let nextExp = expectation(description: "next") 58 | 59 | let unstructuredTask = Task { 60 | do { 61 | try await withTimeout(nanoseconds: 500_000_000) { 62 | await delay( 63 | timeinterval: 1, 64 | onCancel: { 65 | exp.fulfill() 66 | } 67 | ) 68 | } 69 | } catch { 70 | switch error { 71 | case TimeoutHandlerError.timeoutOccured: 72 | XCTFail() 73 | case is CancellationError: 74 | break 75 | default: 76 | XCTFail(error.localizedDescription) 77 | } 78 | } 79 | nextExp.fulfill() 80 | } 81 | 82 | try? await Task.sleep(nanoseconds: 1_000_000) 83 | 84 | unstructuredTask.cancel() 85 | 86 | await fulfillment(of: [nextExp, exp]) 87 | 88 | } 89 | } 90 | 91 | private func delay(timeinterval: TimeInterval, onCancel: @escaping @Sendable () -> Void) async { 92 | 93 | await withTaskCancellationHandler { 94 | 95 | await withCheckedContinuation { (c: CheckedContinuation) -> Void in 96 | DispatchQueue.main.asyncAfter(deadline: .now() + timeinterval) { 97 | c.resume() 98 | } 99 | } 100 | } onCancel: { 101 | onCancel() 102 | } 103 | 104 | } 105 | --------------------------------------------------------------------------------