openvidu-test-e2e: fix the two connection-quality flakes

pull/910/merge
pabloFuente 2026-09-16 13:32:04 +02:00
parent 34c70808ad
commit 7f6f1381d7
2 changed files with 91 additions and 17 deletions

View File

@ -28,7 +28,8 @@ public class NetworkConditioner {
/** /**
* UDP port of the SFU's embedded TURN server (livekit.yaml * UDP port of the SFU's embedded TURN server (livekit.yaml
* {@code turn.udp_port}). Same reasoning as {@link #SFU_ICE_TCP_PORT}: it is the * {@code turn.udp_port}). Same reasoning as {@link #SFU_ICE_TCP_PORT}: it is
* the
* last route out of a media blackout, since a client whose host and srflx * last route out of a media blackout, since a client whose host and srflx
* candidates are all dead falls back to allocating a TURN relay. Blocking the * candidates are all dead falls back to allocating a TURN relay. Blocking the
* listener is enough; the relay range ({@code turn.relay_range_*}) is where the * listener is enough; the relay range ({@code turn.relay_range_*}) is where the
@ -58,6 +59,16 @@ public class NetworkConditioner {
// blackoutOutbound(). // blackoutOutbound().
private static String blackoutContainer; private static String blackoutContainer;
// Container Pumba is currently impairing. Tracked so that clear() can scrub its
// network namespace itself instead of trusting Pumba to have reverted.
private static String impairedContainer;
// SIGTERM-to-SIGKILL grace given to Pumba on clear(). Reverting is not just a
// syscall for Pumba: it starts ANOTHER container (the nettools sidecar) to run
// the tc/iptables delete, so on a loaded Docker daemon it can take much longer
// than the usual 1-2 s. Killed halfway, it leaves the impairment installed.
private static final int PUMBA_STOP_GRACE_SEC = 20;
public enum Direction { public enum Direction {
OUTBOUND, INBOUND OUTBOUND, INBOUND
} }
@ -225,6 +236,7 @@ public class NetworkConditioner {
cmd = pumbaRun() + " iptables " + opts + " loss --mode random --probability " cmd = pumbaRun() + " iptables " + opts + " loss --mode random --probability "
+ String.format(Locale.US, "%.2f", probability) + " " + targetContainer; + String.format(Locale.US, "%.2f", probability) + " " + targetContainer;
} }
impairedContainer = targetContainer;
runPumba(cmd); runPumba(cmd);
} }
@ -307,17 +319,25 @@ public class NetworkConditioner {
} }
String cmd = pumbaRun() + " netem " + opts + " delay --time " + delayMs + " --jitter " + jitterMs + " " String cmd = pumbaRun() + " netem " + opts + " delay --time " + delayMs + " --jitter " + jitterMs + " "
+ targetContainer; + targetContainer;
impairedContainer = targetContainer;
runPumba(cmd); runPumba(cmd);
} }
/** /**
* Stop the current Pumba container (SIGTERM) so it reverts the netem qdisc / * Remove every impairment: stop the current Pumba container (SIGTERM, so it
* iptables rule immediately. * reverts the netem qdisc / iptables rule itself) and then scrub the target's
* network namespace anyway, so that the impairment is gone whether or not
* Pumba got to revert it.
*/ */
public static void clear() { public static void clear() {
final String target = impairedContainer;
impairedContainer = null;
if (currentPumbaContainerName != null) { if (currentPumbaContainerName != null) {
log.info("Clearing network impairment (stopping Pumba container {})", currentPumbaContainerName); log.info("Clearing network impairment (stopping Pumba container {})", currentPumbaContainerName);
commandLine.executeCommand("docker stop -t 5 " + currentPumbaContainerName, 30); String out = commandLine.executeCommand(
"docker stop -t " + PUMBA_STOP_GRACE_SEC + " " + currentPumbaContainerName + " 2>&1",
PUMBA_STOP_GRACE_SEC + 30);
log.info("docker stop {} result: {}", currentPumbaContainerName, out);
currentPumbaContainerName = null; currentPumbaContainerName = null;
} }
if (blackoutContainer != null) { if (blackoutContainer != null) {
@ -326,6 +346,20 @@ public class NetworkConditioner {
nettools(blackoutContainer, "iptables", "-F OUTPUT"); nettools(blackoutContainer, "iptables", "-F OUTPUT");
blackoutContainer = null; blackoutContainer = null;
} }
if (target != null) {
scrubImpairments(target);
}
}
/**
* Delete anything Pumba may have left behind in {@code targetContainer}'s
* network namespace: its INPUT DROP rules (ingress loss) and the root qdisc
* tree carrying its netem (egress loss/delay).
*/
private static void scrubImpairments(String targetContainer) {
String out = nettools(targetContainer, "sh", "-c \"iptables -F INPUT; tc qdisc del dev eth0 root\"");
log.info("Scrubbing leftover impairments on container {} (iptables -F INPUT; tc qdisc del dev eth0 root): {}",
targetContainer, out.isBlank() ? "removed" : out.trim());
} }
/** /**

View File

@ -437,6 +437,46 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
return null; return null;
} }
private static int qualityRank(ConnectionQuality quality) {
if (quality == null) {
return Integer.MAX_VALUE;
}
switch (quality) {
case LOST:
return 0;
case POOR:
return 1;
case GOOD:
return 2;
case EXCELLENT:
return 3;
default:
return Integer.MAX_VALUE;
}
}
private static ConnectionQuality worstQuality(ConnectionQuality a, ConnectionQuality b) {
return qualityRank(b) < qualityRank(a) ? b : a;
}
// Holds the current impairment for `holdMillis` and records, for that loss
// step, the WORST quality each participant reported at any point during it.
private void holdAndRecordWorstQuality(OpenViduTestappUser publisher, OpenViduTestappUser subscriber,
long holdMillis, int lossPct, Map<Integer, ConnectionQuality> publisherQuality,
Map<Integer, ConnectionQuality> subscriberQuality) throws InterruptedException {
final long sampleIntervalMillis = 1000;
final long deadline = System.currentTimeMillis() + holdMillis;
ConnectionQuality worstPublisher = null;
ConnectionQuality worstSubscriber = null;
do {
worstPublisher = worstQuality(worstPublisher, latestConnectionQuality(publisher, 0, "PunchbagUser"));
worstSubscriber = worstQuality(worstSubscriber, latestConnectionQuality(subscriber, 0, "RegularUser"));
Thread.sleep(sampleIntervalMillis);
} while (System.currentTimeMillis() < deadline);
publisherQuality.put(lossPct, worstPublisher);
subscriberQuality.put(lossPct, worstSubscriber);
}
// First loss% (ascending) at which the recorded quality equals `level`, or -1 // First loss% (ascending) at which the recorded quality equals `level`, or -1
// if never reached. // if never reached.
private static int firstLossReaching(Map<Integer, ConnectionQuality> observed, ConnectionQuality level) { private static int firstLossReaching(Map<Integer, ConnectionQuality> observed, ConnectionQuality level) {
@ -1237,18 +1277,17 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
final int STEP_PCT = 10; final int STEP_PCT = 10;
final int HOLD_SECONDS = 16; final int HOLD_SECONDS = 16;
// Ramp the PUBLISHER's uplink loss and record, at each step, the settled // Ramp the PUBLISHER's uplink loss and record, at each step, the WORST
// quality of both the impaired publisher (PunchbagUser) and the untouched // quality seen during the whole hold, for both the impaired publisher
// subscriber's OWN quality (RegularUser). // (PunchbagUser) and the untouched subscriber's OWN quality (RegularUser).
Map<Integer, ConnectionQuality> publisherQuality = new LinkedHashMap<>(); Map<Integer, ConnectionQuality> publisherQuality = new LinkedHashMap<>();
Map<Integer, ConnectionQuality> subscriberQuality = new LinkedHashMap<>(); Map<Integer, ConnectionQuality> subscriberQuality = new LinkedHashMap<>();
try { try {
for (int pct = STEP_PCT; pct <= 90; pct += STEP_PCT) { for (int pct = STEP_PCT; pct <= 90; pct += STEP_PCT) {
log.info("Packet loss to " + pct + "%"); log.info("Packet loss to " + pct + "%");
NetworkConditioner.updateOutboundLossPercent(container, pct); NetworkConditioner.updateOutboundLossPercent(container, pct);
Thread.sleep(HOLD_SECONDS * 1000L); holdAndRecordWorstQuality(punchbagUser, regularUser, HOLD_SECONDS * 1000L, pct, publisherQuality,
publisherQuality.put(pct, latestConnectionQuality(punchbagUser, 0, "PunchbagUser")); subscriberQuality);
subscriberQuality.put(pct, latestConnectionQuality(regularUser, 0, "RegularUser"));
} }
// Final step: a TOTAL blackout (100% loss) across the WHOLE SFU media port // Final step: a TOTAL blackout (100% loss) across the WHOLE SFU media port
@ -1264,24 +1303,25 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
publisherQuality.put(BLACKOUT_PCT, pubBlackout); publisherQuality.put(BLACKOUT_PCT, pubBlackout);
subscriberQuality.put(BLACKOUT_PCT, latestConnectionQuality(regularUser, 0, "RegularUser")); subscriberQuality.put(BLACKOUT_PCT, latestConnectionQuality(regularUser, 0, "RegularUser"));
log.info(buildRampResultTable(publisherQuality, subscriberQuality)); final String table = buildRampResultTable(publisherQuality, subscriberQuality);
log.info(table);
int firstGood = firstLossReaching(publisherQuality, ConnectionQuality.GOOD); int firstGood = firstLossReaching(publisherQuality, ConnectionQuality.GOOD);
int firstPoor = firstLossReaching(publisherQuality, ConnectionQuality.POOR); int firstPoor = firstLossReaching(publisherQuality, ConnectionQuality.POOR);
int firstLost = firstLossReaching(publisherQuality, ConnectionQuality.LOST); int firstLost = firstLossReaching(publisherQuality, ConnectionQuality.LOST);
Assertions.assertTrue(firstGood >= 10 && firstGood <= 20, Assertions.assertTrue(firstGood >= 10 && firstGood <= 20,
"EXCELLENT->GOOD transition expected between 10% and 20% loss, but first GOOD was at " + firstGood "EXCELLENT->GOOD transition expected between 10% and 20% loss, but first GOOD was at " + firstGood
+ "%"); + "%" + table);
Assertions.assertTrue(firstPoor >= 20 && firstPoor <= 50, Assertions.assertTrue(firstPoor >= 20 && firstPoor <= 50,
"GOOD->POOR transition expected between 20% and 50% loss, but first POOR was at " + firstPoor "GOOD->POOR transition expected between 20% and 50% loss, but first POOR was at " + firstPoor
+ "%"); + "%" + table);
Assertions.assertTrue(firstPoor > firstGood, Assertions.assertTrue(firstPoor > firstGood,
"POOR must appear after GOOD (firstGood=" + firstGood + "%, firstPoor=" + firstPoor + "%)"); "POOR must appear after GOOD (firstGood=" + firstGood + "%, firstPoor=" + firstPoor + "%)" + table);
Assertions.assertTrue(firstLost >= 50, Assertions.assertTrue(firstLost >= 50,
"POOR->LOST transition expected ONLY at severe loss (>=50%), but first LOST was at " + firstLost "POOR->LOST transition expected ONLY at severe loss (>=50%), but first LOST was at " + firstLost
+ "%"); + "%" + table);
Assertions.assertTrue(firstLost > firstPoor, Assertions.assertTrue(firstLost > firstPoor,
"LOST must appear after POOR (firstPoor=" + firstPoor + "%, firstLost=" + firstLost + "%)"); "LOST must appear after POOR (firstPoor=" + firstPoor + "%, firstLost=" + firstLost + "%)" + table);
// Subscriber's own network is always EXCELLENT // Subscriber's own network is always EXCELLENT
for (Entry<Integer, ConnectionQuality> e : subscriberQuality.entrySet()) { for (Entry<Integer, ConnectionQuality> e : subscriberQuality.entrySet()) {
@ -1289,7 +1329,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
Assertions.assertFalse( Assertions.assertFalse(
sq == ConnectionQuality.GOOD || sq == ConnectionQuality.POOR || sq == ConnectionQuality.LOST, sq == ConnectionQuality.GOOD || sq == ConnectionQuality.POOR || sq == ConnectionQuality.LOST,
"RegularUser (subscriber) network is NOT impaired, so its own connection quality must stay " "RegularUser (subscriber) network is NOT impaired, so its own connection quality must stay "
+ "EXCELLENT, but was " + sq + " at " + e.getKey() + "% publisher loss"); + "EXCELLENT, but was " + sq + " at " + e.getKey() + "% publisher loss" + table);
} }
} finally { } finally {
NetworkConditioner.clear(); NetworkConditioner.clear();