26

for a couple of days I'm now stuck with trying to get my webRTC client to work and I can't figure out what I'm doing wrong. I'm trying to create multi peer webrtc client and am testing both sides with Chrome. When the callee receives the call and create the Answer, I get the following error:

Failed to set local answer sdp: Called in wrong state: kStable

The receiving side correctly establishes both video connnections and is showing the local and remote streams. But the caller seems not to receive the callees answer. Can someone hint me what I am doing wrong here?

Here is the code I am using (it's a stripped version to just show the relevant parts and make it better readable)

class WebRTC_Client
{
    private peerConns = {};
    private sendAudioByDefault = true;
    private sendVideoByDefault = true;
    private offerOptions = {
        offerToReceiveAudio: true,
        offerToReceiveVideo: true
    };
    private constraints = {
        "audio": true,
        "video": {
            frameRate: 5,
            width: 256,
            height: 194
        }
    };
    private serversCfg = {
        iceServers: [{
            urls: ["stun:stun.l.google.com:19302"]
        }]
    };
    private SignalingChannel;

    public constructor(SignalingChannel){
        this.SignalingChannel = SignalingChannel;
        this.bindSignalingHandlers();
    }

    /*...*/

    private gotStream(stream) {
        (<any>window).localStream = stream;
        this.videoAssets[0].srcObject = stream;
     }

    private stopLocalTracks(){}

    private start() {
        var self = this;

        if( !this.isReady() ){
            console.error('Could not start WebRTC because no WebSocket user connectionId had been assigned yet');
        }

        this.buttonStart.disabled = true;

        this.stopLocalTracks();

        navigator.mediaDevices.getUserMedia(this.getConstrains())
            .then((stream) => {
                self.gotStream(stream);
                self.SignalingChannel.send(JSON.stringify({type: 'onReadyForTeamspeak'}));
            })
            .catch(function(error) { trace('getUserMedia error: ', error); });
    }

    public addPeerId(peerId){
        this.availablePeerIds[peerId] = peerId;
        this.preparePeerConnection(peerId);
    }

    private preparePeerConnection(peerId){
        var self = this;

        if( this.peerConns[peerId] ){
            return;
        }

        this.peerConns[peerId] = new RTCPeerConnection(this.serversCfg);
        this.peerConns[peerId].ontrack = function (evt) { self.gotRemoteStream(evt, peerId); };
        this.peerConns[peerId].onicecandidate = function (evt) { self.iceCallback(evt, peerId); };
        this.peerConns[peerId].onnegotiationneeded = function (evt) { if( self.isCallingTo(peerId) ) { self.createOffer(peerId); } };

        this.addLocalTracks(peerId);
    }

    private addLocalTracks(peerId){
        var self = this;

        var localTracksCount = 0;
        (<any>window).localStream.getTracks().forEach(
            function (track) {
                self.peerConns[peerId].addTrack(
                    track,
                    (<any>window).localStream
            );
                localTracksCount++;
            }
        );
        trace('Added ' + localTracksCount + ' local tracks to remote peer #' + peerId);
    }

    private call() {
        var self = this;

        trace('Start calling all available new peers if any available');

        // only call if there is anyone to call
        if( !Object.keys(this.availablePeerIds).length ){
            trace('There are no callable peers available that I know of');
            return;
        }

        for( let peerId in this.availablePeerIds ){
            if( !this.availablePeerIds.hasOwnProperty(peerId) ){
                continue;
            }
            this.preparePeerConnection(peerId);
        }
    }

    private createOffer(peerId){
        var self = this;

        this.peerConns[peerId].createOffer( this.offerOptions )
            .then( function (offer) { return self.peerConns[peerId].setLocalDescription(offer); } )
            .then( function () {
                trace('Send offer to peer #' + peerId);
                self.SignalingChannel.send(JSON.stringify({ "sdp": self.peerConns[peerId].localDescription, "remotePeerId": peerId, "type": "onWebRTCPeerConn" }));
            })
            .catch(function(error) { self.onCreateSessionDescriptionError(error); });
    }

    private answerCall(peerId){
        var self = this;

        trace('Answering call from peer #' + peerId);

        this.peerConns[peerId].createAnswer()
            .then( function (answer) { return self.peerConns[peerId].setLocalDescription(answer); } )
            .then( function () {
                trace('Send answer to peer #' + peerId);
                self.SignalingChannel.send(JSON.stringify({ "sdp": self.peerConns[peerId].localDescription, "remotePeerId": peerId, "type": "onWebRTCPeerConn" }));
            })
            .catch(function(error) { self.onCreateSessionDescriptionError(error); });
    }

    private onCreateSessionDescriptionError(error) {
        console.warn('Failed to create session description: ' + error.toString());
    }

    private gotRemoteStream(e, peerId) {
        if (this.audioAssets[peerId].srcObject !== e.streams[0]) {
            this.videoAssets[peerId].srcObject = e.streams[0];
            trace('Added stream source of remote peer #' + peerId + ' to DOM');
        }
    }

    private iceCallback(event, peerId) {
        this.SignalingChannel.send(JSON.stringify({ "candidate": event.candidate, "remotePeerId": peerId, "type": "onWebRTCPeerConn" }));
    }

    private handleCandidate(candidate, peerId) {
        this.peerConns[peerId].addIceCandidate(candidate)
            .then(
                this.onAddIceCandidateSuccess,
                this.onAddIceCandidateError
            );
        trace('Peer #' + peerId + ': New ICE candidate: ' + (candidate ? candidate.candidate : '(null)'));
    }

    private onAddIceCandidateSuccess() {
        trace('AddIceCandidate success.');
    }

    private onAddIceCandidateError(error) {
        console.warn('Failed to add ICE candidate: ' + error.toString());
    }

    private hangup() {}

    private bindSignalingHandlers(){
        this.SignalingChannel.registerHandler('onWebRTCPeerConn', (signal) => this.handleSignals(signal));
    }

    private handleSignals(signal){
        var self = this,
            peerId = signal.connectionId;

        if( signal.sdp ) {
            trace('Received sdp from peer #' + peerId);

            this.peerConns[peerId].setRemoteDescription(new RTCSessionDescription(signal.sdp))
                .then( function () {
                    if( self.peerConns[peerId].remoteDescription.type === 'answer' ){
                        trace('Received sdp answer from peer #' + peerId);
                    } else if( self.peerConns[peerId].remoteDescription.type === 'offer' ){
                        trace('Received sdp offer from peer #' + peerId);
                        self.answerCall(peerId);
                    } else {
                        trace('Received sdp ' + self.peerConns[peerId].remoteDescription.type + ' from peer #' + peerId);
                    }
                })
                .catch(function(error) { trace('Unable to set remote description for peer #' + peerId + ': ' + error); });
        } else if( signal.candidate ){
            this.handleCandidate(new RTCIceCandidate(signal.candidate), peerId);
        } else if( signal.closeConn ){
            trace('Closing signal received from peer #' + peerId);
            this.endCall(peerId,true);
        }
    }
}
Eric Xyz
  • 452
  • 1
  • 5
  • 14
  • As commented by Philipp Hancke, [this issue](https://bugs.chromium.org/p/chromium/issues/detail?id=740501) has only recently been fixed in Chrome. It has the label "M-66", but I'm not sure if the fix got included in Chrome 66. If my answer helped, please mark it as the solution to close this question ;) – j1elo Apr 24 '18 at 16:37

2 Answers2

20

I have been using a similar construct to build up the WebRTC connections between sender and receiver peers, by calling the method RTCPeerConnection.addTrack twice (one for the audio track, and one for the video track).

I used the same structure as shown in the Stage 2 example shown in The evolution of WebRTC 1.0:

let pc1 = new RTCPeerConnection(), pc2 = new RTCPeerConnection(), stream, videoTrack, videoSender;

(async () => {
  try {
    stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
    videoTrack = stream.getVideoTracks()[0];
    pc1.addTrack(stream.getAudioTracks()[0], stream);
  } catch (e) {
    console.log(e);  
  }
})();

checkbox.onclick = () => {
  if (checkbox.checked) {
    videoSender = pc1.addTrack(videoTrack, stream);
  } else {
    pc1.removeTrack(videoSender);
  }
}

pc2.ontrack = e => {
  video.srcObject = e.streams[0];
  e.track.onended = e => video.srcObject = video.srcObject; // Chrome/Firefox bug
}

pc1.onicecandidate = e => pc2.addIceCandidate(e.candidate);
pc2.onicecandidate = e => pc1.addIceCandidate(e.candidate);
pc1.onnegotiationneeded = async e => {
  try {
    await pc1.setLocalDescription(await pc1.createOffer());
    await pc2.setRemoteDescription(pc1.localDescription);
    await pc2.setLocalDescription(await pc2.createAnswer());
    await pc1.setRemoteDescription(pc2.localDescription);
  } catch (e) {
    console.log(e);  
  }
}

Test it here: https://jsfiddle.net/q8Lw39fd/

As you'll notice, in this example the method createOffer is never called directly; instead, it is indirectly called via addTrack triggering an RTCPeerConnection.onnegotiationneeded event.

However, just as in your case, Chrome triggers this event twice, once for each track, and this causes the error message you mentioned:

DOMException: Failed to set local answer sdp: Called in wrong state: kStable

This doesn't happen in Firefox, by the way: it triggers the event only once.

The solution to this issue is to write a workaround for the Chrome behavior: a guard that prevents nested calls to the (re)negotiation mechanism.

The relevant part of the fixed example would be like this:

pc1.onicecandidate = e => pc2.addIceCandidate(e.candidate);
pc2.onicecandidate = e => pc1.addIceCandidate(e.candidate);

var isNegotiating = false;  // Workaround for Chrome: skip nested negotiations
pc1.onnegotiationneeded = async e => {
  if (isNegotiating) {
    console.log("SKIP nested negotiations");
    return;
  }
  isNegotiating = true;
  try {
    await pc1.setLocalDescription(await pc1.createOffer());
    await pc2.setRemoteDescription(pc1.localDescription);
    await pc2.setLocalDescription(await pc2.createAnswer());
    await pc1.setRemoteDescription(pc2.localDescription);
  } catch (e) {
    console.log(e);  
  }
}

pc1.onsignalingstatechange = (e) => {  // Workaround for Chrome: skip nested negotiations
  isNegotiating = (pc1.signalingState != "stable");
}

Test it here: https://jsfiddle.net/q8Lw39fd/8/

You should be able to easily implement this guard mechanism into your own code.

j1elo
  • 1,029
  • 8
  • 21
  • 1
    negotiationneeded is broken in Chrome. This was fixed only recently in https://bugs.chromium.org/p/chromium/issues/detail?id=740501 – Philipp Hancke Mar 02 '18 at 06:25
  • @j1elo that actually makes a lot of sense. Thanks for the explanation, this is a good solution. – Eric Xyz Mar 02 '18 at 09:15
  • this is still broken as of chrome 66.0.3359.139. I had to use this work around. – manit Apr 27 '18 at 19:45
  • 1
    Still broken in 68. Shame to Google. – Oleg Aug 23 '18 at 22:43
  • 2
    Is this still a problem? I'm experiencing something similar in Chrome 84 – boozi Jul 30 '20 at 13:13
  • Note that as of today, this bug is fixed in Chrome (was resolved in M75), but I still found it in Ionic, using iosrtc which goes like 20 versions behind in terms of updating the WebRTC library. So watch out, this workaround might still be needed! – j1elo Oct 01 '20 at 16:37
1

You're sending the answer here:

.then( function (answer) { return self.peerConns[peerId].setLocalDescription(answer); } )

Look at mine:

     var callback = function (answer) {
         createdDescription(answer, fromId);
     };
     peerConnection[fromId].createAnswer().then(callback).catch(errorHandler);


    function createdDescription(description, fromId) {
        console.log('Got description');

        peerConnection[fromId].setLocalDescription(description).then(function() {
            console.log("Sending SDP:", fromId, description);
            serverConnection.emit('signal', fromId, {'sdp': description});
        }).catch(errorHandler);
    }
Keyne Viana
  • 6,194
  • 2
  • 24
  • 55
  • 1
    Well, in my version I'm chaining the promises with **.then()** which I belief should also work and make the signal wait for the **setLocalDescription** to finish before sending the signal. But I figured out, that in my version **createOffer** gets called multiple times by the **onnegotiationneeded** event. But I don't know why the browser is sending it multiple times. It should only be sent once. I changed my code so that **createOffer** is called within **call()** function instead within **onnegotiationneeded**. Now the _kStable_ warning has gone. – Eric Xyz Feb 28 '18 at 11:07
  • But now I'm still not seeing the remote video at the caller side. Somehow I think now **ontrack** event is not beeing called after the sdp answer. I don't know why – Eric Xyz Feb 28 '18 at 11:09
  • @EricXyz You're right, I miss that piece of code... I don't understand why you put createOffer on negotiationended since the offer that actually initilizes the negotiation.. you should have a "call" button which will trigger createOffer only once. – Keyne Viana Feb 28 '18 at 14:19
  • I did this because I'm using that webRTC client in a context where the connection is automatically beeing created, without the user having to press a call button. I didn't know better and I tried to also automatically implement renegotiation on changed stream settings (like turning video on or off or changing the streaming quality) - I thought I should use onnegotiationneeded for this kind of scenario – Eric Xyz Feb 28 '18 at 14:50
  • @EricXyz Either automatic or not, it should not be called using peerconnection events, because that's after the offer has been created. You mentioned renegotiation, this is another subject after the connection is already established and you want to update de stream (which have some bugs depending on the browser). – Keyne Viana Feb 28 '18 at 16:13
  • @KeyneViana actually, this should work fine according to the MDN docs: [addTrack](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/addTrack) triggers an [onnegotiationneeded](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onnegotiationneeded), and in the Example section they use this handler to call `createOffer()`. Same technique of indirectly calling `createOffer()` is used in the _Stage 2_ example shown in [The evolution of WebRTC](https://blog.mozilla.org/webrtc/the-evolution-of-webrtc/). But it's true that Chrome shows some warnings for me too. – j1elo Mar 01 '18 at 13:58
  • 1
    @j1elo you're right I actually read the event as "on negotiation ended" lol. But it's "needed". – Keyne Viana Mar 01 '18 at 17:07
  • @KeyneViana same here – Amirreza Sep 17 '18 at 08:24
  • I got it working from your code. Thanks! But why it requires a callback? If we have the peer connection object then why we can't use it there only? It was throwing an error regarding SDP. mismatch I'm confused as, why this worked! – ishan shah Dec 17 '20 at 10:57
  • @ishanshah Callback is used to set the SDP. It's been a while since I don't touch any WebRTC code, so I'm not sure about your question. – Keyne Viana Dec 18 '20 at 01:08