I've been playing with this suggestion and spotted an interesting thing.
The complete runnable code snippet can be found here (same repository, added new tests file to isolate the example):
So, the issue is:
the bitcast works fine, but when I actually call the container (and thus call the closure), the arguments are corrupted for the isolated closures (nonisolated work fine).
@MainActor
@Test
func callAsFunction_shouldCallIsolatedClosure() {
struct Arg {
let bool: Bool
let int: Int
}
let sample = Arg(bool: true, int: 42)
let isolatedClosure: @MainActor (Arg) -> Void = { arg in
#expect(arg.bool) // Expectation failed: (arg → Arg(bool: false, int: 10162940616)).bool → false
#expect(arg.int == 42) // Expectation failed: (arg.int → 10162940616) == 42
}
let sut = CompletionContainer(isolatedClosure)
sut.callAsFunction(sample)
}
So, I played a bit with it and thought that we don't actually have to cast the closure to (any Actor, Output) -> Void, we've got the actor anyways, then we cast the closure to the nonisolated one and assert the isolation later like this:
enum EditedCompletionContainer<Output> {
case nonisolated((Output) -> Void)
case isolated (any Actor, (Output) -> Void) // an actor, and a nonisolated closure
init(
_ fn: @escaping @isolated(any) (Output) -> Void
) {
// Due to runtime requirements for interacting with @isolated(any)
guard #available(macOS 15.0, iOS 18.0, *) else {
fatalError("Unsupported runtime!")
}
typealias OriginalFunction = @isolated(any) (Output) -> Void
typealias NonIsolatedFunction = (Output) -> Void
assert(MemoryLayout<NonIsolatedFunction>.size == MemoryLayout<OriginalFunction>.size)
switch fn.isolation {
case .none:
let ret = unsafeBitCast(fn, to: NonIsolatedFunction.self) // cast the closure to the nonisolated one
self = .nonisolated(ret) // store as is
case .some(let actor):
let ret = unsafeBitCast(fn, to: NonIsolatedFunction.self) // cast the closure to the nonisolated one
self = .isolated(actor, ret) // store a reference to the actor and the casted closure
}
}
func callAsFunction(_ output: Output) -> Void {
switch self {
case .nonisolated(let fn):
return fn(output)
case .isolated(let actor, let fn):
actor.preconditionIsolated("Incorrect isolation assumption!")
return fn(output) // call the nonisolated closure
}
}
}
If I do it like this, then the tests consistently pass. But the question is - did I make a correct assumption, or do I miss something fundamental?