iosswiftmultithreadingasync-await

SWIFT TASK CONTINUATION MISUSE: - method leaked its continuation! - Not blocking main thread?


I have to use async/await with withCheckedThrowingContinuation to get a result from an external lib (I cannot modify this lib). I call this from a UIViewController (which means the Task will be on the MainActor if I understood correctly).

But the call to the external lib sometimes prints SWIFT TASK CONTINUATION MISUSE: leaked its continuation, which means its completion will not be called if I understood correctly, and the code after this is not executed. It happens especially when I spam the button.

My first question is, why can I still use the main thread if the code after is never executed ? Shouldn't the main thread be blocked there waiting for it's completion ?

My second question is, how does the system know that the completion will never be called ? Does it track if the completion is retained in the external lib at runtime, if the owner of the reference dies, it raises this leak warning ?

My third question is, could this lead to a crash ? Or the system just cancel the Task and I shouldn't worry about it ?

And my last question is, what can I do if I cannot modify the external lib ?

Here's a sample of my code :

class MyViewController: UIViewController {

    func onButtonTap() {
        Task {
             do {
                 try await callExternalLib() // <--- This can be called after the leak
             }
        }
    }
    
    func callExternalLib() async throws {

         print("Main thread") // <--- This is always called on the main thread

         try await withCheckedThrowingContinuation { continuation in
              myExternalLib.doSomething {
                   continuation.resume()
              }, { error in
                   continuation.resume(throwing: error)
              }
         }

         print("Here thread is unlocked") // <--- This is never called when it leaks
    }
}


Solution

  • My first question is, why can I still use the main thread if the code after is never executed ? Shouldn't the main thread be blocked there waiting for it's completion ?

    That's not how Swift Concurrency works. The whole concept / idea behind Swift Concurrency is that the current thread is not blocked by an await call.

    To put it briefly and very simply, you can imagine an asynchronous operation as a piece of work that is processed internally on threads. However, while waiting for an operation, other operations can be executed because you're just creating a suspension point. The whole management and processing of async operations is handled internally by Swift.

    In general, with Swift Concurrency you should refrain from thinking in terms of “threads”, these are managed internally and the thread on which an operation is executed is deliberately not visible to the outside world.

    In fact, with Swift Concurrency you are not even allowed to block threads without further ado, but that's another topic.

    If you want to learn more details about async/await and the concepts implemented by Swift, I recommend reading SE-0296 or watching one of the many WWDC videos Apple has published on this topic.

    My second question is, how does the system know that the completion will never be called ? Does it track if the completion is retained in the external lib at runtime, if the owner of the reference dies, it raises this leak warning ?

    See the official documentation:

    Missing to invoke it (eventually) will cause the calling task to remain suspended indefinitely which will result in the task “hanging” as well as being leaked with no possibility to destroy it.

    The checked continuation offers detection of mis-use, and dropping the last reference to it, without having resumed it will trigger a warning. Resuming a continuation twice is also diagnosed and will cause a crash.

    For the rest of your questions, I assume that you have shown us all the relevant parts of the code.

    My third question is, could this lead to a crash ? Or the system just cancel the Task and I shouldn't worry about it ?

    Only multiple calls to a continuation would lead to a crash (see my previous answer). However, you should definitely make sure that the continuation is called, otherwise you will create a suspension point that will never be resolved. Think of it like an operation that is never completed and thus causes a leak.

    And my last question is, what can I do if I cannot modify the external lib ?

    According to the code you have shown us, there is actually only one possibility:

    Calling doSomething multiple times causes calls to the same method that are still running to be canceled internally by the library and therefore the completion closures are never called.

    You should therefore check the documentation of doSomething to see what it says about multiple calls and cancelations.

    In terms of what you could do if the library doesn't give you a way to detect cancelations:

    1. Prevent multiple calls of the method. This is probably the simplest solution, you could, for example, simply deactivate the button while the library method is still running.
    2. If you want to deliberately allow multiple calls, you must detect these cases and complete any unfulfilled continuations.

    Here is a very simple code example that should demonstrate how you can solve the problem for this case (2):

    private var pendingContinuation: (UUID, CheckedContinuation<Void, any Error>)?
    
    func callExternalLib() async throws {
        if let (_, continuation) = pendingContinuation {
            self.pendingContinuation = nil
            continuation.resume(throwing: CancellationError())
        }
    
        try await withCheckedThrowingContinuation { continuation in
            let continuationID = UUID()
            pendingContinuation = (continuationID, continuation)
    
            myExternalLib.doSomething {
                Task { @MainActor in
                    if let (id, continuation) = self.pendingContinuation, id == continuationID {
                        self.pendingContinuation = nil
                        continuation.resume()
                    }
                }
            } error: { error in
                Task { @MainActor in
                    if let (id, continuation) = self.pendingContinuation, id == continuationID {
                        self.pendingContinuation = nil
                        continuation.resume(throwing: error)
                    }
                }
            }
        }
    }
    

    Note that this solution assumes that there are no other scenarios in which doSomething never calls its completion handlers.