Skip to content

Commit 4baa44e

Browse files
committed
Fix data race on SessionDataTask.started and tighten concurrency tests
started was written by resume() without synchronization while ImageDownloader.startDownloadTask reads it from other threads — the same class of race as the forceCancel one fixed in #2539. Guard it with the existing lock, making resume() an atomic check-and-set with task.resume() called outside the lock. Also condense the forceCancel comment introduced in #2539 and add a single combined stress test hammering all mutating and reading surfaces of SessionDataTask concurrently (addCallback, forceCancel, resume, started, containsCallbacks, didReceiveData, mutableDataCount, completeAndRemoveAllCallbacks). Like the #2539 regression test, it is meaningful mainly under Thread Sanitizer: xcodebuild test -project Kingfisher.xcodeproj -scheme Kingfisher \ -destination 'platform=macOS' -enableThreadSanitizer YES \ -only-testing:KingfisherTests/ImageDownloaderTests/testSessionDataTaskConcurrentAccessIsThreadSafe
1 parent 6207a84 commit 4baa44e

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
@@ -87,7 +87,13 @@ public class SessionDataTask: @unchecked Sendable {
8787
let onTaskDone = Delegate<(Result<(Data, URLResponse?), KingfisherError>, [TaskCallback]), Void>()
8888
let onCallbackCancelled = Delegate<(CancelToken, TaskCallback), Void>()
8989

90-
var started = false
90+
private var _started = false
91+
var started: Bool {
92+
lock.lock()
93+
defer { lock.unlock() }
94+
return _started
95+
}
96+
9197
var containsCallbacks: Bool {
9298
// We should be able to use `task.state != .running` to check it.
9399
// However, in some rare cases, cancelling the task does not change
@@ -143,8 +149,14 @@ public class SessionDataTask: @unchecked Sendable {
143149
}
144150

145151
func resume() {
146-
guard !started else { return }
147-
started = true
152+
// Atomic check-and-set; `task.resume()` is called outside the lock.
153+
lock.lock()
154+
guard !_started else {
155+
lock.unlock()
156+
return
157+
}
158+
_started = true
159+
lock.unlock()
148160
task.resume()
149161
}
150162

@@ -156,16 +168,9 @@ public class SessionDataTask: @unchecked Sendable {
156168
}
157169

158170
func forceCancel() {
159-
// `callbacksStore` is protected by `lock` for every other access. Snapshot the
160-
// tokens under the lock before cancelling, for two reasons:
161-
// 1. `forceCancel()` is reachable from the public `ImageDownloader.cancelAll()` and
162-
// `cancel(url:)` on arbitrary caller threads, while the session delegate queue may
163-
// concurrently mutate the store via `addCallback`/`completeAndRemoveAllCallbacks`.
164-
// Reading the live `keys` view here would be a data race on the dictionary.
165-
// 2. `cancel(token:)` removes the token via `removeCallback`, so iterating the live
166-
// `keys` view would mutate the dictionary mid-iteration.
167-
// Iterating an immutable snapshot avoids both. The lock is released before calling
168-
// `cancel(token:)`, which re-acquires it (the non-recursive `lock` would otherwise deadlock).
171+
// Snapshot the tokens under the lock, then cancel outside of it: `forceCancel` can run on
172+
// any thread while `callbacksStore` is being mutated, and `cancel(token:)` re-acquires the
173+
// non-recurrent lock.
169174
lock.lock()
170175
let tokens = Array(callbacksStore.keys)
171176
lock.unlock()

Tests/KingfisherTests/ImageDownloaderTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,38 @@ class ImageDownloaderTests: XCTestCase {
426426
}
427427
}
428428

429+
// Hammers all mutating and reading surfaces of `SessionDataTask` concurrently, including the
430+
// `started` flag which `resume()` writes while `ImageDownloader.startDownloadTask` reads it
431+
// from other threads. Meaningful mainly under Thread Sanitizer.
432+
func testSessionDataTaskConcurrentAccessIsThreadSafe() {
433+
let url = URL(string: "https://example.com/concurrent-access")!
434+
// `resume()` resumes the underlying URLSessionDataTask; stub the URL so no real request leaves.
435+
stub(url, data: Data())
436+
let options = KingfisherParsedOptionsInfo(nil)
437+
438+
for _ in 0..<50 {
439+
let task = SessionDataTask(task: URLSession.shared.dataTask(with: url))
440+
let group = DispatchGroup()
441+
442+
func hammer(_ body: @escaping () -> Void) {
443+
group.enter()
444+
DispatchQueue.global().async {
445+
body()
446+
group.leave()
447+
}
448+
}
449+
450+
hammer { for _ in 0..<20 { _ = task.addCallback(.init(onCompleted: nil, options: options)) } }
451+
hammer { for _ in 0..<5 { task.forceCancel() } }
452+
hammer { for _ in 0..<20 { task.resume() } }
453+
hammer { for _ in 0..<20 { _ = task.started; _ = task.containsCallbacks } }
454+
hammer { for _ in 0..<20 { task.didReceiveData(Data([0x01])); _ = task.mutableDataCount } }
455+
hammer { _ = task.completeAndRemoveAllCallbacks() }
456+
457+
group.wait()
458+
}
459+
}
460+
429461
// Issue 532 https://github.com/onevcat/Kingfisher/issues/532#issuecomment-305644311
430462
func testCancelThenRestartSameDownload() {
431463
let exp = expectation(description: #function)

0 commit comments

Comments
 (0)