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
}
}
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.
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.
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:
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.
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.
@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.