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