Procházet zdrojové kódy

Allow Automatic Cancellation By Default (#3757)

### Issue Link :link:
#3727

### Goals :soccer:
This PR changes Alamofire's default behavior to better follow user
expectations around automatically cancellation when the enclosing async
context is cancelled.

This PR also updates some other async behaviors for more reliable tests
and behaviors.

### Implementation Details :construction:
Previously, Alamofire defaulted `shouldAutomaticallyCancel` to `true` in
order to follow what was then anticipated user expectations as well as
correct behavior for async APIs. However, many of the Swift Concurrency
cancellation behaviors that prompted this default turned out to be bugs.
Now that the correct behavior has been around for a while it's important
for Alamofire to fix its own behavior to match.

### Testing Details :mag:
Updated tests for new default and added tests when it's turned off.
Jon Shier před 2 roky
rodič
revize
d732bc9edf

+ 2 - 3
.github/workflows/ci.yml

@@ -293,10 +293,9 @@ jobs:
           if not exist .build\x86_64-unknown-windows-msvc\debug\Alamofire.swiftmodule exit 1
   CodeQL:
     name: Analyze with CodeQL
-    runs-on: macOS-12
+    runs-on: macOS-13
     env:
-      DEVELOPER_DIR: "/Applications/Xcode_14.2.app/Contents/Developer"
-      CODEQL_ENABLE_EXPERIMENTAL_FEATURES_SWIFT: true
+      DEVELOPER_DIR: "/Applications/Xcode_14.3.1.app/Contents/Developer"
     timeout-minutes: 10
     steps:
       - name: Clone

+ 43 - 43
Source/Concurrency.swift

@@ -37,7 +37,7 @@ extension Request {
     /// - Returns:                   The `StreamOf<Progress>`.
     public func uploadProgress(bufferingPolicy: StreamOf<Progress>.BufferingPolicy = .unbounded) -> StreamOf<Progress> {
         stream(bufferingPolicy: bufferingPolicy) { [unowned self] continuation in
-            uploadProgress(queue: .singleEventQueue) { progress in
+            uploadProgress(queue: underlyingQueue) { progress in
                 continuation.yield(progress)
             }
         }
@@ -50,7 +50,7 @@ extension Request {
     /// - Returns:                   The `StreamOf<Progress>`.
     public func downloadProgress(bufferingPolicy: StreamOf<Progress>.BufferingPolicy = .unbounded) -> StreamOf<Progress> {
         stream(bufferingPolicy: bufferingPolicy) { [unowned self] continuation in
-            downloadProgress(queue: .singleEventQueue) { progress in
+            downloadProgress(queue: underlyingQueue) { progress in
                 continuation.yield(progress)
             }
         }
@@ -63,7 +63,7 @@ extension Request {
     /// - Returns:                   The `StreamOf<URLRequest>`.
     public func urlRequests(bufferingPolicy: StreamOf<URLRequest>.BufferingPolicy = .unbounded) -> StreamOf<URLRequest> {
         stream(bufferingPolicy: bufferingPolicy) { [unowned self] continuation in
-            onURLRequestCreation(on: .singleEventQueue) { request in
+            onURLRequestCreation(on: underlyingQueue) { request in
                 continuation.yield(request)
             }
         }
@@ -76,7 +76,7 @@ extension Request {
     /// - Returns:                   The `StreamOf<URLSessionTask>`.
     public func urlSessionTasks(bufferingPolicy: StreamOf<URLSessionTask>.BufferingPolicy = .unbounded) -> StreamOf<URLSessionTask> {
         stream(bufferingPolicy: bufferingPolicy) { [unowned self] continuation in
-            onURLSessionTaskCreation(on: .singleEventQueue) { task in
+            onURLSessionTaskCreation(on: underlyingQueue) { task in
                 continuation.yield(task)
             }
         }
@@ -89,7 +89,7 @@ extension Request {
     /// - Returns:                   The `StreamOf<String>`.
     public func cURLDescriptions(bufferingPolicy: StreamOf<String>.BufferingPolicy = .unbounded) -> StreamOf<String> {
         stream(bufferingPolicy: bufferingPolicy) { [unowned self] continuation in
-            cURLDescription(on: .singleEventQueue) { description in
+            cURLDescription(on: underlyingQueue) { description in
                 continuation.yield(description)
             }
         }
@@ -173,13 +173,13 @@ extension DataRequest {
     /// - Parameters:
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DataTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///   - dataPreprocessor:          `DataPreprocessor` which processes the received `Data` before completion.
     ///   - emptyResponseCodes:        HTTP response codes for which empty responses are allowed. `[204, 205]` by default.
     ///   - emptyRequestMethods:       `HTTPMethod`s for which empty responses are always valid. `[.head]` by default.
     ///
     /// - Returns: The `DataTask`.
-    public func serializingData(automaticallyCancelling shouldAutomaticallyCancel: Bool = false,
+    public func serializingData(automaticallyCancelling shouldAutomaticallyCancel: Bool = true,
                                 dataPreprocessor: DataPreprocessor = DataResponseSerializer.defaultDataPreprocessor,
                                 emptyResponseCodes: Set<Int> = DataResponseSerializer.defaultEmptyResponseCodes,
                                 emptyRequestMethods: Set<HTTPMethod> = DataResponseSerializer.defaultEmptyRequestMethods) -> DataTask<Data> {
@@ -195,7 +195,7 @@ extension DataRequest {
     ///   - type:                      `Decodable` type to decode from response data.
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DataTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///   - dataPreprocessor:          `DataPreprocessor` which processes the received `Data` before calling the serializer.
     ///                                `PassthroughPreprocessor()` by default.
     ///   - decoder:                   `DataDecoder` to use to decode the response. `JSONDecoder()` by default.
@@ -204,7 +204,7 @@ extension DataRequest {
     ///
     /// - Returns: The `DataTask`.
     public func serializingDecodable<Value: Decodable>(_ type: Value.Type = Value.self,
-                                                       automaticallyCancelling shouldAutomaticallyCancel: Bool = false,
+                                                       automaticallyCancelling shouldAutomaticallyCancel: Bool = true,
                                                        dataPreprocessor: DataPreprocessor = DecodableResponseSerializer<Value>.defaultDataPreprocessor,
                                                        decoder: DataDecoder = JSONDecoder(),
                                                        emptyResponseCodes: Set<Int> = DecodableResponseSerializer<Value>.defaultEmptyResponseCodes,
@@ -221,7 +221,7 @@ extension DataRequest {
     /// - Parameters:
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DataTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///   - dataPreprocessor:          `DataPreprocessor` which processes the received `Data` before calling the serializer.
     ///                                `PassthroughPreprocessor()` by default.
     ///   - encoding:                  `String.Encoding` to use during serialization. Defaults to `nil`, in which case
@@ -231,7 +231,7 @@ extension DataRequest {
     ///   - emptyRequestMethods:       `HTTPMethod`s for which empty responses are always valid. `[.head]` by default.
     ///
     /// - Returns: The `DataTask`.
-    public func serializingString(automaticallyCancelling shouldAutomaticallyCancel: Bool = false,
+    public func serializingString(automaticallyCancelling shouldAutomaticallyCancel: Bool = true,
                                   dataPreprocessor: DataPreprocessor = StringResponseSerializer.defaultDataPreprocessor,
                                   encoding: String.Encoding? = nil,
                                   emptyResponseCodes: Set<Int> = StringResponseSerializer.defaultEmptyResponseCodes,
@@ -249,16 +249,16 @@ extension DataRequest {
     ///   - serializer:                `ResponseSerializer` responsible for serializing the request, response, and data.
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DataTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///
     /// - Returns: The `DataTask`.
     public func serializingResponse<Serializer: ResponseSerializer>(using serializer: Serializer,
-                                                                    automaticallyCancelling shouldAutomaticallyCancel: Bool = false)
+                                                                    automaticallyCancelling shouldAutomaticallyCancel: Bool = true)
         -> DataTask<Serializer.SerializedObject> {
-        dataTask(automaticallyCancelling: shouldAutomaticallyCancel) {
-            self.response(queue: .singleEventQueue,
-                          responseSerializer: serializer,
-                          completionHandler: $0)
+        dataTask(automaticallyCancelling: shouldAutomaticallyCancel) { [self] in
+            response(queue: underlyingQueue,
+                     responseSerializer: serializer,
+                     completionHandler: $0)
         }
     }
 
@@ -269,16 +269,16 @@ extension DataRequest {
     ///                                response, and data.
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DataTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///
     /// - Returns: The `DataTask`.
     public func serializingResponse<Serializer: DataResponseSerializerProtocol>(using serializer: Serializer,
-                                                                                automaticallyCancelling shouldAutomaticallyCancel: Bool = false)
+                                                                                automaticallyCancelling shouldAutomaticallyCancel: Bool = true)
         -> DataTask<Serializer.SerializedObject> {
-        dataTask(automaticallyCancelling: shouldAutomaticallyCancel) {
-            self.response(queue: .singleEventQueue,
-                          responseSerializer: serializer,
-                          completionHandler: $0)
+        dataTask(automaticallyCancelling: shouldAutomaticallyCancel) { [self] in
+            response(queue: underlyingQueue,
+                     responseSerializer: serializer,
+                     completionHandler: $0)
         }
     }
 
@@ -366,13 +366,13 @@ extension DownloadRequest {
     /// - Parameters:
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DownloadTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///   - dataPreprocessor:          `DataPreprocessor` which processes the received `Data` before completion.
     ///   - emptyResponseCodes:        HTTP response codes for which empty responses are allowed. `[204, 205]` by default.
     ///   - emptyRequestMethods:       `HTTPMethod`s for which empty responses are always valid. `[.head]` by default.
     ///
     /// - Returns:                   The `DownloadTask`.
-    public func serializingData(automaticallyCancelling shouldAutomaticallyCancel: Bool = false,
+    public func serializingData(automaticallyCancelling shouldAutomaticallyCancel: Bool = true,
                                 dataPreprocessor: DataPreprocessor = DataResponseSerializer.defaultDataPreprocessor,
                                 emptyResponseCodes: Set<Int> = DataResponseSerializer.defaultEmptyResponseCodes,
                                 emptyRequestMethods: Set<HTTPMethod> = DataResponseSerializer.defaultEmptyRequestMethods) -> DownloadTask<Data> {
@@ -390,7 +390,7 @@ extension DownloadRequest {
     ///   - type:                      `Decodable` type to decode from response data.
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DownloadTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///   - dataPreprocessor:          `DataPreprocessor` which processes the received `Data` before calling the serializer.
     ///                                `PassthroughPreprocessor()` by default.
     ///   - decoder:                   `DataDecoder` to use to decode the response. `JSONDecoder()` by default.
@@ -399,7 +399,7 @@ extension DownloadRequest {
     ///
     /// - Returns:                   The `DownloadTask`.
     public func serializingDecodable<Value: Decodable>(_ type: Value.Type = Value.self,
-                                                       automaticallyCancelling shouldAutomaticallyCancel: Bool = false,
+                                                       automaticallyCancelling shouldAutomaticallyCancel: Bool = true,
                                                        dataPreprocessor: DataPreprocessor = DecodableResponseSerializer<Value>.defaultDataPreprocessor,
                                                        decoder: DataDecoder = JSONDecoder(),
                                                        emptyResponseCodes: Set<Int> = DecodableResponseSerializer<Value>.defaultEmptyResponseCodes,
@@ -416,10 +416,10 @@ extension DownloadRequest {
     /// - Parameters:
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DownloadTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///
     /// - Returns: The `DownloadTask`.
-    public func serializingDownloadedFileURL(automaticallyCancelling shouldAutomaticallyCancel: Bool = false) -> DownloadTask<URL> {
+    public func serializingDownloadedFileURL(automaticallyCancelling shouldAutomaticallyCancel: Bool = true) -> DownloadTask<URL> {
         serializingDownload(using: URLResponseSerializer(),
                             automaticallyCancelling: shouldAutomaticallyCancel)
     }
@@ -429,7 +429,7 @@ extension DownloadRequest {
     /// - Parameters:
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DownloadTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///   - dataPreprocessor:          `DataPreprocessor` which processes the received `Data` before calling the
     ///                                serializer. `PassthroughPreprocessor()` by default.
     ///   - encoding:                  `String.Encoding` to use during serialization. Defaults to `nil`, in which case
@@ -439,7 +439,7 @@ extension DownloadRequest {
     ///   - emptyRequestMethods:       `HTTPMethod`s for which empty responses are always valid. `[.head]` by default.
     ///
     /// - Returns:                   The `DownloadTask`.
-    public func serializingString(automaticallyCancelling shouldAutomaticallyCancel: Bool = false,
+    public func serializingString(automaticallyCancelling shouldAutomaticallyCancel: Bool = true,
                                   dataPreprocessor: DataPreprocessor = StringResponseSerializer.defaultDataPreprocessor,
                                   encoding: String.Encoding? = nil,
                                   emptyResponseCodes: Set<Int> = StringResponseSerializer.defaultEmptyResponseCodes,
@@ -457,16 +457,16 @@ extension DownloadRequest {
     ///   - serializer:                `ResponseSerializer` responsible for serializing the request, response, and data.
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DownloadTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///
     /// - Returns: The `DownloadTask`.
     public func serializingDownload<Serializer: ResponseSerializer>(using serializer: Serializer,
-                                                                    automaticallyCancelling shouldAutomaticallyCancel: Bool = false)
+                                                                    automaticallyCancelling shouldAutomaticallyCancel: Bool = true)
         -> DownloadTask<Serializer.SerializedObject> {
-        downloadTask(automaticallyCancelling: shouldAutomaticallyCancel) {
-            self.response(queue: .singleEventQueue,
-                          responseSerializer: serializer,
-                          completionHandler: $0)
+        downloadTask(automaticallyCancelling: shouldAutomaticallyCancel) { [self] in
+            response(queue: underlyingQueue,
+                     responseSerializer: serializer,
+                     completionHandler: $0)
         }
     }
 
@@ -478,16 +478,16 @@ extension DownloadRequest {
     ///                                response, and data.
     ///   - shouldAutomaticallyCancel: `Bool` determining whether or not the request should be cancelled when the
     ///                                enclosing async context is cancelled. Only applies to `DownloadTask`'s async
-    ///                                properties. `false` by default.
+    ///                                properties. `true` by default.
     ///
     /// - Returns: The `DownloadTask`.
     public func serializingDownload<Serializer: DownloadResponseSerializerProtocol>(using serializer: Serializer,
-                                                                                    automaticallyCancelling shouldAutomaticallyCancel: Bool = false)
+                                                                                    automaticallyCancelling shouldAutomaticallyCancel: Bool = true)
         -> DownloadTask<Serializer.SerializedObject> {
-        downloadTask(automaticallyCancelling: shouldAutomaticallyCancel) {
-            self.response(queue: .singleEventQueue,
-                          responseSerializer: serializer,
-                          completionHandler: $0)
+        downloadTask(automaticallyCancelling: shouldAutomaticallyCancel) { [self] in
+            response(queue: underlyingQueue,
+                     responseSerializer: serializer,
+                     completionHandler: $0)
         }
     }
 

+ 8 - 0
Source/NetworkReachabilityManager.swift

@@ -266,11 +266,19 @@ extension SCNetworkReachabilityFlags {
     var canConnectWithoutUserInteraction: Bool { canConnectAutomatically && !contains(.interventionRequired) }
     var isActuallyReachable: Bool { isReachable && (!isConnectionRequired || canConnectWithoutUserInteraction) }
     var isCellular: Bool {
+        #if swift(>=5.9)
+        #if os(iOS) || os(tvOS) || os(visionOS)
+        return contains(.isWWAN)
+        #else
+        return false
+        #endif
+        #else
         #if os(iOS) || os(tvOS)
         return contains(.isWWAN)
         #else
         return false
         #endif
+        #endif
     }
 
     /// Human readable `String` for all states, to help with debugging.

+ 3 - 8
Source/Request.swift

@@ -863,13 +863,7 @@ public class Request {
     /// - Returns:           The instance.
     @discardableResult
     public func cURLDescription(calling handler: @escaping (String) -> Void) -> Self {
-        $mutableState.write { mutableState in
-            if mutableState.requests.last != nil {
-                underlyingQueue.async { handler(self.cURLDescription()) }
-            } else {
-                mutableState.cURLHandler = (underlyingQueue, handler)
-            }
-        }
+        cURLDescription(on: underlyingQueue, calling: handler)
 
         return self
     }
@@ -935,12 +929,13 @@ public class Request {
 
     /// Final cleanup step executed when the instance finishes response serialization.
     func cleanup() {
-        delegate?.cleanup(after: self)
         let handlers = $mutableState.finishHandlers
         handlers.forEach { $0() }
         $mutableState.write { state in
             state.finishHandlers.removeAll()
         }
+
+        delegate?.cleanup(after: self)
     }
 }
 

+ 103 - 15
Tests/ConcurrencyTests.swift

@@ -127,14 +127,14 @@ final class DataRequestConcurrencyTests: BaseTestCase {
         XCTAssertTrue(request.isCancelled, "Underlying DataRequest should be cancelled.")
     }
 
-    func testThatDataTaskIsAutomaticallyCancelledInTaskWhenEnabled() async {
+    func testThatDataTaskIsAutomaticallyCancelledInTask() async {
         // Given
         let session = stored(Session())
         let request = session.request(.get)
 
         // When
         let task = Task {
-            await request.serializingDecodable(TestResponse.self, automaticallyCancelling: true).result
+            await request.serializingDecodable(TestResponse.self).result
         }
 
         task.cancel()
@@ -146,7 +146,26 @@ final class DataRequestConcurrencyTests: BaseTestCase {
         XCTAssertTrue(request.isCancelled, "Underlying DataRequest should be cancelled.")
     }
 
-    func testThatDataTaskIsAutomaticallyCancelledInTaskGroupWhenEnabled() async {
+    func testThatDataTaskIsNotAutomaticallyCancelledInTaskWhenDisabled() async {
+        // Given
+        let session = stored(Session())
+        let request = session.request(.get)
+
+        // When
+        let task = Task {
+            await request.serializingDecodable(TestResponse.self, automaticallyCancelling: false).result
+        }
+
+        task.cancel()
+        let result = await task.value
+
+        // Then
+        XCTAssertTrue(task.isCancelled, "Task should be cancelled.")
+        XCTAssertFalse(request.isCancelled, "Underlying DataRequest should not be cancelled.")
+        XCTAssertTrue(result.isSuccess, "DataRequest should succeed.")
+    }
+
+    func testThatDataTaskIsAutomaticallyCancelledInTaskGroup() async {
         // Given
         let session = stored(Session())
         let request = session.request(.get)
@@ -155,7 +174,7 @@ final class DataRequestConcurrencyTests: BaseTestCase {
         let task = Task {
             await withTaskGroup(of: Result<TestResponse, AFError>.self) { group -> Result<TestResponse, AFError> in
                 group.addTask {
-                    await request.serializingDecodable(TestResponse.self, automaticallyCancelling: true).result
+                    await request.serializingDecodable(TestResponse.self).result
                 }
 
                 return await group.first(where: { _ in true })!
@@ -170,6 +189,31 @@ final class DataRequestConcurrencyTests: BaseTestCase {
         XCTAssertTrue(task.isCancelled, "Task should be cancelled.")
         XCTAssertTrue(request.isCancelled, "Underlying DataRequest should be cancelled.")
     }
+
+    func testThatDataTaskIsNotAutomaticallyCancelledInTaskGroupWhenDisabled() async {
+        // Given
+        let session = stored(Session())
+        let request = session.request(.get)
+
+        // When
+        let task = Task {
+            await withTaskGroup(of: Result<TestResponse, AFError>.self) { group -> Result<TestResponse, AFError> in
+                group.addTask {
+                    await request.serializingDecodable(TestResponse.self, automaticallyCancelling: false).result
+                }
+
+                return await group.first(where: { _ in true })!
+            }
+        }
+
+        task.cancel()
+        let result = await task.value
+
+        // Then
+        XCTAssertTrue(task.isCancelled, "Task should be cancelled.")
+        XCTAssertFalse(request.isCancelled, "Underlying DataRequest should not be cancelled.")
+        XCTAssertTrue(result.isSuccess, "DataRequest should succeed.")
+    }
 }
 
 @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@@ -282,14 +326,14 @@ final class DownloadConcurrencyTests: BaseTestCase {
         XCTAssertTrue(response.error?.isExplicitlyCancelledError == true)
     }
 
-    func testThatDownloadTaskIsAutomaticallyCancelledInTaskWhenEnabled() async {
+    func testThatDownloadTaskIsAutomaticallyCancelledInTask() async {
         // Given
         let session = stored(Session())
         let request = session.download(.get)
 
         // When
         let task = Task {
-            await request.serializingDecodable(TestResponse.self, automaticallyCancelling: true).result
+            await request.serializingDecodable(TestResponse.self).result
         }
 
         task.cancel()
@@ -301,7 +345,26 @@ final class DownloadConcurrencyTests: BaseTestCase {
         XCTAssertTrue(request.isCancelled, "Underlying DownloadRequest should be cancelled.")
     }
 
-    func testThatDownloadTaskIsAutomaticallyCancelledInTaskGroupWhenEnabled() async {
+    func testThatDownloadTaskIsNotAutomaticallyCancelledInTaskWhenDisabled() async {
+        // Given
+        let session = stored(Session())
+        let request = session.download(.get)
+
+        // When
+        let task = Task {
+            await request.serializingDecodable(TestResponse.self, automaticallyCancelling: false).result
+        }
+
+        task.cancel()
+        let result = await task.value
+
+        // Then
+        XCTAssertTrue(task.isCancelled, "Task should be cancelled.")
+        XCTAssertFalse(request.isCancelled, "Underlying DownloadRequest should not be cancelled.")
+        XCTAssertTrue(result.isSuccess, "DownloadRequest should succeed.")
+    }
+
+    func testThatDownloadTaskIsAutomaticallyCancelledInTaskGroup() async {
         // Given
         let session = stored(Session())
         let request = session.download(.get)
@@ -310,7 +373,7 @@ final class DownloadConcurrencyTests: BaseTestCase {
         let task = Task {
             await withTaskGroup(of: Result<TestResponse, AFError>.self) { group -> Result<TestResponse, AFError> in
                 group.addTask {
-                    await request.serializingDecodable(TestResponse.self, automaticallyCancelling: true).result
+                    await request.serializingDecodable(TestResponse.self).result
                 }
 
                 return await group.first(where: { _ in true })!
@@ -325,6 +388,31 @@ final class DownloadConcurrencyTests: BaseTestCase {
         XCTAssertTrue(task.isCancelled, "Task should be cancelled.")
         XCTAssertTrue(request.isCancelled, "Underlying DownloadRequest should be cancelled.")
     }
+
+    func testThatDownloadTaskIsNotAutomaticallyCancelledInTaskGroupWhenDisabled() async {
+        // Given
+        let session = stored(Session())
+        let request = session.download(.get)
+
+        // When
+        let task = Task {
+            await withTaskGroup(of: Result<TestResponse, AFError>.self) { group -> Result<TestResponse, AFError> in
+                group.addTask {
+                    await request.serializingDecodable(TestResponse.self, automaticallyCancelling: false).result
+                }
+
+                return await group.first(where: { _ in true })!
+            }
+        }
+
+        task.cancel()
+        let result = await task.value
+
+        // Then
+        XCTAssertTrue(task.isCancelled, "Task should be cancelled.")
+        XCTAssertFalse(request.isCancelled, "Underlying DownloadRequest should not be cancelled.")
+        XCTAssertTrue(result.isSuccess, "DownloadRequest should succeed.")
+    }
 }
 
 @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@@ -505,13 +593,13 @@ final class ClosureAPIConcurrencyTests: BaseTestCase {
         #endif
 
         // Then
-        XCTAssertTrue(values.uploadProgresses.isEmpty)
-        XCTAssertNotNil(values.downloadProgresses.last)
-        XCTAssertTrue(values.downloadProgresses.last?.isFinished == true)
-        XCTAssertNotNil(values.requests.last)
-        XCTAssertNotNil(values.tasks.last)
-        XCTAssertNotNil(values.descriptions.last)
-        XCTAssertTrue(values.response.result.isSuccess)
+        XCTAssertTrue(values.uploadProgresses.isEmpty, "uploadProgresses should be empty")
+        XCTAssertNotNil(values.downloadProgresses.last, "downloadProgresses should not be empty")
+        XCTAssertTrue(values.downloadProgresses.last?.isFinished == true, "last download progression should be finished")
+        XCTAssertNotNil(values.requests.last, "requests should not be empty")
+        XCTAssertNotNil(values.tasks.last, "tasks should not be empty")
+        XCTAssertNotNil(values.descriptions.last, "descriptions should not be empty")
+        XCTAssertTrue(values.response.result.isSuccess, "request should succeed")
     }
 }
 

+ 1 - 1
Tests/MultipartFormDataTests.swift

@@ -632,7 +632,7 @@ final class MultipartFormDataWriteEncodedDataToDiskTestCase: BaseTestCase {
             expectedFileData.append(BoundaryGenerator.boundaryData(boundaryType: .initial, boundaryKey: boundary))
             expectedFileData.append(Data((
                 "Content-Disposition: form-data; name=\"unicorn\"; filename=\"unicorn.png\"\(crlf)" +
-                "Content-Type: image/png\(crlf)\(crlf)").utf8
+                    "Content-Type: image/png\(crlf)\(crlf)").utf8
             ))
             expectedFileData.append(unicornImageData.prefix(Int(expectedFileStreamUploadLength)))
             expectedFileData.append(BoundaryGenerator.boundaryData(boundaryType: .final, boundaryKey: boundary))