Skip to content

Commit f90828d

Browse files
committed
Add regression tests for WebDAV task registration race
1 parent 406f6b1 commit f90828d

3 files changed

Lines changed: 265 additions & 63 deletions

File tree

Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,13 @@ class WebDAVSession {
224224
progress.addChild(task.progress, withPendingUnitCount: 1)
225225
let pendingPromise = Promise<HTTPURLResponse>.pending()
226226
let webDAVDownloadTask = WebDAVDownloadTask(promise: pendingPromise, localURL: localURL)
227-
startTask(task, register: { downloadTask in
228-
self.delegate?.addRunningDownloadTask(key: downloadTask, value: webDAVDownloadTask)
229-
}, onTaskCreation: onTaskCreation)
227+
// Register before invoking `onTaskCreation` so a synchronous resume cannot race the delegate's lookup.
228+
delegate?.addRunningDownloadTask(key: task, value: webDAVDownloadTask)
229+
if let onTaskCreation {
230+
onTaskCreation(task)
231+
} else {
232+
task.resume()
233+
}
230234
return pendingPromise
231235
}
232236

@@ -237,21 +241,13 @@ class WebDAVSession {
237241
progress.addChild(task.progress, withPendingUnitCount: 1)
238242
let pendingPromise = Promise<(HTTPURLResponse, Data?)>.pending()
239243
let webDAVDataTask = WebDAVDataTask(promise: pendingPromise)
240-
startTask(task, register: { uploadTask in
241-
self.delegate?.addRunningDataTask(key: uploadTask, value: webDAVDataTask)
242-
}, onTaskCreation: onTaskCreation)
243-
return pendingPromise
244-
}
245-
246-
/// Registers the task with the delegate's running-tasks dictionary, then yields to `onTaskCreation`.
247-
/// Registration must happen before `onTaskCreation` (which may resume the task) so the delegate can
248-
/// match response callbacks without racing an empty dictionary. If the caller does not provide an
249-
/// `onTaskCreation` closure, the task is resumed directly.
250-
private func startTask<Task: URLSessionTask>(_ task: Task, register: (Task) -> Void, onTaskCreation: ((Task?) -> Void)?) {
251-
register(task)
252-
onTaskCreation?(task)
253-
if onTaskCreation == nil {
244+
// Register before invoking `onTaskCreation` so a synchronous resume cannot race the delegate's lookup.
245+
delegate?.addRunningDataTask(key: task, value: webDAVDataTask)
246+
if let onTaskCreation {
247+
onTaskCreation(task)
248+
} else {
254249
task.resume()
255250
}
251+
return pendingPromise
256252
}
257253
}

Tests/CryptomatorCloudAccessTests/WebDAV/URLProtocolMock.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Foundation
1010

1111
enum URLProtocolMockError: Error {
1212
case unexpectedRequest
13+
case simulatedTransportFailure
1314
}
1415

1516
class URLProtocolMock: URLProtocol {
@@ -71,3 +72,47 @@ class URLProtocolAuthenticationMock: URLProtocol, URLAuthenticationChallengeSend
7172

7273
override func stopLoading() {}
7374
}
75+
76+
/// Test-only `URLProtocol` that emits a queued sequence of either HTTP responses or auth challenges
77+
/// in order. Used to target an auth challenge at a specific request (e.g. the transfer task) while
78+
/// letting earlier requests (e.g. the preflight `PROPFIND`) resolve normally.
79+
class URLProtocolSequenceMock: URLProtocol, URLAuthenticationChallengeSender {
80+
enum Step {
81+
case response(HTTPURLResponse, Data?)
82+
case authChallenge(URLAuthenticationChallengeMock)
83+
}
84+
85+
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
86+
87+
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
88+
89+
func cancel(_ challenge: URLAuthenticationChallenge) {}
90+
91+
static var steps = [Step]()
92+
93+
override func startLoading() {
94+
let step = URLProtocolSequenceMock.steps.removeFirst()
95+
switch step {
96+
case let .response(response, data):
97+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
98+
if let data = data {
99+
client?.urlProtocol(self, didLoad: data)
100+
}
101+
client?.urlProtocolDidFinishLoading(self)
102+
case let .authChallenge(settings):
103+
let protectionSpace = URLProtectionSpace(host: "", port: 443, protocol: nil, realm: nil, authenticationMethod: nil)
104+
let challenge = URLAuthenticationChallenge(protectionSpace: protectionSpace, proposedCredential: nil, previousFailureCount: settings.previousFailureCount, failureResponse: settings.failureResponse, error: nil, sender: self)
105+
client?.urlProtocol(self, didReceive: challenge)
106+
}
107+
}
108+
109+
override class func canInit(with request: URLRequest) -> Bool {
110+
return true
111+
}
112+
113+
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
114+
return request
115+
}
116+
117+
override func stopLoading() {}
118+
}

0 commit comments

Comments
 (0)