Watch your background: In-app background tasks
![](https://macmegasite.com/wp-content/uploads/2025/02/dispatchridertimes-UfTI2X-1024x838.png)
So far in this series about running background activities I have concentrated on those run using LaunchAgents and LaunchDaemons property lists. This article shows how an app can create its own background activities that will then be run periodically while it’s running. Apple recommends these for actions such as automatic saves, backups, periodic content fetching and other tasks that aren’t visible to the user. Although these can be implemented using separate XPC activities, they are more complicated and can often be avoided using NSBackgroundActivityScheduler, which uses XPC to schedule and dispatch arbitrary tasks.
Code
Scheduled background activities are simple to code using a completion handler. First create the activity with an appropriate identifier
let activity = NSBackgroundActivityScheduler(identifier: “co.eclecticlight.MyApp.tasks”)
Then configure its properties. For example, for an activity to be repeated every theSeconds with a tolerance of theTolerance seconds at a Background Quality of Service (QoS)
activity.repeats = true
activity.interval = TimeInterval(theSeconds)
activity.tolerance = TimeInterval(theTolerance)
activity.qualityOfService = QualityOfService(rawValue: 9)
Submit that activity for scheduling and dispatch by DAS-CTS by enclosing its code within a block terminated with a completion handler
activity.schedule() { (completion: NSBackgroundActivityScheduler.CompletionHandler) in
… do the task
completion(NSBackgroundActivityScheduler.Result.finished) }
Task code should be treated as running in a separate thread. If you need to return results to the main thread, for instance, do that back on the main thread, for example using
OperationQueue.main.addOperation {
… write result out etc.
}
To remove the activity from DAS-CTS, simply invalidate it with
activity.invalidate()
What happens
In the log excerpts below, I lump together entries from com.apple.xpc and com.apple.xpc.activity as CTS for the sake of simplicity. The identifier given is from my own testbed co.eclecticlight.DispatchRider.tasks. For this example, the activity runs the blowhole command tool to write a single entry in the log, a convenient marker. The activity is set to repeat at an interval of 10 minutes, with a tolerance of 1 minute, and with a Background QoS.
You’ll see references to XPC activity states. Referring to Apple’s source code, those are defined as
0 XPC_ACTIVITY_STATE_CHECK_IN, check-in has been completed;
1 XPC_ACTIVITY_STATE_WAIT, waiting to be dispatched and run;
2 XPC_ACTIVITY_STATE_RUN, now eligible to be run;
3 XPC_ACTIVITY_STATE_DEFER, to be placed back in its wait state with unchanged times;
4 XPC_ACTIVITY_STATE_CONTINUE, will continue its operation beyond the return of its handler block, and used to extend an activity to include asynchronous operations;
5 XPC_ACTIVITY_STATE_DONE, the activity has completed.
Registration
When that code is run, the log first records the code block being added as an XPC activity:
CTS xpc_activity_register: co.eclecticlight.DispatchRider.tasks, criteria: dictionary
CTS [0x6000017c3570] activating connection: mach=true listener=true peer=false name=com.apple.xpc.activity
CTS _xpc_activity_register: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), 0
CTS xpc_activity_set_criteria: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), dict
CTS xpc_activity_set_criteria, lower half: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), dict
CTS _xpc_activity_set_criteria: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), dict
CTS _xpc_activity_set_criteria: xpc_set_event co.eclecticlight.DispatchRider.tasks, 1
CTS Subscribed to event co.eclecticlight.DispatchRider.tasks using token 1640
With its check-in completed, the activity is put in the wait state, awaiting dispatch:
CTS xpc_activity_set_criteria: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), setting state now to 1
CTS _xpc_activity_set_state: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), 1
CTS Creating on XPC add event: co.eclecticlight.DispatchRider.tasks
CTS Created: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
CTS Submitting: 501:co.eclecticlight.DispatchRider.tasks:922FF9 (CTS Activity 0x9cca6d900)
DAS now registers the activity, sets its priority at 5, gives it a time window to be run, and a score it must reach to be dispatched by DAS:
DAS SUBMITTING: 501:co.eclecticlight.DispatchRider.tasks:922FF9
CTS Registered: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
DAS Submitted: 501:co.eclecticlight.DispatchRider.tasks:922FF9 at priority 5 with interval 600 (Mon Jan 27 14:58:01 2025 – Mon Jan 27 15:08:01 2025)
DAS <private>: Optimal Score 0.5687 at <private> (Valid Until: <private>)
Note that the time window allocated here is in the past, to ensure that it already meets time criteria to undergo its first run. The priority assigned to this activity of 5 is different from its QoS of 9.
Dispatch
As with its other activities, dispatch of in-app background tasks starts when DAS rescores all its activities and the task’s score exceeds its threshold:
DAS default dasd dasd Rescoring all 534 activities [<private>]
DAS scoring dasd dasd 501:co.eclecticlight.DispatchRider.tasks:922FF9:[ ], Decision: CP Score: 0.995792}
DAS ‘501:co.eclecticlight.DispatchRider.tasks:922FF9’ CurrentScore: 0.995792, ThresholdScore: 0.531465 DecisionToRun:1
DAS With <private> …Tasks pre-running in group [com.apple.dasd.default] are 1!
DAS REQUESTING START: 501:co.eclecticlight.DispatchRider.tasks:922FF9
CTS DAS told us to run co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
DAS Setting timer (isWaking=1, activityRequiresWaking=0) between <private> and <private> for <private>
With that, CTS makes the activity eligible to be run, and it’s dispatched:
CTS evaluating activities
CTS co.eclecticlight.DispatchRider.tasks state change 1 -> 2
CTS Initiating: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
CTS [0x9cca6ea80] activating connection: mach=false listener=true peer=false name=(anonymous)
CTS [0x9cca6ea80] Channel could not return listener port.
CTS [0x9cca6df40] activating connection: name=com.apple.xpc.activity publishToken=1640
CTS [0x149e0a3b0] activating connection: mach=false listener=false peer=true name=com.apple.xpc.activity.peer[605].0x149e0a3b0
CTS _xpc_activity_dispatch: beginning dispatch, activity name co.eclecticlight.DispatchRider.tasks, seqno 1
CTS _xpc_activity_dispatch: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60): found an activity with matching seqno 1
CTS _xpc_activity_begin_running: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60) seqno: 1.
CTS _xpc_activity_dispatch: lower half, activity name co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), seqno from top half was 1
CTS [0x14b106880] activating connection: mach=false listener=false peer=false name=(anonymous)
CTS _xpc_activity_dispatch: created connection 0x14b106880 for activity name co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), seqno 1
CTS _xpc_activity_set_state: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), 2
CTS _xpc_activity_set_state: send new state to CTS: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), 2
CTS [0x9cca6e940] activating connection: mach=false listener=false peer=true name=com.apple.xpc.anonymous.0x9cca6ea80.peer[2992].0x9cca6e940
CTS Running (PID 2992): co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
DAS STARTING: <private>
CTS _xpc_activity_set_state_from_cts: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), set activity state to 2
No sooner has it started than CTS is waiting for it to complete and return to a checked-in state:
CTS __XPC_ACTIVITY_CALLING_HANDLER__: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), current state 2, pending state 0
CTS _xpc_activity_set_state: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), 4
DAS STARTING <_DASActivity: “501:co.eclecticlight.DispatchRider.tasks:922FF9”, Maintenance, 60s, [27/01/2025, 14:58:01 – 27/01/2025, 15:08:01], Started at 27/01/2025, 14:59:53, Group: com.apple.dasd.default, PID: 2992>!
DAS Activity <private> has preventDeviceSleep 0. PluggedIn state: 1
DAS With <private> …Tasks running in group [com.apple.dasd.default] are 2!
DAS 501:co.eclecticlight.DispatchRider.tasks:922FF9:[ ], Decision: CP Score: 0.995792}
The activity runs, in this case writing an entry in the log:
BWH Blowhole snorted!
Its state is then set to indicate it has completed:
CTS _xpc_activity_set_state: co.eclecticlight.DispatchRider.tasks (0x6000019c8e60), 5
Deregistration
This takes place when the activity is invalidated and removes it from scheduling and dispatch.
That perhaps unexpectedly results in its state being changed to make it ready to run:
CTS [0x9cca6e940] invalidated because the client process (pid 2992) either cancelled the connection or exited
CTS Client connection closed: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
CTS co.eclecticlight.DispatchRider.tasks state change 2 -> 3
But instead of that, it’s deferred and submitted for cancellation:
CTS Deferring: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
CTS Canceling: 501:co.eclecticlight.DispatchRider.tasks:922FF9 (CTS Activity 0x9cca6d900)
CTS Submitting: 501:co.eclecticlight.DispatchRider.tasks:0D2ADD (CTS Activity 0x9cca6d900)
DAS SUBMITTING: 501:co.eclecticlight.DispatchRider.tasks:0D2ADD
DAS CANCELED: 501:co.eclecticlight.DispatchRider.tasks:922FF9 at priority 5
DAS NO LONGER RUNNING 501:co.eclecticlight.DispatchRider.tasks:922FF9 …Tasks running in group [com.apple.dasd.default] are 1!
CTS Unregistered on XPC remove event: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
Although it has now completed execution, it still has an entry in the process table, as a process in the terminated state. This allows the parent process to read its exit status via the wait system call. For this, the activity is made a zombie:
CTS Creating zombie: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
CTS Set timer for zombie: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
DAS Submitted: 501:co.eclecticlight.DispatchRider.tasks:0D2ADD at priority 5 with interval 600 (Mon Jan 27 14:58:01 2025 – Mon Jan 27 15:08:01 2025)
DAS 501:co.eclecticlight.DispatchRider.tasks:0D2ADD:[ ], Decision: CP Score: 0.995792}
DAS ‘501:co.eclecticlight.DispatchRider.tasks:0D2ADD’ CurrentScore: 0.995792, ThresholdScore: 0.547394 DecisionToRun:1
DAS Running <private> immediately on submission
DAS With <private> …Tasks pre-running in group [com.apple.dasd.default] are 1!
DAS REQUESTING START: 501:co.eclecticlight.DispatchRider.tasks:0D2ADD
CTS DAS told us to run co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
The zombie’s entry is removed from the process table, an action known as reaping, and its state set to -1:
CTS evaluating activities
CTS Reaping zombie: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
CTS co.eclecticlight.DispatchRider.tasks state change 3 -> -1
CTS Canceling: 501:co.eclecticlight.DispatchRider.tasks:0D2ADD (CTS Activity 0x9cca6d900)
CTS [0x9cca6ea80] invalidated because the current process cancelled the connection by calling xpc_connection_cancel()
CTS REAPED zombie: co.eclecticlight.DispatchRider.tasks (0x9cca6d900)
DAS CANCELED: 501:co.eclecticlight.DispatchRider.tasks:0D2ADD at priority 5
Dispatch intervals
In this example, the activity was set to repeat at an interval of 10 minutes, with a tolerance of 1 minute. According to Apple’s documentation, that should result in the activity being dispatched and run within a window of 9-11 minute intervals from the previous run. To assess how well DAS-CTS performed in this case, intervals between log entries by blowhole were calculated. They ranged between 5m08s and 16m34s, with an average of 9m29s (9 minutes 29 seconds), over 21 runs with 20 intervals. A total of 11 (55%) took place within the expected window, and their distribution is shown in the histogram below.
Most of those took place soon after user login, during a period of sustained and intensive background activity as Spotlight indexing took place, a backup was performed, and XProtect Remediator scans were undertaken.
Summary
Running background activities as arbitrary blocks of code within an app is simple using NSBackgroundActivityScheduler, and doesn’t require the use of XPC.
XPC activity states range from 0 to 5, as given above.
Background activities are then scheduled and dispatched by DAS-CTS.
Activity priorities are different from Quality of Service (QoS).
Dispatch is performed by DAS telling CTS to run an activity.
Deregistration first turns the activity into a zombie to return its result to the parent process, then reaps it from the process table.
Background activities aren’t run at constant intervals, but most intervals between runs should be within the window set by their interval and tolerance.
Running background activities using NSBackgroundActivityScheduler is an excellent way for them to be scheduled flexibly.
Previous articles
Background activities with DAS-CTS
Scheduling XProtect Remediator scans