Task execution behaviour inconsistency among different Xcode Versions

I've recently developed a networking package to implement a layer on top of URLSession to execute network requests.

While doing so, I wanted to make it easy for the users of this package to update their UI after receiving the result. (I was not so optimistic I could achieve this though) Like they don't have to specify their Task to run on main actor.

Then I tried annotating the networking request function with @MainActor to make it run and return its result with main actor. Which was fine because the subsequent calls within would wait on other async calls that are not run on main actor. So the method signature was like:

@MainActor
func fetch() async -> String {
    // some other async calls happen and I return String eventually
}

Then I tried to call this request method from a task as following:

Task {
    // here we are on a background thread
    let result = await networking.fetch()
    // after receiving the result the `Task` would continue on the main thread
    // and updating the UI via delegate would be fine, because the `fetch` method runs on Main actor
    delegate.didCompleteFetching(value: result)
}

So this was working, the Task would start on a background thread, and after receiving the result from networking with main actor, the Task would remain on the main thread so I could update the UI, without specifying that Task should run on MainActor or anything. Although it felt weird, because I was expecting the Task to switch back to its original thread or a background thread, I thought maybe it is intended to behave this way, and it was solving my problem and I proceeded with this approach.

All of this implementation was done using Xcode 13.3 though.

Now fast forward to today, and when I try the same package with Xcode 14.0, I get crash due to attempting to update UI on a non main thread. So the behaviour of the Task is now different than how it was before, if we look at the code snippet again with Xcode 14.0:

Task {
    // here we are on a background thread
    let result = await networking.fetch()
    // Now on Xcode 14.0, after receiving the result the `Task` will switch back to a background thread
    //and attempting to UI via delegate will result in crash
    delegate.didCompleteFetching(value: result)
}

Now the same code is crashing as I explained in comments in the code snippet above.

I see a few possibilities and some actions that I can take with regard to this:

1.) The behaviour I get on Xcode 13.3 was not intended, and although it helped me for what I needed, it was wrong in the first place, and now with Xcode 14.0 it is being fixed and I must update my code to specifically call networking method on a Task that runs on main actor, and remove @MainActor annotation from my networking methods.

2.) The behaviour I get on Xcode 13.3 was intended and correct, but now there is a problem with Xcode 14.0 and I should file a bug report, and keep my code as it is.

3.) Regardless of the behaviour of Task, I should update my code to remove @MainActor annotation from my networking method, and require callers to run on main actor if they need to update UI, because what I did in the first place was a misuse of the concurrency.

Also I created a small project here to better mimic a real scenario of a networking and UI updating:

Which will run fine on Xcode 13.3, 13.4 but will crash on Xcode 14.0

That’s the correct answer. Swift 5.5 and 5.6 implementation tends to reuse the executor after returning from an async function — this is an awkward and inconsistent behavior, and is likely to be misused (like what you did in the case). SE-0338 clarified the intended behavior and Swift 5.7 implemented it. You can read through the proposal text if you’re interested.

2 Likes

Thanks for your answer. I will have a read at the proposal. Since you mentioned 5.5 and 5.6 "tends to..." and the behaviour is inconsistent. Does this mean that there is a chance of a crash for apps built with Xcode 13.3 as well? Or can I assume that, my usage would be fine until I start building my app with Xcode 14.0 (Swift 5.7)?

I may be missing something (just skimmed the post), but I think what you actually want is none of the options you proposed, but instead to put @MainActor on the declaration of the didCompleteFetching method.

The rule for Swift Concurrency is instead of you telling your code where you want it to run at each call site, your code tells the system where it wants to run. So instead of a MainActor task, you just tag the methods you call, and it takes care of it for you.

While it was considered best practice to offer users control over queue usage in the old Dispatch world, it's neither necessary nor suggested to do the same thing in the world of Swift Concurrency. Allow users to complete work in whatever context they want. If they want a MainActor context they can easily provide one.

Thanks! @David_Smith @Jon_Shier that was my initial approach as well, but since this is an internal package, we thought it would be easier if the colleagues wouldn't have to write @MainActor every time they make a network request, since almost always they update the UI in the end. (One could argue that writing @MainActor is not much of a hassle every time you introduce a new feature/method and need to update UI after making a network request, which I would agree btw)

I will just update our usage and better align with the expected and intended Task behaviour :+1: