Encapsulated mutable actor state

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?

CurrentTask is nonisolated, and fail method on it as well, calling which happens off the actor (see SE-338), that’s why this produces an error: your class is actually can be unsafely mutated off the actor isolation. Isolating it to a global actor (if I understood you correctly), makes it isolated, and fail is executed in the context of the actor, and therefore it is safe to call in a concurrent way.

Using global actor is a viable solution, but using isolated parameter here should’ve worked as well pretty good, and probably in your case is more suitable. Can you share the code with isolated parameter?

Aha! I figured out why isolated wasn't working for me, having gone back to SE-0420.

    func fail(
      with error: any Error,
      isolation: isolated WorkManager = #isolation
    ) async {
      self.error = error
    }

works but

    func fail(
      with error: any Error,
      isolation isolated: WorkManager = #isolation
    ) async {
      self.error = error
    }

does not. Get the colon out of place and the magical isolation propagation just doesn't happen without any indication that it could be related to the error. Also note this ordering doesn't work either:

      isolated isolation: WorkManager = #isolation

A diagnostic that you're assigning #isolation to a non-isolated type would go a long way toward helping people keep the ordering right.

The global actor thing spooks me though. I would have expected for that to work, I would have to make this declaration:

@globalActor actor WorkManager: GlobalActor {
  static let shared = WorkManager()

  @WorkManager
  private final class CurrentTask {
    var error: (any Error)? = nil

    func fail(
      with error: any Error
    ) async {
      self.error = error
    }
  }

But I didn't need to make the inner annotation. This was sufficient:

@globalActor actor WorkManager: GlobalActor {
  static let shared = WorkManager()

  private final class CurrentTask {
    var error: (any Error)? = nil

    func fail(
      with error: any Error
    ) async {
      self.error = error
    }
  }

Yeah, that doesn't seem right. I think this worth filing an issue.

1 Like