Making makeshift/partial generic isolation using generic global actors

One day, while messing with global actors, I found out that they can have generic parameters.

@globalActor
struct GenericGlobalActor<T> {
    static var shared: MainActor { .shared }
}

@GenericGlobalActor<Int>
func foo() { print(#isolation) }

await foo()

When I discovered this, an idea came to me. Can I use this to make generic isolation sinde since there is no direct support for it?


After a bit of work and dancing with EXC_BAD_ACCESS crashes, I finally found a stable solution. This approach uses a helper in-between layer to achieve generic isolation.

Long Implementation
// You could simplify this a lot if you only need generic global actor isolation.

// MARK: Instance ID

protocol ActorInstanceProtocol<ActorType>: SendableMetatype {
    associatedtype ActorType: InstancedActor
    associatedtype ID: GNumber = IN0

    static var id: UInt { get }
    static var shared: ActorType { get }
}

extension ActorInstanceProtocol {
    static var id: UInt { ID.value }
}

enum ActorInstance<
    ActorType: InstancedActor,
    ID: GNumber
>: ActorInstanceProtocol {
    static var shared: ActorType {
        ActorType.instances.withLock { $0.get(with: id) }
    }
}

// MARK: - GNumber

// Integer generic parameters has a high version requirement and are a bit limiting.
protocol GNumber: SendableMetatype {
    static var value: UInt { get }
}

enum IN0: GNumber { static let value: UInt = 0 }
enum IN1: GNumber { static let value: UInt = 1 }
enum IN2: GNumber { static let value: UInt = 2 }
enum IN3: GNumber { static let value: UInt = 3 }
enum IN4: GNumber { static let value: UInt = 4 }
enum IN5: GNumber { static let value: UInt = 5 }
enum IN6: GNumber { static let value: UInt = 6 }
enum IN7: GNumber { static let value: UInt = 7 }
enum IN8: GNumber { static let value: UInt = 8 }
enum IN9: GNumber { static let value: UInt = 9 }

struct DynamicInstanceNumber<
    N9: GNumber,
    N8: GNumber,
    N7: GNumber,
    N6: GNumber,
    N5: GNumber,
    N4: GNumber,
    N3: GNumber,
    N2: GNumber,
    N1: GNumber,
    N0: GNumber
>: GNumber {
    static var value: UInt {
        let digitTypes: [any GNumber.Type] = [
            N0.self, N1.self, N2.self, N3.self, N4.self,
            N5.self, N6.self, N7.self, N8.self, N9.self
        ]

        var iterator = digitTypes.makeIterator()
        var multiplier: UInt = 1
        var result: UInt = iterator.next()!.value

        while let digitType = iterator.next() {
            multiplier *= 10
            result += multiplier * (digitType.value)
        }

        return result
    }
}

func instanceNumber(from int: UInt) -> any GNumber.Type {
    func constructor<
        N9: GNumber,
        N8: GNumber,
        N7: GNumber,
        N6: GNumber,
        N5: GNumber,
        N4: GNumber,
        N3: GNumber,
        N2: GNumber,
        N1: GNumber,
        N0: GNumber
    >(
        type9: N9.Type,
        type8: N8.Type,
        type7: N7.Type,
        type6: N6.Type,
        type5: N5.Type,
        type4: N4.Type,
        type3: N3.Type,
        type2: N2.Type,
        type1: N1.Type,
        type0: N0.Type,
    ) -> any GNumber.Type {
        DynamicInstanceNumber<N9, N8, N7, N6, N5, N4, N3, N2, N1, N0>.self
    }

    func typeFromDigit(_ digit: UInt) -> any GNumber.Type {
        switch digit {
        case 0: IN0.self
        case 1: IN1.self
        case 2: IN2.self
        case 3: IN3.self
        case 4: IN4.self
        case 5: IN5.self
        case 6: IN6.self
        case 7: IN7.self
        case 8: IN8.self
        case 9: IN9.self

        default:
            fatalError()
        }
    }

    var int = int

    var digitTypes = Array<any GNumber.Type>(repeating: IN0.self, count: 10)

    var digit = 0

    while int > 10 {
        digitTypes[digit] = typeFromDigit(int % 10)
        int = int/10
        digit += 1
        precondition(digit < 10, "Digit count overflow")
    }

    digitTypes[digit] = typeFromDigit(int % 10)

    return constructor(
        type9: digitTypes[9],
        type8: digitTypes[8],
        type7: digitTypes[7],
        type6: digitTypes[6],
        type5: digitTypes[5],
        type4: digitTypes[4],
        type3: digitTypes[3],
        type2: digitTypes[2],
        type1: digitTypes[1],
        type0: digitTypes[0]
    )
}

// MARK: - Instance Find-ability

// You may be asking why these conform to AnyObject rather than Actor.
// Well if you try to, MainActor will start giving you EXC_BAD_ACCESS crashes
// in the most random places.
typealias InstancedActor = Instanced & Actor

protocol Instanced: AnyObject {
    static var instances: Mutex<Instances<Self>> { get }
}

struct Instances<T: AnyObject> {
    var references: Set<Weak<T>> = []
    var nextID: UInt = 0

    init() {
        references = []
        nextID = 0
    }

    init(_ actor: T) {
        references = [Weak(actor, with: 0)]
        nextID = 1
    }

    init(_ actors: [T]) {
        references = []
        nextID = 0

        actors.forEach { put($0) }
    }

    mutating func put(_ actor: T) {
        references.insert(Weak(actor, with: nextID))
        nextID += 1
    }

    func get(with id: UInt) -> T {
        let i = references
        let weakInstance = i.first { $0.id == id }

        guard let instance = weakInstance?.value else {
            preconditionFailure("Attempted access on deallocated actor")
        }

        return instance
    }

    mutating func remove(with id: Int) {
        let weakInstance = references.first { $0.id == id }

        guard let weakInstance else {
            preconditionFailure("Attempted access on deallocated actor")
        }

        references.remove(weakInstance)
    }

    mutating func cleanup() {
        let deallocated = references.filter { $0.value == nil }
        references.subtract(deallocated)
    }
}

extension Instanced where Self: Actor {
    // You can drop down the required version lower by wrapping this type.
    nonisolated var instanceID: any ActorInstanceProtocol<Self>.Type {
        func construct<ID: GNumber>(
            id: ID.Type
        ) -> any ActorInstanceProtocol<Self>.Type {
            ActorInstance<Self, ID>.self
        }

        return Self.instances.withLock { instance in
            let instance = instance.references.first { $0.value === self }

            guard let instance else {
                fatalError("Actor has not been registered")
            }

            return construct(id: instanceNumber(from: instance.id))
        }
    }

    nonisolated func addInstance() {
        Self.instances.withLock { $0.put(self) }
    }

    nonisolated func removeInstance() {
        Self.instances.withLock { $0.cleanup() }
    }
}

private let _mainActorSharedMutex = Mutex(Instances<MainActor>(.shared))

extension MainActor: ActorInstanceProtocol, Instanced {
    static var instances: Mutex<Instances<MainActor>> { _mainActorSharedMutex }
}

// MARK: - Generic Isolation Global Actor

@globalActor
struct GenericIsolation<I: ActorInstanceProtocol> {
    static var shared: I.ActorType {
        return I.shared
    }
}

extension Instanced where Self: ActorInstanceProtocol {
    static func withGenericOverlay<T: Sendable>(
        _ operation: @GenericIsolation<Self> () throws -> T,
        file: StaticString = #fileID,
        line: UInt = #line
    ) rethrows -> T {
        typealias RawClosure = () throws -> T

        GenericIsolation<Self>.preconditionIsolated()

        return try withoutActuallyEscaping(operation) { closure in
            let rawClosure = unsafeBitCast(closure, to: RawClosure.self)
            return try rawClosure()
        }
    }
}

// This crashes the compiler.
//
//extension Instanced where Self: Actor {
//    func withGenericOverlay<I: ActorInstanceProtocol<Self>, T: Sendable>(
//        _ operation: @GenericIsolation<I> () throws -> T,
//        isolation: I.Type,
//        file: StaticString = #fileID,
//        line: UInt = #line,
//    ) rethrows -> T {
//        typealias RawClosure = () throws -> T
//
//        return try withoutActuallyEscaping(operation) { closure in
//            let rawClosure = unsafeBitCast(closure, to: RawClosure.self)
//            return try rawClosure()
//        }
//    }
//}

// As a workaround, erasing the return value to Any works:

extension Instanced where Self: Actor {
    @discardableResult
    func withGenericOverlay<I: ActorInstanceProtocol<Self>>(
        _ operation: @GenericIsolation<I> () throws -> Any,
        // Sadly you need to provide this manually.
        // Swift doesn't allow `instanceID` to be the default argument.
        isolation: I.Type,
        file: StaticString = #fileID,
        line: UInt = #line,
    ) rethrows -> Any {
        typealias RawClosure = () throws -> Any

        preconditionIsolated("\(#function) misuse, isolation does not match outer isolation", file: file, line: line)

        return try withoutActuallyEscaping(operation) { closure in
            let rawClosure = unsafeBitCast(closure, to: RawClosure.self)
            return try rawClosure()
        }
    }
}

extension GenericIsolation {
    static func _withCoreIsolation_core<T: Sendable>(
        _ operation: @isolated(any) () throws -> T,
        file: StaticString = #fileID,
        line: UInt = #line
    ) rethrows -> T {
        preconditionIsolated("withCoreIsolation misuse, isolation does not match outer isolation", file: file, line: line)

        func withoutIsolatedAny(_ rawClosure: () throws -> T) rethrows -> T {
            return try rawClosure()
        }

        return try withoutIsolatedAny(operation)
    }
}

extension GenericIsolation where I == MainActor {
    static func withCoreIsolation<T: Sendable>(
        _ operation: @MainActor () throws -> T,
        file: StaticString = #fileID,
        line: UInt = #line
    ) rethrows -> T {
        try _withCoreIsolation_core(operation, file: file, line: line)
    }
}

// MARK: - Miscellaneous

struct Weak<T: AnyObject>: Hashable {
    weak let value: T?
    var id: UInt = 0

    init(_ value: T, with id: UInt) {
        self.value = value
        self.id = id
    }

    static func == (lhs: Weak<T>, rhs: Weak<T>) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

final class Mutex<Value>: @unchecked Sendable {
    private var lock = NSLock()
    private var value: Value

    init(_ value: Value) {
        self.value = value
    }

    func withLock<R: ~Copyable, E>(_ body: (inout Value) throws(E) -> R) throws(E) -> R {
        lock.lock()
        defer { lock.unlock() }
        return try body(&value)
    }
}

Here are some examples & explanations:

// This is how you support a GlobalActor.
@globalActor
actor DatabaseActor: Instanced, ActorInstanceProtocol {
    static let instances: Mutex<Instances<DatabaseActor>> = Mutex(.init(shared))
    static let shared = DatabaseActor()
}

extension GenericIsolation where I == DatabaseActor {
    static func withCoreIsolation<T: Sendable>(
        _ operation: @DatabaseActor () throws -> T,
        file: StaticString = #fileID, line: UInt = #line
    ) rethrows -> T {
        try _withCoreIsolation_core(operation, file: file, line: line)
    }
}

// An actor that can have its instances be isolated to.
actor Test: Instanced {
    init() { addInstance() }
    deinit { removeInstance() }

    static let instances = Mutex(Instances<Test>())
}

// Since singletons *kind of* look like global actors.
actor A123: ActorInstanceProtocol, Instanced {
    static let shared: A123 = .init()
    private init() {}

    static let instances: Mutex<Instances<A123>> = Mutex(.init(shared))
}

// Any isolation check still passes since it's just a "wrapper" for the actor.
@GenericIsolation<MainActor>
func preconditionIn() {
    MainActor.preconditionIsolated() // This check passes.
}

// The other way around is also true.
@MainActor
func preconditionOut() {
    GenericIsolation<MainActor>.preconditionIsolated() // This check passes.
}

Task { @GenericIsolation<MainActor> in
    preconditionIn()
}

await Task.yield()

preconditionOut()

// Works, but needs external support with the parameter.
// It doesn't resolve automatically from the context.
@GenericIsolation<I>
func genericIsolatedFunction<I: ActorInstanceProtocol>(isolation: I.Type) {}

// Works. The same applies to this one, too.
func closuredFunction<I: ActorInstanceProtocol>(
    isolation: I.Type = I.self,
    closure: @GenericIsolation<I> () -> Void
) {}

typealias IsolatedClosure = @GenericIsolation<DatabaseActor> () -> Void

// But, the function infers it if you do it like this.
closuredFunction(closure: {} as IsolatedClosure)

// Surprisingly works.
// You can access generic parameters from the global actor specifier.
// Everything below is valid.
@GenericIsolation<I>
struct Structure<I: ActorInstanceProtocol> {
    nonisolated init(_ foo1: @escaping @GenericIsolation<I> () -> Void) {
        self.foo1 = foo1
    }

    var foo1: @GenericIsolation<I> () -> Void

    @GenericIsolation<I>
    var foo2: () -> Void = {}

    @GenericIsolation<I>
    func foo3() { print("Structure \(#function):", #isolation) }

    // This is also isolated.
    func foo4() { print("Structure \(#function):", #isolation) }
}

// Function for testing purposes.
@GenericIsolation<I>
func foo<I: ActorInstanceProtocol>(isolation: I.Type) {
    print("\(#function):", #isolation)
}

let test: Test = Test()

// You can isolate it to individual actor instances.
await foo(isolation: test.instanceID)

// Works.
let structure = Structure<MainActor> {
    foo(isolation: MainActor.self)
}

// You normally can't call this.
// GenericIsolation<X> and X are not callable within each other.
// But you can with the help of the `withGenericOverlay` and
// `withCoreIsolation` functions.
MainActor.withGenericOverlay {
    structure.foo3()
    structure.foo4()
}

// An erased isolator.
let anyIsolator: any ActorInstanceProtocol.Type = MainActor.self

// This fails to compile as expected.
//
// MainActor.withGenericOverlay {
//     foo(isolation: anyIsolator)
// }

// Also this.
//
// Task { @DatabaseActor in
//     DatabaseActor.withGenericOverlay {
//         foo(isolation: anyIsolator)
//     }
// }

// But you can call it in an async context without a problem.

Task {
    await foo(isolation: anyIsolator)
}

await Task.yield()

// Works.
// You have to use the Self prefix for protocols in the header for some reason.
@GenericIsolation<Self.I1>
protocol `Protocol`<I1> {
    associatedtype I1: ActorInstanceProtocol
    associatedtype I2: ActorInstanceProtocol
    associatedtype T

    func foo() -> T

    @GenericIsolation<I2>
    func bar()
}

// The isolation does get resolved from the protocol.
struct ProtocolImplementer: `Protocol` {
    typealias I1 = MainActor
    typealias I2 = DatabaseActor

    func foo() -> Int {
        print("ProtocolImplementer", #function, #isolation)
        return 0
    }

    func bar() { print("ProtocolImplementer", #function, #isolation) }
}

let implementer = ProtocolImplementer()

// Works.
_ = MainActor.withGenericOverlay {
    implementer.foo()
}

// Works.
Task { @DatabaseActor in
    DatabaseActor.withGenericOverlay {
        implementer.bar()
    }
}

await Task.yield()

// Existential types also work. Constrained existentials work with a workaround.
let existential: any `Protocol`<MainActor> = ProtocolImplementer()

await existential.bar()

@GenericIsolation<MainActor>
func open<T: `Protocol`<MainActor>>(_ value: T) -> T.T {
    value.foo()
}

MainActor.withGenericOverlay { [existential] in
    let value = open(existential)

    print("foo value", value)
}

// I tried to write an eraser but I couldn't find a valid implementation for it.

// I couldn't make isolated conformances work for some reason?
//
// protocol ProtocolFoo {
//     func foo()
// }
//
// @GenericIsolation<I>
// class Foo<I: ActorInstanceProtocol>: @GenericIsolation<I> ProtocolFoo {
//     var test: String = "hi"
//
//     @GenericIsolation<I> func foo() {
//         print(test)
//     }
// }
//
// func fooTest<T: ProtocolFoo>(_ input: T) { input.foo() }
//
// let foo2 = Foo<DatabaseActor>()
// fooTest(foo2)

var deallocatingActor: Test? = Test()

Task { [instanceID = deallocatingActor!.instanceID] in
    await foo(isolation: instanceID)
    _ = ()
}

// This is a danger that I found. Although, I provided some safety checks that
// crash the program to not cause undefined behaviour. You can hold a reference
// to the actor in the closure or by wrapping the instanceID.
//
// deallocatingActor = nil
// deallocatingActor = Test()

await Task.yield()

// I haven't tested with distributed actors because I don't have the best
// knowledge on them.
//
// You could probably a very simple macro that gets the current isolation and
// converts it to the required format for generic isolation.

It's not the best. Some things don't work or require a workaround, it could probably be improved in some angles and there may be some issues that I missed. But still, I partially managed to make generic isolation work, yay.