Skip to content

[Enhancement]Coordinate call leave cleanup through the Call state machine#1124

Merged
ipavlidakis merged 2 commits intodevelopfrom
iliaspavlidakis/introduce-call-leaving-stage
Apr 22, 2026
Merged

[Enhancement]Coordinate call leave cleanup through the Call state machine#1124
ipavlidakis merged 2 commits intodevelopfrom
iliaspavlidakis/introduce-call-leaving-stage

Conversation

@ipavlidakis
Copy link
Copy Markdown
Contributor

@ipavlidakis ipavlidakis commented Apr 18, 2026

🔗 Issue Links

N/A

🎯 Goal

Ensure repeated Call.leave() requests share a single teardown path and make the leave lifecycle safer and easier to reason about.

📝 Summary

  • Introduce a dedicated .leaving stage in the Call state machine
  • Move Call.leave() cleanup into the new stage so repeated leave requests are idempotent
  • Keep activeCall / ringingCall cleanup and callEnded notification ordering explicit
  • Add regression tests for repeated leave calls and active call replacement
  • Update the public leave() doc comment to describe the cleanup ordering

🛠 Implementation

This PR adds a new LeavingStage to the Call.StateMachine and routes Call.leave() through it instead of performing teardown inline.

The stage now owns the leave flow:

  • cancels outstanding call-scoped tasks
  • forwards the leave reason to CallController
  • stops closed captions
  • removes the call from cache
  • resets outgoing ringing state and audio filter
  • clears StreamVideo.State.activeCall / ringingCall
  • posts CallNotification.callEnded
  • transitions the call state machine back to .idle

The repeated-leave case is handled by rejecting .leaving -> .leaving transitions, so UI, CallKit, or fallback leave triggers reuse a single cleanup path without double-invoking the controller.

Test coverage was added for:

  • forwarding the leave reason
  • ensuring repeated leave() calls only invoke the controller once
  • ensuring replacing activeCall still posts callEnded for the previous call
  • preserving the existing notification-after-cleanup behavior

🎨 Showcase

N/A

Before After
N/A N/A

🧪 Manual Testing Notes

  • Run the leave-focused unit tests:
    • Call_Tests.test_leave_withReason_reasonWasPassedToCallController
    • Call_Tests.test_leave_whenCalledRepeatedly_callsCallControllerOnlyOnce
    • StreamVideo_Tests.test_streamVideo_callEndedNotificationIsPostedAfterCleanup
    • StreamVideo_Tests.test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall
  • Smoke test a normal join/leave flow and verify the SDK clears active/ringing call state before emitting CallNotification.callEnded

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change follows zero ⚠️ policy (required)
  • This change should receive manual QA
  • Changelog is updated with client-facing changes
  • New code is covered by unit tests
  • Comparison screenshots added for visual changes
  • Affected documentation updated (tutorial, CMS)

Summary by CodeRabbit

  • Refactor

    • Call leave workflow restructured for improved state management.
  • Bug Fixes

    • Call leave operation now correctly handles repeated invocations, ensuring cleanup executes only once and preventing duplicate processing.
  • Tests

    • Added test to verify idempotent behavior when leave is called repeatedly.
    • Added test to validate notification delivery occurs correctly when transitioning between different active calls.

@ipavlidakis ipavlidakis self-assigned this Apr 18, 2026
@ipavlidakis ipavlidakis requested a review from a team as a code owner April 18, 2026 23:28
@ipavlidakis ipavlidakis added the bug Something isn't working label Apr 18, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

📝 Walkthrough

Walkthrough

The Call.leave(reason:) method now delegates cleanup operations to a new .leaving stage in the Call.StateMachine, moving teardown logic from inline code into a dedicated state machine stage that manages controller teardown, cache removal, state cleanup, and notification posting.

Changes

Cohort / File(s) Summary
State Machine Infrastructure
Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift
Added .leaving input case with LeavingInput struct containing reason, disposable bag, controller/adapter/cache references, and reset closures; added .leaving stage identifier to Call.StateMachine.Stage.ID.
State Machine Stage Implementation
Sources/StreamVideo/CallStateMachine/Stages/Call+LeavingStage.swift
New LeavingStage class implementing the .leaving stage that executes cleanup: clears disposable bag, calls controller leave, stops captions adapter, removes from cache, resets ringing/audio state, and transitions to .idle after posting CallNotification.callEnded.
Core Call Logic
Sources/StreamVideo/Call.swift
Refactored leave(reason:) method to delegate to state machine by transitioning to .leaving stage instead of executing cleanup inline.
Tests
StreamVideoTests/Call/Call_Tests.swift, StreamVideoTests/StreamVideo_Tests.swift
Updated existing test to verify reason passed to controller; added test ensuring leave() calls controller only once; added test verifying CallNotification.callEnded posted when active call replaced.

Sequence Diagram

sequenceDiagram
    participant Caller as Caller
    participant Call as Call
    participant StateMachine as StateMachine
    participant LeavingStage as LeavingStage
    participant CallController as CallController
    participant StateManager as StreamVideo.State
    participant Notifications as NotificationCenter

    Caller->>Call: leave(reason:)
    Call->>StateMachine: transition to .leaving(input)
    StateMachine->>LeavingStage: initialize & execute()
    LeavingStage->>CallController: leave(reason:)
    LeavingStage->>StateManager: remove call from cache
    LeavingStage->>StateManager: clear ringingCall/activeCall
    LeavingStage->>StateMachine: transition to .idle
    LeavingStage->>Notifications: post CallNotification.callEnded
    Notifications->>Caller: notification received
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 The call must leave with grace and care,
Through states we dance, a state machine's prayer,
From leaving stage to idle rest,
We clean it up, and test, test, test!
No more inline, the logic flows,
Through structured paths where data goes. 🎭

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: introducing a dedicated .leaving stage in the Call state machine to coordinate call leave cleanup, which is the primary objective of the pull request.
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.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch iliaspavlidakis/introduce-call-leaving-stage

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.

@ipavlidakis ipavlidakis changed the title [Stacked]Implement call leaving stage [Fix] Introduce a Call leaving stage for idempotent teardown Apr 18, 2026
@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

SDK Size

title develop branch diff status
StreamVideo 10.22 MB 10.26 MB +35 KB 🟢
StreamVideoSwiftUI 2.47 MB 2.47 MB 0 KB 🟢
StreamVideoUIKit 2.6 MB 2.6 MB 0 KB 🟢
StreamWebRTC 11.09 MB 11.09 MB 0 KB 🟢

@Stream-SDK-Bot
Copy link
Copy Markdown
Collaborator

StreamVideo XCSize

Object Diff (bytes)
Call+LeavingStage.o +8721
ApplicationLifecycleVideoMuteAdapter.o +5462
Call.o +3011
CallAudioSession.o +2985
Call+Stage.o +2421
Show 28 more objects
Object Diff (bytes)
CallController.o -1491
RTCAudioStore+Action.o +1095
StreamCallAudioRecorder.o -864
RTCAudioStore+Coordinator.o +610
RTCAudioStore+State.o +455
RTCAudioStore+DefaultReducer.o -408
CallKitAlwaysAvailabilityPolicy.o -360
RTCAudioStore+AVAudioSessionReducer.o +336
APIError.o +320
DefaultAPI.o -320
StoreCoordinator.o +292
RTCAudioStore.o +264
CallKitService.o -250
RTCAudioStore+InterruptionsEffect.o +248
CallParticipant.o -240
RTCAudioStore+AVAudioSessionEffect.o +228
RTCAudioStore+AudioDeviceModuleMiddleware.o +160
models.pb.o -152
WebRTCStateAdapter.o +148
Combine.tbd +136
StoreActionBox.o +132
RTCAudioStore+StereoPlayoutEffect.o +130
RTCAudioStore+RouteChangeEffect.o +116
WebSocketClient.o -80
ClosedCaptionsAdapter.o -78
RTCAudioStore+CallKitReducer.o -76
PermissionsStore.o +72
ReflectiveStringConvertible.o -52

Base automatically changed from iliaspavlidakis/ios-1633-bugaccepting-a-call-while-being-in-another-call-causes to develop April 20, 2026 09:01
@ipavlidakis ipavlidakis marked this pull request as draft April 20, 2026 13:29
@ipavlidakis ipavlidakis changed the title [Fix] Introduce a Call leaving stage for idempotent teardown [Enhancement]Coordinate call leave cleanup through the Call state machine Apr 22, 2026
@ipavlidakis ipavlidakis force-pushed the iliaspavlidakis/introduce-call-leaving-stage branch from d146b3d to 10f9a9b Compare April 22, 2026 08:51
@ipavlidakis ipavlidakis marked this pull request as ready for review April 22, 2026 08:52
@github-actions
Copy link
Copy Markdown

Public Interface

🚀 No changes affecting the public interface.

Copy link
Copy Markdown

@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

🧹 Nitpick comments (1)
StreamVideoTests/StreamVideo_Tests.swift (1)

111-111: Rename the new test using the given/when/then pattern.

This test name is hard to scan and does not clearly follow the repository’s new-test naming convention.

Suggested rename
-    func test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall() async throws {
+    func test_activeCallIsReplaced_whenPreviousCallIsLeft_thenPostsCallEnded()
+        async throws {

As per coding guidelines, **/*Tests.swift: “Name new test methods with the pattern test_<given>_<when>_<then>()” and **/*.swift: “Use 80 characters as the maximum line length”.

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

In `@StreamVideoTests/StreamVideo_Tests.swift` at line 111, The test function
test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall
does not follow the repository’s test naming convention; rename it to the
test_<given>_<when>_<then> pattern (use a given like "activeCall", a when like
"replacement", and a then describing the expected outcome) and ensure the new
method name stays within 80 characters, then update the test declaration and any
references to that method accordingly (look for the function symbol
test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall
to locate the code to rename).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/StreamVideo/CallStateMachine/Stages/Call`+LeavingStage.swift:
- Around line 41-43: The code in Call+LeavingStage.swift returns nil for
previousStage.id == .leaving which causes StreamStateMachine to treat repeated
leaves as invalid transitions; change the behavior so duplicate leave requests
are idempotent: either add a guard inside Call.leave(reason:) to no-op if
current stage is .leaving (check the current stage id and return early) or
modify the leaving stage handler to return the existing leaving stage instead of
nil (keep previousStage when previousStage.id == .leaving) so
StreamStateMachine.transition sees a valid no-op transition; reference:
previousStage.id, .leaving, StreamStateMachine.transition, and
Call.leave(reason:).

---

Nitpick comments:
In `@StreamVideoTests/StreamVideo_Tests.swift`:
- Line 111: The test function
test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall
does not follow the repository’s test naming convention; rename it to the
test_<given>_<when>_<then> pattern (use a given like "activeCall", a when like
"replacement", and a then describing the expected outcome) and ensure the new
method name stays within 80 characters, then update the test declaration and any
references to that method accordingly (look for the function symbol
test_streamVideo_activeCallReplacement_postsCallEndedNotificationForPreviousCall
to locate the code to rename).
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 22c8fa39-1f90-43e6-b503-e02f0c6b326f

📥 Commits

Reviewing files that changed from the base of the PR and between e9a16d6 and 10f9a9b.

📒 Files selected for processing (5)
  • Sources/StreamVideo/Call.swift
  • Sources/StreamVideo/CallStateMachine/Stages/Call+LeavingStage.swift
  • Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift
  • StreamVideoTests/Call/Call_Tests.swift
  • StreamVideoTests/StreamVideo_Tests.swift

Comment on lines +41 to +43
switch previousStage.id {
case .leaving:
return nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid logging expected duplicate leaves as invalid transitions.

Returning nil here prevents duplicate cleanup, but StreamStateMachine logs nil transitions as InvalidStateMachineTransition. Since repeated leave requests are expected, guard before calling transition or add a silent idempotent path that preserves the current leaving stage.

One possible fix in Call.leave(reason:)
 public func leave(reason: String? = nil) {
-    stateMachine.transition(
-        .leaving(
+    stateMachine.withLock { currentStage, transitionHandler in
+        guard currentStage.id != .leaving else {
+            return
+        }
+
+        transitionHandler(
+            .leaving(
                 .init(
                     call: self,
                     input: .leaving(
@@
-                reason: reason
+                    reason: reason
+                )
             )
         )
-    )
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/StreamVideo/CallStateMachine/Stages/Call`+LeavingStage.swift around
lines 41 - 43, The code in Call+LeavingStage.swift returns nil for
previousStage.id == .leaving which causes StreamStateMachine to treat repeated
leaves as invalid transitions; change the behavior so duplicate leave requests
are idempotent: either add a guard inside Call.leave(reason:) to no-op if
current stage is .leaving (check the current stage id and return early) or
modify the leaving stage handler to return the existing leaving stage instead of
nil (keep previousStage when previousStage.id == .leaving) so
StreamStateMachine.transition sees a valid no-op transition; reference:
previousStage.id, .leaving, StreamStateMachine.transition, and
Call.leave(reason:).

@sonarqubecloud
Copy link
Copy Markdown

@ipavlidakis ipavlidakis merged commit f4fa6a8 into develop Apr 22, 2026
15 checks passed
@ipavlidakis ipavlidakis deleted the iliaspavlidakis/introduce-call-leaving-stage branch April 22, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants