Skip to content

Commit

Permalink
deadline
Browse files Browse the repository at this point in the history
  • Loading branch information
swhitty committed Sep 8, 2024
1 parent f345f25 commit d3a714f
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 13 deletions.
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ To install using Swift Package Manager, add this to the `dependencies:` section

# Usage

Usage is similar to using structured concurrency:
Usage is similar to using structured concurrency, provide a closure and a [`ContinousClock.Instant`](https://developer.apple.com/documentation/swift/continuousclock/instant) for when the child task is cancelled and `TimeoutError` is thrown:

```swift
import Timeout

let val = try await withThrowingTimeout(seconds: 1.5) {
let val = try await withThrowingTimeout(after: .now + .seconds(2)) {
try await perform()
}
```

If the timeout expires before a value is returned the task is cancelled and `TimeoutError` is thrown.
`TimeInterval` can also be provided:

```swift
let val = try await withThrowingTimeout(seconds: 2.0) {
try await perform()
}
```

When deadline is reached the task executing the closure is cancelled and `TimeoutError` is thrown.

# Credits

Expand Down
49 changes: 43 additions & 6 deletions Sources/Timeout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ import Foundation
public struct TimeoutError: LocalizedError {
public var errorDescription: String?

public init(timeout: TimeInterval) {
self.errorDescription = "Task timed out before completion. Timeout: \(timeout) seconds."
init(_ description: String) {
self.errorDescription = description
}
}

Expand All @@ -45,15 +45,52 @@ public func withThrowingTimeout<T>(
seconds: TimeInterval,
body: () async throws -> sending T
) async throws -> sending T {
try await _withThrowingTimeout(isolation: isolation, body: body) {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.")
}.value
}

@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
public func withThrowingTimeout<T, C: Clock>(
isolation: isolated (any Actor)? = #isolation,
after instant: C.Instant,
tolerance: C.Instant.Duration? = nil,
clock: C,
body: () async throws -> sending T
) async throws -> sending T {
try await _withThrowingTimeout(isolation: isolation, body: body) {
try await Task.sleep(until: instant, tolerance: tolerance, clock: clock)
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
}.value
}

@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
public func withThrowingTimeout<T>(
isolation: isolated (any Actor)? = #isolation,
after instant: ContinuousClock.Instant,
tolerance: ContinuousClock.Instant.Duration? = nil,
body: () async throws -> sending T
) async throws -> sending T {
try await _withThrowingTimeout(isolation: isolation, body: body) {
try await Task.sleep(until: instant, tolerance: tolerance, clock: ContinuousClock())
throw TimeoutError("Task timed out before completion. Deadline: \(instant).")
}.value
}

private func _withThrowingTimeout<T>(
isolation: isolated (any Actor)? = #isolation,
body: () async throws -> sending T,
timeout: @Sendable @escaping () async throws -> Void
) async throws -> Transferring<T> {
try await withoutActuallyEscaping(body) { escapingBody in
let bodyTask = Task {
defer { _ = isolation }
return try await Transferring(escapingBody())
}
let timeoutTask = Task {
defer { bodyTask.cancel() }
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError(timeout: seconds)
try await timeout()
}

let bodyResult = await withTaskCancellationHandler {
Expand All @@ -74,7 +111,7 @@ public func withThrowingTimeout<T>(
throw bodyError
}
}
}.value
}
}

private struct Transferring<Value>: Sendable {
Expand Down Expand Up @@ -109,7 +146,7 @@ private func _withThrowingTimeout<T: Sendable>(
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError(timeout: seconds)
throw TimeoutError("Task timed out before completion. Timeout: \(seconds) seconds.")
}
let success = try await group.next()!
group.cancelAll()
Expand Down
46 changes: 42 additions & 4 deletions Tests/TimeoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ struct TimeoutTests {
@Test @MainActor
func mainActor_ReturnsValue() async throws {
let val = try await withThrowingTimeout(seconds: 1) {
MainActor.assertIsolated()
MainActor.safeAssertIsolated()
try await Task.sleep(nanoseconds: 1_000)
MainActor.assertIsolated()
MainActor.safeAssertIsolated()
return "Fish"
}
#expect(val == "Fish")
Expand All @@ -51,8 +51,8 @@ struct TimeoutTests {
func mainActorThrowsError_WhenTimeoutExpires() async {
await #expect(throws: TimeoutError.self) { @MainActor in
try await withThrowingTimeout(seconds: 0.05) {
MainActor.assertIsolated()
defer { MainActor.assertIsolated() }
MainActor.safeAssertIsolated()
defer { MainActor.safeAssertIsolated() }
try await Task.sleep(nanoseconds: 60_000_000_000)
}
}
Expand Down Expand Up @@ -105,6 +105,20 @@ struct TimeoutTests {
try await task.value
}
}

@Test
func returnsValue_beforeDeadlineExpires() async throws {
#expect(
try await TestActor("Fish").returningValue(before: .now + .seconds(2)) == "Fish"
)
}

@Test
func throwsError_WhenDeadlineExpires() async {
await #expect(throws: TimeoutError.self) {
try await TestActor("Fish").returningValue(after: 0.1, before: .now)
}
}
}

public struct NonSendable<T> {
Expand All @@ -130,9 +144,33 @@ final actor TestActor<T: Sendable> {
func returningValue(after sleep: TimeInterval = 0, timeout: TimeInterval = 1) async throws -> T {
try await withThrowingTimeout(seconds: timeout) {
try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
#if compiler(>=5.10)
self.assertIsolated()
#endif
return self.value
}
}

func returningValue(after sleep: TimeInterval = 0, before instant: ContinuousClock.Instant) async throws -> T {
try await withThrowingTimeout(after: instant) {
try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
#if compiler(>=5.10)
self.assertIsolated()
#endif
return self.value
}
}
}

extension MainActor {

static func safeAssertIsolated() {
#if compiler(>=5.10)
assertIsolated()
#else
precondition(Thread.isMainThread)
#endif
}
}

#endif
24 changes: 24 additions & 0 deletions Tests/TimeoutXCTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ final class TimeoutTests: XCTestCase {
XCTAssertTrue(error is CancellationError)
}
}

func testReturnsValue_beforeDeadlineExpires() async throws {
let val = try await TestActor("Fish").returningValue(before: .now + .seconds(2))
XCTAssert(val == "Fish")
}

func testThrowsError_WhenDeadlineExpires() async {
do {
_ = try await TestActor("Fish").returningValue(after: 0.1, before: .now)
XCTFail("Expected Error")
} catch {
XCTAssertTrue(error is TimeoutError)
}
}
}

public struct NonSendable<T> {
Expand Down Expand Up @@ -137,5 +151,15 @@ final actor TestActor<T: Sendable> {
return self.value
}
}

func returningValue(after sleep: TimeInterval = 0, before instant: ContinuousClock.Instant) async throws -> T {
try await withThrowingTimeout(after: instant) {
try await Task.sleep(nanoseconds: UInt64(sleep * 1_000_000_000))
#if compiler(>=5.10)
self.assertIsolated()
#endif
return self.value
}
}
}
#endif

0 comments on commit d3a714f

Please sign in to comment.