diff --git a/JitsiConference.ts b/JitsiConference.ts index af3fa2b246..229aac964d 100644 --- a/JitsiConference.ts +++ b/JitsiConference.ts @@ -115,6 +115,7 @@ export interface IConferenceOptions { e2eping?: { enabled?: boolean; }; + enableIceRestart?: boolean; enableNoAudioDetection?: boolean; enableNoisyMicDetection?: boolean; enableTalkWhileMuted?: boolean; @@ -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. */ @@ -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'); @@ -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 @@ -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} - Resolves when Jicofo has accepted the request, rejects otherwise. + */ + public restartJvbIce(reason: string = 'api'): Promise { + 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. diff --git a/modules/RTCStats/RTCStatsEvents.ts b/modules/RTCStats/RTCStatsEvents.ts index 412b665457..bc91b0f5f3 100644 --- a/modules/RTCStats/RTCStatsEvents.ts +++ b/modules/RTCStats/RTCStatsEvents.ts @@ -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. */ diff --git a/modules/sdp/SDPUtil.spec.ts b/modules/sdp/SDPUtil.spec.ts index 686b1d6b9f..8cf7ca47e4 100644 --- a/modules/sdp/SDPUtil.spec.ts +++ b/modules/sdp/SDPUtil.spec.ts @@ -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); + }); + }); }); diff --git a/modules/sdp/SDPUtil.ts b/modules/sdp/SDPUtil.ts index b75d88221a..020f15ff8a 100644 --- a/modules/sdp/SDPUtil.ts +++ b/modules/sdp/SDPUtil.ts @@ -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, diff --git a/modules/xmpp/JingleSessionPC.spec.ts b/modules/xmpp/JingleSessionPC.spec.ts index 817efeadd9..b756dbcdb1 100644 --- a/modules/xmpp/JingleSessionPC.spec.ts +++ b/modules/xmpp/JingleSessionPC.spec.ts @@ -584,3 +584,289 @@ describe('notifyMySSRCUpdate - P2P source-remove triggers termination', () => { expect(sendIQSpy).toHaveBeenCalled(); }); }); + +describe('JingleSessionPC in-place ICE restart', () => { + const SID = 'sid12345'; + const BRIDGE_SESSION_ID = 'bridge-session-1'; + + // The remote (bridge) offer, as it would be found in pc.currentRemoteDescription. + const REMOTE_OFFER = [ + 'v=0', + 'o=- 1 2 IN IP4 127.0.0.1', + 's=-', + 't=0 0', + 'a=group:BUNDLE 0', + 'm=audio 10000 UDP/TLS/RTP/SAVPF 111', + 'c=IN IP4 10.0.0.1', + 'a=mid:0', + 'a=ice-ufrag:oldfrag', + 'a=ice-pwd:oldpwdoldpwdoldpwdoldpwd', + 'a=candidate:1 1 udp 2130706431 10.0.0.1 10000 typ host generation 0', + 'a=setup:actpass', + 'a=sendonly', + '' + ].join('\r\n'); + + // The local answer, as it would be found in pc.localDescription after the restart. + const LOCAL_ANSWER = [ + 'v=0', + 'o=- 1 2 IN IP4 127.0.0.1', + 's=-', + 't=0 0', + 'a=group:BUNDLE 0', + 'm=audio 9 UDP/TLS/RTP/SAVPF 111', + 'c=IN IP4 0.0.0.0', + 'a=mid:0', + 'a=ice-ufrag:mynewfrag', + 'a=ice-pwd:mynewpwdmynewpwdmynewpwd', + 'a=fingerprint:sha-256 AA:BB', + 'a=setup:active', + 'a=recvonly', + '' + ].join('\r\n'); + + /** + * Builds the element of an in-place ICE restart 'transport-info'. + * + * @param {Object} options - Overrides for the transport attributes. + * @returns {Element} + */ + function buildBridgeTransport({ + generation = 1, + ufrag = 'brnewfrag', + pwd = 'brnewpwd', + numCandidates = 2 + }: { + generation?: unknown; numCandidates?: number; pwd?: Nullable; ufrag?: Nullable; + } = {}): Element { + const candidates = []; + + for (let i = 0; i < numCandidates; i++) { + candidates.push(`'); + } + + const attributes = [ `ice-generation="${generation}"` ]; + + ufrag !== null && attributes.push(`ufrag="${ufrag}"`); + pwd !== null && attributes.push(`pwd="${pwd}"`); + + const iq = parseXML( + '' + + '' + + `` + + candidates.join('') + + '' + + '' + + ''); + + return findFirst(iq, 'content>transport'); + } + + /** + * Creates a JVB session with a peer connection mocked up for an ICE restart. + * + * @returns {Object} + */ + function createJvbSession() { + const connection = new MockStropheConnection(); + + (connection as any).connected = true; + + const session = new JingleSessionPC(SID, 'peer1', 'focus', connection, { }, { }, false, false); + + session.initialize( + new MockChatRoom(), + new MockRTC(), + { setSSRCOwner: () => { }, removeSSRCOwners: () => { } }, // eslint-disable-line no-empty-function + { }); + (session as any).state = JingleSessionState.ACTIVE; + (session as any)._bridgeSessionId = BRIDGE_SESSION_ID; + + // The modification queue starts paused; it is normally resumed when the offer is accepted. + (session as any).modificationQueue.resume(); + + const tpc = session.peerconnection as any; + const nativePc = { + currentRemoteDescription: { sdp: REMOTE_OFFER }, + iceConnectionState: 'connected', + signalingState: 'stable' + }; + + tpc.peerconnection = nativePc; + tpc.addIceCandidate = jasmine.createSpy('addIceCandidate').and.returnValue(Promise.resolve()); + Object.defineProperty(tpc, 'localDescription', { get: () => ({ sdp: LOCAL_ANSWER }) }); + Object.defineProperty(tpc, 'remoteDescription', { get: () => ({ sdp: REMOTE_OFFER }) }); + spyOn(tpc, 'setRemoteDescription').and.returnValue(Promise.resolve()); + spyOn(tpc, 'createAnswer').and.returnValue(Promise.resolve({ sdp: LOCAL_ANSWER, + type: 'answer' })); + spyOn(tpc, 'setLocalDescription').and.returnValue(Promise.resolve()); + + // The restart goes through _renegotiate(), which signals any SSRCs the browser regenerates. That is not + // what these tests are about, and it would try to send a source-update. + spyOn(session as any, 'notifyMySSRCUpdate'); + + return { connection, + nativePc, + session, + tpc }; + } + + /** + * Resolves once every task queued on the session's modification queue has run. + * + * @param {JingleSessionPC} session - The session. + * @returns {Promise} + */ + function drainQueue(session: JingleSessionPC): Promise { + return new Promise(resolve => { + (session as any).modificationQueue.push( + finished => finished(), + () => resolve()); + }); + } + + describe('restartIce', () => { + it('sends a session-info with a bridge-session requesting an ICE restart', async () => { + const { connection, session } = createJvbSession(); + + await session.restartIce('api'); + + expect(connection.sentIQs.length).toBe(1); + + const iq = connection.sentIQs[0].tree(); + + expect(findFirst(iq, 'jingle').getAttribute('action')).toBe('session-info'); + expect(findFirst(iq, 'jingle').getAttribute('sid')).toBe(SID); + + const bridgeSession = findFirst(iq, 'jingle>bridge-session'); + + expect(bridgeSession.getAttribute('xmlns')).toBe('http://jitsi.org/protocol/focus'); + expect(bridgeSession.getAttribute('id')).toBe(BRIDGE_SESSION_ID); + expect(bridgeSession.getAttribute('ice-restart')).toBe('true'); + }); + + it('rejects without sending anything when no bridge session is known', async () => { + const { connection, session } = createJvbSession(); + + (session as any)._bridgeSessionId = null; + + await expectAsync(session.restartIce('api')).toBeRejected(); + expect(connection.sentIQs.length).toBe(0); + }); + + it('rejects for a P2P session', async () => { + const { session } = createJvbSession(); + + (session as any).isP2P = true; + + await expectAsync(session.restartIce('api')).toBeRejected(); + }); + }); + + describe('onBridgeIceRestartTransport', () => { + it('applies the patched offer, answers it and only then adds the candidates', async () => { + const { nativePc, session, tpc } = createJvbSession(); + + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 1 })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).toHaveBeenCalledTimes(1); + + const applied = tpc.setRemoteDescription.calls.mostRecent().args[0]; + + expect(applied.type).toBe('offer'); + expect(applied.sdp).toContain('a=ice-ufrag:brnewfrag'); + expect(applied.sdp).toContain('a=ice-pwd:brnewpwd'); + expect(applied.sdp).not.toContain('a=candidate:'); + + expect(tpc.createAnswer).toHaveBeenCalledTimes(1); + expect(tpc.setLocalDescription).toHaveBeenCalledTimes(1); + expect(tpc.addIceCandidate).toHaveBeenCalledTimes(2); + + // The candidates must be added after the offer/answer, not as part of it. + expect(tpc.addIceCandidate.calls.first().invocationOrder) + .toBeGreaterThan(tpc.setLocalDescription.calls.first().invocationOrder); + }); + + it('signals the new local ICE credentials back tagged with the same generation', async () => { + const { connection, session } = createJvbSession(); + + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 7 })); + await drainQueue(session); + + expect(connection.sentIQs.length).toBe(1); + + const iq = connection.sentIQs[0].tree(); + + expect(findFirst(iq, 'jingle').getAttribute('action')).toBe('transport-info'); + + const transport = findFirst(iq, 'jingle>content>transport'); + + expect(transport.getAttribute('ice-generation')).toBe('7'); + expect(transport.getAttribute('ufrag')).toBe('mynewfrag'); + expect(transport.getAttribute('pwd')).toBe('mynewpwdmynewpwdmynewpwd'); + }); + + it('ignores a generation that is not newer than the last one applied', async () => { + const { connection, session, tpc } = createJvbSession(); + + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 3 })); + await drainQueue(session); + expect(tpc.setRemoteDescription).toHaveBeenCalledTimes(1); + + // A duplicate and an out-of-order (older) push must both be dropped. + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 3 })); + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 2 })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).toHaveBeenCalledTimes(1); + expect(connection.sentIQs.length).toBe(1); + + // A newer one is applied. + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 4 })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).toHaveBeenCalledTimes(2); + expect(connection.sentIQs.length).toBe(2); + }); + + it('ignores a transport with an invalid generation', async () => { + const { session, tpc } = createJvbSession(); + + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 'not-a-number' })); + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 0 })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).not.toHaveBeenCalled(); + }); + + it('ignores a transport with incomplete ICE credentials', async () => { + const { session, tpc } = createJvbSession(); + + session.onBridgeIceRestartTransport(buildBridgeTransport({ pwd: null })); + session.onBridgeIceRestartTransport(buildBridgeTransport({ ufrag: null })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).not.toHaveBeenCalled(); + }); + + it('still applies a newer generation after one failed to apply', async () => { + const { session, tpc } = createJvbSession(); + + tpc.setRemoteDescription.and.returnValue(Promise.reject(new Error('nope'))); + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 1 })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).toHaveBeenCalledTimes(1); + + // The same generation is not retried, but a newer one still is. + tpc.setRemoteDescription.and.returnValue(Promise.resolve()); + session.onBridgeIceRestartTransport(buildBridgeTransport({ generation: 2 })); + await drainQueue(session); + + expect(tpc.setRemoteDescription).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/modules/xmpp/JingleSessionPC.ts b/modules/xmpp/JingleSessionPC.ts index ea9da31c64..173e891c0d 100644 --- a/modules/xmpp/JingleSessionPC.ts +++ b/modules/xmpp/JingleSessionPC.ts @@ -9,7 +9,7 @@ import { MediaDirection } from '../../service/RTC/MediaDirection'; import { MediaType } from '../../service/RTC/MediaType'; import { SSRC_GROUP_SEMANTICS } from '../../service/RTC/StandardVideoQualitySettings'; import { VideoType } from '../../service/RTC/VideoType'; -import { AnalyticsEvents, createAudioWedgeRecoveryEvent } from '../../service/statistics/AnalyticsEvents'; +import { AnalyticsEvents, createAudioWedgeRecoveryEvent, createJingleEvent } from '../../service/statistics/AnalyticsEvents'; import { XMPPEvents } from '../../service/xmpp/XMPPEvents'; import { XEP } from '../../service/xmpp/XMPPExtensioProtocols'; import JitsiLocalTrack from '../RTC/JitsiLocalTrack'; @@ -17,6 +17,8 @@ import JitsiRemoteTrack from '../RTC/JitsiRemoteTrack'; import RemoteAudioWedgeDetector from '../RTC/RemoteAudioWedgeDetector'; import { SS_DEFAULT_FRAME_RATE } from '../RTC/ScreenObtainer'; import TraceablePeerConnection, { IAudioQuality, IVideoQuality } from '../RTC/TraceablePeerConnection'; +import RTCStats from '../RTCStats/RTCStats'; +import { RTCStatsEvents } from '../RTCStats/RTCStatsEvents'; import browser from '../browser'; import FeatureFlags from '../flags/FeatureFlags'; import SDP from '../sdp/SDP'; @@ -54,6 +56,25 @@ const DEFAULT_MAX_STATS: number = 300; */ const ICE_CAND_GATHERING_TIMEOUT: number = 150; +/** + * How long the media stats sampler runs after an in-place ICE restart started, in ms. + * @type {number} + */ +const ICE_RESTART_STATS_DURATION: number = 3000; + +/** + * The interval at which media stats are sampled during an ICE restart, in ms. + * @type {number} + */ +const ICE_RESTART_STATS_INTERVAL: number = 100; + +/** + * The prefix used by all the logs of the in-place ICE restart flow, so that a whole restart can be extracted from + * a log file with a single grep. + * @type {string} + */ +const ICE_RESTART_LOG_PREFIX: string = '[ice-restart]'; + /** * Matches a plain decimal string (no sign, no separators). Used as the first check on a signaled ssrc attribute. * @type {RegExp} @@ -159,6 +180,7 @@ interface IJingleSessionPCOptions { p2p?: object; startSilent?: boolean; testing?: { + debugIceRestart?: boolean; enableCodecSelectionAPI?: boolean; failICE?: boolean; }; @@ -204,6 +226,9 @@ export default class JingleSessionPC extends JingleSession { private _cachedNewLocalSdp: Optional; private _iceCheckingStartedTimestamp: Nullable; private _gatheringStartedTimestamp: Nullable; + private _iceRestartT0: Nullable; + private _iceRestartStatsTimer: Nullable; + private _lastIceGeneration: number; private _sourceReceiverConstraints: Nullable>; private _localSendReceiveVideoActive: boolean; private _remoteSendReceiveVideoActive: boolean; @@ -362,6 +387,32 @@ export default class JingleSessionPC extends JingleSession { */ this._gatheringStartedTimestamp = null; + /** + * Stores the result of {@link window.performance.now()} at the time when an in-place ICE restart started. + * Used as the reference point ("t+Xms") for the ICE restart instrumentation logs. Reset to null when the + * ICE restart stats sampler finishes. + * @type {Nullable} + * @private + */ + this._iceRestartT0 = null; + + /** + * The id of the interval timer of the ICE restart stats sampler, if it is currently running. + * @type {Nullable} + * @private + */ + this._iceRestartStatsTimer = null; + + /** + * The highest ICE generation of a bridge transport that was applied to this session, as carried by the + * `ice-generation` attribute of the `` of an in-place ICE restart 'transport-info'. Zero means + * no in-place ICE restart has been applied yet (the initial transport carries no generation). Used as a + * monotonic guard against duplicate or reordered pushes, see {@link onBridgeIceRestartTransport}. + * @type {number} + * @private + */ + this._lastIceGeneration = 0; + /** * Receiver constraints (max height) set by the application per remote source. Will be used for p2p connection. * @@ -1077,6 +1128,223 @@ export default class JingleSessionPC extends JingleSession { cand, null, this.newJingleErrorHandler(), IQ_TIMEOUT); } + /** + * Starts a short-lived sampler which logs, every {@link ICE_RESTART_STATS_INTERVAL} ms for up to + * {@link ICE_RESTART_STATS_DURATION} ms after an ICE restart started, the number of audio/video bytes sent and + * received since the previous sample, video freeze/PLI/dropped-frame counters (to pin down whether a visible + * freeze is caused by receive-side packet loss around the pair cutover, as opposed to the send-side ICE + * mechanics), the currently selected ICE candidate pair (id and state) and the total number of STUN + * connectivity checks sent and responses received. This shows precisely when media stopped and resumed in each + * direction during the restart, and when connectivity checks for the new ICE generation started. + * + * This is diagnostic-only and is off unless the `testing.debugIceRestart` config option is set. + * + * @private + * @returns {void} + */ + private _startIceRestartStatsSampler(): void { + if (!this.options?.testing?.debugIceRestart) { + return; + } + + if (this._iceRestartStatsTimer !== null) { + window.clearInterval(this._iceRestartStatsTimer); + this._iceRestartStatsTimer = null; + } + + const t0 = this._iceRestartT0 ?? window.performance.now(); + let prev: Nullable<{ + audioIn: number; + audioOut: number; + reqSent: number; + respRecv: number; + videoFramesDropped: number; + videoFreezeCount: number; + videoFreezeDurationMs: number; + videoIn: number; + videoOut: number; + videoPliCount: number; + }> = null; + + const stop = () => { + if (this._iceRestartStatsTimer !== null) { + window.clearInterval(this._iceRestartStatsTimer); + this._iceRestartStatsTimer = null; + } + this._iceRestartT0 = null; + }; + + const sample = () => { + const now = window.performance.now(); + + if (now - t0 > ICE_RESTART_STATS_DURATION + || this.state === JingleSessionState.ENDED + || this.peerconnection?.signalingState === 'closed') { + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} t+${Math.round(now - t0)}ms: stats sampler done`); + stop(); + + return; + } + + this.peerconnection.getStats().then(report => { + const cur = { + audioIn: 0, + audioOut: 0, + reqSent: 0, + respRecv: 0, + videoFramesDropped: 0, + videoFreezeCount: 0, + videoFreezeDurationMs: 0, + videoIn: 0, + videoOut: 0, + videoPliCount: 0 + }; + const pairsById = new Map(); + let selectedPairId: Nullable = null; + + report.forEach(stat => { + switch (stat.type) { + case 'outbound-rtp': + cur[stat.kind === MediaType.AUDIO ? 'audioOut' : 'videoOut'] += stat.bytesSent ?? 0; + break; + case 'inbound-rtp': + cur[stat.kind === MediaType.AUDIO ? 'audioIn' : 'videoIn'] += stat.bytesReceived ?? 0; + if (stat.kind === MediaType.VIDEO) { + cur.videoFramesDropped += stat.framesDropped ?? 0; + cur.videoFreezeCount += stat.freezeCount ?? 0; + cur.videoFreezeDurationMs += (stat.totalFreezesDuration ?? 0) * 1000; + cur.videoPliCount += stat.pliCount ?? 0; + } + break; + case 'candidate-pair': + pairsById.set(stat.id, stat); + cur.reqSent += stat.requestsSent ?? 0; + cur.respRecv += stat.responsesReceived ?? 0; + break; + case 'transport': + selectedPairId = stat.selectedCandidatePairId ?? selectedPairId; + break; + } + }); + + const pair = selectedPairId ? pairsById.get(selectedPairId) : undefined; + const pairStr = pair ? `${selectedPairId}/${pair.state}${pair.nominated ? '(nom)' : ''}` : 'none'; + const d = (key: keyof typeof cur) => (prev === null ? cur[key] : cur[key] - prev[key]); + + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} t+${Math.round(now - t0)}ms: ` + + `video out +${d('videoOut')}B in +${d('videoIn')}B ` + + `audio out +${d('audioOut')}B in +${d('audioIn')}B ` + + `video freezes +${d('videoFreezeCount')} (+${Math.round(d('videoFreezeDurationMs'))}ms) ` + + `pli +${d('videoPliCount')} framesDropped +${d('videoFramesDropped')} ` + + `pair=${pairStr} pairs=${pairsById.size} checks sent +${d('reqSent')} resp +${d('respRecv')}` + + `${prev === null ? ' (baseline, absolute values)' : ''}`); + prev = cur; + }) + .catch(error => { + logger.warn(`${this} ${ICE_RESTART_LOG_PREFIX} stats sampler failed: ${error}`); + stop(); + }); + }; + + // Sample periodically, and take an immediate baseline sample. + this._iceRestartStatsTimer = window.setInterval(sample, ICE_RESTART_STATS_INTERVAL); + sample(); + } + + /** + * Parses the `` elements of a Jingle transport into {@link RTCIceCandidate} instances. + * + * @param {Element[]} candidateElements - the `` elements. + * @returns {RTCIceCandidate[]} + * @private + */ + private _parseIceCandidates(candidateElements: Element[]): RTCIceCandidate[] { + return candidateElements.map(candidate => { + let line = SDPUtil.candidateFromJingle(candidate); + + line = line.replace('\r\n', '').replace('a=', ''); + + // FIXME this code does not care to handle + // non-bundle transport + return new RTCIceCandidate({ + candidate: line, + sdpMLineIndex: 0, + + // FF comes up with more complex names like audio-23423, + // Given that it works on both Chrome and FF without + // providing it, let's leave it like this for the time + // being... + // sdpMid: 'audio', + sdpMid: '' + }); + }); + } + + /** + * Adds the given ICE candidates to the peer connection. The caller is responsible for the serialization with + * the rest of the peer connection operations (i.e. this is meant to be called from within a modification + * queue task). + * + * @param {RTCIceCandidate[]} iceCandidates - the candidates to add. + * @returns {Promise} - resolves once all of the candidates have been added (or have failed to be added, + * which is only logged). + * @private + */ + private _addIceCandidatesToPeerConnection(iceCandidates: RTCIceCandidate[]): Promise { + return Promise.all(iceCandidates.map(iceCandidate => this.peerconnection.addIceCandidate(iceCandidate) + .then( + () => logger.debug(`${this} addIceCandidate ok!`), + err => logger.error(`${this} addIceCandidate failed!`, err)))) + .then(() => undefined); + } + + /** + * Sends a Jingle 'transport-info' carrying the local transport (the ICE credentials that the browser rotated + * to while answering the bridge's ICE restart offer), tagged with the `ice-generation` of that restart round. + * Jicofo's regular 'transport-info' handling relays it to the bridge, which needs it because it is the + * controlling agent: its outgoing connectivity checks have to be authenticated with our new credentials. + * + * @param {SDP} localSDP - the local session description with the new local transport. + * @param {number} generation - the ICE generation of the restart round these credentials belong to. + * @returns {void} + */ + private _sendIceRestartTransportInfo(localSDP: SDP, generation: number): void { + const transportInfo = $iq({ to: this.remoteJid, type: 'set' }) + .c('jingle', { + action: 'transport-info', + initiator: this.initiatorJid, + sid: this.sid, + xmlns: 'urn:xmpp:jingle:1' + }); + + localSDP.media.forEach((medialines, idx) => { + const mline = SDPUtil.parseMLine(medialines.split('\r\n')[0]); + + transportInfo.c('content', { + creator: this.initiatorJid === this.localJid ? 'initiator' : 'responder', + name: mline.media + }); + localSDP.transportToJingle(idx, transportInfo); + + // transportToJingle() leaves the cursor back on , so tag the it just appended + // directly rather than through the builder. + const transportEl = transportInfo.node?.lastElementChild; + + if (transportEl?.tagName === 'transport') { + transportEl.setAttribute('ice-generation', String(generation)); + } else { + logger.warn(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation}: could not tag the transport ` + + 'with the ICE generation'); + } + + transportInfo.up(); + }); + + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation}: signalling our new local ICE ` + + `credentials (ufrag=${SDPUtil.getUfrag(localSDP.raw)})`); + this.connection.sendIQ(transportInfo, null, this.newJingleErrorHandler(), IQ_TIMEOUT); + } + /** * Sends Jingle 'session-accept' message. * @@ -1487,6 +1755,196 @@ export default class JingleSessionPC extends JingleSession { }); } + /** + * Asks Jicofo to restart ICE for this endpoint on the bridge, by sending a Jingle 'session-info' carrying a + * ``. Jicofo forwards the request to the bridge over colibri2; the bridge + * creates a NEW ICE agent with fresh credentials while the old one keeps carrying media, and its transport + * comes back to us as a 'transport-info' tagged with an `ice-generation`, handled by + * {@link onBridgeIceRestartTransport} - that is where the restart is actually applied. + * + * Grep the logs for `[ice-restart]` to follow a restart end to end. + * + * @param {string} reason - why the restart was requested, for the logs. + * @returns {Promise} - resolves when Jicofo has acknowledged the request, rejects if it did not accept + * it (which is the signal to fall back to a full session restart). + */ + public restartIce(reason: string = 'api'): Promise { + if (this.isP2P) { + return Promise.reject(new Error('an in-place ICE restart is only supported for the JVB session')); + } + + if (!this._bridgeSessionId) { + return Promise.reject(new Error('no bridge session ID is known for this session')); + } + + if (!this._assertNotEnded()) { + return Promise.reject(new Error('the session has ended')); + } + + this._iceRestartT0 = window.performance.now(); + this._startIceRestartStatsSampler(); + + const sessionInfo = $iq({ to: this.remoteJid, type: 'set' }) + .c('jingle', { + action: 'session-info', + initiator: this.initiatorJid, + sid: this.sid, + xmlns: 'urn:xmpp:jingle:1' + }) + .c('bridge-session', { + 'ice-restart': true, + 'id': this._bridgeSessionId, + 'xmlns': 'http://jitsi.org/protocol/focus' + }) + .up(); + + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} requesting an in-place ICE restart, reason=${reason}, ` + + `bridgeSession=${this._bridgeSessionId}, lastAppliedGeneration=${this._lastIceGeneration}`); + Statistics.sendAnalytics(createJingleEvent( + AnalyticsEvents.ACTION_JINGLE_ICE_RESTART_REQUESTED, { + p2p: this.isP2P, + reason + })); + RTCStats.sendStatsEntry(RTCStatsEvents.ICE_RESTART_REQUESTED_EVENT, null, { reason }); + + return new Promise((resolve, reject) => { + this.connection.sendIQ( + sessionInfo, + () => { + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} the ICE restart request was accepted`); + resolve(); + }, + this.newJingleErrorHandler(error => { + logger.warn(`${this} ${ICE_RESTART_LOG_PREFIX} the ICE restart request was rejected: ` + + `${JSON.stringify(error)}`); + reject(new Error(error?.reason ?? error?.msg ?? 'the ICE restart request was rejected')); + }), + IQ_TIMEOUT); + }); + } + + /** + * Applies the bridge's new ICE transport for an in-place ICE restart. Called for a Jingle 'transport-info' + * whose `` carries an `ice-generation` attribute, which marks it as the transport of a NEW ICE + * agent the bridge created in response to {@link restartIce} (as opposed to plain trickled candidates). + * + * The whole thing runs as a single modification queue task: + * 1. the generation is checked against the last one applied, and anything not strictly newer is dropped + * (pushes can be duplicated or reordered); + * 2. a patched offer is built from the current remote description with the bridge's new ICE credentials + * substituted in and ALL candidate lines stripped; + * 3. that offer is applied and answered with a real `createAnswer()` - we stay the answerer, so the restart + * is entirely local and needs no signalling round trip on the critical path. The browser rotates our own + * ICE credentials as part of answering; + * 4. only THEN are the bridge's new candidates trickled in with `addIceCandidate()`. Adding them as part of + * the offer in step 2 would make libwebrtc destroy and rebuild the selected candidate pair synchronously, + * which is exactly the media freeze the make-before-break design exists to avoid; + * 5. our new local ICE credentials are signalled back, tagged with the same generation - the bridge is the + * controlling agent and needs them to authenticate its connectivity checks. + * + * @param {Element} transportEl - the `` element of the incoming 'transport-info'. + * @returns {void} + */ + public onBridgeIceRestartTransport(transportEl: Element): void { + const generation = Number(transportEl.getAttribute('ice-generation')); + const ufrag = transportEl.getAttribute('ufrag'); + const pwd = transportEl.getAttribute('pwd'); + const candidates = this._parseIceCandidates(findAll(transportEl, ':scope>candidate')); + + if (!Number.isInteger(generation) || generation <= 0) { + logger.error(`${this} ${ICE_RESTART_LOG_PREFIX} ignoring a bridge transport with an invalid ICE ` + + `generation (${transportEl.getAttribute('ice-generation')})`); + + return; + } + + if (!ufrag || !pwd) { + logger.error(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation}: ignoring a bridge transport with ` + + 'incomplete ICE credentials'); + + return; + } + + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation}: received the bridge's new transport, ` + + `ufrag=${ufrag}, candidates=${candidates.length}`); + + if (this._iceRestartT0 === null) { + this._iceRestartT0 = window.performance.now(); + this._startIceRestartStatsSampler(); + } + + const t0 = this._iceRestartT0; + const dt = () => Math.round(window.performance.now() - t0); + + const workFunction = async (finishedCallback: (err?: Error) => void) => { + try { + if (generation <= this._lastIceGeneration) { + logger.warn(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation}: ignoring a stale bridge ` + + `transport, generation ${this._lastIceGeneration} has already been applied`); + finishedCallback(); + + return; + } + + const pc = this.peerconnection.peerconnection; + const remoteSdp = this.peerconnection.remoteDescription?.sdp; + + if (!remoteSdp) { + throw new Error('there is no current remote description'); + } + + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation} t+${dt()}ms: applying, ` + + `signalingState=${pc.signalingState}, iceConnectionState=${pc.iceConnectionState}`); + this._lastIceGeneration = generation; + + // Allow the candidates the browser gathers for the new generation to be signalled. + this.lasticecandidate = false; + + const patchedOffer = SDPUtil.replaceIceCredentialsAndStripCandidates(remoteSdp, ufrag, pwd); + + // Drive the offer/answer through the regular renegotiation path rather than calling + // setRemoteDescription/createAnswer/setLocalDescription directly: it keeps + // TraceablePeerConnection's own view of the session consistent, and it signals any SSRCs the + // browser regenerates while answering, which would otherwise go unsignalled. + await this._renegotiate(patchedOffer); + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation} t+${dt()}ms: the patched offer ` + + `was applied (candidates stripped) and answered, signalingState=${pc.signalingState}`); + + // The candidates must be added only now, after the offer/answer cycle has completed. Adding them + // together with the new credentials tears down the selected pair and breaks make-before-break, see + // SDPUtil.replaceIceCredentialsAndStripCandidates and https://issues.webrtc.org/issues/543082385 + await this._addIceCandidatesToPeerConnection(candidates); + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation} t+${dt()}ms: ` + + `${candidates.length} of the bridge's candidates were added`); + + this._sendIceRestartTransportInfo( + new SDP(this.peerconnection.localDescription.sdp), generation); + + Statistics.sendAnalytics(createJingleEvent(AnalyticsEvents.ACTION_JINGLE_ICE_RESTART_SUCCESS, { + p2p: this.isP2P, + value: this.sid + })); + RTCStats.sendStatsEntry(RTCStatsEvents.ICE_RESTART_APPLIED_EVENT, null, { generation }); + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation} t+${dt()}ms: done`); + finishedCallback(); + } catch (error) { + logger.error(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation} t+${dt()}ms: failed`, error); + finishedCallback(error as Error); + } finally { + // The stats sampler, when it is enabled, owns _iceRestartT0 and resets it when it is done. + if (this._iceRestartStatsTimer === null) { + this._iceRestartT0 = null; + } + } + }; + + this.modificationQueue.push(workFunction, (error?: Error) => { + if (error && !(error instanceof ClearedQueueError)) { + logger.error(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${generation}: the task failed: ${error}`); + } + }); + } + /** * Accepts incoming Jingle 'session-initiate' and should send 'session-accept' in result. * @@ -1551,30 +2009,7 @@ export default class JingleSessionPC extends JingleSession { return; } - const iceCandidates: RTCIceCandidate[] = []; - - findAll(elem, ':scope>content>transport>candidate') - .forEach(candidate => { - let line = SDPUtil.candidateFromJingle(candidate); - - line = line.replace('\r\n', '').replace('a=', ''); - - // FIXME this code does not care to handle - // non-bundle transport - const rtcCandidate = new RTCIceCandidate({ - candidate: line, - sdpMLineIndex: 0, - - // FF comes up with more complex names like audio-23423, - // Given that it works on both Chrome and FF without - // providing it, let's leave it like this for the time - // being... - // sdpMid: 'audio', - sdpMid: '' - }); - - iceCandidates.push(rtcCandidate); - }); + const iceCandidates = this._parseIceCandidates(findAll(elem, ':scope>content>transport>candidate')); if (!iceCandidates.length) { logger.error(`${this} No ICE candidates to add ?`, elem[0]?.outerHTML); @@ -1587,12 +2022,7 @@ export default class JingleSessionPC extends JingleSession { // the assumption that candidates are spawned after the offer/answer // and XMPP preserves order). const workFunction = finishedCallback => { - for (const iceCandidate of iceCandidates) { - this.peerconnection.addIceCandidate(iceCandidate) - .then( - () => logger.debug(`${this} addIceCandidate ok!`), - err => logger.error(`${this} addIceCandidate failed!`, err)); - } + this._addIceCandidatesToPeerConnection(iceCandidates); finishedCallback(); logger.debug(`${this} ICE candidates task finished`); @@ -1714,6 +2144,12 @@ export default class JingleSessionPC extends JingleSession { this._audioWedgeDetector?.stop(); this._audioWedgeDetector = null; + if (this._iceRestartStatsTimer !== null) { + window.clearInterval(this._iceRestartStatsTimer); + this._iceRestartStatsTimer = null; + } + this._iceRestartT0 = null; + if (this.peerconnection) { this.peerconnection.onicecandidate = null; this.peerconnection.oniceconnectionstatechange = null; @@ -1827,6 +2263,19 @@ export default class JingleSessionPC extends JingleSession { const candidate = ev.candidate; const now = window.performance.now(); + if (this._iceRestartT0 !== null && this.options?.testing?.debugIceRestart) { + const t = Math.round(now - this._iceRestartT0); + + if (candidate) { + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${this._lastIceGeneration} t+${t}ms: ` + + `local candidate gathered (${candidate.type}/${candidate.protocol}, ` + + `mid=${candidate.sdpMid})`); + } else { + logger.info(`${this} ${ICE_RESTART_LOG_PREFIX} gen=${this._lastIceGeneration} t+${t}ms: ` + + 'end of local candidate gathering'); + } + } + if (candidate) { if (this._gatheringStartedTimestamp === null) { this._gatheringStartedTimestamp = now; diff --git a/modules/xmpp/strophe.jingle.js b/modules/xmpp/strophe.jingle.js index 34eaae1f9d..bbd35b9eae 100644 --- a/modules/xmpp/strophe.jingle.js +++ b/modules/xmpp/strophe.jingle.js @@ -260,9 +260,20 @@ export default class JingleConnectionPlugin extends ConnectionPlugin { break; } case 'transport-info': { - const candidates = _parseIceCandidates(findFirst(iq, 'jingle>content>transport')); + const transportElement = findFirst(iq, 'jingle>content>transport'); + const candidates = _parseIceCandidates(transportElement); logger.debug(`Received ${action} from ${fromJid} for candidates=${candidates.join(', ')}`); + + // A transport tagged with an ice-generation is the transport of a new ICE agent that the bridge + // created in response to an in-place ICE restart request. It replaces the remote ICE credentials + // and its candidates have to be added only after the offer/answer completes, so it is applied as a + // single atomic operation instead of going through the generic candidate handling. + if (transportElement?.getAttribute('ice-generation')) { + sess.onBridgeIceRestartTransport(transportElement); + break; + } + this.eventEmitter.emit(XMPPEvents.TRANSPORT_INFO, sess, jingleElement); break; } diff --git a/service/statistics/AnalyticsEvents.ts b/service/statistics/AnalyticsEvents.ts index 9ec8cc99f0..222b2c3bc3 100644 --- a/service/statistics/AnalyticsEvents.ts +++ b/service/statistics/AnalyticsEvents.ts @@ -24,6 +24,17 @@ export enum AnalyticsEvents { + /** + * The "action" value for Jingle events which indicates that an in-place ICE restart was requested. + */ + ACTION_JINGLE_ICE_RESTART_REQUESTED = 'ice-restart.requested', + + /** + * The "action" value for Jingle events which indicates that an in-place ICE restart completed successfully, + * i.e. the renegotiation completed and the new local transport was signalled. + */ + ACTION_JINGLE_ICE_RESTART_SUCCESS = 'ice-restart.success', + /** * The "action" value for Jingle events which indicates that the Jingle session * was restarted (TODO: verify/fix the documentation)