Skip to content

Commit ce4a065

Browse files
committed
Timeout
1 parent da42d84 commit ce4a065

File tree

4 files changed

+170
-25
lines changed

4 files changed

+170
-25
lines changed

Sources/Timeout.swift

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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: SharedState
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+
final class SharedState: Sendable {
56+
let state: Mutex<State>
57+
58+
init(pending: @escaping @Sendable () async throws -> Never) {
59+
state = Mutex(.init(pending: pending))
60+
}
61+
}
62+
}
63+
64+
extension Timeout {
65+
66+
init(
67+
canary: @escaping @Sendable () -> Void,
68+
pending closure: @escaping @Sendable () async throws -> Never
69+
) {
70+
self.canary = canary
71+
self.state = .init(pending: closure)
72+
}
73+
74+
func enqueue(_ closure: @escaping @Sendable () async throws -> Never) {
75+
state.state.withLock { s in
76+
s.pending = closure
77+
s.running?.cancel()
78+
}
79+
}
80+
81+
func startPendingTask() -> Task<Never, any Error>? {
82+
return state.state.withLock { s in
83+
guard let pending = s.pending else { return nil }
84+
let task = Task { try await pending() }
85+
s.pending = nil
86+
s.running = task
87+
return task
88+
}
89+
}
90+
91+
func waitForTimeout() async throws {
92+
while let task = startPendingTask() {
93+
do {
94+
try await withTaskCancellationHandler {
95+
try await task.value
96+
} onCancel: {
97+
task.cancel()
98+
}
99+
} catch is CancellationError {
100+
continue
101+
} catch {
102+
throw error
103+
}
104+
}
105+
}
106+
}
107+
108+
func withNonEscapingTimeout<T>(
109+
_ timeout: @escaping @Sendable () async throws -> Never,
110+
isolation: isolated (any Actor)? = #isolation,
111+
body: (Timeout) async throws -> sending T
112+
) async throws -> sending T {
113+
// canary ensuring Timeout does not escape at runtime.
114+
// Swift 6.2 and later enforce at compile time with ~Escapable
115+
try await withoutActuallyEscaping({ @Sendable in }) { escaping in
116+
_ = isolation
117+
let timeout = Timeout(canary: escaping, pending: timeout)
118+
return try await Transferring(body(timeout))
119+
}.value
120+
}
121+
122+
#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

docker-run-tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ set -eu
55
docker run -it \
66
--rm \
77
--mount src="$(pwd)",target=/package,type=bind \
8-
swift:5.10-jammy \
8+
swift:6.1.2-jammy \
99
/usr/bin/swift test --package-path /package

0 commit comments

Comments
 (0)