Combine `.receive(on: RunLoop.main)` loses sent value. How can I make it work?

I've been wondering a similar surprising behaviour with delay(for:scheduler:). Seems like it doesn't delay the completion in the case of Empty either. I'm unsure if that's by design.

Failure and completion events should not be delayed but propagated down the pipeline as soon as possible. If you want to delay such an event you have to catch it, materialize (not sure combine has such a hook), then delay the new pipeline and finally de-materialize.

1 Like

Right, I understand that. But most newcomers won't at first — I was just pointing out that what looks like an intuitive pipeline isn't (unless you understand the difference between completion events and regular values and the way that they're treated — which, yes, users of Combine (and other reactive frameworks) should take the time to understand.)

It would be useful to introduce a delayCompletion(for:scheduler:) operator that would delay completion events. It would sit alongside delay(for:scheduler:) in code completion prompting/reminding the user that completion events are treated differently and won't be delayed by the delay operator when they reach for it.

2 Likes

Matt Gallagher has been doing a rather deep dive into Combine on his blog (part 1, part 2, part 3) which may shed some more detailed light on the behaviors seen in this thread. Namely, part 3 deals with the exact same receive issues discussed here, along with many other async scenarios using Combine. He specifically recommends four things:

  1. Subscription and other “black boxes” should be fully documented (we shouldn’t be guessing about thread safety and graph lifecycles)
  2. support buffered subjects and other ways of sharing cached computations
  3. support scenarios where demand must never be zero
  4. receive(on:) should synchronously establish initial demand (only subscribe(on:) should asynchronously complete construction)

As an aside, to me, open sourcing Combine could take care of #1, or at least make it possible for the community to contribute documentation.

6 Likes

If failure and completion are sent asynchronously from values, then it would be easy to "lose" the last values sent before a completion. Setup (receive(subscription:)) is a bit of a different case since here since we're talking about if it's first & synchronous or first & asynchronous.

1 Like

Hello all,

As of developer beta 1 of iOS 13.3 (and associated releases for other platforms), we've changed the behavior of receive(on:) plus other Scheduler operators to synchronously send their subscription downstream. Previously, they would "async" it to the provided scheduler.

This means that the test case in the original post of this thread now does what most would expect (print the value). Since the subscription is received synchronously by sink, and it synchronously requests .unlimited from its upstream, there is no opportunity for any values sent to the PassthroughSubject to be dropped.

If you have access to the developer betas, please give it a try with your own Combine scenarios and let me know how it goes.

I'd like to extend a special thank you to everyone here and elsewhere across the web who provided us valuable feedback on this.

25 Likes

Hi @Tony_Parker I am facing a similar issue, stated below is the example:

Xcode 11.2.1 (11B500)

Problem:

When .receive(on: RunLoop.main) is used sink doesn't receive any values, however when it is commented out, then sink receives values.

Note: I have tried the following but still doesn't work:

  • Retained cancellable
  • Tested on iOS Device / Simulator / Playground.

Code:

import Foundation
import Combine

extension Notification.Name {
    public static let didPost = Notification.Name("didPost")
}

func postNotifications() {
    
    let names = ["aaa", "bbb", "ccc"]
    
    for name in names {
        
        NotificationCenter.default.post(name: .didPost,
                                        object: nil,
                                        userInfo: ["Car" : name])
    }
}

let cancellable = NotificationCenter.default.publisher(for: .didPost)
    .compactMap { $0.userInfo?["Car"] as? String }
    .receive(on: RunLoop.main) //works when commented out
    .sink {
        print("sink: \($0)")
}

postNotifications()

Hi @somu,

What version of iOS are you testing on?

@Tony_Parker

Following are the versions:
iPhone: iOS 13.2.3 (17B111)
iOS Simulator: iOS 13.2.2

Are you able to test using the new behavior on iOS 13.3 (beta)?

1 Like

Oops my bad, I haven't tested it on iOS 13.3 (beta), will test it and post the results.

@Tony_Parker @clayellis, sorry about the confusion, I have tested using Xcode Beta 11.3 beta (11C24b) using the iOS 13.3 simulator and the values to the subscriber are coming through while using receive(on: RunLoop.main) as expected.

Note: I am sorry I don't have a test device to test so couldn't test on a real device but simulator works as expected.

Simulator testing should be fine too! Thanks for verifying.

1 Like

Thanks a lot for the quick response.

I am experiencing the same issue. Specifically we are wanting to run a publisher that performs an expensive operation on a background thread while receiving the results on the main queue. It appears to be a bug in the framework but any workarounds are appreciated! (edit: read too fast and missed that this might be fixed in the Xcode 11.3 beta—will check there—thanks!)

Followup: I tested this against Xcode 11.3 beta 1 in Playgrounds and the issue is still present. Workarounds/fixes appreciated!

let a = PassthroughSubject<Int, Never>()
let b = PassthroughSubject<String, Never>()

let c = a
    .map { a -> Int in
        RunLoop.current.run(until: Date().addingTimeInterval(1))
        return a
    }
    .subscribe(on: DispatchQueue.global())
    .receive(on: DispatchQueue.main)
    
let cancellable = Publishers.CombineLatest(b, c).sink {
    print("b, c", $0, $1)
}

a.send(3)
b.send("3")
1 Like

subscribe(on:) is now the one operator which does send the subscription asynchronously.

Hello, what is "now"? Does the Combine behavior change from one OS version to another? Is there some release notes that could help us developers adapt our Combine code depending on the OS version?

"now" is: as of iOS 13.3 (as noted above).

I'm working on the release note issue.

1 Like

Thank you! When we write apps or libraries, it's important to be aware of the various Combine flavors!

1 Like

You folks have been talking about iOS. How does all of this relate to macOS?

2 Likes

The change is present on all the aligned software updates for all platforms. iOS 13.3, watchOS 6.1.1, tvOS 13.3, and macOS 10.15.2. (Hope I got all those version numbers right).