I was testing a library I created for thread-safety, and I ran into this really frustrating issue with a deadlock. After a lot of work, I've isolated the code that is causing the deadlock into the following snippet below. Is is the minimum reproducible example, not the full version.
The testDeadlock() function is the entry point. It runs the refreshTokensConcurrently function 10,000 times. During one of the invocations of this function, the deadlock will occur. It could be on the first invocation or the 400th invocation; the behavior is unpredictable.
The deadlock occurs at the line print(authorizationManager), which is within the sink for authorizationManager.didChange
Here are the last few lines of output in the console before the deadlock:
access token IS expired
i: 13; j: 18
authorizationManager.didChange sink: receive value: (())
i: 12; j: 14
WILL print authorizationManager
AuthorizationManager.description: BEFORE queue
For some reason, the call to dispatchQueue.sync in AuthorizationManager.description is blocking indefinitely.
Here's another really strange finding: if I remove the following print statement in AuthorizationManager.refreshTokens():
print("access token IS expired")
Then the deadlock does NOT occur.
What's going on here? What am I doing wrong?
import Foundation
import Combine
class AuthorizationManager {
public var expirationDate: Date
let dispatchQueue = DispatchQueue(label: "AuthorizationManager")
let didChange = PassthroughSubject<Void, Never>()
init() {
self.expirationDate = Date()
}
func refreshTokens() -> AnyPublisher<Void, Error> {
return dispatchQueue.sync {
print("access token IS expired")
return Result<Void, Error>
.Publisher(())
// this is meant to simulate a network request
.delay(for: 0.1, scheduler: DispatchQueue.global())
.map {
self.dispatchQueue.sync {
self.expirationDate = Date().addingTimeInterval(100_000)
}
self.didChange.send()
}
.eraseToAnyPublisher()
}
}
}
extension AuthorizationManager: CustomStringConvertible {
var description: String {
dispatchPrecondition(
condition: .notOnQueue(dispatchQueue)
)
print("AuthorizationManager.description: BEFORE queue")
return dispatchQueue.sync {
print("AuthorizationManager.description: INSIDE queue")
let dateString = self.expirationDate.description(with: .current)
return """
AuthorizationManager(
expirationDate: "\(dateString)"
)
"""
}
}
}
let authorizationManager = AuthorizationManager()
/// The entry point.
func testDeadlock() {
for i in 0...10_000 {
print("\n--- \(i) ---\n")
authorizationManager.expirationDate = Date().addingTimeInterval(-1)
refreshTokensConcurrently()
}
}
func refreshTokensConcurrently() {
var cancellables: Set<AnyCancellable> = []
authorizationManager.didChange
.print("authorizationManager.didChange sink")
.sink(receiveValue: {
dispatchPrecondition(
condition: .notOnQueue(authorizationManager.dispatchQueue)
)
print("WILL print authorizationManager")
// MARK: - Deadlock Occurs Here -
print(authorizationManager)
print("DID print authorizationManager")
})
.store(in: &cancellables)
let group = DispatchGroup()
let internalQueue = DispatchQueue(
label: "testDeadlock: internal queue"
)
let concurrentQueue = DispatchQueue(
label: "testDeadlock concurrent queue"
)
concurrentQueue.sync {
DispatchQueue.concurrentPerform(iterations: 20) { i in
for j in 0..<20 {
print("i: \(i); j: \(j)")
group.enter()
dispatchPrecondition(
condition: .notOnQueue(authorizationManager.dispatchQueue)
)
let cancellable = authorizationManager
.refreshTokens()
.sink { _ in
group.leave()
}
internalQueue.async {
cancellables.insert(cancellable)
}
}
}
}
print("waiting for tokens to be refreshed")
group.wait()
print("finished waiting")
}