Skip to content
80 changes: 77 additions & 3 deletions JitsiConference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export interface IConferenceOptions {
e2eping?: {
enabled?: boolean;
};
enableIceRestart?: boolean;
enableNoAudioDetection?: boolean;
enableNoisyMicDetection?: boolean;
enableTalkWhileMuted?: boolean;
Expand Down Expand Up @@ -204,6 +205,12 @@ const TRANSLATION_REQUEST_TIMEOUT = 15000;
*/
const JINGLE_SI_TIMEOUT: number = 5000;

/**
* How long (ms) to wait for ICE to recover after an in-place ICE restart (triggered by an ICE failure) before
* falling back to a session restart.
*/
const JVB_ICE_RESTART_RECOVERY_TIMEOUT = 15000;

/**
* Default source language for transcribing the local participant.
*/
Expand Down Expand Up @@ -1853,11 +1860,16 @@ export default class JitsiConference extends Listenable {
// Use an exponential backoff timer for ICE restarts.
const jitterDelay = getJitterDelay(this._iceRestarts, 1000 /* min. delay */);

this._delayedIceFailed = new IceFailedHandling(this);
setTimeout(() => {
logger.error(`triggering ice restart after ${jitterDelay} `);
this._delayedIceFailed.start();
this._iceRestarts++;
if (this.isIceRestartSupported()) {
logger.info(`Attempting an in-place ICE restart after ${jitterDelay}`);
this._restartJvbIceWithFallback();
} else {
logger.error(`triggering ice restart after ${jitterDelay} `);
this._delayedIceFailed = new IceFailedHandling(this);
this._delayedIceFailed.start();
}
}, jitterDelay);
} else if (this.jvbJingleSession === session) {
logger.warn('ICE failed, force reloading the conference after failed attempts to re-establish ICE');
Expand All @@ -1872,6 +1884,34 @@ export default class JitsiConference extends Listenable {
}
}

/**
* Attempts an in-place ICE restart of the JVB session, falling back to the legacy session restart
* (session-terminate with a restart request, handled by Jicofo with a re-invite) if the request fails or if
* ICE doesn't recover within a timeout.
*
* @private
* @returns {void}
*/
private _restartJvbIceWithFallback(): void {
const fallback = (message: string) => {
logger.warn(`${message}, falling back to a session restart`);
this._delayedIceFailed = new IceFailedHandling(this);
this._delayedIceFailed.start();
};

this.restartJvbIce('ice-failed')
.then(() => {
setTimeout(() => {
const iceState = this.jvbJingleSession?.getIceConnectionState();

if (iceState !== 'connected' && iceState !== 'completed') {
fallback(`ICE not recovered (state=${iceState}) after an in-place ICE restart`);
}
}, JVB_ICE_RESTART_RECOVERY_TIMEOUT);
})
.catch(error => fallback(`In-place ICE restart request failed (${error?.message ?? error})`));
}

/**
* Handles P2P_TERMINATION_REQUIRED event. Fired when a source-remove is detected on a P2P connection, which
* indicates that the browser has regenerated SSRCs for an existing source. The P2P session is stopped so the
Expand Down Expand Up @@ -2387,6 +2427,40 @@ export default class JitsiConference extends Listenable {
this.qualityController.audioController.setIncludeSources(include);
}

/**
* Checks whether an in-place ICE restart of the JVB session can be used: it must be enabled in the client
* configuration ('enableIceRestart').
*
* @returns {boolean}
*/
public isIceRestartSupported(): boolean {
return Boolean(this.options.config.enableIceRestart);
}

/**
* Triggers an in-place ICE restart of the JVB session: Jicofo is asked to have the bridge create a new ICE
* agent with fresh credentials while the old one keeps carrying media (make-before-break). The bridge's new
* transport comes back asynchronously as a Jingle 'transport-info' and is applied by
* {@link JingleSessionPC.onBridgeIceRestartTransport}, so the promise returned here settling only means that
* the request itself was accepted. Trigger from the console: `APP.conference._room.restartJvbIce()`.
*
* @param {string} reason - Why the restart was triggered, for logs and analytics ('api', 'ice-failed', ...).
* @returns {Promise<void>} - Resolves when Jicofo has accepted the request, rejects otherwise.
*/
public restartJvbIce(reason: string = 'api'): Promise<void> {
if (!this.isIceRestartSupported()) {
return Promise.reject(new Error('ICE restart is not supported (disabled in config)'));
}

const session = this.jvbJingleSession;

if (!session) {
return Promise.reject(new Error('No JVB Jingle session'));
}

return session.restartIce(reason);
}

/**
* Sends the 'VideoTypeMessage' to the bridge on the bridge channel so that the bridge can make bitrate allocation
* decisions based on the video type of the local source.
Expand Down
11 changes: 11 additions & 0 deletions modules/RTCStats/RTCStatsEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ export enum RTCStatsEvents {
*/
GET_USER_MEDIA_ERROR_EVENT = 'getUserMediaError',

/**
* Event that indicates that an in-place ICE restart completed successfully, i.e. the renegotiation completed
* and the new local transport was signalled.
*/
ICE_RESTART_APPLIED_EVENT = 'iceRestartApplied',

/**
* Event that indicates that an in-place ICE restart was requested.
*/
ICE_RESTART_REQUESTED_EVENT = 'iceRestartRequested',

/**
* Event that indicates that the JVB media session is restarted because of ICE failure.
*/
Expand Down
93 changes: 93 additions & 0 deletions modules/sdp/SDPUtil.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,97 @@ describe('SDPUtil', () => {
expect(newPayloadTypes[0]).toEqual(103);
});
});

describe('replaceIceCredentialsAndStripCandidates', () => {
// A bundled 2 m-line remote offer as it comes from the bridge, with per m-line ICE credentials and both
// trickled and in-SDP candidates.
const OFFER = [
'v=0',
'o=- 1 2 IN IP4 127.0.0.1',
's=-',
't=0 0',
'a=group:BUNDLE 0 1',
'a=msid-semantic: WMS *',
'm=audio 10000 UDP/TLS/RTP/SAVPF 111',
'c=IN IP4 10.0.0.1',
'a=mid:0',
'a=rtpmap:111 opus/48000/2',
'a=ice-ufrag:oldfrag',
'a=ice-pwd:oldpwdoldpwdoldpwdoldpwd',
'a=candidate:1 1 udp 2130706431 10.0.0.1 10000 typ host generation 0',
'a=candidate:2 1 udp 1694498815 1.2.3.4 10000 typ srflx generation 0',
'a=end-of-candidates',
'a=fingerprint:sha-256 AA:BB',
'a=setup:actpass',
'a=sendonly',
'm=video 10000 UDP/TLS/RTP/SAVPF 100',
'c=IN IP4 10.0.0.1',
'a=mid:1',
'a=rtpmap:100 VP8/90000',
'a=ice-ufrag:oldfrag',
'a=ice-pwd:oldpwdoldpwdoldpwdoldpwd',
'a=candidate:1 1 udp 2130706431 10.0.0.1 10000 typ host generation 0',
'a=end-of-candidates',
'a=fingerprint:sha-256 AA:BB',
'a=setup:actpass',
'a=sendonly',
''
].join('\r\n');

it('replaces every ICE ufrag and pwd', () => {
const patched = SDPUtil.replaceIceCredentialsAndStripCandidates(OFFER, 'newfrag', 'newpwd');
const lines = patched.split('\r\n');

expect(lines.filter(l => l.startsWith('a=ice-ufrag:'))).toEqual([
'a=ice-ufrag:newfrag',
'a=ice-ufrag:newfrag'
]);
expect(lines.filter(l => l.startsWith('a=ice-pwd:'))).toEqual([
'a=ice-pwd:newpwd',
'a=ice-pwd:newpwd'
]);
expect(patched).not.toContain('oldfrag');
expect(patched).not.toContain('oldpwd');
});

it('strips every candidate and end-of-candidates line', () => {
const patched = SDPUtil.replaceIceCredentialsAndStripCandidates(OFFER, 'newfrag', 'newpwd');

expect(patched).not.toContain('a=candidate:');
expect(patched).not.toContain('a=end-of-candidates');
});

it('leaves every other line untouched', () => {
const patched = SDPUtil.replaceIceCredentialsAndStripCandidates(OFFER, 'newfrag', 'newpwd');
const isIceLine = line => line.startsWith('a=candidate:')
|| line.startsWith('a=end-of-candidates')
|| line.startsWith('a=ice-ufrag:')
|| line.startsWith('a=ice-pwd:');

expect(patched.split('\r\n').filter(l => !isIceLine(l)))
.toEqual(OFFER.split('\r\n').filter(l => !isIceLine(l)));
});

it('preserves the CRLF line endings', () => {
const patched = SDPUtil.replaceIceCredentialsAndStripCandidates(OFFER, 'newfrag', 'newpwd');

expect(patched.split('\n').every(l => l === '' || l.endsWith('\r'))).toBe(true);
expect(patched.endsWith('\r\n')).toBe(true);
});

it('handles an SDP with LF line endings', () => {
const lfOffer = OFFER.replace(/\r\n/g, '\n');
const patched = SDPUtil.replaceIceCredentialsAndStripCandidates(lfOffer, 'newfrag', 'newpwd');

expect(patched).not.toContain('\r');
expect(patched).toContain('a=ice-ufrag:newfrag\n');
expect(patched).not.toContain('a=candidate:');
});

it('is a no-op for an SDP with no ICE lines', () => {
const noIce = 'v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n';

expect(SDPUtil.replaceIceCredentialsAndStripCandidates(noIce, 'newfrag', 'newpwd')).toEqual(noIce);
});
});
});
43 changes: 43 additions & 0 deletions modules/sdp/SDPUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,49 @@ const SDPUtil = {
}
},

/**
* Rewrites the ICE credentials of an SDP and removes every ICE candidate from it. Used to build the patched
* remote offer that an in-place ICE restart is applied with: the new ufrag/pwd must be applied on their own,
* with the candidates trickled in afterwards via `addIceCandidate()`.
*
* Applying the new candidates in the same `setRemoteDescription()` as the new credentials makes libwebrtc stamp
* them with the new ICE generation (candidate lines carry no credentials of their own), treat them as brand new
* candidates for the same remote address and synchronously tear down the selected candidate pair - which
* defeats the make-before-break the whole in-place restart exists for.
*
* Reported upstream as https://issues.webrtc.org/issues/543082385
*
* Every other line, and the original line separators, are preserved verbatim.
*
* @param {string} sdp - The SDP to patch.
* @param {string} ufrag - The new ICE ufrag.
* @param {string} pwd - The new ICE pwd.
* @returns {string} - The patched SDP.
*/
replaceIceCredentialsAndStripCandidates(sdp: string, ufrag: string, pwd: string): string {
const patched = [];

for (const line of sdp.split('\n')) {
// Preserve the original line separator ('\r\n' vs '\n').
const cr = line.endsWith('\r') ? '\r' : '';
const content = cr ? line.substring(0, line.length - 1) : line;

if (content.startsWith('a=candidate:') || content.startsWith('a=end-of-candidates')) {
continue;
}

if (content.startsWith('a=ice-ufrag:')) {
patched.push(`a=ice-ufrag:${ufrag}${cr}`);
} else if (content.startsWith('a=ice-pwd:')) {
patched.push(`a=ice-pwd:${pwd}${cr}`);
} else {
patched.push(line);
}
}

return patched.join('\n');
},

/**
* Strips the given codec from the given mline. All related RTX payload
* types are also stripped. If the resulting mline would have no codecs,
Expand Down
Loading