Simulcast: getRids orders layers by a=rid line order, not the a=simulcast preference list (RFC 8853 §5.2)
Summary
getRids (sdp.go) determines the order of simulcast streams from the order in which the a=rid attribute lines appear, and consults a=simulcast only to flag ~-prefixed paused streams. Per RFC 8853 §5.2, it's the a=simulcast send list — not the a=rid line order — that expresses the stream preference order:
The order of the listed simulcast streams in the "send" direction suggests a proposed order of preference, in decreasing order
So when a peer lists its a=rid lines in a different order than its a=simulcast:send list, pion follows the a=rid order.
Where
getRids(sdp.go) appends onesimulcastRidpera=ridattribute in document order; thea=simulcastvalue is scanned only to setpausedon~-prefixed ids. It is never used to order the slice.- That order then flows to:
trackDetailsToRTPReceiveParameters(sdp.go) —encodings[i].RID = trackDetails.rids[i], i.e. the receiver's encoding order ==a=ridline order.addTransceiverSDPanswer emission — iteratesmediaSection.ridsemittinga=rid:<id> recvanda=simulcast:recv <joined>in that same order.
Reproduce
An offer whose a=rid lines are in the opposite order to a=simulcast:send:
a=rid:q send
a=rid:h send
a=rid:f send
a=simulcast:send f;h;qgetRids returns [q, h, f] (a=rid order); the a=simulcast preference is [f, h, q]. A table test (included in the patch below) fails today:
Expected: [f h q]
Actual: [q h f]Impact
Latent in practice. Browsers (Firefox 152, Chrome) emit a=rid and a=simulcast in the same order, and runtime demux is by RID string, so media isn't broken. It affects the order of RTPReceiveParameters.Encodings / Tracks() and the a=simulcast:recv preference list echoed back in the answer — i.e. pion doesn't convey the offerer's stated preference order back to it.
To be clear about normative strength: this is a SHOULD-level alignment, not a spec violation. §5.2 "suggests" the order (non-normative wording), and §5.3.2 only mandates reversing send↔recv (SHALL) and forbids adding streams (MUST NOT) — it does not itself require preserving order. Mirroring the offered preference back is simply the correct, interoperable behavior.
Proposed fix
Stable-sort the rids by their first position in the a=simulcast attribute; any rid absent from it keeps its a=rid declaration order at the end. The patch below builds, is go vet / gofmt clean, and the new test fails before / passes after:
sdp.go + sdp_test.go)diff --git a/sdp.go b/sdp.go
index 1525f049..96d1d1b0 100644
--- a/sdp.go
+++ b/sdp.go
@@ -6,9 +6,11 @@
package webrtc
import (
+ "cmp"
"encoding/base64"
"errors"
"fmt"
+ "math"
"net/url"
"regexp"
"slices"
@@ -298,10 +300,17 @@ func getRids(media *sdp.MediaDescription) []*simulcastRid { // nolint:cyclop
if space := strings.Index(simulcastAttr, " "); space > 0 {
simulcastAttr = simulcastAttr[space+1:]
}
+ // RFC 8853 §5.2: the a=simulcast send list "suggests a proposed order of
+ // preference, in decreasing order"; the order of the a=rid lines is not
+ // significant. Record that order and sort the rids by it below so a
+ // generated answer mirrors the offered preference instead of the
+ // incidental a=rid line order.
+ simulcastOrder := map[string]int{}
ridStates := strings.SplitSeq(simulcastAttr, ";")
for ridState := range ridStates {
+ ridID := ridState
if len(ridState) > 0 && ridState[:1] == "~" {
- ridID := ridState[1:]
+ ridID = ridState[1:]
for _, rid := range rids {
if rid.id == ridID {
rid.paused = true
@@ -310,7 +319,20 @@ func getRids(media *sdp.MediaDescription) []*simulcastRid { // nolint:cyclop
}
}
}
+ if _, ok := simulcastOrder[ridID]; !ok {
+ simulcastOrder[ridID] = len(simulcastOrder)
+ }
}
+ orderOf := func(id string) int {
+ if idx, ok := simulcastOrder[id]; ok {
+ return idx
+ }
+
+ return math.MaxInt
+ }
+ slices.SortStableFunc(rids, func(a, b *simulcastRid) int {
+ return cmp.Compare(orderOf(a.id), orderOf(b.id))
+ })
}
return rids
diff --git a/sdp_test.go b/sdp_test.go
index 540ecde7..4798bfc3 100644
--- a/sdp_test.go
+++ b/sdp_test.go
@@ -1306,6 +1306,33 @@ func TestGetRIDs(t *testing.T) {
}
}
+// TestGetRIDsOrderFollowsSimulcast verifies that getRids orders the simulcast
+// streams by the a=simulcast attribute, not by the order of the a=rid lines.
+// RFC 8853 §5.2: the a=simulcast send list "suggests a proposed order of
+// preference, in decreasing order"; the order of the a=rid lines is not
+// significant. Here the a=rid lines are listed in the opposite order to
+// a=simulcast:send, so the result must follow the attribute (f, h, q).
+func TestGetRIDsOrderFollowsSimulcast(t *testing.T) {
+ media := &sdp.MediaDescription{
+ MediaName: sdp.MediaName{
+ Media: "video",
+ },
+ Attributes: []sdp.Attribute{
+ {Key: sdpAttributeRid, Value: "q send"},
+ {Key: sdpAttributeRid, Value: "h send"},
+ {Key: sdpAttributeRid, Value: "f send"},
+ {Key: sdpAttributeSimulcast, Value: "send f;h;q"},
+ },
+ }
+
+ got := make([]string, 0, 3)
+ for _, rid := range getRids(media) {
+ got = append(got, rid.id)
+ }
+
+ assert.Equal(t, []string{"f", "h", "q"}, got)
+}
+
// TestGetRIDs_EmptySimulcastTokens verifies that getRids does not panic when theI'm happy to open this as a PR if you'd like the change — just wanted to check whether you'd take it (and whether there was a deliberate reason for the current a=rid ordering) before sending one.
Context: found while auditing the sans-io Rust port (webrtc-rs/rtc), which had the identical behavior; I've prepared the equivalent fix there.
Source: pion/webrtc