diff --git a/src/main/java/org/ice4j/ice/Agent.java b/src/main/java/org/ice4j/ice/Agent.java
index b8e20915..ae1c0371 100644
--- a/src/main/java/org/ice4j/ice/Agent.java
+++ b/src/main/java/org/ice4j/ice/Agent.java
@@ -759,6 +759,75 @@ public void startConnectivityEstablishment()
}
}
+ /**
+ * Performs an in-place ICE restart on this already-established agent: re-runs
+ * connectivity checks against (possibly new) remote credentials/candidates
+ * while keeping the same local credentials and, crucially, keeping the
+ * currently selected pair in use for sending media until a new pair is
+ * nominated (make-before-break).
+ *
+ * This is the peer-driven counterpart to creating a brand new agent for an
+ * ICE restart. The caller is expected to have already applied the new remote
+ * ufrag/password (via {@link IceMediaStream#setRemoteUfrag(String)} /
+ * {@link IceMediaStream#setRemotePassword(String)}) and any signalled remote
+ * candidates before calling this method; new peer-reflexive remote addresses
+ * are also discovered from the incoming checks as usual.
+ *
+ * Concretely this: cancels any pending (or already fired) termination so the
+ * agent is not torn down; re-arms the connectivity check client (whose
+ * {@code stop()} on termination had disabled it); resets each stream (clears
+ * the valid list so the stale nominee no longer blocks a fresh nomination,
+ * moves the check list back to RUNNING, and flags each component so the first
+ * pair nominated during the restart replaces the selected pair); rebuilds the
+ * check lists; moves the agent back to {@link IceProcessingState#RUNNING};
+ * and starts the checks.
+ */
+ public void restartIce()
+ {
+ synchronized (startLock)
+ {
+ logger.info("Restarting ICE (in-place) on the existing agent.");
+
+ // Cancel any pending/completed termination so the agent (and its
+ // check client, which terminate() stops) is not torn down.
+ synchronized (terminationFutureSyncRoot)
+ {
+ if (terminationFuture != null)
+ {
+ terminationFuture.cancel(true);
+ terminationFuture = null;
+ }
+ }
+
+ shutdown = false;
+
+ // Re-enable the check client (terminate() had stopped it).
+ connCheckClient.restart();
+
+ // Reset each stream for the restart (keeps the selected pair).
+ for (IceMediaStream stream : getStreams())
+ {
+ stream.restart();
+ }
+
+ try
+ {
+ initCheckLists();
+ }
+ catch (ArithmeticException e)
+ {
+ setState(IceProcessingState.FAILED);
+ return;
+ }
+
+ //change state before we actually send checks so that we don't
+ //miss responses and hence the possibility to nominate a pair.
+ setState(IceProcessingState.RUNNING);
+
+ connCheckClient.startChecks();
+ }
+ }
+
/**
* Free()s and removes from this agent components or entire streams
* if they do not contain remote candidates. A possible reason for this
diff --git a/src/main/java/org/ice4j/ice/CheckList.java b/src/main/java/org/ice4j/ice/CheckList.java
index 0993344f..ce5cf1be 100644
--- a/src/main/java/org/ice4j/ice/CheckList.java
+++ b/src/main/java/org/ice4j/ice/CheckList.java
@@ -130,6 +130,26 @@ protected void setState(CheckListState newState)
fireStateChange(oldState, newState);
}
+ /**
+ * Resets this check list so that connectivity checks can be run again on it
+ * as part of an in-place ICE restart (see {@link Agent#restartIce()}). Moves
+ * the state back to {@link CheckListState#RUNNING}, clears the
+ * {@code paceMakerStarted} latch so a new {@code PaceMaker} can be scheduled
+ * and empties the triggered-check queue. The pairs themselves are rebuilt by
+ * {@link IceMediaStream#initCheckList()}; this method does not touch them.
+ */
+ protected void restart()
+ {
+ paceMakerStarted.set(false);
+
+ synchronized (triggeredCheckQueue)
+ {
+ triggeredCheckQueue.clear();
+ }
+
+ setState(CheckListState.RUNNING);
+ }
+
/**
* Adds pair to the local triggered check queue unless it's already
* there. Additionally, the method sets the pair's state to {@link
@@ -406,13 +426,24 @@ protected synchronized void handleNominationConfirmed(
if (cmp.getSelectedPair() != null)
{
- return;
+ // Normally nomination is set-once. During an in-place ICE restart we
+ // deliberately keep the old selected pair in use for sending until a
+ // new pair is nominated (make-before-break), and then swap to it here.
+ if (!cmp.isIceRestarting())
+ {
+ return;
+ }
+ logger.info("Swapping selected pair for stream " + cmp.toShortString()
+ + " after ICE restart: " + nominatedPair.toRedactedShortString());
+ cmp.setIceRestarting(false);
+ }
+ else
+ {
+ logger.info(
+ "Selected pair for stream " + cmp.toShortString() + ": "
+ + nominatedPair.toRedactedShortString());
}
- logger.info(
- "Selected pair for stream " + cmp.toShortString() + ": "
- + nominatedPair.toRedactedShortString());
-
cmp.setSelectedPair(nominatedPair);
Iterator pairsIter = iterator();
diff --git a/src/main/java/org/ice4j/ice/Component.java b/src/main/java/org/ice4j/ice/Component.java
index e5bd66c6..47490b12 100644
--- a/src/main/java/org/ice4j/ice/Component.java
+++ b/src/main/java/org/ice4j/ice/Component.java
@@ -109,6 +109,17 @@ public class Component
*/
private CandidatePair selectedPair;
+ /**
+ * Whether an in-place ICE restart (see {@link Agent#restartIce()}) is
+ * currently in progress for this component. While {@code true} the existing
+ * {@link #selectedPair} is kept in use for sending media (make-before-break),
+ * and the normal set-once nomination guard in
+ * {@link CheckList#handleNominationConfirmed(CandidatePair)} is relaxed so
+ * that the first pair nominated during the restart replaces the selected
+ * pair. Reset to {@code false} as soon as that swap happens.
+ */
+ private volatile boolean iceRestarting = false;
+
/**
* The default RemoteCandidate for this component or in other
* words, the candidate that we would have used to communicate with the
@@ -1055,6 +1066,26 @@ public CandidatePair getSelectedPair()
return selectedPair;
}
+ /**
+ * @return whether an in-place ICE restart is currently in progress for this
+ * component. See {@link #iceRestarting}.
+ */
+ protected boolean isIceRestarting()
+ {
+ return iceRestarting;
+ }
+
+ /**
+ * Sets whether an in-place ICE restart is in progress for this component.
+ * See {@link #iceRestarting}.
+ *
+ * @param iceRestarting the new value.
+ */
+ protected void setIceRestarting(boolean iceRestarting)
+ {
+ this.iceRestarting = iceRestarting;
+ }
+
/**
* Returns a human readable name that can be used in debug logs associated
* with this component.
diff --git a/src/main/java/org/ice4j/ice/ConnectivityCheckClient.java b/src/main/java/org/ice4j/ice/ConnectivityCheckClient.java
index bc3c9573..041fd4ed 100644
--- a/src/main/java/org/ice4j/ice/ConnectivityCheckClient.java
+++ b/src/main/java/org/ice4j/ice/ConnectivityCheckClient.java
@@ -743,7 +743,12 @@ private void processSuccessResponse(StunResponseEvent ev)
if (parentAgent.isControlling()
&& request.containsAttribute(Attribute.USE_CANDIDATE))
{
- if (validPair.getParentComponent().getSelectedPair() == null)
+ // Normally nomination is confirmed only once, while there is no selected pair yet. During an
+ // in-place ICE restart we keep the old selected pair in use (make-before-break), so we must also
+ // confirm the nomination of the new pair while a selected pair still exists;
+ // handleNominationConfirmed() then swaps the selected pair over.
+ if (validPair.getParentComponent().getSelectedPair() == null
+ || validPair.getParentComponent().isIceRestarting())
{
logger.info("Nomination confirmed for pair: "
+ validPair.toRedactedShortString()
@@ -1042,4 +1047,17 @@ public boolean isStopped() {
return stopped;
}
}
+
+ /**
+ * Clears the {@code stopped} flag so that checks can be started again after a
+ * previous {@link #stop()}, as part of an in-place ICE restart (see
+ * {@link Agent#restartIce()}). {@link #stop()} left {@code stopped == true}
+ * and removed all {@link PaceMaker}s; a subsequent {@link #startChecks()}
+ * recreates them. Must be called before {@code startChecks()}.
+ */
+ void restart() {
+ synchronized (paceMakers) {
+ stopped = false;
+ }
+ }
}
diff --git a/src/main/java/org/ice4j/ice/IceMediaStream.java b/src/main/java/org/ice4j/ice/IceMediaStream.java
index 6ac0f496..f98b4976 100644
--- a/src/main/java/org/ice4j/ice/IceMediaStream.java
+++ b/src/main/java/org/ice4j/ice/IceMediaStream.java
@@ -368,6 +368,36 @@ protected void initCheckList()
}
}
+ /**
+ * Prepares this stream for an in-place ICE restart (see
+ * {@link Agent#restartIce()}). Clears the valid list (so the stale nominee
+ * from the previous run no longer blocks a fresh nomination) and resets the
+ * check list state back to {@link CheckListState#RUNNING}, and marks each
+ * component as ICE-restarting so that the first pair nominated during the
+ * restart replaces the currently selected pair. The currently selected pair
+ * is intentionally left in place so media keeps flowing on it until the new
+ * pair is nominated (make-before-break). The caller is expected to have set
+ * the new remote credentials/candidates, and to rebuild the check list pairs
+ * via {@link #initCheckList()} afterwards.
+ */
+ protected void restart()
+ {
+ synchronized (validList)
+ {
+ validList.clear();
+ }
+
+ for (Component component : getComponents())
+ {
+ if (component.getSelectedPair() != null)
+ {
+ component.setIceRestarting(true);
+ }
+ }
+
+ checkList.restart();
+ }
+
/**
* Creates and adds to checkList all the CandidatePairs
* in all Components of this stream.
diff --git a/src/test/java/org/ice4j/ice/IceRestartTest.java b/src/test/java/org/ice4j/ice/IceRestartTest.java
new file mode 100644
index 00000000..2ff474a2
--- /dev/null
+++ b/src/test/java/org/ice4j/ice/IceRestartTest.java
@@ -0,0 +1,207 @@
+/*
+ * ice4j, the OpenSource Java Solution for NAT and Firewall Traversal.
+ *
+ * Copyright @ 2024 - present 8x8, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.ice4j.ice;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.ice4j.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests {@link Agent#restartIce()} — the in-place ICE restart on an existing,
+ * already-concluded agent. This exercises the state-machine re-arm (un-terminate,
+ * reset the check list, un-stop the check client, re-nominate) and, crucially,
+ * verifies that the previously selected pair is kept in use throughout so media
+ * never has to stop (make-before-break).
+ *
+ * The two agents connect over loopback using host candidates only (no STUN/UPnP,
+ * so the test is hermetic). Because an {@link Agent}'s local credentials are
+ * immutable, the "remote" side keeps its credentials across the restart; this is
+ * sufficient to drive the full re-arm/re-validate/re-nominate path — the point of
+ * the primitive is the state-machine reset, which is credential-independent.
+ */
+public class IceRestartTest
+{
+ private Agent localAgent;
+ private Agent remotePeer;
+
+ @BeforeEach
+ public void setUp()
+ {
+ // Terminate a few seconds after completion: long enough that, once both
+ // (ice4j) agents restart, they both re-complete before either re-enters
+ // the terminated state (where its check client stops and would reject the
+ // peer's in-flight checks), short enough to keep the test quick. In the
+ // real (flavor B) scenario the peer is libwebrtc and never "terminates",
+ // so this coupling does not exist.
+ System.setProperty("org.ice4j.TERMINATION_DELAY", "3000");
+ }
+
+ @AfterEach
+ public void tearDown()
+ {
+ System.clearProperty("org.ice4j.TERMINATION_DELAY");
+ if (localAgent != null)
+ {
+ localAgent.free();
+ }
+ if (remotePeer != null)
+ {
+ remotePeer.free();
+ }
+ }
+
+ @Test
+ public void inPlaceRestartKeepsSelectedPair() throws Exception
+ {
+ localAgent = createAgent();
+ remotePeer = createAgent();
+
+ localAgent.setControlling(true);
+ remotePeer.setControlling(false);
+
+ // Exchange candidates and credentials both ways.
+ transferRemoteCandidates(localAgent, remotePeer);
+ transferRemoteCandidates(remotePeer, localAgent);
+
+ localAgent.startConnectivityEstablishment();
+ remotePeer.startConnectivityEstablishment();
+
+ // Wait until the (controlling) local agent completes ICE.
+ assertTrue(
+ waitForState(localAgent, IceProcessingState.COMPLETED, 10000),
+ "local agent should complete ICE");
+
+ Component rtp = localAgent.getStream("audio").getComponent(Component.RTP);
+ CandidatePair originalPair = rtp.getSelectedPair();
+ assertNotNull(originalPair, "a pair should be selected after completion");
+
+ // Wait for the agent to terminate (which stops its connectivity check
+ // client) so the restart has to re-arm it.
+ assertTrue(
+ waitForState(localAgent, IceProcessingState.TERMINATED, 8000),
+ "local agent should terminate after the termination delay");
+ // Also wait for the peer to terminate so both are re-armed from the
+ // stopped state by their respective restarts.
+ assertTrue(
+ waitForState(remotePeer, IceProcessingState.TERMINATED, 8000),
+ "remote peer should terminate after the termination delay");
+ // The selected pair must survive termination (media keeps flowing).
+ CandidatePair pairBeforeRestart = rtp.getSelectedPair();
+ assertNotNull(pairBeforeRestart, "selected pair must survive termination");
+
+ // --- Restart ICE in place ---
+ localAgent.restartIce();
+
+ // The re-arm invariants (peer-independent, these are the landmines the
+ // primitive has to get right):
+ // - the agent is moved out of TERMINATED back to RUNNING;
+ // - the check list is RUNNING again (so checks are actually scheduled);
+ // - the previously selected pair is STILL in use — media keeps flowing on
+ // it while the new generation is validated (make-before-break);
+ // - the component is flagged as restarting so the first pair nominated
+ // during the restart replaces (rather than being rejected by the
+ // set-once guard on) the selected pair.
+ assertEquals(IceProcessingState.RUNNING, localAgent.getState(),
+ "agent should be RUNNING again right after restartIce()");
+ assertEquals(CheckListState.RUNNING,
+ localAgent.getStream("audio").getCheckList().getState(),
+ "check list should be RUNNING again after restartIce()");
+ assertSame(pairBeforeRestart, rtp.getSelectedPair(),
+ "the same selected pair must be preserved across the restart (make-before-break)");
+ assertTrue(rtp.isIceRestarting(),
+ "component should be flagged as ICE-restarting until a new pair is nominated");
+
+ // The selected pair must never go null while checks re-run for a while.
+ // (Full re-nomination/swap against a live peer is covered end-to-end by
+ // the boris2 deployment test; here the peer is a terminated ice4j agent
+ // whose role after a mutual restart is non-deterministic due to ICE
+ // role-conflict resolution, so we don't assert on re-completion.)
+ long deadline = System.currentTimeMillis() + 1000;
+ while (System.currentTimeMillis() < deadline)
+ {
+ assertNotNull(rtp.getSelectedPair(),
+ "selected pair must never be null during re-establishment");
+ Thread.sleep(20);
+ }
+ }
+
+ /**
+ * Creates a host-candidate-only agent with a single "audio" stream and one
+ * (RTP) component on an ephemeral port.
+ */
+ private Agent createAgent() throws Exception
+ {
+ Agent agent = new Agent();
+ // No harvesters added -> host candidates only (hermetic).
+ IceMediaStream stream = agent.createMediaStream("audio");
+ agent.createComponent(stream, KeepAliveStrategy.SELECTED_ONLY, true);
+
+ return agent;
+ }
+
+ private static void transferRemoteCandidates(Agent to, Agent from)
+ {
+ for (IceMediaStream toStream : to.getStreams())
+ {
+ IceMediaStream fromStream = from.getStream(toStream.getName());
+ if (fromStream == null)
+ {
+ continue;
+ }
+ toStream.setRemoteUfrag(from.getLocalUfrag());
+ toStream.setRemotePassword(from.getLocalPassword());
+
+ for (Component toComponent : toStream.getComponents())
+ {
+ Component fromComponent = fromStream.getComponent(toComponent.getComponentID());
+ if (fromComponent == null)
+ {
+ continue;
+ }
+ for (LocalCandidate lc : fromComponent.getLocalCandidates())
+ {
+ toComponent.addRemoteCandidate(new RemoteCandidate(
+ lc.getTransportAddress(),
+ toComponent,
+ lc.getType(),
+ lc.getFoundation(),
+ lc.getPriority(),
+ null));
+ }
+ }
+ }
+ }
+
+ private static boolean waitForState(Agent agent, IceProcessingState target, long timeoutMs)
+ throws InterruptedException
+ {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline)
+ {
+ if (agent.getState() == target)
+ {
+ return true;
+ }
+ Thread.sleep(20);
+ }
+ return agent.getState() == target;
+ }
+}