Repository navigation
Ignore a duplicated server answer - #1257
Conversation
Transport.setRemoteDescription applied every answer it was handed. A proxy duplicating the signal message, or a server retry, therefore applied the same answer twice. The second apply happens with no local offer left to answer, which is invalid: pc.setRemoteDescription throws, the error is swallowed into a warning, and the transport then flushes pending ICE candidates and clears restartingIce a second time for a message that should have been dropped. Offers are not numbered on this transport, so use the rule the JS SDK falls back to for unnumbered answers: in `stable` there is no local offer left to answer. An in-flight flag covers a copy that arrives while the first is still being applied, and is released however the apply ends so a genuine retry still works. Remote offers are untouched. Port of livekit/client-sdk-js#2114. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| } | ||
|
|
||
| if (sd.type == 'answer') { | ||
| // Offers are not numbered on this transport, so the signaling state is what says |
There was a problem hiding this comment.
Why can't we do the same as we did in JS to ensure the message is indeed duplicated?
There was a problem hiding this comment.
(Reposting here so it is attached to your question rather than sitting at the bottom of the PR.)
Short answer: this SDK has no offer ids to match against yet.
The proto field exists (SessionDescription.id), but Flutter never populates it — SignalClient.sendOffer sends offer.toPBType() with no id, so every offer goes out as id 0 and the server echoes that back. SignalAnswerEvent then carries only sd and drops msg.answer.id anyway.
So the numbered half of the JS fix has nothing to key on here. What this PR implements is specifically JS's own fallback for that case — isDuplicateAnswer returns signalingState === 'stable' && !hasPendingInitialOffer when offerId is 0, which is the branch legacy servers take. Flutter is permanently in that regime, so this matches JS rather than diverging from it. The _answerInFlight flag covers the mid-apply race that JS handles with answerInFlight.
Making it id-based the way JS is would mean teaching this SDK to assign offer ids: a counter in Transport.createAndSendOffer, threading it through sendOffer, and carrying it back on SignalAnswerEvent. That is strictly more precise — it would catch a duplicate arriving after a new offer is already pending, which the state check cannot — and I am happy to do it as a follow-up. I kept this PR to the minimal fix since the state check covers the reported failure.
One caveat either way: I could not get automated coverage of a second negotiation round, because MockPeerConnection.signalingState reports stable forever once any answer is applied. Id-based matching would sidestep that mock limitation too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Good question — the short answer is that this SDK has no offer ids to match against yet. The proto field exists ( So the numbered half of the JS fix has nothing to key on here. What I implemented is specifically JS's own fallback for that case — Making it id-based the way JS is would mean teaching this SDK to assign offer ids: a counter in One caveat worth flagging either way: I could not get automated coverage of a second negotiation round, because |
Port of livekit/client-sdk-js#2114.
The bug
Transport.setRemoteDescriptionapplied every answer it was handed, with no check that one was still wanted:A proxy duplicating the signal message, or a server retry, therefore applies the same answer twice. The second apply happens with no local offer left to answer, which is an invalid transition. Because the failure is swallowed into a warning, the method then carries on and flushes
_pendingCandidatesand clearsrestartingIcea second time — side effects for a message that should have been dropped.Repro
Added a
Transport.setRemoteDescription duplicate answersgroup totransport_negotiate_test.dart. Against the unfixed code:The fix
Offer ids are not plumbed through this SDK, so the numbered-answer half of the upstream fix has no analogue here. What does apply is the rule JS falls back to for unnumbered answers: in
stablethere is no local offer left to answer, so an answer arriving then is a duplicate. An_answerInFlightflag covers a copy that arrives while the first is still being applied, and is released in afinallyhowever the apply ends, so a genuine retry still works.This is consistent with how the transport already reasons about negotiation —
createAndSendOfferlikewise gates ongetSignalingState() == RTCSignalingStateHaveLocalOffer.Only answers are gated; remote offers (normal subscriber traffic, which do arrive in
stable) are untouched, and there is a test for that.The ICE-restart path at
transport.dart:368callspc.setRemoteDescriptiondirectly rather than going through this method, so it is unaffected.Verification
flutter test471/471,flutter analyze lib/src/core/transport.dartclean. The three new cases cover the duplicate, the still-wanted answer, and the remote-offer-in-stable case.One thing worth a reviewer's eye
I could not get an automated test to cover a second negotiation round (offer → answer → offer → answer).
MockPeerConnection.signalingStateonly reportsHaveLocalOfferwhen_remoteDescriptionis null, so once any answer has been applied the mock reportsstableforever, even after a freshsetLocalDescription(offer).Real WebRTC moves to
have-local-offerthere, so the second answer is accepted in production — and no existing test exercises that path throughTransport.setRemoteDescription, which is why the suite stays green. But that means the mock cannot currently prove it. If you'd rather, I can tighten the mock's state model in a follow-up so this is actually pinned down.🤖 Generated with Claude Code