Skip to content

Commit 17051a9

Browse files
committed
Initial Commit
1 parent 21829a9 commit 17051a9

File tree

9 files changed

+422
-0
lines changed

9 files changed

+422
-0
lines changed

.github/workflows/build.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: build
2+
3+
on:
4+
push:
5+
pull_request:
6+
workflow_dispatch:
7+
8+
jobs:
9+
xcode_16:
10+
runs-on: macos-14
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v4
14+
- name: 🔍 Xcode Select
15+
run: |
16+
XCODE_PATH=`mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode' && kMDItemVersion == '16.*'" -onlyin /Applications | head -1`
17+
echo "DEVELOPER_DIR=$XCODE_PATH/Contents/Developer" >> $GITHUB_ENV
18+
- name: Version
19+
run: swift --version
20+
- name: Build
21+
run: swift test --enable-code-coverage --filter do_not_test
22+
- name: Test
23+
run: swift test --enable-code-coverage --skip-build
24+
- name: Gather code coverage
25+
run: xcrun llvm-cov export -format="lcov" .build/debug/TaskTimeoutPackageTests.xctest/Contents/MacOS/TaskTimeoutPackageTests -instr-profile .build/debug/codecov/default.profdata > coverage_report.lcov
26+
- name: Upload Coverage
27+
uses: codecov/codecov-action@v4
28+
with:
29+
token: ${{ secrets.CODECOV_TOKEN }}
30+
files: ./coverage_report.lcov
31+
32+
xcode_15_4:
33+
runs-on: macos-14
34+
env:
35+
DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer
36+
steps:
37+
- name: Checkout
38+
uses: actions/checkout@v4
39+
- name: Version
40+
run: swift --version
41+
- name: Build
42+
run: swift build --build-tests
43+
- name: Test
44+
run: swift test --skip-build
45+
46+
linux_swift_5_10:
47+
runs-on: ubuntu-latest
48+
container: swift:5.10
49+
steps:
50+
- name: Checkout
51+
uses: actions/checkout@v4
52+
- name: Version
53+
run: swift --version
54+
- name: Build
55+
run: swift build --build-tests
56+
- name: Test
57+
run: swift test --skip-build
58+
59+
linux_swift_6_0:
60+
runs-on: ubuntu-latest
61+
container: swiftlang/swift:nightly-6.0-jammy
62+
steps:
63+
- name: Checkout
64+
uses: actions/checkout@v4
65+
- name: Version
66+
run: swift --version
67+
- name: Build
68+
run: swift build --build-tests
69+
- name: Test
70+
run: swift test --skip-build
71+

.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// swift-tools-version:6.0
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "TaskTimeout",
7+
platforms: [
8+
.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)
9+
],
10+
products: [
11+
.library(
12+
name: "TaskTimeout",
13+
targets: ["TaskTimeout"]
14+
)
15+
],
16+
targets: [
17+
.target(
18+
name: "TaskTimeout",
19+
path: "Sources",
20+
swiftSettings: .upcomingFeatures
21+
),
22+
.testTarget(
23+
name: "TaskTimeoutTests",
24+
dependencies: ["TaskTimeout"],
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+
}

[email protected]

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// swift-tools-version:5.10
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "TaskTimeout",
7+
platforms: [
8+
.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)
9+
],
10+
products: [
11+
.library(
12+
name: "TaskTimeout",
13+
targets: ["TaskTimeout"]
14+
)
15+
],
16+
targets: [
17+
.target(
18+
name: "TaskTimeout",
19+
path: "Sources",
20+
swiftSettings: .upcomingFeatures
21+
),
22+
.testTarget(
23+
name: "TaskTimeoutTests",
24+
dependencies: ["TaskTimeout"],
25+
path: "Tests",
26+
swiftSettings: .upcomingFeatures
27+
)
28+
]
29+
)
30+
31+
extension Array where Element == SwiftSetting {
32+
33+
static var upcomingFeatures: [SwiftSetting] {
34+
[
35+
.enableExperimentalFeature("StrictConcurrency")
36+
]
37+
}
38+
}

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[![Build](https://github.com/swhitty/TaskTimeout/actions/workflows/build.yml/badge.svg)](https://github.com/swhitty/TaskTimeout/actions/workflows/build.yml)
2+
[![Codecov](https://codecov.io/gh/swhitty/TaskTimeout/graphs/badge.svg)](https://codecov.io/gh/swhitty/TaskTimeout)
3+
[![Platforms](https://img.shields.io/badge/platforms-iOS%20|%20Mac%20|%20tvOS%20|%20Linux%20|%20Windows-lightgray.svg)](https://github.com/swhitty/TaskTimeout/blob/main/Package.swift)
4+
[![Swift 6.0](https://img.shields.io/badge/swift-5.10%20–%206.0-red.svg?style=flat)](https://developer.apple.com/swift)
5+
[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://opensource.org/licenses/MIT)
6+
[![Twitter](https://img.shields.io/badge/[email protected])](http://twitter.com/simonwhitty)
7+
8+
# Introduction
9+
10+
**TaskTimeout** is a lightweight wrapper around [`ThrowingTaskGroup`](https://developer.apple.com/documentation/swift/throwingtaskgroup) that executes a closure with a given timeout.
11+
12+
# Installation
13+
14+
TaskTimeout can be installed by using Swift Package Manager.
15+
16+
**Note:** TaskTimeout requires Swift 5.10 on Xcode 15.4+. It runs on iOS 13+, tvOS 13+, macOS 10.15+, Linux and Windows.
17+
To install using Swift Package Manager, add this to the `dependencies:` section in your Package.swift file:
18+
19+
```swift
20+
.package(url: "https://github.com/swhitty/TaskTimeout.git", .upToNextMajor(from: "0.1.0"))
21+
```
22+
23+
# Usage
24+
25+
Usage is similar to using task groups:
26+
27+
```swift
28+
let val = try await withThrowingTimeout(seconds: 1.5) {
29+
try await perform()
30+
}
31+
```
32+
33+
If the timeout expires before a value is returned the task is cancelled and `TimeoutError` is thrown.
34+
35+
# Credits
36+
37+
TaskTimeout is primarily the work of [Simon Whitty](https://github.com/swhitty).
38+
39+
([Full list of contributors](https://github.com/swhitty/TaskTimeout/graphs/contributors))

Sources/TaskTimeout.swift

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// TaskTimeout.swift
3+
// TaskTimeout
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/TaskTimeout
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 Foundation
33+
34+
public struct TimeoutError: LocalizedError {
35+
public var errorDescription: String?
36+
37+
public init(timeout: TimeInterval) {
38+
self.errorDescription = "Task timed out before completion. Timeout: \(timeout) seconds."
39+
}
40+
}
41+
42+
#if compiler(>=6.0)
43+
public func withThrowingTimeout<T>(
44+
isolation: isolated (any Actor)? = #isolation,
45+
seconds: TimeInterval,
46+
body: () async throws -> sending T
47+
) async throws -> sending T {
48+
let transferringBody = { try await Transferring(body()) }
49+
typealias NonSendableClosure = () async throws -> Transferring<T>
50+
typealias SendableClosure = @Sendable () async throws -> Transferring<T>
51+
return try await withoutActuallyEscaping(transferringBody) {
52+
(_ fn: @escaping NonSendableClosure) async throws -> Transferring<T> in
53+
let sendableFn = unsafeBitCast(fn, to: SendableClosure.self)
54+
return try await _withThrowingTimeout(isolation: isolation, seconds: seconds, body: sendableFn)
55+
}.value
56+
}
57+
58+
// Sendable
59+
private func _withThrowingTimeout<T: Sendable>(
60+
isolation: isolated (any Actor)? = #isolation,
61+
seconds: TimeInterval,
62+
body: @Sendable @escaping () async throws -> T
63+
) async throws -> T {
64+
try await withThrowingTaskGroup(of: T.self) { group in
65+
group.addTask {
66+
try await body()
67+
}
68+
group.addTask {
69+
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
70+
throw TimeoutError(timeout: seconds)
71+
}
72+
let success = try await group.next()!
73+
group.cancelAll()
74+
return success
75+
}
76+
}
77+
78+
private struct Transferring<Value>: Sendable {
79+
80+
nonisolated(unsafe) public var value: Value
81+
82+
init(_ value: Value) {
83+
self.value = value
84+
}
85+
}
86+
#else
87+
public func withThrowingTimeout<T>(
88+
seconds: TimeInterval,
89+
body: () async throws -> T
90+
) async throws -> T {
91+
let transferringBody = { try await Transferring(body()) }
92+
typealias NonSendableClosure = () async throws -> Transferring<T>
93+
typealias SendableClosure = @Sendable () async throws -> Transferring<T>
94+
return try await withoutActuallyEscaping(transferringBody) {
95+
(_ fn: @escaping NonSendableClosure) async throws -> Transferring<T> in
96+
let sendableFn = unsafeBitCast(fn, to: SendableClosure.self)
97+
return try await _withThrowingTimeout(seconds: seconds, body: sendableFn)
98+
}.value
99+
}
100+
101+
// Sendable
102+
private func _withThrowingTimeout<T: Sendable>(
103+
seconds: TimeInterval,
104+
body: @Sendable @escaping () async throws -> T
105+
) async throws -> T {
106+
try await withThrowingTaskGroup(of: T.self) { group in
107+
group.addTask {
108+
try await body()
109+
}
110+
group.addTask {
111+
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
112+
throw TimeoutError(timeout: seconds)
113+
}
114+
let success = try await group.next()!
115+
group.cancelAll()
116+
return success
117+
}
118+
}
119+
120+
private struct Transferring<Value>: @unchecked Sendable {
121+
122+
var value: Value
123+
124+
init(_ value: Value) {
125+
self.value = value
126+
}
127+
}
128+
#endif

0 commit comments

Comments
 (0)