Stumped by URLSession behaviour I don't understand...

I have an app that has been using the following code to down load audio files:

        if let url = URL(string: episode.fetchPath()) {
            var request = URLRequest(url: url)
            request.httpMethod = "get"
            let task = session.downloadTask(with: request)

And then the following completionHandler code:

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
                try FileManager.default.moveItem(at: location, to: localUrl)

In the spirit of modernization, I'm trying to update this code to use async await:

        var request = URLRequest(url: url)
            request.httpMethod = "get"
            let (data, response) = try await URLSession.shared.data(for: request)
            try data.write(to: localUrl, options: [.atomicWrite, .completeFileProtection])

Both these code paths use the same url value. Both return the same Data blobs (they return the same hash value) Unfortunately the second code path (using await) introduces a problem. When the audio is playing and the iPhone goes to sleep, after 15 seconds, the audio stops. This problem does not occur when running the first code (using the didFinish completion handler) Same data, stored in the same URL, but using different URLSession calls. I would like to use async/await and not have to experience the audio ending after just 15 seconds of the device screen being asleep. any guidance greatly appreciated.

Answered by DTS Engineer in 873065022

Background execution is tricky on iOS. I’ve collected together a bunch of links to useful resources in Background Tasks Resources. Specifically, make sure you read Testing and Debugging Code Running in the Background.

I would like to use async/await and not have to experience the audio ending after just 15 seconds of the device screen being asleep.

You’re wrangling two completely separate axes of background execution here:

  • Background audio
  • URLSession

Background audio is the most straightforward, at least conceptually: If your app supports background audio (UIBackgroundModes contains audio) and you start playing in the foreground, iOS will keep your app executing as it moves between the foreground and background as long as your app continues to play audio.

Note I can only help you with the conceptual side of this. The actual mechanics of audio playback are outside my area of expertise.

URLSession has two background execution models:

  • Background sessions
  • Standard sessions

In a background session, the system is allowed to suspend (and even terminate) your app when it’s in the background. When all the requests in the background session complete, the system will resume (or relaunch) the app in the background to process the results.

Standard sessions behave like all other networking on iOS: They work just fine as long as your app is running. If your app gets suspended, network connections simply tear.

So, these two axes are connected when you create an audio streaming app. If you’re streaming audio in the background, you can use a standard session for your networking because your audio playback prevents your app from being suspended.


Next, let’s talk about tasks:

  • Background sessions are primarily focused on download and upload tasks.
  • They do support data tasks but those tasks only work while your app is running. If your app gets suspended, any data tasks in a background session will fail. Thus, running data tasks in a background session is something you’d do only in very odd circumstances.
  • Background sessions don’t support the convenience APIs. This includes the completion handler APIs and the Swift async functions which are layered on top of them. You must use the delegate-based APIs.

In general, it’s hard to model a task in a background session as a Swift async function. That’s because of the terminate-and-relaunch behaviour I described above.

While a Swift async function is async under the covers, conceptually it’s synchronous. You call a function and it doesn’t return until it’s complete. Thus, any reasonable program builds up a call stack, where function A calls function B calls function C, and so on. This call stack isn’t stored on the thread stack, like it would be for a Swift synchronous function, but it still exists.

And, critically, it exists in memory. If the process gets terminated, the state of all those async functions goes away. There’s no way rebuild that state when the process is relaunched.

Given that, you can’t model a task in a background session as a Swift async function. Tab A just doesn’t fit into slot B.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

What type of session is this? A background session? Or a standard session?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

@DTS Engineer I think you're on to something... however I feel like your question is uncovering another wrinkle. The two code paths in my original question use different URLSessions. The version that is able to play the files (option1) uses:

        let config = URLSessionConfiguration.background(withIdentifier: "MySession")
        config.isDiscretionary = false
        config.sessionSendsLaunchEvents = true
        return URLSession(configuration: config, delegate: nil, delegateQueue: nil)

The version that is unable to play once the app is backgrounded (option2) uses: URLSession.shared

NOTE: In both cases the download has completed before I begin playback. In both cases I've been able to confirm I'm receiving identical complete blobs of data before playback begins.

Having said that ^, I still wanted to see what happens if I update option2 to use the background URLSession used in option1. When I update the URLSession code of option2 to match option1, the app throws: Terminating app due to uncaught exception 'NSGenericException', reason: 'Completion handler blocks are not supported in background sessions. Use a delegate instead.'

I understand that session.data(for: request) is a convenience func that uses a completionHandler under the hood. And I conceptually see how this approach may be problematic with async/await.

Might it be possible to use async/await to do inline downloading that can continue in the background? (I realize this might be a greedy thing to be asking for :-) )

(This question is somewhat prefaced on the assumption that somehow the URLSessionConfiguration may be having an effect on the playback behaviour of otherwise identical blobs of audio data. I say somewhat because I would very much prefer my async/await implementation to also support background downloading irrespective of the playback issue)

Background execution is tricky on iOS. I’ve collected together a bunch of links to useful resources in Background Tasks Resources. Specifically, make sure you read Testing and Debugging Code Running in the Background.

I would like to use async/await and not have to experience the audio ending after just 15 seconds of the device screen being asleep.

You’re wrangling two completely separate axes of background execution here:

  • Background audio
  • URLSession

Background audio is the most straightforward, at least conceptually: If your app supports background audio (UIBackgroundModes contains audio) and you start playing in the foreground, iOS will keep your app executing as it moves between the foreground and background as long as your app continues to play audio.

Note I can only help you with the conceptual side of this. The actual mechanics of audio playback are outside my area of expertise.

URLSession has two background execution models:

  • Background sessions
  • Standard sessions

In a background session, the system is allowed to suspend (and even terminate) your app when it’s in the background. When all the requests in the background session complete, the system will resume (or relaunch) the app in the background to process the results.

Standard sessions behave like all other networking on iOS: They work just fine as long as your app is running. If your app gets suspended, network connections simply tear.

So, these two axes are connected when you create an audio streaming app. If you’re streaming audio in the background, you can use a standard session for your networking because your audio playback prevents your app from being suspended.


Next, let’s talk about tasks:

  • Background sessions are primarily focused on download and upload tasks.
  • They do support data tasks but those tasks only work while your app is running. If your app gets suspended, any data tasks in a background session will fail. Thus, running data tasks in a background session is something you’d do only in very odd circumstances.
  • Background sessions don’t support the convenience APIs. This includes the completion handler APIs and the Swift async functions which are layered on top of them. You must use the delegate-based APIs.

In general, it’s hard to model a task in a background session as a Swift async function. That’s because of the terminate-and-relaunch behaviour I described above.

While a Swift async function is async under the covers, conceptually it’s synchronous. You call a function and it doesn’t return until it’s complete. Thus, any reasonable program builds up a call stack, where function A calls function B calls function C, and so on. This call stack isn’t stored on the thread stack, like it would be for a Swift synchronous function, but it still exists.

And, critically, it exists in memory. If the process gets terminated, the state of all those async functions goes away. There’s no way rebuild that state when the process is relaunched.

Given that, you can’t model a task in a background session as a Swift async function. Tab A just doesn’t fit into slot B.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thank you @DTS Engineer for the energy you put into helping us write better code, I really appreciate your commitment to this. I had a couple of mostly unrelated points/responses

  1. I want to give a bit of background to describe my original goals/intentions (just in case there is a better option I have not considered)
  2. Something interesting my 'research' uncovered

Background

I've created an app for fetching and listening to podcasts. It includes a scan function that:

  1. scans subscribed podcasts to see if there are known episodes it should download
  2. makes api calls to fetch podcast metadata to see if there are any 'new' episodes
  3. fetch these new episodes if we aren't currently storing too much new content (eg just because we have meta data for 10 episodes that are 2 hours each, don't download them all right away)

The code that does this work has multiple URLSession callbacks, state information and multiple error cases that need to be handled. The flow is a bit complex. I thought this functionality would be better implemented using a single code path with async/await. At this point, based on what I've learned here it feels like async/await won't meet my needs because I want them to continue if the app moves to the background. (if a scan starts while the app is in the foreground, I want the user to be able to move the app to the background and have the scan continue; and also continue playing audio; as you pointed out, two complete independent flavours of background task) So at this point, absent a third option I'm not yet aware of, I'm going to stick with my more complex completionHandler-based implementation of this scan function.

Something. I discovered

Out of curiosity, I modified my option1 implementation to use URLSession.shared instead of background-capable instance and..... option1 still works. I think this means my audio dropouts are not related to whether the URLSession is background able or not. If I had to guess, the only difference I see between the two options is the steps to put the data blob in a file. In option1, URLSession creates the file in a temporary location and my code merely moves that file to the required location. In option2, my code actually creates the file. My current assumption is that there is something in the way my code is writing the file that is causing the problem. But at this point, even if I can uncover the cause of the audio drop outs, I'll still be stuck as I don't see a way to use the data task, as they only work while the app is in the foreground.

Jumping in with some thoughts from the background execution side...

The code that does this work has multiple URLSession callbacks, state information, and multiple error cases that need to be handled. The flow is a bit complex. I thought this functionality would be better implemented using a single code path with async/await. At this point, based on what I've learned here, it feels like async/await won't meet my needs because I want them to continue if the app moves to the background.

No, I don’t think the async/await architecture itself is really a factor. Background execution operates at the process level, and, in general, a process that's "awake" works the same way a backgrounded process does. There are a few exceptions (notably, access to the GPU), but, in general, background execution issues are about keeping your app "awake", NOT what it can do once it's awake.

That's PARTICULARLY true of the "audio" background category. Expanding on what Quinn said here:

"Background audio is the most straightforward, at least conceptually: If your app supports background audio (UIBackgroundModes contains audio) and you start playing in the foreground, iOS will keep your app executing as it moves between the foreground and background as long as your app continues to play audio."

It's entirely possible for an app with the background category to keep itself awake in the background effectively "forever". That is, the ONLY reason your app will suspend is because higher priority audio interrupts it or it chooses to deactivate.

That leads to here:

My current assumption is that there is something in the way my code is writing the file that is causing the problem.

Yes, there is. I suspect at least one of the issues is the ".completeFileProtection" here:

try data.write(to: localUrl, options: [.atomicWrite, .completeFileProtection])

The "complete" protection level means "this file should only be accessible when the device is unlocked". That sounds like a simple idea; however, it's a huge Bug in Waiting™ for apps that run in the background. The problem here is that that the transition from "unlocked" to "locked" doesn't cleanly map to your app’s own lifecycle. That is, the user could be running your app and then:

  • Push the lock button and put their phone away... at which point your app will be sent to the background and the device will lock shortly after that (generally 10-15s).

OR

  • Send your app to the background and spend the next 3 hours browsing the web... at which point the device will NEVER lock.

In other words, the expected background access window of a file at protection level complete varies from "a few seconds" to "forever". Needless to say, that means "complete" isn't really all that useful if your app ever runs in the background. There may be other problems, but using complete here is going to cause serious problems. Try passing "completeFileProtectionUntilFirstUserAuthentication" and see if that helps.

Finally, as a general warning, if you've EVER used the "Data Protection Entitlement" in any app, I'd recommend reviewing this post and, in most cases, removing it entirely.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Stumped by URLSession behaviour I don't understand...
 
 
Q