I'm hesitant to reach for an actor here. Is this use case justified?

If I can get away without using an actor, of course I would prefer that.

My setup looks like this

nonisolated
final class PodcastManager { 
   var currentPodcast: Podcast?
   var podcastPolling: Task<Void, Never>?

   func _getPodcast() async throws -> Podcast {}
   func getPodcast() async throws  { 
        let podcast = try await _getPodcast()
        if podcast.status == 100 { currentPodcast = podcast }
        for await update in pollPodcast { currentPodcast = update }
   }

    func pollPodcast() -> AsyncStream<Podcast> { 
         AsyncStream {  cont in
               podcastPolling = Task { 
                     while !Task.isCancelled { 
                           try await Task.sleep(for: .seconds(5)
                           let update = try await _getPodcast()
                           cont.yield(update)
                      }
               }

               cont.onTermination = { _ in
                    podcastPolling?.cancel() // The problem!: Capture of 'self' with non-Sendable type 'PodcastManager' in a '@Sendable' closure
               }

          }
    }
   
}

In this case I can't make PodcastManager Sendable. So do I need an actor for this?

You should simply store the task into a local variable and capture it into the closure instead of self because even if you make the class sendable with a lock or use an actor, you'll introduce a potential race condition where cont.onTermination will cancel the wrong task.

4 Likes

Actors are good.

I wonder how you are going using pollPodcast()? Is that from UI?

I find it easier to expose the state in observable model instead of a stream that I'd need to poll somehow.

Minimal example
@MainActor class PodcastManager: ObservableObject {
    struct State {
        var string: String
    }
    
    @Published var state = State(string: "")
    
    private let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main)
    
    init() {
        poll()
    }
    
    func poll() {
        var url: URL!

        // get the change when it happens or timeout after one minute if there's no change
        session.dataTask(with: url) { data, response, error in
            MainActor.assumeIsolated {
                if let error {
                    self.state.string = "..."
                } else if let response, response.status >= 200 && response.status < 300 {
                    self.state.string = "..."
                } else {
                    self.state.string = "..."
                }
                self.poll()
            }
        }.resume()
    }
}

@MainActor struct MyView {
    @StateObject private var model = PodcastManager()
    
    var body: View {
        Text(model.state.string)
    }
}
1 Like

I wonder how you are going using pollPodcast() ? Is that from UI?

My first attempt here was actually on the UI but it felt weird polling from the UI so I ditched that approach. Although I must admit at this point, other than separation of concerns, I don't have a strong preference for either approach. Only when the view gets more complicated would I really prefer to update UI logic inside from a Observable class to avoid unnecessary view re-renders.