Skip to content

Stop orphaned publisher transceivers that trigger SFU force-rejoin - #1757

Merged
aleksandar-apostolov merged 1 commit into
developfrom
fix/publisher-transceiver-orphans-vid-1376
Aug 3, 2026
Merged

Stop orphaned publisher transceivers that trigger SFU force-rejoin#1757
aleksandar-apostolov merged 1 commit into
developfrom
fix/publisher-transceiver-orphans-vid-1376

Conversation

@PratimMallick

@PratimMallick PratimMallick commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Goal

Fixes AND-1351 linked to VID-1367
Fixes three publisher transceiver-lifecycle bugs that can leave a live sendonly m-line on the publisher PeerConnection which SetPublisher.tracks never announces. The SFU then rejects the offer with track ... not announced by user and force-rejoins the publisher — a disruptive reconnect for the user.

The three bugs this PR solves:

  1. syncPublishOptions tears down every transceiver — the cleanup loop compared the transceiver cache against itself, so it always matched and stopped/disposed all transceivers (audio + video) on each SFU ChangePublishOptions event, dropping tracks (and crashing native WebRTC via dispose() on a live PC).
  2. publishStreamInternal fallback orphans the old transceiver — the sender-reuse fallback created a replacement transceiver but dropped the old one from the cache without stopping it, leaving an orphaned live m-line that is never announced.
  3. addTransceiver strands the previous transceiver — for the same [track_type, publish_option_id] the cache entry was silently overwritten, orphaning the earlier transceiver on the PeerConnection.

Implementation

  • syncPublishOptions cleanup predicate — the cleanup loop compared the transceiver cache against itself (transceiverCache.has(option)), which was always true, so every transceiver was torn down on each ChangePublishOptions event (dropping audio + video). It now keeps transceivers whose [id, track_type] option is still requested by the SFU and only removes the ones that are actually gone.
  • publishStreamInternal fallback — when the sender can't be reused, the fallback created a replacement transceiver but dropped the old one from the cache without stopping it, leaving an orphaned live m-line. It now stop()s the old transceiver before replacing it.
  • addTransceiver guard — the cache is keyed by [track_type, publish_option_id], so adding a second transceiver for the same tuple silently overwrote the entry and stranded the previous transceiver. A guard now stop()s any pre-existing transceiver for the same option before adding the new one.

In all three paths we call stop() only, never dispose(), on a live PeerConnection: stop() marks the m-line inactive so it is recycled in the next offer, while the PC retains ownership of the native transceiver/sender and frees it safely at teardown. Disposing mid-session is a use-after-free on network_thread and hard-crashes native WebRTC (SIGSEGV), which we observed and fixed during on-device reproduction.

Testing

  • ./gradlew :stream-video-android-core:testDebugUnitTest --tests "io.getstream.video.android.core.call.connection.PublisherTest" — BUILD SUCCESSFUL
  • ./gradlew :stream-video-android-core:spotlessApply — BUILD SUCCESSFUL
  • Pre-push quality gate (spotlessCheck) — BUILD SUCCESSFUL
  • Manual (on device): reproduced each bug via temporary debug hooks (SFU ChangePublishOptions codec swap VP9→H264, sender fallback, duplicate transceiver). With the fix: old video transceiver is stop()ed, audio is untouched, no orphaned m-line, no not announced by user force-rejoin, and no native SIGSEGV. Debug hooks removed before this PR.

New unit tests added to PublisherTest:

  • syncPublishOptions keeps transceivers whose options are still requested
  • publishStreamInternal fallback stops the orphaned transceiver
  • addTransceiver stops existing transceiver for the same publish option before adding

☑️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

Skipped — pure RTC logic fix, no UI change.

… loop

Three transceiver-lifecycle bugs in Publisher could strand live sendonly
m-lines that SetPublisher.tracks never announces, causing the SFU to
force-rejoin the publisher (VID-1376):

- syncPublishOptions: the cleanup loop compared the cache against itself
  (always true), so every transceiver was torn down on each
  ChangePublishOptions event. Now keep transceivers whose option is still
  requested by the SFU.
- publishStreamInternal fallback: replaced the old transceiver without
  stopping it, leaving an orphaned m-line. Now stop() the old one first.
- addTransceiver: silently overwrote the cache entry for the same
  [track_type, publish_option_id], stranding the old transceiver. Now
  stop() any pre-existing transceiver before adding.

All paths use stop() only (never dispose()) on a live PeerConnection; the
PC owns the native transceiver/sender and frees it safely at teardown.
Disposing mid-session is a use-after-free on network_thread (SIGSEGV).

Adds unit tests covering all three cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
@PratimMallick
PratimMallick requested a review from a team as a code owner July 30, 2026 14:51
@github-actions

github-actions Bot commented Jul 30, 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.

@PratimMallick PratimMallick changed the title fix(core): stop orphaned publisher transceivers to prevent SFU rejoin loop fix(core): stop orphaned publisher transceivers that trigger SFU force-rejoin Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Publisher now prevents orphaned live transceivers during track replacement and cache updates, avoids disposing transceivers still associated with the peer connection, and adds regression tests for these lifecycle behaviors.

Changes

Publisher transceiver lifecycle

Layer / File(s) Summary
Transceiver lifecycle handling
stream-video-android-core/.../Publisher.kt
Sender fallback and cache replacement stop stale transceivers, while synchronization stops but does not dispose options no longer requested.
Lifecycle regression tests
stream-video-android-core/.../PublisherTest.kt
Tests cover retained transceivers, orphan cleanup during sender fallback, and replacement of duplicate cached transceivers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: rahul-lohra

Poem

A bunny hops through tracks anew,
Stops stale lines the whole way through.
Old senders rest, but stay intact,
Fresh transceivers join the act.
Tests thump softly: “Lifecycle right!”

🚥 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 identifies the primary fix: stopping orphaned publisher transceivers that can trigger SFU force-rejoin.
Description check ✅ Passed The description explains the goal, implementation, testing, regression coverage, and known checklist status in sufficient detail.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/publisher-transceiver-orphans-vid-1376

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

🧹 Nitpick comments (1)
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt (1)

470-477: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover removal with stop-only cleanup.

This verifies retained options only. Add an unrequested cached option and assert stop() once, dispose() never, and cache removal once; otherwise the direct syncPublishOptions no-dispose guarantee can regress untested.

🤖 Prompt for 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.

In
`@stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt`
around lines 470 - 477, Add an unrequested option to the cached transceivers in
the PublisherTest syncPublishOptions scenario, then verify its stop() is called
exactly once, dispose() is never called, and the cache removes it exactly once.
Keep the existing assertions for retained options to preserve coverage of the
no-dispose guarantee.
🤖 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/connection/Publisher.kt`:
- Around line 295-302: The transceiver cache is removed even when stopping the
existing transceiver fails, allowing the same live m-line to be recreated. In
Publisher.kt at lines 295-302, 367-374, and 432-440, update the stop handling in
the fallback and removed-track paths to detect stop success, retain the
TransceiverCache entry on failure, and only remove or replace/add a transceiver
after a successful stop or explicit recovery.

In
`@stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt`:
- Around line 435-574: Update PublisherTest to extend and use the existing
TestBase harness instead of maintaining its own mocked setup and coroutine test
context. Migrate the lifecycle tests around syncPublishOptions,
publishStreamInternal, and addTransceiver to the TestBase conventions while
preserving their current assertions and behavior.

---

Nitpick comments:
In
`@stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt`:
- Around line 470-477: Add an unrequested option to the cached transceivers in
the PublisherTest syncPublishOptions scenario, then verify its stop() is called
exactly once, dispose() is never called, and the cache removes it exactly once.
Keep the existing assertions for retained options to preserve coverage of the
no-dispose guarantee.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43e2de77-3efc-4237-99bc-6976d1d9d672

📥 Commits

Reviewing files that changed from the base of the PR and between 53976d2 and 0870bdd.

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

@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.27 MB 12.27 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 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 Jul 30, 2026
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
50.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@aleksandar-apostolov aleksandar-apostolov changed the title fix(core): stop orphaned publisher transceivers that trigger SFU force-rejoin Stop orphaned publisher transceivers that trigger SFU force-rejoin Jul 31, 2026

@aleksandar-apostolov aleksandar-apostolov 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.

LGTM

@aleksandar-apostolov
aleksandar-apostolov merged commit 8a55912 into develop Aug 3, 2026
21 of 29 checks passed
@aleksandar-apostolov
aleksandar-apostolov deleted the fix/publisher-transceiver-orphans-vid-1376 branch August 3, 2026 08:14
@stream-public-bot stream-public-bot added the released Included in a release label Aug 5, 2026
@stream-public-bot

Copy link
Copy Markdown
Collaborator

🚀 Available in v1.30.0

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

Labels

pr:bug Fixes a bug released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants