Skip to content

Commit 2faebb8

Browse files
committed
Timeout
1 parent 4131696 commit 2faebb8

File tree

3 files changed

+229
-24
lines changed

3 files changed

+229
-24
lines changed

Sources/Timeout.swift

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//
2+
// Timeout.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 compiler(>=6.0)
33+
import Foundation
34+
35+
public struct Timeout: Sendable {
36+
fileprivate var canary: @Sendable () -> Void
37+
fileprivate let state: SharedState
38+
39+
public func expireAfter(seconds: TimeInterval) {
40+
enqueue {
41+
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
42+
throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.")
43+
}
44+
}
45+
46+
public func expireImmediatley() {
47+
enqueue {
48+
throw TimeoutError("Task timed out before completion. expireImmediatley()")
49+
}
50+
}
51+
52+
public func cancelExpiration() {
53+
enqueue {
54+
try await Task.sleepIndefinitely()
55+
}
56+
}
57+
58+
struct State {
59+
var running: Task<Never, any Error>?
60+
var pending: (@Sendable () async throws -> Never)?
61+
}
62+
63+
final class SharedState: Sendable {
64+
let state: Mutex<State>
65+
66+
init(pending: @escaping @Sendable () async throws -> Never) {
67+
state = Mutex(.init(pending: pending))
68+
}
69+
}
70+
}
71+
72+
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
73+
public extension Timeout {
74+
75+
func expire<C: Clock>(
76+
after instant: C.Instant,
77+
tolerance: C.Instant.Duration? = nil,
78+
clock: C
79+
) {
80+
enqueue {
81+
try await Task.sleep(until: instant, tolerance: tolerance, clock: clock)
82+
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
83+
}
84+
}
85+
86+
func expire(
87+
after instant: ContinuousClock.Instant,
88+
tolerance: ContinuousClock.Instant.Duration? = nil
89+
) {
90+
expire(after: instant, tolerance: tolerance, clock: ContinuousClock())
91+
}
92+
}
93+
94+
extension Timeout {
95+
96+
init(
97+
canary: @escaping @Sendable () -> Void,
98+
pending closure: @escaping @Sendable () async throws -> Never
99+
) {
100+
self.canary = canary
101+
self.state = .init(pending: closure)
102+
}
103+
104+
func enqueue(_ closure: @escaping @Sendable () async throws -> Never) {
105+
state.state.withLock { s in
106+
s.pending = closure
107+
s.running?.cancel()
108+
}
109+
}
110+
111+
func startPendingTask() -> Task<Never, any Error>? {
112+
return state.state.withLock { s in
113+
guard let pending = s.pending else { return nil }
114+
let task = Task { try await pending() }
115+
s.pending = nil
116+
s.running = task
117+
return task
118+
}
119+
}
120+
121+
func waitForTimeout() async throws {
122+
while let task = startPendingTask() {
123+
do {
124+
try await withTaskCancellationHandler {
125+
try await task.value
126+
} onCancel: {
127+
task.cancel()
128+
}
129+
} catch is CancellationError {
130+
continue
131+
} catch {
132+
throw error
133+
}
134+
}
135+
}
136+
}
137+
138+
func withNonEscapingTimeout<T>(
139+
_ timeout: @escaping @Sendable () async throws -> Never,
140+
isolation: isolated (any Actor)? = #isolation,
141+
body: (Timeout) async throws -> sending T
142+
) async throws -> sending T {
143+
// canary ensuring Timeout does not escape at runtime.
144+
// Swift 6.2 and later enforce at compile time with ~Escapable
145+
try await withoutActuallyEscaping({ @Sendable in }) { escaping in
146+
_ = isolation
147+
let timeout = Timeout(canary: escaping, pending: timeout)
148+
return try await Transferring(body(timeout))
149+
}.value
150+
}
151+
152+
#endif

Sources/withThrowingTimeout.swift

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ public func withThrowingTimeout<T>(
4444
isolation: isolated (any Actor)? = #isolation,
4545
seconds: TimeInterval,
4646
body: () async throws -> sending T
47+
) async throws -> sending T {
48+
try await _withThrowingTimeout(isolation: isolation, body: { _ in try await 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+
public func withThrowingTimeout<T>(
55+
isolation: isolated (any Actor)? = #isolation,
56+
seconds: TimeInterval,
57+
body: (Timeout) async throws -> sending T
4758
) async throws -> sending T {
4859
try await _withThrowingTimeout(isolation: isolation, body: body) {
4960
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
@@ -59,7 +70,7 @@ public func withThrowingTimeout<T, C: Clock>(
5970
clock: C,
6071
body: () async throws -> sending T
6172
) async throws -> sending T {
62-
try await _withThrowingTimeout(isolation: isolation, body: body) {
73+
try await _withThrowingTimeout(isolation: isolation, body: { _ in try await body() }) {
6374
try await Task.sleep(until: instant, tolerance: tolerance, clock: clock)
6475
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
6576
}.value
@@ -72,39 +83,41 @@ public func withThrowingTimeout<T>(
7283
tolerance: ContinuousClock.Instant.Duration? = nil,
7384
body: () async throws -> sending T
7485
) async throws -> sending T {
75-
try await _withThrowingTimeout(isolation: isolation, body: body) {
86+
try await _withThrowingTimeout(isolation: isolation, body: { _ in try await body() }) {
7687
try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock())
7788
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
7889
}.value
7990
}
8091

8192
private func _withThrowingTimeout<T>(
8293
isolation: isolated (any Actor)? = #isolation,
83-
body: () async throws -> sending T,
84-
timeout: @Sendable @escaping () async throws -> Never
94+
body: (Timeout) async throws -> sending T,
95+
timeout closure: @Sendable @escaping () async throws -> Never
8596
) async throws -> Transferring<T> {
8697
try await withoutActuallyEscaping(body) { escapingBody in
87-
let bodyTask = Task {
88-
defer { _ = isolation }
89-
return try await Transferring(escapingBody())
90-
}
91-
let timeoutTask = Task {
92-
defer { bodyTask.cancel() }
93-
try await timeout()
94-
}
98+
try await withNonEscapingTimeout(closure) { timeout in
99+
let bodyTask = Task {
100+
defer { _ = isolation }
101+
return try await Transferring(escapingBody(timeout))
102+
}
103+
let timeoutTask = Task {
104+
defer { bodyTask.cancel() }
105+
try await timeout.waitForTimeout()
106+
}
95107

96-
let bodyResult = await withTaskCancellationHandler {
97-
await bodyTask.result
98-
} onCancel: {
99-
bodyTask.cancel()
100-
}
101-
timeoutTask.cancel()
108+
let bodyResult = await withTaskCancellationHandler {
109+
await bodyTask.result
110+
} onCancel: {
111+
bodyTask.cancel()
112+
}
113+
timeoutTask.cancel()
102114

103-
if case .failure(let timeoutError) = await timeoutTask.result,
104-
timeoutError is TimeoutError {
105-
throw timeoutError
106-
} else {
107-
return try bodyResult.get()
115+
if case .failure(let timeoutError) = await timeoutTask.result,
116+
timeoutError is TimeoutError {
117+
throw timeoutError
118+
} else {
119+
return try bodyResult.get()
120+
}
108121
}
109122
}
110123
}

Tests/withThrowingTimeoutTests.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
//
3131

3232
#if canImport(Testing)
33-
import Timeout
33+
@testable import Timeout
3434
import Foundation
3535
import Testing
3636

@@ -137,6 +137,46 @@ struct WithThrowingTimeoutTests {
137137
}
138138
}
139139
}
140+
141+
@Test
142+
func timeout_ExpiresImmediatley() async throws {
143+
await #expect(throws: TimeoutError.self) {
144+
try await withThrowingTimeout(seconds: 1_000) { timeout in
145+
timeout.expireImmediatley()
146+
}
147+
}
148+
}
149+
150+
@Test
151+
func timeout_ExpiresAfterSeconds() async throws {
152+
await #expect(throws: TimeoutError.self) {
153+
try await withThrowingTimeout(seconds: 1_000) { timeout in
154+
timeout.expireAfter(seconds: 0.1)
155+
try await Task.sleepIndefinitely()
156+
}
157+
}
158+
}
159+
160+
@Test
161+
func timeout_ExpiresAfterDeadline() async throws {
162+
await #expect(throws: TimeoutError.self) {
163+
try await withThrowingTimeout(seconds: 1_000) { timeout in
164+
timeout.expire(after: .now + .seconds(0.1))
165+
try await Task.sleepIndefinitely()
166+
}
167+
}
168+
}
169+
170+
@Test
171+
func timeout_ExpirationCancels() async throws {
172+
#expect(
173+
try await withThrowingTimeout(seconds: 0.1) { timeout in
174+
timeout.cancelExpiration()
175+
try await Task.sleep(for: .seconds(0.3))
176+
return "Fish"
177+
} == "Fish"
178+
)
179+
}
140180
}
141181

142182
public struct NonSendable<T> {

0 commit comments

Comments
 (0)