Skip to content

Fix flaky shared flow subscription - #4489

Open
murfel wants to merge 11 commits into
fix-flaky-shared-flow-subscriptionfrom
fix-flaky-shared-flow-subscription-impl
Open

Fix flaky shared flow subscription#4489
murfel wants to merge 11 commits into
fix-flaky-shared-flow-subscriptionfrom
fix-flaky-shared-flow-subscription-impl

Conversation

@murfel

@murfel murfel commented Jul 25, 2025

Copy link
Copy Markdown
Contributor

@murfel
murfel requested a review from dkhalanskyjb July 28, 2025 10:58
@dkhalanskyjb

Copy link
Copy Markdown
Collaborator

The change makes sense, thanks! However, the failing tests are important. This changes the behavior of other operators non-trivially, and we need to evaluate how big the change is. Could you please classify the tests into those that fail reasonably and those that should ideally still pass? Then, we'll figure out if we need to isolate the change to just collectLatest and will have an idea of how big the impact of the change is.

Comment on lines 94 to 95
fun testBaseline() =
checkBuffer(-1) { this }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated: Idea gives a warning on this: Flow is constructed but not used. That's a false positive, right? I'll report it if so.

import kotlinx.coroutines.testing.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.testing.*

@murfel murfel Aug 8, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated: L1: Do we need the suppression on Line 1? I don't quite understand how it is relevant here.

@murfel

murfel commented Aug 11, 2025

Copy link
Copy Markdown
Contributor Author

I aimed to fix all tests but these (conflicting with #4493)

  •      🔴 kotlinx.coroutines.flow.FlatMapLatestTest.testSwitchRendevouzBuffer[jvm]
  •      🔴 kotlinx.coroutines.flow.TransformLatestTest.testSwitchRendezvousBuffer[jvm]
  •      🔴 kotlinx.coroutines.flow.TransformLatestTest.testSwitchBuffer[jvm]

Comment thread kotlinx-coroutines-core/common/test/flow/operators/ConflateTest.kt Outdated
@murfel
murfel requested a review from dkhalanskyjb February 17, 2026 16:01
@murfel

murfel commented Feb 18, 2026

Copy link
Copy Markdown
Contributor Author

@dkhalanskyjb I don't exactly understand why the build failed, could you take a look?

@dkhalanskyjb

Copy link
Copy Markdown
Collaborator

The test kotlinx.coroutines.rx2.ObservableAsFlowTest#testLongRange hangs.

The chain of events is:

  • kotlinx.coroutines.flow.internal.ChannelFlow#collect is called on the channelFlow of Observable#asFlow.
  • The dispatcher stays the same, so CoroutineStart.UNDISPATCHED is used in the call to produceImplInternal.
  • The lambda passed to channelFlow in kotlinx.coroutines.rx2.RxConvertKt#asFlow gets invoked. There, we subscribe to the Observable, which is a lazy sequence of more than 64 values.
  • onNext is repeatedly called by the lazy sequence, populating the channel. At some point, it can't do that anymore, because the channel gets full.
  • Because trySendBlocking is used to send values into the channel and contains runBlocking internally, it parks the current thread...
  • ... but that was the only thread we had. We have a deadlock. No coroutines are currently waiting for their turn: the coroutine that was supposed to receive elements from the channel is not suspended, it's in an UNDISPATCHED call to the code that blocked without ever suspending, and the code will be block until someone reads from the channel.

@murfel

murfel commented Feb 18, 2026

Copy link
Copy Markdown
Contributor Author

How do you understand which test hangs from this page?

https://teamcity.jetbrains.com/buildConfiguration/KotlinTools_KotlinxCoroutines_BuildWindows/5892908

@dkhalanskyjb

Copy link
Copy Markdown
Collaborator

Click the red exclamation mark here:

image

Scroll up paste the thread dump at the moment of termination, to the exact moment the build was cancelled, and unfold the ObservableAsFlowTest collapsible section:

image

val desired = context[ContinuationInterceptor]
val start = if (desired == null || desired == current) {
CoroutineStart.UNDISPATCHED
} else CoroutineStart.ATOMIC

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider using a consistent code style.

We have some legacy places where one branch is wrapped into braces while another is not, but it's rather confusing than intentional

override suspend fun collect(collector: FlowCollector<T>): Unit =
coroutineScope {
collector.emitAll(produceImpl(this))
// If upstream and collect have the same dispatcher, launch the `produce` coroutine undispatched.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, produceImpl called markConsumed, but now it is no longer the case. ChannelAsFlow.collect mentions it explicitly.
Probably can be broken by a double collection

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

val channel = Channel<Int>(1).also { it.send(1) }
val flow = channel.consumeAsFlow().buffer()
launch { flow.collect() }
channel.close()
flow.collect()

Something like this should fail, but it does not, so I assume this is a regression.
Ditto for collecting non-closing channel, it probably hangs instead of failing fast

scope.produce(context, produceCapacity, onBufferOverflow, start = CoroutineStart.ATOMIC, block = collectToFun)
produceImplInternal(scope, CoroutineStart.ATOMIC)

internal open fun produceImplInternal(scope: CoroutineScope, start: CoroutineStart): ReceiveChannel<T> =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably shouldn't be open, there is a single implementation of this method

val start = if (desired == null || desired == current) {
CoroutineStart.UNDISPATCHED
} else CoroutineStart.ATOMIC
collector.emitAll(produceImplInternal(this, start))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another problem:

produceImpl was overridden in inheritors, produceImplInternal is not, so any behavioural divergence might result in a user-visible regression.

For example, .merge().conflate() chain now behaves differently, depending on whether it is collected or produceIn'd because they handle onBufferOverflow differently.

@dkhalanskyjb dkhalanskyjb added this to the 1.12 milestone Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants