Concurrency update: dependency delegate methods never called

I'm building out a new feature using the new Grand Concurrency Update (as I like to call it). It's made modelling dependency clients much, much nicer. As confirmed by your VoiceMemos case study, it felt natural to push the dependencies into a private actor, with an endpoint on the actor which returns an AsyncThrowingStream.

However I'm having an issue, where the CLLocationManagerDelegate methods are never called (even though the locationManagerDidChangeAuthorization(_:) should be called after the CLLocationManager is initialised for instance) , and I'm wondering if it's related to how I modelled the dependancy with an actor and an AsyncThrowingStream.

Here's the important bits of the code:

extension BeaconClient {
  static var live: Self {
    let beacon = BeaconActor()
    return Self { await beacon.start(major: $0, minor: $1) }
  }
}

private actor BeaconActor {
  var delegate: Delegate?
  var locationManager: CLLocationManager?
  var peripheralManager: CBPeripheralManager?
  
  func start(major: UInt16, minor: UInt16) -> AsyncThrowingStream<[Beacon], Error> {
    return AsyncThrowingStream { continuation in
                                
      self.delegate = Delegate(
        detectorAuthorizationChanged: { [locationManager = UncheckedSendable(locationManager)] auth in
          
          print("Detector authorization changed: \(auth)") // never prints
          switch auth {
          // ...
          }
        },
        detectorFailed: { error in
          print("Detector Failed: \(error)") // never prints
          continuation.finish(throwing: error)
        },
        detectorRangedBeacons: { beacons in
          print("Detector ranged beacons: \(beacons)") // never prints
          continuation.yield(beacons)
        }
      )
      
      continuation.onTermination = { /* ...cleanup... */ }
      
      self.locationManager = CLLocationManager()
      locationManager?.delegate = self.delegate
      locationManager?.requestWhenInUseAuthorization()
      
    }
  }
}

private final class Delegate: NSObject, CLLocationManagerDelegate, Sendable {
  let detectorAuthorizationChanged: @Sendable (CLAuthorizationStatus) -> Void
  let detectorFailed: @Sendable (Error) -> Void
  let detectorRangedBeacons: @Sendable ([Beacon]) -> Void
  
  init(/* ... */) { }
  
  func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    print("Authorization changed: \(manager.authorizationStatus)") // never prints
    self.detectorAuthorizationChanged(manager.authorizationStatus)
  }
  
  // other delegate methods
}

Is it possible that the CLLocationManager is somehow getting deallocated instantly?

Edit: I'm not sure whether this matters or not, but the endpoint is getting called inside a withThrowingTaskGroup inside of a .run in the reducer, like this:

return .run { send in
  await withThrowingTaskGroup(of: Void.self) { group in
    group.addTask {
      for try await beacons in await environment.beacon.start(major, minor) {
        await send(.beaconsResponse(beacons))
      }
    }
    
    // other stuff 
  }
}

@nikitamounier I think we'll need to full implementation to debug. E.g. are you capturing the weak delegate in onTermination?

Sure! I created a GitHub repo, and the files of interest are BeaconLive.swift and the reducer in ContentView.swift.

Of note is that the CBPeripheralManagerDelegate methods actually do get called (the console prints Advertiser state changed: CBManagerState(rawValue: 5) quite quickly) – so I don't think the issue is the Delegate class being deallocated.

And no, I'm not capturing the weak delegate in onTermination – although I am capturing the CLLocationManager with UncheckedSendable(locationManager).

@nikitamounier When this line is invoked, the closure is eagerly capturing locationManager at a point in which it is nil. I think you may need to box this optional value in a reference type (using for example a Box class) if it's getting set later.

Interesting – so I just did that (with a Box class) but it still didn't work. Let me know if the implementation looks correct: P2PTest/BeaconLive.swift at main · nikitamounier/P2PTest · GitHub – the Box class is defined at the bottom of the file.

I'm wondering whether maybe the CLLocationManager doesn't play nicely with the Swift cooperative thread pool yet, and prefers to deliver its delegate events on the main thread.

1 Like

Hey @nikitamounier I have experienced the exact same issue you are describing here. My "location fetcher" type is not even an actor, just a class using with a vanilla CLLocationManager + withCheckedThrowingContinuation.

Interestingly, if I mark the class with @MainActor then the delegate callbacks get invoked as expected.

This is the basic implementation of the LocationManager class:

class LocationManager: NSObject, CLLocationManagerDelegate {

    private lazy var locationManager: CLLocationManager = {
        let locationManager = CLLocationManager()
        locationManager.delegate = self
        return locationManager
    }()

    private var continuation: CheckedContinuation<CLLocation, Error>?

    func getCurrentLocation() async throws -> CLLocation {
        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CLLocation, Error>) in
            self.continuation = continuation
            self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
            if CLLocationManager.locationServicesEnabled() {
                self.locationManager.requestLocation()
            }
        }
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        print("Auth changed")
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        print("Location update received")
        guard let latestLocation = locations.last else {
            self.continuation?.resume(with: .failure(LocationManagerError.unknown))
            return
        }
        self.continuation?.resume(with: .success(latestLocation))
        self.continuation = nil
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("Location update error'd")
        self.continuation?.resume(with: .failure(error))
        self.continuation = nil
    }
}

This might be a clue as to why the delegate calls are MIA (from CLLocationManager | Apple Developer Documentation):

Core Location calls the methods of your delegate object using the RunLoop of the thread on which you initialized CLLocationManager. That thread must itself have an active RunLoop, like the one found in your app’s main thread.

1 Like

Ahhh... as soon as I posted that documentation snipped above, I had a thought and tried to create the CLLocationManager instance outside of my async function (by making it non-lazy) and voilá, everything started working as expected.

Yes! Putting everything on the main actor made it work for me. Thank you! It does introduce a lot of thread hops in my code though – I wonder if we can make one of Swift's cooperative thread pool's threads have their own active RunLoop too.

I haven't dug into the project, but with the docs @rog points to, it's probably not safe to pass a CLLocationManager between concurrent boundaries, as the code is doing above using UncheckedSendable. You could try initializing it directly in the actor to see if that helps its threading model work better. It also might be the case that CLLocationManager might not be safe to use in an actor yet.

AFAIU, I already am initializing it directly in the actor, which is what confuses me.

I did some digging about the interaction between actors and RunLoop, and found something interesting – someone modelled a RunLoop as a global actor, which I could just annotate onto the class holding the CLLocationManager https://github.com/rustle/AccessibilityElement/blob/main/Sources/AccessibilityElement/Observer/ObserverRunLoopActor.swift.

So I added the RunLoop global actor, and it worked! The delegate methods are getting called correctly. Here's the updated code – tapit-app/Live.swift at main · nikitamounier/tapit-app · GitHub.

Do you think there's anything dangerous going on?

@stephencelis Sorry to revive this thread, but is there a better way to guarantee CLLocationManager is created on the main thread without marking the entire client as @MainActor or creating a custom actor/runLoop like @nikitamounier outlined?

For anyone who is running into the same issue, an easy way to guarantee CLLocationManager gets initialized on the main thread is to add _ = LocationManager.live to your AppDelegate’s didFinishLaunching method.

I don’t like it, but it’s the best way I can think of at this time.

1 Like

I my case I just added @MainActor to the .run execution

  return .run { @MainActor send in
      await locationManager.requestPermission()
      send(.something) 
   }