-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Simplify reusing http request Tasks in ImageDownloader #23817
Changes from all commits
8afc35c
6f64397
660030d
4076c07
dcf9997
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -64,10 +64,56 @@ class ImageDownloaderTests: CoreDataTestCase { | |
let _ = try await task.value | ||
XCTFail() | ||
} catch { | ||
XCTAssertEqual((error as? URLError)?.code, .cancelled) | ||
// XCTAssertEqual((error as? URLError)?.code, .cancelled) | ||
XCTAssertTrue(error is CancellationError) | ||
} | ||
} | ||
|
||
func testCancelOneOfManySubscribers() async throws { | ||
// GIVEN | ||
let httpRequestReceived = self.expectation(description: "HTTP request received") | ||
let imageURL = try XCTUnwrap(URL(string: "https://example.files.wordpress.com/2023/09/image.jpg")) | ||
stub(condition: { _ in true }, response: { _ in | ||
httpRequestReceived.fulfill() | ||
|
||
guard let sourceURL = try? XCTUnwrap(Bundle.test.url(forResource: "test-image", withExtension: "jpg")), | ||
let data = try? Data(contentsOf: sourceURL) else { | ||
return HTTPStubsResponse(error: URLError(.unknown)) | ||
} | ||
|
||
return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) | ||
.responseTime(0.3) | ||
}) | ||
|
||
// WHEN there are concurrent calls to download the same image and one of those downloads is cancelled | ||
let taskCompleted = self.expectation(description: "Image downloaded") | ||
taskCompleted.expectedFulfillmentCount = 3 | ||
for _ in 1...3 { | ||
try await Task.sleep(for: .milliseconds(50)) | ||
Task.detached { | ||
do { | ||
_ = try await self.sut.image(from: imageURL) | ||
} catch { | ||
XCTFail("Unexpected error: \(error)") | ||
} | ||
taskCompleted.fulfill() | ||
} | ||
} | ||
|
||
let taskCancelled = expectation(description: "Task is cancelled") | ||
let taskToBeCancelled = Task.detached { | ||
do { | ||
_ = try await self.sut.image(from: imageURL) | ||
XCTFail("Unexpected successful result.") | ||
} catch { | ||
taskCancelled.fulfill() | ||
} | ||
} | ||
taskToBeCancelled.cancel() | ||
|
||
await fulfillment(of: [httpRequestReceived, taskCompleted, taskCancelled], timeout: 0.5) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kean I added this new test case to verify cancelling one download task with other in-flight download tasks. I think this test case is legitimate and should pass. It doesn't pass though. Do you think that's a bug in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Task cancellation is cooperative. When you call cancel() nothing actually happens. In this case, there are still three more tasks subscribed to the same unit of work, so the download continues as normal and finishes. It works as designed. What does happen is let taskToBeCancelled = Task.detached {
do {
_ = try await self.sut.image(from: imageURL)
XCTAssertTrue(Task.isCancelled) // succeeds
I'd suggest adding another expectation (somewhere in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I believe the cancellation would be passed along to whatever "inner" task that's currently running? In this case, if you cancel Here is a test code: import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
func fetch() async throws -> String {
_ = try await URLSession.shared.data(from: URL(string: "https://www.apple.com")!)
return "Things"
}
func download() async throws -> String {
try await fetch()
}
func use() async throws {
_ = try await download()
}
let t = Task.detached {
do {
try await use()
print("Success")
} catch {
print("Error: \(error)")
}
}
t.cancel()
The test fails at There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This code throws the following error ( Error: Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=https://www.apple.com/, NSErrorFailingURLKey=https://www.apple.com/, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalDataTask <338C255E-64EF-4162-B5EB-A0E72990C5DE>.<1>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <338C255E-64EF-4162-B5EB-A0E72990C5DE>.<1>, NSLocalizedDescription=cancelled} It happens because the underlying In the case of the
I could argue both sides. The simplest implementation is to let the task finish and not throw an error. There is probably a way to update There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Hmm... I don't know. I'm not sure how it's okay for a cancelled task/operation to return a successful result. I can understand it if there is a race condition, where the cancellation happens right at the time when the task is completed. But this test case is not that. Besides, the I thought the whole point of keeping a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test
This always is one because you typically I don't think there are any simple ways of updating There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
No, the download task should not get cancelled. The
dcf9997 (similar to #23823) should fix the issue. I'm not saying that's the only solution though. Sorry I think I was confused about how the |
||
|
||
func testMemoryCache() async throws { | ||
// GIVEN | ||
let imageURL = try XCTUnwrap(URL(string: "https://example.files.wordpress.com/2023/09/image.jpg")) | ||
|
@@ -125,6 +171,40 @@ class ImageDownloaderTests: CoreDataTestCase { | |
XCTAssertEqual(image.size, CGSize(width: 1024, height: 680)) | ||
} | ||
|
||
func testReuseHTTPRequest() async throws { | ||
// GIVEN | ||
let httpRequestReceived = self.expectation(description: "HTTP request received") | ||
let imageURL = try XCTUnwrap(URL(string: "https://example.files.wordpress.com/2023/09/image.jpg")) | ||
stub(condition: { _ in true }, response: { _ in | ||
httpRequestReceived.fulfill() | ||
|
||
guard let sourceURL = try? XCTUnwrap(Bundle.test.url(forResource: "test-image", withExtension: "jpg")), | ||
let data = try? Data(contentsOf: sourceURL) else { | ||
return HTTPStubsResponse(error: URLError(.unknown)) | ||
} | ||
|
||
return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) | ||
.responseTime(0.3) | ||
}) | ||
|
||
// WHEN | ||
let taskCompleted = self.expectation(description: "Image downloaded") | ||
taskCompleted.expectedFulfillmentCount = 3 | ||
for _ in 1...3 { | ||
try await Task.sleep(for: .milliseconds(50)) | ||
Task.detached { | ||
do { | ||
_ = try await self.sut.image(from: imageURL) | ||
} catch { | ||
XCTFail("Unexpected error: \(error)") | ||
} | ||
taskCompleted.fulfill() | ||
} | ||
} | ||
|
||
await fulfillment(of: [httpRequestReceived, taskCompleted], timeout: 0.5) | ||
} | ||
|
||
// MARK: - Helpers | ||
|
||
/// `Media` is hardcoded to work with a specific direcoty URL managed by `MediaFileManager` | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This version implements coalescing of tasks for the same URLs, which is especially useful for prefetching. If there is an outstanding task, it adds a subscriber to the existing task. The task gets canceled only when there are no subscribers left. It would ideally also reuse decompression/resizing, but it's a bit more challenging to implement, especially if you want to reuse these separate from the downloads.
Background:
ImageDownloader
is an existing class that I updated a while ago by adding background decompression, resizing, coalescing, and stuff like that. I also removed a few other systems. The downloader is currently the primary API for fetching images.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, of course. I don't know what I was thinking... The HTTP task is only canceled if all subscribers are canceled.