├── codecov.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── container-run-tests.sh ├── Package.swift ├── LICENSE ├── Sources ├── Transferring.swift ├── Task+SleepIndefinitely.swift ├── Mutex+Darwin.swift ├── AsyncTimeoutSequence.swift ├── TimeoutController.swift └── withThrowingTimeout.swift ├── .gitignore ├── Tests ├── Task+SleepIndefinitelyTests.swift ├── MutexTests.swift ├── AsyncTimeoutSequenceTests.swift └── withThrowingTimeoutTests.swift ├── README.md └── .github └── workflows └── build.yml /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - Tests 3 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /container-run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | container run -it \ 6 | --rm \ 7 | --mount src="$(pwd)",target=/package,type=bind \ 8 | swift:6.2 \ 9 | /usr/bin/swift test --package-path /package 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Timeout", 7 | platforms: [ 8 | .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) 9 | ], 10 | products: [ 11 | .library( 12 | name: "Timeout", 13 | targets: ["Timeout"] 14 | ) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Timeout", 19 | path: "Sources", 20 | swiftSettings: .upcomingFeatures 21 | ), 22 | .testTarget( 23 | name: "TimeoutTests", 24 | dependencies: ["Timeout"], 25 | path: "Tests", 26 | swiftSettings: .upcomingFeatures 27 | ) 28 | ] 29 | ) 30 | 31 | extension Array where Element == SwiftSetting { 32 | 33 | static var upcomingFeatures: [SwiftSetting] { 34 | [ 35 | .swiftLanguageMode(.v6) 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 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 | -------------------------------------------------------------------------------- /Sources/Transferring.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transferring.swift 3 | // swift-timeout 4 | // 5 | // Created by Simon Whitty on 02/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-timeout 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 | struct Transferring: Sendable { 33 | nonisolated(unsafe) public var value: Value 34 | init(_ value: Value) { 35 | self.value = value 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Tests/Task+SleepIndefinitelyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+SleepIndefinitelyTests.swift 3 | // swift-timeout 4 | // 5 | // Created by Simon Whitty on 02/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-timeout 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 | @testable import Timeout 33 | import struct Foundation.TimeInterval 34 | import Testing 35 | 36 | struct TaskSleepIndefinitelyTests { 37 | 38 | @Test 39 | func throwsWhenInitiallyCancelled() async { 40 | let task = Task { 41 | _ = try? await Task.sleepIndefinitely() 42 | try await Task.sleepIndefinitely() 43 | } 44 | 45 | task.cancel() 46 | 47 | await #expect(throws: CancellationError.self) { 48 | try await task.value 49 | } 50 | } 51 | 52 | @Test 53 | func throwsWhenCancelled() async { 54 | let task = Task { 55 | try await Task.sleepIndefinitely() 56 | } 57 | 58 | try? await Task.sleep(nanoseconds: 200_000) 59 | task.cancel() 60 | 61 | await #expect(throws: CancellationError.self) { 62 | try await task.value 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Task+SleepIndefinitely.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+SleepIndefinitely.swift 3 | // swift-timeout 4 | // 5 | // Created by Simon Whitty on 02/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-timeout 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 | package extension Task { 33 | 34 | private typealias State = (isCancelled: Bool, continuation: CheckedContinuation?) 35 | 36 | static func sleepIndefinitely() async throws -> Never { 37 | let state = Mutex((isCancelled: false, continuation: nil)) 38 | try await withTaskCancellationHandler { 39 | try await withCheckedThrowingContinuation { continuation in 40 | let isCancelled = state.withLock { 41 | if $0.isCancelled { 42 | return true 43 | } else { 44 | $0.continuation = continuation 45 | return false 46 | } 47 | } 48 | if isCancelled { 49 | continuation.resume(throwing: _Concurrency.CancellationError()) 50 | } 51 | } 52 | } onCancel: { 53 | let continuation = state.withLock { 54 | $0.isCancelled = true 55 | return $0.continuation 56 | } 57 | continuation?.resume(throwing: _Concurrency.CancellationError()) 58 | } 59 | fatalError("can never occur") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/swhitty/swift-timeout/actions/workflows/build.yml/badge.svg)](https://github.com/swhitty/swift-timeout/actions/workflows/build.yml) 2 | [![Codecov](https://codecov.io/gh/swhitty/swift-timeout/graphs/badge.svg)](https://codecov.io/gh/swhitty/swift-timeout) 3 | [![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswhitty%2Fswift-timeout%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swhitty/swift-timeout) 4 | [![Swift 6.0](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswhitty%2Fswift-timeout%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swhitty/swift-timeout) 5 | 6 | # Introduction 7 | 8 | **swift-timeout** is a lightweight wrapper around [`Task`](https://developer.apple.com/documentation/swift/task) that executes a closure with a given timeout. 9 | 10 | # Installation 11 | 12 | Timeout can be installed by using Swift Package Manager. 13 | 14 | **Note:** Timeout 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/swift-timeout.git", .upToNextMajor(from: "0.3.0")) 19 | ``` 20 | 21 | # Usage 22 | 23 | Provide a closure and a [`Instant`](https://developer.apple.com/documentation/swift/continuousclock/instant) for when the child task must complete else `TimeoutError` is thrown: 24 | 25 | ```swift 26 | import Timeout 27 | 28 | let val = try await withThrowingTimeout(after: .now + .seconds(2)) { 29 | try await perform() 30 | } 31 | ``` 32 | 33 | `TimeInterval` can also be provided: 34 | 35 | ```swift 36 | let val = try await withThrowingTimeout(seconds: 2.0) { 37 | try await perform() 38 | } 39 | ``` 40 | 41 | > Note: When the timeout expires the task executing the closure is cancelled and `TimeoutError` is thrown. 42 | 43 | An overload includes a `TimeoutController` object allowing the body to cancel or move the expiration where required: 44 | 45 | ```swift 46 | try await withThrowingTimeout(seconds: 1.0) { timeout in 47 | try await foo() 48 | timeout.cancelExpiration() 49 | try await bar() 50 | timeout.expire(after: .now + .seconds(0.5)) 51 | try await baz() 52 | } 53 | ``` 54 | 55 | `AsyncTimeoutSequence` allows each iteration a fresh timeout to return the next element; 56 | 57 | ```swift 58 | for try await val in sequence.timeout(seconds: 2.0) { 59 | ... 60 | } 61 | ``` 62 | 63 | # Credits 64 | 65 | **swift-timeout** is primarily the work of [Simon Whitty](https://github.com/swhitty). 66 | 67 | ([Full list of contributors](https://github.com/swhitty/swift-timeout/graphs/contributors)) 68 | -------------------------------------------------------------------------------- /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(Darwin) 33 | @testable import Timeout 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/AsyncTimeoutSequenceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncTimeoutSequenceTests.swift 3 | // swift-timeout 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-timeout 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 | @testable import Timeout 33 | import Testing 34 | 35 | struct AsyncTimeoutSequenceTests { 36 | 37 | @Test 38 | func timeoutSeconds() async throws { 39 | let (stream, continuation) = AsyncStream.makeStream() 40 | let t = Task { 41 | continuation.yield(1) 42 | try await Task.sleep(nanoseconds: 1_000) 43 | continuation.yield(2) 44 | try await Task.sleepIndefinitely() 45 | } 46 | defer { t.cancel() } 47 | var iterator = stream.timeout(seconds: 0.1).makeAsyncIterator() 48 | 49 | #expect(try await iterator.next() == 1) 50 | #expect(try await iterator.next() == 2) 51 | await #expect(throws: TimeoutError.self) { 52 | try await iterator.next() 53 | } 54 | } 55 | 56 | @Test 57 | func timeoutDuration() async throws { 58 | let (stream, continuation) = AsyncStream.makeStream() 59 | let t = Task { 60 | continuation.yield(1) 61 | try await Task.sleep(nanoseconds: 1_000) 62 | continuation.yield(2) 63 | try await Task.sleepIndefinitely() 64 | } 65 | defer { t.cancel() } 66 | var iterator = stream.timeout(duration: .milliseconds(100)).makeAsyncIterator() 67 | 68 | #expect(try await iterator.next() == 1) 69 | #expect(try await iterator.next() == 2) 70 | await #expect(throws: TimeoutError.self) { 71 | try await iterator.next() 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/Mutex+Darwin.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 canImport(Darwin) 33 | 34 | import struct os.os_unfair_lock_t 35 | import struct os.os_unfair_lock 36 | import func os.os_unfair_lock_lock 37 | import func os.os_unfair_lock_unlock 38 | import func os.os_unfair_lock_trylock 39 | 40 | // Backports the Swift 6 type Mutex to all Darwin platforms 41 | struct Mutex: ~Copyable { 42 | let storage: Storage 43 | 44 | init(_ initialValue: consuming sending Value) { 45 | self.storage = Storage(initialValue) 46 | } 47 | 48 | borrowing func withLock( 49 | _ body: (inout sending Value) throws(E) -> sending Result 50 | ) throws(E) -> sending Result { 51 | storage.lock() 52 | defer { storage.unlock() } 53 | return try body(&storage.value) 54 | } 55 | 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 | final class Storage { 68 | private let _lock: os_unfair_lock_t 69 | var value: Value 70 | 71 | init(_ initialValue: consuming Value) { 72 | self._lock = .allocate(capacity: 1) 73 | self._lock.initialize(to: os_unfair_lock()) 74 | self.value = initialValue 75 | } 76 | 77 | func lock() { 78 | os_unfair_lock_lock(_lock) 79 | } 80 | 81 | func unlock() { 82 | os_unfair_lock_unlock(_lock) 83 | } 84 | 85 | func tryLock() -> Bool { 86 | os_unfair_lock_trylock(_lock) 87 | } 88 | 89 | deinit { 90 | self._lock.deinitialize(count: 1) 91 | self._lock.deallocate() 92 | } 93 | } 94 | 95 | #endif 96 | -------------------------------------------------------------------------------- /Sources/AsyncTimeoutSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncTimeoutSequence.swift 3 | // swift-timeout 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-timeout 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 | import struct Foundation.TimeInterval 33 | 34 | public extension AsyncSequence where Element: Sendable { 35 | 36 | /// Creates an asynchronous sequence that throws error if any iteration 37 | /// takes longer than provided `TimeInterval`. 38 | func timeout(seconds: TimeInterval) -> AsyncTimeoutSequence { 39 | AsyncTimeoutSequence(base: self, seconds: seconds) 40 | } 41 | 42 | /// Creates an asynchronous sequence that throws error if any iteration 43 | /// takes longer than provided `Duration`. 44 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 45 | func timeout(duration: Duration) -> AsyncTimeoutSequence { 46 | AsyncTimeoutSequence(base: self, duration: duration) 47 | } 48 | } 49 | 50 | public struct AsyncTimeoutSequence: AsyncSequence where Base.Element: Sendable { 51 | public typealias Element = Base.Element 52 | 53 | private let base: Base 54 | private let interval: TimeoutInterval 55 | 56 | public init(base: Base, seconds: TimeInterval) { 57 | self.base = base 58 | self.interval = .timeInterval(seconds) 59 | } 60 | 61 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 62 | public init(base: Base, duration: Duration) { 63 | self.base = base 64 | self.interval = .duration(.init(duration)) 65 | } 66 | 67 | public func makeAsyncIterator() -> AsyncIterator { 68 | AsyncIterator( 69 | iterator: base.makeAsyncIterator(), 70 | interval: interval 71 | ) 72 | } 73 | 74 | public struct AsyncIterator: AsyncIteratorProtocol { 75 | private var iterator: Base.AsyncIterator 76 | private let interval: TimeoutInterval 77 | 78 | init(iterator: Base.AsyncIterator, interval: TimeoutInterval) { 79 | self.iterator = iterator 80 | self.interval = interval 81 | } 82 | 83 | public mutating func next() async throws -> Base.Element? { 84 | switch interval { 85 | case .timeInterval(let seconds): 86 | return try await withThrowingTimeout(seconds: seconds) { 87 | try await self.iterator.next() 88 | } 89 | 90 | case .duration(let durationBox): 91 | guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) else { 92 | fatalError("cannot occur") 93 | } 94 | return try await withThrowingTimeout(after: .now + durationBox.value) { 95 | try await self.iterator.next() 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | enum TimeoutInterval { 103 | case timeInterval(TimeInterval) 104 | case duration(DurationBox) 105 | 106 | struct DurationBox { 107 | private let storage: Any 108 | 109 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 110 | var value: Duration { 111 | storage as! Duration 112 | } 113 | 114 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 115 | init(_ duration: Duration) { 116 | self.storage = duration 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /.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/TimeoutPackageTests.xctest/Contents/MacOS/TimeoutPackageTests -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_26: 31 | runs-on: macos-15 32 | env: 33 | DEVELOPER_DIR: /Applications/Xcode_26.0.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_16_2: 45 | runs-on: macos-15 46 | env: 47 | DEVELOPER_DIR: /Applications/Xcode_16.2.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_6_0: 59 | runs-on: ubuntu-latest 60 | container: swift:6.0.3 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_1: 72 | runs-on: ubuntu-latest 73 | container: swift:6.1.2 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_2: 85 | runs-on: ubuntu-latest 86 | container: swiftlang/swift:nightly-6.2-noble 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_2: 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.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 | -------------------------------------------------------------------------------- /Sources/TimeoutController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeoutController.swift 3 | // swift-timeout 4 | // 5 | // Created by Simon Whitty on 02/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-timeout 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(Darwin) 33 | import Synchronization 34 | typealias Mutex = Synchronization.Mutex 35 | #endif 36 | 37 | import struct Foundation.TimeInterval 38 | 39 | public struct TimeoutController: Sendable { 40 | fileprivate var canary: @Sendable () -> Void 41 | fileprivate let shared: SharedState 42 | 43 | @discardableResult 44 | public func expire(seconds: TimeInterval) -> Bool { 45 | enqueue { 46 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 47 | throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.") 48 | } 49 | } 50 | 51 | @discardableResult 52 | public func expireImmediatley() -> Bool { 53 | enqueue(flagAsComplete: true) { 54 | throw TimeoutError("Task timed out before completion. expireImmediatley()") 55 | } 56 | } 57 | 58 | @discardableResult 59 | public func cancelExpiration() -> Bool { 60 | enqueue { 61 | try await Task.sleepIndefinitely() 62 | } 63 | } 64 | 65 | struct State { 66 | var running: Task? 67 | var pending: (@Sendable () async throws -> Never)? 68 | var isComplete: Bool = false 69 | } 70 | 71 | final class SharedState: Sendable { 72 | let state: Mutex 73 | 74 | init(pending: @escaping @Sendable () async throws -> Never) { 75 | state = Mutex(.init(pending: pending)) 76 | } 77 | } 78 | } 79 | 80 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 81 | public extension TimeoutController { 82 | 83 | @discardableResult 84 | func expire( 85 | after instant: C.Instant, 86 | tolerance: C.Instant.Duration? = nil, 87 | clock: C 88 | ) -> Bool { 89 | enqueue { 90 | try await Task.sleep(until: instant, tolerance: tolerance, clock: clock) 91 | throw TimeoutError("Task timed out before completion. Deadline: \(instant).") 92 | } 93 | } 94 | 95 | @discardableResult 96 | func expire( 97 | after instant: ContinuousClock.Instant, 98 | tolerance: ContinuousClock.Instant.Duration? = nil 99 | ) -> Bool { 100 | expire(after: instant, tolerance: tolerance, clock: ContinuousClock()) 101 | } 102 | } 103 | 104 | extension TimeoutController { 105 | 106 | init( 107 | canary: @escaping @Sendable () -> Void, 108 | pending closure: @escaping @Sendable () async throws -> Never 109 | ) { 110 | self.canary = canary 111 | self.shared = .init(pending: closure) 112 | } 113 | 114 | @discardableResult 115 | func enqueue(flagAsComplete: Bool = false, closure: @escaping @Sendable () async throws -> Never) -> Bool { 116 | shared.state.withLock { s in 117 | guard !s.isComplete else { return false } 118 | s.pending = closure 119 | s.running?.cancel() 120 | s.isComplete = flagAsComplete 121 | return true 122 | } 123 | } 124 | 125 | func startPendingTask() -> Task? { 126 | return shared.state.withLock { s in 127 | guard let pending = s.pending else { 128 | s.isComplete = true 129 | return nil 130 | } 131 | let task = Task { try await pending() } 132 | s.pending = nil 133 | s.running = task 134 | return task 135 | } 136 | } 137 | 138 | func waitForTimeout() async throws { 139 | var lastError: (any Error)? 140 | while let task = startPendingTask() { 141 | do { 142 | try await withTaskCancellationHandler { 143 | try await task.value 144 | } onCancel: { 145 | task.cancel() 146 | } 147 | } catch is CancellationError { 148 | lastError = nil 149 | } catch { 150 | lastError = error 151 | } 152 | } 153 | 154 | if let lastError { 155 | throw lastError 156 | } 157 | } 158 | } 159 | 160 | func withNonEscapingTimeout( 161 | _ timeout: @escaping @Sendable () async throws -> Never, 162 | isolation: isolated (any Actor)? = #isolation, 163 | body: (TimeoutController) async throws -> sending T 164 | ) async throws -> sending T { 165 | // canary ensuring TimeoutController does not escape at runtime. 166 | // Swift 6.2 and later can enforce at compile time with ~Escapable 167 | try await withoutActuallyEscaping({ @Sendable in }) { escaping in 168 | _ = isolation 169 | let timeout = TimeoutController(canary: escaping, pending: timeout) 170 | return try await Transferring(body(timeout)) 171 | }.value 172 | } 173 | -------------------------------------------------------------------------------- /Tests/withThrowingTimeoutTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // withThrowingTimeoutTests.swift 3 | // swift-timeout 4 | // 5 | // Created by Simon Whitty on 31/08/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-timeout 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 | @testable import Timeout 33 | import struct Foundation.TimeInterval 34 | import Testing 35 | 36 | struct WithThrowingTimeoutTests { 37 | 38 | @Test @MainActor 39 | func mainActor_ReturnsValue() async throws { 40 | let val = try await withThrowingTimeout(seconds: 1) { 41 | MainActor.assertIsolated() 42 | try await Task.sleep(nanoseconds: 1_000) 43 | MainActor.assertIsolated() 44 | return "Fish" 45 | } 46 | #expect(val == "Fish") 47 | } 48 | 49 | @Test 50 | func mainActorThrowsError_WhenTimeoutExpires() async { 51 | await #expect(throws: TimeoutError.self) { @MainActor in 52 | try await withThrowingTimeout(seconds: 0.05) { 53 | MainActor.assertIsolated() 54 | defer { MainActor.assertIsolated() } 55 | try await Task.sleepIndefinitely() 56 | } 57 | } 58 | } 59 | 60 | @Test 61 | func sendable_ReturnsValue() async throws { 62 | let sendable = TestActor() 63 | let value = try await withThrowingTimeout(seconds: 1) { 64 | sendable 65 | } 66 | #expect(value === sendable) 67 | } 68 | 69 | @Test 70 | func nonSendable_ReturnsValue() async throws { 71 | let ns = try await withThrowingTimeout(seconds: 1) { 72 | NonSendable("chips") 73 | } 74 | #expect(ns.value == "chips") 75 | } 76 | 77 | @Test 78 | func actor_ReturnsValue() async throws { 79 | #expect( 80 | try await TestActor("Fish").returningValue() == "Fish" 81 | ) 82 | } 83 | 84 | @Test 85 | func actorThrowsError_WhenTimeoutExpires() async { 86 | await #expect(throws: TimeoutError.self) { 87 | try await withThrowingTimeout(seconds: 0.05) { 88 | try await TestActor().returningValue(after: 60, timeout: 0.05) 89 | } 90 | } 91 | } 92 | 93 | @Test 94 | func timeout_cancels() async { 95 | let task = Task { 96 | try await withThrowingTimeout(seconds: 1) { 97 | try await Task.sleep(nanoseconds: 1_000_000_000) 98 | } 99 | } 100 | 101 | task.cancel() 102 | 103 | await #expect(throws: CancellationError.self) { 104 | try await task.value 105 | } 106 | } 107 | 108 | @Test 109 | func returnsValue_beforeDeadlineExpires() async throws { 110 | #expect( 111 | try await TestActor("Fish").returningValue(before: .now + .seconds(2)) == "Fish" 112 | ) 113 | } 114 | 115 | @Test 116 | func throwsError_WhenDeadlineExpires() async { 117 | await #expect(throws: TimeoutError.self) { 118 | try await TestActor("Fish").returningValue(after: 0.1, before: .now) 119 | } 120 | } 121 | 122 | @Test 123 | func returnsValueWithClock_beforeDeadlineExpires() async throws { 124 | #expect( 125 | try await withThrowingTimeout(after: .now + .seconds(2), clock: ContinuousClock()) { 126 | "Fish" 127 | } == "Fish" 128 | ) 129 | } 130 | 131 | @Test 132 | func throwsErrorWithClock_WhenDeadlineExpires() async { 133 | await #expect(throws: TimeoutError.self) { 134 | try await withThrowingTimeout(after: .now, clock: ContinuousClock()) { 135 | try await Task.sleep(for: .seconds(2)) 136 | } 137 | } 138 | } 139 | 140 | @Test 141 | func timeout_ExpiresImmediatley() async throws { 142 | await #expect(throws: TimeoutError.self) { 143 | try await withThrowingTimeout(seconds: 1_000) { timeout in 144 | timeout.expireImmediatley() 145 | } 146 | } 147 | } 148 | 149 | @Test 150 | func timeout_ExpiresAfterSeconds() async throws { 151 | await #expect(throws: TimeoutError.self) { 152 | try await withThrowingTimeout(seconds: 1_000) { timeout in 153 | timeout.expire(seconds: 0.1) 154 | try await Task.sleepIndefinitely() 155 | } 156 | } 157 | } 158 | 159 | @Test 160 | func timeout_ExpiresAfterDeadline() async throws { 161 | await #expect(throws: TimeoutError.self) { 162 | try await withThrowingTimeout(seconds: 1_000) { timeout in 163 | timeout.expire(after: .now + .seconds(0.1)) 164 | try await Task.sleepIndefinitely() 165 | } 166 | } 167 | } 168 | 169 | @Test 170 | func timeout_ExpirationCancels() async throws { 171 | #expect( 172 | try await withThrowingTimeout(seconds: 0.1) { timeout in 173 | timeout.cancelExpiration() 174 | try await Task.sleep(for: .seconds(0.3)) 175 | return "Fish" 176 | } == "Fish" 177 | ) 178 | } 179 | } 180 | 181 | public struct NonSendable { 182 | public var value: T 183 | 184 | init(_ value: T) { 185 | self.value = value 186 | } 187 | } 188 | 189 | final actor TestActor { 190 | 191 | private var value: T 192 | 193 | init(_ value: T) { 194 | self.value = value 195 | } 196 | 197 | init() where T == String { 198 | self.init("fish") 199 | } 200 | 201 | func returningValue(after sleep: TimeInterval = 0, timeout: TimeInterval = 1) async throws -> T { 202 | try await withThrowingTimeout(seconds: timeout) { 203 | try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000)) 204 | self.assertIsolated() 205 | return self.value 206 | } 207 | } 208 | 209 | func returningValue(after sleep: TimeInterval = 0, before instant: ContinuousClock.Instant) async throws -> T { 210 | try await withThrowingTimeout(after: instant) { 211 | try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000)) 212 | self.assertIsolated() 213 | return self.value 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Sources/withThrowingTimeout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // withThrowingTimeout.swift 3 | // swift-timeout 4 | // 5 | // Created by Simon Whitty on 31/08/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-timeout 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 | import protocol Foundation.LocalizedError 33 | import struct Foundation.TimeInterval 34 | 35 | public struct TimeoutError: LocalizedError { 36 | public var errorDescription: String? 37 | 38 | init(_ description: String) { 39 | self.errorDescription = description 40 | } 41 | } 42 | 43 | #if compiler(>=6.2) 44 | 45 | nonisolated(nonsending) public func withThrowingTimeout( 46 | seconds: TimeInterval, 47 | body: () async throws -> T 48 | ) async throws -> T { 49 | try await _withThrowingTimeout(body: { _ in try await body() }) { 50 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 51 | throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.") 52 | }.value 53 | } 54 | 55 | nonisolated(nonsending) public func withThrowingTimeout( 56 | seconds: TimeInterval, 57 | body: (TimeoutController) async throws -> T 58 | ) async throws -> T { 59 | try await _withThrowingTimeout(body: body) { 60 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 61 | throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.") 62 | }.value 63 | } 64 | 65 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 66 | nonisolated(nonsending) public func withThrowingTimeout( 67 | after instant: C.Instant, 68 | tolerance: C.Instant.Duration? = nil, 69 | clock: C, 70 | body: () async throws -> sending T 71 | ) async throws -> sending T { 72 | try await _withThrowingTimeout(body: { _ in try await body() }) { 73 | try await Task.sleep(until: instant, tolerance: tolerance, clock: clock) 74 | throw TimeoutError("Task timed out before completion. Deadline: \(instant).") 75 | }.value 76 | } 77 | 78 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 79 | nonisolated(nonsending) public func withThrowingTimeout( 80 | after instant: ContinuousClock.Instant, 81 | tolerance: ContinuousClock.Instant.Duration? = nil, 82 | body: () async throws -> sending T 83 | ) async throws -> sending T { 84 | try await _withThrowingTimeout(body: { _ in try await body() }) { 85 | try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock()) 86 | throw TimeoutError("Task timed out before completion. Deadline: \(instant).") 87 | }.value 88 | } 89 | 90 | private func _withThrowingTimeout( 91 | isolation: isolated (any Actor)? = #isolation, 92 | body: (TimeoutController) async throws -> T, 93 | timeout closure: @Sendable @escaping () async throws -> Never 94 | ) async throws -> Transferring { 95 | try await withoutActuallyEscaping(body) { escapingBody in 96 | try await withNonEscapingTimeout(closure) { timeout in 97 | let bodyTask = Task { 98 | defer { _ = isolation } 99 | return try await Transferring(escapingBody(timeout)) 100 | } 101 | let timeoutTask = Task { 102 | defer { bodyTask.cancel() } 103 | try await timeout.waitForTimeout() 104 | } 105 | 106 | let bodyResult = await withTaskCancellationHandler { 107 | await bodyTask.result 108 | } onCancel: { 109 | bodyTask.cancel() 110 | } 111 | timeoutTask.cancel() 112 | 113 | if case .failure(let timeoutError) = await timeoutTask.result, 114 | timeoutError is TimeoutError { 115 | throw timeoutError 116 | } else { 117 | return try bodyResult.get() 118 | } 119 | } 120 | } 121 | } 122 | 123 | #else 124 | 125 | public func withThrowingTimeout( 126 | isolation: isolated (any Actor)? = #isolation, 127 | seconds: TimeInterval, 128 | body: () async throws -> sending T 129 | ) async throws -> sending T { 130 | try await _withThrowingTimeout(isolation: isolation, body: { _ in try await body() }) { 131 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 132 | throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.") 133 | }.value 134 | } 135 | 136 | public func withThrowingTimeout( 137 | isolation: isolated (any Actor)? = #isolation, 138 | seconds: TimeInterval, 139 | body: (TimeoutController) async throws -> sending T 140 | ) async throws -> sending T { 141 | try await _withThrowingTimeout(isolation: isolation, body: body) { 142 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 143 | throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.") 144 | }.value 145 | } 146 | 147 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 148 | public func withThrowingTimeout( 149 | isolation: isolated (any Actor)? = #isolation, 150 | after instant: C.Instant, 151 | tolerance: C.Instant.Duration? = nil, 152 | clock: C, 153 | body: () async throws -> sending T 154 | ) async throws -> sending T { 155 | try await _withThrowingTimeout(isolation: isolation, body: { _ in try await body() }) { 156 | try await Task.sleep(until: instant, tolerance: tolerance, clock: clock) 157 | throw TimeoutError("Task timed out before completion. Deadline: \(instant).") 158 | }.value 159 | } 160 | 161 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 162 | public func withThrowingTimeout( 163 | isolation: isolated (any Actor)? = #isolation, 164 | after instant: ContinuousClock.Instant, 165 | tolerance: ContinuousClock.Instant.Duration? = nil, 166 | body: () async throws -> sending T 167 | ) async throws -> sending T { 168 | try await _withThrowingTimeout(isolation: isolation, body: { _ in try await body() }) { 169 | try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock()) 170 | throw TimeoutError("Task timed out before completion. Deadline: \(instant).") 171 | }.value 172 | } 173 | 174 | private func _withThrowingTimeout( 175 | isolation: isolated (any Actor)? = #isolation, 176 | body: (TimeoutController) async throws -> sending T, 177 | timeout closure: @Sendable @escaping () async throws -> Never 178 | ) async throws -> Transferring { 179 | try await withoutActuallyEscaping(body) { escapingBody in 180 | try await withNonEscapingTimeout(closure) { timeout in 181 | let bodyTask = Task { 182 | defer { _ = isolation } 183 | return try await Transferring(escapingBody(timeout)) 184 | } 185 | let timeoutTask = Task { 186 | defer { bodyTask.cancel() } 187 | try await timeout.waitForTimeout() 188 | } 189 | 190 | let bodyResult = await withTaskCancellationHandler { 191 | await bodyTask.result 192 | } onCancel: { 193 | bodyTask.cancel() 194 | } 195 | timeoutTask.cancel() 196 | 197 | if case .failure(let timeoutError) = await timeoutTask.result, 198 | timeoutError is TimeoutError { 199 | throw timeoutError 200 | } else { 201 | return try bodyResult.get() 202 | } 203 | } 204 | } 205 | } 206 | 207 | #endif 208 | --------------------------------------------------------------------------------