Skip to content

Fix multicaster race that leaves a new downstream without a producer#740

Open
MartinStrambach wants to merge 1 commit into
MobileNativeFoundation:mainfrom
MartinStrambach:fix/multicaster-dead-producer-race
Open

Fix multicaster race that leaves a new downstream without a producer#740
MartinStrambach wants to merge 1 commit into
MobileNativeFoundation:mainfrom
MartinStrambach:fix/multicaster-dead-producer-race

Conversation

@MartinStrambach

Copy link
Copy Markdown
Contributor

The bug

StoreChannelManager keeps a stale producer reference after the producer has been cancelled. When the last downstream is removed, doRemove calls producer?.cancelAndJoin() but does not clear the field — it waits for the producer's UpstreamFinished message to do that. That message is sent asynchronously from SharedFlowProducer.start()'s joiner coroutine, so there is a window in which an AddChannel message is processed first:

  1. AddChannel(#2)doAddactivateIfNecessary() sees producer != null (dead) → upstream is not restarted.
  2. UpstreamFinisheddoHandleUpstreamClose: nothing was dispatched (dispatchedValue == false), so with piggybackingDownstream = true the new downstream is parked as piggybacked, producer is set to null, and reactivation only happens for leftovers (empty).

The new downstream now hangs forever with zero emissions. It only recovers if some later downstream on the same multicaster restarts the producer, which dispatches to piggybacked channels too.

The ordering is easy to hit in practice: Multicaster.newDownstream() sends RemoveChannel from onCompletion and AddChannel from onStart, so cancelling a collector and immediately re-collecting (a routine "restart the subscription" pattern, e.g. flatMapLatest-style UI code over store.stream()) races the resubscribe against the in-flight UpstreamFinished. Since FetcherController builds its multicaster with piggybackingDownstream = true, the visible symptom at the Store level is store.stream() collectors that never receive anything — we tracked down a production "infinite loading" bug in our app to exactly this race.

The fix

Clear the producer reference in doRemove immediately after cancelAndJoin(), so a subsequent AddChannel starts a fresh producer. This is safe:

  • The actor processes messages sequentially, so no concurrent access to the field.
  • The cancelled producer's late UpstreamFinished is already ignored by the identity check at the top of doHandleUpstreamClose (if (this.producer !== producer) return).

Testing

Added a deterministic regression test in StoreChannelManagerTests. It runs on a single-threaded test dispatcher and exploits the actor's rendezvous channel: while the actor is suspended in doRemove's cancelAndJoin(), the next addDownstream parks as a pending sender ahead of UpstreamFinished, reproducing the exact production ordering. The upstream suspends on its first collection (fetch in flight, then cancelled) and emits on the second.

  • Before the fix: the test fails with expected:<1> but was:<null> — the second downstream never receives a value.
  • After the fix: :multicast:jvmTest, :store:jvmTest, and :multicast:ktlintCheck all pass.

When the last downstream is removed, StoreChannelManager cancels the
producer but keeps the stale reference until the producer's async
UpstreamFinished message is processed. An AddChannel message that
arrives in between sees producer != null and does not restart the
upstream. If nothing was dispatched and piggybackingDownstream is
enabled, doHandleUpstreamClose then parks that downstream as
piggybacked without reactivation, so it never receives a value.

Store hits this via FetcherController (piggybackingDownstream = true):
cancelling a collector of store.stream() and immediately resubscribing
to the same key can hang the new collector forever until an unrelated
subscriber on the same key restarts the fetcher.

Clear the producer reference in doRemove right after cancelAndJoin so
a subsequent AddChannel starts a fresh producer. The stale
UpstreamFinished of the cancelled producer is already ignored by the
identity check in doHandleUpstreamClose.

Signed-off-by: Martin.Strambach <martin.strambach@gmail.com>
@MartinStrambach MartinStrambach force-pushed the fix/multicaster-dead-producer-race branch from 93732b0 to 0c1cf62 Compare July 9, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🆕 Triage

Development

Successfully merging this pull request may close these issues.

1 participant