Merge pull request #21008 from prlanzarin/u27/feat/ice-restart

feat: add experimental support for ICE restart
This commit is contained in:
Anton Georgiev 2024-09-13 10:24:35 -04:00 committed by GitHub
commit 02bd94a400
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 268 additions and 12 deletions

View File

@ -1 +1 @@
git clone --branch v2.14.2 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu
git clone --branch v2.15.0-beta.0 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu

View File

@ -33,6 +33,10 @@ const TRANSPARENT_LISTEN_ONLY = MEDIA.transparentListenOnly;
const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, '');
const CONNECTION_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000;
const { audio: NETWORK_PRIORITY } = MEDIA.networkPriorities || {};
const {
enabled: RESTART_ICE = false,
retries: RESTART_ICE_RETRIES = 1,
} = Meteor.settings.public.kurento?.restartIce?.audio || {};
const SENDRECV_ROLE = 'sendrecv';
const RECV_ROLE = 'recv';
const BRIDGE_NAME = 'fullaudio';
@ -379,6 +383,8 @@ export default class SFUAudioBridge extends BaseAudioBridge {
mediaStreamFactory: this.mediaStreamFactory,
gatheringTimeout: GATHERING_TIMEOUT,
transparentListenOnly: isTransparentListenOnlyEnabled(),
restartIce: RESTART_ICE,
restartIceMaxRetries: RESTART_ICE_RETRIES,
};
this.broker = new AudioBroker(

View File

@ -16,6 +16,10 @@ const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
const { screenshare: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
const {
enabled: RESTART_ICE = false,
retries: RESTART_ICE_RETRIES = 3,
} = Meteor.settings.public.kurento?.restartIce?.screenshare || {};
const BRIDGE_NAME = 'kurento';
const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
@ -363,6 +367,8 @@ export default class KurentoScreenshareBridge {
forceRelay: shouldForceRelay(),
traceLogs: TRACE_LOGS,
gatheringTimeout: GATHERING_TIMEOUT,
restartIce: RESTART_ICE,
restartIceMaxRetries: RESTART_ICE_RETRIES,
};
this.broker = new ScreenshareBroker(
@ -442,6 +448,7 @@ export default class KurentoScreenshareBridge {
traceLogs: TRACE_LOGS,
networkPriority: NETWORK_PRIORITY,
gatheringTimeout: GATHERING_TIMEOUT,
restartIce: RESTART_ICE,
};
this.broker = new ScreenshareBroker(

View File

@ -48,6 +48,10 @@ const {
const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
const {
enabled: RESTART_ICE = false,
retries: RESTART_ICE_RETRIES = 3,
} = Meteor.settings.public.kurento?.restartIce?.video || {};
const intlClientErrors = defineMessages({
permissionError: {
@ -324,6 +328,10 @@ class VideoProvider extends Component {
this.handleIceCandidate(parsedMessage);
break;
case 'restartIceResponse':
this.handleRestartIceResponse(parsedMessage);
break;
case 'pong':
break;
@ -500,6 +508,36 @@ class VideoProvider extends Component {
this.sendMessage(message);
}
requestRestartIce(peer, stream) {
if (peer == null) {
throw new Error('No peer to restart ICE');
}
if (peer.vpRestartIceRetries >= RESTART_ICE_RETRIES) {
throw new Error('Max ICE restart retries reached');
}
const role = VideoService.getRole(peer.isPublisher);
const message = {
id: 'restartIce',
type: 'video',
cameraId: stream,
role,
};
// eslint-disable-next-line no-param-reassign
peer.vpRestartIceRetries += 1;
logger.warn({
logCode: 'video_provider_restart_ice',
extraInfo: {
cameraId: stream,
role,
restartIceRetries: peer.vpRestartIceRetries,
},
}, `Requesting ICE restart (${peer.vpRestartIceRetries}/${RESTART_ICE_RETRIES})`);
this.sendMessage(message);
}
startResponse(message) {
const { cameraId: stream, role } = message;
const peer = this.webRtcPeers[stream];
@ -565,6 +603,36 @@ class VideoProvider extends Component {
}
}
handleRestartIceResponse(message) {
const { cameraId: stream, sdp } = message;
const peer = this.webRtcPeers[stream];
if (peer) {
peer?.restartIce(sdp, peer?.isPublisher)
.catch((error) => {
const { peerConnection } = peer;
if (peerConnection) peerConnection.onconnectionstatechange = null;
logger.error({
logCode: 'video_provider_restart_ice_error',
extraInfo: {
errorMessage: error?.message,
errorCode: error?.code,
errorName: error?.name,
cameraId: stream,
role: VideoService.getRole(peer?.isPublisher),
},
}, `ICE restart failed for camera ${stream}`);
this._onWebRTCError(
new Error('iceConnectionStateError'),
stream,
VideoService.isLocalStream(stream),
);
});
}
}
clearRestartTimers(stream) {
if (this.restartTimeout[stream]) {
clearTimeout(this.restartTimeout[stream]);
@ -667,6 +735,7 @@ class VideoProvider extends Component {
peer.isPublisher = true;
peer.originalProfileId = profileId;
peer.currentProfileId = profileId;
peer.vpRestartIceRetries = 0;
peer.start();
peer.generateOffer().then((offer) => {
// Store the media stream if necessary. The scenario here is one where
@ -978,16 +1047,13 @@ class VideoProvider extends Component {
}
_handleIceConnectionStateChange(stream, isLocal) {
const { intl } = this.props;
const peer = this.webRtcPeers[stream];
const role = VideoService.getRole(isLocal);
if (peer && peer.peerConnection) {
const pc = peer.peerConnection;
const connectionState = pc.connectionState;
notifyStreamStateChange(stream, connectionState);
if (connectionState === 'failed' || connectionState === 'closed') {
const { connectionState } = pc;
const handleFatalFailure = () => {
const error = new Error('iceConnectionStateError');
// prevent the same error from being detected multiple times
pc.onconnectionstatechange = null;
@ -1002,6 +1068,45 @@ class VideoProvider extends Component {
}, `Camera ICE connection state changed: ${connectionState}. Role: ${role}.`);
this._onWebRTCError(error, stream, isLocal);
};
notifyStreamStateChange(stream, connectionState);
switch (connectionState) {
case 'closed':
handleFatalFailure();
break;
case 'failed':
if (!RESTART_ICE) {
handleFatalFailure();
} else {
try {
this.requestRestartIce(peer, stream);
} catch (error) {
handleFatalFailure();
}
}
break;
case 'connected':
if (peer && peer?.vpRestartIceRetries > 0) {
logger.info({
logCode: 'video_provider_ice_restarted',
extraInfo: {
cameraId: stream,
role: VideoService.getRole(peer?.isPublisher),
restartIceRetries: peer?.vpRestartIceRetries,
},
}, 'ICE restart successful');
peer.vpRestartIceRetries = 0;
}
break;
default:
break;
}
} else {
logger.error({

View File

@ -151,6 +151,9 @@ class AudioBroker extends BaseBroker {
this.onstart(parsedMessage.success);
this.started = true;
break;
case 'restartIceResponse':
this.handleRestartIceResponse(parsedMessage);
break;
case 'webRTCAudioError':
case 'error':
this.handleSFUError(parsedMessage);

View File

@ -41,6 +41,8 @@ class ScreenshareBroker extends BaseBroker {
// traceLogs
// networkPriority
// gatheringTimeout
// restartIce
// restartIceMaxRetries
Object.assign(this, options);
}
@ -97,6 +99,9 @@ class ScreenshareBroker extends BaseBroker {
case 'iceCandidate':
this.handleIceCandidate(parsedMessage.candidate);
break;
case 'restartIceResponse':
this.handleRestartIceResponse(parsedMessage);
break;
case 'error':
this.handleSFUError(parsedMessage);
break;

View File

@ -6,6 +6,7 @@ const WS_HEARTBEAT_OPTS = {
interval: 15000,
delay: 3000,
};
const ICE_RESTART = 'restartIce';
class BaseBroker {
static assembleError(code, reason) {
@ -29,6 +30,9 @@ class BaseBroker {
this.signallingTransportOpen = false;
this.logCodePrefix = `${this.sfuComponent}_broker`;
this.peerConfiguration = {};
this.restartIce = false;
this.restartIceMaxRetries = 3;
this._restartIceRetries = 0;
this.onbeforeunload = this.onbeforeunload.bind(this);
this._onWSError = this._onWSError.bind(this);
@ -277,18 +281,47 @@ class BaseBroker {
handleConnectionStateChange (eventIdentifier) {
if (this.webRtcPeer) {
const { peerConnection } = this.webRtcPeer;
const connectionState = peerConnection.connectionState;
if (eventIdentifier) {
notifyStreamStateChange(eventIdentifier, connectionState);
}
if (connectionState === 'failed' || connectionState === 'closed') {
const { connectionState } = peerConnection;
const handleFatalFailure = () => {
if (this.webRtcPeer?.peerConnection) {
this.webRtcPeer.peerConnection.onconnectionstatechange = null;
}
// 1307: "ICE_STATE_FAILED",
const error = BaseBroker.assembleError(1307);
this.onerror(error);
};
if (eventIdentifier) notifyStreamStateChange(eventIdentifier, connectionState);
switch (connectionState) {
case 'closed':
handleFatalFailure();
break;
case 'failed':
if (!this.restartIce) {
handleFatalFailure();
} else {
try {
this.requestRestartIce();
} catch (error) {
handleFatalFailure();
}
}
break;
case 'connected':
if (this._restartIceRetries > 0) {
this._restartIceRetries = 0;
logger.info({
logCode: `${this.logCodePrefix}_ice_restarted`,
extraInfo: { sfuComponent: this.sfuComponent },
}, 'ICE restart successful');
}
break;
default:
break;
}
}
}
@ -333,6 +366,52 @@ class BaseBroker {
}
}
// Sends a message to the SFU to restart ICE
requestRestartIce() {
if (this._restartIceRetries >= this.restartIceMaxRetries) {
throw new Error('Max ICE restart retries reached');
}
const message = {
id: ICE_RESTART,
type: this.sfuComponent,
role: this.role,
};
this._restartIceRetries += 1;
logger.warn({
logCode: `${this.logCodePrefix}_restart_ice`,
extraInfo: {
sfuComponent: this.sfuComponent,
retries: this._restartIceRetries,
},
}, `Requesting ICE restart (${this._restartIceRetries}/${this.restartIceMaxRetries})`);
this.sendMessage(message);
}
handleRestartIceResponse({ sdp }) {
if (this.webRtcPeer) {
this.webRtcPeer.restartIce(sdp, this.offering).catch((error) => {
logger.error({
logCode: `${this.logCodePrefix}_restart_ice_error`,
extraInfo: {
errorMessage: error?.message,
errorCode: error?.code,
errorName: error?.name,
sfuComponent: this.sfuComponent,
},
}, 'ICE restart failed');
if (this.webRtcPeer?.peerConnection) {
this.webRtcPeer.peerConnection.onconnectionstatechange = null;
}
// 1307: "ICE_STATE_FAILED",
this.onerror(BaseBroker.assembleError(1307));
});
}
}
disposePeer () {
if (this.webRtcPeer) {
this.webRtcPeer.dispose();

View File

@ -440,6 +440,42 @@ export default class WebRtcPeer extends EventEmitter2 {
});
}
restartIce(remoteSdp, initiator) {
if (this.isPeerConnectionClosed()) {
this.logger.error('BBB::WebRtcPeer::restartIce - peer connection closed');
throw new Error('Peer connection is closed');
}
const sdp = new RTCSessionDescription({
type: initiator ? 'offer' : 'answer',
sdp: remoteSdp,
});
this.logger.debug('BBB::WebRtcPeer::restartIce - setting remote description', sdp);
// If this peer was the original initiator, process remote first
if (initiator) {
return this.peerConnection.setRemoteDescription(sdp)
.then(() => this.peerConnection.createAnswer())
.then((answer) => this.peerConnection.setLocalDescription(answer))
.then(() => {
const localDescription = this.getLocalSessionDescriptor();
this.logger.debug('BBB::WebRtcPeer::restartIce - local description set', localDescription.sdp);
return localDescription.sdp;
});
}
// not the initiator - need to create offer first
return this.peerConnection.createOffer({ iceRestart: true })
.then((newOffer) => this.peerConnection.setLocalDescription(newOffer))
.then(() => {
const localDescription = this.getLocalSessionDescriptor();
this.logger.debug('BBB::WebRtcPeer::restartIce - local description set', localDescription.sdp);
return localDescription.sdp;
})
.then(() => this.peerConnection.setRemoteDescription(sdp));
}
dispose() {
this.logger.debug('BBB::WebRtcPeer::dispose');

View File

@ -319,6 +319,21 @@ public:
# Controls whether ICE candidates should be signaled to bbb-webrtc-sfu.
# Enable this if you want to use Kurento as the media server.
signalCandidates: false
# restartIce: controls whether ICE restarts should be signaled to bbb-webrtc-sfu
# whenever peers of the selected type (audio, video, screenshare) transition
# to failure states. Disabled by default (experimental).
# restartIce.<mediaType>.retries: number of ICE restart retries before giving up
# (i.e.: throwing an error). Default is 1 for audio, 3 for video and screenshare.
restartIce:
audio:
enabled: false
retries: 1
video:
enabled: false
retries: 3
screenshare:
enabled: false
retries: 3
# traceLogs: <Boolean> - enable trace logs in SFU peers
traceLogs: false
cameraTimeouts: