If I have a task that can be replaced by a newer task, how do I tell - at the completion of that task - if it survived; if it was not replaced?
e.g.:
struct SomeView: View {
var slowTask: Task<Never, Never>? = nil
var body: some View {
Button("Do the thing") {
slowTask?.cancel()
slowTask = Task.detached {
… // Some slow stuff.
await MainActor.run {
// How do I tell if I'm still `slowTask `?
}
}
}.onDisappear {
slowTask?.cancel()
slowTask = nil
}
}
}
There's no apparent way to get the current task (withUnsafeCurrentTask doesn't provide access to the actual Task instance), to simply compare with the contents of slowTask.
It appears that the task doesn't change when jumping to the main actor, so is it therefore possible to use cancellation as a proxy for whether the current task survived (assuming no logic errors elsewhere in the program regarding the tying of cancellation to removal from slowTask, and that cancellation is only ever performed from the main actor)?
Is there a better way that doesn't rely on unenforceable conventions throughout my code?
Yes, I mentioned that, but the problem is that it relies on cancellation only ever coming from the main actor (in the common situation above). There's no good way to enforce that, as far as I can see.
Regardless of how check is performed, to be non-racy is should be done on exactly the actor/queue/thread that replaces the task. Assuming that replacing the task implies cancelling the old task, then Task.isCancelled is all you need. But you need to ensure that you are calling it from the right actor, compiler won’t help you. And in your example you seem to be doing it correctly.
Note that you still can have extra checks earlier, to cancel as much work as possible.
I've done things like that sometimes. I'm hoping there's a simpler (and more efficient) way.
It's frustrating that Tasks aren't more readily accessible, and don't even provide a stable unique identifier for themselves (such a thing could be useful as something that can outlive the Task itself, too, in other use-cases).
Right. Forcing cancellation to synchronise through a particular actor is generally acceptable for my uses, the trick is enforcing that.
I can put @MainActor on the nominal var storing the Task reference, slowTask, which is a start, but nothing technically stops the Task reference from being sent to other isolation contexts (and sometimes I want it to be accessible from elsewhere, because cancellation might be triggered asynchronously). I kind of need an "@Unsendable" decorator.
Thinking about this more, I suppose I can use some otherwise needless abstraction to try to have this enforced at compile-time, e.g.:
That's a tad inflexible since it needs to be hard-coded to a specific global actor (though admittedly for my purposes that's usually the main actor anyway, due to the constraint that it be usable from SwiftUI).
What we do in general for similar patterns is simply along these lines:
private var tokenExtractionTask: Task<Void, Error>?
...
func cancelTokenTask() async {
if let tokenExtractionTask {
tokenExtractionTask.cancel()
do {
_ = try await tokenExtractionTask.value // wait for cancellation to go through
} catch {
print("Caught task cancellation for tokenExtractionTask \(error)")
}
self.tokenExtractionTask = nil
}
}
...
await cancelTokenTask() // wait and ensure previous token extractions task are cancelled
tokenExtractionTask = Task {
do {
// do some heavy work here
try Task.checkCancellation()
Task { @MainActor in
// do some stuff
}
} catch {} // for cancellations, just skip out
}
(it does require that the "slow stuff" honours cancellation in a reasonable manner)