Skip to content

Commit

Permalink
Add infrastructure for timeouts (#9)
Browse files Browse the repository at this point in the history
# Add infrastructure for timeouts

## ♻️ Current situation & Problem
Async operations sometimes require to make sure that there is a maximum
time an operations runs. To have a standardized way of dealing with
timeouts, this PR introduces a new, generalized `TimeoutError`
(similarly simple like Swift's `CancellationError`) to communicate that
a timeout occurred. Additional, the PR adds a `withTimeout` method that
makes it easier to race timeouts against an async operation.


## ⚙️ Release Notes 
* Add `TimeoutError` 
* Add `withTimeout(of:perform:)` method


## 📚 Documentation
Code examples have been added to illustrate how to use those new types.


## ✅ Testing
Unit testing was added to verify functionality.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Jun 18, 2024
1 parent 01af5b9 commit db3618b
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 2 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,5 @@ jobs:
uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2
with:
coveragereports: SpeziFoundation.xcresult SpeziFoundationWatchOS.xcresult SpeziFoundationVisionOS.xcresult SpeziFoundationTvOS.xcresult SpeziFoundationMacOS.xcresult
secrets:
token: ${{ secrets.CODECOV_TOKEN }}
99 changes: 99 additions & 0 deletions Sources/SpeziFoundation/Misc/TimeoutError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Foundation


/// Timeout occurred inside an async operation.
public struct TimeoutError {
/// Create a new timeout error.
public init() {}
}


extension TimeoutError: Error {}


/// Race a timeout.
///
/// This method can be used to race an operation against a timeout.
///
/// ### Timeout in Async Context
///
/// Below is a code example showing how to best use the `withTimeout(of:perform:)` method in an async method.
/// The example uses [Structured Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency)
/// creating a child task running the timeout task. This makes sure that the timeout is automatically cancelled when the method goes out of scope.
///
/// - Note: The example isolates the `continuation` property to the MainActor to ensure accesses are synchronized.
/// Further, the method throws an error if the operation is already running. We use the `OperationAlreadyInUseError`
/// error as an example.
///
/// ```swift
/// @MainActor
/// var operation: CheckedContinuation<Void, Error>?
///
/// @MainActor
/// func foo() async throws {
/// guard continuation == nil else {
/// throw OperationAlreadyInUseError() // exemplary way of handling concurrent accesses
/// }
///
/// async let _ = withTimeout(of: .seconds(30)) { @MainActor in
/// // operation timed out, resume continuation by throwing a `TimeoutError`.
/// if let continuation = operation {
/// operation = nil
/// continuation.resume(throwing: TimeoutError())
/// }
/// }
///
/// runOperation()
/// try await withCheckedThrowingContinuation { continuation in
/// self.continuation = continuation
/// }
/// }
///
/// @MainActor
/// func handleOperationCompleted() {
/// if let continuation = operation {
/// operation = nil
/// continuation.resume()
/// }
/// }
/// ```
///
/// ### Timeout in Sync Context
///
/// Using `withTimeout(of:perform:)` in a synchronous method is similar. However, you will need to take care of cancellation yourself.
///
/// ```swift
/// func foo() throws {
/// let timeoutTask = Task {
/// await withTimeout(of: .seconds(30)) {
/// // cancel operation ...
/// }
/// }
///
/// defer {
/// timeoutTask.cancel()
/// }
///
/// try operation()
/// }
/// ```
///
/// - Parameters:
/// - timeout: The duration of the timeout.
/// - action: The action to run once the timeout passed.
public func withTimeout(of timeout: Duration, perform action: () async -> Void) async {
try? await Task.sleep(for: timeout)
guard !Task.isCancelled else {
return
}

await action()
}
42 changes: 42 additions & 0 deletions Sources/SpeziFoundation/SpeziFoundation.docc/SPI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# System Programming Interfaces

<!--
#
# This source file is part of the Stanford Spezi open-source project
#
# SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
#
# SPDX-License-Identifier: MIT
#
-->

An overview of System Programming Interfaces (SPIs) provided by Spezi Foundation.

## Overview

A [System Programming Interface](https://blog.eidinger.info/system-programming-interfaces-spi-in-swift-explained) is a subset of API
that is targeted only for certain users (e.g., framework developers) and might not be necessary or useful for app development.
Therefore, these interfaces are not visible by default and need to be explicitly imported.
This article provides an overview of supported SPI provided by SpeziFoundation

### TestingSupport

The `TestingSupport` SPI provides additional interfaces that are useful for unit and UI testing.
Annotate your import statement as follows.

```swift
@_spi(TestingSupport) import SpeziFoundation
```

- Note: As of Swift 5.8, you can solely import the SPI target without any other interfaces of the SPM target
by setting the `-experimental-spi-only-imports` Swift compiler flag and using `@_spiOnly`.

```swift
@_spiOnly import SpeziFoundation
```

#### RuntimeConfig

The `RuntimeConfig` stores configurations of the current runtime environment for testing support.

- `RuntimeConfig/testMode`: Holds `true` if the `--testMode` command line flag was supplied to indicate to enable additional testing functionalities.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ Spezi Foundation provides a base layer of functionality useful in many applicati

- ``AsyncSemaphore``

### Runtime Configuration
### Timeout

- `RuntimeConfig` (exposed via the `TestingSupport` SPI target)
- ``TimeoutError``
- ``withTimeout(of:perform:)``

### System Programming Interfaces

- <doc:SPI>
60 changes: 60 additions & 0 deletions Tests/SpeziFoundationTests/TimeoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//


import SpeziFoundation
import XCTest


final class TimeoutTests: XCTestCase {
@MainActor private var continuation: CheckedContinuation<Void, any Error>?

func operation(for duration: Duration) {
Task { @MainActor in
try? await Task.sleep(for: duration)
if let continuation = self.continuation {
continuation.resume()
self.continuation = nil
}
}
}

@MainActor
func operationMethod(timeout: Duration, operation: Duration, timeoutExpectation: XCTestExpectation) async throws {
async let _ = withTimeout(of: timeout) { @MainActor in
timeoutExpectation.fulfill()
if let continuation {
continuation.resume(throwing: TimeoutError())
self.continuation = nil
}
}

try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
self.operation(for: operation)
}
}

func testTimeout() async throws {
let negativeExpectation = XCTestExpectation()
negativeExpectation.isInverted = true
try await operationMethod(timeout: .seconds(1), operation: .milliseconds(500), timeoutExpectation: negativeExpectation)


await fulfillment(of: [negativeExpectation], timeout: 2)

let expectation = XCTestExpectation()
do {
try await operationMethod(timeout: .milliseconds(500), operation: .seconds(5), timeoutExpectation: expectation)
XCTFail("Operation did unexpectedly complete!")
} catch {
XCTAssert(error is TimeoutError)
}
await fulfillment(of: [expectation])
}
}

0 comments on commit db3618b

Please sign in to comment.