Skip to content

Commit ea39b69

Browse files
committed
Don't ignore ICE candidates received before offer/answer
The main bug here was a race on the callee side because we await-ed on setRemoteDescription before setting the opponent party ID, and while we were await-ing, the callEventHandler could give us candidate events which we'd duly ignore because we thought the party ID didn't match. This also meant that any candidates that arrived before the answer would have been ignored. Save these up by party ID and then add the ones from the party ID that we pick once the answer comes in. Also fix the confusion on party IDs where we weren't sure whether we hadn't picked an opponent or we'd picked an opponent without a party ID. It's now undefined for the former and null for the latter, as it claims to be in the comment.
1 parent fccf08e commit ea39b69

File tree

2 files changed

+143
-46
lines changed

2 files changed

+143
-46
lines changed

spec/unit/webrtc/call.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,62 @@ describe('Call', function() {
190190
// Hangup to stop timers
191191
call.hangup(CallErrorCode.UserHangup, true);
192192
});
193+
194+
it('should add candidates received before answer if party ID is correct', async function() {
195+
await call.placeVoiceCall();
196+
call.peerConn.addIceCandidate = jest.fn();
197+
198+
call.onRemoteIceCandidatesReceived({
199+
getContent: () => {
200+
return {
201+
version: 1,
202+
call_id: call.callId,
203+
party_id: 'the_correct_party_id',
204+
candidates: [
205+
{
206+
candidate: 'the_correct_candidate',
207+
sdpMid: '',
208+
},
209+
],
210+
};
211+
},
212+
});
213+
214+
call.onRemoteIceCandidatesReceived({
215+
getContent: () => {
216+
return {
217+
version: 1,
218+
call_id: call.callId,
219+
party_id: 'some_other_party_id',
220+
candidates: [
221+
{
222+
candidate: 'the_wrong_candidate',
223+
sdpMid: '',
224+
},
225+
],
226+
};
227+
},
228+
});
229+
230+
expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(0);
231+
232+
await call.onAnswerReceived({
233+
getContent: () => {
234+
return {
235+
version: 1,
236+
call_id: call.callId,
237+
party_id: 'the_correct_party_id',
238+
answer: {
239+
sdp: DUMMY_SDP,
240+
},
241+
};
242+
},
243+
});
244+
245+
expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1);
246+
expect(call.peerConn.addIceCandidate).toHaveBeenCalledWith({
247+
candidate: 'the_correct_candidate',
248+
sdpMid: '',
249+
});
250+
});
193251
});

src/webrtc/call.ts

Lines changed: 85 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,6 @@ export class MatrixCall extends EventEmitter {
251251
private localAVStream: MediaStream;
252252
private inviteOrAnswerSent: boolean;
253253
private waitForLocalAVStream: boolean;
254-
// XXX: This is either the invite or answer from remote...
255-
private msg: any;
256254
// XXX: I don't know why this is called 'config'.
257255
private config: MediaStreamConstraints;
258256
private successor: MatrixCall;
@@ -284,16 +282,18 @@ export class MatrixCall extends EventEmitter {
284282
private makingOffer: boolean;
285283
private ignoreOffer: boolean;
286284

285+
// If candidates arrive before we've picked an opponent (which, in particular,
286+
// will happen if the opponent sends candidates eagerly before the user answers
287+
// the call) we buffer them up here so we can then add the ones from the party we pick
288+
private remoteCandidateBuffer = new Map<string, RTCIceCandidate[]>();
289+
287290
constructor(opts: CallOpts) {
288291
super();
289292
this.roomId = opts.roomId;
290293
this.client = opts.client;
291294
this.type = null;
292295
this.forceTURN = opts.forceTURN;
293296
this.ourPartyId = this.client.deviceId;
294-
// We compare this to null to checks the presence of a party ID:
295-
// make sure it's null, not undefined
296-
this.opponentPartyId = null;
297297
// Array of Objects with urls, username, credential keys
298298
this.turnServers = opts.turnServers || [];
299299
if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) {
@@ -541,11 +541,16 @@ export class MatrixCall extends EventEmitter {
541541
* @param {MatrixEvent} event The m.call.invite event
542542
*/
543543
async initWithInvite(event: MatrixEvent) {
544-
this.msg = event.getContent();
544+
const invite = event.getContent();
545545
this.direction = CallDirection.Inbound;
546+
546547
this.peerConn = this.createPeerConnection();
548+
// we must set the party ID before await-ing on anything: the call event
549+
// handler will start giving us more call events (eg. candidates) so if
550+
// we haven't set the party ID, we'll ignore them.
551+
this.chooseOpponent(event);
547552
try {
548-
await this.peerConn.setRemoteDescription(this.msg.offer);
553+
await this.peerConn.setRemoteDescription(invite.offer);
549554
} catch (e) {
550555
logger.debug("Failed to set remote description", e);
551556
this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
@@ -564,13 +569,6 @@ export class MatrixCall extends EventEmitter {
564569
this.type = this.remoteStream.getTracks().some(t => t.kind === 'video') ? CallType.Video : CallType.Voice;
565570

566571
this.setState(CallState.Ringing);
567-
this.opponentVersion = this.msg.version;
568-
if (this.opponentVersion !== 0) {
569-
// ignore party ID in v0 calls: party ID isn't a thing until v1
570-
this.opponentPartyId = this.msg.party_id || null;
571-
}
572-
this.opponentCaps = this.msg.capabilities || {};
573-
this.opponentMember = event.sender;
574572

575573
if (event.getLocalAge()) {
576574
setTimeout(() => {
@@ -584,7 +582,7 @@ export class MatrixCall extends EventEmitter {
584582
}
585583
this.emit(CallEvent.Hangup);
586584
}
587-
}, this.msg.lifetime - event.getLocalAge());
585+
}, invite.lifetime - event.getLocalAge());
588586
}
589587
}
590588

@@ -596,7 +594,6 @@ export class MatrixCall extends EventEmitter {
596594
// perverse as it may seem, sometimes we want to instantiate a call with a
597595
// hangup message (because when getting the state of the room on load, events
598596
// come in reverse order and we want to remember that a call has been hung up)
599-
this.msg = event.getContent();
600597
this.setState(CallState.Ended);
601598
}
602599

@@ -1030,37 +1027,33 @@ export class MatrixCall extends EventEmitter {
10301027
return;
10311028
}
10321029

1030+
const cands = ev.getContent().candidates;
1031+
if (!cands) {
1032+
logger.info("Ignoring candidates event with no candidates!");
1033+
return;
1034+
}
1035+
1036+
const fromPartyId = ev.getContent().version === 0 ? null : ev.getContent().party_id || null;
1037+
1038+
if (this.opponentPartyId === undefined) {
1039+
// we haven't picked an opponent yet so save the candidates
1040+
logger.info(`Bufferring ${cands.length} candidates until we pick an opponent`);
1041+
const bufferedCands = this.remoteCandidateBuffer.get(fromPartyId) || [];
1042+
bufferedCands.push(...cands);
1043+
this.remoteCandidateBuffer.set(fromPartyId, bufferedCands);
1044+
return;
1045+
}
1046+
10331047
if (!this.partyIdMatches(ev.getContent())) {
10341048
logger.info(
10351049
`Ignoring candidates from party ID ${ev.getContent().party_id}: ` +
10361050
`we have chosen party ID ${this.opponentPartyId}`,
10371051
);
1038-
return;
1039-
}
10401052

1041-
const cands = ev.getContent().candidates;
1042-
if (!cands) {
1043-
logger.info("Ignoring candidates event with no candidates!");
10441053
return;
10451054
}
10461055

1047-
for (const cand of cands) {
1048-
if (
1049-
(cand.sdpMid === null || cand.sdpMid === undefined) &&
1050-
(cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined)
1051-
) {
1052-
logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex");
1053-
return;
1054-
}
1055-
logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
1056-
try {
1057-
this.peerConn.addIceCandidate(cand);
1058-
} catch (err) {
1059-
if (!this.ignoreOffer) {
1060-
logger.info("Failed to add remore ICE candidate", err);
1061-
}
1062-
}
1063-
}
1056+
this.addIceCandidates(cands);
10641057
}
10651058

10661059
/**
@@ -1072,20 +1065,15 @@ export class MatrixCall extends EventEmitter {
10721065
return;
10731066
}
10741067

1075-
if (this.opponentPartyId !== null) {
1068+
if (this.opponentPartyId !== undefined) {
10761069
logger.info(
10771070
`Ignoring answer from party ID ${event.getContent().party_id}: ` +
10781071
`we already have an answer/reject from ${this.opponentPartyId}`,
10791072
);
10801073
return;
10811074
}
10821075

1083-
this.opponentVersion = event.getContent().version;
1084-
if (this.opponentVersion !== 0) {
1085-
this.opponentPartyId = event.getContent().party_id || null;
1086-
}
1087-
this.opponentCaps = event.getContent().capabilities || {};
1088-
this.opponentMember = event.sender;
1076+
this.chooseOpponent(event);
10891077

10901078
this.setState(CallState.Connecting);
10911079

@@ -1702,9 +1690,60 @@ export class MatrixCall extends EventEmitter {
17021690

17031691
private partyIdMatches(msg): boolean {
17041692
// They must either match or both be absent (in which case opponentPartyId will be null)
1705-
const msgPartyId = msg.party_id || null;
1693+
// Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same
1694+
// here and use null if the version is 0 (woe betide any opponent sending messages in the
1695+
// same call with different versions)
1696+
const msgPartyId = msg.version === 0 ? null : msg.party_id || null;
17061697
return msgPartyId === this.opponentPartyId;
17071698
}
1699+
1700+
// Commits to an opponent for the call
1701+
// ev: An invite or answer event
1702+
private chooseOpponent(ev: MatrixEvent) {
1703+
// I choo-choo-choose you
1704+
const msg = ev.getContent();
1705+
1706+
this.opponentVersion = msg.version;
1707+
if (this.opponentVersion === 0) {
1708+
// set to null to indicate that we've chosen an opponent, but because
1709+
// they're v0 they have no party ID (even if they sent one, we're ignoring it)
1710+
this.opponentPartyId = null;
1711+
} else {
1712+
// set to their party ID, or if they're naughty and didn't send one despite
1713+
// not being v0, set it to null to indicate we picked an opponent with no
1714+
// party ID
1715+
this.opponentPartyId = msg.party_id || null;
1716+
}
1717+
this.opponentCaps = msg.capabilities || {};
1718+
this.opponentMember = ev.sender;
1719+
1720+
const bufferedCands = this.remoteCandidateBuffer.get(this.opponentPartyId);
1721+
if (bufferedCands) {
1722+
logger.info(`Adding ${bufferedCands.length} buffered candidates for opponent ${this.opponentPartyId}`);
1723+
this.addIceCandidates(bufferedCands);
1724+
}
1725+
this.remoteCandidateBuffer = null;
1726+
}
1727+
1728+
private addIceCandidates(cands: RTCIceCandidate[]) {
1729+
for (const cand of cands) {
1730+
if (
1731+
(cand.sdpMid === null || cand.sdpMid === undefined) &&
1732+
(cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined)
1733+
) {
1734+
logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex");
1735+
return;
1736+
}
1737+
logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
1738+
try {
1739+
this.peerConn.addIceCandidate(cand);
1740+
} catch (err) {
1741+
if (!this.ignoreOffer) {
1742+
logger.info("Failed to add remore ICE candidate", err);
1743+
}
1744+
}
1745+
}
1746+
}
17081747
}
17091748

17101749
function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boolean) {

0 commit comments

Comments
 (0)