Skip to content

Commit 9ea07b9

Browse files
committed
Timeout
1 parent 4131696 commit 9ea07b9

File tree

3 files changed

+246
-24
lines changed

3 files changed

+246
-24
lines changed

Sources/Timeout.swift

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