Making a protocol generic over actor isolation for callback closures

I have a protocol where implementations hook into some events and forward them via a listener closure:

protocol Interceptor<Event> {
    associatedtype Event
    func listen(_ listener: @escaping (Event) -> Void)
}

With Swift concurrency, the listener needs an isolation annotation. The obvious choice is @Sendable, but that's overly restrictive. Some interceptors guarantee they call the listener on a specific actor. For example, some interceptors always fire on @MainActor. Marking the closure @Sendable forces callers to hop back to MainActor unnecessarily:

// With @Sendable listener, caller can't assume MainActor:
interceptor.listen { event in
    MainActor.assumeIsolated { self.updateUI(event) } // runtime check, not compile-time
}

I want the protocol to let each conformance declare the isolation context (or lack thereof) in which the listener is called, so callers get compile-time safety.

Solutions considered

1. Protocol hierarchy

protocol SendableInterceptor<Event> {
    associatedtype Event
    func listen(_ listener: @escaping @Sendable (Event) -> Void)
}

protocol MainActorInterceptor<Event> {
    associatedtype Event
    func listen(_ listener: @escaping @Sendable @MainActor (Event) -> Void)
}

That works. @MainActor on the closure gives the caller synchronous MainActor access with no await:

func register(interceptor: some MainActorInterceptor<Event>) {
    interceptor.listen { event in
        updateUI(event) // MainActor-isolated, compile-time safety
    }
}

But it requires a separate protocol per isolation context, which is a bit weird.

2. Async callback + caller annotation

func listen(_ listener: @escaping @Sendable (Event) async -> Void)

Callers could annotate { @MainActor event in ... }, but every callback goes through Task even when you're already on the right actor.


Is there a way to express a protocol that is generic over isolation context (including nonisolated), without splitting into separate protocol types? Something like an Isolation generic parameter that can be MainActor, another global actor, or "nonisolated," and that annotates the closure in a way the compiler understands at the call site?

2 Likes

Have you tried isolated conformance? I think this is probably what you're looking for.

protocol Interceptor<Event> {
    associatedtype Event
    func listen(_ listener: @escaping (Event) -> Void)
}

class NS {}

@MainActor
struct S: @MainActor Interceptor {
    typealias Event = NS

    func listen(_ listener: @escaping (Event) -> Void) {
        // ...
    }

    func updateUI(_ event: Event) {}

    func test() {
        listen { event in 
            updateUI(event)
        }
    }
}

Note that the closue passed toInterceptor.listen doesn't even need to be Sendable. So it can capture non-Sendablve values (the code doesn't demonstrate it).

EDIT: if the event producer runs in a different isolation, the closure needs to be Sendable.

1 Like

I find these statements in contradiction with itself. If the guarantee is that they always fire on the MainActor then the closure will by definition also run on the MainActor. Or perhaps I'm misunderstanding something?

Interceptor is the event producer (it intercepts an event and broadcasts it to listeners). I don’t see how isolated conformances will help here though.

The code indeed runs on MainActor, but the compiler doesn’t know that at compile time.

2 Likes

NotificationCenter has a similar problem where a notification may be guaranteed to be delivered on MainActor or on arbitrary threads, and so the observer closure may need to be @Sendable or @MainActor.

NotificationCenter handles this by providing two protocols MainActorMessage and AsyncMessage and requiring messages to conform to MainActorMessage or AsyncMessage based on their isolation which is very similar to solution 1 described in the original post so I suppose that’s the way to handle it today.

1 Like