From 1e3be671e005dfbf110e0293de97425aaab1cc86 Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Fri, 30 Oct 2026 13:28:16 +0100 Subject: [PATCH] openvidu-test-e2e: build server SDK publishers before the room exists OpenVidu in evaluation mode closes a room after 5 minutes. The first test of each SDK created its room and only then pulled the image and built the publisher: for rust the room had been open for up to 344 s when it started publishing, and the room closed while the late subscribers were being checked. prepareServerSdkPublisher() now pulls and builds each SDK's publisher once per run, before the test creates any room. Co-Authored-By: Claude Opus 5.5 --- .../io/openvidu/test/e2e/OpenViduTestE2e.java | 180 +++++++++++------- .../e2e/OpenViduTestAppE2eServerSdkTest.java | 5 + 2 files changed, 118 insertions(+), 67 deletions(-) diff --git a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java index 49c5c7de4..2da1eac1e 100644 --- a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java +++ b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; @@ -44,6 +45,7 @@ import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.BindMode; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.Network; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.utility.DockerImageName; @@ -519,6 +521,108 @@ public class OpenViduTestE2e { }); } + /** + * How the publisher program of a server SDK is built and run: its runtime + * image, the command that installs its toolchain and dependencies and compiles + * it into the /cache volume, the command that runs it (which still does both, + * from the cache), how many minutes each may take and the environment of both. + */ + private record SdkPublisher(String image, String prepareCommand, String runCommand, int timeoutMinutes, + Map env) { + } + + private static SdkPublisher sdkPublisher(String sdk) { + Map env = new HashMap<>(); + switch (sdk) { + case "go": + env.put("GOMODCACHE", "/cache/gomod"); + env.put("GOCACHE", "/cache/gobuild"); + return new SdkPublisher("golang:1.27", "go build -o /dev/null .", "go run .", 5, env); + case "node": + env.put("npm_config_cache", "/cache/npm"); + String npmInstall = "npm install --no-audit --no-fund --loglevel=error"; + return new SdkPublisher("node:24", npmInstall, npmInstall + " && node main.mjs", 5, env); + case "python": + env.put("PIP_CACHE_DIR", "/cache/pip"); + String pipInstall = "pip install -q -r requirements.txt"; + return new SdkPublisher("python:3.14", pipInstall, pipInstall + " && python main.py", 5, env); + case "rust": + // webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21 + // (apt.llvm.org); the first build compiles the whole livekit crate + // graph + String clang = "apt-get update -qq >/dev/null && apt-get install -y -qq lsb-release gnupg >/dev/null" + + " && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && bash /tmp/llvm.sh 21 >/dev/null 2>&1"; + env.put("CARGO_HOME", "/cache/cargo"); + env.put("CARGO_TARGET_DIR", "/cache/cargo-target"); + env.put("CC", "clang-21"); + env.put("CXX", "clang++-21"); + return new SdkPublisher("rust:1", clang + " && cargo build --release --locked", + clang + " && cargo run --release --locked", 30, env); + case "dotnet": + env.put("NUGET_PACKAGES", "/cache/nuget"); + env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1"); + return new SdkPublisher("mcr.microsoft.com/dotnet/sdk:10.0", "dotnet build -c Release", + "dotnet run -c Release", 10, env); + default: + throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk); + } + } + + /** + * A container of the given SDK's image that mounts its publisher program + * read-only at /app and the SDK's cache volume at /cache, copies the program to + * /work and runs the command there. Its output is logged as [<sdk>-<role>]. + */ + private GenericContainer sdkPublisherContainer(String sdk, String role, String image, Map env, + String command) { + String programDir = Paths.get("src/test/resources/" + sdk + "-publisher").toAbsolutePath().toString(); + String cacheVolume = sdkCacheVolume(sdk); + return new GenericContainer<>(DockerImageName.parse(image)) + .withCreateContainerCmdModifier( + cmd -> cmd.withName(sdk + "-" + role + "-" + (int) (Math.random() * 100000))) + // Not withFileSystemBind: it resolves its first argument as a host path + .withCreateContainerCmdModifier(cmd -> { + List mounts = new ArrayList<>(); + if (cmd.getHostConfig().getMounts() != null) { + mounts.addAll(cmd.getHostConfig().getMounts()); + } + mounts.add(new Mount().withType(MountType.VOLUME).withSource(cacheVolume).withTarget("/cache")); + cmd.getHostConfig().withMounts(mounts); + }) + .withNetworkMode("host").withFileSystemBind(programDir, "/app", BindMode.READ_ONLY).withEnv(env) + .withCommand("sh", "-c", "mkdir -p /work && cp -r /app/. /work && cd /work && " + command) + .withLogConsumer(frame -> log.info("[{}-{}] {}", sdk, role, frame.getUtf8String().stripTrailing())); + } + + // Server SDKs whose publisher program is already prepared in this JVM + private static final Set PREPARED_SDK_PUBLISHERS = ConcurrentHashMap.newKeySet(); + + /** + * Pulls the image of the given SDK's publisher program and runs its prepare + * command (toolchain, dependencies and compilation into the SDK's cache volume), + * once per JVM. Call it before creating the room the publisher will join: the + * first preparation of an SDK takes minutes (rust: image pull, clang install and + * the build of the whole livekit crate graph), and OpenVidu in evaluation mode + * closes every room after 5 minutes. Fails if the command does not exit with 0. + */ + public void prepareServerSdkPublisher(String sdk) { + synchronized (PREPARED_SDK_PUBLISHERS) { + if (PREPARED_SDK_PUBLISHERS.contains(sdk)) { + return; + } + SdkPublisher publisher = sdkPublisher(sdk); + long start = System.currentTimeMillis(); + try (GenericContainer container = sdkPublisherContainer(sdk, "prepare", publisher.image(), + publisher.env(), publisher.prepareCommand()) + .withStartupCheckStrategy(new OneShotStartupCheckStrategy() + .withTimeout(Duration.ofMinutes(publisher.timeoutMinutes())))) { + container.start(); + } + log.info("{} SDK publisher prepared in {} s", sdk, (System.currentTimeMillis() - start) / 1000); + PREPARED_SDK_PUBLISHERS.add(sdk); + } + } + /** * Starts a LiveKit server RTC SDK participant (a minimal program at * src/test/resources/<sdk>-publisher) in a plain runtime container @@ -531,58 +635,17 @@ public class OpenViduTestE2e { * test JVM (see {@link #sdkCacheVolume}): nothing of a previous run or of * another job on the same machine reaches the program, while the publishers * of one SDK still share their downloads (and the rust build, which takes up - * to 30 minutes from cold) within the run. Returns once the program logs - * TRACK_PUBLISHED. The container is stopped automatically on test dispose. + * to 30 minutes from cold) within the run. Run + * {@link #prepareServerSdkPublisher} before creating the room, so that the + * room does not wait for that. Returns once the program logs TRACK_PUBLISHED. + * The container is stopped automatically on test dispose. */ public void startServerSdkPublisher(String sdk, String roomName, String codec, int layers) throws Exception { if (layers < 1 || layers > 3) { throw new IllegalArgumentException("Unsupported number of video layers: " + layers); } - final String image; - final String runCommand; - int startupTimeoutMinutes = 5; - Map env = new HashMap<>(); - switch (sdk) { - case "go": - image = "golang:1.27"; - runCommand = "go run ."; - env.put("GOMODCACHE", "/cache/gomod"); - env.put("GOCACHE", "/cache/gobuild"); - break; - case "node": - image = "node:24"; - runCommand = "npm install --no-audit --no-fund --loglevel=error && node main.mjs"; - env.put("npm_config_cache", "/cache/npm"); - break; - case "python": - image = "python:3.14"; - runCommand = "pip install -q -r requirements.txt && python main.py"; - env.put("PIP_CACHE_DIR", "/cache/pip"); - break; - case "rust": - image = "rust:1"; - // webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21 - // (apt.llvm.org); the first run then compiles the whole livekit - // crate graph - runCommand = "apt-get update -qq >/dev/null && apt-get install -y -qq lsb-release gnupg >/dev/null" - + " && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && bash /tmp/llvm.sh 21 >/dev/null 2>&1" - + " && cargo run --release --locked"; - env.put("CARGO_HOME", "/cache/cargo"); - env.put("CARGO_TARGET_DIR", "/cache/cargo-target"); - env.put("CC", "clang-21"); - env.put("CXX", "clang++-21"); - startupTimeoutMinutes = 30; - break; - case "dotnet": - image = "mcr.microsoft.com/dotnet/sdk:10.0"; - runCommand = "dotnet run -c Release"; - env.put("NUGET_PACKAGES", "/cache/nuget"); - env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1"); - startupTimeoutMinutes = 10; - break; - default: - throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk); - } + SdkPublisher publisher = sdkPublisher(sdk); + Map env = new HashMap<>(publisher.env()); // Publishers receive a ready-made token: it keeps the programs shorter // (no per-SDK token dependency) and works with any api secret length AccessToken accessToken = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET); @@ -593,27 +656,10 @@ public class OpenViduTestE2e { env.put("VIDEO_CODEC", codec); env.put("VIDEO_LAYERS", String.valueOf(layers)); - String programDir = Paths.get("src/test/resources/" + sdk + "-publisher").toAbsolutePath().toString(); - String cacheVolume = sdkCacheVolume(sdk); - - GenericContainer publisherContainer = new GenericContainer<>(DockerImageName.parse(image)) - .withCreateContainerCmdModifier( - cmd -> cmd.withName(sdk + "-publisher-" + (int) (Math.random() * 100000))) - // Not withFileSystemBind: it resolves its first argument as a host path - .withCreateContainerCmdModifier(cmd -> { - List mounts = new ArrayList<>(); - if (cmd.getHostConfig().getMounts() != null) { - mounts.addAll(cmd.getHostConfig().getMounts()); - } - mounts.add(new Mount().withType(MountType.VOLUME).withSource(cacheVolume).withTarget("/cache")); - cmd.getHostConfig().withMounts(mounts); - }) - .withNetworkMode("host").withFileSystemBind(programDir, "/app", BindMode.READ_ONLY).withEnv(env) - .withCommand("sh", "-c", "mkdir -p /work && cp -r /app/. /work && cd /work && " + runCommand) - .withLogConsumer( - frame -> log.info("[{}-publisher] {}", sdk, frame.getUtf8String().stripTrailing())) + GenericContainer publisherContainer = sdkPublisherContainer(sdk, "publisher", publisher.image(), env, + publisher.runCommand()) .waitingFor(Wait.forLogMessage("^.*TRACK_PUBLISHED.*$", 1) - .withStartupTimeout(Duration.ofMinutes(startupTimeoutMinutes))); + .withStartupTimeout(Duration.ofMinutes(publisher.timeoutMinutes()))); if ("go".equals(sdk)) { // The Go SDK sends pre-encoded media: loop a real (decodable) 5s diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java index cc7675907..9897f474c 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java @@ -180,6 +180,11 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT log.info("{} SDK {} {}-layer publisher to Chrome and Firefox early and late subscribers", sdk, codec, layers); + // Before the room exists: the first test of an SDK would otherwise spend the + // image pull and the build (minutes for rust) inside the room, which OpenVidu + // in evaluation mode closes after 5 minutes + this.prepareServerSdkPublisher(sdk); + OpenViduTestappUser chrome = setupBrowserAndConnectToOpenViduTestapp("chrome"); OpenViduTestappUser firefox = setupBrowserAndConnectToOpenViduTestapp("firefox");