Skip to content

SDP negotiation: quirks surfaced by characterization-test pass #1630

@sipsorcery

Description

@sipsorcery

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions