Why do I need runtime support to unsafeBitCast an @isolated<any> closure?

I’m trying to migrate some code to Swift 6.2. It’s actually a copy paste of this code from Observation (my minimum deployment target is 17, but I want to use this 26 API ahead of time), but that’s just for context. My codebase also has warnings as errors on.

My copy-paste of the code has a warning about calling an @isolated<any> function synchronously: Converting @isolated(any) function of type '@isolated(any) @Sendable () throws(Failure) -> Element' to synchronous function type '() throws(Failure) -> Element' is not allowed; this will be an error in a future Swift language mode. It looks to me like the original author was depending on calling trackEmission with emit.isolation, making it safe to call the closure synchronously.

So I felt like the best solution, in the absence of being able to prove this to the compiler, was to bitcast to a non-isolated closure. Unfortunately, this code doesn't compile, and this time it's an error. Here's a simpler reproduction:

func f(
    isolated: @escaping @isolated(any) @Sendable () -> ()
) {
   // Runtime support for '@isolated(any)' function types is only available in iOS 18.0.0 or newer
    let unisolated = unsafeBitCast(isolated, to: (() -> ()).self) 
    print(unisolated)
}

So I have a few questions: first, just for my curiosity: what does the runtime have to do with this? Second, maybe more importantly: are there any workarounds anyone can suggest so I can get a warning-free codebase?

Actually, it looks like you need runtime support to pass an @isolated<any> closure into any generic function:

func f(
    isolated: @escaping @isolated(any) @Sendable () -> ()
) {
    generic(isolated)
}

func generic<T>(_ t: T) {
    print(t)
}

this also does not build.

1 Like

This line is what Observation uses to convert the isolated closure to a synchronous one, not unsafe bit-casting. There's a build warning, but I imagine it's present when building the Observation module, as well.

It's possible that there are better tools to achieve this, or that those tools don't exist yet, but Result(catching: closure) seems to be the first party workaround for now.

Swift generally requires the use of type metadata for a type in order to use it as a generic argument, and for built-in types like functions, that requires runtime support.

That check is done in a conservatively correct way and doesn't have a hardcoded exception for unsafeBitCast.

3 Likes

the only one i can think of, which is somewhat annoying, would be to quarantine things like this into a separate compilation target and build it with the -suppress-warnings option enabled. that's not great for various reasons, but i would expect it should 'work' if you are strongly motivated to make the warning disappear by any means necessary.

IMO we're in an unfortunate position currently with respect to this pattern because we're missing a way to either do an unsafe, narrowly-scoped opt out that compiles warning-free, and we lack a runtime analog for something like assumeIsolated() on an isolated(any) function.

another possible 'compiler-side' solution could be to suppress the diagnostic under some circumstances (a @preconcurrency context check, or if it's re-bound to a nonisolated(unsafe) variable or something, maybe?). fundamentally though, the conversion subverts the concurrency model, which is why the diagnostic exists:

let okay: @MainActor @Sendable () -> Void = { @MainActor in print("main actor") }
let dubious1: @Sendable () -> Void = okay // 🛑 swift 6 error: converting function value of type '@MainActor @Sendable () -> Void' to '@Sendable () -> Void' loses global actor 'MainActor'

let okay2: @isolated(any) @Sendable () -> Void = okay
let dubious2: @Sendable () -> Void = okay2 // ⚠️ converting @isolated(any) function of type '@isolated(any) @Sendable () -> Void' to synchronous function type '@Sendable () -> Void' is not allowed
// 👆 this conversion was silently allowed prior to the 6.2 compiler
1 Like

Thanks Jamie. I did consider compiling just this file with Swift 5 or something, but ultimately found another solution.

All of the compiler changes you're describing would be very welcome. I suppose that the assumeIsolated work described in the original SE is not a high priority right now though given how rare the need for this type is in the first place.

The solution I found is to remove @_inheritActorContext from the initializers, which also removes the need for @isolation(any) entirely. I instead grab the isolation from the caller, and pass it around in the Emit enum. Currently the init changes to

public init(
        isolation: isolated(any Actor)? = #isolation,
        _ emit: @escaping @Sendable () throws(Failure) -> Element
    ) {
        self.init(emit: .element(emit, isolation: isolation))
    }

I think that I'm slightly changing the capabilities of the API, since without isolation(any) I presumably need to only capture Sendable things, but I think I could just as easily remove the Sendable annotation from these inits, and then unsafeBitCast them to Sendable internally now that I don't need runtime support for @isolation(any) anymore.

Edit: this solution does change the API in an undesirable way, and I can't unsafeBitCast because I need runtime support for typed throws as well. There's a much better solution below.

1 Like

Thanks, I just realized you spelled that out explicitly in the SE (minus unsafeBitCast not being special), as well as the future directions to solve this very problem.

I guess I can also try using the trick you described there of wrapping it in a struct to get around it as well (assuming that a normal closure actually has the same memory layout as an isolated one that is).

Edit: casting does seem to work in my testing. So I end up with unchanged interfaces, passing the isolation around manually, and these as the targets for casting:

fileprivate struct HasIsolation<Element, Failure> where Failure: Error {
    let wrapped: @isolated(any) @Sendable () throws(Failure) -> Element
}

fileprivate struct NoIsolation<Element, Failure> where Failure: Error {
    let wrapped: @Sendable () throws(Failure) -> Element
}

1 Like

thanks for sharing your findings! the struct wrapper + unsafe cast looks like a much nicer workaround (assuming it works) – definitely curious to know if you end up running into anything unexpected if you go forward with that approach.

as a follow-up question, @John_McCall i'm wondering if you could shed any light on why the struct wrapper technique works around this issue. i had tried various modes of indirection when looking for a solution (boxing via Any/AnyObject, optional wrapping, etc), but none seemed to work. in particular i'm curious about the difference in this sort of thing:

typealias IsoFn = @isolated(any) @Sendable () -> Void

struct Wrapper {
  var fn: IsoFn
}

func f() {
  let fnSize = MemoryLayout<IsoFn>.size // 🛑 Runtime support for '@isolated(any)' function types is only available in...
  let strSize = MemoryLayout<Wrapper>.size // ✅

  print(fnSize, strSize) // 16, 16
}

Some speculation: Swift doesn't always specialize generics, so it would need the metadata to know how to handle the value inside; how big it is, where its destructors are, etc. But when the type is known (like in these unspecialized structs) it's presumably just directly referencing all that stuff in the parent's own type metadata/when its laying out the wrapper struct in memory.

I'm also going to assume that MemoryLayout isn't special cased, same as unsafeBitCast.

1 Like

Type metadata is, by design, a runtime expression of the identity of a type. It therefore has to store all the information that goes into that identity. However, it does not have to store information that does not affect type identity.

As a general rule, the identity of a type includes everything you would have to write in source code in order to get exactly that type without using anything like a typealias. (It sometimes also includes things you don’t write because they’re contextually understood; for example, a type declared in a generic function has different identity for different generic arguments.)

For @isolated(any) () -> (), that includes the @isolated(any) attribute. So every time we add a new attribute like this which affects function types, we have to update the runtime to store it properly.

The stored properties of a struct are obviously important to the nature of that struct, but they aren’t part of its identity. To use the struct, you just name it. So the program needing type metadata for a struct type doesn’t mean it needs type metadata for all of its stored properties. That’s the essence of why wrapping something in a struct works as a workaround.

The runtime code does sometimes need to be able to lay out a type dynamically, but the compiler can hide information from this that wouldn’t affect layout. In this case, the compiler knows the exact size of the wrapper struct, so there’s no dynamic layout at all. But even if you had something that needed that:

struct NeedsDynamicLayout<T> {
  var value: T
  var operation: @isolated(any) (T) -> Int
}

The compiler can just tell the runtime layout code the size and alignment of the operation property; it doesn’t have to produce type metadata for the exact type. So the workaround works reliably as a way of avoiding needing runtime type metadata support.

6 Likes