I have an actor that has nontrivial internal state that's all optional together. That is, when it has a current task it will have all that state, but otherwise there would be none. Frustratingly, I can't seem to find a way to declare operations on that internal structure without stumbling across concurrency-related compiler errors.
Here's a snippet that shows the error:
import XCTest
actor WorkManager {
private final class CurrentTask {
var error: (any Error)? = nil
func fail(with error: any Error) async {
self.error = error
// async because I/O is required to record the failure
}
}
private var currentTask: CurrentTask?
func submit() async {
let task = CurrentTask()
self.currentTask = task
await task.fail(with: IntentionalError.someError) // error: Sending 'task' risks causing data races
self.currentTask = nil
}
func cancel() {
if let currentTask {
// do something.
}
}
}
enum IntentionalError: Error {
case someError
}
class WorkManagerTests: XCTestCase {
func testWorks() async throws {
let manager = WorkManager()
await manager.submit()
await manager.cancel()
}
}
I was searching for some way to declare this inner class as sharing the isolation of the actor, but couldn't find a straightforward way to do so.
I tried adding isolated
parameters to the async methods of CurrentTask
; this had no effect.
If I remove the currentTask
member of the actor, this makes calls through task
work but breaks the ability to cancel the task.
Making WorkManager a global actor did clear up the compiler error, but it's not clear to me why that worked. That is:
@globalActor actor WorkManager: GlobalActor {
static let shared: WorkManager = .init()
// no other changes
}
... no longer complains about task
causing race conditions.
Another thing that works is to declare fail
directly on the actor, making CurrentTask just a dumb data holder:
private final class CurrentTask {
var error: (any Error)? = nil
}
func submit() async {
let task = CurrentTask()
self.currentTask = task
await fail(task, with: IntentionalError.someError)
self.currentTask = nil
}
private func fail(
_ task: CurrentTask,
with error: (any Error)
) async {
task.error = error
// async because I/O is required to record the failure
}
That this works indicates to me that this means the actor's isolation isn't propagating into CurrentTask.fail
.
Another option that works is just to give up sharing the isolation with the WorkManager actor and make the CurrentTask an actor itself. I'm not a huge fan of the idea that everything needs to be an actor if anything is an actor.
Of course it's also possible to declare CurrentTask: @unchecked Sendable
but this is essentially just opting out of compile-time checking.
So, how do I declare CurrentTask
so that it shares its isolation context with its enclosing actor without making it a global actor or just giving up and making it a dumb data holder?