# Background Task Patterns

Deeper material for the `background-processing` skill: debugging, error codes,
checkpointed processing, background transfer details, silent push limits, the
SwiftUI modifier and continued-processing specifics.

## Debugging

### Forcing a launch from the debugger

Background tasks rarely fire while you are watching. With the app running
under Xcode, pause it and type in the LLDB console:

```
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"org.sample.photos.refresh"]
```

Resume, and the registered handler runs. To test the expiry path, start the
task that way, pause again and send:

```
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"org.sample.photos.refresh"]
```

These selectors are private. Apple documents them for development only; they
must never appear in code that ships to the App Store.

### What is scheduled right now

```swift
BGTaskScheduler.shared.getPendingTaskRequests { pending in
    for request in pending {
        Log.background.debug("\(request.identifier) not before \(String(describing: request.earliestBeginDate))")
    }
}
```

### Why a task never runs

| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| Never fires anywhere | Identifier absent from `BGTaskSchedulerPermittedIdentifiers` | Add the exact string |
| Never fires anywhere | Background modes off | Enable `fetch` or `processing` |
| Never fires on one device | Background App Refresh turned off, or Low Power Mode on | Read `UIApplication.shared.backgroundRefreshStatus`; Low Power Mode also shortens runtime |
| `submit` throws `.notPermitted` | Code and Info.plist spell the identifier differently | Make them match |
| `submit` throws `.unavailable` | Called from an app extension | Schedule from the app; extensions cannot use `BGTaskScheduler` |
| `submit` throws `.tooManyPendingTaskRequests` | Over the limit of 1 refresh and 10 processing requests pending | Cancel stale requests before adding new ones |
| `submit` throws `.immediateRunIneligible` | A continued processing request used `.fail` and could not start now | Tell the user, or submit with `.queue` |

A new request with the same identifier as one that has not run yet replaces
the old one, so resubmitting is safe.

## Processing tasks in depth

### Overnight work on power and network

```swift
func scheduleNightlyBackup() {
    let request = BGProcessingTaskRequest(identifier: "org.sample.photos.backup")
    request.requiresExternalPower = true
    request.requiresNetworkConnectivity = true
    request.earliestBeginDate = Calendar.current.nextDate(
        after: .now,
        matching: DateComponents(hour: 2, minute: 0),
        matchingPolicy: .nextTime
    )
    try? BGTaskScheduler.shared.submit(request)
}
```

`nextDate(after:matching:matchingPolicy:)` finds the next 2 AM, which becomes
the lower bound for the run.

### Checkpointing

Long jobs rarely finish in one window. Save a cursor after each item and pick
up where the last run stopped:

```swift
func backUp(_ task: BGProcessingTask, assets: [PhotoAsset]) {
    nonisolated(unsafe) let task = task
    let cursorKey = "backup.cursor"
    let work = Task {
        do {
            let start = UserDefaults.standard.integer(forKey: cursorKey)
            for (offset, asset) in assets.dropFirst(start).enumerated() {
                try Task.checkCancellation()
                try await upload(asset)
                UserDefaults.standard.set(start + offset + 1, forKey: cursorKey)
            }
            UserDefaults.standard.removeObject(forKey: cursorKey)
            task.setTaskCompleted(success: true)
        } catch {
            task.setTaskCompleted(success: false)
        }
    }
    task.expirationHandler = { work.cancel() }
}
```

When the window closes, the handler cancels the work, `checkCancellation()`
throws, and the `catch` reports `success: false`: one completion path, as in
`perform` and `handleExport`. The saved cursor lets the next run skip what is
done.
The `nonisolated(unsafe)` copy lets the non-`Sendable` `BGTask` be shared by
the work `Task` and the expiration handler under Swift 6.

## Background URLSession details

### Configuration options

```swift
let config = URLSessionConfiguration.background(withIdentifier: "org.sample.podcasts.transfers")
config.isDiscretionary = true
config.sessionSendsLaunchEvents = true
config.timeoutIntervalForResource = 7 * 24 * 60 * 60
config.allowsCellularAccess = true
```

- `isDiscretionary`: for transfers that are not urgent; the system waits for
  Wi-Fi and power.
- `sessionSendsLaunchEvents`: relaunch the app when transfers complete.
- `timeoutIntervalForResource`: how long a transfer may take overall; one week
  here.
- `allowsCellularAccess` is already `true` by default; set it to `false` to
  keep large transfers on Wi-Fi.

### Uploads

```swift
@MainActor
func sendRecording(at file: URL, to endpoint: URL) {
    var request = URLRequest(url: endpoint)
    request.httpMethod = "PUT"
    let job = EpisodeDownloader.shared.session.uploadTask(with: request, fromFile: file)
    job.resume()
}
```

Background sessions accept only `uploadTask(with:fromFile:)` and
`downloadTask(with:)` style tasks. Data tasks, uploads from in-memory `Data`
(`uploadTask(with:from:)`) and every completion-handler or async variant are
rejected.

### Relaunch sequence

1. The system calls `application(_:handleEventsForBackgroundURLSession:completionHandler:)`.
   Keep the handler.
2. Create the session again with the same identifier; touching the lazy
   `session` property is enough to reconnect to the running transfers.
3. The delegate receives the queued events, then
   `urlSessionDidFinishEvents(forBackgroundURLSession:)`, where you call the
   stored handler on the main actor.

### Resuming after failure

```swift
nonisolated func urlSession(_ s: URLSession, task job: URLSessionTask, didCompleteWithError error: (any Error)?) {
    guard let error = error as NSError? else { return }
    if let partial = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
        s.downloadTask(withResumeData: partial).resume()
    } else if error.code == NSURLErrorNetworkConnectionLost, let url = job.originalRequest?.url {
        s.downloadTask(with: url).resume()
    }
}
```

Resume data lets the transfer continue from where it stopped. Without it, a
dropped connection is retried from the start using the original URL.

### Progress

```swift
nonisolated func urlSession(_ s: URLSession, downloadTask job: URLSessionDownloadTask,
                didWriteData chunk: Int64, totalBytesWritten written: Int64,
                totalBytesExpectedToWrite expected: Int64) {
    guard expected > 0 else { return }
    let fraction = Double(written) / Double(expected)
    Task { @MainActor in DownloadProgress.shared.update(job.taskIdentifier, fraction) }
}
```

UI updates go through the main actor. This method and the failure handler
above are `nonisolated` because they belong to the `@MainActor` downloader from
the parent skill, while the session calls them on its own queue.

## Silent push details

- `content-available: 1` is required; custom keys sit beside `aps` at the top
  level.
- Leave out `alert`, `badge` and `sound`. Any visible key turns the push into a
  user-facing notification with different handling.
- APNs treats these pushes as low priority with no delivery promise. Send no
  more than two or three an hour. When several are held, the system may keep
  only the newest and drop the rest.
- If the user force-quits the app, silent pushes stop until they open it
  again by hand.
- Use priority `5`; priority `10` is meant for notifications the user sees.
- The app has about 30 seconds to call the completion handler. Missing that
  shrinks its future background push budget.

```swift
func handleSilentPush(_ payload: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
    switch payload["kind"] as? String {
    case "album-changed":
        return await LibrarySync.shared.syncChangedAlbums() ? .newData : .noData
    case "purge-cache":
        ThumbnailCache.shared.removeAll()
        return .newData
    default:
        return .noData
    }
}
```

The fetch-completion delegate method calls this and forwards the result to its
handler; a payload with no known kind reports `.noData`.

## SwiftUI `.backgroundTask`

A SwiftUI app can declare the handler on the scene instead of registering it
by hand:

```swift
@main
struct PhotosApp: App {
    var body: some Scene {
        WindowGroup { LibraryView() }
            .backgroundTask(.appRefresh("org.sample.photos.refresh")) {
                _ = await LibrarySync.shared.syncChangedAlbums()
                LibrarySync.shared.scheduleRefresh()
            }
    }
}
```

The rest of the contract is unchanged: the identifier is still listed in
Info.plist, background modes are still on, the next run is still scheduled,
and the work must still stop cleanly when cancelled.

## Continued processing specifics

### Resources

Call `BGTaskScheduler.supportedResources` and request `.gpu` only when it is
listed. GPU use also depends on the Background GPU Access entitlement.

### Submission strategy

`BGContinuedProcessingTaskRequest.SubmissionStrategy` has two cases:

| Strategy | Behaviour | Pick it when |
|----------|-----------|--------------|
| `.queue` | Accepted and started as soon as the system allows | The job can start a little later without harm |
| `.fail` | `submit` fails unless the task can start now | The user is waiting and a delayed start is useless |

### Cancellation from the Live Activity

The user can stop the job from the system Live Activity. That arrives through
`expirationHandler`: cancel the work and delete partial output there; the
cancelled work then completes with `success: false`, as `handleExport` does.

### Progress drives survival

When resources run short, the system reads each task's `Progress` to decide
what to end first. A task that never updates progress is the first to go, so
report small steps often.
