diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index daf4bb45..9f0f34b3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -26,4 +26,7 @@ jobs: stress_timeout_minutes: 120 artifact_retention_days: 14 run_coverage: false + memory_ffi_iterations: 50 + memory_connected_iterations: 50 + memory_timeout_minutes: 20 secrets: inherit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 398aa0f0..d3a8155d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,21 @@ on: required: false type: boolean default: true + memory_ffi_iterations: + description: Memory lifecycle tester iterations for initialize/shutdown + unused sources. + required: false + type: number + default: 30 + memory_connected_iterations: + description: Memory lifecycle tester iterations for the connected software smoke. + required: false + type: number + default: 20 + memory_timeout_minutes: + description: Memory lifecycle smoke step timeout in minutes. + required: false + type: number + default: 10 workflow_dispatch: inputs: build_type: @@ -109,6 +124,21 @@ on: required: false type: boolean default: true + memory_ffi_iterations: + description: Memory lifecycle tester iterations for initialize/shutdown + unused sources. + required: false + type: number + default: 30 + memory_connected_iterations: + description: Memory lifecycle tester iterations for the connected software smoke. + required: false + type: number + default: 20 + memory_timeout_minutes: + description: Memory lifecycle smoke step timeout in minutes. + required: false + type: number + default: 10 permissions: contents: read @@ -357,7 +387,7 @@ jobs: --gtest_output="xml:${{ env.BUILD_DIR }}\unit-test-results.xml" - name: Start livekit-server - if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) + if: matrix.e2e-testing id: livekit_server uses: livekit/dev-server-action@5d4d5337a875e2d1afd37bed03c601d159dab002 # v1.1.1 with: @@ -368,7 +398,7 @@ jobs: # Needed by token helper script - name: Install livekit-cli - if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) + if: matrix.e2e-testing shell: bash env: # Windows installs lk via `gh api` / `gh release download`, which need this env var @@ -425,6 +455,51 @@ jobs: --gtest_recreate_environments_when_repeating=1 \ --gtest_output=xml:${{ env.BUILD_DIR }}/integration-test-results.xml + - name: Set memory lifecycle RSS cap + if: matrix.e2e-testing + shell: bash + run: | + set -euo pipefail + build="${{ inputs.build_type }}" + case "${RUNNER_OS}-${build}" in + Linux-release) cap=262144 ;; + Linux-debug) cap=524288 ;; + macOS-release) cap=262144 ;; + macOS-debug) cap=524288 ;; + Windows-release) cap=393216 ;; + Windows-debug) cap=786432 ;; + *) + echo "::error::No memory RSS cap for ${RUNNER_OS} ${build}" + exit 1 + ;; + esac + # Calibrated from a local macOS release run (~85 MiB connected, ~37 MiB + # ffi-cycles). Caps are ~3x that for release and ~2x again for debug, + # with extra Windows working-set headroom. Adjust from CI logs if needed. + echo "LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB=${cap}" >> "${GITHUB_ENV}" + echo "Memory RSS cap: ${cap} KiB (${RUNNER_OS} ${build})" + + - name: Run memory lifecycle smoke + if: matrix.e2e-testing + timeout-minutes: ${{ inputs.memory_timeout_minutes }} + shell: bash + run: | + set -euo pipefail + source scripts/set-test-tokens.sh + tester="${{ env.BUILD_DIR }}/bin/livekit_memory_lifecycle_tester" + bash scripts/run-with-backtrace.sh \ + "${tester}" \ + --iterations ${{ inputs.memory_ffi_iterations }} \ + --ffi-cycles \ + --sources + bash scripts/run-with-backtrace.sh \ + "${tester}" \ + --iterations ${{ inputs.memory_connected_iterations }} \ + --sources \ + --media \ + --data-frames \ + --receive + - name: Run stress tests if: matrix.e2e-testing && inputs.run_stress_tests timeout-minutes: ${{ inputs.stress_timeout_minutes }} @@ -441,7 +516,7 @@ jobs: --gtest_output=xml:${{ env.BUILD_DIR }}/stress-test-results.xml - name: Dump livekit-server log on failure - if: failure() && matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) + if: failure() && matrix.e2e-testing shell: bash run: tail -n 500 "${{ steps.livekit_server.outputs.log-path }}" || true diff --git a/AGENTS.md b/AGENTS.md index 55da0430..b4c9d8ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,8 @@ Be sure to update the directory layout in this file if the directory layout chan |------|-------------| | `include/livekit/` | Public API headers (what SDK consumers include) | | `src/` | Implementation files and internal-only headers (`ffi_client.h`, `lk_log.h`, etc.) | -| `src/tests/` | Google Test integration and stress tests | +| `src/tests/` | Google Test unit, integration, and stress tests | +| `src/tests/manual/` | Standalone testers built with the test targets but not registered with CTest | | `examples/` | In-tree example applications | | `client-sdk-rust/` | Git submodule holding the Rust core of the SDK| | `cpp-tools/` | Git submodule holding shared LiveKit C++ engineering guidance, clang-format / clang-tidy configs, scripts, docs, and CI workflow | diff --git a/client-sdk-rust b/client-sdk-rust index 2d9f01ab..f76b7ac1 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit 2d9f01ab1e933a86a8a5c53805ee29ee58b9be1b +Subproject commit f76b7ac1b97e419a42ea5907f01394b8831e5d4b diff --git a/docs/building.md b/docs/building.md index fd4cbb83..4620eaa3 100644 --- a/docs/building.md +++ b/docs/building.md @@ -58,12 +58,31 @@ sudo apt update && sudo apt install -y \ build-essential cmake ninja-build pkg-config \ llvm-dev libclang-dev clang \ libprotobuf-dev protobuf-compiler libabsl-dev \ - libssl-dev + libssl-dev libcurl4-openssl-dev # Install Rust if you don't already have it curl https://sh.rustup.rs -sSf | sh ``` +The bundled `libwebrtc` artifact uses a hermetic libc++ built from LLVM trunk. +Ubuntu 24.04's Clang 18 is too old for it. The Linux CI uses LLVM 21.1.8; install +that toolchain and select it for the SDK build without replacing Ubuntu's +system compiler: + +```bash +export LLVM_VERSION=21.1.8 +export LLVM_ROOT="$HOME/.local/llvm-$LLVM_VERSION" +client-sdk-rust/.github/scripts/install-clang.sh +export CC="$LLVM_ROOT/bin/clang" +export CXX="$LLVM_ROOT/bin/clang++" + +# Confirm the selected compiler before configuring the SDK. +"$CXX" --version +``` + +Set `CC` and `CXX` before the first build, or remove the affected build +directory before reconfiguring so CMake does not retain the previous compiler. + If you plan to build the [example collection](https://github.com/livekit-examples/cpp-example-collection) (SDL-based renderer + camera/mic capture), also install: diff --git a/docs/testing.md b/docs/testing.md index 33919c0e..1a11d498 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,7 @@ __Note:__ The tests require tokens and a running LiveKit server. See the section | `livekit_unit_tests` | Pure unit tests (no server required) | | `livekit_integration_tests` | Quick tests (~1-2 minutes) for SDK functionality | | `livekit_stress_tests` | Long-running tests (configurable, default 1 hour) | +| `livekit_memory_lifecycle_tester` | Manual/CI smoke for SDK connect/publish/subscribe teardown and RSS growth. Not registered with CTest. | ## Running a local LiveKit server for tests @@ -72,6 +73,9 @@ export LIVEKIT_TOKEN_B="" # Optional (for stress tests) export RPC_STRESS_DURATION_SECONDS=3600 # Test duration (default: 1 hour) export RPC_STRESS_CALLER_THREADS=4 # Concurrent caller threads (default: 4) + +# Optional (memory lifecycle tester / CI smoke) +export LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB=1048576 # fail if final RSS exceeds this many KiB ``` ### Generating tokens for the test suites @@ -105,6 +109,28 @@ export LIVEKIT_TOKEN_B="$(lk token create --api-key devkey --api-secret secret - - **Audio frame**: frame creation, manipulation, edge cases. - **RPC**: round-trip calls, max payload (15 KB), timeouts, errors, concurrent calls. - **Stress**: high throughput, bidirectional RPC, memory pressure. +- **Memory lifecycle**: repeated source, room, media, data-track, and software + subscribe teardown. CI runs this as a short RSS-capped smoke test; longer + hardware runs stay manual. + +## Memory lifecycle smoke + +`livekit_memory_lifecycle_tester` is built with the test targets but is not +part of `ctest`. CI runs two short software-only invocations after the +integration suite: + +```bash +source scripts/set-test-tokens.sh +./build-release/bin/livekit_memory_lifecycle_tester --iterations 30 --ffi-cycles --sources +./build-release/bin/livekit_memory_lifecycle_tester --iterations 20 --sources --media --data-frames --receive +``` + +`--receive` needs `LIVEKIT_TOKEN_B` and a running LiveKit server. The tester +fails if `LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB` is set and final process RSS exceeds +that cap; leave the variable unset for local runs. CUDA and platform-audio +paths are not part of this smoke test; see +`src/tests/manual/cuda_video_lifecycle_tester` and +`src/tests/manual/memory_lifecycle_tester/README.md`. ## Memory checks (valgrind) diff --git a/scripts/track_process_memory.py b/scripts/track_process_memory.py new file mode 100755 index 00000000..b97aa440 --- /dev/null +++ b/scripts/track_process_memory.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# +# Copyright 2026 LiveKit +# +# 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. + +"""Run a command and report its resident memory usage.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time + +MEMORY_LIMIT_ENV = "LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a command and report its initial, final, and peak RSS." + ) + parser.add_argument( + "--interval", + type=float, + default=0.1, + help="seconds between RSS samples (default: 0.1)", + ) + parser.add_argument( + "--max-final-rss-kib", + type=int, + default=None, + help=( + "fail if the last observed RSS exceeds this many KiB; " + f"overrides {MEMORY_LIMIT_ENV} when set" + ), + ) + parser.add_argument( + "command", + nargs=argparse.REMAINDER, + help="command and its arguments; prefix it with -- when needed", + ) + args = parser.parse_args() + if args.interval <= 0: + parser.error("--interval must be greater than zero") + if args.max_final_rss_kib is not None and args.max_final_rss_kib <= 0: + parser.error("--max-final-rss-kib must be greater than zero") + if not args.command: + parser.error("a command is required") + return args + + +def resolve_max_final_rss_kib(explicit: int | None) -> int | None: + if explicit is not None: + return explicit + value = os.environ.get(MEMORY_LIMIT_ENV) + if value is None or value == "": + return None + try: + parsed = int(value) + except ValueError as error: + raise ValueError(f"{MEMORY_LIMIT_ENV} must be an integer") from error + if parsed <= 0: + raise ValueError(f"{MEMORY_LIMIT_ENV} must be greater than zero") + return parsed + + +def windows_rss_kib(pid: int) -> int | None: + import ctypes + from ctypes import wintypes + + class ProcessMemoryCounters(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + psapi = ctypes.WinDLL("psapi", use_last_error=True) + process_query_information = 0x0400 + process_vm_read = 0x0010 + handle = kernel32.OpenProcess(process_query_information | process_vm_read, False, pid) + if not handle: + return None + try: + counters = ProcessMemoryCounters() + counters.cb = ctypes.sizeof(counters) + if not psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb): + return None + rss_kib = int(counters.WorkingSetSize) // 1024 + return rss_kib if rss_kib > 0 else None + finally: + kernel32.CloseHandle(handle) + + +def process_rss_kib(pid: int) -> int | None: + if sys.platform == "win32": + return windows_rss_kib(pid) + + result = subprocess.run( + ["ps", "-o", "rss=", "-p", str(pid)], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + + rss = result.stdout.strip() + if not rss: + return None + + rss_kib = int(rss) + # macOS reports 0 RSS for a child that has exited but has not yet been + # reaped. Do not overwrite the last live-process sample with that value. + return rss_kib if rss_kib > 0 else None + + +def format_rss(rss_kib: int) -> str: + return f"{rss_kib:,} KiB ({rss_kib / 1024:.2f} MiB)" + + +def main() -> int: + args = parse_args() + command = args.command + if command[0] == "--": + command = command[1:] + if not command: + print("error: a command is required after --", file=sys.stderr) + return 2 + + try: + max_final_rss_kib = resolve_max_final_rss_kib(args.max_final_rss_kib) + except ValueError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + try: + process = subprocess.Popen(command) + except OSError as error: + print(f"error: could not start {command[0]!r}: {error}", file=sys.stderr) + return 127 + + started_at = time.monotonic() + initial_rss_kib: int | None = None + final_rss_kib: int | None = None + peak_rss_kib: int | None = None + + while process.poll() is None: + rss_kib = process_rss_kib(process.pid) + if rss_kib is not None: + if initial_rss_kib is None: + initial_rss_kib = rss_kib + final_rss_kib = rss_kib + peak_rss_kib = max(peak_rss_kib or rss_kib, rss_kib) + time.sleep(args.interval) + + elapsed_s = time.monotonic() - started_at + exit_code = process.wait() + print(f"command: {' '.join(command)}") + print(f"exit code: {exit_code}") + print(f"elapsed: {elapsed_s:.2f} s") + if initial_rss_kib is None: + print("RSS: no samples collected; the command exited before sampling began") + if max_final_rss_kib is not None and exit_code == 0: + print( + f"error: {MEMORY_LIMIT_ENV} is set but RSS could not be sampled", + file=sys.stderr, + ) + return 1 + else: + assert final_rss_kib is not None + assert peak_rss_kib is not None + print(f"RSS initial: {format_rss(initial_rss_kib)}") + print(f"RSS final observed: {format_rss(final_rss_kib)}") + print(f"RSS peak: {format_rss(peak_rss_kib)}") + print(f"RSS change: {format_rss(final_rss_kib - initial_rss_kib)}") + if max_final_rss_kib is not None and final_rss_kib > max_final_rss_kib: + print( + "error: final RSS " + f"{format_rss(final_rss_kib)} exceeds limit {format_rss(max_final_rss_kib)}", + file=sys.stderr, + ) + if exit_code == 0: + return 1 + + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 9583af73..b15b3c8f 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -348,3 +348,94 @@ endif() if(TARGET livekit_stress_tests) add_dependencies(run_all_tests livekit_stress_tests) endif() + +# ============================================================================ +# Manual testers (built with the test targets, not registered with CTest) +# ============================================================================ + +add_executable(livekit_memory_lifecycle_tester + "${CMAKE_CURRENT_SOURCE_DIR}/manual/memory_lifecycle_tester/main.cpp" +) + +target_link_libraries(livekit_memory_lifecycle_tester PRIVATE livekit) +if(WIN32) + target_link_libraries(livekit_memory_lifecycle_tester PRIVATE psapi) +endif() +target_include_directories(livekit_memory_lifecycle_tester PRIVATE ${LIVEKIT_ROOT_DIR}/include) +target_compile_definitions(livekit_memory_lifecycle_tester PRIVATE + $<$:_USE_MATH_DEFINES> +) + +if(WIN32) + add_custom_command(TARGET livekit_memory_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/livekit_ffi.dll" + $ + COMMENT "Copying DLLs next to livekit_memory_lifecycle_tester" + ) +elseif(APPLE) + add_custom_command(TARGET livekit_memory_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.dylib" + $ + COMMENT "Copying dylibs next to livekit_memory_lifecycle_tester" + ) +else() + add_custom_command(TARGET livekit_memory_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.so" + $ + COMMENT "Copying shared libraries next to livekit_memory_lifecycle_tester" + ) +endif() + +add_executable(livekit_cuda_video_lifecycle_tester + "${CMAKE_CURRENT_SOURCE_DIR}/manual/cuda_video_lifecycle_tester/main.cpp" +) + +target_link_libraries(livekit_cuda_video_lifecycle_tester PRIVATE livekit) +target_include_directories(livekit_cuda_video_lifecycle_tester PRIVATE ${LIVEKIT_ROOT_DIR}/include) +target_compile_definitions(livekit_cuda_video_lifecycle_tester PRIVATE + $<$:_USE_MATH_DEFINES> +) + +if(WIN32) + add_custom_command(TARGET livekit_cuda_video_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/livekit_ffi.dll" + $ + COMMENT "Copying DLLs next to livekit_cuda_video_lifecycle_tester" + ) +elseif(APPLE) + add_custom_command(TARGET livekit_cuda_video_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.dylib" + $ + COMMENT "Copying dylibs next to livekit_cuda_video_lifecycle_tester" + ) +else() + add_custom_command(TARGET livekit_cuda_video_lifecycle_tester POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$/liblivekit_ffi.so" + $ + COMMENT "Copying shared libraries next to livekit_cuda_video_lifecycle_tester" + ) +endif() diff --git a/src/tests/manual/cuda_video_lifecycle_tester/README.md b/src/tests/manual/cuda_video_lifecycle_tester/README.md new file mode 100644 index 00000000..2d34f1c7 --- /dev/null +++ b/src/tests/manual/cuda_video_lifecycle_tester/README.md @@ -0,0 +1,67 @@ +# CUDA video lifecycle tester + +This standalone application repeatedly creates two C++ SDK rooms, publishes a +synthetic I420 video track from one, verifies that the other receives a frame, +and tears down both rooms and the SDK. It is intended to exercise the CUDA +video codec lifecycle on an NVIDIA system. + +It requires a CUDA-enabled SDK build, an NVIDIA driver with NVENC and NVDEC +support, a running LiveKit server, and two tokens for the same room. The +existing `memory_lifecycle_tester` remains the hardware-neutral lifecycle +test; this tester adds real video publication and reception. + +## Build + +```bash +./build.sh release-tests +``` + +The Rust build must not print the following message: + +```text +cuda.h not found; building without hardware accelerated video codec support for NVidia GPUs +``` + +## Run + +Start a local development server in one terminal: + +```bash +livekit-server --dev +``` + +In another terminal, load the test credentials and prefer the NVIDIA encoder: + +```bash +source scripts/set-test-tokens.sh +export LIVEKIT_PREFERRED_HW_ENCODER=nvenc +export RUST_LOG=libwebrtc=debug + +./build-release/bin/livekit_cuda_video_lifecycle_tester --iterations 100 +``` + +Confirm that stderr includes messages such as `Using NVIDIA HW encoder (NVENC) +for H264` and `Using NVIDIA HW decoder (NVDEC) for H264`. The native WebRTC +logs are forwarded to Rust with the `libwebrtc` target and Debug severity, so +`RUST_LOG=libwebrtc=debug` must be set before starting the tester. +The tester explicitly requests H264 because NVENC does not support the default +VP8 codec. `LIVEKIT_PREFERRED_HW_ENCODER=nvenc` selects the NVIDIA backend for +that codec, but it does not turn an unavailable NVENC backend into a hard +failure, so those log messages are the current proof that the negotiated video +path used NVIDIA hardware. + +For a memory regression run: + +```bash +python3 scripts/track_process_memory.py --interval 0.01 -- \ + ./build-release/bin/livekit_cuda_video_lifecycle_tester --iterations 1000 +``` + +Monitor NVIDIA memory concurrently: + +```bash +watch -n 1 nvidia-smi +``` + +Allocator caching means final RSS need not return to the initial value. Look +for sustained or iteration-proportional host RSS or GPU-memory growth. diff --git a/src/tests/manual/cuda_video_lifecycle_tester/main.cpp b/src/tests/manual/cuda_video_lifecycle_tester/main.cpp new file mode 100644 index 00000000..595f9bd4 --- /dev/null +++ b/src/tests/manual/cuda_video_lifecycle_tester/main.cpp @@ -0,0 +1,209 @@ +/* + * Copyright 2026 LiveKit + * + * 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. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace std::chrono_literals; + +constexpr int kDefaultIterations = 100; +constexpr int kVideoWidth = 640; +constexpr int kVideoHeight = 360; +constexpr int kFramesPerIteration = 500; +constexpr char kTrackName[] = "cuda-lifecycle-video"; +constexpr auto kH264VideoCodec = static_cast(1); + +struct Configuration { + std::string url; + std::string sender_token; + std::string receiver_token; +}; + +struct Options { + int iteration_count{kDefaultIterations}; +}; + +const char* requiredEnvironment(const char* name) { + const char* value = std::getenv(name); + if (value == nullptr || *value == '\0') { + throw std::runtime_error(std::string(name) + " must be set"); + } + return value; +} + +int parseIterationCount(const char* value) { + try { + const int parsed = std::stoi(value); + if (parsed <= 0) { + throw std::runtime_error("iteration count must be greater than zero"); + } + return parsed; + } catch (const std::invalid_argument&) { + throw std::runtime_error("iteration count must be an integer"); + } catch (const std::out_of_range&) { + throw std::runtime_error("iteration count is out of range"); + } +} + +Options parseOptions(int argc, char* argv[]) { + Options options; + if (argc == 1) { + return options; + } + if (argc == 3 && std::strcmp(argv[1], "--iterations") == 0) { + options.iteration_count = parseIterationCount(argv[2]); + return options; + } + if (argc == 2) { + options.iteration_count = parseIterationCount(argv[1]); + return options; + } + throw std::runtime_error("usage: cuda_video_lifecycle_tester [--iterations N]"); +} + +std::shared_ptr localParticipant(livekit::Room& room) { + auto participant = room.localParticipant().lock(); + if (!participant) { + throw std::runtime_error("local participant is unavailable"); + } + return participant; +} + +void runIteration(const Configuration& config) { + livekit::Room receiver_room; + livekit::Room sender_room; + std::mutex mutex; + std::condition_variable frame_received; + bool received_frame = false; + + try { + if (!receiver_room.connect(config.url, config.receiver_token, {})) { + throw std::runtime_error("receiver failed to connect"); + } + if (!sender_room.connect(config.url, config.sender_token, {})) { + throw std::runtime_error("sender failed to connect"); + } + + const std::string sender_identity = localParticipant(sender_room)->identity(); + if (sender_identity.empty()) { + throw std::runtime_error("sender identity is empty"); + } + + receiver_room.setOnVideoFrameCallback( + sender_identity, kTrackName, + [&mutex, &frame_received, &received_frame](const livekit::VideoFrame&, std::int64_t) { + std::lock_guard lock(mutex); + received_frame = true; + frame_received.notify_all(); + }); + + auto source = std::make_shared(kVideoWidth, kVideoHeight); + auto track = livekit::LocalVideoTrack::createLocalVideoTrack(kTrackName, source); + if (!track) { + throw std::runtime_error("failed to create local video track"); + } + + livekit::TrackPublishOptions publish_options; + publish_options.source = livekit::TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + publish_options.video_codec = kH264VideoCodec; + localParticipant(sender_room)->publishTrack(track, publish_options); + + auto frame = livekit::VideoFrame::create(kVideoWidth, kVideoHeight, livekit::VideoBufferType::I420); + std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); + for (int frame_index = 0; frame_index < kFramesPerIteration; ++frame_index) { + source->captureFrame(frame); + + std::unique_lock lock(mutex); + if (frame_received.wait_for(lock, 20ms, [&received_frame] { return received_frame; })) { + break; + } + } + + { + std::lock_guard lock(mutex); + if (!received_frame) { + throw std::runtime_error("timed out waiting for the receiver to get a video frame"); + } + } + + if (const auto publication = track->publication()) { + localParticipant(sender_room)->unpublishTrack(publication->sid()); + } + receiver_room.clearOnVideoFrameCallback(sender_identity, kTrackName); + } catch (...) { + (void)sender_room.disconnect(); + (void)receiver_room.disconnect(); + throw; + } + + if (!sender_room.disconnect()) { + throw std::runtime_error("sender failed to disconnect"); + } + if (!receiver_room.disconnect()) { + throw std::runtime_error("receiver failed to disconnect"); + } +} + +} // namespace + +int main(int argc, char* argv[]) { + try { + const Options options = parseOptions(argc, argv); + const Configuration config{ + requiredEnvironment("LIVEKIT_URL"), + requiredEnvironment("LIVEKIT_TOKEN_A"), + requiredEnvironment("LIVEKIT_TOKEN_B"), + }; + const int progress_interval = options.iteration_count < 10 ? 1 : options.iteration_count / 10; + + std::cout << "Running " << options.iteration_count << " CUDA video lifecycle cycles\n"; + for (int iteration = 1; iteration <= options.iteration_count; ++iteration) { + if (!livekit::initialize(livekit::LogLevel::Info)) { + throw std::runtime_error("initialize failed at iteration " + std::to_string(iteration)); + } + + try { + runIteration(config); + } catch (...) { + livekit::shutdown(); + throw; + } + livekit::shutdown(); + + if (iteration % progress_interval == 0 || iteration == options.iteration_count) { + std::cout << "Completed " << iteration << "/" << options.iteration_count << " cycles\n"; + } + } + } catch (const std::exception& error) { + std::cerr << "CUDA video lifecycle tester failed: " << error.what() << '\n'; + return 1; + } + + return 0; +} diff --git a/src/tests/manual/memory_lifecycle_tester/README.md b/src/tests/manual/memory_lifecycle_tester/README.md new file mode 100644 index 00000000..17767d52 --- /dev/null +++ b/src/tests/manual/memory_lifecycle_tester/README.md @@ -0,0 +1,129 @@ +# Memory lifecycle tester + +This standalone application repeatedly exercises the public C++ SDK lifecycle +to expose retained lower-level Rust/WebRTC resources. + +By default, the application calls `livekit::initialize()` once, runs every +selected workload for the configured number of iterations, and calls +`livekit::shutdown()` once. This models a long-running process that repeatedly +joins and leaves rooms without relying on global shutdown to release each +room's resources. Pass `--ffi-cycles` to initialize and shut down the SDK on +every iteration instead. + +Workloads can be combined: + +- `--sources`: creates an `AudioSource`/`LocalAudioTrack` and a 1280x720 + `VideoSource`/`LocalVideoTrack`; +- `--connect`: joins and leaves a LiveKit room; +- `--media`: publishes audio and video tracks, captures three frames from each, + and unpublishes them; implies `--connect`; +- `--data-track`: publishes and unpublishes a `LocalDataTrack`; implies + `--connect`; +- `--data-frames`: immediately sends 10 1 KiB payloads; implies + `--data-track` and `--connect`; +- `--receive`: connects a second participant, publishes modest synthetic + audio/video (640x360, default software codec), and waits until the subscriber + receives a few frames via `Room` frame callbacks. Implies `--connect` and + requires `LIVEKIT_TOKEN_B`. This is opt-in and is **not** part of the default + all-workloads run. + +The `--sources` video source intentionally receives no captured frame. This +exercises teardown of the Rust keepalive task that runs until the first raw +video frame arrives and previously retained roughly one 720p frame per +lifecycle. The separate `--media` source takes the normal first-capture path. +`--receive` uses a smaller I420 source so CI can cover subscribe/decode and +`SubscriptionThreadDispatcher` teardown without NVIDIA hardware. + +This tester stays on the software media path. It does not set +`LIVEKIT_PREFERRED_HW_ENCODER` and does not exercise `PlatformAudio`. CUDA +encode/decode shakeouts belong in `src/tests/manual/cuda_video_lifecycle_tester`. + +The executable is built with the normal test targets but is not registered with +CTest. CI invokes it as a short smoke test; longer runs are manual. + +## Build + +```bash +./build.sh release-tests +``` + +## Run + +Start a local LiveKit server and load the test credentials: + +```bash +source scripts/set-test-tokens.sh +``` + +`--connect`, `--media`, `--data-track`, and `--data-frames` require +`LIVEKIT_URL` and `LIVEKIT_TOKEN_A`. `--receive` also requires +`LIVEKIT_TOKEN_B`. The default is 1,000 iterations. With no workload flags, the +tester runs every workload except `--receive` in one SDK lifecycle: + +```bash +./build-release/bin/livekit_memory_lifecycle_tester +``` + +Use `--iterations N` (or the legacy single numeric argument) to set the loop +count. Examples: + +```bash +# Offline audio/video-source lifecycle only. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 1000 --sources + +# Room setup and teardown only. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 1000 --connect + +# Published audio/video capture and teardown. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 1000 --media + +# Incrementally add data-track allocation and data-frame delivery. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 1000 --data-track +./build-release/bin/livekit_memory_lifecycle_tester --iterations 1000 --data-frames + +# Software subscribe/decode path with a second participant. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 100 --receive + +# Combine explicitly selected workloads. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 100 --sources --data-frames + +# Exercise full FFI initialization and disposal on every iteration. +./build-release/bin/livekit_memory_lifecycle_tester --iterations 100 --ffi-cycles +``` + +The tester prints RSS at progress intervals and `RSS final` at the end. Set +`LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB` to fail if that final sample exceeds the +limit. Leave it unset for local and hardware-lab runs; CI sets it per OS. + +```bash +LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB=1048576 \ + ./build-release/bin/livekit_memory_lifecycle_tester --iterations 20 --sources --media --data-frames --receive +``` + +To compare memory behavior before and after a lifecycle fix, use identical +iteration counts and build configurations: + +```bash +python3 scripts/track_process_memory.py --interval 0.01 -- \ + ./build-release/bin/livekit_memory_lifecycle_tester 100 +``` + +The helper accepts `--max-final-rss-kib N` and honors `LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB` +when the flag is omitted. + +Allocator caching means final RSS need not return to the initial value. The +useful regression signal is sustained or iteration-proportional growth. The +optional RSS cap is a smoke ceiling, not a tight leak bound. + +## CI smoke + +PR CI runs two short invocations after the integration tests (release build, +software codecs only): + +```bash +./build-release/bin/livekit_memory_lifecycle_tester --iterations 30 --ffi-cycles --sources +./build-release/bin/livekit_memory_lifecycle_tester --iterations 20 --sources --media --data-frames --receive +``` + +Nightly uses the same commands with 50 iterations each on a debug build. Neither +job is a hardware shakeout. diff --git a/src/tests/manual/memory_lifecycle_tester/main.cpp b/src/tests/manual/memory_lifecycle_tester/main.cpp new file mode 100644 index 00000000..1d093a74 --- /dev/null +++ b/src/tests/manual/memory_lifecycle_tester/main.cpp @@ -0,0 +1,572 @@ +/* + * Copyright 2026 LiveKit + * + * 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. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +// clang-format off +#include +#include +// clang-format on +#elif defined(__APPLE__) +#include +#else +#include +#endif + +namespace { + +constexpr int kDefaultIterations = 1'000; +constexpr int kAudioSampleRate = 48'000; +constexpr int kAudioChannels = 1; +constexpr int kAudioQueueSizeMs = 100; +constexpr int kAudioFrameDurationMs = 10; +constexpr int kVideoWidth = 1'280; +constexpr int kVideoHeight = 720; +constexpr int kMediaFrameCount = 3; +constexpr std::size_t kDataPayloadSize = 1'024; +constexpr int kDataFrameCount = 10; +constexpr char kDataTrackName[] = "lifecycle-data"; +constexpr int kReceiveVideoWidth = 640; +constexpr int kReceiveVideoHeight = 360; +constexpr int kReceiveRequiredFrames = 3; +constexpr int kReceiveCaptureAttempts = 250; +constexpr char kReceiveAudioTrackName[] = "lifecycle-receive-audio"; +constexpr char kReceiveVideoTrackName[] = "lifecycle-receive-video"; +constexpr char kMemoryLimitEnv[] = "LIVEKIT_MEMORY_MAX_FINAL_RSS_KIB"; + +struct Configuration { + std::string url; + std::string token; + std::string receiver_token; +}; + +struct Options { + int iteration_count{kDefaultIterations}; + bool sources{false}; + bool connect{false}; + bool media{false}; + bool data_track{false}; + bool data_frames{false}; + bool receive{false}; + bool ffi_cycles{false}; + bool mode_selected{false}; +}; + +const char* requiredEnvironment(const char* name) { + const char* value = std::getenv(name); + if (value == nullptr || *value == '\0') { + throw std::runtime_error(std::string(name) + " must be set"); + } + return value; +} + +int parseIterationCount(const char* value) { + try { + const int parsed = std::stoi(value); + if (parsed <= 0) { + throw std::runtime_error("iteration count must be greater than zero"); + } + return parsed; + } catch (const std::invalid_argument&) { + throw std::runtime_error("iteration count must be an integer"); + } catch (const std::out_of_range&) { + throw std::runtime_error("iteration count is out of range"); + } +} + +std::optional parseMemoryLimitKib() { + const char* value = std::getenv(kMemoryLimitEnv); + if (value == nullptr || *value == '\0') { + return std::nullopt; + } + try { + const unsigned long long parsed = std::stoull(value); + if (parsed == 0) { + throw std::runtime_error(std::string(kMemoryLimitEnv) + " must be greater than zero"); + } + return static_cast(parsed); + } catch (const std::invalid_argument&) { + throw std::runtime_error(std::string(kMemoryLimitEnv) + " must be an integer"); + } catch (const std::out_of_range&) { + throw std::runtime_error(std::string(kMemoryLimitEnv) + " is out of range"); + } +} + +std::optional currentRssKib() { +#if defined(_WIN32) + PROCESS_MEMORY_COUNTERS counters{}; + counters.cb = sizeof(counters); + if (GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters)) == 0) { + return std::nullopt; + } + return static_cast(counters.WorkingSetSize) / 1024; +#elif defined(__APPLE__) + mach_task_basic_info_data_t info{}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + const kern_return_t result = + task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count); + if (result != KERN_SUCCESS) { + return std::nullopt; + } + return static_cast(info.resident_size) / 1024; +#else + std::ifstream status("/proc/self/status"); + if (!status) { + return std::nullopt; + } + std::string line; + while (std::getline(status, line)) { + if (line.compare(0, 6, "VmRSS:") != 0) { + continue; + } + std::istringstream fields(line.substr(6)); + std::uint64_t kib = 0; + fields >> kib; + if (!fields) { + return std::nullopt; + } + return kib; + } + return std::nullopt; +#endif +} + +std::string formatRssKib(std::uint64_t rss_kib) { + std::ostringstream stream; + stream << rss_kib << " KiB (" << std::fixed << std::setprecision(2) << static_cast(rss_kib) / 1024.0 + << " MiB)"; + return stream.str(); +} + +std::string formatRssSample() { + const auto rss_kib = currentRssKib(); + if (!rss_kib) { + return "unavailable"; + } + return formatRssKib(*rss_kib); +} + +void checkFinalRss() { + const auto rss_kib = currentRssKib(); + if (rss_kib) { + std::cout << "RSS final: " << formatRssKib(*rss_kib) << '\n'; + } else { + std::cout << "RSS final: unavailable\n"; + } + + const auto limit_kib = parseMemoryLimitKib(); + if (!limit_kib) { + return; + } + if (!rss_kib) { + throw std::runtime_error(std::string(kMemoryLimitEnv) + " is set but RSS could not be sampled"); + } + if (*rss_kib > *limit_kib) { + throw std::runtime_error("final RSS " + formatRssKib(*rss_kib) + " exceeds " + kMemoryLimitEnv + " " + + formatRssKib(*limit_kib)); + } +} + +void runUnusedSources() { + auto audio_source = std::make_shared(kAudioSampleRate, kAudioChannels, kAudioQueueSizeMs); + auto audio_track = livekit::LocalAudioTrack::createLocalAudioTrack("lifecycle-audio", audio_source); + if (!audio_track) { + throw std::runtime_error("failed to create local audio track"); + } + // Deliberately do not capture a frame. This covers teardown of the Rust + // keepalive task used before a raw video source receives its first frame. + auto video_source = std::make_shared(kVideoWidth, kVideoHeight); + auto video_track = livekit::LocalVideoTrack::createLocalVideoTrack("lifecycle-video", video_source); + if (!video_track) { + throw std::runtime_error("failed to create local video track"); + } +} + +template +void unpublishTrackIfPublished(const std::shared_ptr& participant, + const std::shared_ptr& track) { + const auto publication = track->publication(); + if (publication) { + participant->unpublishTrack(publication->sid()); + } +} + +std::shared_ptr requireLocalParticipant(livekit::Room& room) { + auto participant = room.localParticipant().lock(); + if (!participant) { + throw std::runtime_error("local participant is unavailable"); + } + return participant; +} + +void runMediaWork(const std::shared_ptr& participant) { + auto audio_source = std::make_shared(kAudioSampleRate, kAudioChannels, 0); + auto audio_track = livekit::LocalAudioTrack::createLocalAudioTrack("lifecycle-audio-active", audio_source); + if (!audio_track) { + throw std::runtime_error("failed to create active local audio track"); + } + + auto video_source = std::make_shared(kVideoWidth, kVideoHeight); + auto video_track = livekit::LocalVideoTrack::createLocalVideoTrack("lifecycle-video-active", video_source); + if (!video_track) { + throw std::runtime_error("failed to create active local video track"); + } + + livekit::TrackPublishOptions audio_options; + audio_options.source = livekit::TrackSource::SOURCE_MICROPHONE; + livekit::TrackPublishOptions video_options; + video_options.source = livekit::TrackSource::SOURCE_CAMERA; + video_options.simulcast = false; + + participant->publishTrack(audio_track, audio_options); + participant->publishTrack(video_track, video_options); + + auto audio_frame = + livekit::AudioFrame::create(kAudioSampleRate, kAudioChannels, kAudioSampleRate * kAudioFrameDurationMs / 1'000); + auto video_frame = livekit::VideoFrame::create(kVideoWidth, kVideoHeight, livekit::VideoBufferType::RGBA); + std::fill(video_frame.data(), video_frame.data() + video_frame.dataSize(), 0x40); + + for (int frame = 0; frame < kMediaFrameCount; ++frame) { + audio_source->captureFrame(audio_frame, 1'000); + video_source->captureFrame(video_frame); + } + + unpublishTrackIfPublished(participant, video_track); + unpublishTrackIfPublished(participant, audio_track); +} + +void runReceiveWork(livekit::Room& sender_room, const Configuration& config) { + livekit::Room receiver_room; + std::mutex mutex; + std::condition_variable frames_received; + int audio_frames = 0; + int video_frames = 0; + + const auto received_enough = [&audio_frames, &video_frames] { + return audio_frames >= kReceiveRequiredFrames && video_frames >= kReceiveRequiredFrames; + }; + + try { + if (!receiver_room.connect(config.url, config.receiver_token, {})) { + throw std::runtime_error("receiver failed to connect to the LiveKit room"); + } + + const auto sender = requireLocalParticipant(sender_room); + const std::string sender_identity = sender->identity(); + if (sender_identity.empty()) { + throw std::runtime_error("sender identity is empty"); + } + + receiver_room.setOnAudioFrameCallback(sender_identity, kReceiveAudioTrackName, + [&mutex, &frames_received, &audio_frames](const livekit::AudioFrame&) { + std::lock_guard lock(mutex); + ++audio_frames; + frames_received.notify_all(); + }); + receiver_room.setOnVideoFrameCallback( + sender_identity, kReceiveVideoTrackName, + [&mutex, &frames_received, &video_frames](const livekit::VideoFrame&, std::int64_t) { + std::lock_guard lock(mutex); + ++video_frames; + frames_received.notify_all(); + }); + + auto audio_source = std::make_shared(kAudioSampleRate, kAudioChannels, 0); + auto audio_track = livekit::LocalAudioTrack::createLocalAudioTrack(kReceiveAudioTrackName, audio_source); + if (!audio_track) { + throw std::runtime_error("failed to create receive audio track"); + } + auto video_source = std::make_shared(kReceiveVideoWidth, kReceiveVideoHeight); + auto video_track = livekit::LocalVideoTrack::createLocalVideoTrack(kReceiveVideoTrackName, video_source); + if (!video_track) { + throw std::runtime_error("failed to create receive video track"); + } + + livekit::TrackPublishOptions audio_options; + audio_options.source = livekit::TrackSource::SOURCE_MICROPHONE; + livekit::TrackPublishOptions video_options; + video_options.source = livekit::TrackSource::SOURCE_CAMERA; + video_options.simulcast = false; + sender->publishTrack(audio_track, audio_options); + sender->publishTrack(video_track, video_options); + + auto audio_frame = + livekit::AudioFrame::create(kAudioSampleRate, kAudioChannels, kAudioSampleRate * kAudioFrameDurationMs / 1'000); + auto video_frame = + livekit::VideoFrame::create(kReceiveVideoWidth, kReceiveVideoHeight, livekit::VideoBufferType::I420); + std::fill(video_frame.data(), video_frame.data() + video_frame.dataSize(), 0x7f); + + for (int attempt = 0; attempt < kReceiveCaptureAttempts; ++attempt) { + audio_source->captureFrame(audio_frame, 20); + video_source->captureFrame(video_frame); + std::unique_lock lock(mutex); + if (frames_received.wait_for(lock, std::chrono::milliseconds(20), received_enough)) { + break; + } + } + + { + std::lock_guard lock(mutex); + if (!received_enough()) { + throw std::runtime_error("timed out waiting for the receiver to get audio and video frames"); + } + } + + unpublishTrackIfPublished(sender, video_track); + unpublishTrackIfPublished(sender, audio_track); + receiver_room.clearOnVideoFrameCallback(sender_identity, kReceiveVideoTrackName); + receiver_room.clearOnAudioFrameCallback(sender_identity, kReceiveAudioTrackName); + } catch (...) { + (void)receiver_room.disconnect(); + throw; + } + + if (!receiver_room.disconnect()) { + throw std::runtime_error("failed to disconnect the receiver from the LiveKit room"); + } +} + +void runRoomIteration(const Configuration& config, const Options& options) { + livekit::Room room; + if (!room.connect(config.url, config.token, {})) { + throw std::runtime_error("failed to connect to the LiveKit room"); + } + + try { + auto participant = requireLocalParticipant(room); + + if (options.media) { + runMediaWork(participant); + } + + if (options.data_track) { + auto result = participant->publishDataTrack(kDataTrackName); + if (!result) { + throw std::runtime_error("failed to publish data track: " + result.error().message); + } + const auto& data_track = result.value(); + + if (options.data_frames) { + const livekit::DataTrackFrame data_frame(std::vector(kDataPayloadSize, 0x5a)); + for (int frame = 0; frame < kDataFrameCount; ++frame) { + auto push_result = data_track->tryPush(data_frame); + if (!push_result) { + throw std::runtime_error("failed to publish data: " + push_result.error().message); + } + } + } + + data_track->unpublishDataTrack(); + } + + if (options.receive) { + runReceiveWork(room, config); + } + } catch (...) { + (void)room.disconnect(); + throw; + } + + if (!room.disconnect()) { + throw std::runtime_error("failed to disconnect from the LiveKit room"); + } +} + +void printUsage(const char* executable) { + std::cerr << "usage: " << executable + << " [--iterations N] [--ffi-cycles] [--sources] [--connect] [--media] [--data-track] " + "[--data-frames] [--receive]\n" + << " --ffi-cycles Initialize and shut down the SDK on every iteration.\n" + << " --sources Create and drop unused local audio/video sources and tracks.\n" + << " --connect Connect to and leave a room.\n" + << " --media Publish, capture, and unpublish audio/video tracks (implies --connect).\n" + << " --data-track Publish and unpublish a data track (implies --connect).\n" + << " --data-frames Send data frames (implies --data-track and --connect).\n" + << " --receive Subscribe to synthetic audio/video from a second participant " + "(implies --connect).\n" + << " By default the SDK is initialized once and all workloads except --receive run on every " + "iteration.\n" + << " A single numeric argument remains supported as the iteration count.\n" + << " Set " << kMemoryLimitEnv << " to fail if final RSS exceeds that many KiB.\n"; +} + +Options parseOptions(int argc, char* argv[]) { + Options options; + for (int argument = 1; argument < argc; ++argument) { + const char* value = argv[argument]; + if (std::strcmp(value, "--iterations") == 0) { + if (++argument == argc) { + throw std::runtime_error("--iterations requires a value"); + } + options.iteration_count = parseIterationCount(argv[argument]); + } else if (std::strcmp(value, "--sources") == 0) { + options.sources = true; + options.mode_selected = true; + } else if (std::strcmp(value, "--connect") == 0) { + options.connect = true; + options.mode_selected = true; + } else if (std::strcmp(value, "--media") == 0) { + options.media = true; + options.mode_selected = true; + } else if (std::strcmp(value, "--data-track") == 0) { + options.data_track = true; + options.mode_selected = true; + } else if (std::strcmp(value, "--data-frames") == 0) { + options.data_frames = true; + options.mode_selected = true; + } else if (std::strcmp(value, "--receive") == 0) { + options.receive = true; + options.mode_selected = true; + } else if (std::strcmp(value, "--ffi-cycles") == 0) { + options.ffi_cycles = true; + } else if (std::strcmp(value, "--help") == 0 || std::strcmp(value, "-h") == 0) { + printUsage(argv[0]); + std::exit(0); + } else if (argument == 1 && argc == 2) { + options.iteration_count = parseIterationCount(value); + } else { + throw std::runtime_error(std::string("unknown argument: ") + value); + } + } + + if (!options.mode_selected) { + options.sources = true; + options.connect = true; + options.media = true; + options.data_track = true; + options.data_frames = true; + } else if (options.media) { + options.connect = true; + } + if (options.data_frames) { + options.data_track = true; + options.connect = true; + } else if (options.data_track) { + options.connect = true; + } + if (options.receive) { + options.connect = true; + } + return options; +} + +void runIteration(const Configuration& config, const Options& options) { + if (options.sources) { + runUnusedSources(); + } + if (options.connect) { + runRoomIteration(config, options); + } +} + +void initializeSdk(int iteration) { + if (!livekit::initialize(livekit::LogLevel::Warn)) { + throw std::runtime_error("initialize failed at iteration " + std::to_string(iteration)); + } +} + +Configuration loadConfiguration(const Options& options) { + Configuration config; + if (!options.connect) { + return config; + } + config.url = requiredEnvironment("LIVEKIT_URL"); + config.token = requiredEnvironment("LIVEKIT_TOKEN_A"); + if (options.receive) { + config.receiver_token = requiredEnvironment("LIVEKIT_TOKEN_B"); + } + return config; +} + +} // namespace + +int main(int argc, char* argv[]) { + try { + const Options options = parseOptions(argc, argv); + const int progress_interval = options.iteration_count < 10 ? 1 : options.iteration_count / 10; + const Configuration config = loadConfiguration(options); + + if (options.ffi_cycles) { + std::cout << "Running " << options.iteration_count << " LiveKit initialize/work/shutdown cycles\n"; + } else { + std::cout << "Running " << options.iteration_count << " workload cycles in one LiveKit SDK lifecycle\n"; + initializeSdk(1); + } + + for (int iteration = 1; iteration <= options.iteration_count; ++iteration) { + if (options.ffi_cycles) { + initializeSdk(iteration); + } + + try { + runIteration(config, options); + } catch (...) { + livekit::shutdown(); + throw; + } + if (options.ffi_cycles) { + livekit::shutdown(); + } + + if (iteration % progress_interval == 0 || iteration == options.iteration_count) { + std::cout << "Completed " << iteration << "/" << options.iteration_count << " cycles (RSS " << formatRssSample() + << ")\n"; + } + } + + if (!options.ffi_cycles) { + try { + checkFinalRss(); + } catch (...) { + livekit::shutdown(); + throw; + } + livekit::shutdown(); + } else { + checkFinalRss(); + } + } catch (const std::exception& error) { + std::cerr << "memory lifecycle tester failed: " << error.what() << '\n'; + printUsage(argv[0]); + return 1; + } + + return 0; +}