Skip to content

Commit db0ea41

Browse files
authored
Merge pull request #2549 from onevcat/fix-force-cancel-race
Fix data race on SessionDataTask.started and tighten concurrency tests
2 parents 994b9fb + 4baa44e commit db0ea41

2 files changed

Lines changed: 50 additions & 13 deletions

File tree

Sources/Networking/SessionDataTask.swift

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ public class SessionDataTask: @unchecked Sendable {
9898
let onTaskDone = Delegate<(Result<(Data, URLResponse?), KingfisherError>, [TaskCallback]), Void>()
9999
let onCallbackCancelled = Delegate<(CancelToken, TaskCallback), Void>()
100100

101-
var started = false
101+
private var _started = false
102+
var started: Bool {
103+
lock.lock()
104+
defer { lock.unlock() }
105+
return _started
106+
}
107+
102108
var containsCallbacks: Bool {
103109
// We should be able to use `task.state != .running` to check it.
104110
// However, in some rare cases, cancelling the task does not change
@@ -154,8 +160,14 @@ public class SessionDataTask: @unchecked Sendable {
154160
}
155161

156162
func resume() {
157-
guard !started else { return }
158-
started = true
163+
// Atomic check-and-set; `task.resume()` is called outside the lock.
164+
lock.lock()
165+
guard !_started else {
166+
lock.unlock()
167+
return
168+
}
169+
_started = true
170+
lock.unlock()
159171
task.resume()
160172
}
161173

@@ -167,16 +179,9 @@ public class SessionDataTask: @unchecked Sendable {
167179
}
168180

169181
func forceCancel() {
170-
// `callbacksStore` is protected by `lock` for every other access. Snapshot the
171-
// tokens under the lock before cancelling, for two reasons:
172-
// 1. `forceCancel()` is reachable from the public `ImageDownloader.cancelAll()` and
173-
// `cancel(url:)` on arbitrary caller threads, while the session delegate queue may
174-
// concurrently mutate the store via `addCallback`/`completeAndRemoveAllCallbacks`.
175-
// Reading the live `keys` view here would be a data race on the dictionary.
176-
// 2. `cancel(token:)` removes the token via `removeCallback`, so iterating the live
177-
// `keys` view would mutate the dictionary mid-iteration.
178-
// Iterating an immutable snapshot avoids both. The lock is released before calling
179-
// `cancel(token:)`, which re-acquires it (the non-recursive `lock` would otherwise deadlock).
182+
// Snapshot the tokens under the lock, then cancel outside of it: `forceCancel` can run on
183+
// any thread while `callbacksStore` is being mutated, and `cancel(token:)` re-acquires the
184+
// non-recurrent lock.
180185
lock.lock()
181186
let tokens = Array(callbacksStore.keys)
182187
lock.unlock()

Tests/KingfisherTests/ImageDownloaderTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,38 @@ class ImageDownloaderTests: XCTestCase {
505505
}
506506
}
507507

508+
// Hammers all mutating and reading surfaces of `SessionDataTask` concurrently, including the
509+
// `started` flag which `resume()` writes while `ImageDownloader.startDownloadTask` reads it
510+
// from other threads. Meaningful mainly under Thread Sanitizer.
511+
func testSessionDataTaskConcurrentAccessIsThreadSafe() {
512+
let url = URL(string: "https://example.com/concurrent-access")!
513+
// `resume()` resumes the underlying URLSessionDataTask; stub the URL so no real request leaves.
514+
stub(url, data: Data())
515+
let options = KingfisherParsedOptionsInfo(nil)
516+
517+
for _ in 0..<50 {
518+
let task = SessionDataTask(task: URLSession.shared.dataTask(with: url))
519+
let group = DispatchGroup()
520+
521+
func hammer(_ body: @escaping () -> Void) {
522+
group.enter()
523+
DispatchQueue.global().async {
524+
body()
525+
group.leave()
526+
}
527+
}
528+
529+
hammer { for _ in 0..<20 { _ = task.addCallback(.init(onCompleted: nil, options: options)) } }
530+
hammer { for _ in 0..<5 { task.forceCancel() } }
531+
hammer { for _ in 0..<20 { task.resume() } }
532+
hammer { for _ in 0..<20 { _ = task.started; _ = task.containsCallbacks } }
533+
hammer { for _ in 0..<20 { task.didReceiveData(Data([0x01])); _ = task.mutableDataCount } }
534+
hammer { _ = task.completeAndRemoveAllCallbacks() }
535+
536+
group.wait()
537+
}
538+
}
539+
508540
// Issue 532 https://github.com/onevcat/Kingfisher/issues/532#issuecomment-305644311
509541
func testCancelThenRestartSameDownload() {
510542
let exp = expectation(description: #function)

0 commit comments

Comments
 (0)