I present an in-app UI that shows progress in addition to updating the background task.
Perfect, that makes sense then. I just wanted to make sure this wasn't a case of overlooking an edge case.
Reordering things a bit for clarity:
If there were an issue with the device configuration, wouldn't the submit request throw an error since the strategy is set to fail if it can't be started immediately?
Theoretically, yes, but that's tricky to be sure about. Architecturally, what actually happens is a two-stage process where you:
-
Submit your task into the duet*
-
duet then send that task back to your app to actually run.
For everything except BGContinuedProcessingTask, those two stages are "inherently" separate— that is, you'd basically "never" see a refresh or a processing task fire "immediately" after submission, since the point of both tasks is to run work "later".
*The daemon that manages the BackgroundTask framework
Moving to here:
Or are there scenarios where it's expected behavior for an error to not be thrown and yet the launchHandler won't be called?
The term "expected" here is tricky. In terms of the API "contract", the expectation is that a request set as "fail" should either start "immediately" or return an error.
However, in terms of what will actually happen in ALL cases... I don't know. Looking through our code, I'm fairly confident that stage #1 ("submit") will always either "succeed" (meaning, duet accepts the job) or "fail" (meaning, it returns an error), but the internals of stage #2 are much harder to follow.
I talked to the engineer lead about this as well, and his take was largely the same as mine— this should definitely not be happening, but the code involved is complicated enough that it's hard to be sure that there isn't a bug "somewhere".
That leads to here:
I asked them, but they said the issue is no longer occurring 😮 I inquired if anything had changed, any device settings, low power mode enabled state, or anything like that, but nothing they can think of. I suspect no one else is going to reach out to report the issue because I quickly put a workaround in place to call startProcessing(backgroundTask: nil) if it is not called within 1.5s of submitting the request. 😅
Unfortunately, my guess as to what happened is something like this:
-
duet accepted the job because it did think it was/could run the job.
-
Some internal state/issue meant it couldn't run the job NOW, so it delayed starting it.
-
Whatever caused #2 should have been short/temporary, so it didn't fail the job.
-
Whatever happened at #3 didn't clear, so the job never started.
Of course, that's just one possibility/guess here. Without a sysdiagnose to see what actually happened, this is just speculation.
One other thought that just caught my eye is in your register call here:
...[weak self] ...
What object is this? And is there ANY possibility that this object might deallocate?
For my simple use case, it seems much more straightforward to perform the work once the launchHandler is invoked (or if the submit request fails, perform the work without a BGContinuedProcessingTask).
OK. All of this is ultimately your own judgment call. My main comment here is that you're creating a situation where entangling the two processes is causing complexity to proliferate. You started with 2 code paths and are now technically at 4:
-
No BGContinuedProcessingTask (iOS 18/disabled).
-
BGContinuedProcessingTask works correctly (iOS 26 "expected" behavior).
-
BGContinuedProcessingTask doesn't start within 1.5s (your workaround for the failure we're talking about) and doesn't start at all.
-
BGContinuedProcessingTask starts AFTER 1.5s (so #3 has occurred).
That's a lot to test and think through. The alternative approach is to separate your work from BGContinuedProcessingTask, so that you now have two "unrelated" code paths that are "weakly" connected:
-
Performing your actual work.
-
Your BGContinuedProcessingTask management, which works by "monitoring" #1 but isn't actually integrated with it.
If you set things up right, #2 is simply hooking into existing functionality of #1 that's already being used by other parts of your app— the progress object is that same progress object #1 already uses, expiration is just save/cancel/etc.
This also avoids a seemingly complicated scenario: what do you do if the register launchHandler is invoked after its work had already completed/was canceled/failed, unclear what you'd do with that BGContinuedProcessingTask, shouldn't persist it, there's not a way to "cancel" it.
This one’s actually an easy one. When the work is "done", then you call setTaskCompleted(success:). That's true regardless of how/why the job "finished". Also, in the case of BGContinuedProcessingTask, I think you'll basically always send "true". You pass "false" when you need the system to run to your task "again", but that doesn't apply to BGContinuedProcessingTask.
However, that does lead me to one other "code level":
-
Your snippet doesn't include setting an expiration handler. Do you set one somewhere else?
-
Do you call setTaskCompleted(success:) and do you always pass in "true"?
-
Are you reusing the same task identifier?
Speculating off the top of my head, if you're using the same task ID AND the task was marked as "failed" (either because you marked it or because you didn't set an expiration handler), then it's possible that could create the situation you saw. The "fail" mark might keep the task "live" in Duet, but we wouldn't fire the task again (because we don't refire BGContinuedProcessingTasks). That would absolutely be "a bug", but it would also require some API mistakes on your part.
If it's possible this is happening in your app, I'd appreciate you testing this particular case and letting me know what you find.
__
Kevin Elliott
DTS Engineer, CoreOS/Hardware