Skip to content

iOS Dispatchers.Main.immediate behaves like non-immediate #4430

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
pablo432 opened this issue May 6, 2025 · 2 comments
Open

iOS Dispatchers.Main.immediate behaves like non-immediate #4430

pablo432 opened this issue May 6, 2025 · 2 comments

Comments

@pablo432
Copy link

pablo432 commented May 6, 2025

Describe the bug

Having a multiplatform project targetting iOS and Android with shared Compose UI, we have observed a situation, where application started on iOS behaves differently than one on Android when running coroutines in viewModelScope, which, according to documentation at https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-viewmodel.html should be an immediate dispatcher (and also DarwinMainDispatcher implementation in kotlinx-coroutines-core suggests it should be supported).

I am attaching a sample project that shows the issue. See AppViewModel.kt class in commonMain module and simply run the project on Android (I was using Pixel 7 API 34 Emulator and Samsung S24 with Android 14) and observe logcat by AppViewModel tag. Afterwards run the same project on iOS via Xcode (I was using iPhone 16 Pro Simulator with iOS 18.0) and observe console output there (also filtering by AppViewModel). Compare the logs.

Consider the following piece of code, which is a part of the minimal project I'm attaching to this report:

class AppViewModel : ViewModel() {

    companion object {
        private val TAG = "AppViewModel"
    }

    private val sharedFlow = MutableSharedFlow<String>(replay = 1).apply {
        tryEmit("first")
    }

    init {
        viewModelScope.launch {
            val dispatcher = currentCoroutineContext()[CoroutineDispatcher]
            getPlatform().log(TAG, "Dispatcher is $dispatcher")

            launch {
                sharedFlow.collect {
                    getPlatform().log(TAG, "collected: $it")
                }
            }

            delay(1000)

            getPlatform().log(TAG, "will emit 'second'")
            val secondSuccess = sharedFlow.tryEmit("second")
            getPlatform().log(TAG, "secondSuccess = $secondSuccess")

            getPlatform().log(TAG, "will emit 'third'")
            val thirdSuccess = sharedFlow.tryEmit("third")
            getPlatform().log(TAG, "thirdSuccess = $thirdSuccess")
        }
    }
}

Expected Behavior
As dispatcher used for viewModelScope is supposed to be immediate, collection should happen on the same stack as the new value has been emitted to sharedFlow. For the code above, below is the logcat output of Android project which I consider to be correct:

2025-05-05 23:27:11.318 18244-18244 AppViewModel            org.example.project                  D  Dispatcher is Dispatchers.Main.immediate
2025-05-05 23:27:11.323 18244-18244 AppViewModel            org.example.project                  D  collected: first
2025-05-05 23:27:12.319 18244-18244 AppViewModel            org.example.project                  D  will emit 'second'
2025-05-05 23:27:12.320 18244-18244 AppViewModel            org.example.project                  D  collected: second
2025-05-05 23:27:12.320 18244-18244 AppViewModel            org.example.project                  D  secondSuccess = true
2025-05-05 23:27:12.320 18244-18244 AppViewModel            org.example.project                  D  will emit 'third'
2025-05-05 23:27:12.320 18244-18244 AppViewModel            org.example.project                  D  collected: third
2025-05-05 23:27:12.320 18244-18244 AppViewModel            org.example.project                  D  thirdSuccess = true

The order of logs is correct for immediate dispatcher: will emit second -> collected second -> secondSuccess = true -> will emit third -> collected third -> thirdSuccess = true.

Actual behavior on iOS

Below is the console output from iOS:

2025-05-05 23:29:39.076 AppViewModel: Dispatcher is Dispatchers.Main.immediate
2025-05-05 23:29:39.099 AppViewModel: collected: first
2025-05-05 23:29:40.100 AppViewModel: will emit 'second'
2025-05-05 23:29:40.100 AppViewModel: secondSuccess = true
2025-05-05 23:29:40.100 AppViewModel: will emit 'third'
2025-05-05 23:29:40.100 AppViewModel: thirdSuccess = false
2025-05-05 23:29:40.100 AppViewModel: collected: second

The order of logs suggests iOS Dispatcher does not behave in an immediate manner.

When a second value is attempted to be emitted, it would be expected that collect would happen immediately in the same stack, as there is no thread change involved and it is supposed to be an immediate dispatcher. Yet there is no collect for second at this point - it seems to happen out of stack, after an attempt to emit third fails. I understand that tryEmit returns false when there is no chance to emit a value without suspending, but there should be no need for suspending in the example I'm showing.

Provide a Reproducer

I am attaching a minimal example project below.

CoroutinesTest-minimal.zip

Environment summary
Android: Pixel 7 API 34 Emulator, Samsung S24 Android 14 (works as expected for both)
iOS: Simulator for iPhone 16 Pro with iOS 18.0
Library versions:

  • kotlinx-coroutines-core 1.8.0
  • Compose multiplatform: 1.7.3
  • androidx-lifecycle-viewmodel / androidx-lifecycle-viewmodel-compose: 2.8.4
  • Kotlin: 2.1.20
@pablo432 pablo432 added the bug label May 6, 2025
@dkhalanskyjb
Copy link
Collaborator

Hi! Thank you for the well-made reproducing project.

This is not a bug, this behaviour is in line with the documentation. In this scenario, one coroutine running in Dispatchers.Main.immediate invokes another one. https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-main-coroutine-dispatcher/immediate.html says this:

Immediate dispatcher is safe from stack overflows and in case of nested invocations forms event-loop similar to Dispatchers.Unconfined.

The event loop (described in https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-unconfined.html) has an unspecified order of events:

Can print both "1 2 3" and "1 3 2". This is an implementation detail that can be changed.

So, both behaviours you're showing are correct. There is no guarantee that collection will happen immediately. Please see #3760 for details.

Despite not being a bug, I still think this is worth fixing, at least for consistency across platforms in a single project. Note, though, that we still reserve the right to change the ordering in a future release (but if we do that, we should do it for all platforms simultaneously).

@pablo432
Copy link
Author

pablo432 commented May 8, 2025

Hello, thank you kindly for your response and providing very insightful links. Especially, thank you very much for addressing this in a pull request to ensure platform consistency.

I admit I missed the documentation part, where:

Can print both "1 2 3" and "1 3 2". This is an implementation detail that can be changed.

As a matter of fact, a long-time observed behavior felt so natural it was easy to assume it is only correct.
I've read #3760, also #3506 mentioned there was an interesting read. The situation here is similar - having an old codebase built around RxJava and trying to port it to a Compose Multiplatform by (among others) replacing Observables with Flows to free ourselves from RxJava dependency was the moment we've observed inconsistent behavior across platforms - due to different order of execution, some emissions were skipped on iOS. This could not happen in RxJava - if observer was on the same thread as emitter, collection would always happen immediately and the order was not even something that has to be discussed.

If order of execution is an implementation detail, could you by any chance provide a hint on what would be the recommended approach to:

In short, how to achieve what we're actually are achieving now by taking advantage of implementation detail of Android's Dispatcher, but in a future-proof way? Would it be something like DirectDispatcher you already suggested to someone in #3506 (comment) ?

At least two solutions I can think of that should work:

  • using suspending variant of emit for SharedFlow: order of execution in this case wouldn't matter, if all emitted values would reach the collector in expected (1 2 3) order (though this requires a bit of an extra refactoring effort on our side);
  • switching from Flows to regular, oldschool callbacks (boring, but rather easy work).

Any advice would be highly appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants