Skip to content

Failed to execute 'setHeaderExtensionsToNegotiate' on 'RTCRtpTransceiver': Attempted to stop a mandatory extension #394

Description

@jmillan

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):

  1. RtpTransceiver::OnNegotiationUpdate() rewrites header_extensions_to_negotiate_ after every applied offer/answer, through ModifyCapabilitiesAccordingToHeaders(). That helper flips to kStopped every extension that wasn't in the applied m= section — including urn:ietf:params:rtp-hdrext:sdes:mid.

  2. 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 _firstMid and only disables it, so stopSending() skips transceiver.stop() — that leaves a live, non-stopping audio transceiver, exactly the one the inheritance loop picks.
  • RemoteAnswerMediaSection only emits an a=extmap if the URI is also present in offerMediaObject.ext, and offerMediaObject is localSdpObject.media[mediaSectionIdx.idx], with idx computed before addTransceiver()/setLocalDescription(). With recycled m-lines that index can land on a rejected m= line that has no ext at 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

  1. changed the title [-]Failed to execute 'setHeaderExtensionsToNegotiate' on 'RTCRtpTransceiver': Attempted to stop a mandatory extension.[/-] [+]Failed to execute 'setHeaderExtensionsToNegotiate' on 'RTCRtpTransceiver': Attempted to stop a mandatory extension[/+] on Oct 21, 2026
  2. added theissue type on Oct 21, 2026
  3. added this to the v3 updates milestone on Oct 21, 2026
  4. added a commit that references this issue on Oct 23, 2026
    ebbb03c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions