skip to Main Content

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
    }
}

2

Answers


  1. 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.

    Login or Signup to reply.
  2. You said:

    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.

    The library apparently does not handle concurrent requests.

    This will happen if the library saves your completion-handler closures in properties, which are replaced with new closures when you call it concurrently. This is a mistake on the part of the library and it should either (a) not permit concurrent requests, immediately calling the error completion hander if one attempts to do so; or (b) be modified to support concurrent requests.

    I would advise opening a ticket with he provider of that library. But until it is remedied, it is your responsibility to prevent concurrent requests. There are a variety of approaches:

    1. The easiest way would be to disable the button:

      func onButtonTap(_ button: UIButton) {
          button.isEnabled = false
      
          Task {
              defer { button.isEnabled = true }
      
              do {
                  try await callExternalLib()
              } catch {
                  …
              }
          }
      }
      
    2. If you quickly tap the button, say, five times and you really want the external library to run its routine five times, then you would want to make your tasks to run sequentially. The easiest way to do this would be an AsyncChannel from the Swift Async Algorithms package.

      So, you’d create a channel:

      let channel = AsyncChannel<Void>()
      

      You would monitor that channel:

      for try await _ in channel {
          try await callExternalLib()
      }
      

      And then, on a button tap, you would send to your channel:

      func onButtonTap(_ button: UIButton) {
          Task { await self.channel.send(()) }
      }
      

      So, a full MRE of this pattern would be:

      import UIKit
      import AsyncAlgorithms
      
      class ViewController: UIViewController {
          var monitorTask: Task<Void, any Error>?
          let externalLibrary = ExternalLibrary()
          let channel = AsyncChannel<Void>()
      
          override func viewDidAppear(_ animated: Bool) {
              super.viewDidAppear(animated)
      
              startMonitoring()
          }
      
          override func viewDidDisappear(_ animated: Bool) {
              super.viewDidDisappear(animated)
      
              stopMonitoring()
          }
      
          @IBAction func onButtonTap(_ button: UIButton) {
              Task { await self.channel.send(()) }
          }
      }
      
      // MARK: - Private implementation
      
      private extension ViewController {
          func startMonitoring() {
              guard monitorTask == nil else { return } // if accidentally called when already running, don’t start again, losing reference to old task
      
              monitorTask = Task {
                  for try await _ in channel {
                      try await callExternalLib()
                  }
              }
          }
      
          func stopMonitoring() {
              monitorTask?.cancel()
              monitorTask = nil
          }
      
          func callExternalLib() async throws {
              try await withCheckedThrowingContinuation { continuation in
                  externalLibrary.doSomething {
                      continuation.resume()
                  } onFailure: { error in
                      continuation.resume(throwing: error)
                  }
              }
          }
      }
      
      // MARK: - ExternalLibrary
      
      class ExternalLibrary {
          private var successClosure: (() -> Void)?
          private var failureClosure: ((any Error) -> Void)?
      
          func doSomething(onSuccess: @escaping () -> Void, onFailure: @escaping (any Error) -> Void) {
              successClosure = onSuccess
              failureClosure = onFailure
      
              DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [self] in
                  successClosure?()
                  successClosure = nil
                  failureClosure = nil
              }
          }
      }
      

      The only interesting thing to note here is that you do not want to rely upon deinit to cancel your asynchronous sequence, but rather start the monitoring of the channel when the view appears and cancel it when the view is ultimately dismissed.

    3. The third approach would be to cancel the previous doSomething before submitting another. When wrapping the completion-handler-closure-based call in a withCheckedThrowingContinuation, you’d also wrap that in a withTaskCancellationHandler. E.g.,

      class ViewController: UIViewController {
          var externalLibraryTask: Task<Void, any Error>?
          let externalLibrary = ExternalLibrary()
      
          @IBAction func onButtonTap(_ button: UIButton) {
              externalLibraryTask?.cancel()
      
              externalLibraryTask = Task {
                  try await self.callExternalLib()
              }
          }
      }
      
      // MARK: - Private implementation
      
      private extension ViewController {
          func callExternalLib() async throws {
              try await withTaskCancellationHandler {
                  try await withCheckedThrowingContinuation { continuation in
                      externalLibrary.doSomething {
                          continuation.resume()
                      } onFailure: { error in
                          continuation.resume(throwing: error)
                      }
                  }
              } onCancel: {
                  …
              }
          }
      }
      

      But, this pattern only works if the external library supports cancellation at all. I might wager that if they failed to handle something as basic as concurrent requests, they probably have not contemplated cancellation support, either. We simply cannot comment further without more details about this external library.

      But, I include this for the sake of completeness.


    To answer your follow-up questions:

    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 ?

    No, an await does not block an actor. It suspends the current function’s execution, but frees the actor to carry on and do other work. Actors are “reentrant”. See Actor Reentrancy in SE-0306.

    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?

    When the last reference to the continuation is removed (i.e., the third party library releases those closures), the system verifies whether the continuation has been called or not. If not, you get this 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 ?

    If you used the “unsafe” rendition, withUnsafe[Throwing]Continuation it would crash. It will not crash when using the “checked” rendition.

    But that does not mean you should not fix it. See SE-0300, which says:

    Instead of leading to undefined behavior, CheckedContinuation will instead trap if the program attempts to resume the continuation multiple times. CheckedContinuation will also log a warning if the continuation is discarded without ever resuming the task, which leaves the task stuck in its suspended state, leaking any resources it holds.

    Bottom line, you want to heed the warning and resolve the issue.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search