Skip to content

Commit 91b3015

Browse files
committed
Timeout
1 parent da42d84 commit 91b3015

File tree

3 files changed

+161
-24
lines changed

3 files changed

+161
-24
lines changed

Sources/Timeout.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
34+
public struct Timeout: Sendable {
35+
fileprivate var canary: @Sendable () -> Void
36+
fileprivate let state: Mutex<State>
37+
38+
public func expireImmediatley() {
39+
enqueue {
40+
throw TimeoutError("Task timed out before completion. expireImmediatley()")
41+
}
42+
}
43+
44+
public func cancelExpiration() {
45+
enqueue {
46+
try await Task.sleepIndefinitely()
47+
}
48+
}
49+
50+
struct State {
51+
var running: Task<Never, any Error>?
52+
var pending: (@Sendable () async throws -> Never)?
53+
}
54+
}
55+
56+
extension Timeout {
57+
58+
init(
59+
canary: @escaping @Sendable () -> Void,
60+
pending closure: @escaping @Sendable () async throws -> Never
61+
) {
62+
self.canary = canary
63+
self.state = .init(State(pending: closure))
64+
}
65+
66+
func enqueue(_ closure: @escaping @Sendable () async throws -> Never) {
67+
state.withLock { s in
68+
s.pending = closure
69+
s.running?.cancel()
70+
}
71+
}
72+
73+
func startPendingTask() -> Task<Never, any Error>? {
74+
return state.withLock { s in
75+
guard let pending = s.pending else { return nil }
76+
let task = Task { try await pending() }
77+
s.pending = nil
78+
s.running = task
79+
return task
80+
}
81+
}
82+
83+
func waitForTimeout() async throws {
84+
while let task = startPendingTask() {
85+
do {
86+
try await withTaskCancellationHandler {
87+
try await task.value
88+
} onCancel: {
89+
task.cancel()
90+
}
91+
} catch is CancellationError {
92+
continue
93+
} catch {
94+
throw error
95+
}
96+
}
97+
}
98+
}
99+
100+
func withNonEscapingTimeout<T>(
101+
_ timeout: @escaping @Sendable () async throws -> Never,
102+
isolation: isolated (any Actor)? = #isolation,
103+
body: (Timeout) async throws -> sending T
104+
) async throws -> sending T {
105+
// canary ensuring Timeout does not escape at runtime.
106+
// Swift 6.2 and later enforce at compile time with ~Escapable
107+
try await withoutActuallyEscaping({ @Sendable in }) { escaping in
108+
_ = isolation
109+
let timeout = Timeout(canary: escaping, pending: timeout)
110+
return try await Transferring(body(timeout))
111+
}.value
112+
}
113+
114+
#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: 11 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,16 @@ 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+
5060
@Test
5161
func mainActorThrowsError_WhenTimeoutExpires() async {
5262
await #expect(throws: TimeoutError.self) { @MainActor in

0 commit comments

Comments
 (0)