Lazy var + non-Sendable = false positive actor isolation warning?

Having stared at this warning for a long time now, it seems to me to be a false positive, but maybe I’m missing something? Help me see it.

@MainActor type Foo has a lazy var property that is initialized (lazily) with an instance of @MainActor type Bar. Bar’s initializer takes a parameter of type Baz that is not Sendable.

/* intentionally not sendable */
class Baz {
    
}

@MainActor
final class Bar {
    init(baz: Baz) {}
}

@MainActor
final class Foo {
    let baz: Baz
    private lazy var myLazyProperty = Bar(baz: baz)
    // warning: non-sendable type 'Baz' in asynchronous access to main actor-isolated property 'baz' cannot cross actor boundary

    init(baz: Baz) {
        self.baz = baz
    }
}

The warning doesn’t make sense to me because:

  • The property baz is isolated to the main actor
  • The property myLazyProperty is also isolated to the main actor
  • So when myLazyProperty is lazily initialized, no actor boundaries are being crossed (right?)

Hoping to increase my understanding of what’s going on (or happy to file a compiler bug).

Thanks!

The error might a bit unclear, but it is correct in the terms of it introducing an issue. Consider following code:

// nonisolated async function
func test() async {
    let baz = Baz()
    let foo = Foo(baz: baz)
}

Foo is isolated on the main actor, baz are passed from a nonisolated region, which means when you are passing it to a Foo initializer it is crossing an isolation boundary first time. But being non-Sendable it cannot (at least for now - region based isolation will address this cases) safely cross isolation boundary. The (mostly) same thing happens for Bar with it receiving in general non-Sendable object.

There are actually more details with initialisation, which covered in this proposal:

Thank you for your reply, @vns. In the example you gave, I do see how baz crosses an isolation boundary (and how SE-0430 would address it). However, I think there is an important difference between your sample code and mine. In my sample code, baz is already isolated to the main actor at the point in time it is passed to the Bar initializer (which is also isolated to the main actor), and thus doesn’t cross a boundary (IIUC).

I should note that initializing the lazy var with an IIFE takes care of the warning, I just don’t understand the semantic difference between the two:

private lazy var myLazyProperty = { @MainActor in Bar(baz: baz) }()
// no warning

You have nonisolated self case with init and lazy vars still tied up with the object initialization:

And this works exactly as direct consequence: your initialization of lazy property now isolated on the main actor and everything is fine.

Thanks, @vns. I’ll read through SE-0327 again and see if I can figure out what I’m missing.

And this works exactly as direct consequence: your initialization of lazy property now isolated on the main actor and everything is fine.

My understanding is that my original version not using IIFE is also isolated on the main actor, so I don’t understand the difference. Because myLazyProperty is isolated to the main actor, it can only be accessed from the main actor, and thus any lazy initialization (on first access some time after init) will happen on the main actor.

Maybe my mental model of lazy is wrong, but shouldn’t myLazyProperty and a manually-implemented myLazyProperty2 below be the exact same (in terms of concurrency warnings)?

/* intentionally not sendable */
class Baz {
    
}

@MainActor
final class Bar {
    init(baz: Baz) {}
}

@MainActor
final class Foo {
    let baz: Baz
    private lazy var myLazyProperty = Bar(baz: baz)
    // ↳ warning: non-sendable type 'Baz' in asynchronous access to main actor-isolated property 'baz' cannot cross actor boundary

    private var _myLazyProperty2: Optional<Bar> = .none
    private var myLazyProperty2: Bar {
        get {
            switch _myLazyProperty2 {
            case .none:
                let bar = Bar(baz: baz)
                _myLazyProperty2 = .some(bar)
                return bar
            case let .some(bar):
                return bar
            }
        }
    }
    // ↳ no warnings

    init(baz: Baz) {
        self.baz = baz
    }
}

It’s not easy as for me to get there everything from first take, I’ve gone through whole document few times :slight_smile: And still feel I have to revisit it. The second link is to case you are running into with nonisolated self

Here I step on slippery ground, because most what I know that lazy variables is connected with object initialization — IIRC related to the memory. Probably have to go through that again to give a complete answer, and just for myself too. In general it is not exactly like the second version you’ve provided.