Skip to content

feat: Add in-place ICE restart of the JVB session - #3083

Merged
bgrozev merged 8 commits into
masterfrom
in-place-ice-restart
Aug 13, 2026
Merged

feat: Add in-place ICE restart of the JVB session#3083
bgrozev merged 8 commits into
masterfrom
in-place-ice-restart

Conversation

@bgrozev

@bgrozev bgrozev commented Aug 6, 2026

Copy link
Copy Markdown
Member

Implements the client side of an in-place ICE restart of the JVB session. Instead of tearing the session down and being re-invited, the client applies a new set of bridge ICE credentials in place, keeping the same DTLS session, SSRCs and RTP state so media keeps flowing throughout.

Flow

  1. JitsiConference.restartJvbIce(reason) asks jicofo for a restart with a Jingle session-info carrying <bridge-session ice-restart="true"/>, and resolves when the IQ is acked.
  2. The bridge rotates its credentials and jicofo relays the new transport back as a transport-info tagged with an ice-generation. strophe.jingle.js routes any such transport-info to JingleSessionPC.onBridgeIceRestartTransport().
  3. That runs as a single atomic modification-queue task: monotonic generation guard, then a patched offer built from the current remote description with the new ufrag/pwd swapped in and every candidate line stripped, then setRemoteDescription / createAnswer / setLocalDescription, then the bridge's candidates added back via addIceCandidate(), then our own new credentials signalled back tagged with the same generation.

The client stays the answerer throughout and uses a real createAnswer(). Since jicofo already drives renegotiation with offer-shaped Jingle IQs in production, no new offerer machinery is needed, and the whole restart becomes a local operation with no signaling round trip on the critical path. Measured against a real bridge, the queue is held for 85ms.

Why the candidates are stripped and re-added

Applying the new candidates in the same setRemoteDescription() as the new credentials makes libwebrtc stamp them with the new ICE generation (candidate lines carry no credentials of their own), treat them as brand new candidates for the same remote address, and synchronously tear down the selected candidate pair, which defeats the make-before-break this feature exists for. Trickling them in after the offer/answer cycle avoids it.

Reported upstream as https://issues.webrtc.org/issues/543082385

Gating and fallback

Gated on the enableIceRestart config flag. The existing reactive path is unchanged: _restartJvbIceWithFallback() still falls back to the legacy full session restart via IceFailedHandling when an in-place restart is rejected or does not recover the connection. APP.conference._room.restartJvbIce() triggers one manually.

A 3s/100ms media-stats sampler is available behind config.testing.debugIceRestart, off by default.

Tests

15 new tests. SDPUtil.replaceIceCredentialsAndStripCandidates is covered for credential replacement, candidate stripping, byte-for-byte preservation of every other line, and both line-separator conventions. onBridgeIceRestartTransport is covered for the request element shape, the generation guard (duplicate, older, invalid, and newer-after-a-failure), and for addIceCandidate happening strictly after setLocalDescription, asserted via jasmine invocation order.

Part of a multi-repo change; all PRs share the in-place-ice-restart branch name so CI tests them together:

bgrozev added 4 commits August 6, 2026 13:21
SDPUtil.replaceIceCredentialsAndStripCandidates() rewrites every
a=ice-ufrag/a=ice-pwd line and removes every a=candidate and
a=end-of-candidates line, preserving the rest of the SDP (and its line
separators) verbatim.

This is the transformation an in-place ICE restart needs to build the
patched remote offer with: applying the new candidates in the same
setRemoteDescription() as the new credentials makes libwebrtc treat them
as brand new candidates for the same remote address and tear down the
selected pair, so they have to be trickled in afterwards instead.
JitsiConference.restartJvbIce() / JingleSessionPC.restartIce() ask Jicofo
to restart ICE for this endpoint by sending a Jingle 'session-info' with
a <bridge-session ice-restart="true"/>. The bridge creates a NEW ICE
agent with fresh credentials while the old one keeps carrying media
(make-before-break) and its transport is relayed back to us as a
'transport-info' whose <transport> is tagged with an 'ice-generation'.

strophe.jingle routes such a transport-info to
JingleSessionPC.onBridgeIceRestartTransport(), which applies it as a
single modification queue task: drop anything whose generation is not
newer than the last one applied, build a patched offer from the current
remote description with the new credentials and no candidates, apply it,
answer it with a real createAnswer() (the client stays the answerer, so
there is no signalling round trip on the critical path), and only then
trickle in the bridge's new candidates. Adding those candidates as part
of the offer instead makes libwebrtc destroy and rebuild the selected
candidate pair synchronously, which is the media freeze the whole design
exists to avoid.

The local ICE credentials the browser rotates to while answering are
signalled back with a 'transport-info' carrying the same generation; the
bridge is the controlling agent and needs them for its connectivity
checks.

Gated by the enableIceRestart config option. Wired into the reactive ICE
failure path as well: an ICE failure attempts an in-place restart first
and falls back to the legacy session restart if the request is rejected
or ICE does not recover in time.

Includes a short-lived media stats sampler for diagnosing restarts,
enabled with the testing.debugIceRestart config option. Every stage logs
under the '[ice-restart]' prefix with the ICE generation.
Covers the shape of the 'session-info' restart request, the offer/answer
ordering (candidates added only after setLocalDescription), the
generation tagging of the local credentials sent back, and the monotonic
generation guard against duplicate, reordered and malformed pushes.
The reason an in-place ICE restart has to strip the candidates from the
patched offer and trickle them in after the offer/answer cycle is a
libwebrtc behaviour we reported upstream:

https://issues.webrtc.org/issues/543082385
bgrozev added 2 commits August 6, 2026 15:31
A restart that is going to work completes well inside a second, so 15s of
a broken connection before falling back to a session restart was far more
than the happy path needs.

It also matters for the unhappy path: the bridge answers a restart request
it will not honour with a plain response carrying no transport, and that is
not propagated back to the client, so the restart never lands and this
timeout is the only thing that notices. That case is not exotic - a bridge
with videobridge.ice.restart.enabled=false, or one whose transport is not
established yet, hits it - and it happens throughout a rollout where clients
have the feature enabled and only some bridges do. At 15s this made recovery
from an ICE failure slower than it was before the feature existed.

5s keeps ample headroom for a slow network while staying under the bridge's
own restart timeout (10s by default), so the client stops waiting before the
bridge gives up rather than after.

Also log the recovery check being armed and its outcome, so the fallback
decision is visible either way.
This reverts commit 630f3e5.

Shortening the timeout only narrowed the window in which a restart the
bridge never applied goes unnoticed. Making jicofo react to that directly
addresses it instead, so the client-side timeout stays as it was.
Comment thread modules/xmpp/JingleSessionPC.ts Outdated

const answer = await this.peerconnection.createAnswer(this.mediaConstraints as RTCOfferOptions);

await this.peerconnection.setLocalDescription(answer);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you call this._renegotiate({ sdp: patchedOffer, type: 'offer' }) instead of calling sRD/cA/sLD on the native pc? This will also take care of signaling the SSRCs if the browser produces a different set of SSRCs after this operation.

The offer/answer cycle was driven by calling setRemoteDescription on the
native peer connection and createAnswer/setLocalDescription on
TraceablePeerConnection. That split left TPC's own view of the session
stale, suppressed the REMOTE_UFRAG_CHANGED event, and - the reason this
matters - skipped notifyMySSRCUpdate, so any SSRCs the browser regenerated
while answering the restart offer were never signalled.

Go through _renegotiate() instead, which is the path the rest of the class
uses. The original reason for bypassing it, that the patched SDP is already
the transformed remote description and must not go through the pipeline
twice, does not hold: the no-argument _renegotiate() feeds
peerconnection.remoteDescription straight back into
TraceablePeerConnection.setRemoteDescription and is used that way
throughout.

The tests mocked the native setRemoteDescription, so they move to the TPC
one and the mock gains the remoteDescription getter _renegotiate() reads.
jitsi-ci Bot pushed a commit to jitsi/jitsi-pr-tests-pages that referenced this pull request Aug 10, 2026
jitsi-ci Bot pushed a commit to jitsi/jitsi-pr-tests-pages that referenced this pull request Aug 13, 2026
@bgrozev
bgrozev merged commit 28875bf into master Aug 13, 2026
3 checks passed
@bgrozev
bgrozev deleted the in-place-ice-restart branch August 13, 2026 21:22
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.

2 participants