From 6db0e7f7f8195f8ed5dc7cd84d8c61b37f1c5ba6 Mon Sep 17 00:00:00 2001 From: Greg Cotten Date: Mon, 16 Mar 2026 21:21:02 -0700 Subject: [PATCH 1/2] add `Clock` support to AsyncTimeoutSequence --- Sources/AsyncTimeoutSequence.swift | 53 +++++++++++++++++++-------- Tests/AsyncTimeoutSequenceTests.swift | 21 +++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/Sources/AsyncTimeoutSequence.swift b/Sources/AsyncTimeoutSequence.swift index 748425c..66e8f7c 100644 --- a/Sources/AsyncTimeoutSequence.swift +++ b/Sources/AsyncTimeoutSequence.swift @@ -40,10 +40,13 @@ public extension AsyncSequence where Element: Sendable { } /// Creates an asynchronous sequence that throws error if any iteration - /// takes longer than provided `Duration`. + /// takes longer than provided `Duration` using the supplied `Clock`. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - func timeout(duration: Duration) -> AsyncTimeoutSequence { - AsyncTimeoutSequence(base: self, duration: duration) + func timeout( + duration: Duration, + clock: C = ContinuousClock() + ) -> AsyncTimeoutSequence where C.Duration == Duration { + AsyncTimeoutSequence(base: self, duration: duration, clock: clock) } } @@ -51,7 +54,7 @@ public struct AsyncTimeoutSequence: AsyncSequence where Bas public typealias Element = Base.Element private let base: Base - private let interval: TimeoutInterval + private let interval: TimeoutInterval public init(base: Base, seconds: TimeInterval) { self.base = base @@ -59,9 +62,13 @@ public struct AsyncTimeoutSequence: AsyncSequence where Bas } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - public init(base: Base, duration: Duration) { + public init( + base: Base, + duration: Duration, + clock: C = ContinuousClock() + ) where C.Duration == Duration { self.base = base - self.interval = .duration(.init(duration)) + self.interval = .duration(.init(duration, clock: clock)) } public func makeAsyncIterator() -> AsyncIterator { @@ -73,9 +80,9 @@ public struct AsyncTimeoutSequence: AsyncSequence where Bas public struct AsyncIterator: AsyncIteratorProtocol { private var iterator: Base.AsyncIterator - private let interval: TimeoutInterval + private let interval: TimeoutInterval - init(iterator: Base.AsyncIterator, interval: TimeoutInterval) { + fileprivate init(iterator: Base.AsyncIterator, interval: TimeoutInterval) { self.iterator = iterator self.interval = interval } @@ -91,7 +98,7 @@ public struct AsyncTimeoutSequence: AsyncSequence where Bas 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) { + return try await durationBox.withThrowingTimeout { try await self.iterator.next() } } @@ -99,21 +106,37 @@ public struct AsyncTimeoutSequence: AsyncSequence where Bas } } -enum TimeoutInterval { +private enum TimeoutInterval { case timeInterval(TimeInterval) case duration(DurationBox) struct DurationBox { - private let storage: Any + private typealias TimeoutClosure = (() async throws -> sending T) async throws -> sending T + + private let storage: TimeoutClosure @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - var value: Duration { - storage as! Duration + init( + _ duration: C.Duration, + clock: C + ) { + self.storage = { closure in + try await Timeout.withThrowingTimeout( + after: clock.now.advanced(by: duration), + clock: clock + ) { + try await closure() + } + } } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - init(_ duration: Duration) { - self.storage = duration + func withThrowingTimeout( + _ closure: () async throws -> sending T + ) async throws -> T { + try await storage { + try await closure() + } } } } diff --git a/Tests/AsyncTimeoutSequenceTests.swift b/Tests/AsyncTimeoutSequenceTests.swift index 8eedab1..5ca313f 100644 --- a/Tests/AsyncTimeoutSequenceTests.swift +++ b/Tests/AsyncTimeoutSequenceTests.swift @@ -71,4 +71,25 @@ struct AsyncTimeoutSequenceTests { try await iterator.next() } } + + @Test + func timeoutDurationWithSuspendingClock() 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), clock: SuspendingClock()) + .makeAsyncIterator() + + #expect(try await iterator.next() == 1) + #expect(try await iterator.next() == 2) + await #expect(throws: TimeoutError.self) { + try await iterator.next() + } + } } From 94e5f522ca2baff351a530493fabd82f6ce02fe8 Mon Sep 17 00:00:00 2001 From: Greg Cotten Date: Mon, 16 Mar 2026 21:29:03 -0700 Subject: [PATCH 2/2] also add tolerance to AsyncTimeoutSequence --- Sources/AsyncTimeoutSequence.swift | 23 ++++++++++++++++------- Tests/AsyncTimeoutSequenceTests.swift | 6 +++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Sources/AsyncTimeoutSequence.swift b/Sources/AsyncTimeoutSequence.swift index 66e8f7c..5975838 100644 --- a/Sources/AsyncTimeoutSequence.swift +++ b/Sources/AsyncTimeoutSequence.swift @@ -43,10 +43,16 @@ public extension AsyncSequence where Element: Sendable { /// takes longer than provided `Duration` using the supplied `Clock`. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) func timeout( - duration: Duration, + duration: C.Duration, + tolerance: C.Instant.Duration? = nil, clock: C = ContinuousClock() - ) -> AsyncTimeoutSequence where C.Duration == Duration { - AsyncTimeoutSequence(base: self, duration: duration, clock: clock) + ) -> AsyncTimeoutSequence { + AsyncTimeoutSequence( + base: self, + duration: duration, + tolerance: tolerance, + clock: clock + ) } } @@ -64,11 +70,12 @@ public struct AsyncTimeoutSequence: AsyncSequence where Bas @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public init( base: Base, - duration: Duration, + duration: C.Duration, + tolerance: C.Instant.Duration? = nil, clock: C = ContinuousClock() - ) where C.Duration == Duration { + ) { self.base = base - self.interval = .duration(.init(duration, clock: clock)) + self.interval = .duration(.init(duration: duration, tolerance: tolerance, clock: clock)) } public func makeAsyncIterator() -> AsyncIterator { @@ -117,12 +124,14 @@ private enum TimeoutInterval { @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) init( - _ duration: C.Duration, + duration: C.Duration, + tolerance: C.Instant.Duration? = nil, clock: C ) { self.storage = { closure in try await Timeout.withThrowingTimeout( after: clock.now.advanced(by: duration), + tolerance: tolerance, clock: clock ) { try await closure() diff --git a/Tests/AsyncTimeoutSequenceTests.swift b/Tests/AsyncTimeoutSequenceTests.swift index 5ca313f..3853fbc 100644 --- a/Tests/AsyncTimeoutSequenceTests.swift +++ b/Tests/AsyncTimeoutSequenceTests.swift @@ -83,7 +83,11 @@ struct AsyncTimeoutSequenceTests { } defer { t.cancel() } var iterator = stream - .timeout(duration: .milliseconds(100), clock: SuspendingClock()) + .timeout( + duration: .milliseconds(100), + tolerance: .zero, + clock: SuspendingClock() + ) .makeAsyncIterator() #expect(try await iterator.next() == 1)