diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 5020b52..3bee43a 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -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 }} diff --git a/Sources/SpeziFoundation/Misc/TimeoutError.swift b/Sources/SpeziFoundation/Misc/TimeoutError.swift new file mode 100644 index 0000000..3a5e708 --- /dev/null +++ b/Sources/SpeziFoundation/Misc/TimeoutError.swift @@ -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? +/// +/// @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() +} diff --git a/Sources/SpeziFoundation/SpeziFoundation.docc/SPI.md b/Sources/SpeziFoundation/SpeziFoundation.docc/SPI.md new file mode 100644 index 0000000..9422ff8 --- /dev/null +++ b/Sources/SpeziFoundation/SpeziFoundation.docc/SPI.md @@ -0,0 +1,42 @@ +# System Programming Interfaces + + + +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. diff --git a/Sources/SpeziFoundation/SpeziFoundation.docc/SpeziFoundation.md b/Sources/SpeziFoundation/SpeziFoundation.docc/SpeziFoundation.md index 3028413..23f0237 100644 --- a/Sources/SpeziFoundation/SpeziFoundation.docc/SpeziFoundation.md +++ b/Sources/SpeziFoundation/SpeziFoundation.docc/SpeziFoundation.md @@ -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 + +- diff --git a/Tests/SpeziFoundationTests/TimeoutTests.swift b/Tests/SpeziFoundationTests/TimeoutTests.swift new file mode 100644 index 0000000..c2fdc54 --- /dev/null +++ b/Tests/SpeziFoundationTests/TimeoutTests.swift @@ -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? + + 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]) + } +}