swiftasync-awaitcombinecontinuations

Swift: using combination of Combine and async/await results in Fatal error: SWIFT TASK CONTINUATION MISUSE tried to resume its continuation


I am trying to learn Combine and understand how I can make it work with the new async/await syntax. I have this code which consists of a view controller with a button and a table view, the button triggers a request to MapKit's MKLocalSearchCompleter with a random text, which then publishes the update to reload the view controller's table view, with the data fetched from the MKLocalSearchCompleter.

For some reason, the second time that I tap the button and withCheckedContinuation gets called again, I get this error:

Fatal error: SWIFT TASK CONTINUATION MISUSE: fillTableView(with:) tried to resume its continuation more than once, returning ["Recklinghausen", "Recquignies", "Recloses", "Recco", "Recy", "Reconvilier", "Rectorat de Paris", "Recke", "Recques-sur-Course", "Recto Versoi", "Rechberghausen", "Rechlin", "Reclinghem", "Reckange-sur-Mess", "Récourt"]!

I don't understand because I though that the continuation was correctly resuming after the first call. What is happening?

Here is the code:

import _Concurrency
import Combine
import MapKit
import UIKit

class ViewController: UIViewController {
    private var components: [String] = []
    // View Properties
    private let randomTexts = ["re", "ma", "so", "lem", "tap", "do", "rec"]
    // MapKit Properties
    private var searchCompleter = MKLocalSearchCompleter()
    // Combine Properties
    private var cancellables = Set<AnyCancellable>()
    private var publisher = PassthroughSubject<[String], Never>()
    
    @IBOutlet weak var tableView: UITableView!
    
    // Methods
    @IBAction func tapped() {
        guard let randomText = randomTexts.randomElement() else {
            return
        }
        searchCompleter.queryFragment = randomText
        fillTableView(with: randomText)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        searchCompleter.delegate = self
    }
    
    private func fillTableView(with randomElement: String) {
        Task {
            let autocompletedComponents = await withCheckedContinuation { continuation in
                publisher
                    .sink { continuation.resume(returning: $0) }
                    .store(in: &cancellables)
            }
            components = autocompletedComponents
            tableView.reloadData()
        }
    }
}

extension ViewController: MKLocalSearchCompleterDelegate {
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        publisher.send(
            completer.results.map { $0.title }
        )
    }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return components.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = components[indexPath.row]
        return cell
    }
}

Thank you for your help


Solution

  • A continuation must be resumed exactly once. You are retaining the continuation in the sink closure, and you have a constant publisher value, so the second time you hit the button, a new sink operation is created and the publisher is now sending its results to two continuations. You need to make the sink operation happen once only by unsubscribing it when it's done, or by nullifying a local reference to the continuation in the sink (untested example below):

    let autocompletedComponents = await withCheckedContinuation { continuation in
        var oneShot = Optional(continuation) 
        publisher
            .sink { 
                oneShot?.resume(returning: $0) 
                oneShot = nil
            }
            .store(in: &cancellables)
    }
    

    However, apart from learning purposes it's not clear what benefits you're adding by mixing Combine and structured concurrency in this example. Given that the nature of the local search completer is to deliver a continuously updating set of values as the user types, Combine seems like a better fit here. You could wrap the delegate calls with a continuation but you'd have the same problem, you must resume the continuation once and only once which probably means creating brand new delegates for each request.