Reconsider inference of global actor based on property wrappers

Currently, we can infer @MainActor on an entire type based on the presence of certain property wrappers within that type. TL;DR: I'm making a case that that's a bad idea, and we should reconsider it if possible.

When talking about global actor inference, SE-0316 (Global Actors) states:

Declarations that are not explicitly annotated with either a global actor or nonisolated can infer global actor isolation from several different places:

[...]

  • A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:

    @propertyWrapper
    struct UIUpdating<Wrapped> {
      @MainActor var wrappedValue: Wrapped
    }
    
    struct CounterView { // infers @MainActor from use of @UIUpdating
      @UIUpdating var intValue: Int = 0
    }
    

I have found that this particular inference rule is surprising and nonobvious to most users. In practical usage, this often comes up when using SwiftUI. I frequently deal with developers who have trouble understanding the Swift Concurrency model, because it's not obvious to them when actor isolation applies. When something is inferred, it is not visible to the user, and that makes it harder to understand.

@MainActor func mainActorStuff() {}

// @MainActor -- inferred from presence of @StateObject var
struct MyView: View {
    @StateObject private var model = Model()
    var body: some View {
        Text("Hello, \(model.name)").onAppear { updateUI() }
    }

    func updateUI() {
      mainActorStuff()
    }
}

The above view compiles just fine. But if we change @StateObject to @Stateโ€ฆ

func updateUI() {
    // error: Call to main actor-isolated global function
    // 'mainActorStuff()' in a synchronous nonisolated context
    mainActorStuff()
}

In my experience, it's not at all obvious to most developers that changing @StateObject to @State should cause some function that doesn't even touch that property to stop compiling. The same happens if we remove an unused @StateObject property entirely.

In fact, we can also get errors if we add a new property:

@propertyWrapper
struct DBObject<Wrapped> {
  @DatabaseActor var wrappedValue: Wrapped
}

struct MyView: View {
  // ๐Ÿ‘‡ This is new 
  @DBObject private var dbConnection = DBConnection()
  @StateObject private var model = Model()
  // ...
}

When we have two property wrappers that have wrappedValues isolated to two different actors, then the inference just silently stops, and you're back to having to declare it manually. The compiler doesn't tell you that it's doing this; it just silently infers (or doesn't infer) it. This feels a lot like Einstein's "spooky action at a distance", where changing one property declaration can cause another function to fail to compile, even if it doesn't use that property at all.

It's not clear to me why the inference based on property wrappers was proposed initially. I didn't find any discussion of it during the Global Actors review. I suspect it may have been to make it easier to interact with @ObservedObject when SwiftUI first rolled out. But I'm not sure it actually makes it significantly easier; it only saves us from writing a single annotation, and the loss of that annotation causes its own confusion, as shown above. (See also this recent tweet and this recent blog post)

I'd propose that we should stop inferring actor isolation based on property wrappers, perhaps in Swift 6. That would definitely break some code right now, but as we ramp up the enforcement of actor isolation in upcoming Swift releases, the breakage would likely increase. In practice, the source breakage could be limited if SwiftUI.View were annotated @MainActor directly. (I recognize that SwiftUI isn't in the purview of Swift Evolution, though. But it does give Apple a way to mitigate the breakage for their users.)

Is there anything I'm missing that justifies the current inference behavior?

46 Likes

Oh wow, I think when I read this initially I interpreted that as inferring actor-isolation for the wrapped property, which, of course it does. I agree it seems weird that this inferred isolation propagates 'up' to the type level... all the other isolation inference rules operate at the same 'level' (i.e., types infer isolation based on the isolation of other types/protocols, members infer isolation from other members) or I suppose in the 'downward' direction (since we infer actor isolation of members from the isolation of the containing type).

Also curious what the motivating use case for this was. Can't recall if it was discussed in review but the proposal as-written doesn't sufficiently motivate this surprising behavior, IMO.

14 Likes

Yes please!

Those rules of bubbling the actor inference to the type based on its properties feels so random!

1 Like

Yeah, I came across this recently and was completely caught off guard by this behavior. I eventually found this post from Paul Hudson explaining why it was happening and was quite mystified about the whole thing.

I would also love to see this reconsidered. This is spooky action at a distance.

7 Likes

Wow, that is surprising. I agree this behavior should have to be explicit.

I just encountered this and find it very unexpected, and its currently presenting a pretty big hurdle in adopting async/await.

i have some existing propertyWrappers that ensure that i am calling them on the main-thread.

simplified version here

struct OnMainThread<Value> {
    var _wrapped: Value
  
    public var wrappedValue: Value {
        set {
            dispatchPrecondition(condition: .onQueue(.main))
            _wrapped = newValue
        }
        get {
            dispatchPrecondition(condition: .onQueue(.main))
            _wrapped
        }
    }
}

and im often using initializers with default-expressions

class MyContainer {
     let contained = Contained() // error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}

class Contained {
    @OnMainThread var int = 1
}

this pattern is quite common in our codebase

i now wanted to introduce the @MainActor on my propertyWrapper and it breaks in a LOT of places.

I find the behaviour very unintuitive, since i have not annotated the Contained class itself with @MainActor

Is there room to reconsider this?

1 Like

Also is there a way to opt out of this behaviour other than adding a property isolated to a different actor?

i dont really like the idea of adding "dummy" properties with wrappers just to disable this behaviour.

I completely agree that this particular deduction is extremely unexpected. I believe it was added so that SwiftUI could remain source-compatible?

In general, I find all global actor inference to be problematic. It's basically impossible when looking at a declaration to know whether it's actor-isolated or not, without inspecting arbitrarily many influences (superclasses, protocols, property wrappers). And rules may differ between main implementations and extensions. I'd like to see Swift 6 consider removing global actor inference entirely (with fix-its suggesting adding an explicit global actor annotation when problems arise)

Hi all, so that we don't lose track of this thread, perhaps one or more of you who's a proponent of making a source-breaking change in Swift 6 might be interested in referencing and briefly summarizing the discussion over in the thread about Swift 6 language mode design priorities?

Thanks, I've added my 2ยข here: Design Priorities for the Swift 6 Language Mode - #64 by KeithBauerANZ