diff --git a/Sources/AsyncTimeoutSequence.swift b/Sources/AsyncTimeoutSequence.swift new file mode 100644 index 0000000..997a257 --- /dev/null +++ b/Sources/AsyncTimeoutSequence.swift @@ -0,0 +1,119 @@ +// +// AsyncTimeoutSequence.swift +// swift-timeout +// +// Created by Simon Whitty on 03/06/2025. +// Copyright 2025 Simon Whitty +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-timeout +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +public extension AsyncSequence where Element: Sendable { + + /// Creates an asynchronous sequence that throws error if any iteration + /// takes longer than provided `TimeInterval`. + func timeout(seconds: TimeInterval) -> AsyncTimeoutSequence { + AsyncTimeoutSequence(base: self, seconds: seconds) + } + + /// Creates an asynchronous sequence that throws error if any iteration + /// takes longer than provided `Duration`. + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func timeout(duration: Duration) -> AsyncTimeoutSequence { + AsyncTimeoutSequence(base: self, duration: duration) + } +} + +public struct AsyncTimeoutSequence: AsyncSequence where Base.Element: Sendable { + public typealias Element = Base.Element + + private let base: Base + private let interval: TimeoutInterval + + public init(base: Base, seconds: TimeInterval) { + self.base = base + self.interval = .timeInterval(seconds) + } + + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + public init(base: Base, duration: Duration) { + self.base = base + self.interval = .duration(.init(duration)) + } + + public func makeAsyncIterator() -> AsyncIterator { + AsyncIterator( + iterator: base.makeAsyncIterator(), + interval: interval + ) + } + + public struct AsyncIterator: AsyncIteratorProtocol { + private var iterator: Base.AsyncIterator + private let interval: TimeoutInterval + + init(iterator: Base.AsyncIterator, interval: TimeoutInterval) { + self.iterator = iterator + self.interval = interval + } + + public mutating func next() async throws -> Base.Element? { + switch interval { + case .timeInterval(let seconds): + return try await withThrowingTimeout(seconds: seconds) { + try await self.iterator.next() + } + + case .duration(let durationBox): + guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) else { + fatalError("cannot occur") + } + return try await withThrowingTimeout(after: .now + durationBox.value) { + try await self.iterator.next() + } + } + } + } +} + +enum TimeoutInterval { + case timeInterval(TimeInterval) + case duration(DurationBox) + + struct DurationBox { + private let storage: Any + + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + var value: Duration { + storage as! Duration + } + + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + init(_ duration: Duration) { + self.storage = duration + } + } +} diff --git a/Sources/Task+SleepIndefinitely.swift b/Sources/Task+SleepIndefinitely.swift index 432577b..dfe08ee 100644 --- a/Sources/Task+SleepIndefinitely.swift +++ b/Sources/Task+SleepIndefinitely.swift @@ -29,8 +29,6 @@ // SOFTWARE. // -#if compiler(>=6) - package extension Task { private typealias State = (isCancelled: Bool, continuation: CheckedContinuation?) @@ -61,5 +59,3 @@ package extension Task { fatalError("can never occur") } } - -#endif diff --git a/Tests/AsyncTimeoutSequenceTests.swift b/Tests/AsyncTimeoutSequenceTests.swift new file mode 100644 index 0000000..2a1368f --- /dev/null +++ b/Tests/AsyncTimeoutSequenceTests.swift @@ -0,0 +1,76 @@ +// +// AsyncTimeoutSequenceTests.swift +// swift-timeout +// +// Created by Simon Whitty on 03/06/2025. +// Copyright 2025 Simon Whitty +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-timeout +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +#if canImport(Testing) +@testable import Timeout +import Testing + +struct AsyncTimeoutSequenceTests { + + @Test + func timeoutSeconds() async throws { + let (stream, continuation) = AsyncStream.makeStream() + let t = Task { + continuation.yield(1) + try await Task.sleep(nanoseconds: 1_000) + continuation.yield(2) + try await Task.sleepIndefinitely() + } + defer { t.cancel() } + var iterator = stream.timeout(seconds: 0.1).makeAsyncIterator() + + #expect(try await iterator.next() == 1) + #expect(try await iterator.next() == 2) + await #expect(throws: TimeoutError.self) { + try await iterator.next() + } + } + + @Test + func timeoutDuration() async throws { + let (stream, continuation) = AsyncStream.makeStream() + let t = Task { + continuation.yield(1) + try await Task.sleep(nanoseconds: 1_000) + continuation.yield(2) + try await Task.sleepIndefinitely() + } + defer { t.cancel() } + var iterator = stream.timeout(duration: .milliseconds(100)).makeAsyncIterator() + + #expect(try await iterator.next() == 1) + #expect(try await iterator.next() == 2) + await #expect(throws: TimeoutError.self) { + try await iterator.next() + } + } +} +#endif diff --git a/Tests/AsyncTimeoutSequenceXCTests.swift b/Tests/AsyncTimeoutSequenceXCTests.swift new file mode 100644 index 0000000..226c642 --- /dev/null +++ b/Tests/AsyncTimeoutSequenceXCTests.swift @@ -0,0 +1,86 @@ +// +// AsyncTimeoutSequenceXCTests.swift +// swift-timeout +// +// Created by Simon Whitty on 03/06/2025. +// Copyright 2025 Simon Whitty +// +// Distributed under the permissive MIT license +// Get the latest version from here: +// +// https://github.com/swhitty/swift-timeout +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +#if !canImport(Testing) +@testable import Timeout +import XCTest + +final class AsyncTimeoutSequenceXCTests: XCTestCase { + + func testTimeoutSeconds() async throws { + let (stream, continuation) = AsyncStream.makeStream() + let t = Task { + continuation.yield(1) + try await Task.sleep(nanoseconds: 1_000) + continuation.yield(2) + try await Task.sleepIndefinitely() + } + defer { t.cancel() } + var iterator = stream.timeout(seconds: 0.1).makeAsyncIterator() + + var val = try await iterator.next() + XCTAssertEqual(val, 1) + val = try await iterator.next() + XCTAssertEqual(val, 2) + + do { + _ = try await iterator.next() + XCTFail("expected error") + } catch { + XCTAssertTrue(error is TimeoutError) + } + } + + func testTimeoutDuration() async throws { + let (stream, continuation) = AsyncStream.makeStream() + let t = Task { + continuation.yield(1) + try await Task.sleep(nanoseconds: 1_000) + continuation.yield(2) + try await Task.sleepIndefinitely() + } + defer { t.cancel() } + var iterator = stream.timeout(duration: .milliseconds(100)).makeAsyncIterator() + + var val = try await iterator.next() + XCTAssertEqual(val, 1) + val = try await iterator.next() + XCTAssertEqual(val, 2) + + do { + _ = try await iterator.next() + XCTFail("expected error") + } catch { + XCTAssertTrue(error is TimeoutError) + } + } +} +#endif