Skip to content

Always announce video layers on SetPublisher - #1765

Open
PratimMallick wants to merge 1 commit into
developfrom
fix/setpublisher-validation-rejoin
Open

Always announce video layers on SetPublisher#1765
PratimMallick wants to merge 1 commit into
developfrom
fix/setpublisher-validation-rejoin

Conversation

@PratimMallick

@PratimMallick PratimMallick commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Goal

Fixes AND-1377
Prevent unnecessary full rejoins when the first SetPublisher races ahead of the video track becoming LIVE.

On first join, Android often negotiated while track.state() != LIVE. Layer computation was gated on LIVE, so the announce went out with empty layers. The SFU rejected that with ERROR_CODE_REQUEST_VALIDATION_FAILED / Invalid SetPublisher request, and Publisher rejoined — flashing reconnect UI and emitting ParticipantLeft to peers. The second session usually succeeded.

Seen in dogfood and Blink (default:019f45a0-1388-70ab-970d-ee1b4d195d3a).

Dashboard: https://beta.dashboard.getstream.io/organization/1234199/1336271/video/call/viewer/default:019f45a0-1388-70ab-970d-ee1b4d195d3a/

Implementation

  • Always compute video/screenshare layers from capture dimension + PublishOption in toTrackInfo, even when the track is not LIVE (TrackInfo.muted can still reflect !LIVE).
  • Layer math does not depend on frames/capturer readiness; empty layers were a self-imposed gate, not an async “layers ready” signal.
  • Keep ERROR_CODE_REQUEST_VALIDATION_FAILEDrejoin() for remaining real mismatches (aligned with prior recovery that fixed persistent validation failures).
  • On SetPublisher error, return early instead of applying a potentially empty remote SDP.
  • Unit coverage for non-LIVE video still announcing layers, and validation failure still rejoining.

Testing

  • ./gradlew :stream-video-android-core:testDebugUnitTest --tests "io.getstream.video.android.core.call.connection.PublisherTest" — passed
  • Manual dogfood: join with camera racing mid-join; first announce can be muted=true with nonempty q/h/f layers; SetPublisher succeeds without validation rejoin
  • React/iOS note: React keys RPC retry on should_retry (this validation case is should_retry=false). Android still rejoins on remaining validation failures after the layers fix; that recovery path is intentional.
Evidence from dogfood
Negotiating with tracks: ... muted=true, layers=[q/h/f...]
Publisher negotiation successfully done ✅
No Invalid SetPublisher / REQUEST_VALIDATION_FAILED

☑️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 — analytics/RTC publish path fix, no UI change.

Made with Cursor

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of publisher validation errors during connection negotiation.
    • Automatically rejoins when publisher validation fails.
    • Ensured ended video tracks are announced correctly with their available layers.
    • Improved reporting of other publisher errors during negotiation.

Avoid empty-layer announces when the track is not LIVE yet, which caused
ERROR_CODE_REQUEST_VALIDATION_FAILED and an unnecessary full rejoin.

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

github-actions Bot commented Aug 11, 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 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Publisher negotiation now handles SFU errors before SDP processing and rejoins on validation failures. Track-layer computation now includes non-live video tracks. Tests cover rejoin behavior and muted ended-track announcements.

Changes

Publisher negotiation and error handling

Layer / File(s) Summary
Negotiation error handling
stream-video-android-core/src/main/kotlin/.../Publisher.kt, stream-video-android-core/src/test/kotlin/.../PublisherTest.kt
negotiate processes setPublisher errors before the SDP answer. Validation failures trigger one rejoin. Other errors are logged and stop negotiation.
Track layer announcement
stream-video-android-core/src/main/kotlin/.../Publisher.kt, stream-video-android-core/src/test/kotlin/.../PublisherTest.kt
Non-audio tracks compute layers regardless of live status. Cached layers are used when computation returns no result. Tests verify muted ended video tracks retain layers.

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

Sequence Diagram(s)

sequenceDiagram
  participant Publisher
  participant SFU
  participant RejoinCallback
  Publisher->>SFU: setPublisher
  SFU-->>Publisher: validation error
  Publisher->>RejoinCallback: rejoin
  Publisher-->>Publisher: stop negotiation before SDP processing
Loading

Suggested reviewers: aleksandar-apostolov, andremion, gpunto

Poem

I twitch my nose at tracks anew,
With layers kept when streams are through.
If SFU says, “Please try again,”
I hop to rejoin, then count to ten.
SDP waits while errors clear.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% 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
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: announcing video layers during SetPublisher.
Description check ✅ Passed The description covers the goal, implementation, testing, evidence, and checklist, and states that UI changes do not apply.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/setpublisher-validation-rejoin

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)

321-355: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that an error response does not apply remote SDP.

Add coVerify(exactly = 0) { publisher.setRemoteDescription(any()) }. The test must protect the early-return contract for the empty sdp in this response.

🤖 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 321 - 355, The SetPublisher validation-failure test must verify
that an error response with empty sdp does not apply remote SDP. In
`SetPublisher validation failure rejoins`, add an exact-zero verification for
`publisher.setRemoteDescription(any())` while preserving the existing
setPublisher and rejoin assertions.
🤖 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 209-235: Wrap the negotiation flow in the relevant Publisher
method with try/finally immediately after setting isIceRestarting, and reset
isIceRestarting in the finally block. Ensure the SFU error return inside the
submit lambda, including the response.error handling around SetPublisherRequest,
still clears the flag before subsequent negotiations.

In
`@stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt`:
- Around line 293-355: Migrate the PublisherTest fixture to extend TestBase,
preserving its current initialization and cleanup behavior by moving or
retaining setup in the appropriate TestBase lifecycle hooks. Update the class
declaration and any affected fixture references so all existing tests, including
getAnnouncedTracks and SetPublisher validation failure rejoins, continue using
the same setup.

---

Nitpick comments:
In
`@stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/PublisherTest.kt`:
- Around line 321-355: The SetPublisher validation-failure test must verify that
an error response with empty sdp does not apply remote SDP. In `SetPublisher
validation failure rejoins`, add an exact-zero verification for
`publisher.setRemoteDescription(any())` while preserving the existing
setPublisher and rejoin assertions.
🪄 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: 7839db21-21fd-43f0-9d05-e256a862ce25

📥 Commits

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

📒 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

Comment on lines 209 to +235
if (response.error != null) {
logger.e {
"SetPublisherRequest Received error: ${response.error}, SetPublisherRequest: $request"
}
tracer.trace("negotiate-error-setpublisher", response.error.message ?: "unknown")
logger.e { "rejoin cause error in sfuClient.setPublisher, message:${response.error.message}" }

when (response.error.code) {
/**
* We are getting this error right away after joining the call first time
* Full error: 16:04:05.032 Call:PeerC...:publisher E (DefaultDispatcher-worker-17:526) SetPublisherRequest Received error: Error{code=ERROR_CODE_REQUEST_VALIDATION_FAILED, message=Invalid SetPublisher request, should_retry=false}
* This will cause emission of ParticipantLeftEvent to other person
* Historically common right after first join when video layers were omitted
* for non-LIVE tracks. Layers are now always computed from publish options, so
* remaining validation failures are treated as a real publisher/SFU mismatch.
*/
ErrorCode.ERROR_CODE_REQUEST_VALIDATION_FAILED -> rejoin()
ErrorCode.ERROR_CODE_REQUEST_VALIDATION_FAILED -> {
logger.e {
"rejoin cause error in sfuClient.setPublisher, " +
"message:${response.error.message}"
}
rejoin()
}

else -> {}
else -> {
logger.e {
"Unhandled SetPublisher error code=${response.error.code}, " +
"message=${response.error.message}"
}
}
}
return@submit

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reset isIceRestarting before returning on an SFU error.

When iceRestart is true, Line 235 exits the submit lambda before Line 249 resets isIceRestarting. Every later negotiation then exits at Lines 174-177 as if an ICE restart is still active.

Use try/finally after setting the flag so every error path clears it.

Proposed fix
             isIceRestarting = iceRestart
-            setLocalDescription(offer).onErrorSuspend {
-                tracer.trace("negotiate-error-setlocaldescription", it.message ?: "unknown")
-            }
-            val request = SetPublisherRequest(
-                sdp = offer.description,
-                tracks = trackInfos,
-                session_id = sessionId,
-            )
-            val response = sfuClient.setPublisher(request)
-            if (response.error != null) {
-                // existing error handling
-                return@submit
-            }
-
-            logger.i { "Received answer: ${response.sdp}" }
-            setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, response.sdp))
+            try {
+                setLocalDescription(offer).onErrorSuspend {
+                    tracer.trace("negotiate-error-setlocaldescription", it.message ?: "unknown")
+                }
+                val request = SetPublisherRequest(
+                    sdp = offer.description,
+                    tracks = trackInfos,
+                    session_id = sessionId,
+                )
+                val response = sfuClient.setPublisher(request)
+                if (response.error != null) {
+                    // existing error handling
+                    return@submit
+                }
+
+                logger.i { "Received answer: ${response.sdp}" }
+                setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, response.sdp))
+            } finally {
+                isIceRestarting = false
+            }
-        isIceRestarting = false
🤖 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/main/kotlin/io/getstream/video/android/core/call/connection/Publisher.kt`
around lines 209 - 235, Wrap the negotiation flow in the relevant Publisher
method with try/finally immediately after setting isIceRestarting, and reset
isIceRestarting in the finally block. Ensure the SFU error return inside the
submit lambda, including the response.error handling around SetPublisherRequest,
still clears the flag before subsequent negotiations.

Comment on lines +293 to +355
@Test
fun `getAnnouncedTracks includes video layers even when track is not LIVE`() {
val mockVideoTrack = mockk<VideoTrack>(relaxed = true) {
every { id() } returns "video-1"
every { kind() } returns "video"
every { isDisposed } returns false
every { state() } returns MediaStreamTrack.State.ENDED
}
val mockSender = mockk<RtpSender>(relaxed = true) {
every { track() } returns mockVideoTrack
}
val mockTransceiver = mockk<RtpTransceiver>(relaxed = true) {
every { sender } returns mockSender
every { mid } returns "0"
}
every { mockTransceiverCache.items() } returns listOf(
TransceiverId(videoPublishOption, mockTransceiver),
)
every { mockTransceiverCache.indexOf(videoPublishOption) } returns 0
every { mockTransceiverCache.getLayers(videoPublishOption) } returns null

val announced = publisher.getAnnouncedTracks(null, null)

assertEquals(1, announced.size)
assertTrue(announced[0].muted)
assertTrue(announced[0].layers.isNotEmpty())
}

@Test
fun `SetPublisher validation failure rejoins`() = runTest(coroutineContext) {
every { publisher.getAnnouncedTracks(any(), any()) } returns listOf(
TrackInfo(
track_id = "video-1",
track_type = TrackType.TRACK_TYPE_VIDEO,
mid = "0",
muted = false,
layers = listOf(
stream.video.sfu.models.VideoLayer(
rid = "f",
video_dimension = VideoDimension(1280, 720),
bitrate = 1_000_000,
fps = 30,
),
),
publish_option_id = videoPublishOption.id,
),
)
coEvery { publisher.setLocalDescription(any()) } returns Result.Success(Unit)
coEvery { publisher.setRemoteDescription(any()) } returns Result.Success(Unit)
coEvery { mockSignalServerService.setPublisher(any()) } returns SetPublisherResponse(
sdp = "",
error = Error(
code = ErrorCode.ERROR_CODE_REQUEST_VALIDATION_FAILED,
message = "Invalid SetPublisher request",
should_retry = false,
),
)

publisher.negotiate(source = "test")

coVerify(exactly = 1) { mockSignalServerService.setPublisher(any()) }
assertEquals(1, rejoinInvocations)
}

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use TestBase for this unit-test fixture.

PublisherTest does not use TestBase. Migrate this fast unit-test class to TestBase and retain the existing setup in its lifecycle hooks.

As per coding guidelines, **/src/test/**/*.{kt,kts} requires TestBase for fast unit tests.

🤖 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 293 - 355, Migrate the PublisherTest fixture to extend TestBase,
preserving its current initialization and cleanup behavior by moving or
retaining setup in the appropriate TestBase lifecycle hooks. Update the class
declaration and any affected fixture references so all existing tests, including
getAnnouncedTracks and SetPublisher validation failure rejoins, continue using
the same setup.

Source: Coding guidelines

@PratimMallick PratimMallick added the pr:bug Fixes a bug label Aug 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.29 MB 12.29 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.70 MB 5.70 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.19 MB 6.19 MB 0.00 MB 🟢

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

@aleksandar-apostolov aleksandar-apostolov changed the title fix(core): always announce video layers on SetPublisher Always announce video layers on SetPublisher Aug 13, 2026
}
}
}
return@submit

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.

safeCall is inline, so this return@submit returns out of the whole submit lambda and skips the isIceRestarting = false reset at the end of negotiate().

On an iceRestart = true negotiate that errors here, the flag stays true and every later negotiate() no-ops on the isIceRestarting guard — renegotiation is dead until a full reconnect. The else -> branch is exposed (no rejoin()); before this PR the error path fell through and always reset. Regression.

Suggest a try/catch/finally instead of safeCall — throwable stays visible and finally guarantees the reset. Plus a regression test: iceRestart = true + non-validation error, assert the next negotiate() isn't skipped.

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.

2 participants