Skip to content

Commit 303f04d

Browse files
committed
deadline
1 parent f345f25 commit 303f04d

File tree

4 files changed

+146
-17
lines changed

4 files changed

+146
-17
lines changed

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,25 @@ To install using Swift Package Manager, add this to the `dependencies:` section
2222

2323
# Usage
2424

25-
Usage is similar to using structured concurrency:
25+
Usage is similar to using structured concurrency, provide a closure and a [`ContinousClock.Instant`](https://developer.apple.com/documentation/swift/continuousclock/instant) for when the child task is cancelled and `TimeoutError` is thrown:
2626

2727
```swift
2828
import Timeout
2929

30-
let val = try await withThrowingTimeout(seconds: 1.5) {
30+
let val = try await withThrowingTimeout(after: .now + .seconds(2)) {
3131
try await perform()
3232
}
3333
```
3434

35-
If the timeout expires before a value is returned the task is cancelled and `TimeoutError` is thrown.
35+
`TimeInterval` can also be provided:
36+
37+
```swift
38+
let val = try await withThrowingTimeout(seconds: 2.0) {
39+
try await perform()
40+
}
41+
```
42+
43+
When deadline is reached the task executing the closure is cancelled and `TimeoutError` is thrown.
3644

3745
# Credits
3846

Sources/Timeout.swift

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ import Foundation
3434
public struct TimeoutError: LocalizedError {
3535
public var errorDescription: String?
3636

37-
public init(timeout: TimeInterval) {
38-
self.errorDescription = "Task timed out before completion. Timeout: \(timeout) seconds."
37+
init(_ description: String) {
38+
self.errorDescription = description
3939
}
4040
}
4141

@@ -45,15 +45,52 @@ public func withThrowingTimeout<T>(
4545
seconds: TimeInterval,
4646
body: () async throws -> sending T
4747
) async throws -> sending T {
48+
try await _withThrowingTimeout(isolation: isolation, body: body) {
49+
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
50+
throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.")
51+
}.value
52+
}
53+
54+
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
55+
public func withThrowingTimeout<T, C: Clock>(
56+
isolation: isolated (any Actor)? = #isolation,
57+
after instant: C.Instant,
58+
tolerance: C.Instant.Duration? = nil,
59+
clock: C,
60+
body: () async throws -> sending T
61+
) async throws -> sending T {
62+
try await _withThrowingTimeout(isolation: isolation, body: body) {
63+
try await Task.sleep(until: instant, tolerance: tolerance, clock: clock)
64+
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
65+
}.value
66+
}
67+
68+
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
69+
public func withThrowingTimeout<T>(
70+
isolation: isolated (any Actor)? = #isolation,
71+
after instant: ContinuousClock.Instant,
72+
tolerance: ContinuousClock.Instant.Duration? = nil,
73+
body: () async throws -> sending T
74+
) async throws -> sending T {
75+
try await _withThrowingTimeout(isolation: isolation, body: body) {
76+
try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock())
77+
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
78+
}.value
79+
}
80+
81+
private func _withThrowingTimeout<T>(
82+
isolation: isolated (any Actor)? = #isolation,
83+
body: () async throws -> sending T,
84+
timeout: @Sendable @escaping () async throws -> Void
85+
) async throws -> Transferring<T> {
4886
try await withoutActuallyEscaping(body) { escapingBody in
4987
let bodyTask = Task {
5088
defer { _ = isolation }
5189
return try await Transferring(escapingBody())
5290
}
5391
let timeoutTask = Task {
5492
defer { bodyTask.cancel() }
55-
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
56-
throw TimeoutError(timeout: seconds)
93+
try await timeout()
5794
}
5895

5996
let bodyResult = await withTaskCancellationHandler {
@@ -74,7 +111,7 @@ public func withThrowingTimeout<T>(
74111
throw bodyError
75112
}
76113
}
77-
}.value
114+
}
78115
}
79116

80117
private struct Transferring<Value>: Sendable {
@@ -94,22 +131,44 @@ public func withThrowingTimeout<T>(
94131
return try await withoutActuallyEscaping(transferringBody) {
95132
(_ fn: @escaping NonSendableClosure) async throws -> Transferring<T> in
96133
let sendableFn = unsafeBitCast(fn, to: SendableClosure.self)
97-
return try await _withThrowingTimeout(seconds: seconds, body: sendableFn)
134+
return try await _withThrowingTimeout(body: sendableFn) {
135+
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
136+
throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.")
137+
}
138+
}.value
139+
}
140+
141+
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
142+
public func withThrowingTimeout<T>(
143+
after instant: ContinuousClock.Instant,
144+
tolerance: ContinuousClock.Instant.Duration? = nil,
145+
body: () async throws -> T
146+
) async throws -> T {
147+
let transferringBody = { try await Transferring(body()) }
148+
typealias NonSendableClosure = () async throws -> Transferring<T>
149+
typealias SendableClosure = @Sendable () async throws -> Transferring<T>
150+
return try await withoutActuallyEscaping(transferringBody) {
151+
(_ fn: @escaping NonSendableClosure) async throws -> Transferring<T> in
152+
let sendableFn = unsafeBitCast(fn, to: SendableClosure.self)
153+
return try await _withThrowingTimeout(body: sendableFn) {
154+
try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock())
155+
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
156+
}
98157
}.value
99158
}
100159

101160
// Sendable
102161
private func _withThrowingTimeout<T: Sendable>(
103-
seconds: TimeInterval,
104-
body: @Sendable @escaping () async throws -> T
162+
body: @Sendable @escaping () async throws -> T,
163+
timeout: @Sendable @escaping () async throws -> Void
105164
) async throws -> T {
106165
try await withThrowingTaskGroup(of: T.self) { group in
107166
group.addTask {
108167
try await body()
109168
}
110169
group.addTask {
111-
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
112-
throw TimeoutError(timeout: seconds)
170+
try await timeout()
171+
throw TimeoutError("expired")
113172
}
114173
let success = try await group.next()!
115174
group.cancelAll()

Tests/TimeoutTests.swift

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ struct TimeoutTests {
3939
@Test @MainActor
4040
func mainActor_ReturnsValue() async throws {
4141
let val = try await withThrowingTimeout(seconds: 1) {
42-
MainActor.assertIsolated()
42+
MainActor.safeAssertIsolated()
4343
try await Task.sleep(nanoseconds: 1_000)
44-
MainActor.assertIsolated()
44+
MainActor.safeAssertIsolated()
4545
return "Fish"
4646
}
4747
#expect(val == "Fish")
@@ -51,8 +51,8 @@ struct TimeoutTests {
5151
func mainActorThrowsError_WhenTimeoutExpires() async {
5252
await #expect(throws: TimeoutError.self) { @MainActor in
5353
try await withThrowingTimeout(seconds: 0.05) {
54-
MainActor.assertIsolated()
55-
defer { MainActor.assertIsolated() }
54+
MainActor.safeAssertIsolated()
55+
defer { MainActor.safeAssertIsolated() }
5656
try await Task.sleep(nanoseconds: 60_000_000_000)
5757
}
5858
}
@@ -105,6 +105,20 @@ struct TimeoutTests {
105105
try await task.value
106106
}
107107
}
108+
109+
@Test
110+
func returnsValue_beforeDeadlineExpires() async throws {
111+
#expect(
112+
try await TestActor("Fish").returningValue(before: .now + .seconds(2)) == "Fish"
113+
)
114+
}
115+
116+
@Test
117+
func throwsError_WhenDeadlineExpires() async {
118+
await #expect(throws: TimeoutError.self) {
119+
try await TestActor("Fish").returningValue(after: 0.1, before: .now)
120+
}
121+
}
108122
}
109123

110124
public struct NonSendable<T> {
@@ -130,9 +144,33 @@ final actor TestActor<T: Sendable> {
130144
func returningValue(after sleep: TimeInterval = 0, timeout: TimeInterval = 1) async throws -> T {
131145
try await withThrowingTimeout(seconds: timeout) {
132146
try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
147+
#if compiler(>=5.10)
133148
self.assertIsolated()
149+
#endif
150+
return self.value
151+
}
152+
}
153+
154+
func returningValue(after sleep: TimeInterval = 0, before instant: ContinuousClock.Instant) async throws -> T {
155+
try await withThrowingTimeout(after: instant) {
156+
try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
157+
#if compiler(>=5.10)
158+
self.assertIsolated()
159+
#endif
134160
return self.value
135161
}
136162
}
137163
}
164+
165+
extension MainActor {
166+
167+
static func safeAssertIsolated() {
168+
#if compiler(>=5.10)
169+
assertIsolated()
170+
#else
171+
precondition(Thread.isMainThread)
172+
#endif
173+
}
174+
}
175+
138176
#endif

Tests/TimeoutXCTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ final class TimeoutTests: XCTestCase {
108108
XCTAssertTrue(error is CancellationError)
109109
}
110110
}
111+
112+
func testReturnsValue_beforeDeadlineExpires() async throws {
113+
let val = try await TestActor("Fish").returningValue(before: .now + .seconds(2))
114+
XCTAssert(val == "Fish")
115+
}
116+
117+
func testThrowsError_WhenDeadlineExpires() async {
118+
do {
119+
_ = try await TestActor("Fish").returningValue(after: 0.1, before: .now)
120+
XCTFail("Expected Error")
121+
} catch {
122+
XCTAssertTrue(error is TimeoutError)
123+
}
124+
}
111125
}
112126

113127
public struct NonSendable<T> {
@@ -137,5 +151,15 @@ final actor TestActor<T: Sendable> {
137151
return self.value
138152
}
139153
}
154+
155+
func returningValue(after sleep: TimeInterval = 0, before instant: ContinuousClock.Instant) async throws -> T {
156+
try await withThrowingTimeout(after: instant) {
157+
try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
158+
#if compiler(>=5.10)
159+
self.assertIsolated()
160+
#endif
161+
return self.value
162+
}
163+
}
140164
}
141165
#endif

0 commit comments

Comments
 (0)