Skip to content

fix(webrtc): Fix Firefox simulcast race condition and respect HPB bandwidth limits#17775

Closed
tareko wants to merge 1 commit intonextcloud:mainfrom
tareko:fix/17774/firefox-simulcast-race
Closed

fix(webrtc): Fix Firefox simulcast race condition and respect HPB bandwidth limits#17775
tareko wants to merge 1 commit intonextcloud:mainfrom
tareko:fix/17774/firefox-simulcast-race

Conversation

@tareko
Copy link
Copy Markdown

@tareko tareko commented Apr 23, 2026

Title

Firefox simulcast: setParameters() race condition causes failed simulcast initialization on low-bandwidth connections

Description

When using Firefox with the High Performance Backend (HPB / MCU) and simulcast enabled, the publisher side initializes simulcast encodings via sender.setParameters(). However, the returned Promise is not awaited before createOffer() is called. This creates a race condition where the offer SDP may be generated before the simulcast parameters are actually applied to the sender.

On low-bandwidth connections, this is catastrophic: if simulcast fails to initialize, Firefox falls back to a single high-bitrate stream. Combined with hardcoded maxBitrates (high: 900000, medium: 300000, low: 100000) that ignore the signaling server's configured maxstreambitrate, the publisher saturates its uplink. This causes severe packet loss, ICE connection failures, and a constant disconnect/reconnect loop for subscribers.

Steps to Reproduce

  1. Use Firefox as the client browser.
  2. Connect to a room served by the HPB (Standalone Signaling Server + Janus) with simulcast enabled.
  3. Throttle the client's uplink to < 1 Mbps (e.g., using Firefox DevTools Network tab or a low-bandwidth network).
  4. Join a call with video enabled.
  5. Observe the console logs and connection state.

Expected Behavior

  • Simulcast encodings (rid: h/m/l) should be fully applied before the offer is created.
  • The publisher should respect the signaling server's maxstreambitrate limit instead of using hardcoded values.
  • The connection should remain stable; if bandwidth is insufficient, the MCU should gracefully switch to a lower simulcast layer.

Actual Behavior

  • The connection repeatedly disconnects and reconnects.
  • Firefox console shows ICE state cycling through checkingconnecteddisconnectedfailed.
  • The MCU requests new offers every 10 seconds (requestOffer loop in webrtc.js).
  • about:webrtc shows packet loss spiking before each disconnect.

Root Cause Analysis

1. setParameters() race condition in peer.js

In src/utils/webrtc/simplewebrtc/peer.js, the offer() method:

if (sendVideo && this.enableSimulcast && adapter.browserDetails.browser === 'firefox') {
    const sender = this.pc.getSenders().find(function(s) {
        return s.track && s.track.kind === 'video'
    })
    if (sender) {
        let parameters = sender.getParameters()
        parameters.encodings = [
            { rid: 'h', active: true, maxBitrate: this.maxBitrates.high },
            { rid: 'm', active: true, maxBitrate: this.maxBitrates.medium, scaleResolutionDownBy: 2 },
            { rid: 'l', active: true, maxBitrate: this.maxBitrates.low, scaleResolutionDownBy: 4 },
        ]
        sender.setParameters(parameters)  // <-- Promise returned but NOT awaited
    }
}
this.pc.createOffer(options).then(function(offer) {
    // ...
})

sender.setParameters() returns a Promise. Because it is not chained or awaited, createOffer() runs immediately in the next tick, potentially before the simulcast encodings are committed by Firefox.

2. Hardcoded maxBitrates ignoring server bandwidth limit

In src/utils/webrtc/simplewebrtc/simplewebrtc.js:

maxBitrates: {
    high: 900000,
    medium: 300000,
    low: 100000,
},

The Standalone Signaling Server already sends room.bandwidth.maxstreambitrate in the room join response (see server/hub.go and api/signaling.go), but the frontend ignores it. When the server is configured with a limit lower than ~1.3 Mbps (the sum of the hardcoded layers), the publisher still tries to push the full hardcoded bitrate, congesting the link.

Proposed Fix

Fix 1: Await setParameters() before creating the offer

In src/utils/webrtc/simplewebrtc/peer.js, restructure the offer() method so that createOffer() is only called after sender.setParameters() resolves:

Peer.prototype.offer = function(options) {
    const sendVideo = this.sendVideoIfAvailable && this.type !== 'screen'
    const self = this

    const createOffer = function() {
        self.pc.createOffer(options).then(function(offer) {
            // ... existing simulcast SDP munging for Chrome/Safari ...
            self.pc.setLocalDescription(offer).then(function() {
                // ...
            })
        })
    }

    if (sendVideo && this.enableSimulcast && adapter.browserDetails.browser === 'firefox') {
        const sender = this.pc.getSenders().find(function(s) {
            return s.track && s.track.kind === 'video'
        })
        if (sender) {
            let parameters = sender.getParameters()
            if (!parameters) {
                parameters = {}
            }
            parameters.encodings = [
                { rid: 'h', active: true, maxBitrate: this.maxBitrates.high },
                { rid: 'm', active: true, maxBitrate: this.maxBitrates.medium, scaleResolutionDownBy: 2 },
                { rid: 'l', active: true, maxBitrate: this.maxBitrates.low, scaleResolutionDownBy: 4 },
            ]
            sender.setParameters(parameters).then(createOffer).catch(function(error) {
                console.warn('Failed to set simulcast parameters, falling back to normal offer', error)
                createOffer()
            })
            return
        }
    }

    createOffer()
}

Fix 2: Calculate maxBitrates dynamically from the signaling server's limit

In src/utils/webrtc/simplewebrtc/simplewebrtc.js, replace the hardcoded maxBitrates with a function that derives values from opts.connection.maxStreamBits:

function splitBandwidthIntegersOmitRemainder(totalBps = 1048576) {
    if (typeof totalBps !== 'number' || totalBps < 0) {
        totalBps = 1048576
    }
    const partValue = Math.floor(totalBps / 21 / 100) * 100
    return {
        low: partValue,
        medium: partValue * 4,
        high: partValue * 16,
    }
}

// Then in the constructor:
maxBitrates: splitBandwidthIntegersOmitRemainder(opts.connection?.maxStreamBits),

And in src/utils/signaling.js, store the value from the room join response:

// In Signaling.Standalone.prototype.joinResponseReceived
if (data.room?.bandwidth?.maxstreambitrate) {
    const totalBps = data.room.bandwidth.maxstreambitrate
    if (typeof totalBps === 'number' && totalBps > 0) {
        this.maxStreamBits = totalBps
    }
}

Environment

  • Nextcloud Talk version: Latest main branch (post-31)
  • Browser: Firefox (any recent version, e.g., 135+)
  • Signaling server: nextcloud-spreed-signaling with Janus MCU, simulcast enabled
  • Network condition: Low bandwidth (< 1 Mbps uplink)

Related Code

  • spreed/src/utils/webrtc/simplewebrtc/peer.jsPeer.prototype.offer()
  • spreed/src/utils/webrtc/simplewebrtc/simplewebrtc.jsmaxBitrates configuration
  • spreed/src/utils/signaling.jsjoinResponseReceived()
  • nextcloud-spreed-signaling/server/hub.goprocessRoom() (sends Bandwidth in room response)
  • nextcloud-spreed-signaling/api/signaling.goRoomBandwidth.MaxStreamBitrate

Severity

High — causes completely unusable video calls in Firefox for users on constrained networks.

…dwidth limits

- Chain sender.setParameters() before createOffer() in Firefox to avoid
  a race where the offer SDP is generated before simulcast encodings are
  applied.  Previously the promise was ignored, causing simulcast to fail
  silently on low-bandwidth links and fall back to a single high-bitrate
  stream that congested the connection.

- Add _getMaxBitrates() helper that derives simulcast tiers from the
  signaling server's maxstreambitrate instead of hardcoded values.
  The server already sent this limit in the room join response but the
  client never used it, so publishers always tried to push 1.3 Mbps even
  when the uplink or server policy was much lower.

- Parse data.room.bandwidth.maxstreambitrate in joinResponseReceived and
  expose it on the signaling connection so Peer objects can read it.

- Guard s.track before accessing s.track.kind in getSenders() find,
  matching the existing upstream fix for disabled tracks in Firefox.

Fixes nextcloud#17774

Signed-off-by: Tarek Loubani <tarek@tarek.org>
@tareko
Copy link
Copy Markdown
Author

tareko commented Apr 23, 2026

Closing to reopen from nextcloud/spreed branch instead of fork.

@tareko tareko closed this Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant