Skip to content

Single-flight Call.join to stop concurrent-join race - #1764

Open
PratimMallick wants to merge 8 commits into
developfrom
fix/join-single-flight
Open

Single-flight Call.join to stop concurrent-join race#1764
PratimMallick wants to merge 8 commits into
developfrom
fix/join-single-flight

Conversation

@PratimMallick

@PratimMallick PratimMallick commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Goal

closes AND-1379

Prevent overlapping Call.join() calls from creating multiple RtcSessions that share the same sessionId. The SFU keeps only the latest participant and evicts the others, which leaves zombie publishers that cannot publish A/V and often fail subsequent RPCs with PARTICIPANT_NOT_FOUND, triggering reconnect/rejoin loops.

Also fix a related footgun: calling join() again while already joined used to return Failure and clear the live session / set RealtimeConnection.Failed, which tore down a healthy call (easy to hit with accidental double-join).

Implementation

  • Extract StreamRefCountedSingleFlightProcessor: keyed single-flight that runs shared work on the call scope, tracks waiters, and cancels the shared job only when the last waiter is cancelled (one UI cancel does not kill other waiters / auto-join).
  • Wire CallJoinCoordinator.join() through that processor so concurrent callers share one join attempt and once-only setup (telemetry, interceptor, leave guard, InProgress).
  • Subsequent join() while a session already exists returns Success(existing) (idempotent) instead of failing and tearing down the call.
  • Keep discardFailedSession() cleanup on SFU connect failure during join so failed sessions do not keep issuing RPCs after eviction.
  • Unit tests for the processor (coalesce, cancel-one / cancel-last, fresh flight) and join coordinator (concurrent join, already-joined Success, cleanup).

Behavior notes for reviewers

  • Cancelling one of several concurrent join() waiters no longer cancels the shared join; cancelling the last waiter does.
  • join() while already joined: Failure("already been joined")Success(existing session) (intentional API softening; avoids destroying a live call).

Testing

  • ./gradlew :stream-video-android-core:spotlessApply
  • ./gradlew :stream-video-android-core:testDebugUnitTest --tests 'io.getstream.video.android.core.call.components.CallJoinCoordinatorTest' — passed
  • ./gradlew :stream-video-android-core:testDebugUnitTest --tests 'io.getstream.video.android.core.utils.StreamRefCountedSingleFlightProcessorTest' — passed
  • Manual dogfood (debug UI reverted before ship): concurrent lobby/activity joins coalesce to one SFU join; in-call re-join returns existing session without finishing the activity
Failure modes this mitigates
- Duplicate SFU joinRequests for the same session_id / unified_session_id
- Zombie RtcSessions after SFU participant eviction
- PARTICIPANT_NOT_FOUND on SetPublisher / UpdateMuteStates / IceTrickle / sendAnswer
- Publisher PC thrash (NEW→CLOSED for losers; survivor stuck CHECKING)
- Inability to publish audio/video after a “successful” join UI
- Cascading full-rejoin / reconnect loops driven by those RPC failures
- Accidental second join() tearing down an already-connected call

☑️Contributor Checklist

General

  • I have signed the Stream CLA (required)
  • Assigned a person / code owner group (required)
  • Thread with the PR link started in a respective Slack channel (required internally)
  • PR targets the develop branch
  • PR is linked to the GitHub issue it resolves

Code & documentation

  • Changelog is updated with client-facing changes
  • New code is covered by unit tests
  • Comparison screenshots added for visual changes
  • Affected documentation updated (KDocs, docusaurus, tutorial)
  • Tutorial starter kit updated
  • Examples/guides starter kits updated (stream-video-examples)

☑️Reviewer Checklist

  • XML sample runs & works
  • Compose sample runs & works
  • Tutorial starter kit
  • Example starter kits work
  • UI Changes correct (before & after images)
  • Bugs validated (bugfixes)
  • New feature tested and works
  • Release notes and docs clearly describe changes
  • All code we touched has new or updated KDocs
  • Check the SDK Size Comparison table in the CI logs

🎉 GIF

N/A — core join orchestration fix, no UI changes.

Coalesce overlapping join() callers onto one in-flight attempt and clean
up sessions that fail to connect, preventing SFU-evicted zombie publishers.

Co-authored-by: Cursor <cursoragent@cursor.com>
@PratimMallick
PratimMallick requested a review from a team as a code owner August 10, 2026 11:18
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled, or the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

CallJoinCoordinator now shares concurrent join work through a single in-flight result. Terminal or unrecoverable SFU failures clean up the session created by the failed join. Tests cover concurrency, interceptor retention, fresh joins, and cleanup.

Changes

Call join coordination

Layer / File(s) Summary
Single-flight join execution
stream-video-android-core/src/main/kotlin/.../CallJoinCoordinator.kt, stream-video-android-core/src/test/kotlin/.../CallJoinCoordinatorTest.kt
Concurrent callers share one join request, SFU connection, and session. Initialization runs once and preserves the first interceptor. Later joins start a new request.
Failed session cleanup
stream-video-android-core/src/main/kotlin/.../CallJoinCoordinator.kt, stream-video-android-core/src/test/kotlin/.../CallJoinCoordinatorTest.kt
Terminal and unrecoverable SFU failures discard and clean up the session created by the current join. Tests verify session flow clearing.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConcurrentCallers
  participant CallJoinCoordinator
  participant executeJoin
  participant API
  participant SFUConnection
  ConcurrentCallers->>CallJoinCoordinator: call join
  CallJoinCoordinator->>executeJoin: execute one join
  executeJoin->>API: request join
  executeJoin->>SFUConnection: connect once
  CallJoinCoordinator-->>ConcurrentCallers: share join result
Loading
sequenceDiagram
  participant executeJoin
  participant SFUConnection
  participant CallJoinCoordinator
  participant Session
  executeJoin->>SFUConnection: report terminal failure
  executeJoin->>CallJoinCoordinator: discard failed session
  CallJoinCoordinator->>Session: clear and clean up session
  CallJoinCoordinator-->>executeJoin: return failure
Loading

Possibly related PRs

Suggested labels: pr:internal

Suggested reviewers: rahul-lohra

Poem

I’m a rabbit guarding joins tonight,
One shared hop keeps callers right.
Failed sessions leave no trace,
Fresh joins find their proper place.
Interceptors stay in line—
Thump, thump, concurrency works fine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: preventing concurrent Call.join races through single-flight coordination.
Description check ✅ Passed The description covers the goal, implementation, behavior changes, testing, manual validation, and UI applicability; some checklist items remain unchecked.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/join-single-flight

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt`:
- Line 390: Update executeJoin’s failed-join cleanup so it clears the active
session only when it is still the same localSession; preserve any replacement
installed by discardFailedSession during recovery. Add a recovery-failure test
that installs a replacement session before returning Failure and verifies the
replacement remains active.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b98171e-ff06-4133-a27e-e543cf2d2d64

📥 Commits

Reviewing files that changed from the base of the PR and between b1ba57b and 6013e68.

📒 Files selected for processing (2)
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt
  • stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.29 MB 12.30 MB 0.02 MB 🟢
stream-video-android-ui-xml 5.70 MB 5.68 MB -0.02 MB 🚀
stream-video-android-ui-compose 6.20 MB 6.20 MB 0.00 MB 🟢

@PratimMallick PratimMallick added the pr:bug Fixes a bug label Aug 10, 2026
Remove the discardFailedSession ownership guard. Once join is returning
Failure (including after failed join-time recovery), clear the active
slot and cleanup both the join session and any reconnect replacement.

Co-authored-by: Cursor <cursoragent@cursor.com>
@aleksandar-apostolov

aleksandar-apostolov commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I think porting the SingleFlight mechanism from core and then using it here would be easier for migration than this in-line implementation. WDYT?

@PratimMallick

Copy link
Copy Markdown
Contributor Author

I think porting the SingleFlight mechanism from core and then using it here would be easier for migration than this in-line implementation. WDYT?

The one from core runs on its own scope(which is the call scope), whereas for join we want to run in the caller's scope(UI/viewmodel). Hence used a newer way

@aleksandar-apostolov

aleksandar-apostolov commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I think porting the SingleFlight mechanism from core and then using it here would be easier for migration than this in-line implementation. WDYT?

The one from core runs on its own scope(which is the call scope), whereas for join we want to run in the caller's scope(UI/viewmodel). Hence used a newer way

But you can create a val callFlights = SingleFlight(whateverScope) no?, My point is, we can re-use that implementation and when we merge this to v2, we remove the ported impl and the Call part remains the same.

Or do you mean to use the caller scope, like the UI scope for example to rely on the scope cancellation for join cancellation also?

@aleksandar-apostolov aleksandar-apostolov changed the title fix(core): single-flight Call.join to stop concurrent-join race Single-flight Call.join to stop concurrent-join race Aug 13, 2026
Move join coalescing to StreamRefCountedSingleFlightProcessor so work
runs on the call scope, survives individual waiter cancellation, and
cancels only when the last waiter leaves. Subsequent join() on an
already-joined call returns the existing session instead of failing and
tearing down the live call.

Co-authored-by: Cursor <cursoragent@cursor.com>
PratimMallick and others added 2 commits August 18, 2026 18:52
Make flights ConcurrentHashMap-safe, remove+cancel under one lock so
newcomers cannot attach to a Cancelling flight, refactor run into
acquire/select/await helpers, and add regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Last-/sole-waiter cancel aborts the call-scoped join. When that landed
after setActiveSession, the half-joined session and Joined state stayed
behind and the idempotent join() path then returned Success on that
zombie. Tear it down on cancel, and keep the already-joined check in
executeJoin only so joinInternal has a single caller-owned precondition.

Co-authored-by: Cursor <cursoragent@cursor.com>
PratimMallick and others added 2 commits August 19, 2026 13:29
Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse only isActive flights, and cancel/clear/stop now remove then
cancel under the same mutex as the closed check so a new run cannot
join a dying job or start after stop.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sonarqubecloud

Copy link
Copy Markdown

* (e.g. screen handoff, UI join + call-scoped auto-join) but should not keep running after
* nobody is waiting — unless [scope] itself is cancelled (leave / call cleanup).
*
* Candidate for Stream Android Core v2 alongside [StreamSingleFlightProcessorImpl].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-ran this at HEAD after e0094292 — the cancelling-attach and stop() races are both closed, and the two new tests are the deterministic shape. Verified rather than taken on the diff.

On the KDoc though: stream-android-core already ships StreamSingleFlightProcessor with this exact run/has/cancel/clear/stop surface, and video already carries a copy of StreamSingleFlightProcessorImpl. This makes three. The ref-counting is the genuinely new idea and core is where it belongs — worth a follow-up rather than growing a third copy here.

Two things that point the same way: cancel/clear/stop just became suspend to serve the mutex, which makes them awkward from a non-suspending teardown path, and nothing calls them. Core avoids the mutex entirely by evicting on invokeOnCompletion with a conditional remove(key, value) (core#73) — non-suspending, and the Cancelling window cannot exist.

// Single already-joined gate for the whole join flow: subsequent join() calls while a
// session is live return that session instead of building a second one. Every retry
// below clears the session first, so [joinInternal] always starts without one.
sessionManager.session.value?.let { existing ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Telemetry deltas worth a deliberate call, since join reporting matters:

  • This gate returns before onJoinFunctionStart(), so join() on a live call now emits nothing and returns Success. Previously it emitted the join start and reported a Failure. Accidental double-join — the footgun this PR fixes — goes invisible, so we lose the ability to measure how often it happens in the field.
  • N concurrent joins now emit one onJoinFunctionStart() instead of N. Right as a count of real joins, but a step change in the metric.
  • A coalesced caller's callJoinInterceptor is dropped silently — state.callJoinInterceptor is a single slot assigned inside the flight.

Suggest a distinct signal on the already-joined and coalesced paths instead of going quiet, plus a logger.w when a coalescing caller passes a different interceptor than the in-flight one.

* [RtcSession] for the same `sessionId` and produce the SFU-evicted zombie this coordinator
* exists to prevent.
*/
suspend fun joinInternal(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This drops the guard and replaces it with a comment. joinInternal is still module-visible with default args and is called directly by CallJoinCoordinatorTest and JoinRecoverableFailureTest, so the only thing stopping a second RtcSession on the same sessionId is now KDoc. executeJoin clears the session before every retry, so putting the early return back is free and keeps the invariant enforced in code.

@aleksandar-apostolov

Copy link
Copy Markdown
Contributor

Goal section: closes [AND-1379](https://linear.app/stream/issue/AND-1376/fix-concurrent-join-race) — the text and the link point at different tickets. AND-1379 is a separate issue (ICE restart / rejoin escalation). Both AND-1376 and AND-1379 currently have this PR attached and both sit in In Review, so AND-1379 reads as in-progress when nothing here touches it. Should be Fixes AND-1376.

mutex.withLock {
flight.waiters = (flight.waiters - 1).coerceAtLeast(0)
if (cancelIfLast && flight.waiters == 0 && flight.deferred.isActive) {
cancelAndDetachLocked(flight)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Detach cancelled flights even when the deferred is no longer active

Issue

A deferred created from an already-cancelled scope may be cancelled before its coroutine body starts. In that case, the body’s finally block may never run.

The current waiter cleanup removes the flight only when the deferred is still active:

if (cancelIfLast && flight.waiters == 0 && flight.deferred.isActive) {
    cancelAndDetachLocked(flight)
}

Because an already-cancelled deferred has isActive == false, its entry can remain in flights after the final waiter leaves.

Risk

This is particularly risky when the processor will support replacing or reusing its scope:

  • has(key) can incorrectly report that an inactive flight exists.
  • Flights created by the old scope remain associated with that scope.
  • Supplying a new scope cannot restart or migrate old deferreds.
  • Calls using unique keys can accumulate stale entries.
  • Old and new scope work could overlap unless replacement clears existing flights first.

Solution

Always remove a flight when its final waiter leaves. Cancellation should remain conditional because completed or already-cancelled deferreds do not need to be cancelled again.

flight.waiters--

if (flight.waiters == 0) {
    if (cancelIfLast && flight.deferred.isActive) {
        cancelAndDetachLocked(flight)
    } else {
        removeFlightIfCurrentLocked(
            key = flight.key,
            deferred = flight.deferred,
        )
    }
}

When scope replacement is added, we will clear and cancel all existing flights before exposing the new scope. The replacement must use the same mutex so it cannot race with run():

mutex.withLock {
    val oldFlights = flights.values.toList()

    flights.clear()
    oldFlights.forEach { it.deferred.cancel() }

    scope = newScope
}

A Deferred permanently belongs to the scope that created it, so old flights must never be carried over to the new scope.

@rahul-lohra rahul-lohra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work, left [P2] comment about clearing stale flights before introducing a reusable coroutine scope. It does not block this PR, so I’m approving it.

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

Labels

pr:bug Fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants