Skip to content

Comments

Add stateScope for StateFlows that survive call.leave()#1618

Closed
aleksandar-apostolov wants to merge 3 commits intodevelopfrom
feature/reusable-call-state-scope
Closed

Add stateScope for StateFlows that survive call.leave()#1618
aleksandar-apostolov wants to merge 3 commits intodevelopfrom
feature/reusable-call-state-scope

Conversation

@aleksandar-apostolov
Copy link
Contributor

@aleksandar-apostolov aleksandar-apostolov commented Feb 18, 2026

Goal

Make Call objects reusable after leave() by:

  1. Introducing a separate stateScope for StateFlows that need to survive leave
  2. Removing scope cancellation so the Call can be rejoined

This is an alternative approach to #1605 (implements Rahul's Approach 3) that provides structural safety.

Implementation

1. Add stateScope for StateFlows (survives leave)

  • Add stateScope in Call.kt - a long-lived scope that is NOT cancelled on leave()
  • Pass stateScope to CallState, CallStats, and ParticipantState
  • Update all stateIn() calls to use stateScope:
    • _pinnedParticipants
    • livestream
    • duration / durationInMs
    • liveDurationInMs
    • CallStats.local
    • ParticipantState.video

2. Remove scope cancellation (allows rejoin)

  • Remove shutDownJobsGracefully() which cancelled scope and supervisorJob
  • RTC jobs are already manually cancelled in leave():
    • sfuEvents, monitorPublisherPCStateJob, monitorSubscriberPCStateJob
    • leaveTimeoutAfterDisconnect, callStatsReportingJob
  • RtcSession.cleanup() cancels its own supervisorJob

Key benefits over #1605:

  • Cleaner separation: stateScope for StateFlows, scope for RTC work
  • If a stateIn is accidentally left on scope, it survives (since scope isn't cancelled)
  • Less state reset logic needed

Testing

  • Join a call, leave, then rejoin using the same Call object - verify audio/video works
  • Verify StateFlows (duration, participants, etc.) continue to receive coordinator updates after leave
  • Verify call.state updates work even without joining (via call.get() or queries)
  • Run ./gradlew apiCheck to confirm API changes are intentional (CallState constructor signature)

Introduce a separate stateScope that lives for the entire Call object
lifetime and is NOT cancelled on leave(). This allows StateFlows using
stateIn() to survive leave() and continue receiving coordinator updates.

The existing scope behavior is preserved - it's still cancelled on
leave() to clean up RTC-related coroutines. Only the stateIn() flows
in CallState, CallStats, and ParticipantState now use stateScope.

This approach (Rahul's Approach 3) provides structural safety:
- Existing cancellation behavior preserved (scope cancelled on leave)
- If a stateIn is missed, it dies on leave - safe default, no zombies
- StateFlows that need updates after leave() explicitly use stateScope
@aleksandar-apostolov aleksandar-apostolov requested a review from a team as a code owner February 18, 2026 09:05
@github-actions
Copy link
Contributor

github-actions bot commented Feb 18, 2026

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.

🎉 Great job! This PR is ready for review.

@aleksandar-apostolov aleksandar-apostolov added the pr:improvement Enhances an existing feature or code label Feb 18, 2026
@aleksandar-apostolov aleksandar-apostolov changed the title feat(core): add stateScope for StateFlows that survive leave() Add stateScope for StateFlows that survive call.leave() Feb 18, 2026
Remove shutDownJobsGracefully() which cancelled scope and supervisorJob.
This prevented rejoining after leave() since the scope was dead.

RTC-related jobs are already manually cancelled in leave():
- sfuEvents, monitorPublisherPCStateJob, monitorSubscriberPCStateJob
- leaveTimeoutAfterDisconnect, callStatsReportingJob

RtcSession.cleanup() cancels its own supervisorJob, handling its coroutines.
@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

Walkthrough

The pull request introduces a new long-lived stateScope coroutine scope to CallState for managing state flows separately from the call's operational lifecycle. The original scope is preserved, and CallState now accepts both scopes as constructor parameters, enabling state management to persist across leave() operations.

Changes

Cohort / File(s) Summary
API Declaration Update
stream-video-android-core/api/stream-video-android-core.api
Constructor signature for CallState extended with an additional CoroutineScope parameter, increasing total parameters from four to five.
Scope Introduction & CallState Initialization
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
Introduced internal stateScope field for long-lived state operations. Updated CallState constructor call to pass both original scope and new stateScope parameters.
State Management Refactoring
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt
Added public stateScope constructor parameter. Refactored multiple StateFlow initializations, debounce operations, and participant state creation to use stateScope instead of original scope, affecting state lifecycle and persistence across leave operations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A scope for forever, a scope for today,
We split them apart in the sweetest way,
States live on after you hop away,
Two scopes now dance, in harmony's sway! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: introducing a stateScope for StateFlows that survive call.leave().
Description check ✅ Passed The description covers all critical sections: Goal explains the purpose, Implementation details the changes, and Testing provides a checklist, though UI Changes section is not applicable here.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/reusable-call-state-scope

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt (1)

351-424: ⚠️ Potential issue | 🟡 Minor

livestreamFlow uses scope.launch internally but is shared via stateScope — will break silently after leave().

The channelFlow at line 351 launches coroutines on scope (lines 357, 366), which is cancelled on leave(). However, stateIn(stateScope, ...) at line 424 keeps the flow alive. After leave(), the inner scope.launch calls silently fail, so the flow stops emitting new values while appearing active.

Since this API is @Deprecated, the degraded behavior is acceptable, but worth noting. If this flow is ever un-deprecated or copied, the inner launches should use stateScope (or the channelFlow's own coroutine scope via this.launch).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt`
around lines 351 - 424, livestreamFlow launches coroutines on scope (via
scope.launch) while the resulting flow is kept alive with
stateIn(stateScope...), so after leave() those launched coroutines are cancelled
and emissions stop silently; fix by switching the internal launches in
livestreamFlow to run on the channelFlow's coroutine scope (use this.launch or
plain launch inside channelFlow) or explicitly use stateScope for those launches
so emissions continue while stateIn is active—update the two scope.launch blocks
inside livestreamFlow (the one collecting _participants and the one subscribing
to call) accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`:
- Around line 169-177: The stateScope CoroutineScope is created with an
unparented SupervisorJob(), causing leaks; change its construction to parent the
SupervisorJob to the clientImpl.scope's Job (e.g., combine
clientImpl.scope.coroutineContext with
SupervisorJob(clientImpl.scope.coroutineContext[Job])) so it is cancelled when
the client is torn down, and also add an explicit cancellation in the Call
cleanup path (in cleanup() — after shutDownJobsGracefully() and related
teardown) by calling stateScope.cancel() to ensure final teardown of any
stateIn() coroutines; update references around the Call constructor where
stateScope is defined and ensure CallState continues to receive the same scope.

---

Outside diff comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt`:
- Around line 351-424: livestreamFlow launches coroutines on scope (via
scope.launch) while the resulting flow is kept alive with
stateIn(stateScope...), so after leave() those launched coroutines are cancelled
and emissions stop silently; fix by switching the internal launches in
livestreamFlow to run on the channelFlow's coroutine scope (use this.launch or
plain launch inside channelFlow) or explicitly use stateScope for those launches
so emissions continue while stateIn is active—update the two scope.launch blocks
inside livestreamFlow (the one collecting _participants and the one subscribing
to call) accordingly.

Comment on lines 169 to 177
/**
* A long-lived scope for StateFlows that need to survive leave() and receive coordinator updates.
* This scope lives for the entire lifetime of the Call object and is NOT cancelled on leave().
* Used for stateIn() operators in CallState, CallStats, and ParticipantState.
*/
internal val stateScope = CoroutineScope(clientImpl.scope.coroutineContext + SupervisorJob())

/** The call state contains all state such as the participant list, reactions etc */
val state = CallState(client, this, user, scope)
val state = CallState(client, this, user, scope, stateScope)
Copy link

@coderabbitai coderabbitai bot Feb 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for stateScope.cancel and related cancellation patterns
echo "=== Searching for stateScope.cancel or .close ==="
rg -n 'stateScope\.(cancel|close)' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

echo ""
echo "=== Searching for stateScope initialization and usage ==="
rg -n 'stateScope' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt | head -20

echo ""
echo "=== Looking at cleanup() and shutDownJobsGracefully() methods ==="
sed -n '1519,1540p' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

Repository: GetStream/stream-video-android

Length of output: 1217


🏁 Script executed:

#!/bin/bash
# Check the internalLeave method to understand the full teardown sequence
echo "=== Examining internalLeave() method ==="
sed -n '985,1010p' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

echo ""
echo "=== Check if there's a destroy or onCleared method that might cancel stateScope ==="
rg -n 'fun (destroy|onCleared|dispose|finalCleanup)' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

Repository: GetStream/stream-video-android

Length of output: 1000


🏁 Script executed:

#!/bin/bash
# Verify the exact SupervisorJob initialization
echo "=== Get the exact line 174 context ==="
sed -n '169,180p' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

Repository: GetStream/stream-video-android

Length of output: 765


stateScope is never cancelled — resource leak when the Call is discarded.

SupervisorJob() creates an unparented job, leaving stateScope completely disconnected from the clientImpl.scope hierarchy. While the design comment correctly notes that stateScope should survive leave() to support reuse, there is no code path that ever cancels stateScope — not in cleanup(), not in shutDownJobsGracefully(), and not anywhere else. Every stateIn() coroutine launched on stateScope will run until process death, even if the Call object is discarded without being reused.

Two fixes to consider:

  1. Parent the SupervisorJob to clientImpl.scope's job so it gets cancelled when the client is torn down:
Proposed fix
-    internal val stateScope = CoroutineScope(clientImpl.scope.coroutineContext + SupervisorJob())
+    internal val stateScope = CoroutineScope(
+        clientImpl.scope.coroutineContext + SupervisorJob(clientImpl.scope.coroutineContext[Job])
+    )
  1. Additionally, explicitly cancel stateScope in cleanup() for final teardown:
fun cleanup() {
    session?.cleanup()
    shutDownJobsGracefully()
    callStatsReportingJob?.cancel()
    mediaManager.cleanup()
    session = null
    scopeProvider.cleanup()
    stateScope.cancel() // final teardown
}

Fix (1) is the minimum — without it, stateScope outlives even the StreamVideo client itself.

Violates: "Keep concurrency deterministic—use structured coroutines and avoid global scope" and "Ensure cleanup/teardown paths handle cancellation and failure."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`
around lines 169 - 177, The stateScope CoroutineScope is created with an
unparented SupervisorJob(), causing leaks; change its construction to parent the
SupervisorJob to the clientImpl.scope's Job (e.g., combine
clientImpl.scope.coroutineContext with
SupervisorJob(clientImpl.scope.coroutineContext[Job])) so it is cancelled when
the client is torn down, and also add an explicit cancellation in the Call
cleanup path (in cleanup() — after shutDownJobsGracefully() and related
teardown) by calling stateScope.cancel() to ensure final teardown of any
stateIn() coroutines; update references around the Call constructor where
stateScope is defined and ensure CallState continues to receive the same scope.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is intentional

Copy link

Choose a reason for hiding this comment

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

@aleksandar-apostolov, understood — I've noted that the stateScope lifecycle design is intentional for this PR.

Since the goal is to make Call objects reusable after leave(), keeping stateScope alive across leave/rejoin cycles makes sense for maintaining StateFlow subscriptions that need to receive coordinator updates continuously. The separation between scope (cancelled on leave) and stateScope (survives leave) achieves the reusability objective.


🧠 Learnings used
Learnt from: aleksandar-apostolov
Repo: GetStream/stream-video-android PR: 1587
File: stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt:795-798
Timestamp: 2025-12-19T14:10:16.577Z
Learning: In the `rejoin()` function in `stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`, the design invariant is that `rejoin()` should only be called when there is an active session (`this.session != null`). Therefore, using `this.session!!` is safe and intentional.

Learnt from: CR
Repo: GetStream/stream-video-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-19T09:15:37.269Z
Learning: Applies to **/*.{kt,kts} : Ensure cleanup/teardown paths handle cancellation and failure (important for sockets, queues, retries)

Learnt from: CR
Repo: GetStream/stream-video-android PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-19T09:15:37.269Z
Learning: Applies to **/*.{kt,kts} : Keep concurrency deterministic—use structured coroutines and avoid global scope

Learnt from: rahul-lohra
Repo: GetStream/stream-video-android PR: 1588
File: stream-video-android-core/api/stream-video-android-core.api:3561-3569
Timestamp: 2026-01-07T09:52:52.695Z
Learning: The `LocalCallMissedEvent` class in `stream-video-android-core` is only instantiated internally by the library maintainers. Clients only read these instances and never construct them directly, so breaking changes to the constructor are acceptable.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 18, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.00 MB 12.00 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.28 MB 6.27 MB -0.02 MB 🚀

Instead of passing stateScope as constructor parameter:
- Add stateScope property to CallActions interface
- CallState, CallStats access call.stateScope directly
- ParticipantState accesses callActions.stateScope

This is cleaner than passing scopes through constructors - each class
gets the scope from its existing dependencies.
@aleksandar-apostolov
Copy link
Contributor Author

Approach needs to be revisited

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

Labels

pr:improvement Enhances an existing feature or code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant