How can I "change the type" of an parameter in an synchronous @isolated(any) closure?

I am currently trying to "change the type" of a parameter in a closure. I have an implementation that works for normal closures. But if I mark the closures with @isolated(any) the code fails to compile.

// Call to @isolated(any) parameter 'input' in a synchronous nonisolated context
func foo(_ closure: @isolated(any) (String) -> Void) {
    bar({ parameter in closure("\(parameter)") })
}

func bar(_: @isolated(any) (Int) -> Void) {}

I understand that the compiler cannot guarantee if the closures have the same isolation, or not. But I don't know how to fix this issue. I cannot change the bar function.

How can I make this work?

An @isolated(any) function that is neither sending nor @Sendable is always difficult to use. But on top of that, it is synchronous, which is also strange and typically very difficult to use.

I'm not sure there is a transformation you can make here that will be both valid and also compatible. I'm not saying it does not exist, but I'm failing to figure out how you could do it without modifying bar.

1 Like

Are there any legitimate use cases for synchronous @isolated(any) closures? If not, I don't think I need to support it. I have been trying for a while. I'm not an expert at concurrency.

1 Like

There are, but they are extremely narrow and I would consider it a very advanced tool. It can be useful if you need to write code that can work for any isolation but can tolerate exactly one suspension but none internal to the function itself. There could be others as well, though I have not encountered them.

An @isolated(any) closure which does not have sending or @Sendable also could potentially have uses. I have never seen any though, but I suspect it could only work by relying on the compiler deciding a transfer is safe.

Another thing I just noticed is that bar is a synchronous function with a non-escaping argument. I think such a function cannot possibly have a valid internal implementation without introducing the possibility of data races.

Long story short: I think it is at least conceivable that bar works right internally and is just missing some annotations. But I think it is much more likely that this is hard to make work because bar does not actually work right internally, at least in some cases which its signature supports.

1 Like

The language will let you "cast off" the isolation in various ways, though I'm not sure any of them are intended, and they certainly open the door to data races. But something like this would work in your case I think:

func foo(_ closure: @isolated(any) (String) -> Void) {
    func callWithoutIsolation(_ cl: (String) -> Void) {
        bar({ parameter in cl("\(parameter)")})
    }

    // "manually" check that we're on the expected executor
    closure.isolation?.preconditionIsolated() 
    callWithoutIsolation(closure) // ⚠️ converting @isolated(any) function of type '@isolated(any) (String) -> Void' to synchronous function type '(String) -> Void' is not allowed; this will be an error in a future Swift language mode
}

If the closures were escaping, there is also an unsafeBitCast option discussed in this thread which offers a way around that warning... although now I suppose it'd probably be better to use @diagnose(ConversionFromIsolatedAnyToSynchronous, as: ignored) if you just wanted to be rid of that diagnostic.

Avoiding this if you can may be the way to go, though I am curious as to the motivation behind the question. A non-escaping, synchronous, @isolated(any) function does seem rather unusual, so interested as to why it came up.

1 Like

Blockquote

You can't, and that's a good thing. Because what if that closure is a @MainActor (String) -> Void...?

Well, I was making a macro and this specific situation came up to my mind, but I didn't know how to handle it. I think it's better for me to not support synchronous @isolated(any) for now to not randomly cause concurrency faults.

Again, this specific situation came up to my mind. I didn't know if this situation actually had a real life use case, since I'm not an expert at concurrency. The macro implementation had no real difference between escaping and non-escaping closures so I didn't specify it in my question and again, I didn't know it made a big difference. The same is true for sending and @Sendable. As mattie explained (thank you), the use cases are very limited and probably won't be used by normal library users.

Also a side note which is probably important and I didn't write, I probably need to inherit the isolation from the inner closure since the { parameter in } closure is really meant to be a translation/casting layer rather than an actual closure.

I don't understand, shouldn't @isolated(any) type erase all isolation? Why is MainActor isolation special? Or I'm probably missing the bigger picture that you try to mean. I clarified my question a bit now, can you explain it?

@isolated(any) works like any other type erasure- it's just a statically type-erased box to hide the real type of the value it holds inside. but at runtime when you access the wrapped value, it still has its original type.

so... here the when you pull out the wrapped closure and call it, execution will hop to whatever actor the runtime wants to isolate it to.

the only way it can guarantee that actor is isolated, is to promise it can't be a global actor that already exists. so it can't be @MainActor and it can't be any global actor you've declared either.

that's why if you try to @MainActor @isolated(any)... to a closure, you're just gonna get an error:

Function type cannot have both a global actor and '@isolated(any)'

I think about this a little differently, though it could end up being equivalent. I don't view @isolated(any) as the thing that does the erasing. I see it has a means of later recovering isolation that was erased.

But the price you pay for that is that you might get a closure that does indeed not match the executing context. Which is why even synchronous @isolated(any) functions must be called with await. And that means you must be able to get that function into a async context somehow so you can actually invoke it.

Well, I did find an "implementation" that fits my needs, but it's the most obscure and probably the unsafest thing ever.

Pandora's code
func foo(_ closure: @isolated(any) (String) -> Void) {
    let isolation = closure.isolation

    func asRawClosure(_ rawClosure: (String) -> Void) {
        @Sendable func barHelper(@_inheritActorContext(always) _ transformedClosure: @Sendable @isolated(any) (Int) -> Void) {
            bar(transformedClosure)
        }
        
        withoutActuallyEscaping(rawClosure) { escapingRawClosure in
            typealias SendableClosure = @Sendable (String) -> Void

            let sendableClosure = unsafeBitCast(escapingRawClosure, to: SendableClosure.self)


            // Making the isolation same as the "closure"s isolation.
            // Please give us closure isolation control. :)
            if let isolation {
                isolation.fakeIsolation { isolation in
                    barHelper { parameter in
                        sendableClosure("\(parameter)")
                    }
                }
            } else {
                barHelper { parameter in
                    sendableClosure("\(parameter)")
                }
            }
        }
    }

    asRawClosure(closure)
}

private extension Actor {
    nonisolated func fakeIsolation(_ operation: (isolated Self) -> Void) {
        typealias RawClosure = (Self) -> Void

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


func bar(_ closure: @isolated(any) (Int) -> Void) {
    print(closure.isolation)
}

I decided, I will definitely NOT be implementing/supporting this. And it doesn't even work if bar is async. I can't even test it because I can't find a valid implementation for bar. Now I realize the obscurity of my question. :upside_down_face: