nextcloud / nextcloud/spreed

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

Open
#17,774 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug Needs triage
Dominant language
JavaScript
Stars
2.2k
Forks
586
Avg merge
18h 27m
Merged PRs (30d)
333

Description

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. Please note that I used OpenCode with Kimi 2.6 and Qwen 3.6 35B3A to help me find, debug, and fix this issue.

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.

Talk app

Talk app version: from main branch on github

Custom Signaling server configured: yes, version 69e616b8ae1f3ffd96fb25a02c1935a429c4c519~docker

Custom TURN server configured: yes

Custom STUN server configured: yes

Browser

Microphone available: yes

Camera available: yes

Operating system: GNU/Linux Ubuntu 25.10

Browser name: Firefox

Browser version: 149

Browser log
``` Insert your browser log here, this could for example include: a) The javascript console log b) The network log c) ... ```

Server configuration

Operating system: Ubuntu
Web server: Nginx

Database: MySQL

PHP version: 8.3

Nextcloud Version: 33.0.2

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with Peer.prototype.offer() in src/utils/webrtc/simplewebrtc/peer.js, then trace maxBitrates in src/utils/webrtc/simplewebrtc/simplewebrtc.js and joinResponseReceived() in src/utils/signaling.js. Check the related bandwidth fields in server/hub.go and api/signaling.go. Done means Firefox applies simulcast before offer creation, respects the signaling limit, and remains stable on constrained uplinks.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
audio-video-rtc, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.