Allowing User-Provided Isolation

I'm attempting to wrap some closure API which provides a completion handler to return a value asynchronously into an API which provides an async context to provide the value instead. Unfortunately, unlike the version which uses a DispatchQueue to control the context on which the provided closure is called, I can't seem to find a way to allow the user to provide an isolation domain for their async closure. In essence, I'd like to provide a similar capability to Task but two layers removed. However, none of the combinations of isolation features seem to provide what I want. The closest I've gotten is this:

@discardableResult
public func onHTTPResponse<Isolation>(
    isolatedTo isolation: Isolation,
    perform handler:  @escaping @Sendable (_ response: HTTPURLResponse) async -> ResponseDisposition
) -> Self where Isolation: Actor {
    @Sendable func performHandler(
        isolatedTo isolation: isolated Isolation,
        response: HTTPURLResponse,
        @_inheritActorContext handler: @escaping @Sendable (HTTPURLResponse) async -> ResponseDisposition
    ) async -> ResponseDisposition {
        print("performHandler", Thread.isMainThread)
        return await handler(response)
    }

    onHTTPResponse(on: underlyingQueue) { response, completionHandler in
        Task {
            print("inResponse", Thread.isMainThread)
            let disposition = await performHandler(isolatedTo: isolation, response: response, handler: handler)
            completionHandler(disposition)
        }
    }
    return self
}

When called as

.onHTTPResponse(isolatedTo: MainActor.shared) { response in
  print("inClosure", Thread.isMainThread)
                
  return .allow
}

in a test, it prints

inResponse false
performHandler true
inClosure false

As I understand it, the isolation contexts work something like this:

  • DataRequest, which defines onHTTPResponse, is not actor isolated, it provides its own thread-safety, so...
  • When the closure is called, it occurs on the underlyingQueue, so...
  • The Task created has no actor isolation, so it executes its closure on the default executor, so...
  • performHandler, which takes an isolated actor parameter, executes its sync context in that isolation, so...
  • I would expect the use of @_inheritActorContext on the handler parameter to execute that closure in the provided actor's isolation, however...
  • it does not, executing instead on the default executor.

I can't tell whether this is a bug in that @_inheritActorContext isn't properly capturing the isolated actor, @_inheritActorContext isn't supposed to capture isolated contexts in the first place, adding @_inheritActorContext to an existing closure without it doesn't work, or there's a flaw in my reasoning.

(The underlying Alamofire work is tracked in this PR.)

1 Like

The completion handler needs to take an isolated Actor parameter for this to work.

My general experience with trying to add similar things to Realm is that this is very much not how Swift Concurrency wants to work, and while you kinda can dynamically isolate things to user-provided actors you have to fight the language the whole way.

Interesting, that does seem to work. Terrible DX though, so I can't ship it.

I do still wonder whether @_inheritActorContext could provide the solution here and why it currently doesn't.