Effects that require background execution time #2012
-
I'm trying to write a generic helper function and effect that lets me perform an effect with extended background execution time (e.g. using
I've mostly got this working except for the last part and I'm not sure how to deal with this - by the time I've called This is my core implementation - public func withExtendedBackgroundExecution<Result: Sendable>(
taskName: String,
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async throws -> Result
) async throws -> Result {
@Dependency(\.backgroundExecutionClient) var client
var taskIdentifier: UIBackgroundTaskIdentifier = .invalid
let task = Task(priority: priority, operation: operation)
taskIdentifier = client.beginBackgroundTask(taskName) {
// We don't need to call end background task here because cancelling the task will
// cause a cancellation error to be thrown and caught by the catch block below.
task.cancel()
}
do {
let result = try await task.value
client.endBackgroundTask(taskIdentifier)
return result
} catch {
client.endBackgroundTask(taskIdentifier)
throw error
}
} And the effect helper just builds on top of this: extension EffectTask where Failure == Never {
public static func backgroundExecutionTask(
withName name: String,
priority: TaskPriority? = nil,
operation: @escaping @Sendable (Send) async throws -> Void,
catch handler: (@Sendable (Error, Send) async -> Void)? = nil,
file: StaticString = #file,
fileID: StaticString = #fileID,
line: UInt = #line
) -> Self {
.run(
priority: priority,
operation: { send in
try await withExtendedBackgroundExecution(taskName: name, priority: priority) {
try await operation(send)
}
},
catch: handler,
file: file,
fileID: fileID,
line: line
)
}
} I'm not quite sure how to adapt this to allow me to send a final action on cancellation, I've tried doing this but it complains that I can't use func run() -> EffectTask<Job.Action> {
.backgroundExecutionTask(withName: "example task", priority: .high) { send in
withTaskCancellationHandler {
// do some stuff that takes a while
} onCancel: {
send(.jobCancelled) // I want to handle this in my reducer to update some state to indicate the job was cancelled.
}
} catch: { error, send in
await send(.jobFailed(error as NSError))
}
} I'm open to suggestions. |
Beta Was this translation helpful? Give feedback.
Replies: 4 comments 8 replies
-
IIRC, cancellation of background tasks gets one turn of the main run loop in which to complete any cancellation work. Anything more may be dropped because the app has transitioned to the background. I'm pretty sure background tasks and async / await are fundamentally incompatible, aside from the new SwiftUI APIs, maybe. Whether this also applies to cancellation while the app is foregrounded, I don't know. |
Beta Was this translation helpful? Give feedback.
-
@lukeredpath I've got to run, but I have a few small comments that may help in the meantime. Will check back in later and read things in more depth if there's still more to figure out. One thing that stands out in skimming this is that in With that updated I think your do {
…
} catch is CancellationError {
await send(.jobCancelled)
} |
Beta Was this translation helpful? Give feedback.
-
I did a bit of real world testing - I found that there does seem to be enough time when the expiration handler is called to send one more action (although I guess this isn't guaranteed). The problem with doing it by catching the cancellation error for the background executing task is that by that point, I need to do some more testing of this on a device but so far I've found that adding an taskIdentifier = client.beginBackgroundTask(taskName) {
Task(priority: .high) { @MainActor in
// We'll call the expiry handler first to give it a small amount of time to do some
// work before we cancel the task.
await expiryHandler?()
// We don't need to call end background task here because cancelling the task will
// cause a cancellation error to be thrown and caught by the catch block below.
task.cancel()
}
} |
Beta Was this translation helpful? Give feedback.
-
I made one other small change to the .run(
priority: priority,
operation: { send in
try await withExtendedBackgroundExecution(taskName: name, priority: priority) {
do {
try await operation(send)
} catch is CancellationError {
return
} catch {
// Rather than passing the handler to `run` directly, we'll catch
// any errors here and call the handler here instead - this will
// ensure any error handler is called _before_ the background execution
// task is ended. If no handler is passed in and operation throws,
// the default `.run` handler will trigger a runtime warning/test failure.
await handler?(error, send)
}
} onExpiry: {
onExpire?(send)
}
},
catch: nil,
file: file,
fileID: fileID,
line: line
) To the caller the result is mostly the same - cancellation errors will bubble up, skipping the catch handler and cause the task to be cancelled and the background task to end. Other errors will get caught first by the provided catch handler, then |
Beta Was this translation helpful? Give feedback.
@lukeredpath I've got to run, but I have a few small comments that may help in the meantime. Will check back in later and read things in more depth if there's still more to figure out.
One thing that stands out in skimming this is that in
withExtendedBackgroundExecution
you wait fortask.value
, but the cancellation of that task will not be propagated to the caller unless you thread the cancellation through using Swift'swithTaskCancellationHandler
. We have an internal helper in TCA calledTask.cancellableValue
that does this work, since we need to do this dance a few places throughout the repo:https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchit…