Skip to content

Commit 1b53a68

Browse files
authored
fix(llc): renegotiate unacknowledged transceivers (#1288)
* renegotiate unacknowledged transcivers * changelog * tweaks * format fix * tweaks * tweak * tweak
1 parent cd8d47f commit 1b53a68

8 files changed

Lines changed: 514 additions & 18 deletions

File tree

packages/stream_video/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
## Unreleased
1+
## Upcoming
22

33
### 🐞 Fixed
44

5+
- Fixed an issue where republishing could reuse a cached publisher transceiver without renegotiating.
56
- Fixed a `FormatException` when sending requests if the application name (or other device/app info) contains non-ASCII characters. Values included in the `X-Stream-Client` header are now sanitized to valid header characters.
67

78
## 1.4.2

packages/stream_video/lib/src/call/session/call_session.dart

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,15 @@ class CallSession extends Disposable {
553553
final publisher = rtcManager?.publisher;
554554
if (publisher != null) {
555555
_logger.v(() => '[fastReconnect] triggering publisher renegotiation');
556-
await _onRenegotiationNeeded(publisher);
556+
final renegotiation = await _onRenegotiationNeeded(publisher);
557+
if (renegotiation is Failure) {
558+
_logger.w(
559+
() =>
560+
'[fastReconnect] publisher renegotiation failed: '
561+
'${renegotiation.error}',
562+
);
563+
result = renegotiation;
564+
}
557565
}
558566

559567
final remoteTracks = rtcManager!.tracks.values
@@ -620,7 +628,8 @@ class CallSession extends Disposable {
620628

621629
// Signaling stall: local offer created but SetPublisher never completed,
622630
// leaving the peer connection wedged in `have-local-offer`. Negotiation
623-
// won't resume on its own, so renegotiate and rejoin if recovery fails.
631+
// won't resume on its own, so renegotiate and fall back to a reconnect
632+
// if the recovery fails.
624633
final signalingState = publisher.pc.signalingState;
625634
if (signalingState ==
626635
rtc.RTCSignalingState.RTCSignalingStateHaveLocalOffer) {
@@ -640,9 +649,9 @@ class CallSession extends Disposable {
640649
_logger.w(
641650
() =>
642651
'[publisherConnectionCheck] recovery renegotiation failed '
643-
'(${result.getErrorOrNull()}) — triggering rejoin',
652+
'(${result.getErrorOrNull()}) — triggering fast reconnect',
644653
);
645-
onReconnectionNeeded(publisher, SfuReconnectionStrategy.rejoin);
654+
onReconnectionNeeded(publisher, SfuReconnectionStrategy.fast);
646655
}
647656
return;
648657
}
@@ -1097,7 +1106,7 @@ class CallSession extends Disposable {
10971106
return Result.error('SFU WS is not connected');
10981107
}
10991108

1100-
await _negotiationLock.synchronized(() async {
1109+
return _negotiationLock.synchronized(() async {
11011110
_logger.d(() => '[negotiate] type: ${pc.type}');
11021111

11031112
final offer = await pc.createOffer();
@@ -1114,7 +1123,14 @@ class CallSession extends Disposable {
11141123
_logger.w(
11151124
() => '[negotiate] rejected(tracksInfo is empty): $tracksInfo',
11161125
);
1117-
return pc.rollbackLocalDescription();
1126+
1127+
// Nothing to publish is a no-op, not a negotiation failure — but a
1128+
// failed rollback leaves the publisher wedged in `have-local-offer`,
1129+
// which is.
1130+
final rollback = await pc.rollbackLocalDescription();
1131+
if (rollback.isFailure) return rollback;
1132+
1133+
return const Result.success(null);
11181134
}
11191135

11201136
_logger.v(() => '[negotiate] announcing tracks: $tracksInfo');
@@ -1130,7 +1146,10 @@ class CallSession extends Disposable {
11301146

11311147
if (pubResult is! Success<sfu.SetPublisherResponse>) {
11321148
_logger.w(() => '[negotiate] #setPublisher; failed: $pubResult');
1133-
return pc.rollbackLocalDescription();
1149+
await pc.rollbackLocalDescription();
1150+
return Result<void>.error(
1151+
'SetPublisher failed: ${pubResult.getErrorOrNull()}',
1152+
);
11341153
}
11351154

11361155
if (pubResult.data.hasSdp()) {
@@ -1139,15 +1158,21 @@ class CallSession extends Disposable {
11391158
_logger.w(
11401159
() => '[negotiate] #setRemoteAnswer; failed: $ansResult',
11411160
);
1161+
return Result<void>.error(
1162+
'Failed to set remote answer: ${ansResult.getErrorOrNull()}',
1163+
);
11421164
}
11431165
}
1166+
1167+
rtcManager?.transceiversManager.markNegotiated(tracksInfo);
1168+
1169+
return const Result.success(null);
11441170
} catch (e, stk) {
11451171
_logger.e(() => '[negotiate] failed: $e\n$stk');
1146-
return pc.rollbackLocalDescription();
1172+
await pc.rollbackLocalDescription();
1173+
return Result.failure(VideoErrors.compose(e, stk));
11471174
}
11481175
});
1149-
1150-
return const Result.success(null);
11511176
}
11521177

11531178
Future<void> _onRemoteTrackReceived(

packages/stream_video/lib/src/webrtc/peer_connection.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ class StreamPeerConnection extends Disposable {
8888
/// Flag to indicate that a reconnect is in progress.
8989
bool _isReconnecting = false;
9090

91+
/// Whether a reconnect is currently in progress for this peer connection.
92+
bool get isReconnecting => _isReconnecting;
93+
9194
void setReconnecting(bool value) {
9295
_logger.v(() => '[setReconnecting] #$type; value: $value');
9396
_isReconnecting = value;

packages/stream_video/lib/src/webrtc/rtc_manager.dart

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,8 @@ extension PublisherRtcManager on RtcManager {
811811
tracks[audioTrack.trackId] = audioTrack;
812812
var updatedTrack = audioTrack.copyWith(stopTrackOnMute: stopTrackOnMute);
813813

814+
var needsRenegotiation = false;
815+
814816
for (final option in publishOptions) {
815817
if (option.trackType != audioTrack.trackType) continue;
816818

@@ -819,7 +821,8 @@ extension PublisherRtcManager on RtcManager {
819821
final mediaTrackClone = await audioTrack.mediaTrack.clone();
820822
final trackToPublish = audioTrack.copyWith(mediaTrack: mediaTrackClone);
821823

822-
final cachedTransceiver = transceiversManager.get(option)?.transceiver;
824+
final cachedBundle = transceiversManager.get(option);
825+
final cachedTransceiver = cachedBundle?.transceiver;
823826
if (cachedTransceiver == null) {
824827
final transceiverResult = await _addTransceiver(
825828
trackToPublish,
@@ -854,6 +857,13 @@ extension PublisherRtcManager on RtcManager {
854857
trackPublishOptions: publishOptions,
855858
);
856859

860+
// Reusing a transceiver via replaceTrack does not fire
861+
// onRenegotiationNeeded. If its previous negotiation never reached the
862+
// SFU, the SFU still doesn't know about it, so force a renegotiation.
863+
if (cachedBundle != null && !cachedBundle.negotiated) {
864+
needsRenegotiation = true;
865+
}
866+
857867
_logger.v(
858868
() => '[publishAudioTrack] cached transceiver: $cachedTransceiver',
859869
);
@@ -864,6 +874,10 @@ extension PublisherRtcManager on RtcManager {
864874
);
865875
}
866876

877+
if (needsRenegotiation) {
878+
_forceRenegotiation('[publishAudioTrack]');
879+
}
880+
867881
// Notify listeners.
868882
onLocalTrackPublished?.call(updatedTrack);
869883
tracks[updatedTrack.trackId] = updatedTrack;
@@ -901,6 +915,8 @@ extension PublisherRtcManager on RtcManager {
901915
);
902916
}
903917

918+
var needsRenegotiation = false;
919+
904920
for (final option in publishOptions) {
905921
if (option.trackType != videoTrack.trackType) continue;
906922

@@ -909,7 +925,8 @@ extension PublisherRtcManager on RtcManager {
909925
final mediaTrackClone = await videoTrack.mediaTrack.clone();
910926
final trackToPublish = videoTrack.copyWith(mediaTrack: mediaTrackClone);
911927

912-
final cachedTransceiver = transceiversManager.get(option)?.transceiver;
928+
final cachedBundle = transceiversManager.get(option);
929+
final cachedTransceiver = cachedBundle?.transceiver;
913930
if (cachedTransceiver == null) {
914931
final transceiverResult = await _addTransceiver(
915932
trackToPublish,
@@ -936,6 +953,13 @@ extension PublisherRtcManager on RtcManager {
936953

937954
transceiversManager.update(option, track: trackToPublish);
938955

956+
// Reusing a transceiver via replaceTrack does not fire
957+
// onRenegotiationNeeded. If its previous negotiation never reached the
958+
// SFU, the SFU still doesn't know about it, so force a renegotiation.
959+
if (cachedBundle != null && !cachedBundle.negotiated) {
960+
needsRenegotiation = true;
961+
}
962+
939963
_logger.v(
940964
() => '[publishVideoTrack] cached transceiver: $cachedTransceiver',
941965
);
@@ -946,6 +970,10 @@ extension PublisherRtcManager on RtcManager {
946970
);
947971
}
948972

973+
if (needsRenegotiation) {
974+
_forceRenegotiation('[publishVideoTrack]');
975+
}
976+
949977
// Notify listeners.
950978
onLocalTrackPublished?.call(updatedTrack);
951979
tracks[updatedTrack.trackId] = updatedTrack;
@@ -1002,6 +1030,39 @@ extension PublisherRtcManager on RtcManager {
10021030
];
10031031
}
10041032

1033+
/// Explicitly triggers a publisher renegotiation.
1034+
void _forceRenegotiation(String tag) {
1035+
final pub = publisher;
1036+
if (pub == null) return;
1037+
1038+
if (pub.isReconnecting) {
1039+
_logger.v(
1040+
() => '$tag skipping forced renegotiation — reconnect in progress',
1041+
);
1042+
return;
1043+
}
1044+
1045+
_logger.v(
1046+
() =>
1047+
'$tag forcing renegotiation for a reused transceiver the SFU never '
1048+
'acknowledged',
1049+
);
1050+
1051+
pub.onRenegotiationNeeded?.call(pub);
1052+
}
1053+
1054+
/// Forces a publisher renegotiation if any transceiver sending [trackId]
1055+
/// was never acknowledged by the SFU.
1056+
void _renegotiateIfUnacknowledged(String trackId, String tag) {
1057+
final hasUnacknowledged = transceiversManager
1058+
.findAll((t) => t.track.trackId == trackId)
1059+
.any((t) => t.transceiver.sender.track != null && !t.negotiated);
1060+
1061+
if (hasUnacknowledged) {
1062+
_forceRenegotiation(tag);
1063+
}
1064+
}
1065+
10051066
Future<Result<rtc.RTCRtpTransceiver>> _addTransceiver(
10061067
RtcLocalTrack track,
10071068
SfuPublishOptions publishOptions,
@@ -1181,13 +1242,15 @@ extension PublisherRtcManager on RtcManager {
11811242
tracks[trackId] = updatedTrack;
11821243
onLocalTrackMuted?.call(updatedTrack, false);
11831244

1245+
_renegotiateIfUnacknowledged(trackId, '[unmuteTrack]');
11841246
return Result.success(updatedTrack);
11851247
}
11861248

11871249
// Otherwise simply enable it again
11881250
track.enable();
11891251
onLocalTrackMuted?.call(track, false);
11901252

1253+
_renegotiateIfUnacknowledged(trackId, '[unmuteTrack]');
11911254
return Result.success(track);
11921255
}
11931256

packages/stream_video/lib/src/webrtc/transceiver_cache.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart';
33

44
import '../sfu/data/models/sfu_publish_options.dart';
55
import '../sfu/data/models/sfu_track_type.dart';
6+
import 'model/rtc_tracks_info.dart';
67
import 'rtc_track/rtc_track.dart';
78
import 'rtc_track/rtc_track_publish_options.dart';
89

@@ -12,16 +13,21 @@ class TransceiverCache {
1213
required this.publishOption,
1314
required this.transceiver,
1415
required this.trackPublishOptions,
16+
this.negotiated = false,
1517
});
1618

1719
RtcLocalTrack track;
1820
SfuPublishOptions publishOption;
1921
RTCRtpTransceiver transceiver;
2022
RtcTrackPublishOptions trackPublishOptions;
2123

24+
/// Whether the SFU has acknowledged this transceiver through a completed
25+
/// publisher negotiation.
26+
bool negotiated;
27+
2228
@override
2329
String toString() {
24-
return 'TransceiverCache{mediaTrackId: ${track.mediaTrack.id}, publishOption: ${publishOption.id},${publishOption.codec}, sender.track.enabled: ${transceiver.sender.track?.enabled}}';
30+
return 'TransceiverCache{mediaTrackId: ${track.mediaTrack.id}, publishOption: ${publishOption.id},${publishOption.codec}, sender.track.enabled: ${transceiver.sender.track?.enabled}, negotiated: $negotiated}';
2531
}
2632
}
2733

@@ -110,6 +116,20 @@ class TransceiverManager {
110116
return _transceivers;
111117
}
112118

119+
/// Marks the cached transceivers that were part of [announced] as negotiated,
120+
/// i.e. acknowledged by the SFU after a completed negotiation.
121+
void markNegotiated(Iterable<RtcTrackInfo> announced) {
122+
for (final info in announced) {
123+
final item = find(
124+
(c) =>
125+
c.publishOption.id == info.publishOptionId &&
126+
c.track.mediaTrack.id == info.trackId,
127+
);
128+
129+
item?.negotiated = true;
130+
}
131+
}
132+
113133
/// Init index of the transceiver in the cache.
114134
int indexOf(RTCRtpTransceiver transceiver) {
115135
return _transceiverOrder.indexOf(transceiver);

packages/stream_video/test/src/call/session/call_session_reconnect_safety_test.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ void main() {
246246
});
247247

248248
test(
249-
'renegotiates and escalates to rejoin when the publisher is wedged in '
250-
'"have-local-offer" after the delay',
249+
'renegotiates and asks for a fast reconnect when the publisher is wedged '
250+
'in "have-local-offer" after the delay',
251251
() {
252252
fakeAsync((async) {
253253
final reconnects =
@@ -273,10 +273,13 @@ void main() {
273273
..flushMicrotasks();
274274

275275
// The recovery renegotiation cannot complete (no SFU connection in
276-
// the test), so the watchdog falls back to a full rejoin.
276+
// the test), so the watchdog falls back to a fast reconnect. It does
277+
// not ask for a rejoin directly — the reconnect loop escalates to one
278+
// once the fast attempts are exhausted or a peer connection is
279+
// unhealthy.
277280
expect(reconnects, hasLength(1));
278281
expect(reconnects.single.$1, same(wires.publisher));
279-
expect(reconnects.single.$2, SfuReconnectionStrategy.rejoin);
282+
expect(reconnects.single.$2, SfuReconnectionStrategy.fast);
280283
});
281284
},
282285
);

0 commit comments

Comments
 (0)