-
Notifications
You must be signed in to change notification settings - Fork 743
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
Fix for concurrent fetching bugs #1227
Changes from all commits
1e2f325
6a9afaa
0b31a84
19cdc59
90598a0
385962a
da677e5
61ebda5
645b910
3f611d3
cb8d46f
0f5772e
6c1c832
e58853d
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 |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import Foundation | ||
|
||
public class TaskData { | ||
|
||
public let rawCompletion: URLSessionClient.RawCompletion? | ||
public let completionBlock: URLSessionClient.Completion | ||
private(set) var data: Data = Data() | ||
private(set) var response: HTTPURLResponse? = nil | ||
|
||
init(rawCompletion: URLSessionClient.RawCompletion?, | ||
completionBlock: @escaping URLSessionClient.Completion) { | ||
self.rawCompletion = rawCompletion | ||
self.completionBlock = completionBlock | ||
} | ||
|
||
func append(additionalData: Data) { | ||
self.data.append(additionalData) | ||
} | ||
|
||
func responseReceived(response: URLResponse) { | ||
if let httpResponse = response as? HTTPURLResponse { | ||
self.response = httpResponse | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,10 +23,7 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
/// A completion block returning a result. On `.success` it will contain a tuple with non-nil `Data` and its corresponding `HTTPURLResponse`. On `.failure` it will contain an error. | ||
public typealias Completion = (Result<(Data, HTTPURLResponse), Error>) -> Void | ||
|
||
private var completionBlocks = Atomic<[Int: Completion]>([:]) | ||
private var rawCompletions = Atomic<[Int: RawCompletion]>([:]) | ||
private var datas = Atomic<[Int: Data]>([:]) | ||
private var responses = Atomic<[Int: HTTPURLResponse]>([:]) | ||
private var tasks = Atomic<[Int: TaskData]>([:]) | ||
|
||
/// The raw URLSession being used for this client | ||
open private(set) var session: URLSession! | ||
|
@@ -44,24 +41,22 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
delegateQueue: callbackQueue) | ||
} | ||
|
||
deinit { | ||
self.clearAllTasks() | ||
} | ||
|
||
/// Clears underlying dictionaries of any data related to a particular task identifier. | ||
/// | ||
/// - Parameter identifier: The identifier of the task to clear. | ||
open func clearTask(with identifier: Int) { | ||
self.rawCompletions.value.removeValue(forKey: identifier) | ||
self.completionBlocks.value.removeValue(forKey: identifier) | ||
self.datas.value.removeValue(forKey: identifier) | ||
self.responses.value.removeValue(forKey: identifier) | ||
open func clear(task identifier: Int) { | ||
self.tasks.mutate { $0.removeValue(forKey: identifier) } | ||
} | ||
|
||
/// Clears underlying dictionaries of any data related to all tasks. | ||
/// | ||
/// Mostly useful for cleanup and/or after invalidation of the `URLSession`. | ||
open func clearAllTasks() { | ||
designatednerd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
self.rawCompletions.value.removeAll() | ||
self.completionBlocks.value.removeAll() | ||
self.datas.value.removeAll() | ||
self.responses.value.removeAll() | ||
self.tasks.mutate { $0.removeAll() } | ||
} | ||
|
||
/// The main method to perform a request. | ||
|
@@ -76,16 +71,15 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
open func sendRequest(_ request: URLRequest, | ||
rawTaskCompletionHandler: RawCompletion? = nil, | ||
completion: @escaping Completion) -> URLSessionTask { | ||
let dataTask = self.session.dataTask(with: request) | ||
if let rawCompletion = rawTaskCompletionHandler { | ||
self.rawCompletions.value[dataTask.taskIdentifier] = rawCompletion | ||
} | ||
let task = self.session.dataTask(with: request) | ||
let taskData = TaskData(rawCompletion: rawTaskCompletionHandler, | ||
completionBlock: completion) | ||
|
||
self.completionBlocks.value[dataTask.taskIdentifier] = completion | ||
self.datas.value[dataTask.taskIdentifier] = Data() | ||
dataTask.resume() | ||
self.tasks.mutate { $0[task.taskIdentifier] = taskData } | ||
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 would create some "insert" that checks if ID is already there just for sanity sake :) self.tasks.mutate {
// assert if id is in $0
$0[task.taskIdentifier] = taskData
} 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. generally i'd agree with this but I did get confirmation that the issue was my misuse of locks that was causing the problem with the task data failing to increment. If this isn't working now, it's a clear 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 also was able to validate that with the changes the number increments properly) 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. the problem might arise if someone changes locking logic and will reintroduce bug. Without any checking error will be just "swallowed" until someone reports it :) 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. Fair, though I think that's probably better to check through tests than an assertion 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. updated the test with |
||
|
||
return dataTask | ||
task.resume() | ||
|
||
return task | ||
} | ||
|
||
/// Cancels a given task and clears out its underlying data. | ||
|
@@ -94,16 +88,16 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
/// | ||
/// - Parameter task: The task you wish to cancel. | ||
open func cancel(task: URLSessionTask) { | ||
self.clearTask(with: task.taskIdentifier) | ||
self.clear(task: task.taskIdentifier) | ||
task.cancel() | ||
} | ||
|
||
// MARK: - URLSessionDelegate | ||
|
||
open func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { | ||
let finalError = error ?? URLSessionClientError.sessionBecameInvalidWithoutUnderlyingError | ||
for block in self.completionBlocks.value.values { | ||
block(.failure(finalError)) | ||
for task in self.tasks.value.values { | ||
task.completionBlock(.failure(finalError)) | ||
} | ||
|
||
self.clearAllTasks() | ||
|
@@ -145,38 +139,33 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
open func urlSession(_ session: URLSession, | ||
task: URLSessionTask, | ||
didCompleteWithError error: Error?) { | ||
let taskIdentifier = task.taskIdentifier | ||
defer { | ||
self.clearTask(with: taskIdentifier) | ||
self.clear(task: task.taskIdentifier) | ||
} | ||
|
||
guard let completion = self.completionBlocks.value.removeValue(forKey: taskIdentifier) else { | ||
guard let taskData = self.tasks.value[task.taskIdentifier] else { | ||
// No completion blocks, the task has likely been cancelled. Bail out. | ||
return | ||
} | ||
|
||
let data = self.datas.value[taskIdentifier] | ||
let response = self.responses.value[taskIdentifier] | ||
let data = taskData.data | ||
let response = taskData.response | ||
|
||
if let rawCompletion = self.rawCompletions.value.removeValue(forKey: taskIdentifier) { | ||
if let rawCompletion = taskData.rawCompletion { | ||
rawCompletion(data, response, error) | ||
} | ||
|
||
guard let finalData = data else { | ||
// Data is immediately created for a task on creation, so if it's not there, something's gone wrong. | ||
completion(.failure(URLSessionClientError.dataForRequestNotFound(request: task.originalRequest))) | ||
return | ||
} | ||
let completion = taskData.completionBlock | ||
|
||
if let finalError = error { | ||
completion(.failure(URLSessionClientError.networkError(data: finalData, response: response, underlying: finalError))) | ||
completion(.failure(URLSessionClientError.networkError(data: data, response: response, underlying: finalError))) | ||
} else { | ||
guard let finalResponse = response else { | ||
completion(.failure(URLSessionClientError.noHTTPResponse(request: task.originalRequest))) | ||
return | ||
} | ||
|
||
completion(.success((finalData, finalResponse))) | ||
completion(.success((data, finalResponse))) | ||
} | ||
} | ||
|
||
|
@@ -215,7 +204,14 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
open func urlSession(_ session: URLSession, | ||
dataTask: URLSessionDataTask, | ||
didReceive data: Data) { | ||
self.datas.value[dataTask.taskIdentifier]?.append(data) | ||
self.tasks.mutate { | ||
guard let taskData = $0[dataTask.taskIdentifier] else { | ||
assertionFailure("No data found for task \(dataTask.taskIdentifier), cannot append received data") | ||
return | ||
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. would personally add some assert as it "must" be here 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've been really trying to avoid asserts in library code here, but I do think it's reasonable for 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. assert is not great for reason that they don't play nicely with tests... but just ignoring possible error is not nice... 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. yeah, this is where not being able to throw an error is really annoying. I did add the assert to the |
||
} | ||
|
||
taskData.append(additionalData: data) | ||
} | ||
} | ||
|
||
@available(iOS 9.0, OSXApplicationExtension 10.11, OSX 10.11, *) | ||
|
@@ -246,8 +242,12 @@ open class URLSessionClient: NSObject, URLSessionDelegate, URLSessionTaskDelegat | |
completionHandler(.allow) | ||
} | ||
|
||
if let httpResponse = response as? HTTPURLResponse { | ||
self.responses.value[dataTask.taskIdentifier] = httpResponse | ||
self.tasks.mutate { | ||
guard let taskData = $0[dataTask.taskIdentifier] else { | ||
return | ||
} | ||
|
||
taskData.responseReceived(response: response) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,43 +1,59 @@ | ||
import Foundation | ||
|
||
enum HTTPBinAPI { | ||
static let baseURL = URL(string: "https://httpbin.org/")! | ||
enum Endpoint { | ||
case bytes(count: Int) | ||
case get | ||
case headers | ||
case image | ||
case post | ||
|
||
var toString: String { | ||
|
||
switch self { | ||
case .bytes(let count): | ||
return "bytes/\(count)" | ||
case .get: | ||
return "get" | ||
case .headers: | ||
return "headers" | ||
case .image: | ||
return "image/jpeg" | ||
case .post: | ||
return "post" | ||
} | ||
} | ||
|
||
var toURL: URL { | ||
HTTPBinAPI.baseURL.appendingPathComponent(self.toString) | ||
} | ||
static let baseURL = URL(string: "https://httpbin.org")! | ||
enum Endpoint { | ||
case bytes(count: Int) | ||
case get | ||
case getWithIndex(index: Int) | ||
case headers | ||
case image | ||
case post | ||
|
||
var toString: String { | ||
|
||
switch self { | ||
case .bytes(let count): | ||
return "bytes/\(count)" | ||
case .get, | ||
.getWithIndex: | ||
return "get" | ||
case .headers: | ||
return "headers" | ||
case .image: | ||
return "image/jpeg" | ||
case .post: | ||
return "post" | ||
} | ||
} | ||
} | ||
|
||
struct HTTPBinResponse: Codable { | ||
|
||
let headers: [String: String] | ||
let url: String | ||
let json: [String: String]? | ||
var queryParams: [URLQueryItem]? { | ||
switch self { | ||
case .getWithIndex(let index): | ||
return [URLQueryItem(name: "index", value: "\(index)")] | ||
default: | ||
return nil | ||
} | ||
} | ||
|
||
init(data: Data) throws { | ||
self = try JSONDecoder().decode(Self.self, from: data) | ||
var toURL: URL { | ||
var components = URLComponents(url: HTTPBinAPI.baseURL, resolvingAgainstBaseURL: false)! | ||
components.path = "/\(self.toString)" | ||
components.queryItems = self.queryParams | ||
|
||
return components.url! | ||
} | ||
} | ||
} | ||
|
||
struct HTTPBinResponse: Codable { | ||
|
||
let headers: [String: String] | ||
let url: String | ||
let json: [String: String]? | ||
let args: [String: String]? | ||
|
||
init(data: Data) throws { | ||
self = try JSONDecoder().decode(Self.self, from: data) | ||
} | ||
} |
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.
wonder if you need do some "cleanup" on deinit (like calling completion handlers)
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.
There's no way to tell if the completion handlers have already been called, so I don't think that's a great idea in this case.