Using cooperative cancellation in `expirationHandler` of `beginBackgroundTask(...)`

Let's say I have a Task that I want to extend into the background with beginBackgroundTask(expirationHandler:). Furthermore, I'd like to leverage cooperative cancelation of subtasks when responding to the expiration handler. Unfortunately, the expirationHandler: closure parameter is not async, so I'm unable to do something like:

actor MyTaskManagerOne {
    var backgroundID = UIBackgroundTaskIdentifier.invalid

    func start() {
        Task {
            let doTheWorkTask = Task {
                await self.doTheWork()
            }

            backgroundID = await UIApplication.shared.beginBackgroundTask {
                doTheWorkTask.cancel()

                // next line: compile error, since not an async context
                await doTheWorkTask.value // ensure work finishes up

                // next line: generates MainActor compilation warnings despite docs allowing it
                UIApplication.shared.endBackgroundTask(self.backgroundID)
            }
            await doTheWorkTask.value
        }
    }

    func doTheWork() async {}
}

So instead, I think I have to do something like this. It, however, generates runtime warnings, since I'm not directly calling endBackgroundTask(_:) at the end of the expirationHandler:

actor MyTaskManagerTwo {
    var backgroundID = UIBackgroundTaskIdentifier.invalid

    func start() {
        Task {
            let doTheWorkTask = Task {
                await self.doTheWork()
            }

            backgroundID = await UIApplication.shared.beginBackgroundTask {
                doTheWorkTask.cancel()

                // 1. not calling endBackgroundTask here generates runtime warnings
            }
            await doTheWorkTask.value

            // 2. even though endBackgroundTask gets called
            // here (as long as my cooperative cancellation
            // implementations abort quickly in `doTheWork()`)
            await UIApplication.shared.endBackgroundTask(self.backgroundID)
        }
    }

    func doTheWork() async {}
}

As best I can tell, the MyTaskManagerTwo actor works and does not cause a watchdog termination (as long as cancellation is sufficiently fast). It is, however, producing the following runtime warning:

Background task still not ended after expiration handlers were called: <_UIBackgroundTaskInfo: 0x302753840>: taskID = 2, taskName = Called by libswift_Concurrency.dylib, from <redacted>, creationTime = 9674 (elapsed = 28). This app will likely be terminated by the system. Call UIApplication.endBackgroundTask(_:) to avoid this.

Is the runtime warning ok to ignore in this case?

Answered by DTS Engineer in 795492022

Basically, the kind of architecture you're trying to build here doesn't really work. Off the main thread, it's ENTIRELY possible that your task will start AFTER the system has already started expiring background tasks, which means you end up crashing because you failed to end the background task you just started.

More broadly, this kind of very fine grained task management makes it much harder to actual manage the work your app is doing. In practice, what typically ends up happening is something like this:

  1. Your app is scheduling a larger collection of these small tasks and it works best if they "all" get done.

  2. In your ininitial implementation, some other part/component of your app has it's own active background task. That task is what ACTUALLY keeps your app awake between every small task, so the work "all" gets done. Everything appears to work great, so you move on.

  3. At some later point, the component from #2 changes, shortening or removing the background task. Suddenly your app starts suspending in the middle of the work that was previously finishing, even though nothing changed in the task code.

The right approach here is to manage your background task at a much higher level- not "what is the specific code I need to execute", but "what is the work may app is awake to do and have I finished all of that work".

On the specific warning here:

Background task still not ended after expiration handlers were called: <_UIBackgroundTaskInfo: 0x302753840>: taskID = 2, taskName = Called by libswift_Concurrency.dylib, from <redacted>, creationTime = 9674 (elapsed = 28). This app will likely be terminated by the system. Call UIApplication.endBackgroundTask(_:) to avoid this.

Is the runtime warning ok to ignore in this case?

Yes, but only under very specific circumstances. IF your app called "beginBackgroundTask" before task expiration time started, then it has ~10s call "endBackgroundTask" once expiration time starts.

If you KNOW your work it "short enough" AND you also know you're in a "safe" app state, then you can call "beginBackgroundTask", fire off the work, then call endBackgroundTask when the work is done.

In concrete terms, if you're saving a small amount of data in "applicationDidEnterBackground" the approach above may make more sense than trying to implement some kind of cancel. The save should finish LONG before task expiration and if SOMETHING interferes then a crash for failing to expire is probably better than expiring in the middle of the work you can't really cancel.

However, the key here is still that you need to call "beginBackgroundTask" in that safe context (main thread, before anything expires).

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Basically, the kind of architecture you're trying to build here doesn't really work. Off the main thread, it's ENTIRELY possible that your task will start AFTER the system has already started expiring background tasks, which means you end up crashing because you failed to end the background task you just started.

More broadly, this kind of very fine grained task management makes it much harder to actual manage the work your app is doing. In practice, what typically ends up happening is something like this:

  1. Your app is scheduling a larger collection of these small tasks and it works best if they "all" get done.

  2. In your ininitial implementation, some other part/component of your app has it's own active background task. That task is what ACTUALLY keeps your app awake between every small task, so the work "all" gets done. Everything appears to work great, so you move on.

  3. At some later point, the component from #2 changes, shortening or removing the background task. Suddenly your app starts suspending in the middle of the work that was previously finishing, even though nothing changed in the task code.

The right approach here is to manage your background task at a much higher level- not "what is the specific code I need to execute", but "what is the work may app is awake to do and have I finished all of that work".

On the specific warning here:

Background task still not ended after expiration handlers were called: <_UIBackgroundTaskInfo: 0x302753840>: taskID = 2, taskName = Called by libswift_Concurrency.dylib, from <redacted>, creationTime = 9674 (elapsed = 28). This app will likely be terminated by the system. Call UIApplication.endBackgroundTask(_:) to avoid this.

Is the runtime warning ok to ignore in this case?

Yes, but only under very specific circumstances. IF your app called "beginBackgroundTask" before task expiration time started, then it has ~10s call "endBackgroundTask" once expiration time starts.

If you KNOW your work it "short enough" AND you also know you're in a "safe" app state, then you can call "beginBackgroundTask", fire off the work, then call endBackgroundTask when the work is done.

In concrete terms, if you're saving a small amount of data in "applicationDidEnterBackground" the approach above may make more sense than trying to implement some kind of cancel. The save should finish LONG before task expiration and if SOMETHING interferes then a crash for failing to expire is probably better than expiring in the middle of the work you can't really cancel.

However, the key here is still that you need to call "beginBackgroundTask" in that safe context (main thread, before anything expires).

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Thanks for the reply, Kevin!

I think I didn't make my use case clear enough, though.

Off the main thread, it's ENTIRELY possible that your task will start AFTER the system has already started expiring background tasks

In all situations, I'm calling MyTaskManager*.start() during normal app operations, and in the happy path, doTheWork() actually finishes before the app is ever backgrounded. I am not calling start() in response to applicationDidEnterBackground or anything like that. I'm calling it in response to, say, a button tap.

The doTheWork() function performs some laggy network back-and-forth, so there's a nontrivial likelihood of:

  • the parent Task is started while the app is in the foreground,
  • the parent Task (nearly) immediately calls beginBackgroundTask,
  • a few seconds later, the user backgrounds the app,
  • the child Task continues to attempt to finish its work in the allotted time extension

And if it takes too long, then the expiration handler gets called, and I give up on the Task by canceling it. At some point (very quickly) later, the child Task finishes early via cooperative cancellation, and the parent Task calls endBackgroundTask(_:)

And I hope that the user doesn't background the app so quickly next time, thereby giving it a better chance to finish while the app is still foregrounded.

Thanks for the reply, Kevin! I think I didn't make my use case clear enough, though.

Off the main thread, it's ENTIRELY possible that your task will start AFTER the system has already started expiring background tasks

In all situations, I'm calling MyTaskManager*.start() during normal app operations, and in the happy path, doTheWork() actually finishes before the app is ever backgrounded. I am not calling start() in response to applicationDidEnterBackground or anything like that. I'm calling it in response to, say, a button tap.

That's fine, but that's actually exactly how this kind of code tends to create problems. If it simply "didn't work" that would actually make these issue much simpler. The code would break, you'd fix it, everyone moves on. The problem here is that incorrect logic OFTEN does work just fine, typically due to exactly the kind of internal logic you're talking about. The problem is that it will either fail so rarely that you won't notice the issue or, even more likely, it won't fail until some distant point in the future long after you've forgot all about our conversation. Even worse, the actual failure it creates is almost NEVER at the task itself or (quite often) anything remotely related to that task.

That's what makes this API scary. It's not that it's failure prone or generates a large number problems, it's that when something does wrong it's going to create exactly the kinds of bugs that are most difficult to debug.

The doTheWork() function performs some laggy network back-and-forth, so there's a nontrivial likelihood of:

the parent Task is started while the app is in the foreground,

the parent Task (nearly) immediately calls beginBackgroundTask,

a few seconds later, the user backgrounds the app,

the child Task continues to attempt to finish its work in the allotted time extension And if it takes too long, then the expiration handler gets called, and I give up on the Task by canceling it. At some point (very quickly) later, the child Task finishes early via cooperative cancellation, and the parent Task calls endBackgroundTask(_:)

Here is that way I would approach this, sketched out in vague terms because I haven't thought through the full implementation.

  1. There is a background task which begins before the parent task is created and is specifically started on the main thread. ALWAYS start background tasks on the main thread, no excuses, not exceptions. Change your architecture to make this happen, don't bend the rule.

  2. Use a TaskGroup (or "something") to track all bending work/Task's. This is responsible for managing the background task (there's only one), triggering cancellation, and anything else you want to "attach" to it.

  3. When all work is finished, in ends the task.

  4. If the task expires, it triggers cancellation, waits for all work to finish/cancel, then ends the task. How exactly it ends that ask doesn't really matter. You probably could make it so that it block in your expiration handler, but that's actually delaying expiration deliver on other work which isn't great. I'd probably have the expiration handler just trigger cancellation and have the completion flow end the task (with cancelling just being an different kind of completion). You'll get an error logged about that, but I think the error is less disruptive than distorting your architecture to block in the expiration handler would be.

The key things this avoids are calling "begin" on a background thread (which I've already warned about) and the risk child tasks calling "begin" (which risks calling at unexpected/invalid times).

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

Using cooperative cancellation in `expirationHandler` of `beginBackgroundTask(...)`
 
 
Q