1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-09 10:22:46 +03:00

Do an ICE Restart if WebRTC become disconnected (#3341)

* Do an ice restart if ICE disconnected

  - Waite two seconds after disconnected
  - Remove check for finish ICE gathering and try to add each local candidate. Avoid race in multible ICE gathering

* Add tests for failed iceConnectionState

* suppress type check in unit test

* fix pr issues
This commit is contained in:
Enrico Schwendig
2023-05-11 16:00:26 +02:00
committed by GitHub
parent 73ca9c9ed2
commit 90e8336797
3 changed files with 62 additions and 9 deletions

View File

@@ -239,6 +239,8 @@ export class MockRTCPeerConnection {
public triggerIncomingDataChannel(): void { public triggerIncomingDataChannel(): void {
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent); this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
} }
public restartIce(): void {}
} }
export class MockRTCRtpSender { export class MockRTCRtpSender {

View File

@@ -1652,12 +1652,18 @@ describe("Call", function () {
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers(); jest.useFakeTimers();
jest.spyOn(call, "hangup"); jest.spyOn(call, "hangup");
await fakeIncomingCall(client, call, "1"); await fakeIncomingCall(client, call, "1");
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection; mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
mockPeerConn.iceConnectionState = "disconnected"; mockPeerConn.iceConnectionState = "disconnected";
mockPeerConn.iceConnectionStateChangeListener!(); mockPeerConn.iceConnectionStateChangeListener!();
jest.spyOn(mockPeerConn, "restartIce");
});
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
jest.advanceTimersByTime(3 * 1000);
expect(mockPeerConn.restartIce).toHaveBeenCalled();
}); });
it("should hang up after being disconnected for 30 seconds", () => { it("should hang up after being disconnected for 30 seconds", () => {
@@ -1665,6 +1671,20 @@ describe("Call", function () {
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false); expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
}); });
it("should restart ICE gathering once again after ICE being failed", () => {
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
// @ts-ignore
mockPeerConn.restartIce = null;
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should not hangup if we've managed to re-connect", () => { it("should not hangup if we've managed to re-connect", () => {
mockPeerConn.iceConnectionState = "connected"; mockPeerConn.iceConnectionState = "connected";
mockPeerConn.iceConnectionStateChangeListener!(); mockPeerConn.iceConnectionStateChangeListener!();

View File

@@ -263,7 +263,8 @@ const CALL_TIMEOUT_MS = 60 * 1000; // ms
const CALL_LENGTH_INTERVAL = 1000; // ms const CALL_LENGTH_INTERVAL = 1000; // ms
/** The time after which we end the call, if ICE got disconnected */ /** The time after which we end the call, if ICE got disconnected */
const ICE_DISCONNECTED_TIMEOUT = 30 * 1000; // ms const ICE_DISCONNECTED_TIMEOUT = 30 * 1000; // ms
/** The time after which we try a ICE restart, if ICE got disconnected */
const ICE_RECONNECTING_TIMEOUT = 2 * 1000; // ms
export class CallError extends Error { export class CallError extends Error {
public readonly code: string; public readonly code: string;
@@ -382,6 +383,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private opponentPartyId: string | null | undefined; private opponentPartyId: string | null | undefined;
private opponentCaps?: CallCapabilities; private opponentCaps?: CallCapabilities;
private iceDisconnectedTimeout?: ReturnType<typeof setTimeout>; private iceDisconnectedTimeout?: ReturnType<typeof setTimeout>;
private iceReconnectionTimeOut?: ReturnType<typeof setTimeout> | undefined;
private inviteTimeout?: ReturnType<typeof setTimeout>; private inviteTimeout?: ReturnType<typeof setTimeout>;
private readonly removeTrackListeners = new Map<MediaStream, () => void>(); private readonly removeTrackListeners = new Map<MediaStream, () => void>();
@@ -965,6 +967,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.initOpponentCrypto(); await this.initOpponentCrypto();
try { try {
await this.peerConn.setRemoteDescription(invite.offer); await this.peerConn.setRemoteDescription(invite.offer);
logger.debug(`Call ${this.callId} initWithInvite() set remote description: ${invite.offer.type}`);
await this.addBufferedIceCandidates(); await this.addBufferedIceCandidates();
} catch (e) { } catch (e) {
logger.debug(`Call ${this.callId} initWithInvite() failed to set remote description`, e); logger.debug(`Call ${this.callId} initWithInvite() failed to set remote description`, e);
@@ -1790,10 +1793,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent): void => { private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent): void => {
if (event.candidate) { if (event.candidate) {
if (this.candidatesEnded) { if (this.candidatesEnded) {
logger.warn( logger.warn(`Call ${this.callId} gotLocalIceCandidate() got candidate after candidates have ended!`);
`Call ${this.callId} gotLocalIceCandidate() got candidate after candidates have ended - ignoring!`,
);
return;
} }
logger.debug(`Call ${this.callId} got local ICE ${event.candidate.sdpMid} ${event.candidate.candidate}`); logger.debug(`Call ${this.callId} got local ICE ${event.candidate.sdpMid} ${event.candidate.candidate}`);
@@ -1817,7 +1817,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}`, }`,
); );
if (this.peerConn?.iceGatheringState === "complete") { if (this.peerConn?.iceGatheringState === "complete") {
this.queueCandidate(null); this.queueCandidate(null); // We should leave it to WebRTC to announce the end
logger.debug(
`Call ${this.callId} onIceGatheringStateChange() ice gathering state complete, set candidates have ended`,
);
} }
}; };
@@ -1899,6 +1902,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.isSettingRemoteAnswerPending = true; this.isSettingRemoteAnswerPending = true;
await this.peerConn!.setRemoteDescription(content.answer); await this.peerConn!.setRemoteDescription(content.answer);
this.isSettingRemoteAnswerPending = false; this.isSettingRemoteAnswerPending = false;
logger.debug(`Call ${this.callId} onAnswerReceived() set remote description: ${content.answer.type}`);
} catch (e) { } catch (e) {
this.isSettingRemoteAnswerPending = false; this.isSettingRemoteAnswerPending = false;
logger.debug(`Call ${this.callId} onAnswerReceived() failed to set remote description`, e); logger.debug(`Call ${this.callId} onAnswerReceived() failed to set remote description`, e);
@@ -1991,6 +1995,8 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
await this.peerConn!.setRemoteDescription(description); // SRD rolls back as needed await this.peerConn!.setRemoteDescription(description); // SRD rolls back as needed
this.isSettingRemoteAnswerPending = false; this.isSettingRemoteAnswerPending = false;
logger.debug(`Call ${this.callId} onNegotiateReceived() set remote description: ${description.type}`);
if (description.type === "offer") { if (description.type === "offer") {
let answer: RTCSessionDescriptionInit; let answer: RTCSessionDescriptionInit;
try { try {
@@ -2003,6 +2009,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
} }
await this.peerConn!.setLocalDescription(answer); await this.peerConn!.setLocalDescription(answer);
logger.debug(`Call ${this.callId} onNegotiateReceived() create an answer`);
this.sendVoipEvent(EventType.CallNegotiate, { this.sendVoipEvent(EventType.CallNegotiate, {
description: this.peerConn!.localDescription?.toJSON(), description: this.peerConn!.localDescription?.toJSON(),
@@ -2226,7 +2233,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
return; // because ICE can still complete as we're ending the call return; // because ICE can still complete as we're ending the call
} }
logger.debug( logger.debug(
`Call ${this.callId} onIceConnectionStateChanged() running (state=${this.peerConn?.iceConnectionState})`, `Call ${this.callId} onIceConnectionStateChanged() running (state=${this.peerConn?.iceConnectionState}, conn=${this.peerConn?.connectionState})`,
); );
// ideally we'd consider the call to be connected when we get media but // ideally we'd consider the call to be connected when we get media but
@@ -2234,6 +2241,9 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
if (["connected", "completed"].includes(this.peerConn?.iceConnectionState ?? "")) { if (["connected", "completed"].includes(this.peerConn?.iceConnectionState ?? "")) {
clearTimeout(this.iceDisconnectedTimeout); clearTimeout(this.iceDisconnectedTimeout);
this.iceDisconnectedTimeout = undefined; this.iceDisconnectedTimeout = undefined;
if (this.iceReconnectionTimeOut) {
clearTimeout(this.iceReconnectionTimeOut);
}
this.state = CallState.Connected; this.state = CallState.Connected;
if (!this.callLengthInterval && !this.callStartTime) { if (!this.callLengthInterval && !this.callStartTime) {
@@ -2244,11 +2254,15 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}, CALL_LENGTH_INTERVAL); }, CALL_LENGTH_INTERVAL);
} }
} else if (this.peerConn?.iceConnectionState == "failed") { } else if (this.peerConn?.iceConnectionState == "failed") {
this.candidatesEnded = false;
// Firefox for Android does not yet have support for restartIce() // Firefox for Android does not yet have support for restartIce()
// (the types say it's always defined though, so we have to cast // (the types say it's always defined though, so we have to cast
// to prevent typescript from warning). // to prevent typescript from warning).
if (this.peerConn?.restartIce as (() => void) | null) { if (this.peerConn?.restartIce as (() => void) | null) {
this.candidatesEnded = false; this.candidatesEnded = false;
logger.debug(
`Call ${this.callId} onIceConnectionStateChanged() ice restart (state=${this.peerConn?.iceConnectionState})`,
);
this.peerConn!.restartIce(); this.peerConn!.restartIce();
} else { } else {
logger.info( logger.info(
@@ -2257,7 +2271,19 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.hangup(CallErrorCode.IceFailed, false); this.hangup(CallErrorCode.IceFailed, false);
} }
} else if (this.peerConn?.iceConnectionState == "disconnected") { } else if (this.peerConn?.iceConnectionState == "disconnected") {
this.iceDisconnectedTimeout = setTimeout(() => { this.candidatesEnded = false;
this.iceReconnectionTimeOut = setTimeout((): void => {
logger.info(
`Call ${this.callId} onIceConnectionStateChanged() ICE restarting because of ICE disconnected, (state=${this.peerConn?.iceConnectionState}, conn=${this.peerConn?.connectionState})`,
);
if (this.peerConn?.restartIce as (() => void) | null) {
this.candidatesEnded = false;
this.peerConn!.restartIce();
}
this.iceReconnectionTimeOut = undefined;
}, ICE_RECONNECTING_TIMEOUT);
this.iceDisconnectedTimeout = setTimeout((): void => {
logger.info( logger.info(
`Call ${this.callId} onIceConnectionStateChanged() hanging up call (ICE disconnected for too long)`, `Call ${this.callId} onIceConnectionStateChanged() hanging up call (ICE disconnected for too long)`,
); );
@@ -2887,6 +2913,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
} catch (err) { } catch (err) {
if (!this.ignoreOffer) { if (!this.ignoreOffer) {
logger.info(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate`, err); logger.info(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate`, err);
} else {
logger.debug(
`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate because ignoring offer`,
err,
);
} }
} }
} }