Skip to content

fix(llc): publisher track mid mismatch - #1291

Merged
Brazol merged 10 commits into
mainfrom
fix/publisher-track-mid-mismatch
Jul 30, 2026
Merged

fix(llc): publisher track mid mismatch#1291
Brazol merged 10 commits into
mainfrom
fix/publisher-track-mid-mismatch

Conversation

@Brazol

@Brazol Brazol commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes publisher-negotiation bugs that could leave a track "published" but sending no/wrong media.
The announced track mid was read from a cached transceiver and could drift from the offer after a renegotiation/retry, so the SFU couldn't match the track to its codec. It's now resolved from the live peer-connection state to always match the offer being sent.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed track announcement during renegotiation and republish retries to avoid unresolved/stale MIDs, preventing missing or dropped tracks.
    • Improved MID resolution and transceiver matching, including safer fallback behavior when MID lookup isn’t possible.
    • Enhanced signaling robustness by properly handling remote-description/ICE-candidate serialization and sanitizing client header values with non-ASCII characters.
  • Tests
    • Added/expanded unit and session tests for MID resolution precedence, reconnect/rollback behavior, and negotiated-MID tracking/edge cases.

@Brazol
Brazol requested a review from a team as a code owner July 27, 2026 11:32
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Publisher track announcements now resolve MIDs from live transceivers, publisher SDP, or cached acknowledgements. Unresolved MIDs cause rollback and negotiation failure, while sender-aware cache matching supports reconnect announcements. Tests cover resolution, recovery, and cache behavior.

Changes

Publisher track announcements

Layer / File(s) Summary
Current MID resolution and validation
packages/stream_video/lib/src/webrtc/rtc_manager.dart, packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart
Track MIDs are resolved safely from live transceivers, SDP, provided transceivers, or acknowledged fallback values.
Announcement integration
packages/stream_video/lib/src/webrtc/rtc_manager.dart, packages/stream_video/lib/src/call/stats/trace_tag.dart, packages/stream_video/test/src/webrtc/rtc_manager_announced_tracks_test.dart
Announcements use resolved MIDs and sender track IDs, with tracing for unresolved tracks.
Renegotiation recovery
packages/stream_video/lib/src/call/session/call_session.dart, packages/stream_video/test/src/call/session/call_session_announce_test.dart
Unresolved announcements roll back the local description and return a negotiation error; empty announcements remain non-errors.
Sender-aware cache matching
packages/stream_video/lib/src/webrtc/transceiver_cache.dart, packages/stream_video/test/src/webrtc/transceiver_cache_test.dart
Negotiated tracks match sender-attached IDs and preserve acknowledged MIDs for reconnects.
Release documentation
packages/stream_video/CHANGELOG.md
The upcoming changelog records related WebRTC and header-sanitization fixes.

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

Sequence Diagram(s)

sequenceDiagram
  participant CallSession
  participant RtcManager
  participant PeerConnection
  participant LocalSdp
  CallSession->>RtcManager: request announced tracks
  RtcManager->>PeerConnection: fetch live transceivers
  RtcManager->>LocalSdp: read publisher local SDP
  RtcManager->>RtcManager: resolve track MID
  RtcManager-->>CallSession: return track info or unresolved result
  CallSession->>PeerConnection: roll back unresolved local offer
Loading

Possibly related PRs

Suggested reviewers: renefloor

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description states the bug and approach, but it omits the required template sections and checklist items. Add the required Goal, Implementation details, UI Changes, Testing, and checklist sections, and note any sections that do not apply.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately reflects the main fix to publisher track MID matching.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/publisher-track-mid-mismatch

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.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.34177% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 11.57%. Comparing base (62c7f1d) to head (1b26a31).

Files with missing lines Patch % Lines
...tream_video/lib/src/call/session/call_session.dart 65.00% 7 Missing ⚠️
...kages/stream_video/lib/src/webrtc/rtc_manager.dart 96.15% 2 Missing ⚠️
...stream_video/lib/src/webrtc/transceiver_cache.dart 85.71% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1291      +/-   ##
==========================================
+ Coverage   11.37%   11.57%   +0.20%     
==========================================
  Files         686      686              
  Lines       50408    50441      +33     
==========================================
+ Hits         5734     5841     +107     
+ Misses      44674    44600      -74     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@packages/stream_video/lib/src/call/session/call_session.dart`:
- Around line 1093-1102: Recheck the call/session connection state after
acquiring and waiting on _negotiationLock, immediately before the fallback in
the initial publisher-offer failure path. If the session is already disconnected
or closing, return without invoking onReconnectionNeeded; only trigger
SfuReconnectionStrategy.rejoin for an active session, preserving the existing
rollback behavior.

In `@packages/stream_video/lib/src/webrtc/rtc_manager.dart`:
- Around line 654-662: Update resolveTrackMid’s SDP media matching to split each
m-section’s msid into tokens and compare trackId only against the exact track-id
token, not via contains. Preserve the existing MID return behavior, and add
regression coverage for a prefix track ID and an ID appearing only in the
stream-id token.
🪄 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 Plus

Run ID: ecbdd0fd-78e7-4bcc-b929-57bd297d7136

📥 Commits

Reviewing files that changed from the base of the PR and between d0b3a62 and 2b87908.

📒 Files selected for processing (3)
  • packages/stream_video/CHANGELOG.md
  • packages/stream_video/lib/src/call/session/call_session.dart
  • packages/stream_video/lib/src/webrtc/rtc_manager.dart

Comment thread packages/stream_video/lib/src/call/session/call_session.dart Outdated
Comment thread packages/stream_video/lib/src/webrtc/rtc_manager.dart Outdated

@renefloor renefloor 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.

🔴 The recovery Result is thrown away — _onRenegotiationNeeded always returns success

Every return the PR adds is inside _negotiationLock.synchronized(() async { ... }), so it returns from the closure, not from _onRenegotiationNeeded. call_session.dart:1150 unconditionally returns const Result.success(null). This predates the PR (the old Result.error('Failed to create offer') was equally discarded), but the PR is built entirely around returning recovery results, so it needs fixing here.

Consequence — startPublisherConnectionCheck at call_session.dart:638-646:

final result = await _onRenegotiationNeeded(publisher);
if (result.isFailure && !_isLeavingOrClosed) { ... rejoin ... } // dead branch

The have-local-offer stall path can never escalate to rejoin. That's one of the two stalls this PR claims to fix, and it's still broken. Same for the onIceRestart failure logging at line 974.

return await _negotiationLock.synchronized<Result>(() async {
...
return const Result.success(null); // explicit success at the end of the closure
});

Note the interaction once fixed: _recoverFailedPublisherNegotiation returning Result.success(null) after firing onReconnectionNeeded becomes load-bearing — it's what stops line 645 from firing a second rejoin. That's a defensible choice but it's currently implicit; please comment it.

🟠 getTransceivers() can throw, and neither call site is protected

getAnnouncedTracks is invoked at call_session.dart:1111 — before the try at line 1122 — and createOffer has already set the local description by then. The native impl throws a bare String on PlatformException, and RTCRtpSenderNative.fromMap does Map<dynamic, dynamic> trackMap = map['track']; (non-nullable) — a TypeError if the platform omits track. Any throw escapes _onRenegotiationNeeded entirely: no rollback, no rejoin, publisher wedged in have-local-offer. That is precisely the failure mode this PR set out to eliminate.

The reconnect path is worse: getReconnectDetails (call_session.dart:172) awaits getAnnouncedTracksForReconnect, which now also calls getTransceivers(). On a publisher pc that's already closing during a rejoin, a throw here aborts the whole reconnect rather than degrading.

Wrap it:

final liveTransceivers = await publisher?.pc.getTransceivers().onError((_, __) => []) ?? [];

…or move getAnnouncedTracks inside the existing try in _onRenegotiationNeeded.

🟡 Dropping the type == kind check makes the SDP fallback fail closed and silent

Old predicate: m['type'] == track.kind && ((m['msid'] as String?)?.contains(track.id!) ?? true) — the ?? true meant "no msid on this m-section → match on kind alone." New predicate requires msid present and containing the trackId, else null → the track is dropped from the announce and therefore never published.

I think failing closed is the right call (announcing a wrong mid is worse than announcing nothing), but two asks:

  • Keep m['type'] == kind as an additional guard on the msid match. It's free and protects against the msid appearing in a recycled/inactive m-section that .reversed might land on.
  • The drop is only a _logger.w. The user-visible symptom is "my camera isn't being published" with no error surfaced anywhere. Add a _tracer.trace so it shows up in telemetry — you already trace publisherConnectionCheckStalled for comparable conditions.

🟡 No tests

resolveTrackMid is the most testable function in this PR — pure over a transceiver list plus an SDP string, and test/src/webrtc/peer_connection_renegotiation_test.dart already has the mock-PC scaffolding. Worth covering:

  • live mid wins over SDP
  • empty live mid → SDP msid fallback resolves
  • .reversed picks the last matching m-section (the recycled-m-line case that motivated this fix)
  • msid absent → null (pins the new fail-closed contract)
  • mediaTrack.id == null → null

Plus one on the new escalation: empty tracksInfo + no remote description → onReconnectionNeeded(rejoin).

🔵 Minor

  • Dead code: TransceiverManager.indexOf (transceiver_cache.dart:114) and its backing _transceiverOrder list are now unused — rtc_manager.dart:703 was the only caller. Both should go in this PR.
  • resolveTrackMid(TransceiverCache cache, ...) reads only cache.track.mediaTrack.id. Taking String? trackId would be a more honest signature and trivially testable.
  • _recoverFailedPublisherNegotiation logs 'initial publisher offer failed' for all three call sites, including setPublisher failure. Pass a reason string in.
  • parse(sdp) runs per unresolved track inside the loop. Rare in practice (step 1 usually wins, since createOffer sets the local description before mids are read), so low priority — but hoisting the parse into the two getAnnouncedTracks* callers would remove it entirely.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
packages/stream_video/lib/src/webrtc/rtc_manager.dart (1)

637-660: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the substring msid match and add a regression test for it. The root cause is resolveTrackMid's SDP fallback matching trackId via String.contains instead of an exact token comparison, which can select an unrelated m-section when one id is a substring of another.

  • packages/stream_video/lib/src/webrtc/rtc_manager.dart#L637-L660: replace (m['msid'] as String?)?.contains(trackId) with an exact-token comparison (split msid on whitespace and compare the track-id token), as previously proposed.
  • packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart#L120-L137: add a case where a videoTrackId is a prefix/substring of another m-section's msid (stream-id or track-id token) to lock in the exact-match fix and prevent regression.
🤖 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 `@packages/stream_video/lib/src/webrtc/rtc_manager.dart` around lines 637 -
660, The resolveTrackMid SDP fallback in
packages/stream_video/lib/src/webrtc/rtc_manager.dart:637-660 must match trackId
as an exact whitespace-delimited msid token rather than using substring
matching; add a regression case in
packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart:120-137
where one videoTrackId is a prefix or substring of another m-section’s msid and
verify the correct m-section is selected.
🤖 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.

Outside diff comments:
In `@packages/stream_video/lib/src/webrtc/rtc_manager.dart`:
- Around line 637-660: The resolveTrackMid SDP fallback in
packages/stream_video/lib/src/webrtc/rtc_manager.dart:637-660 must match trackId
as an exact whitespace-delimited msid token rather than using substring
matching; add a regression case in
packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart:120-137
where one videoTrackId is a prefix or substring of another m-section’s msid and
verify the correct m-section is selected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a661810-8143-4c34-9e3c-2a0087568f18

📥 Commits

Reviewing files that changed from the base of the PR and between 2fb5ba1 and 3b7c6c3.

📒 Files selected for processing (2)
  • packages/stream_video/lib/src/webrtc/rtc_manager.dart
  • packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
packages/stream_video/lib/src/call/session/call_session.dart (1)

1173-1183: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Missing rollback on setRemoteAnswer failure, unlike the sibling setPublisher failure branch.

The pubResult failure path just above rolls back before returning an error (Line 1167: await pc.rollbackLocalDescription();). This branch only returns an error, leaving pc wedged in have-local-offer with no rollback attempt. Recovery then depends entirely on the publisherConnectionCheck watchdog picking up the stall later, rather than surfacing/self-healing immediately like the other failure paths in this function.

Proposed fix
         if (pubResult.data.hasSdp()) {
           final ansResult = await pc.setRemoteAnswer(pubResult.data.sdp);
           if (ansResult is! Success<void>) {
             _logger.w(
               () => '[negotiate] `#setRemoteAnswer`; failed: $ansResult',
             );
+            await pc.rollbackLocalDescription();
             return Result<void>.error(
               'Failed to set remote answer: ${ansResult.getErrorOrNull()}',
             );
           }
         }
🤖 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 `@packages/stream_video/lib/src/call/session/call_session.dart` around lines
1173 - 1183, Update the setRemoteAnswer failure branch in the negotiation flow
to await pc.rollbackLocalDescription() before returning the existing error
result, matching the rollback behavior of the preceding pubResult failure path
and preserving the current logging and error propagation.
packages/stream_video/lib/src/webrtc/rtc_manager.dart (1)

950-992: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Stale negotiated flag lets republished audio/video tracks skip renegotiation. Both publishAudioTrack and publishVideoTrack attach a fresh MediaStreamTrack clone to a cached, already-negotiated transceiver via replaceTrack, but only force renegotiation if (cachedBundle != null && !cachedBundle.negotiated). Since TransceiverManager.update() never resets negotiated, an already-negotiated transceiver silently keeps sending the SFU's old trackId while new media flows over the same mid — the same "published while sending... incorrect media" defect class this PR targets elsewhere.

  • packages/stream_video/lib/src/webrtc/rtc_manager.dart#L950-L992: in publishAudioTrack, force renegotiation whenever the cached transceiver's track is swapped, not only when previously unnegotiated (or fix centrally, see below).
  • packages/stream_video/lib/src/webrtc/rtc_manager.dart#L1054-L1088: apply the identical fix in publishVideoTrack.
  • packages/stream_video/lib/src/webrtc/transceiver_cache.dart#L81-L92 (root-cause fix, not directly in this diff): reset bundle.negotiated = false in TransceiverManager.update() whenever a new track is provided, so every consumer of the flag (including _renegotiateIfUnacknowledged) sees an accurate state.
🤖 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 `@packages/stream_video/lib/src/webrtc/rtc_manager.dart` around lines 950 -
992, Reset the cached bundle’s negotiated state in TransceiverManager.update()
whenever a new track is supplied, covering
packages/stream_video/lib/src/webrtc/transceiver_cache.dart#L81-L92. In
publishAudioTrack at
packages/stream_video/lib/src/webrtc/rtc_manager.dart#L950-L992 and
publishVideoTrack at
packages/stream_video/lib/src/webrtc/rtc_manager.dart#L1054-L1088, ensure
replacing a cached transceiver track triggers renegotiation based on the updated
state rather than allowing a stale negotiated flag to suppress it.
🤖 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.

Outside diff comments:
In `@packages/stream_video/lib/src/call/session/call_session.dart`:
- Around line 1173-1183: Update the setRemoteAnswer failure branch in the
negotiation flow to await pc.rollbackLocalDescription() before returning the
existing error result, matching the rollback behavior of the preceding pubResult
failure path and preserving the current logging and error propagation.

In `@packages/stream_video/lib/src/webrtc/rtc_manager.dart`:
- Around line 950-992: Reset the cached bundle’s negotiated state in
TransceiverManager.update() whenever a new track is supplied, covering
packages/stream_video/lib/src/webrtc/transceiver_cache.dart#L81-L92. In
publishAudioTrack at
packages/stream_video/lib/src/webrtc/rtc_manager.dart#L950-L992 and
publishVideoTrack at
packages/stream_video/lib/src/webrtc/rtc_manager.dart#L1054-L1088, ensure
replacing a cached transceiver track triggers renegotiation based on the updated
state rather than allowing a stale negotiated flag to suppress it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d430a33-fd99-4308-a157-0b6265dbd7dd

📥 Commits

Reviewing files that changed from the base of the PR and between 3b7c6c3 and b16fbc4.

📒 Files selected for processing (9)
  • packages/stream_video/CHANGELOG.md
  • packages/stream_video/lib/src/call/session/call_session.dart
  • packages/stream_video/lib/src/call/stats/trace_tag.dart
  • packages/stream_video/lib/src/webrtc/rtc_manager.dart
  • packages/stream_video/lib/src/webrtc/transceiver_cache.dart
  • packages/stream_video/test/src/call/session/call_session_announce_test.dart
  • packages/stream_video/test/src/webrtc/rtc_manager_announced_tracks_test.dart
  • packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart
  • packages/stream_video/test/src/webrtc/transceiver_cache_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 `@packages/stream_video/lib/src/webrtc/rtc_manager.dart`:
- Line 831: Update _transceiverToTrackInfo so transceiverCache.negotiatedMid is
used as lastKnownMid only for getAnnouncedTracksForReconnect, while normal
getAnnouncedTracks calls pass no fallback and return null when the current MID
cannot be resolved. Add a regression test covering a previously negotiated track
that cannot resolve during a normal announcement.
🪄 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 Plus

Run ID: 42ccb68a-6d3d-4972-895e-7c0fb27ecf65

📥 Commits

Reviewing files that changed from the base of the PR and between b16fbc4 and 5322003.

📒 Files selected for processing (6)
  • packages/stream_video/lib/src/call/session/call_session.dart
  • packages/stream_video/lib/src/webrtc/rtc_manager.dart
  • packages/stream_video/lib/src/webrtc/transceiver_cache.dart
  • packages/stream_video/test/src/webrtc/rtc_manager_announced_tracks_test.dart
  • packages/stream_video/test/src/webrtc/rtc_manager_mid_resolution_test.dart
  • packages/stream_video/test/src/webrtc/transceiver_cache_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/stream_video/lib/src/call/session/call_session.dart

Comment thread packages/stream_video/lib/src/webrtc/rtc_manager.dart
@Brazol
Brazol merged commit f56777d into main Jul 30, 2026
18 checks passed
@Brazol
Brazol deleted the fix/publisher-track-mid-mismatch branch July 30, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants