Skip to content

Commit e0cc6dd

Browse files
authored
Throw CancellationError instead of returning nil during early cancellation. (#2401)
### Motivation: Follow up PR for #2399 We currently still return `nil` if the current `Task` is canceled before the first call to `NIOThrowingAsyncSequenceProducer.AsyncIterator.next()` but it should throw `CancellationError` too. In addition, the generic `Failure` type turns out to be a problem. Just throwing a `CancellationError` without checking that `Failure` type is `any Swift.Error` or `CancellationError` introduced a type safety violation as we throw an unrelated type. ### Modifications: - throw `CancellationError` on eager cancellation - deprecates the generic `Failure` type of `NIOThrowingAsyncSequenceProducer`. It now must always be `any Swift.Error`. For backward compatibility we will still return nil if `Failure` is not `any Swift.Error` or `CancellationError`. ### Result: `CancellationError` is now correctly thrown instead of returning `nil` on eager cancelation. Generic `Failure` type is deprecated.
1 parent a7c36a7 commit e0cc6dd

File tree

4 files changed

+143
-9
lines changed

4 files changed

+143
-9
lines changed

Sources/NIOCore/AsyncSequences/NIOAsyncSequenceProducer.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,8 @@ public struct NIOAsyncSequenceProducer<
147147
backPressureStrategy: Strategy,
148148
delegate: Delegate
149149
) -> NewSequence {
150-
let newSequence = NIOThrowingAsyncSequenceProducer.makeSequence(
150+
let newSequence = NIOThrowingAsyncSequenceProducer.makeNonThrowingSequence(
151151
elementType: Element.self,
152-
failureType: Never.self,
153152
backPressureStrategy: backPressureStrategy,
154153
delegate: delegate
155154
)

Sources/NIOCore/AsyncSequences/NIOThrowingAsyncSequenceProducer.swift

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public struct NIOThrowingAsyncSequenceProducer<
9898
/// - backPressureStrategy: The back-pressure strategy of the sequence.
9999
/// - delegate: The delegate of the sequence
100100
/// - Returns: A ``NIOThrowingAsyncSequenceProducer/Source`` and a ``NIOThrowingAsyncSequenceProducer``.
101+
@available(*, deprecated, message: "Support for a generic Failure type is deprecated. Failure type must be `any Swift.Error`.")
101102
@inlinable
102103
public static func makeSequence(
103104
elementType: Element.Type = Element.self,
@@ -113,6 +114,51 @@ public struct NIOThrowingAsyncSequenceProducer<
113114

114115
return .init(source: source, sequence: sequence)
115116
}
117+
118+
/// Initializes a new ``NIOThrowingAsyncSequenceProducer`` and a ``NIOThrowingAsyncSequenceProducer/Source``.
119+
///
120+
/// - Important: This method returns a struct containing a ``NIOThrowingAsyncSequenceProducer/Source`` and
121+
/// a ``NIOThrowingAsyncSequenceProducer``. The source MUST be held by the caller and
122+
/// used to signal new elements or finish. The sequence MUST be passed to the actual consumer and MUST NOT be held by the
123+
/// caller. This is due to the fact that deiniting the sequence is used as part of a trigger to terminate the underlying source.
124+
///
125+
/// - Parameters:
126+
/// - elementType: The element type of the sequence.
127+
/// - failureType: The failure type of the sequence. Must be `Swift.Error`
128+
/// - backPressureStrategy: The back-pressure strategy of the sequence.
129+
/// - delegate: The delegate of the sequence
130+
/// - Returns: A ``NIOThrowingAsyncSequenceProducer/Source`` and a ``NIOThrowingAsyncSequenceProducer``.
131+
@inlinable
132+
public static func makeSequence(
133+
elementType: Element.Type = Element.self,
134+
failureType: Failure.Type = Error.self,
135+
backPressureStrategy: Strategy,
136+
delegate: Delegate
137+
) -> NewSequence where Failure == Error {
138+
let sequence = Self(
139+
backPressureStrategy: backPressureStrategy,
140+
delegate: delegate
141+
)
142+
let source = Source(storage: sequence._storage)
143+
144+
return .init(source: source, sequence: sequence)
145+
}
146+
147+
/// only used internally by``NIOAsyncSequenceProducer`` to reuse most of the code
148+
@inlinable
149+
internal static func makeNonThrowingSequence(
150+
elementType: Element.Type = Element.self,
151+
backPressureStrategy: Strategy,
152+
delegate: Delegate
153+
) -> NewSequence where Failure == Never {
154+
let sequence = Self(
155+
backPressureStrategy: backPressureStrategy,
156+
delegate: delegate
157+
)
158+
let source = Source(storage: sequence._storage)
159+
160+
return .init(source: source, sequence: sequence)
161+
}
116162

117163
@inlinable
118164
/* private */ internal init(
@@ -499,7 +545,20 @@ extension NIOThrowingAsyncSequenceProducer {
499545
return delegate
500546

501547
case .resumeContinuationWithCancellationErrorAndCallDidTerminate(let continuation):
502-
continuation.resume(throwing: CancellationError())
548+
// We have deprecated the generic Failure type in the public API and Failure should
549+
// now be `Swift.Error`. However, if users have not migrated to the new API they could
550+
// still use a custom generic Error type and this cast might fail.
551+
// In addition, we use `NIOThrowingAsyncSequenceProducer` in the implementation of the
552+
// non-throwing variant `NIOAsyncSequenceProducer` where `Failure` will be `Never` and
553+
// this cast will fail as well.
554+
// Everything is marked @inlinable and the Failure type is known at compile time,
555+
// therefore this cast should be optimised away in release build.
556+
if let failure = CancellationError() as? Failure {
557+
continuation.resume(throwing: failure)
558+
} else {
559+
continuation.resume(returning: nil)
560+
}
561+
503562
let delegate = self._delegate
504563
self._delegate = nil
505564

@@ -880,9 +939,26 @@ extension NIOThrowingAsyncSequenceProducer {
880939
switch self._state {
881940
case .initial(_, let iteratorInitialized):
882941
// This can happen if the `Task` that calls `next()` is already cancelled.
883-
self._state = .finished(iteratorInitialized: iteratorInitialized)
884-
885-
return .callDidTerminate
942+
943+
// We have deprecated the generic Failure type in the public API and Failure should
944+
// now be `Swift.Error`. However, if users have not migrated to the new API they could
945+
// still use a custom generic Error type and this cast might fail.
946+
// In addition, we use `NIOThrowingAsyncSequenceProducer` in the implementation of the
947+
// non-throwing variant `NIOAsyncSequenceProducer` where `Failure` will be `Never` and
948+
// this cast will fail as well.
949+
// Everything is marked @inlinable and the Failure type is known at compile time,
950+
// therefore this cast should be optimised away in release build.
951+
if let failure = CancellationError() as? Failure {
952+
self._state = .sourceFinished(
953+
buffer: .init(),
954+
iteratorInitialized: iteratorInitialized,
955+
failure: failure
956+
)
957+
} else {
958+
self._state = .finished(iteratorInitialized: iteratorInitialized)
959+
}
960+
961+
return .none
886962

887963
case .streaming(_, _, .some(let continuation), _, let iteratorInitialized):
888964
// We have an outstanding continuation that needs to resumed

Sources/NIOPerformanceTester/NIOAsyncSequenceProducerBenchmark.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import Atomics
1919

2020
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
2121
final class NIOAsyncSequenceProducerBenchmark: AsyncBenchmark, NIOAsyncSequenceProducerDelegate, @unchecked Sendable {
22-
fileprivate typealias SequenceProducer = NIOThrowingAsyncSequenceProducer<Int, Never, NIOAsyncSequenceProducerBackPressureStrategies.HighLowWatermark, NIOAsyncSequenceProducerBenchmark>
22+
fileprivate typealias SequenceProducer = NIOThrowingAsyncSequenceProducer<Int, Error, NIOAsyncSequenceProducerBackPressureStrategies.HighLowWatermark, NIOAsyncSequenceProducerBenchmark>
2323

2424
private let iterations: Int
2525
private var iterator: SequenceProducer.AsyncIterator!

Tests/NIOCoreTests/AsyncSequences/NIOThrowingAsyncSequenceTests.swift

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,35 @@ final class NIOThrowingAsyncSequenceProducerTests: XCTestCase {
463463
XCTAssertTrue(error is CancellationError)
464464
}
465465
}
466+
467+
@available(*, deprecated, message: "tests the deprecated custom generic failure type")
468+
func testTaskCancel_whenStreaming_andSuspended_withCustomErrorType() async throws {
469+
struct CustomError: Error {}
470+
// We are registering our demand and sleeping a bit to make
471+
// sure our task runs when the demand is registered
472+
let backPressureStrategy = MockNIOElementStreamBackPressureStrategy()
473+
let delegate = MockNIOBackPressuredStreamSourceDelegate()
474+
let new = NIOThrowingAsyncSequenceProducer.makeSequence(
475+
elementType: Int.self,
476+
failureType: CustomError.self,
477+
backPressureStrategy: backPressureStrategy,
478+
delegate: delegate
479+
)
480+
let sequence = new.sequence
481+
let task: Task<Int?, Error> = Task {
482+
let iterator = sequence.makeAsyncIterator()
483+
return try await iterator.next()
484+
}
485+
try await Task.sleep(nanoseconds: 1_000_000)
486+
487+
task.cancel()
488+
let result = await task.result
489+
XCTAssertEqualWithoutAutoclosure(await delegate.events.prefix(1).collect(), [.didTerminate])
490+
491+
try withExtendedLifetime(new.source) {
492+
XCTAssertNil(try result.get())
493+
}
494+
}
466495

467496
func testTaskCancel_whenStreaming_andNotSuspended() async throws {
468497
// We are registering our demand and sleeping a bit to make
@@ -515,9 +544,39 @@ final class NIOThrowingAsyncSequenceProducerTests: XCTestCase {
515544

516545
task.cancel()
517546

518-
let value = try await task.value
547+
let result = await task.result
519548

520-
XCTAssertNil(value)
549+
await XCTAssertThrowsError(try result.get()) { error in
550+
XCTAssertTrue(error is CancellationError, "unexpected error \(error)")
551+
}
552+
}
553+
554+
@available(*, deprecated, message: "tests the deprecated custom generic failure type")
555+
func testTaskCancel_whenStreaming_andTaskIsAlreadyCancelled_withCustomErrorType() async throws {
556+
struct CustomError: Error {}
557+
let backPressureStrategy = MockNIOElementStreamBackPressureStrategy()
558+
let delegate = MockNIOBackPressuredStreamSourceDelegate()
559+
let new = NIOThrowingAsyncSequenceProducer.makeSequence(
560+
elementType: Int.self,
561+
failureType: CustomError.self,
562+
backPressureStrategy: backPressureStrategy,
563+
delegate: delegate
564+
)
565+
let sequence = new.sequence
566+
let task: Task<Int?, Error> = Task {
567+
// We are sleeping here to allow some time for us to cancel the task.
568+
// Once the Task is cancelled we will call `next()`
569+
try? await Task.sleep(nanoseconds: 1_000_000)
570+
let iterator = sequence.makeAsyncIterator()
571+
return try await iterator.next()
572+
}
573+
574+
task.cancel()
575+
576+
let result = await task.result
577+
try withExtendedLifetime(new.source) {
578+
XCTAssertNil(try result.get())
579+
}
521580
}
522581

523582
// MARK: - Next

0 commit comments

Comments
 (0)