├── 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 | [](https://github.com/swhitty/swift-timeout/actions/workflows/build.yml)
2 | [](https://codecov.io/gh/swhitty/swift-timeout)
3 | [](https://swiftpackageindex.com/swhitty/swift-timeout)
4 | [](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 |
--------------------------------------------------------------------------------