Findings from Claude while writing new SDP unit tests.
Across the seven categories of characterization tests added for the planned SDP-negotiation refactor (PRs #1622, #1624, #1625, #1626, #1627, #1628 and the in-flight Categories 5/7), a number of non-obvious behaviours have been pinned down by tests. Each one is locked down by an existing test, so the refactor has to consciously choose to either preserve or change the behaviour — silently flipping any of these will turn a green test red.
Filing this as a single issue so it can serve as a checklist when the refactor starts.
1. RTCPeerConnection and RTPSession disagree on offer m-line order
RTPSession.CreateOffer emits m-lines in the order tracks were added with addTrack. RTCPeerConnection.createOffer canonicalises to audio-first regardless of addTrack order.
- Test:
RTCPeerConnectionCreateOfferUnitTest.VideoThenAudio_RtcPeerConnectionStillEmitsAudioFirst
- Test:
RTPSessionCreateOfferUnitTest.VideoThenAudio_PreservesInsertionOrderInOffer
- Decision: keep the asymmetry, or align both surfaces?
2. RTPSession.RequireRenegotiation defaults to true on construction
Fresh sessions do not start in a "fully-negotiated" state — RequireRenegotiation is true from the constructor. It only flips to false after a successful SetRemoteDescription. Tests that assume false by default fail.
- Test:
RTPSessionCreateOfferUnitTest.Offer_LeavesRequireRenegotiationTrueAfterPriorAnswerCleared
- Decision: is the initial-true the contract, or an accident?
3. CreateAnswer connection-address selection does OS routing
GetSdpConnectionAddress for an answer prefers (in order): relay endpoint, STUN srflx endpoint, OS-routed local address toward the offer's c= line, then the supplied fallback. So the c= line in an answer is usually NOT the address the caller passed in — it is whatever the OS picks to reach the offer IP.
The supplied address is only used when the offer connection is IPAddress.Any (the classic hold form).
- Test:
RTPSessionCreateAnswerUnitTest.Answer_AlwaysHasPopulatedConnectionAddress
- Test:
RTPSessionCreateAnswerUnitTest.Answer_OfferConnectionIsAny_UsesSuppliedFallbackAddress
- Decision: is this the right precedence order for SIP vs WebRTC?
4. SetRemoteDescription auto-injects a default telephone-event into local audio capabilities
If the local track has no telephone-event capability of its own, SetRemoteDescription adds MediaStream.DefaultRTPEventFormat to the local track capabilities — even when the remote did not advertise telephone-event either. Every successful audio negotiation ends up with a telephone-event entry on the local capabilities list.
- Test:
RTPSessionCodecMatchingUnitTest.NoTelephoneEventOnEitherSide_StillInjectsDefault
- Source:
RTPSession.SetRemoteDescription lines ~1232-1247
- Decision: is this useful (always be DTMF-ready), or a surprising side-effect?
5. SDES negotiation is two-sided; SetRemoteDescription alone does not complete it
With UseSdpCryptoNegotiation = true, SetRemoteDescription only sets up the remote half of the SRTP key material. MediaStream.IsSecurityContextReady() stays false until CreateAnswer runs and publishes the local crypto attributes. The full negotiation only completes after both calls.
- Test:
RTPSessionSdesCryptoNegotiationUnitTest.CompatibleCryptoSuite_AfterFullOfferAnswer_SetsSecurityContext
- Decision: callers may expect security to be ready after
SetRemoteDescription — should the API doc make this explicit, or should SetRemoteDescription complete it eagerly?
6. CreateAnswer throws when answering an SDES-mode offer with m= port=0 + no crypto
SetRemoteDescription cleanly accepts an offer where one m-line is rejected (port=0) without a crypto attribute — the explicit port=0 guard in the SDES branch lets it through. But the subsequent CreateAnswer throws System.ApplicationException with "Error creating crypto attribute for SDP answer. No compatible offer." from GetSessionDescription (around line 2206) because the answer path has no matching skip for the rejected m-line.
- Test:
RTPSessionSdesCryptoNegotiationUnitTest.RejectedMediaPortZeroWithoutCrypto_SetRemoteDescriptionReturnsOk (passes)
- Test:
RTPSessionSdesCryptoNegotiationUnitTest.RejectedMediaPortZeroWithoutCrypto_CreateAnswerThrows (currently characterises the throw)
- Decision: either tighten
SetRemoteDescription to reject these earlier, or fix CreateAnswer to emit m=video 0 cleanly. Either is a behaviour change.
7. Unknown RTP header extensions are silently dropped at parse time
The SDP parser only adds an a=extmap:N <URI> entry to the announcement HeaderExtensions dictionary if <URI> is one of the four currently-known URIs:
http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time (AbsSendTime)
urn:ietf:params:rtp-hdrext:ssrc-audio-level (AudioLevel, audio-only)
urn:3gpp:video-orientation (CVO, video-only)
http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01 (TransportWideCC)
A remote offer with toffset, playout-delay, mid (the RTP header extension urn:ietf:params:rtp-hdrext:sdes:mid), or anything else is silently dropped. The application has no way to see what was in the offer that we could not handle.
- Test:
RTCPeerConnectionHeaderExtensionUnitTest.SetRemoteDescription_UnknownExtensionUri_SilentlyDropped
- Source:
SDP.cs around the MEDIA_EXTENSION_MAP_ATTRIBUE_PREFIX parse, RTPHeaderExtension.GetRTPHeaderExtension switch
- Decision: extend the registry, surface the dropped URI in a log warning, or keep silent?
8. Header-extension ID conflicts: answer uses the REMOTE ID
When local and remote both have the same extension URI but use different extmap IDs, the answer emits the extension at the remote ID — the local ID is mutated to match via the _rtpExtensionsUsed registry.
- Test:
RTCPeerConnectionHeaderExtensionUnitTest.Answer_ExtensionIdConflict_AnswerUsesRemoteId
- This is correct behaviour (RFC 8285 §4 — the offerer picks the IDs) but it does mean the local
RTPHeaderExtension object Id field is mutated as a side effect. Worth knowing during refactor.
9. AnnouncementVersion only increments on SetMediaStreamStatus
Per RFC 3264 §8, the SDP o= session-version SHOULD increment whenever the description changes. The current implementation only bumps m_sdpAnnouncementVersion inside SetMediaStreamStatus. Calling CreateOffer repeatedly without other changes produces SDPs with identical version numbers. Adding tracks, modifying capabilities, or anything else does not bump it either.
- Test:
RTPSessionRenegotiationAdvancedUnitTest.TwoOffersWithoutChanges_AnnouncementVersionDoesNotIncrement
- Test:
RTPSessionRenegotiationAdvancedUnitTest.SetMediaStreamStatusAudio_IncrementsAnnouncementVersion
- Decision: the refactor could fix this to bump on every
CreateOffer, or every observable mutation, or preserve current behaviour. Some SIP B2BUAs are version-sensitive when matching re-INVITEs.
10. LocalTrack direction-mirroring is asymmetric
SetLocalTrackStreamStatus (called during SetRemoteDescription) only adjusts the local track direction in these cases:
| Remote announces |
LocalTrack becomes |
Inactive |
Inactive |
c=IN IP4 0.0.0.0 (Any address, port != 9) |
Inactive |
m=… 0 … (port 0) |
Inactive |
sendonly / recvonly / sendrecv |
(unchanged from DefaultStreamStatus) |
So if remote announces sendonly, local does NOT mirror to recvonly — it stays at SendRecv. This is the asymmetric behaviour: local only goes Inactive in response to specific "remote unavailable" signals, never coerced into a complementary direction.
Additionally: a local track that was previously Inactive is automatically restored to DefaultStreamStatus at the top of SetLocalTrackStreamStatus when the next remote offer is non-inactive. So holds and rejected-then-reactivated media restore cleanly.
- Test:
RTPSessionRenegotiationAdvancedUnitTest.RemoteSendOnlyOffer_LocalDirectionStaysAtDefault
- Test:
RTPSessionRenegotiationAdvancedUnitTest.HoldThenResume_LocalTrackRestoredToDefault
- Test:
RTPSessionRenegotiationAdvancedUnitTest.RejectedMediaThenReactivated_LocalTrackRestored
- Decision: a refactor that introduces symmetric direction-mirroring (remote
sendonly → local recvonly) would be a behaviour change. Callers that explicitly drive direction via SetMediaStreamStatus may rely on the local staying at the configured default.
Suggested process for the refactor: when the negotiation extraction starts, work through this checklist and for each entry add a note to the PR description ("preserved" / "fixed to X / Y rationale"). That way reviewers (and future readers) can see each quirk was a conscious choice rather than an oversight.
Filed by Claude Code (Opus 4.7) based on test findings from PRs #1622, #1624, #1625, #1626, #1627, #1628 and in-flight Category 5 + 7 branches.
Findings from Claude while writing new SDP unit tests.
Across the seven categories of characterization tests added for the planned SDP-negotiation refactor (PRs #1622, #1624, #1625, #1626, #1627, #1628 and the in-flight Categories 5/7), a number of non-obvious behaviours have been pinned down by tests. Each one is locked down by an existing test, so the refactor has to consciously choose to either preserve or change the behaviour — silently flipping any of these will turn a green test red.
Filing this as a single issue so it can serve as a checklist when the refactor starts.
1.
RTCPeerConnectionandRTPSessiondisagree on offer m-line orderRTPSession.CreateOfferemits m-lines in the order tracks were added withaddTrack.RTCPeerConnection.createOffercanonicalises to audio-first regardless ofaddTrackorder.RTCPeerConnectionCreateOfferUnitTest.VideoThenAudio_RtcPeerConnectionStillEmitsAudioFirstRTPSessionCreateOfferUnitTest.VideoThenAudio_PreservesInsertionOrderInOffer2.
RTPSession.RequireRenegotiationdefaults totrueon constructionFresh sessions do not start in a "fully-negotiated" state —
RequireRenegotiationistruefrom the constructor. It only flips tofalseafter a successfulSetRemoteDescription. Tests that assumefalseby default fail.RTPSessionCreateOfferUnitTest.Offer_LeavesRequireRenegotiationTrueAfterPriorAnswerCleared3.
CreateAnswerconnection-address selection does OS routingGetSdpConnectionAddressfor an answer prefers (in order): relay endpoint, STUN srflx endpoint, OS-routed local address toward the offer's c= line, then the supplied fallback. So the c= line in an answer is usually NOT the address the caller passed in — it is whatever the OS picks to reach the offer IP.The supplied address is only used when the offer connection is
IPAddress.Any(the classic hold form).RTPSessionCreateAnswerUnitTest.Answer_AlwaysHasPopulatedConnectionAddressRTPSessionCreateAnswerUnitTest.Answer_OfferConnectionIsAny_UsesSuppliedFallbackAddress4.
SetRemoteDescriptionauto-injects a default telephone-event into local audio capabilitiesIf the local track has no
telephone-eventcapability of its own,SetRemoteDescriptionaddsMediaStream.DefaultRTPEventFormatto the local track capabilities — even when the remote did not advertise telephone-event either. Every successful audio negotiation ends up with a telephone-event entry on the local capabilities list.RTPSessionCodecMatchingUnitTest.NoTelephoneEventOnEitherSide_StillInjectsDefaultRTPSession.SetRemoteDescriptionlines ~1232-12475. SDES negotiation is two-sided;
SetRemoteDescriptionalone does not complete itWith
UseSdpCryptoNegotiation = true,SetRemoteDescriptiononly sets up the remote half of the SRTP key material.MediaStream.IsSecurityContextReady()staysfalseuntilCreateAnswerruns and publishes the local crypto attributes. The full negotiation only completes after both calls.RTPSessionSdesCryptoNegotiationUnitTest.CompatibleCryptoSuite_AfterFullOfferAnswer_SetsSecurityContextSetRemoteDescription— should the API doc make this explicit, or shouldSetRemoteDescriptioncomplete it eagerly?6.
CreateAnswerthrows when answering an SDES-mode offer withm= port=0+ no cryptoSetRemoteDescriptioncleanly accepts an offer where one m-line is rejected (port=0) without a crypto attribute — the explicit port=0 guard in the SDES branch lets it through. But the subsequentCreateAnswerthrowsSystem.ApplicationExceptionwith"Error creating crypto attribute for SDP answer. No compatible offer."fromGetSessionDescription(around line 2206) because the answer path has no matching skip for the rejected m-line.RTPSessionSdesCryptoNegotiationUnitTest.RejectedMediaPortZeroWithoutCrypto_SetRemoteDescriptionReturnsOk(passes)RTPSessionSdesCryptoNegotiationUnitTest.RejectedMediaPortZeroWithoutCrypto_CreateAnswerThrows(currently characterises the throw)SetRemoteDescriptionto reject these earlier, or fixCreateAnswerto emitm=video 0cleanly. Either is a behaviour change.7. Unknown RTP header extensions are silently dropped at parse time
The SDP parser only adds an
a=extmap:N <URI>entry to the announcement HeaderExtensions dictionary if<URI>is one of the four currently-known URIs:http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time(AbsSendTime)urn:ietf:params:rtp-hdrext:ssrc-audio-level(AudioLevel, audio-only)urn:3gpp:video-orientation(CVO, video-only)http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01(TransportWideCC)A remote offer with
toffset,playout-delay,mid(the RTP header extensionurn:ietf:params:rtp-hdrext:sdes:mid), or anything else is silently dropped. The application has no way to see what was in the offer that we could not handle.RTCPeerConnectionHeaderExtensionUnitTest.SetRemoteDescription_UnknownExtensionUri_SilentlyDroppedSDP.csaround theMEDIA_EXTENSION_MAP_ATTRIBUE_PREFIXparse,RTPHeaderExtension.GetRTPHeaderExtensionswitch8. Header-extension ID conflicts: answer uses the REMOTE ID
When local and remote both have the same extension URI but use different
extmapIDs, the answer emits the extension at the remote ID — the local ID is mutated to match via the_rtpExtensionsUsedregistry.RTCPeerConnectionHeaderExtensionUnitTest.Answer_ExtensionIdConflict_AnswerUsesRemoteIdRTPHeaderExtensionobject Id field is mutated as a side effect. Worth knowing during refactor.9.
AnnouncementVersiononly increments onSetMediaStreamStatusPer RFC 3264 §8, the SDP
o=session-version SHOULD increment whenever the description changes. The current implementation only bumpsm_sdpAnnouncementVersioninsideSetMediaStreamStatus. CallingCreateOfferrepeatedly without other changes produces SDPs with identical version numbers. Adding tracks, modifying capabilities, or anything else does not bump it either.RTPSessionRenegotiationAdvancedUnitTest.TwoOffersWithoutChanges_AnnouncementVersionDoesNotIncrementRTPSessionRenegotiationAdvancedUnitTest.SetMediaStreamStatusAudio_IncrementsAnnouncementVersionCreateOffer, or every observable mutation, or preserve current behaviour. Some SIP B2BUAs are version-sensitive when matching re-INVITEs.10.
LocalTrackdirection-mirroring is asymmetricSetLocalTrackStreamStatus(called duringSetRemoteDescription) only adjusts the local track direction in these cases:InactiveInactivec=IN IP4 0.0.0.0(Any address, port != 9)Inactivem=… 0 …(port 0)Inactivesendonly/recvonly/sendrecvDefaultStreamStatus)So if remote announces
sendonly, local does NOT mirror torecvonly— it stays atSendRecv. This is the asymmetric behaviour: local only goes Inactive in response to specific "remote unavailable" signals, never coerced into a complementary direction.Additionally: a local track that was previously
Inactiveis automatically restored toDefaultStreamStatusat the top ofSetLocalTrackStreamStatuswhen the next remote offer is non-inactive. So holds and rejected-then-reactivated media restore cleanly.RTPSessionRenegotiationAdvancedUnitTest.RemoteSendOnlyOffer_LocalDirectionStaysAtDefaultRTPSessionRenegotiationAdvancedUnitTest.HoldThenResume_LocalTrackRestoredToDefaultRTPSessionRenegotiationAdvancedUnitTest.RejectedMediaThenReactivated_LocalTrackRestoredsendonly→ localrecvonly) would be a behaviour change. Callers that explicitly drive direction viaSetMediaStreamStatusmay rely on the local staying at the configured default.Suggested process for the refactor: when the negotiation extraction starts, work through this checklist and for each entry add a note to the PR description ("preserved" / "fixed to X / Y rationale"). That way reviewers (and future readers) can see each quirk was a conscious choice rather than an oversight.
Filed by Claude Code (Opus 4.7) based on test findings from PRs #1622, #1624, #1625, #1626, #1627, #1628 and in-flight Category 5 + 7 branches.