How to avoid Concurrency Error of capturing a MainActor-isolated closure in a Sendable closure

I spent quite a bit of time trying to figure out how to avoid the following Swift 6 error in the function below (which aims to receive an AsyncStream of property value changes) but failed so far:

Capture of 'observe()' with non-sendable type '() -> ()' in a `@Sendable` closure.

The call to observe() obviously doesn't race, but how can I get Swift to take this into account? Note: The sendable closure is the onChange parameter.

@MainActor
func withContinuousMainActorObservation<T>(
  of property: @autoclosure @escaping @MainActor () -> T
) -> AsyncStream<T> {
  AsyncStream { continuation in
    @MainActor func observe() {
      let value = withObservationTracking {
        property()
      } onChange: {
        Task { @MainActor in
          observe()
        }
      }
      continuation.yield(value)
    }
    observe()
  }
}

I've also written a different, more complex implementation but all have the same issue. Thanks for any hints.

I believe this could be a compiler bug. But, you may be able to work around it by also annotating observe as @Sendable.

1 Like

Yes, it looks pretty similar to your issue.

Unfortunately, I'm not allowed to annotate a local function with @Sendable and @MainActor. This leads to the following error:

Main actor-isolated synchronous local function 'observe()' cannot be marked as '@Sendable'

Which is odd but also unnecessary if local functions isolated to global actors are to be implicitly sendable indeed. I agree it would be nice to have a clarification here.

1 Like

Huh, you're right. This is really close to another conversation I had with someone via a gist.

Maybe there's something helpful in there?

1 Like

This is actually exactly the same goal and it was interesting to see that you both came to the same conclusions. :slightly_smiling_face: One thing to keep in mind about the withObservationTracking API is that the onChange closure is called on willSet of the observed property not didSet.

The current, compiler error-free implementation looks like this after adopting some ideas:

@MainActor
public func withContinuousMainActorObservation<T>(
  of property: @autoclosure @escaping () -> sending T
) -> AsyncStream<T> {
  nonisolated(unsafe) let property = property

  return AsyncStream { continuation in
    let isTerminated = OSAllocatedUnfairLock(initialState: false)

    continuation.onTermination = { _ in
      isTerminated.withLock { $0 = true }
    }

    @Sendable func observe() {
      let value = withObservationTracking {
        property()
      } onChange: {
        if !isTerminated.withLock(\.self) {
          Task { @MainActor in
            observe()
          }
        }
      }
      if !isTerminated.withLock(\.self) {
        continuation.yield(value)
      }
    }
    observe()
  }
}

Thanks again for the valuable answer. I hope the compiler will be further improved to be able to remove the unsafe workaround. Should I also report a bug given yours is the same?

1 Like

Glad you got somewhere. I still think this is way too hard to do...

Nah I don't think its necessary.

1 Like