Repository navigation
Description
- Issue reported in libwebrtc: https://issues.webrtc.org/issues/576082210
- Ticket in header extensions api: always attempt to negotiate mandatory header extensions w3c/webrtc-extensions#262
Issue
The problem seems that chrome is handing us back a header-extension list it then refuses to accept.
Two behaviours in recent libwebrtc, both gated on the WebRTC-HeaderExtensionNegotiateMemory field trial, which is enabled by default (the code checks IsDisabled):
-
RtpTransceiver::OnNegotiationUpdate()rewritesheader_extensions_to_negotiate_after every applied offer/answer, throughModifyCapabilitiesAccordingToHeaders(). That helper flips tokStoppedevery extension that wasn't in the applied m= section — includingurn:ietf:params:rtp-hdrext:sdes:mid. -
RtpTransmissionManager::CreateAndAddTransceiver()no longer seeds a new transceiver from media-engine defaults. It copies the list from the first non-stopping transceiver of the same media type:
for (const auto& transceiver : transceivers()->List()) {
if (transceiver->media_type() == media_type && !transceiver->stopping()) {
header_extensions = transceiver->GetHeaderExtensionsToNegotiate();
break;
}
}Meanwhile RtpTransceiver::SetHeaderExtensionsToNegotiate() rejects the whole list if mid isn't exactly sendrecv:
if (IsMandatoryHeaderExtension(extension.uri) &&
extension.direction != RtpTransceiverDirection::kSendRecv) {
return RTCError::InvalidModification() << "Attempted to stop a mandatory extension.";
}Note it rejects any direction other than sendrecv — there's no exemption for the no-op case where mid was already stopped. So chrome writes mid: stopped into that list itself, and then won't take it back.
Our side of it is that applyForcedRtpExtensions() does a full read-modify-write: it reads the entire capability list, sets abs-capture-time to sendrecv, and submits the whole list again — resubmitting the mid: stopped entry it never intended to touch. Once one live audio transceiver is poisoned, (2) makes every subsequent addTransceiver('audio') inherit it, which is why every retry failed identically rather than recovering.
Two things on our side create the precondition, both worth a look:
RemoteSdp.closeMediaSection()refuses to close_firstMidand only disables it, sostopSending()skipstransceiver.stop()— that leaves a live, non-stopping audio transceiver, exactly the one the inheritance loop picks.RemoteAnswerMediaSectiononly emits ana=extmapif the URI is also present inofferMediaObject.ext, andofferMediaObjectislocalSdpObject.media[mediaSectionIdx.idx], withidxcomputed beforeaddTransceiver()/setLocalDescription(). With recycled m-lines that index can land on a rejected m= line that has noextat all, so the answer negotiates zero extensions for that section.
Beyond swallowing the error, I think the real fix in applyForcedRtpExtensions() is to stop round-tripping entries we don't own: only touch the URIs present in forcedRtpExtensions, never submit a direction other than sendrecv for mid, and keep the try/catch — forcing a preferred extension is best-effort and should never be able to fail produce().
The following patch removes the error itself, but we may need to look it further:
diff --git a/src/handlers/ortc/utils.ts b/src/handlers/ortc/utils.ts
index 33c98c6..be50f65 100644
--- a/src/handlers/ortc/utils.ts
+++ b/src/handlers/ortc/utils.ts
@@ -7,6 +7,17 @@ import type {
const logger = new Logger('ortc/utils');
+/**
+ * RTP header extensions that are mandatory to negotiate (since BUNDLE is always
+ * offered) and hence cannot be disabled via the RTP header extension control
+ * API.
+ *
+ * @see https://www.rfc-editor.org/rfc/rfc8834#name-header-extensions
+ */
+const MandatoryRtpExtensionUris: Set<RtpHeaderExtensionUri> = new Set([
+ 'urn:ietf:params:rtp-hdrext:sdes:mid',
+]);
+
/**
* This function adds RTCP NACK support for OPUS codec in given capabilities.
*/
@@ -81,6 +92,24 @@ export function applyForcedRtpExtensions(
extenCap.direction = 'stopped';
}
+ // Never give back a direction other than 'sendrecv' for a mandatory
+ // extension.
+ //
+ // NOTE: This is not about us wanting to disable them (we don't) but about
+ // the value that the browser itself reported in
+ // getHeaderExtensionsToNegotiate(). libwebrtc overwrites its "extensions
+ // to negotiate" after every applied offer and answer by marking as
+ // 'stopped' every extension not present in the applied m= section (with no
+ // exemption for mandatory ones), and new transceivers inherit that list
+ // from existing non stopped transceivers of the same kind. However
+ // setHeaderExtensionsToNegotiate() rejects the whole given list with
+ // InvalidModificationError "Attempted to stop a mandatory extension" if a
+ // mandatory extension is not 'sendrecv', so just echoing back what we were
+ // given would make this (best effort) API throw.
+ if (MandatoryRtpExtensionUris.has(uri)) {
+ extenCap.direction = 'sendrecv';
+ }
+
return extenCap;
}
);EDIT: No reproducible in mediasoup-demo as is. In order to reproduce with mediasoup-client we need 2 audio producers running and then a trigger that closes/re-creates the producers again.
Standalone STR:
Run it with --force-fieldtrials="WebRTC-HeaderExtensionNegotiateMemory/Enabled and then --force-fieldtrials="WebRTC-HeaderExtensionNegotiateMemory/Disabled. It'll fail with the first one only.
<!doctype html>
<meta charset="utf-8">
<title>getHeaderExtensionsToNegotiate returns a value setHeaderExtensionsToNegotiate rejects</title>
<pre id="o"></pre>
<script>
const MID = 'urn:ietf:params:rtp-hdrext:sdes:mid';
const log = (...a) => (o.textContent += a.join(' ') + '\n');
const dir = t => t.getHeaderExtensionsToNegotiate().find(e => e.uri === MID).direction;
function roundTrip(t) {
try {
t.setHeaderExtensionsToNegotiate(t.getHeaderExtensionsToNegotiate());
return 'OK';
} catch (e) {
return `THREW -> ${e.name}: ${e.message}`;
}
}
(async () => {
log(navigator.userAgent + '\n');
const pc1 = new RTCPeerConnection();
const pc2 = new RTCPeerConnection();
const t1 = pc1.addTransceiver('audio', { direction: 'sendonly' });
log(`1. new transceiver mid=${dir(t1)} round-trip: ${roundTrip(t1)}`);
// Negotiate. The answer does not negotiate the mid extension,
// exactly as an SFU generating its own SDP may legitimately do.
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription({
type: 'answer',
sdp: answer.sdp.split(/\r\n|\n/).filter(l => !l.includes(MID)).join('\r\n'),
});
log(`2. after negotiation mid=${dir(t1)} round-trip: ${roundTrip(t1)}`);
const t2 = pc1.addTransceiver('audio', { direction: 'sendonly' });
log(`3. brand new, unused mid=${dir(t2)} round-trip: ${roundTrip(t2)}`);
pc1.close(); pc2.close();
})().catch(e => log('FAILED: ' + e));
</script>
Activity
[-]Failed to execute 'setHeaderExtensionsToNegotiate' on 'RTCRtpTransceiver': Attempted to stop a mandatory extension.[/-][+]Failed to execute 'setHeaderExtensionsToNegotiate' on 'RTCRtpTransceiver': Attempted to stop a mandatory extension[/+]Workaround to avoid "Failed to execute 'setHeaderExtensionsToNegotiat…