Skip to content

feat: Support in-place ICE restart with a new Agent - #2438

Merged
bgrozev merged 21 commits into
masterfrom
in-place-ice-restart
Aug 17, 2026
Merged

feat: Support in-place ICE restart with a new Agent#2438
bgrozev merged 21 commits into
masterfrom
in-place-ice-restart

Conversation

@bgrozev

@bgrozev bgrozev commented Aug 6, 2026

Copy link
Copy Markdown
Member

Implements the bridge side of an in-place ICE restart. When a client's network changes, the current recovery is a full session teardown and re-invite. This adds a lighter path that keeps the same DTLS session, SSRCs and RTP state, so media keeps flowing throughout.

On an ice-restart request from jicofo the bridge creates a new ICE agent with fresh credentials and returns its transport, while the existing agent keeps carrying media. Only once the new agent connects does the bridge cut over, freeing the old one after a transition window so late checks from the old generation are still answered. If the new agent does not connect, the restart is abandoned and the established agent is kept, so a failed restart never breaks a working transport.

How it works

  • IceTransport is restructured around an AgentBundle (agent + stream + component + listeners + generation). currentBundle is what we send on and describe; pendingBundle is a restart in flight.
  • requestIceRestart() creates the pending bundle but deliberately does not start connectivity checks. The bridge is the controlling agent, and the checks it sends are authenticated with the peer's password, so it waits for the peer's new credentials first. Checks arriving from the peer in the meantime are queued by ice4j and replayed when establishment starts.
  • applyRemoteCredentialsToPendingRestart() runs when a transport update arrives with a pending bundle: it matches the ice-generation, applies the credentials, and starts checks.
  • On the pending bundle reaching COMPLETED, cutOver() swaps it in and frees the old bundle after videobridge.ice.restart.transition-window.
  • On failure or timeout, abandonPendingRestart() frees the pending bundle and keeps the established one.

The colibri WebSocket password is pinned to the initial agent (first commit). The ICE password doubles as the WebSocket auth token baked into the URL the client holds, and the client re-dials that URL on exactly the network change that triggers a restart, so it must not rotate with the ICE credentials.

Config

  • videobridge.ice.restart.enabled (default true)
  • videobridge.ice.restart.transition-window (default 2s)
  • videobridge.ice.restart.timeout (default 10s)

Metrics: ice_restarts_started, _succeeded, _failed, _rejected.

Notes

No ice4j change is needed: this creates a fresh agent rather than re-arming an existing one.

A rejected restart is soft — it logs, bumps ice_restarts_rejected and returns no <transport>, rather than failing the whole conference-modify and taking other endpoints' updates with it.

Verified end-to-end against a real client: cut-over 656ms after the request, with outbound video non-zero in every 100ms sample across the cut-over, zero freezes and zero dropped frames.

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

bgrozev added 2 commits August 6, 2026 13:21
IceTransport.icePassword was the local ICE password read live off the
ice4j Agent, and it doubled as the auth token baked into the colibri
WebSocket URL handed to the peer. Those are separate concerns: the peer
holds that URL and re-dials it on every WebSocket reconnect, so the
value must stay fixed for the lifetime of the transport.

Rename it to webSocketPassword and pin it at construction. No behaviour
change today (there is exactly one Agent per transport), but it stops
the WebSocket URL from moving once the ICE credentials can rotate.
When an endpoint's network changes, jicofo can now ask the bridge for an
in-place ICE restart instead of tearing down and re-inviting the whole
session. The bridge answers with a second ice4j Agent while the
established one keeps carrying media, and cuts over once the new one
connects (make-before-break).

The trigger is explicit: the colibri2 <transport ice-restart="true"/>
attribute, read in Colibri2ConferenceHandler. The bridge does not infer a
restart from a changed remote ufrag.

The sequence, per endpoint:

  1. On the request, create a pending Agent with freshly rotated local
     credentials (RFC 8445 section 9) and the next ice-generation.
     Connectivity checks are deliberately NOT started: we are the
     controlling agent, so our checks need the endpoint's new remote
     credentials to build their USERNAME and MESSAGE-INTEGRITY. The new
     Agent does answer incoming checks in the meantime - ice4j starts its
     check server when the component is created and queues pre-RUNNING
     checks.
  2. Return the pending Agent's transport (candidates, credentials and
     ice-generation) in the conference-modified, so jicofo can relay it
     to the endpoint. describe() prefers the pending bundle for this.
  3. When the endpoint's own new credentials arrive over the existing
     transport-info path, match them to the pending bundle by
     ice-generation, apply them, and start connectivity checks then.
  4. On COMPLETED, cut over: the pending bundle becomes current. The old
     one is freed after a transition window so old-generation checks
     still in flight are answered rather than dropped.
  5. On failure or timeout, abandon the pending bundle and keep the
     established Agent. The transport does not fail.

IceTransport is restructured around a private AgentBundle (agent, stream,
component and listeners) with a current/pending pair guarded by a lock.
send() always uses the current bundle. Only the current bundle drives
transport-level state, so a retired or pending Agent can no longer report
writeability, refresh consent, or fail the transport.

Gated by videobridge.ice.restart.enabled (default true); a request is
rejected and logged when the feature is disabled or the transport is not
established yet. New config: videobridge.ice.restart.transition-window
(2s) and .timeout (10s). New metrics: ice_restarts_started, _succeeded,
_failed and _rejected.

Requires the colibri2 ice-restart and Jingle ice-generation attributes
from jitsi-xmpp-extensions; the pom dependency will be bumped once those
are released.
Picks up the ice-generation and ice-restart attributes used by the in-place
ICE restart.
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.62025% with 96 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.29%. Comparing base (5380ebc) to head (00c5d51).

Files with missing lines Patch % Lines
...rg/jitsi/videobridge/transport/ice/IceTransport.kt 74.11% 44 Missing and 29 partials ⚠️
.../videobridge/colibri2/Colibri2ConferenceHandler.kt 0.00% 16 Missing ⚠️
.../src/main/kotlin/org/jitsi/videobridge/Endpoint.kt 20.00% 4 Missing ⚠️
...c/main/kotlin/org/jitsi/videobridge/relay/Relay.kt 25.00% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##             master    #2438      +/-   ##
============================================
+ Coverage     48.85%   49.29%   +0.44%     
- Complexity     2798     2838      +40     
============================================
  Files           381      382       +1     
  Lines         22674    22923     +249     
  Branches       3458     3507      +49     
============================================
+ Hits          11077    11301     +224     
+ Misses        10417    10402      -15     
- Partials       1180     1220      +40     
Files with missing lines Coverage Δ
...main/kotlin/org/jitsi/videobridge/ice/IceConfig.kt 100.00% <100.00%> (ø)
.../videobridge/websocket/ColibriWebSocketPassword.kt 100.00% <100.00%> (ø)
...c/main/kotlin/org/jitsi/videobridge/relay/Relay.kt 20.41% <25.00%> (+0.19%) ⬆️
.../src/main/kotlin/org/jitsi/videobridge/Endpoint.kt 24.31% <20.00%> (+0.14%) ⬆️
.../videobridge/colibri2/Colibri2ConferenceHandler.kt 1.09% <0.00%> (-0.03%) ⬇️
...rg/jitsi/videobridge/transport/ice/IceTransport.kt 57.84% <74.11%> (+34.14%) ⬆️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5380ebc...00c5d51. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

jitsi-ci Bot pushed a commit to jitsi/jitsi-pr-tests-pages that referenced this pull request Aug 10, 2026
The WebSocket password was pinned to the initial ICE Agent's local
password. Now that it no longer rotates with the ICE credentials, it is
not an ICE value at all, and IceTransport has no reason to own it.

Generate it independently in Endpoint and Relay, which own the WebSocket
it authenticates. The peer treats the URL as opaque: lib-jitsi-meet
stores the url attribute of the signaled <web-socket> element and
re-dials that string, jicofo only strips the element, and a remote
bridge uses the signaled URL as-is. So the value can be any URL-safe
random string.

Also stop logging the expected password on a mismatch.
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/colibri2/Colibri2ConferenceHandler.kt Outdated
/**
* Whether the ice4j [Agent]s created by this transport take the 'controlling' role.
*/
private val controlling = controlling

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.

In general it's possible for the controlling role to differ across different ICE restarts. I think we've structured things so that's not the case but would be a good idea to leave this flexible? How hard would it be?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving this as it is for now. The role is fixed for the lifetime of the transport: every AgentBundle reads the same field, and nothing in colibri2 signals a per-restart role today, so a settable role would be unreachable. It stays easy to change if we ever need it -- make the field a var and set it before the bundle is created.

Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
Comment thread jvb/src/main/resources/reference.conf Outdated

@JonathanLennox JonathanLennox left a comment

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.

Note: this review was generated by AI (Claude Code), from reading this PR against the pinned ice4j 3.2-15-g6da2b08 sources and the jicofo / lib-jitsi-meet sides of the change. It has not been validated by running the code, so please treat each point as something to check rather than as established fact.

The overall design holds up well. The generation counter really does handle overlapping restarts and reordered colibri2 responses; the pendingBundle !== bundle identity guards make a double free() impossible on every path I traced (timeout vs. FAILED vs. supersede vs. cutover); and removing the listeners before agent.free() correctly suppresses the spurious RUNNING -> TERMINATED "failure". The comments below are about things around that core.

The two I'd consider blocking are the inline notes on cutOver's demultiplexing rationale (ice4j routes by remote address, not by ufrag, which makes the restart quietly dependent on the peer's 5-tuple changing) and on applyRemoteCredentialsToPendingRestart, where one untagged <transport> can poison a restart round irrecoverably.

Two further points that don't attach to a specific line:

No prompt recovery from an abandoned restart. An established ice4j Agent never transitions to FAILED -- ConnectivityCheckClient.updateCheckListAndTimerStates bails out on getState().isEstablished() -- so consent-freshness failure only stops refreshing lastIceActivityInstant. After abandonPendingRestart keeps the old Agent, iceTransport.hasFailed() stays false and Endpoint.shouldExpire() falls through to the inactivity path: entity-expiration.timeout is 1 minute with a 1-minute check-interval, i.e. up to ~2 minutes of a live endpoint carrying no media. Real recovery therefore rests entirely on the client noticing its own ICE failure and falling back to session-terminate restart="true". Worth stating explicitly in the PR description, and it's an argument for surfacing the abandonment to jicofo rather than only bumping a counter.

No tests. jvb/src/test has nothing covering IceTransport, and this adds a real state machine (supersede, abandon-vs-cutover, generation mismatch, late credentials). clock is already injected; the ice4j Agent is the obstacle. It would be worth covering at least the pure transition logic if it can be prised apart from Agent construction.

Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/ice/IceConfig.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/colibri2/Colibri2ConferenceHandler.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/ice/IceConfig.kt
Comment thread jvb/src/main/kotlin/org/jitsi/videobridge/transport/ice/IceTransport.kt Outdated
jitsi-ci Bot pushed a commit to jitsi/jitsi-pr-tests-pages that referenced this pull request Aug 13, 2026
bgrozev added 14 commits August 13, 2026 16:49
While a restart was pending, every transport update was applied to the
pending Agent. The peer stamps ice-generation on the restart answer and
on nothing else, so an untagged update is an ordinary transport update
(a trickled candidate, or an old one still in flight). Applying it to
the pending Agent gave that Agent the peer's old password, started its
checks with credentials the peer rejects, and consumed the round, so
the real credentials were later dropped as a repeat and the restart
burned its full timeout.

Route untagged updates to the established bundle, and claim the round
(checksStarted) together with the credentials under the lock, so an
unusable update can no longer consume it.

A generation mismatch is now logged without incrementing
ice_restarts_rejected: it is the normal outcome of a superseded round,
not a rejected request.
An ICE restart request for a transport that has not connected yet was
answered with no transport, which is the signal jicofo uses to escalate
to a full re-invite. That is too heavy here: there is no media to
preserve, the endpoint only needs to keep the Agent it is already
checking against.

Return a three-way result instead of a boolean. A bridge that can not
restart ICE at all (the feature is disabled, or the transport is
stopped) still signals no transport, so a full re-invite follows. A
transport that is merely not established yet now signals its unchanged
credentials, so the endpoint keeps the connection it has.
Agent.createComponent() declares IOException and BindException. Kotlin
does not force us to handle them, so a harvest or bind failure
propagated out of requestIceRestart() into handleColibri2Endpoint(),
where no catch clause matches it. That fails the whole
conference-modify and takes every other endpoint's updates with it,
which is what the soft-rejection design exists to avoid. It also
leaked: an exception from createComponent() left the Agent and its
stream unreferenced but not freed, so the ufrag stayed in the single
port harvester for the life of the process.

Move the construction of the ice4j objects into createAgentBundle(),
which catches, frees whatever it already created, and returns null. A
restart that can not create its Agent now keeps the established one.

Create the new Agent before superseding an in-flight restart, so a
failure leaves that restart alone instead of freeing a bundle the peer
may already be checking against.
A second request for a restart that had not started its checks yet
rolled a new generation, freed the pending bundle the peer may already
have been checking against, discarded its progress and restarted the
timeout. Clients do fire duplicate network-change events.

Re-describe the pending bundle instead. A request that arrives after
the checks have started is a new round and still supersedes.
SCHEDULED_POOL is a single thread shared by the whole bridge, and
Agent.free() shuts down the StunStack, closes sockets and joins
threads. The other two free sites already hop to IO_POOL.
stop() collected only the current and pending bundles. A bundle inside
its transition window is neither: it is reachable only from the task
that frees it, so it outlived the transport until that task fired. With
the default 2 second window this heals itself, but it scales with the
configured window. stop() also left both scheduled tasks to run.

Hold the retiring bundle and the two tasks in fields, so stop() frees
everything and cancels both. A second cutover inside a transition
window now frees the bundle retiring from the first one instead of
letting two old Agents linger.

AgentBundle.free() is idempotent, so a task that fires anyway (it can
already have started when it is cancelled) does not free twice.
A superseded restart incremented ice_restarts_started and then nothing
else, so started never reconciled with succeeded plus failed. Add
ice_restarts_superseded for it.

ice_restarts_rejected no longer doubles as a count of stale-generation
credential drops (that stopped in an earlier commit), so it now counts
exactly the requests for which no new Agent was created.
A zero or negative videobridge.ice.restart.timeout scheduled the
abandon task to fire immediately, after describe() had already handed
the pending credentials to the endpoint. The endpoint was then left
addressing its checks to an Agent that had been freed.

Treat a non-positive timeout the same as the feature being disabled, so
the endpoint falls back to a full re-invite.
describe() stamped the generation whenever the bundle had one, so after
a cutover the established bundle's transport carried a generation that
the endpoint has already seen and would reject as not newer. Nothing
hit this yet, because the only caller describes a transport on create
or on a restart, but it is a trap for the next caller. It also matters
now that a restart which keeps the existing Agent describes that Agent.
requestIceRestart() rotates the credentials we advertise and starts the
restart timeout, and describe() then prefers the pending Agent. Both
ran before the rest of the endpoint update, so an IqProcessingException
raised later in the same request (the visitor-sources check) left the
bridge advertising credentials the endpoint never received, diverting
its transport updates to a bundle that can not connect, for the whole
timeout.

Move the restart handling to the end of handleColibri2Endpoint(), after
everything that can throw.
The comments said the two Agents accept each other's generation of
connectivity checks and that ice4j routes each check to the right Agent
by its local ufrag. It does not: the single port harvester looks the
remote address up in its socket map first and parses the ufrag only for
an address it has never seen.

What the transition window actually does is answer the endpoint's
old-generation checks, which arrive from the old address and so reach
the old Agent's socket. State that, note that both Agents keep
accepting media as well as checks, and record the assumption this
leaves on the restart path: the new Agent is only reachable because the
endpoint's address changes.
The contract was only written down on the jicofo side: a refusal is the
absence of a <transport> in the conference-modified for that endpoint,
not an error, so that one endpoint's rejected request does not fail the
whole conference-modify.
The restart timeout covers the whole signaling round trip, the
endpoint's re-gathering and ICE itself, on an endpoint whose network
has just changed, so 10 seconds was tight. Raise it to 20, and the
transition window from 2 seconds to 3.
Take the creation of the ice4j Agent through a factory, so a test can
supply a mock. Real Agents bind ports, start threads and only change
state by running ICE against a peer, none of which a test of this state
machine needs.

The tests cover which Agent is described, which one gets the peer's
credentials, and which ones are freed: the not-established and disabled
and non-positive-timeout outcomes, a repeated request, a superseded
round, credentials tagged with another generation, an untagged update,
repeated credentials, cutover, abandonment, stop(), and a failure to
create the Agent.

The tests of the four fixes that changed behaviour silently (the
untagged update, the repeated request, the generation stamped after a
cutover, and stop() freeing a retiring Agent) were checked by reverting
each fix and watching the test fail.
@bgrozev

bgrozev commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

(Also partially AI generated)
Thanks -- all of the review points are addressed, one commit each (0afc79e5..ed6aa44f). The two you flagged as blocking:

  • Demultiplexing. Confirmed against the pinned ice4j: AbstractUdpListener looks the remote address up in its socket map first and parses the ufrag only for an address it has not seen, and ConnectivityCheckServer drops on a local-ufrag mismatch. The comments claimed the opposite, so they are corrected in ba946889, along with the assumption this leaves on the restart path: the new Agent is reachable only because the endpoint's address changes. Making it work for an unchanged address needs the socket mapping to be handed between Agents, which is an ice4j change, so it is not in this PR. An endpoint that keeps its address gets the ordinary failed-restart outcome: the restart is abandoned and the established Agent is kept.
  • An untagged <transport> poisoning a restart round. Real and reachable: lib-jitsi-meet stamps ice-generation on the restart answer only, so an untagged update is an ordinary one. Fixed in 0afc79e5: untagged updates go to the established bundle, and the round is claimed together with the credentials rather than before them.

On the two points that did not attach to a line:

  • No prompt recovery from an abandoned restart. Correct. Surfacing the abandonment to jicofo needs a bridge-initiated colibri2 message, which we do not have at all today, so that is future work. Recovery stays with the endpoint for now.
  • No tests. Added in ed6aa44f. The ice4j Agent is created through a factory now, so the tests drive the state machine with mock Agents: request outcomes, a repeated request, supersede, generation matching, untagged updates, cutover, abandonment, stop(), and a failure to create the Agent. The four fixes that changed behaviour silently were each checked by reverting the fix and watching the test fail.

One behaviour change worth calling out, from the comment about rejections triggering a reconnect: a restart requested before ICE is established no longer answers with no <transport>. It answers with the established transport unchanged, so the endpoint keeps the connection it has instead of escalating to a re-invite. A bridge that cannot restart at all (disabled, stopped, or a non-positive restart.timeout) still answers with no transport and still escalates.

jitsi-ci Bot pushed a commit to jitsi/jitsi-pr-tests-pages that referenced this pull request Aug 13, 2026

@JonathanLennox JonathanLennox left a comment

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.

Note: this review was generated by AI (Claude Code), like the previous one. I re-read all 15 commits and ran the new test class in a worktree: 22 tests, all passing, BUILD SUCCESS. As before, please treat each point as something to check rather than as established fact.

Every point from the earlier review looks addressed, and several of the fixes go further than what was raised:

  • Demux -- corrected in cutOver, requestIceRestart, IceConfig and reference.conf, and requestIceRestart's KDoc now states the consequence outright (a peer whose address does not change cannot connect the new Agent, and the restart is abandoned). Documenting rather than fixing seems like the right call here.
  • Untagged updates -- both halves fixed: only generation-tagged updates route to the pending bundle, and the checksStarted claim moved inside the lock.
  • Arming order -- the restart block moved past everything in handleColibri2Endpoint that can throw, with a comment saying why.
  • Timeouts, free() on the scheduled pool, stop() missing the retiring bundle, Agent-creation failure, idempotency, metrics, timeout validation, the describe() generation stamp -- all fixed. The new task bookkeeping (restartTimeoutTask / transitionWindowTask, each with an "unless it's already over" re-check after scheduling) holds up under every interleaving I could trace, and making free() idempotent covers the new stop()-vs-transition-task overlap that retiringBundle introduces.

The colibri WebSocket password rework goes beyond what was raised -- decoupling it from ICE entirely rather than just pinning it, and dropping the expected secret from the failure log, which was a small real leak.

A few things left, inline below. The only one I'd call a defect is the credential/CAS ordering in applyRemoteCredentialsToPendingRestart; the rest are questions and test coverage.

One further note that doesn't attach to a line: createAgentBundle still harvests candidates while holding restartLock, which stop() and cutOver contend for. The comment explains the choice (a failure then leaves an in-flight restart alone), and it looks like a fair trade -- just flagging that binding a port now happens under a lock on the cutover path.

)
return
}
pending.stream.remoteUfrag = ufrag

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.

A duplicate update is applied, then reported as ignored.

pending.stream.remoteUfrag = ufrag
pending.stream.remotePassword = password
if (!pending.checksStarted.compareAndSet(false, true)) { ...ignoring a repeated transport update... ; return }

A second tagged update for the same generation overwrites the remote credentials of an Agent whose checks are already running -- ice4j reads stream.getRemotePassword() live in Agent.getRemoteKey, so this changes the credentials used for both outgoing checks and validating incoming ones -- while the log line says it was ignored.

Everything that can make an update unusable is already checked before this point (null ufrag/pwd above, wrong generation above, no longer pending inside the lock), so the concern in the comment above is satisfied with the CAS moved before the two assignments. That also makes the behaviour match the log message.

// for one that kept the existing Agent (its unchanged credentials, so the endpoint keeps the connection
// it has, with no re-invite). Only IceRestartResult.UNAVAILABLE signals nothing.
if (c2endpoint.create || iceRestartResult == IceRestartResult.STARTED ||
iceRestartResult == IceRestartResult.KEEP_EXISTING

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.

KEEP_EXISTING is signalled as a transport the client discards -- worth confirming the far end agrees.

The transport described here for KEEP_EXISTING carries no ice-generation (correct -- describe() only stamps a pending bundle). Following it through jicofo's ColibriV2SessionManager.endpointIceRestarted:

  • It passes the staleness guard, then hits participantInfo.lastRelayedIceGeneration = generation unconditionally, which resets the high-water mark to GENERATION_UNSPECIFIED and loses staleness protection for the next reordered response.
  • jicofo counts it as a relayed restart, so IceRestartMetrics will over-count in-place restarts that never happened.
  • It is relayed to the client, whose generation guard then drops it -- which is what actually implements "keep what you have". The lib-jitsi-meet tests cover a non-numeric generation and 0; worth confirming an absent attribute takes the same path.

The reasoning behind KEEP_EXISTING itself looks right for the not-established case: the client has not rotated anything, so the initial Agent can still pick up its new address peer-reflexively. It's just that "keep existing" is currently expressed as "send something the peer ignores" rather than signalled explicitly.

// The established Agent is untouched and still carrying media, so keep using it.
logger.warn("Not restarting ICE: failed to create the new Agent. Keeping the existing one.")
iceRestartsRejected.inc()
return IceRestartResult.KEEP_EXISTING

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.

This is the other caller of KEEP_EXISTING, and it seems less clear-cut than the not-established one. Failing to create an Agent is a real resource problem (a bind failure, exhausted ports), and answering with the established transport deliberately bypasses jicofo's new "re-invite the participant if the bridge declines" fallback -- the endpoint's only remaining recovery is its own ICE failure detection. UNAVAILABLE would escalate immediately, which for a resource failure may be what you want. Genuinely a judgement call, but worth being deliberate about, since the two paths returning the same value have quite different causes.

* The total number of ICE restart requests for which no new Agent was created: the feature is
* disabled, the transport is not running, or it is not established yet.
*/
val iceRestartsRejected = VideobridgeMetricsContainer.instance.registerCounter(

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.

Now that KEEP_EXISTING exists, this counter merges two quite different events: the routine "not established yet" case (normal traffic, the endpoint keeps a working session) and a failure to create the Agent (a resource problem you would want to alert on). Splitting them, or at least noting the difference in the help text, would make the metric actionable.

iceGeneration shouldBe IceUdpTransportPacketExtension.GENERATION_UNSPECIFIED
}
}
should("free the old Agent only after the transition window") {

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.

This asserts only the "not yet" half -- that the old Agent survives the cutover. Neither timer path is exercised anywhere in the class: the transition window elapsing (which frees the bundle, clears retiringBundle and hops to the IO pool) and the restart timeout firing (abandonPendingRestart via the scheduled task, as opposed to the FAILED route that is covered) both go untested.

Both are reachable: TaskPools.SCHEDULED_POOL is a public mutable field and VideobridgeTest already substitutes a fake executor for exactly this. Given that this is where the new task-cancellation bookkeeping lives, it seems like the part most worth pinning down.

val password = "password-$index"

var state: IceProcessingState = IceProcessingState.WAITING
var freed = false

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.

freed is written from the IO pool (free() is submitted there) and spun on from the test thread in awaitTrue, with no synchronisation and no happens-before. It will almost always be observed promptly on x86, but it can in principle spin out the full 5 s and fail spuriously in CI. @Volatile here (and on created's usage, if an Agent is ever created off the test thread) would make it sound.

}
should("supersede a restart whose checks have started") {
transport.requestIceRestart()
transport.startConnectivityEstablishment(remoteTransport(generation = 1))

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.

Nothing in the class covers the ordinary, non-restart path through startConnectivityEstablishment -- every call is inside With ICE established, where the current bundle short-circuits on "Connection already established". Since the routing at the top of that method was restructured for this PR, a leaf under Before ICE is established asserting that an untagged update still reaches the initial Agent (sets its remote credentials, starts its checks) would guard the main negotiation path against a future change to the pending-bundle condition.

transport.describe().ufrag shouldBe initial.ufrag
awaitTrue { agents.created[1].freed }
}
should("not restart the transport's own failure handling") {

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.

Reads like it is missing a word -- "not trigger the transport's own failure handling"?

A repeated transport update for the generation of a restart whose checks
are already running overwrote the remote credentials of that Agent, and
then logged that it had ignored the update. ice4j reads the credentials
off the stream every time it signs a connectivity check or validates an
incoming one (ConnectivityCheckServer.getRemoteKey), so the overwrite
took effect immediately.

Claim the round before applying the credentials rather than after.
Everything that can make an update unusable is checked before this
point, so claiming first can not burn the round on an update we then
reject, which is what the previous order was for.
Failing to create the Agent is a resource problem: ice4j could not
harvest or bind. Keeping the endpoint on the established transport in
that case bypassed the re-invite fallback and left it with nothing but
its own ICE failure detection, even though it had just told us its
network changed. Answer UNAVAILABLE instead, so the endpoint is
re-invited. KEEP_EXISTING now means only "the transport is not
established yet", which is a routine outcome.

The two are counted separately, along with the requests this bridge
rejects outright, because they call for different responses:
ice_restarts_rejected is configuration, ice_restarts_not_established is
ordinary traffic, and ice_restarts_agent_creation_failed is worth
alerting on.
Neither scheduled task was exercised, which is where the task
cancellation bookkeeping lives. Substitute a FakeScheduledExecutorService
for TaskPools.SCHEDULED_POOL, as other tests here do, and cover the
transition window elapsing, the timeout abandoning a restart, a cutover
cancelling the timeout, and stop() cancelling both tasks. The last two
were checked by removing the cancellations and watching them fail.

Also cover the ordinary, non-restart path through
startConnectivityEstablishment, which nothing reached because every
existing case had the transport already established. It guards the
routing that now sits in front of it.

Make FakeAgent.freed @volatile: it is written on the IO pool and spun on
from the test thread. Fix the name of the failure-handling test.
jitsi-ci Bot pushed a commit to jitsi/jitsi-pr-tests-pages that referenced this pull request Aug 17, 2026
@bgrozev
bgrozev merged commit c0b9458 into master Aug 17, 2026
6 checks passed
@bgrozev
bgrozev deleted the in-place-ice-restart branch August 17, 2026 22:02
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