Hallo allemaal, Leo hier. Today we will discuss some very common asynchronous Swift Task Continuation Problem.
The new Swift structured concurrency brings a whole new world of adventures and things that we can use to make our code more expressive and less prone to errors. Meanwhile, it also has a delightful syntax to write and read that makes your asynchronous code way easier to reason.
It is a really good new API to learn and worth your time.
One thing that the Swift engineering community thought when they were developing the new asynchronous API is that in some manner they should support a bridging way to connect your old closure-based APIs with the new async/await
world.
And that is exactly what they did, the Swift team created the Continuation API. This creates a suspension point in your code and that is exactly what you need to use the new async/await semantics.
We will discuss some problems that can happen if you don’t pay attention to this Continuation problem in Swift.
Transform Your Career with the iOS Lead Essentials — Black Friday Offer
This Black Friday, unlock over 40 hours of expert training, mentorship, and community support to secure your place among the best devs. Click for early access to this limited offer!
Let’s code! But first…
Painting of The Day
The paint I chose today is called Esau Selling His Birthright, an 1626 art piece made by Hendrick Jansz ter Brugghen. He was a painter at the start of the Dutch Golden Age painting and a leading member of the Dutch followers of Caravaggio–the so-called Utrecht Caravaggisti. Along with Gerrit van Hondhorst and Dirck van Baburen, Ter Brugghen was one of the most important Dutch painters to have been influenced by Caravaggio.
No references to Ter Brugghen written during his life have been identified. His father Jan Egbertsz ter Brugghen, originally from Overijssel, had moved to Utrecht, where he was appointed secretary to the Court of Utrecht by the Prince of Orange, William the Silent. He had been married to Sophia Dircx. In 1588 he became bailiff to the Provincial Council of Holland in The Hague, where Hendrick was born.
I chose this painting because it is a continuation of a very old Italian tradition called “chiaroscuro”, which is clear tonal contrasts that are often used to suggest the volume and modeling of the subjects depicted. Artists who are famed for the use of chiaroscuro include Leonardo da Vinci and Caravaggio. Got the continuation?
Task Continuation Problem
You have a Continuation using withCheckedContinuation in your code and you are having SWIFT TASK CONTINUATION MISUSE warnings in the log.
First, let’s start with the code setup. Start a new project. And create a new File called DefaultNameService
.
In that file, copy and paste the code below:
protocol NameService { func getNames(completion: @escaping (Result<[String],Error>) -> ()) func getNames() async -> [String] } struct DefaultNameService: NameService { func getNames() async -> [String] { await withCheckedContinuation { continuation in getNames { result in switch result { case .success(let names): continuation.resume(returning: names) case .failure: continuation.resume(returning: [String]()) } } } } func getNames(completion: @escaping (Result<[String],Error>) -> ()) { if Bool.random() { // this is just to add some randomness to the completion print("Did not run the completion closure. Sorry....") return } URLSession.shared.dataTask(with: URL(string: "https://reqres.in/api/users")!) { data, response, error in guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else { return completion(.failure(ServiceError.httpError)) } if let error = error { return completion(.failure(error)) } if let usernameList = try? JSONDecoder().decode(UserDataPageModel.self, from: data ?? Data()) { completion(.success(usernameList.data.map(\.first_name))) } else { completion(.failure(ServiceError.parsingError)) } }.resume() } } enum ServiceError: Error { case httpError case parsingError } struct UserDataPageModel: Codable { let page: Int let data: [UserModel] } struct UserModel: Codable { let first_name: String }
As you noticed in the function getName(completion:)
the first thing is that we are putting a condition to run or not, in this case, a random boolean. This was intended to simulate any condition that your completion handler could not be called.
Today we will not dive into the withCheckedContinuation
or any other async/await syntax. You only need to know that using withCheckedContinuation
is one way to bridge new async code to old closure-based async code.
When you are using Continuation in Swift you have to be sure that it will call the continuation.resume
exactly one time. But in our case and maybe in your case something can happen and the completion closure could not be called and the continuation is lost forever, aka you have a leaked continuation there.
Now you will call this code from your ViewController
, viewDidLoad
:
final class ViewController: UIViewController { private let nameService: NameService = DefaultNameService() private var nameList = [String]() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemRed configureCollectionView() Task { let nameListResult = await nameService.getNames() nameList = nameListResult } } }
When you run the code above you can randomly get this message:
SWIFT TASK CONTINUATION MISUSE: getNames() leaked its continuation!
2022-07-26 07:43:41.072135+0200 CollectionViewFromScratch[66008:34761293] SWIFT TASK CONTINUATION MISUSE: getNames() leaked its continuation!
In our example, this is the same thing that says:
“Hey developer, this is a gentle reminder that your continuation is not being called exactly once, it is getting lost in space and time forever.”
Today we will check the most common problems using continuation and how to avoid them.
Transform Your Career with the iOS Lead Essentials — Black Friday Offer
This Black Friday, unlock over 40 hours of expert training, mentorship, and community support to secure your place among the best devs. Click for early access to this limited offer!
Continuation Problem 1 – Outside the async closure, not all code paths call the Continuation
This is our case here. We had a condition that randomly could drop the call, and that way we never executed the network call that way not even giving the chance to run the completion closure.
Check below to visualize the mistake:
func getNames(completion: @escaping (Result<[String],Error>) -> ()) { if Bool.random() { print("Did not run the completion closure. Sorry....") // precondition not met so we don't even need to run the URLSession call return } URLSession.shared.dataTask(with: URL(string: "https://reqres.in/api/users")!) { data, response, error in // old school network call async code here calling the completion closure }.resume() }
In your code, you could be checking if the user is passing the correct data to the endpoint, or if the endpoint is already loading something and being busy with other requests.
The main takeaway from this common mistake is to always pay attention to things that can happen outside your old-school network calls, and remember to call the completion closure from any other code path.
Continuation Problem 2 – Inside the async closure, not all code paths call the Continuation
Now that you have already checked all the code paths outside the old async code, it is time to check the code path inside it. That is another very common problem. Although it occurs because developers are usually paying more attention to what happens inside the async closure code.
Check the code below:
func getNames(completion: @escaping (Result<[String],Error>) -> ()) { URLSession.shared.dataTask(with: URL(string: "https://reqres.in/api/users")!) { data, response, error in guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else { return // the problem is here } if let error = error { return completion(.failure(error)) } if let usernameList = try? JSONDecoder().decode(UserDataPageModel.self, from: data ?? Data()) { completion(.success(usernameList.data.map(\.first_name))) } else { completion(.failure(ServiceError.parsingError)) } }.resume() }
Imagine that your server returns a status code that is not in the 2xx range. In the code above it will never call the completion closure. This way it would never call the continuation and we would have leaked here too.
The takeaway here is to pay attention to all paths inside your old async code and try to call.
Continuation Problem 3 – Storing the closure/data task/continuation to run in the future, and never run it.
Imagine that we have a service that would store our completion closure somehow and after a while, if no other call comes it would process. Similar to throttling and debouncing do. I know they are different and we could explore the differences in another post.
Check the code below for our new Timer Service:
class TimerDefaultNameService: NameService { private weak var dataTask: URLSessionDataTask? private var timer: Timer? func getNames() async -> [String] { await withCheckedContinuation { continuation in getNames { result in switch result { case .success(let names): continuation.resume(returning: names) case .failure: continuation.resume(returning: [String]()) } } } } func getNames(completion: @escaping (Result<[String],Error>) -> ()) { timer?.invalidate() timer = nil dataTask = URLSession.shared.dataTask(with: URL(string: "https://reqres.in/api/users")!) { data, response, error in guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else { return } if let error = error { return completion(.failure(error)) } if let usernameList = try? JSONDecoder().decode(UserDataPageModel.self, from: data ?? Data()) { completion(.success(usernameList.data.map(\.first_name))) } else { completion(.failure(ServiceError.parsingError)) } } Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in self?.dataTask?.resume() } } }
As you can see, now we are running our task 1 second after the getNames
got it. This looks harmless at first because it doesn’t retain any memory, but as we are using Continuation API the continuation.resume
would never be called in the case that the timer is canceled.
Take a special caution on functions like this. Avoid storing the continuation, well another day I was reading a tweet saying:
Every API that starts with the term “with…” the result and its contents should not be stored.
And yes I think is wise advice. Be careful with your continuation and closure people.
To schedule work and use continuation you should try to finish all of your open continuations, you would need some sort of queue or cache to do so. I could hack a quick workaround solution ( this is not a recommendation ), but maybe a starting point.
class TimerDefaultNameService: NameService { private weak var dataTask: URLSessionDataTask? private var timer: Timer? private var closureCache = [(Result<[String],Error>) -> ()]() func getNames() async -> [String] { await withCheckedContinuation { continuation in getNames { result in switch result { case .success(let names): continuation.resume(returning: names) case .failure: continuation.resume(returning: [String]()) } } } } func getNames(completion: @escaping (Result<[String],Error>) -> ()) { closureCache.append(completion) timer?.invalidate() timer = nil dataTask = URLSession.shared.dataTask(with: URL(string: "https://reqres.in/api/users")!) { data, response, error in guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else { return } if let error = error { return completion(.failure(error)) } if let usernameList = try? JSONDecoder().decode(UserDataPageModel.self, from: data ?? Data()) { self.runSuccess(names: usernameList.data.map(\.first_name)) } else { self.runFailure(error: ServiceError.parsingError) } } Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in self?.dataTask?.resume() } } func runSuccess(names: [String]) { closureCache.dropLast().forEach {$0(.failure(ServiceError.notRun))} let last = closureCache.last last?(.success(names)) closureCache.removeAll() } func runFailure(error: Error) { closureCache.dropLast().forEach {$0(.failure(ServiceError.notRun))} let last = closureCache.last last?(.failure(error)) closureCache.removeAll() } }
You should definitely use an actor or a more elaborated locking technique for the closureCache above, to avoid data races.
And we are done!
Final Tips
If possible, don’t use the Continuation API. Try to use the native async/await
function already embedded in the language.
For example instead of using the old dataTask
with closure completion handler:
URLSession.shared.dataTask(with: URL(string: "https://reqres.in/api/users")! { [...] }
Try to use the new async dataTask
:
let users = try await URLSession.shared.data(from: URL(string: "https://reqres.in/api/users")!)
Summary – Swift Task Continuation Problem
Today we studied some problems that can happen when using Continuation API. There are three main problems that you can face when using network calls. The first one is not calling continuation outside the network call, the second one is not calling continuation within the network call, and finally do not call the continuation because you stored it somehow and it was never called.
In any case, now you know what to pay attention to using the new asynchronous Swift’s Continuation API.
That’s all my people, today we finished the Architecture and iOS Tooling article closing the series about the beginning of iOS development. I hope you liked reading this article as much as I enjoyed writing it. If you want to support this blog you can Buy Me a Coffee or leave a comment saying hello. You can also sponsor posts and I’m open to freelance writing!
You can reach me on LinkedIn or Twitter and send me an e-mail through the contact page.
Thanks for reading and… That’s all folks.
Credits: