Search code examples
iosswiftcombine

Not able to update same @Published variable in sink block


Wondering if this is expected behaviour similar to how we cant set a different value within a variables willSet block. Is this due to race condition?

@MainActor
class Presenter {
    var cancellables: Set<AnyCancellable> = .init()
    @Published var state: State = .ready

    init() {
        $state.sink { [weak self] newValue in
            guard let self else { return }
            switch newValue {
            case .loading:
                switch state {
                case .ready:
                    print("making this finish")
                    state = .finish // this will trigger sink block to run, 
                                   // but variable doesnt update.
                default: break
                }
            default:
                break
            }
        }
        .store(in: &cancellables)
    }
}

Task { @MainActor in
    let presenter = Presenter()
    presenter.state = .loading
    print(presenter.state)
}

## prints ##
making this finish
loading

Solution

  • Wondering if this is expected behaviour similar to how we cant' set a different value within a variables willSet block.

    Yes, this is expected behaviour, and yes, this is very similar to the willSet situation.

    The sink closure will always be called before the property is actually set, as the documentation says.

    Let's consider the state of the program each the sink closure is called.

    The first time it is called, newValue and state are both .ready. This is the initial value, and there is nothing interesting here.

    The second time it is called, newValue is .loading and state is .ready. This call is caused by the line presenter.state = .loading. It is in this call that state = .finish is run.

    This causes the sink closure to be called the third time, caused by state = .finish. newValue is .finish and state is still .ready. state is still not set to .loading at this point because the second call to the sink closure has not returned yet. If it had returned, it would have implied that the line state = .finish has been fully executed. This contradicts the fact that the sink closure is always called before the value is set.

    If you put a breakpoint so that the program pauses at the third call of the sink closure, and do thread backtrace in lldb, you can see that the second call is still on the call stack:

      * frame #0: 0x00000001046602ec Foo`closure #1 in Presenter.init(newValue=finish, self=0x0000600000c0a820) at ContentView.swift:29:19
        frame #1: 0x00000001b7de3208 Combine`Combine.Subscribers.Sink.receive(τ_0_0) -> Combine.Subscribers.Demand + 84
        frame #2: 0x00000001b7de381c Combine`protocol witness for Combine.Subscriber.receive(τ_0_0.Input) -> Combine.Subscribers.Demand in conformance Combine.Subscribers.Sink<τ_0_0, τ_0_1> : Combine.Subscriber in Combine + 20
        frame #3: 0x00000001b7dec610 Combine`Combine.PublishedSubject.Conduit.offer(τ_0_0) -> () + 428
        frame #4: 0x00000001b7ded21c Combine`partial apply forwarder for closure #1 (Combine.ConduitBase<τ_0_0, Swift.Never>) -> () in Combine.PublishedSubject.send(τ_0_0) -> () + 40
        frame #5: 0x00000001b7e23638 Combine`Combine.ConduitList.forEach((Combine.ConduitBase<τ_0_0, τ_0_1>) throws -> ()) throws -> () + 212
        frame #6: 0x00000001b7dec19c Combine`Combine.PublishedSubject.send(τ_0_0) -> () + 196
        frame #7: 0x00000001b7e0b040 Combine`static Combine.Published.subscript.setter : <τ_0_0 where τ_1_0: AnyObject>(_enclosingInstance: τ_1_0, wrapped: Swift.ReferenceWritableKeyPath<τ_1_0, τ_0_0>, storage: Swift.ReferenceWritableKeyPath<τ_1_0, Combine.Published<τ_0_0>>) -> τ_0_0 + 256
        frame #8: 0x000000010465f940 Foo`Presenter.state.setter(value=finish, self=0x0000600000c0a820) at ContentView.swift:0
        frame #9: 0x0000000104660468 Foo`closure #1 in Presenter.init(newValue=loading, self=0x0000600000c0a820) at ContentView.swift:35:27
        frame #10: 0x00000001b7de3208 Combine`Combine.Subscribers.Sink.receive(τ_0_0) -> Combine.Subscribers.Demand + 84
        frame #11: 0x00000001b7de381c Combine`protocol witness for Combine.Subscriber.receive(τ_0_0.Input) -> Combine.Subscribers.Demand in conformance Combine.Subscribers.Sink<τ_0_0, τ_0_1> : Combine.Subscriber in Combine + 20
        frame #12: 0x00000001b7dec610 Combine`Combine.PublishedSubject.Conduit.offer(τ_0_0) -> () + 428
        frame #13: 0x00000001b7ded21c Combine`partial apply forwarder for closure #1 (Combine.ConduitBase<τ_0_0, Swift.Never>) -> () in Combine.PublishedSubject.send(τ_0_0) -> () + 40
        frame #14: 0x00000001b7e23638 Combine`Combine.ConduitList.forEach((Combine.ConduitBase<τ_0_0, τ_0_1>) throws -> ()) throws -> () + 212
        frame #15: 0x00000001b7dec19c Combine`Combine.PublishedSubject.send(τ_0_0) -> () + 196
        frame #16: 0x00000001b7e0b040 Combine`static Combine.Published.subscript.setter : <τ_0_0 where τ_1_0: AnyObject>(_enclosingInstance: τ_1_0, wrapped: Swift.ReferenceWritableKeyPath<τ_1_0, τ_0_0>, storage: Swift.ReferenceWritableKeyPath<τ_1_0, Combine.Published<τ_0_0>>) -> τ_0_0 + 256
        frame #17: 0x000000010465f940 Foo`Presenter.state.setter(value=loading, self=0x0000600000c0a820) at ContentView.swift:0
    

    Frame #17 is the setter setting state to .loading. This eventually calls the sink closure in frame #9, which in turn calls the state setter again (frame #8), and that calls the sink closure again, in frame #0.

    Finally, each of these calls will return, popped off the stack. When the second call to the sink closure returns (frame #9), state finally changes to .finish. But eventually frame #17 returns, which sets state to .loading.


    Is this due to race condition?

    No. Everything is running on the MainActor here. There can't be any races.