Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SWIFT TASK CONTINUATION MISUSE: leaked its continuation - for delegate?

I'm trying to extend my class with async/await capabilities, but at run-time there is an error in the console:

SWIFT TASK CONTINUATION MISUSE: query(_:) leaked its continuation!

Below is the class I'm trying to add the continuation to which uses a delegate:

class LocalSearch: NSObject, MKLocalSearchCompleterDelegate {
    private let completer: MKLocalSearchCompleter
    private var completionContinuation: CheckedContinuation<[MKLocalSearchCompletion], Error>?

    init() {
        completer = MKLocalSearchCompleter()
        super.init()
        completer.delegate = self
    }

    func query(_ value: String) async throws -> [MKLocalSearchCompletion] {
        try await withCheckedThrowingContinuation { continuation in
            completionContinuation = continuation

            guard !value.isEmpty else {
                completionContinuation?.resume(returning: [])
                completionContinuation = nil
                return
            }

            completer.queryFragment = value
        }
    }

    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        completionContinuation?.resume(returning: completer.results)
        completionContinuation = nil
    }

    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        completionContinuation?.resume(throwing: error)
        completionContinuation = nil
    }
}

This is how I use it:

let localSearch = LocalSearch()

do {
    let results = try await localSearch.query("toront")
    print(results)
} catch {
    print(error)
}

What am I doing wrong or is there a better way to achieve this?

like image 358
TruMan1 Avatar asked Jun 26 '21 18:06

TruMan1


1 Answers

This message appears if a continuation you created via withCheckedContinuation, or withCheckedThrowingContinuation doesn't report success or failure before being discarded. This is will lead to resource leaking:

Resuming from a continuation more than once is undefined behavior. Never resuming leaves the task in a suspended state indefinitely, and leaks any associated resources. CheckedContinuation logs a message if either of these invariants is violated.

Excerpt taken from the documentation for CheckedContinuation (emphasis mine).

Here are possible causes for this to happen:

  1. not all code paths resume the continuation, e.g. there is an if/guard/case that exits the scope without instructing the continuation to report success/failure
class Searcher {

    func search(for query: String) async throws -> [String] {
        await withCheckedContinuation { continuation in
            someFunctionCall(withCompletion: { [weak self] in
                guard let `self` = self else {
                    // if `result` doesn't have the expected value, the continuation
                    // will never report completion
                    return    
                }
                continuation.resume(returning: something)
            })
        }
    }
}
  1. an "old"-style async function doesn't call the completion closure on all paths; this is a less obvious reason, and sometimes a harder to debug one:
class Searcher {
    private let internalSearcher = InternalSearcher()

    func search(for query: String) async throws -> [String] {
        await withCheckedContinuation { continuation in
            internalSearcher.search(query: query) { result in
                // everything fine here
                continuation.resume(returning: result)
            }
        }
    }
}

class InternalSearcher {

   func search(query: String, completion: @escaping ([String]) -> Void {
        guard !query.isEmpty else {
            return
            // legit precondition check, however in this case,
            // the completion is not called, meaning that the
            // upstream function call will imediately discard
            // the continuation, without instructing it to report completion
        }

        // perform the actual search, report the results
    }
}
  1. the continuation is stored as a property when a function is called; this means that if a second function call happens while the first one is in progress, then the first completion will be overwritten, meaning it will never report completion:
class Searcher {
    var continuation: CheckedContinuation<[String], Error>?

    func search(for query: String) async throws -> [String] {
        try await withCheckedTrowingContinuation { continuation in
            // note how a second call to `search` will overwrite the
            // previous continuation, in case the delegate method was
            // not yet called
            self.continuation = continuation
           
            // trigger the searching mechanism
        }
    }

    func delegateMethod(results: [String]) {
        self.continuation.resume(returning: results)
        self.continuation = nil
    }
}

#1 and #2 usually happen when dealing with functions that accept completion callbacks, while #3 usually happens when dealing with delegate methods, since in that case, we need to store the continuation somewhere outside the async function scope, in order to access it from the delegate methods.

Bottom line - try to make sure that a continuation reports completion on all possible code paths, otherwise, the continuation will indefinitely block the async call, leading to the task associated with that async call leaking its associated resources.


In your case, what likely happened is that a second query() call occurred before the first call had a chance to finish.

And in that case, the first continuation got discarded without reporting completion, meaning the first caller never continued the execution after the try await query() call, and this is not ok at all.

The following piece of code needs to be fixed, in order not to overwrite a pending continuation:

func query(_ value: String) async throws -> [MKLocalSearchCompletion] {
    try await withCheckedThrowingContinuation { continuation in
        completionContinuation = continuation

One quick solution would be to store an array of continuations, resume all continuations in the delegate methods, and clear the array afterward. Also, in your specific case, you could simply extract the validation out of the continuation code, as you are allowed to synchronously return/throw, even when in an async function:

func query(_ value: String) async throws -> [MKLocalSearchCompletion] {
    guard !value.isEmpty else {        
        return []
    }

    return try await withCheckedThrowingContinuation { continuation in
        continuations.append(continuation)
        completer.queryFragment = value
    }
}

func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
    continuations.forEach { $0.resume(returning: completer.results) }
    continuations.removeAll() 
}

func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
    continuations.forEach { $0.resume(throwing: error) }
    continuations.removeAll() 
}

I'd also strongly recommend converting your class to an actor, in order to avoid data races, regardless if you store one continuation, like now, or you use an array. The reason is that the continuation property is consumed from multiple threads and at some point you might end up with two threads concurrently accessing/writing the property.

like image 166
Cristik Avatar answered Nov 13 '22 00:11

Cristik