Skip to content

Commit 38f2536

Browse files
committed
Timeout
1 parent 4131696 commit 38f2536

File tree

3 files changed

+213
-24
lines changed

3 files changed

+213
-24
lines changed

Sources/Timeout.swift

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
enqueue {
91+
try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock())
92+
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
93+
}
94+
}
95+
}
96+
97+
extension Timeout {
98+
99+
init(
100+
canary: @escaping @Sendable () -> Void,
101+
pending closure: @escaping @Sendable () async throws -> Never
102+
) {
103+
self.canary = canary
104+
self.state = .init(pending: closure)
105+
}
106+
107+
func enqueue(_ closure: @escaping @Sendable () async throws -> Never) {
108+
state.state.withLock { s in
109+
s.pending = closure
110+
s.running?.cancel()
111+
}
112+
}
113+
114+
func startPendingTask() -> Task<Never, any Error>? {
115+
return state.state.withLock { s in
116+
guard let pending = s.pending else { return nil }
117+
let task = Task { try await pending() }
118+
s.pending = nil
119+
s.running = task
120+
return task
121+
}
122+
}
123+
124+
func waitForTimeout() async throws {
125+
while let task = startPendingTask() {
126+
do {
127+
try await withTaskCancellationHandler {
128+
try await task.value
129+
} onCancel: {
130+
task.cancel()
131+
}
132+
} catch is CancellationError {
133+
continue
134+
} catch {
135+
throw error
136+
}
137+
}
138+
}
139+
}
140+
141+
func withNonEscapingTimeout<T>(
142+
_ timeout: @escaping @Sendable () async throws -> Never,
143+
isolation: isolated (any Actor)? = #isolation,
144+
body: (Timeout) async throws -> sending T
145+
) async throws -> sending T {
146+
// canary ensuring Timeout does not escape at runtime.
147+
// Swift 6.2 and later enforce at compile time with ~Escapable
148+
try await withoutActuallyEscaping({ @Sendable in }) { escaping in
149+
_ = isolation
150+
let timeout = Timeout(canary: escaping, pending: timeout)
151+
return try await Transferring(body(timeout))
152+
}.value
153+
}
154+
155+
#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: 22 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

@@ -47,6 +47,27 @@ struct WithThrowingTimeoutTests {
4747
#expect(val == "Fish")
4848
}
4949

50+
@Test @MainActor
51+
func mainActor_ExpiresImmediatley() async throws {
52+
await #expect(throws: TimeoutError.self) { @MainActor in
53+
try await withThrowingTimeout(seconds: 1_000) { timeout in
54+
MainActor.safeAssertIsolated()
55+
timeout.expireImmediatley()
56+
}
57+
}
58+
59+
}
60+
@Test @MainActor
61+
func mainActor_ExpirationMoves() async throws {
62+
await #expect(throws: TimeoutError.self) { @MainActor in
63+
try await withThrowingTimeout(seconds: 1_000) { timeout in
64+
MainActor.safeAssertIsolated()
65+
timeout.expireAfter(seconds: 0.1)
66+
try await Task.sleepIndefinitely()
67+
}
68+
}
69+
}
70+
5071
@Test
5172
func mainActorThrowsError_WhenTimeoutExpires() async {
5273
await #expect(throws: TimeoutError.self) { @MainActor in

0 commit comments

Comments
 (0)