@MainActor propagation?

Hi, could someone explain to me how is @MainActor annotation propagated through a type hierarchy? This is my setup

public protocol Scoped {
    func setup()
}

public class BaseScoped: Scoped {
    final public let tasks = Tasks()

    public func setup() {
    }
}

@MainActor
public protocol ViewModel: Scoped {
    associatedtype S where S: Equatable
    var state: AnyPublisher<S, Never> { get }
}

public class BaseViewModel<S: Equatable>: BaseScoped, ViewModel {
    let _state: CurrentValueRelay<S>

    public var state: AnyPublisher<S, Never> {
        _state.eraseToAnyPublisher()
    }

    public init(initialState: S) {
        _state = CurrentValueRelay(initialState)
    }

    public override func setup() {
        super.setup()
    }
}

final class FooViewModel: BaseViewModel<Int> {

    override func setup() {
        Task {
            log("Thread \(Thread.current)") <-------------------------------
        }
    }
}

Basically I want setup in the FooViewModel to be on main actor. But it is not.
Even if the annotation is added on the BaseViewModel.

Is it because of the BaseScoped superclass? Can I make it work?

Since contexts outside of the main actor can have a reference to a BaseScoped (or an arbitrary Scoped), and they can call setup() on it, there isn't a way to guarantee that its implementations/overrides will only be called on the main actor.

So the solution is to not inherit from BaseScoped and copy it's body into BaseViewModel?

@John_McCall I'm sorry could you please get back to me? Because I see more odd behavior.

From what you said I gather that the annotation propagates on to the type's direct functions & its descendants, i.e. if I were to put @MainActor on FooViewModel, the only way it would work is if I extract the body of setup to a separate function like so

@MainActor
final class FooViewModel: BaseViewModel<Int> {

    override func setup() {
        Task {
            logThread()
        }
    }

    private func logThread() {
        log("Thread \(Thread.current)") <-------------------------------
    }
}

This all makes sense to me.

However I found this hierarchy setup which produces the task being instantiated from main actor context from within setup which is Scoped function

public protocol Scoped {
    func setup()
}

@MainActor
protocol ViewModel: Scoped {
    associatedtype S where S: Equatable
    var state: AnyPublisher<S, Never> { get }
}

public class BaseViewModel<S: Equatable>: ViewModel {
    final public var cancellables = Set<AnyCancellable>()

    final private let _state: CurrentValueRelay<S>
    public var state: AnyPublisher<S, Never> {
        _state.eraseToAnyPublisher()
    }

    public init(_ initialState: S) {
        _state = CurrentValueRelay(initialState)
    }

    public func setup() {
    }
}

final class FooViewModel: BaseViewModel<FooViewModel.State> {
    private let userLogouter: UserLogouter
    private let schedulerProvider: SchedulerProvider

    init(initialState: FooViewModel.State, schedulerProvider: SchedulerProvider, userLogouter: UserLogouter) {
        self.schedulerProvider = schedulerProvider
        self.userLogouter = userLogouter
        super.init(initialState)
    }

    override func setup() {
        Task {
            log("Thread \(Thread.current)") <-------------------------------
        }
    }
}

(what I did is removed the BaseScoped from the hierarchy)

Doesn't that contradict what you said that the setup is Scoped function so its cannot be guaranteed? or is it a bug?

I don't understand what you're asking. Your new example doesn't do anything from setup() which is required to run on the main actor.

It's just a minimal example, to show that the log prints main thread, and I dont understand why, (granted its probably because of the @MainActor on ViewModel), since you said setup is from Scoped which is not @MainActor,

so what's the reason @MainActor annotation ViewModel works to change what actor setup is ran on?
I'd expect that to not be that case

If I remember correctly, the default Task initializer (as opposed to Task.detached) will inherit the main actor if it's called from the main actor even if the static environment isn't constrained to the main actor, because on Darwin we're just being super-cautious about moving things off the main actor.

So, should I rely on this? Seems rather indicental and probably undocumented

If you need something to run on a particular actor, being explicit can't hurt.

Okay so the TLDR; of this thread is that the Scoped interface breaks what I want to do?

I was hoping to have Scoped as a root component interface.

Then it would have 2 children - a non-UI component for background work, say syncing data, which should not be run on main actor;
and a UI-related one, a ViewModel where setup would be constraint to main actor, like this

protocol Scoped {
    func setup()
}

final class SomeDataSyncer : Scoped {
	func setup() {
		Task {
			let data = try await api.fetchData()
			db.saveData(data)
		}
	}
}

@MainActor
protocol ViewModel : Scoped {}

final class SomeViewModel : ViewModel {
	func setup() {
		Task {
			let data = try await db.loadData()
			renderData(data) <---- called on main thread implicitly
		}
	}
}

:unamused:

Btw by being explicit, you mean like this?

class BaseViewModel : Scoped {
	final func setup() {
		setupOnMainActor()
	}

	@MainActor
	setupOnMainActor() {
		...
	}
}