Thank you for the example of how key-paths can be problematic when used with actor-isolated properties. I think I understand, but I want to poke at this a bit more to see if we can limit the restrictions. It seems that the crux of the problem is that using a key-path may take what looks like a synchronous call and require it to be async, as shown in your example above:
let chainedLookup = \A.next?.next
await A().doLookup(chainedLookup)
You called out that the initial lookup (\A.next) is either sync or async depending on the actor-isolation state of our access to the actor performing the key-path lookup. On the other hand, the next segment of the key-path is unconditionally async, even when the calling context would be synchronous. It seems to me that that is where the problem is.
If I have isolated access to an actor instance (e.g. on self within the actor), any of its immediate properties are be safe to access synchronously; doing so can never change the access from synchronous to asynchronous. Likewise, I can access the chained properties of any struct/enum* properties, because they can never force the access to become asynchronous.
The problem arises when applying a key path that forces an otherwise-synchronously-accessible key path to become asynchronous. It seems to me that that happens when the key-path goes through an actor instance. So for example:
actor A {
var next: A?
var identifier: String
var size: CGSize
func doLookup<T>(_ kp : KeyPath<A, T>) -> T {
return self[keyPath: kp]
}
}
let a = A()
// This keyPath should be safe; it cannot force an async lookup
let id = a.doLookup(\.identifier)
// A chained lookup can be safe too
let height = a.doLookup(\.size.height)
// Retrieving a reference to another actor is also safe;
// it cannot force an async lookup
let next = a.doLookup(\.next)
// Things to wrong when we try to go _through_ an actor
let problem = a.doLookup(\.next.identifier)
This suggests to me that it might be possible to only restrict key-paths that go through an actor-isolation change; that is, they start in one isolation context but end in a different one.
There's also a potential issue with appending key paths, though:
// This never causes an isolation change, so it's okay
let structPath = \A.size.height
// This never causes an isolation change, so it's okay
let pathToActor = \A.next
// This is a problem; now we're going _through_ an actor.
let combinedPath = pathToActor.appending(structPath)
Since we can append key-paths together, it seems that individually-isolated key paths cannot be combined safely. That's a problem. If we prohibit key-paths to actors, though, then it seems safe? So a key-path from an actor is fine, so long as it never goes to (or through) another actor.
Incidentally, it's not clear to me that removing Sendable conformance from KeyPath would resolve the safety problem:
extension A {
func doBadStuff() {
let asyncPath = \A.next?.next
// We have isolated access to `self` here, but using
// badPath would still cause an asynchronous lookup.
self[keyPath: badPath]
}
}
So yes, thank you for helping me to see the potential problems with key-paths. I do think that it may be safe to form a key path so long as it never goes to an actor. But there is sufficient complexity here that it may be better to accept the full restriction for now; we can always relax the restriction later.
* I think this applies to classes too, but I haven't fully digested the implications of classes within actors, and I don't want to get distracted on that point because I don't think it's relevant to my argument here.