1

I came across some unexpected behavior in Combine that i'm hoping someone may be able to explain. I would expect the following code to create an infinite loop, but it actually only runs through the stream once.

let pub1 = PassthroughSubject<Int, Never>()
let pub2 = PassthroughSubject<Int, Never>()

pub1
    .handleEvents(receiveOutput: { print("Received new pub1 value: \($0)") })
    .combineLatest(pub2)
    .handleEvents(receiveOutput: { print("Received new combined value: \($0)") })
    .sink { value in
        print(value)
        pub1.send(value.0)
    }.store(in: &subscriptions)

print("sending 1")
pub1.send(1)
print("sending 2")
pub2.send(2)

Generates the following output:

Received new pub1 value: 1
sending 2
Received new combined value: (1, 2)
(1, 2)
Received new pub1 value: 1

Since the value inside pub1 feeds back into itself I would expect sink to be called over and over. What's interesting is that if I get rid of the combineLatest , then this code will create an infinite loop. Something about the combineLatest operator is preventing it and I have no idea what.

I also noticed that adding .receive(on: DispatchQueue.main) before or after the combineLatest will also trigger a loop. I guess I'm not understanding something about how threads are handled in Combine. I'm not seeing this non-looping behavior with other operators. For instance merge(with:) will also create a loop, as expected.

Scott Thompson
  • 22,629
  • 4
  • 32
  • 34

2 Answers2

0

The behaviour you noticed is caused by an implementation detail of the CombineLatest operator. Doing some low level debugging within the binary of the Combine framework revealed this particular instructions:

disassembly

Line 95 is where things diverge, when making the pub1.send(1) and pub2.send(2) calls, the execution continues with the next instructions (line 96), while the send() call from within the sink closure passes the jne test, and the execution jumps towards the end of the function.

The call stack looks like this:

enter image description here

Seems that the CombineLatest implementation either has some safeguard against recursiveness, or this is a side-effect of another implementation detail.

Note that the memory addresses and exact instruction might depend on the computer where the executable was built, however the idea remains the same.

Cristik
  • 30,989
  • 25
  • 91
  • 127
-1

It's just because what you are doing is not asynchronous: the sink send command is still taking place at the time that the value it sends is trying to come down the pipeline:

   pub1   <- - -
      |        |
    pub1.send -

If you permit the poor old send command to have a little temporal freedom, you'll get your infinite loop:

    DispatchQueue.main.asyncAfter(deadline: .now())  {
        pub1.send(value.0)
    }

(That also explains your success using receiveOn. It's the same trick.)

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Then question is, why without the `combineLatest`, the infinite loop is triggered, as we're in the same situation, no async stuff being done. – Cristik Sep 24 '21 at 20:01