skip to Main Content

I am working on a "HealthController" who’s responsibility is to expose helpers for HealthKit and in the future track various health kit data points like heartrate, calories etc…

My setup so far looks like this (This is in a new Swift 6 projects with full concurrency enabled).

import HealthKit
import SwiftUI

@MainActor @Observable
final class HealthController {
    // State
    let store = HKHealthStore()
    var session: HKWorkoutSession?
    var builder: HKLiveWorkoutBuilder?

    // Other code for authorization, configuration defaults etc...

    func startWatchWorkout(_ name: String, _ startDate: Date) async {
      do {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = .traditionalStrengthTraining
        session = try HKWorkoutSession(healthStore: store, configuration: configuration)
        builder = session?.associatedWorkoutBuilder()
        builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration)
        session?.startActivity(with: startDate)
        try await builder?.beginCollection(at: startDate)
      } catch {
        // ...
      }
    }

    func endWatchWorkout(_ endDate: Date) async {
      do {
        session?.end()
        try await builder?.endCollection(at: endDate)
        session = nil
        builder = nil
      } catch {
        // ...
      }
    }
}

Controller is marked as @MainActor so it’s functions can be called from swift ui views. I am now getting following error at builder?.beginCollection(at: startDate) and builder?.endCollection(at: endDate) parts:

Sending main actor-isolated value of type ‘HKLiveWorkoutBuilder’ with
later accesses to nonisolated context risks causing data races

Not sure what the right fix is here, I tried marking builder as nonisolated, but that seems to cause build error with Observation. I also wrapped each of those calls inside MainActor.run, but it didn’t get rid of the error.

2

Answers


  1. The problem is that builder and session are isolated to the MainActor but not Sendable. You cannot call nonisolated async methods on it because that would end up sending it off the main actor.

    You should call beginCollection in a non-isolated method, so there are no actor hops that sends non-sendable values. You should only assign the builder and session back to the instance properties (sending them back to the main actor) after you are done with them. Thanks to region-based isolation, Swift understands that this is your last time using the builder and session in the non-isolated method, so they are safe to be sent off.

    If you send them back to the main actor before that, you are essentially sharing the instance between the main actor and the non-isolated code after that, which of course is not safe with non-sendable values.

    // main actor isolated version so that you can easily call from a view
    func startWatchWorkout(_ name: String, _ startDate: Date) async {
        await startWatchWorkoutImpl(name, startDate)
    }
    
    private nonisolated func startWatchWorkoutImpl(_ name: String, _ startDate: Date) async {
        do {
            let configuration = HKWorkoutConfiguration()
            configuration.activityType = .traditionalStrengthTraining
            // note that I am not assigning to self.session and self.builder here
            let session = try HKWorkoutSession.make(healthStore: store, configuration: configuration)
            let builder = session?.associatedWorkoutBuilder()
            builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration)
            session?.startActivity(with: startDate)
            try await builder?.beginCollection(at: startDate)
            await setSessionAndBuilder(session, builder)
        } catch {
            // ...
        }
    }
    
    // assigning the properties should be done in a main actor isolated context
    private func setSessionAndBuilder(_ s: HKWorkoutSession?, _ b: HKLiveWorkoutBuilder?) {
        session = s
        builder = b
    }
    
    Login or Signup to reply.
  2. I have found another approach for the same issue without using extension HKLiveWorkoutBuilder: @unchecked @retroactive Sendable {} .

    You can declare your builder as a computed property of you class:

    var builder: HKLiveWorkoutBuilder? {
        session?.associatedWorkoutBuilder() 
    }
    

    Then, you can remove from startWatchWorkout method:

    builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration)
    

    and remove from endWatchWorkout method:

    builder = nil
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search