Cannot form key path to actor-isolated property

I was thinking about creating a DI using Actors to ensure data race safety and was exploring the idea. But this error was fired in my face :sweat_smile: saying "Cannot form key path to actor-isolated property 'networkProvider' " . I don't understand the error and if it's not allowed to write keyPath for actors ?

Here is the code:

public actor InjectedValues {
    /// This is only used by the subscript to access the computed property's in the ``InjectedValues`` extension.
    private static var current = InjectedValues()
    
    /// This is a static subscript to read and update the currentValue of the ``InjectionKey``.
    public static subscript<K>(key: K.Type) -> K.Value where K: InjectionKey {
        get { key.currentValue }
        set { key.currentValue = newValue }
    }
    
    /// This is a static subscript to reference and update the dependencies directly.
//    public static subscript<T>(_ keyPath: ReferenceWritableKeyPath<InjectedValues, T>) -> T {
//        get async { current[keyPath: keyPath] }
//    }

    public static func update<T>(_ keyPath: ReferenceWritableKeyPath<InjectedValues, T>, _ value: T) async {
        self.current[keyPath: keyPath] = value
    }
}

// Usage 
await InjectedValues.update(\.networkProvider, NetworkProviderMock()) // Error: Cannot form key path to actor-isolated property 'networkProvider
1 Like

After some experimentation, it seems that keypaths to global-actor isolated properties can be formed only in the same isolation. But keypaths to self-isolated properties cannot be formed even inside the isolation.

And keypath types are non-sendable, which I guess is intended to prevent usage of isolated property keypaths in wrong isolation domains. But unfortunately keypath literals seem to be created in disconnected regions, which still allows keypaths to be sent to a different domain, and cause a data race:

@MainActor
class MA {
	var x: Int = 4
}

nonisolated final class S: Sendable {

}

nonisolated final class DI: Sendable {
	nonisolated func service<ServiceType>(keyPath: KeyPath<DI, ServiceType>) -> ServiceType {
		fatalError()
	}
}

extension DI {
	@MainActor
	var ma: MA {
		service(keyPath: \.ma)
	}

	nonisolated var s: S {
		service(keyPath: \.s)
	}
}

actor MyActor {
	var x: Int = 2

	func test() {
		isolatedProbe(keyPath: \MyActor.x) // error: cannot form key path to actor-isolated property 'x'
	}
}

nonisolated func probe<T, U>(keyPath: @autoclosure @Sendable () -> KeyPath<T, U>) {
	print("nonisolated")
}

@_disfavoredOverload
@MainActor
func probe<T, U>(keyPath: KeyPath<T, U>) {
	print("MainActor")
}


func isolatedProbe<T, U>(keyPath: KeyPath<T, U>, isolation: (any Actor) = #isolation) {
	print(isolation)
}

nonisolated func hack<T: Sendable, U: Sendable>(_ base: T, _ keyPath: sending KeyPath<T, U>) async {
	print(base[keyPath: keyPath])
}

@MainActor func test() async {
	probe(keyPath: \DI.s) // ok, first overload
	probe(keyPath: \DI.ma) // picks first overload and produces an error: cannot form key path to main actor-isolated property 'ma'
	isolatedProbe(keyPath: \DI.ma) // ok
	isolatedProbe(keyPath: \MA.x) // ok

	let a = MyActor()
	await a.test()

	await hack(MA(), \MA.x) // ⚠️ passes!
	await hack(DI(), \DI.ma) // ⚠️ passes!
	await hack(DI(), \DI.s) // ⚠️ passes!
}

EDIT: Reported as Keypath literals are created in disconnected regions allowing them to be sent to another isolation and cause data race · Issue #80388 · swiftlang/swift · GitHub

2 Likes

This is definitely a bug, the intention is for isolated key paths to be merged into the actor's region when they're formed. Thank you for the GitHub issue!

1 Like

@hborla @Nickolas_Pohilets
I feel confuse a little bit, I don't get it yet can I or can't I use KeyPath with Actors ?

Here's an issue I filed that led to this hole (wrt global actors) being fixed: KeyPath discards global actor · Issue #66830 · swiftlang/swift · GitHub

This predates sending, though, which might explain why that loophole slipped through the cracks. KeyPaths+concurrency always seems to have difficult edge cases.

I can't speak to actor instances, but it would seem to make sense that a KeyPath could be formed and used in that context, but not sent out of it. So I guess that's a bug too.

You cannot use key paths to self-isolated actor properties. But you can to nonisolated or global-actor isolated. Key paths to global-actor isolated properties can be constructed only in code isolated to that actor.

Self-isolated actor key paths are not safe even when formed inside isolated code, because they could be applied to another actor instance.

In theory, you should be able to re-invent such key-paths yourself:

struct WritableActorKeyPath<Root: Actor, Value>: Sendable {
    var getter: @Sendable (isolated Root) -> Value
    var setter: @Sendable (isolated Root, Value) -> Void

    subscript(_ root: isolated Root) -> Value {
        get { getter(root) } // error: call to actor-isolated function in a synchronous actor-isolated context
        nonmutating set { setter(root, newValue) } // error: call to actor-isolated function in a synchronous actor-isolated context
    }
}

actor MyActor {
    var likes: Int = 0
    var dislikes: Int = 0

    func vote(_ preference: WritableActorKeyPath<MyActor, Int>) {
        preference[self] += 1
    }
}

let likes = WritableActorKeyPath<MyActor, Int>(
    getter: { $0.likes },
    setter: { (root: isolated MyActor, value) in root.likes = value }
)

func test() async {
    let a = MyActor()
    await a.vote(likes)
}

But looks like there is another bug that prevents this. @hborla, FYI. Reported as False positive in isolation checking subscript isolated to an argument · Issue #80992 · swiftlang/swift · GitHub and fixed in Fixed no copying IsIsolated flag when cloning subscript params by nickolas-pohilets · Pull Request #81022 · swiftlang/swift · GitHub.

Not critical though, you can still use getter and setter directly:

    func vote(_ preference: WritableActorKeyPath<MyActor, Int>) {
        preference.setter(self, preference.getter(self) + 1)
    }

Or declare subscript on Actor instead of key path type:

extension Actor {
    subscript<Value>(keyPath keyPath: WritableActorKeyPath<Self, Value>) -> Value {
        get { keyPath.getter(self) }
        set { keyPath.setter(self, newValue) }
    }
}

actor MyActor {
    var likes: Int = 0
    var dislikes: Int = 0

    func vote(_ preference: WritableActorKeyPath<MyActor, Int>) {
        self[keyPath: preference] += 1
    }
}
3 Likes