I found a very easily reproducible crash involving Combine and Future. (And it leaks memory too.) I'm going to send it off as a complete project to Apple, but before I do, does anyone see anything obviously stupid I'm doing? (Xcode: 11.4.1).
(If you'd like a complete Xcode project: GitHub - davidbaraff/FutureCrash).
To explain the motivation, before Combine, we used a separate queue that would do a blocking HTTP call, off the main thread. I wrote new non-blocking API's using Combine, but wanted to keep our blocking API around by just using the new API and blocking on it. That's what the function blockTillCompletion
does. Anyway, if anyone could look and tell me if you see something dumb, I'd appreciate it:
extension Publisher {
public func blockTillCompletion() throws -> Output {
let semaphore = DispatchSemaphore(value: 0)
var result: Output?
var failure: Failure?
var cancellable: Cancellable? = self.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
failure = error
semaphore.signal()
case .finished:
()
}
}) { value in
result = value
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
cancellable = nil
guard let result = result else { throw failure! }
return result
}
}
Here's how to use it and get a crash:
public func pretendToQuery() -> Future<Data, Error> {
let future = Future<Data, Error> { promise in
// Change 0.0001 to 0.001 to decrease the odds of it crashing, and
// make it easier to watch the memory leak.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.0001) {
let result = Data(count: 1024)
promise(.success(result))
}
}
return future
}
If you simply start several concurrent threads going that do nothing but call pretendToQuery() .blockTillCompletion()
repeatedly, after a few seconds (and several thousand calls) you get a crash. If you watch carefully, it leaks memory as well.
Here's code to just launch them in parallel:
private let _workQueue = DispatchQueue(label: "com.deb.work", attributes: .concurrent)
private func perpetualWorker(index: Int) {
var ctr = 0
while true {
autoreleasepool {
let f = pretendToQuery()
let result = try! f.blockTillCompletion()
if result.isEmpty {
fatalError("Got back empty data")
}
ctr += 1
if ctr % 100 == 0 {
print("Worker [\(index)]: reached", ctr)
}
}
}
}
private let maxWorkers = 8
public func startWorkers() {
for i in 0..<8 {
_workQueue.async {
perpetualWorker(index: i)
}
}
}