From 72dddc0c47bbb2e6b77a5464553643a29dfbbb22 Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 3 Jun 2026 19:28:32 -0600 Subject: [PATCH 1/9] Try docker improvements --- .github/workflows/docker-images.yml | 8 ++++++++ docker/Dockerfile.sdk | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 9c48faf0..a55a5336 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -139,6 +139,8 @@ jobs: set -euxo pipefail docker buildx build \ --platform linux/amd64 \ + --cache-from type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-amd64" \ + --cache-to type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-amd64",mode=max \ --push \ -t "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-amd64" \ -t "${{ needs.detect-changes.outputs.base_image }}:base-main-amd64" \ @@ -183,6 +185,8 @@ jobs: set -euxo pipefail docker buildx build \ --platform linux/arm64 \ + --cache-from type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-arm64" \ + --cache-to type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-arm64",mode=max \ --push \ -t "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-arm64" \ -t "${{ needs.detect-changes.outputs.base_image }}:base-main-arm64" \ @@ -268,6 +272,8 @@ jobs: docker buildx build \ --platform linux/amd64 \ --build-arg BASE_IMAGE="${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-amd64" \ + --cache-from type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-amd64" \ + --cache-to type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-amd64",mode=max \ --push \ -t "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-amd64" \ -t "${{ needs.detect-changes.outputs.sdk_image }}:main-amd64" \ @@ -322,6 +328,8 @@ jobs: docker buildx build \ --platform linux/arm64 \ --build-arg BASE_IMAGE="${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-arm64" \ + --cache-from type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-arm64" \ + --cache-to type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-arm64",mode=max \ --push \ -t "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-arm64" \ -t "${{ needs.detect-changes.outputs.sdk_image }}:main-arm64" \ diff --git a/docker/Dockerfile.sdk b/docker/Dockerfile.sdk index 6d31a878..2a263ee7 100644 --- a/docker/Dockerfile.sdk +++ b/docker/Dockerfile.sdk @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 +# # Copyright 2026 LiveKit # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -45,7 +47,11 @@ RUN mkdir -p /client-sdk-cpp/client-sdk-rust/.cargo \ # Build and install the SDK into a fixed prefix so downstream projects can # consume the image as a prebuilt LiveKit SDK environment. -RUN LLVM_VERSION="$(llvm-config --version | cut -d. -f1)" \ +RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \ + --mount=type=cache,target=/root/.cargo/git,sharing=locked \ + --mount=type=cache,target=/client-sdk-cpp/client-sdk-rust/target,sharing=locked \ + --mount=type=cache,target=/client-sdk-cpp/build-release,sharing=locked \ + LLVM_VERSION="$(llvm-config --version | cut -d. -f1)" \ && export LIBCLANG_PATH="/usr/lib/llvm-${LLVM_VERSION}/lib" \ && export CXXFLAGS="-Wno-deprecated-declarations" \ && export CFLAGS="-Wno-deprecated-declarations" \ From 3759363eacdbbc9452a66b9601cd6029c6bbd2c5 Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 3 Jun 2026 20:46:25 -0600 Subject: [PATCH 2/9] Try better docker behavior --- .github/workflows/builds.yml | 253 ------------------ .github/workflows/ci.yml | 22 +- .github/workflows/docker-images.yml | 371 ++++++++++++++++---------- .github/workflows/docker-validate.yml | 119 --------- AGENTS.md | 18 +- 5 files changed, 265 insertions(+), 518 deletions(-) delete mode 100644 .github/workflows/docker-validate.yml diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index c491b10a..dc4a0e47 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -330,256 +330,3 @@ jobs: else ./build.sh clean-all || true fi - - docker-build-x64: - name: Build (docker-linux-x64) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - name: Checkout (with submodules) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - fetch-depth: 0 - - - name: Check if Dockerfile.base changed - id: base_changed - shell: bash - run: | - set -euo pipefail - if git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -q '^docker/Dockerfile\.base$'; then - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - fi - - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Pull base image from GHCR - if: steps.base_changed.outputs.changed == 'false' - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euxo pipefail - owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - docker pull "ghcr.io/${owner}/client-sdk-cpp-base:base-main-amd64" - docker tag "ghcr.io/${owner}/client-sdk-cpp-base:base-main-amd64" \ - "livekit-cpp-sdk-base-x64:${{ github.sha }}" - - - name: Build base Docker image - if: steps.base_changed.outputs.changed == 'true' - run: | - docker build \ - --build-arg TARGETARCH=amd64 \ - -t livekit-cpp-sdk-base-x64:${{ github.sha }} \ - -f docker/Dockerfile.base \ - docker - - - name: Build SDK Docker image - run: | - docker build \ - --build-arg BASE_IMAGE=livekit-cpp-sdk-base-x64:${{ github.sha }} \ - -t livekit-cpp-sdk-x64:${{ github.sha }} \ - . \ - -f docker/Dockerfile.sdk - - - name: Verify installed SDK inside image - run: | - docker run --rm livekit-cpp-sdk-x64:${{ github.sha }} bash -c \ - 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - - - name: Save Docker image artifact - run: | - docker save livekit-cpp-sdk-x64:${{ github.sha }} | gzip > livekit-cpp-sdk-x64-docker.tar.gz - - - name: Upload Docker image artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: livekit-cpp-sdk-docker-x64 - path: livekit-cpp-sdk-x64-docker.tar.gz - retention-days: 7 - - docker-build-linux-arm64: - name: Build (docker-linux-arm64) - runs-on: ubuntu-24.04-arm - if: github.event_name == 'pull_request' - - steps: - - name: Checkout (with submodules) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - fetch-depth: 0 - - - name: Check if Dockerfile.base changed - id: base_changed - shell: bash - run: | - set -euo pipefail - if git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -q '^docker/Dockerfile\.base$'; then - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - fi - - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Pull base image from GHCR - if: steps.base_changed.outputs.changed == 'false' - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euxo pipefail - owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - docker pull "ghcr.io/${owner}/client-sdk-cpp-base:base-main-arm64" - docker tag "ghcr.io/${owner}/client-sdk-cpp-base:base-main-arm64" \ - "livekit-cpp-sdk-base-arm64:${{ github.sha }}" - - - name: Build base Docker image - if: steps.base_changed.outputs.changed == 'true' - run: | - docker build \ - --build-arg TARGETARCH=arm64 \ - -t livekit-cpp-sdk-base-arm64:${{ github.sha }} \ - -f docker/Dockerfile.base \ - docker - - - name: Build SDK Docker image - run: | - docker build \ - --build-arg BASE_IMAGE=livekit-cpp-sdk-base-arm64:${{ github.sha }} \ - -t livekit-cpp-sdk:${{ github.sha }} \ - . \ - -f docker/Dockerfile.sdk - - - name: Verify installed SDK inside image - run: | - docker run --rm livekit-cpp-sdk:${{ github.sha }} bash -c \ - 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - - - name: Save Docker image artifact - run: | - docker save livekit-cpp-sdk:${{ github.sha }} | gzip > livekit-cpp-sdk-arm64-docker.tar.gz - - - name: Upload Docker image artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: livekit-cpp-sdk-docker-arm64 - path: livekit-cpp-sdk-arm64-docker.tar.gz - retention-days: 7 - - build-collections-linux-arm64: - name: Build (cpp-example-collection-linux-arm64) - runs-on: ubuntu-24.04-arm - needs: docker-build-linux-arm64 - if: github.event_name == 'pull_request' - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - # Reclaim ~30GB before loading the multi-GB SDK image and building the - # example collection inside it. Mirrors the docker-build jobs; without it - # the x64 collection build has hit "no space left on device". - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Download Docker image artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: livekit-cpp-sdk-docker-arm64 - - - name: Load Docker image - run: gzip -dc livekit-cpp-sdk-arm64-docker.tar.gz | docker load - - - name: Build cpp-example-collection against installed SDK - run: | - cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" - docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm livekit-cpp-sdk:${{ github.sha }} bash -lc ' - set -euxo pipefail - git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection - cd /tmp/cpp-example-collection - git fetch --depth 1 origin "$CPP_EX_REF" - git checkout "$CPP_EX_REF" - cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk - cmake --build build --parallel - ' - build-collections-x64: - name: Build (cpp-example-collection-x64) - runs-on: ubuntu-latest - needs: docker-build-x64 - if: github.event_name == 'pull_request' - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - # Reclaim ~30GB before loading the multi-GB SDK image and building the - # example collection inside it. The standard ubuntu-latest runner has hit - # "no space left on device" here without this step. - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Download Docker image artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: livekit-cpp-sdk-docker-x64 - - - name: Load Docker image - run: gzip -dc livekit-cpp-sdk-x64-docker.tar.gz | docker load - - - name: Build cpp-example-collection against installed SDK - run: | - cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" - docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm livekit-cpp-sdk-x64:${{ github.sha }} bash -lc ' - set -euxo pipefail - git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection - cd /tmp/cpp-example-collection - git fetch --depth 1 origin "$CPP_EX_REF" - git checkout "$CPP_EX_REF" - cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk - cmake --build build --parallel - ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 163fdae3..21c828a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ on: permissions: contents: read actions: read - packages: read + packages: write jobs: # Compute once which path groups changed; every other job references these @@ -23,6 +23,7 @@ jobs: runs-on: ubuntu-latest outputs: builds: ${{ steps.filter.outputs.builds }} + docker: ${{ steps.filter.outputs.docker }} tests: ${{ steps.filter.outputs.tests }} docs: ${{ steps.filter.outputs.docs }} cpp_checks: ${{ steps.filter.outputs.cpp_checks }} @@ -40,7 +41,6 @@ jobs: - cpp-example-collection/** - client-sdk-rust/** - cmake/** - - docker/** - CMakeLists.txt - CMakePresets.json - build* @@ -48,6 +48,16 @@ jobs: - vcpkg.json - .github/workflows/ci.yml - .github/workflows/builds.yml + docker: + - docker/** + - .dockerignore + - CMakeLists.txt + - CMakePresets.json + - build* + - .build* + - cmake/** + - .github/workflows/ci.yml + - .github/workflows/docker-images.yml tests: - src/** - include/** @@ -92,6 +102,13 @@ jobs: uses: ./.github/workflows/builds.yml secrets: inherit + docker-images: + name: Docker Images + needs: changes + if: ${{ needs.changes.outputs.docker == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/docker-images.yml + secrets: inherit + tests: name: Tests needs: changes @@ -126,6 +143,7 @@ jobs: needs: - changes - builds + - docker-images - tests - license-check - cpp-checks diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index a55a5336..d4055499 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -1,33 +1,16 @@ name: Docker Images on: - push: - branches: ["main"] - paths: - - src/** - - include/** - - client-sdk-rust/** - - CMakeLists.txt - - build.sh - - build.cmd - - build.h.in - - .build-info.json.in - - CMakePresets.json - - cmake/** - - data/** - - cpp-example-collection - - docker/Dockerfile.base - - docker/Dockerfile.sdk - - .github/workflows/docker-images.yml - - .github/workflows/docker-validate.yml + workflow_call: {} + workflow_dispatch: {} permissions: contents: read packages: write jobs: - detect-changes: - name: Detect Docker image changes + metadata: + name: Docker Metadata runs-on: ubuntu-latest outputs: base_changed: ${{ steps.changes.outputs.base_changed }} @@ -47,8 +30,10 @@ jobs: run: | set -euo pipefail owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - echo "base_image=ghcr.io/${owner}/client-sdk-cpp-base" >> "$GITHUB_OUTPUT" - echo "sdk_image=ghcr.io/${owner}/client-sdk-cpp" >> "$GITHUB_OUTPUT" + { + echo "base_image=ghcr.io/${owner}/client-sdk-cpp-base" + echo "sdk_image=ghcr.io/${owner}/client-sdk-cpp" + } >> "$GITHUB_OUTPUT" - name: Hash base Dockerfile id: hash @@ -64,10 +49,13 @@ jobs: run: | set -euo pipefail - if [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]]; then - changed_files="$(git ls-tree -r --name-only "${{ github.sha }}")" - else + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + changed_files="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")" + elif [[ "${{ github.event_name }}" == "push" && + "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then changed_files="$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}")" + else + changed_files="$(git ls-tree -r --name-only "${{ github.sha }}")" fi echo "Changed files:" @@ -92,24 +80,39 @@ jobs: fi case "${path}" in - docker/Dockerfile.sdk|src/*|include/*|client-sdk-rust/*|cmake/*|data/*|cpp-example-collection|CMakeLists.txt|build.sh|build.cmd|build.h.in|.build-info.json.in|CMakePresets.json|.github/workflows/docker-images.yml|.github/workflows/docker-validate.yml) + docker/*|.dockerignore|src/*|include/*|client-sdk-rust/*|cmake/*|data/*|cpp-example-collection|CMakeLists.txt|CMakePresets.json|build*|.build*|.github/workflows/docker-images.yml) sdk_changed=true ;; esac done <<< "${changed_files}" - echo "base_changed=${base_changed}" >> "$GITHUB_OUTPUT" - echo "sdk_changed=${sdk_changed}" >> "$GITHUB_OUTPUT" - - build-base-amd64: - name: Publish base image (amd64) - runs-on: ubuntu-latest - needs: detect-changes - if: needs.detect-changes.outputs.base_changed == 'true' + { + echo "base_changed=${base_changed}" + echo "sdk_changed=${sdk_changed}" + } >> "$GITHUB_OUTPUT" + + validate: + name: Validate Docker image (${{ matrix.name }}) + needs: metadata + if: needs.metadata.outputs.sdk_changed == 'true' + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-latest + arch: amd64 + - name: linux-arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - - name: Checkout + - name: Checkout (with submodules) uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive + fetch-depth: 0 - name: Free disk space uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 @@ -122,9 +125,6 @@ jobs: docker-images: true swap-storage: true - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Login to GHCR shell: bash env: @@ -133,25 +133,82 @@ jobs: set -euo pipefail echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Build and push base image + - name: Pull base image from GHCR + if: needs.metadata.outputs.base_changed == 'false' shell: bash run: | set -euxo pipefail - docker buildx build \ - --platform linux/amd64 \ - --cache-from type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-amd64" \ - --cache-to type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-amd64",mode=max \ - --push \ - -t "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-amd64" \ - -t "${{ needs.detect-changes.outputs.base_image }}:base-main-amd64" \ + docker pull "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" + docker tag "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" \ + "livekit-cpp-sdk-base-${{ matrix.arch }}:${{ github.sha }}" + + - name: Build base Docker image + if: needs.metadata.outputs.base_changed == 'true' + shell: bash + run: | + set -euxo pipefail + DOCKER_BUILDKIT=1 docker build \ + --build-arg TARGETARCH=${{ matrix.arch }} \ + -t "livekit-cpp-sdk-base-${{ matrix.arch }}:${{ github.sha }}" \ -f docker/Dockerfile.base \ docker - build-base-arm64: - name: Publish base image (arm64) - runs-on: ubuntu-24.04-arm - needs: detect-changes - if: needs.detect-changes.outputs.base_changed == 'true' + - name: Build SDK Docker image + shell: bash + run: | + set -euxo pipefail + DOCKER_BUILDKIT=1 docker build \ + --build-arg BASE_IMAGE="livekit-cpp-sdk-base-${{ matrix.arch }}:${{ github.sha }}" \ + -t "livekit-cpp-sdk-${{ matrix.arch }}:${{ github.sha }}" \ + . \ + -f docker/Dockerfile.sdk + + - name: Verify installed SDK inside image + shell: bash + run: | + set -euxo pipefail + docker run --rm "livekit-cpp-sdk-${{ matrix.arch }}:${{ github.sha }}" bash -c \ + 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' + + - name: Build cpp-example-collection against installed SDK + shell: bash + run: | + set -euxo pipefail + cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" + docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "livekit-cpp-sdk-${{ matrix.arch }}:${{ github.sha }}" bash -lc ' + set -euxo pipefail + git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection + cd /tmp/cpp-example-collection + git fetch --depth 1 origin "$CPP_EX_REF" + git checkout "$CPP_EX_REF" + cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk + cmake --build build --parallel + ' + + publish-base: + name: Publish base image (${{ matrix.name }}) + needs: + - metadata + - validate + if: | + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.metadata.outputs.base_changed == 'true' && + needs.validate.result == 'success' + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-latest + arch: amd64 + - name: linux-arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - name: Checkout @@ -184,23 +241,30 @@ jobs: run: | set -euxo pipefail docker buildx build \ - --platform linux/arm64 \ - --cache-from type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-arm64" \ - --cache-to type=registry,ref="${{ needs.detect-changes.outputs.base_image }}:buildcache-base-arm64",mode=max \ + --platform linux/${{ matrix.arch }} \ + --cache-from type=registry,ref="${{ needs.metadata.outputs.base_image }}:buildcache-base-${{ matrix.arch }}" \ + --cache-to type=registry,ref="${{ needs.metadata.outputs.base_image }}:buildcache-base-${{ matrix.arch }}",mode=max \ --push \ - -t "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-arm64" \ - -t "${{ needs.detect-changes.outputs.base_image }}:base-main-arm64" \ + -t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}" \ + -t "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" \ -f docker/Dockerfile.base \ docker publish-base-manifest: name: Publish base manifest - runs-on: ubuntu-latest needs: - - detect-changes - - build-base-amd64 - - build-base-arm64 - if: needs.detect-changes.outputs.base_changed == 'true' + - metadata + - publish-base + if: | + always() && + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.metadata.outputs.base_changed == 'true' && + needs.publish-base.result == 'success' + permissions: + contents: read + packages: write + runs-on: ubuntu-latest steps: - name: Set up Docker Buildx @@ -219,22 +283,42 @@ jobs: run: | set -euxo pipefail docker buildx imagetools create \ - -t "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}" \ - -t "${{ needs.detect-changes.outputs.base_image }}:base-main" \ - "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-amd64" \ - "${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-arm64" + -t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}" \ + -t "${{ needs.metadata.outputs.base_image }}:base-main" \ + "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-amd64" \ + "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-arm64" - build-sdk-amd64: - name: Publish SDK image (amd64) - runs-on: ubuntu-latest + publish-sdk: + name: Publish SDK image (${{ matrix.name }}) needs: - - detect-changes - - build-base-amd64 + - metadata + - validate + - publish-base + - publish-base-manifest if: | always() && - needs.detect-changes.outputs.sdk_changed == 'true' && - needs.build-base-amd64.result != 'failure' && - needs.build-base-amd64.result != 'cancelled' + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.metadata.outputs.sdk_changed == 'true' && + needs.validate.result == 'success' && + needs.publish-base.result != 'failure' && + needs.publish-base.result != 'cancelled' && + needs.publish-base-manifest.result != 'failure' && + needs.publish-base-manifest.result != 'cancelled' + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-latest + arch: amd64 + - name: linux-arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - name: Checkout @@ -269,47 +353,40 @@ jobs: shell: bash run: | set -euxo pipefail + if [[ "${{ needs.metadata.outputs.base_changed }}" == "true" ]]; then + base_ref="${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}" + else + base_ref="${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" + fi + docker buildx build \ - --platform linux/amd64 \ - --build-arg BASE_IMAGE="${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-amd64" \ - --cache-from type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-amd64" \ - --cache-to type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-amd64",mode=max \ + --platform linux/${{ matrix.arch }} \ + --build-arg BASE_IMAGE="${base_ref}" \ + --cache-from type=registry,ref="${{ needs.metadata.outputs.sdk_image }}:buildcache-sdk-${{ matrix.arch }}" \ + --cache-to type=registry,ref="${{ needs.metadata.outputs.sdk_image }}:buildcache-sdk-${{ matrix.arch }}",mode=max \ --push \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-amd64" \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:main-amd64" \ + -t "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}-${{ matrix.arch }}" \ + -t "${{ needs.metadata.outputs.sdk_image }}:main-${{ matrix.arch }}" \ . \ -f docker/Dockerfile.sdk - build-sdk-arm64: - name: Publish SDK image (arm64) - runs-on: ubuntu-24.04-arm + publish-sdk-manifest: + name: Publish SDK manifest needs: - - detect-changes - - build-base-arm64 + - metadata + - publish-sdk if: | always() && - needs.detect-changes.outputs.sdk_changed == 'true' && - needs.build-base-arm64.result != 'failure' && - needs.build-base-arm64.result != 'cancelled' + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.metadata.outputs.sdk_changed == 'true' && + needs.publish-sdk.result == 'success' + permissions: + contents: read + packages: write + runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - fetch-depth: 0 - - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -321,39 +398,45 @@ jobs: set -euo pipefail echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Build and push SDK image + - name: Publish SDK manifest tags shell: bash run: | set -euxo pipefail - docker buildx build \ - --platform linux/arm64 \ - --build-arg BASE_IMAGE="${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-arm64" \ - --cache-from type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-arm64" \ - --cache-to type=registry,ref="${{ needs.detect-changes.outputs.sdk_image }}:buildcache-sdk-arm64",mode=max \ - --push \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-arm64" \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:main-arm64" \ - . \ - -f docker/Dockerfile.sdk + docker buildx imagetools create \ + -t "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" \ + -t "${{ needs.metadata.outputs.sdk_image }}:main" \ + "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}-amd64" \ + "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}-arm64" - publish-sdk-manifest: - name: Publish SDK manifest - runs-on: ubuntu-latest + validate-published: + name: Validate published Docker image (${{ matrix.name }}) needs: - - detect-changes - - build-sdk-amd64 - - build-sdk-arm64 + - metadata + - publish-sdk-manifest if: | always() && - needs.detect-changes.outputs.sdk_changed == 'true' && - needs.build-sdk-amd64.result != 'failure' && - needs.build-sdk-amd64.result != 'cancelled' && - needs.build-sdk-arm64.result != 'failure' && - needs.build-sdk-arm64.result != 'cancelled' + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + needs.metadata.outputs.sdk_changed == 'true' && + needs.publish-sdk-manifest.result == 'success' + permissions: + contents: read + packages: read + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-latest + - name: linux-arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 - name: Login to GHCR shell: bash @@ -363,12 +446,30 @@ jobs: set -euo pipefail echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Publish SDK manifest tags + - name: Pull SDK image shell: bash run: | set -euxo pipefail - docker buildx imagetools create \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}" \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:main" \ - "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-amd64" \ - "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-arm64" + time docker pull "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" + + - name: Verify installed SDK inside image + shell: bash + run: | + set -euxo pipefail + docker run --rm "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" bash -c \ + 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' + + - name: Build cpp-example-collection against installed SDK + shell: bash + run: | + set -euxo pipefail + cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" + docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" bash -lc ' + set -euxo pipefail + git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection + cd /tmp/cpp-example-collection + git fetch --depth 1 origin "$CPP_EX_REF" + git checkout "$CPP_EX_REF" + cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk + cmake --build build --parallel + ' diff --git a/.github/workflows/docker-validate.yml b/.github/workflows/docker-validate.yml deleted file mode 100644 index a69a3c65..00000000 --- a/.github/workflows/docker-validate.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Docker Validate - -on: - workflow_run: - workflows: ["Docker Images"] - types: [completed] - -permissions: - contents: read - packages: read - -jobs: - validate-x64: - name: Validate Docker image (linux-x64) - runs-on: ubuntu-latest - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - - name: Resolve image name - id: refs - shell: bash - run: | - set -euo pipefail - owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - echo "sdk_image=ghcr.io/${owner}/client-sdk-cpp:sha-${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" - - - name: Login to GHCR - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Pull SDK image - shell: bash - run: | - set -euxo pipefail - time docker pull "${{ steps.refs.outputs.sdk_image }}" - - - name: Verify installed SDK inside image - run: | - docker run --rm "${{ steps.refs.outputs.sdk_image }}" bash -c \ - 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - - - name: Build cpp-example-collection against installed SDK - run: | - cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" - docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ steps.refs.outputs.sdk_image }}" bash -lc ' - set -euxo pipefail - git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection - cd /tmp/cpp-example-collection - git fetch --depth 1 origin "$CPP_EX_REF" - git checkout "$CPP_EX_REF" - cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk - cmake --build build --parallel - ' - - validate-arm64: - name: Validate Docker image (linux-arm64) - runs-on: ubuntu-24.04-arm - if: | - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - - name: Resolve image name - id: refs - shell: bash - run: | - set -euo pipefail - owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - echo "sdk_image=ghcr.io/${owner}/client-sdk-cpp:sha-${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" - - - name: Login to GHCR - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Pull SDK image - shell: bash - run: | - set -euxo pipefail - time docker pull "${{ steps.refs.outputs.sdk_image }}" - - - name: Verify installed SDK inside image - run: | - docker run --rm "${{ steps.refs.outputs.sdk_image }}" bash -c \ - 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - - - name: Build cpp-example-collection against installed SDK - run: | - cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" - docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ steps.refs.outputs.sdk_image }}" bash -lc ' - set -euxo pipefail - git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection - cd /tmp/cpp-example-collection - git fetch --depth 1 origin "$CPP_EX_REF" - git checkout "$CPP_EX_REF" - cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk - cmake --build build --parallel - ' diff --git a/AGENTS.md b/AGENTS.md index 6eaae54d..cd3e35c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -391,18 +391,18 @@ all filtered stages; normal pull requests and pushes use the path filters. - `.github/workflows/generate-docs.yml` — Reusable Doxygen docs validation. - `.github/workflows/license_check.yml` — Cheap license check, run on every CI invocation. -- `.github/workflows/docker-images.yml` — Docker image build/publish workflow, - outside PR-review aggregation. -- `.github/workflows/docker-validate.yml` — Docker image validation workflow, - outside PR-review aggregation. +- `.github/workflows/docker-images.yml` — Reusable Docker packaging workflow. + Called by `ci.yml` when the `docker` filter matches; validates on PRs and + publishes images on `main`. When adding or renaming files that affect a CI stage, update the matching `ci.yml` `changes` filter in the same PR. For example, new build scripts, CMake files, package manifests, or reusable build workflows should be added to -the `builds` filter; test-only helpers to `tests`; formatting/static-analysis -configuration to `cpp_checks`; and docs generation inputs to `docs`. +the `builds` filter; Docker packaging inputs to `docker`; test-only helpers to +`tests`; formatting/static-analysis configuration to `cpp_checks`; and docs +generation inputs to `docs`. Keep broad agent guidance files such as `AGENTS.md` out of the expensive -`builds`, `tests`, `cpp_checks`, and `docs` filters unless they start affecting -generated docs or build artifacts. An `AGENTS.md`-only change should not trigger -those stages; only the always-on cheap checks should run. +`builds`, `docker`, `tests`, `cpp_checks`, and `docs` filters unless they start +affecting generated docs or build artifacts. An `AGENTS.md`-only change should +not trigger those stages; only the always-on cheap checks should run. From 8ed26d34838cb4767645d6d86eb90e3792dfe605 Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 3 Jun 2026 21:05:24 -0600 Subject: [PATCH 3/9] Try a docker-free run --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21c828a4..fd1adabf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,12 +102,12 @@ jobs: uses: ./.github/workflows/builds.yml secrets: inherit - docker-images: - name: Docker Images - needs: changes - if: ${{ needs.changes.outputs.docker == 'true' || github.event_name == 'workflow_dispatch' }} - uses: ./.github/workflows/docker-images.yml - secrets: inherit + # docker-images: + # name: Docker Images + # needs: changes + # if: ${{ needs.changes.outputs.docker == 'true' || github.event_name == 'workflow_dispatch' }} + # uses: ./.github/workflows/docker-images.yml + # secrets: inherit tests: name: Tests @@ -143,7 +143,7 @@ jobs: needs: - changes - builds - - docker-images + # - docker-images - tests - license-check - cpp-checks From 80d57db1bd3d75bf04e865f94f8c5a8015f308d1 Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 9 Jun 2026 17:11:28 -0600 Subject: [PATCH 4/9] Add nightly build, change how docker pushes --- .github/workflows/ci.yml | 15 +- .github/workflows/docker-images.yml | 198 ++++++++++++++++++++---- .github/workflows/nightly.yml | 122 +++++++++++++++ .github/workflows/tests.yml | 225 ++++++++++++++++++++++++---- 4 files changed, 499 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4acd0b95..694e6cfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,7 @@ jobs: - vcpkg.json - .github/workflows/ci.yml - .github/workflows/tests.yml + - .github/workflows/nightly.yml docs: - README.md - include/** @@ -101,12 +102,12 @@ jobs: uses: ./.github/workflows/builds.yml secrets: inherit - # docker-images: - # name: Docker Images - # needs: changes - # if: ${{ needs.changes.outputs.docker == 'true' || github.event_name == 'workflow_dispatch' }} - # uses: ./.github/workflows/docker-images.yml - # secrets: inherit + docker-images: + name: Docker Images + needs: changes + if: ${{ needs.changes.outputs.docker == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/docker-images.yml + secrets: inherit tests: name: Tests @@ -142,7 +143,7 @@ jobs: needs: - changes - builds - # - docker-images + - docker-images - tests - license-check - cpp-checks diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index d4055499..f4e66972 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -1,8 +1,40 @@ name: Docker Images on: - workflow_call: {} - workflow_dispatch: {} + workflow_call: + inputs: + publish_images: + description: Publish built Docker images. + required: false + type: boolean + default: false + cleanup_nightly_images: + description: Delete old nightly Docker image versions after publishing. + required: false + type: boolean + default: false + nightly_retention_days: + description: Number of days to retain nightly Docker image versions. + required: false + type: number + default: 7 + workflow_dispatch: + inputs: + publish_images: + description: Publish built Docker images. + required: false + type: boolean + default: false + cleanup_nightly_images: + description: Delete old nightly Docker image versions after publishing. + required: false + type: boolean + default: false + nightly_retention_days: + description: Number of days to retain nightly Docker image versions. + required: false + type: number + default: 7 permissions: contents: read @@ -18,6 +50,10 @@ jobs: base_hash: ${{ steps.hash.outputs.base_hash }} base_image: ${{ steps.refs.outputs.base_image }} sdk_image: ${{ steps.refs.outputs.sdk_image }} + publish_main: ${{ steps.mode.outputs.publish_main }} + publish_nightly: ${{ steps.mode.outputs.publish_nightly }} + nightly_tag: ${{ steps.mode.outputs.nightly_tag }} + sdk_validation_tag: ${{ steps.mode.outputs.sdk_validation_tag }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -43,6 +79,30 @@ jobs: base_hash="$(shasum -a 256 docker/Dockerfile.base | awk '{print substr($1,1,12)}')" echo "base_hash=${base_hash}" >> "$GITHUB_OUTPUT" + - name: Resolve publish mode + id: mode + shell: bash + run: | + set -euo pipefail + publish_main=false + publish_nightly=false + nightly_tag="nightly-${GITHUB_RUN_ID}" + sdk_validation_tag="sha-${GITHUB_SHA}" + + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then + publish_main=true + elif [[ "${{ inputs.publish_images }}" == "true" ]]; then + publish_nightly=true + sdk_validation_tag="${nightly_tag}" + fi + + { + echo "publish_main=${publish_main}" + echo "publish_nightly=${publish_nightly}" + echo "nightly_tag=${nightly_tag}" + echo "sdk_validation_tag=${sdk_validation_tag}" + } >> "$GITHUB_OUTPUT" + - name: Detect changed inputs id: changes shell: bash @@ -80,7 +140,7 @@ jobs: fi case "${path}" in - docker/*|.dockerignore|src/*|include/*|client-sdk-rust/*|cmake/*|data/*|cpp-example-collection|CMakeLists.txt|CMakePresets.json|build*|.build*|.github/workflows/docker-images.yml) + docker/*|.dockerignore|cmake/*|CMakeLists.txt|CMakePresets.json|build*|.build*|.github/workflows/ci.yml|.github/workflows/docker-images.yml) sdk_changed=true ;; esac @@ -191,8 +251,7 @@ jobs: - metadata - validate if: | - github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && needs.metadata.outputs.base_changed == 'true' && needs.validate.result == 'success' permissions: @@ -240,13 +299,20 @@ jobs: shell: bash run: | set -euxo pipefail + if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then + tag_args=(-t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}") + tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}") + else + tag_args=() + tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-${{ matrix.arch }}") + tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:nightly-latest-base-${{ matrix.arch }}") + fi docker buildx build \ --platform linux/${{ matrix.arch }} \ --cache-from type=registry,ref="${{ needs.metadata.outputs.base_image }}:buildcache-base-${{ matrix.arch }}" \ --cache-to type=registry,ref="${{ needs.metadata.outputs.base_image }}:buildcache-base-${{ matrix.arch }}",mode=max \ --push \ - -t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}" \ - -t "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" \ + "${tag_args[@]}" \ -f docker/Dockerfile.base \ docker @@ -257,8 +323,7 @@ jobs: - publish-base if: | always() && - github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && needs.metadata.outputs.base_changed == 'true' && needs.publish-base.result == 'success' permissions: @@ -282,11 +347,25 @@ jobs: shell: bash run: | set -euxo pipefail + if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then + tag_args=(-t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}") + tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:base-main") + refs=( + "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-amd64" + "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-arm64" + ) + else + tag_args=() + tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base") + tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:nightly-latest-base") + refs=( + "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-amd64" + "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-arm64" + ) + fi docker buildx imagetools create \ - -t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}" \ - -t "${{ needs.metadata.outputs.base_image }}:base-main" \ - "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-amd64" \ - "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-arm64" + "${tag_args[@]}" \ + "${refs[@]}" publish-sdk: name: Publish SDK image (${{ matrix.name }}) @@ -297,8 +376,7 @@ jobs: - publish-base-manifest if: | always() && - github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && needs.metadata.outputs.sdk_changed == 'true' && needs.validate.result == 'success' && needs.publish-base.result != 'failure' && @@ -354,19 +432,29 @@ jobs: run: | set -euxo pipefail if [[ "${{ needs.metadata.outputs.base_changed }}" == "true" ]]; then - base_ref="${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}" + if [[ "${{ needs.metadata.outputs.publish_nightly }}" == "true" ]]; then + base_ref="${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-${{ matrix.arch }}" + else + base_ref="${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}" + fi else base_ref="${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" fi + tag_args=(-t "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}-${{ matrix.arch }}") + if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then + tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:main-${{ matrix.arch }}") + else + tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:nightly-latest-${{ matrix.arch }}") + fi + docker buildx build \ --platform linux/${{ matrix.arch }} \ --build-arg BASE_IMAGE="${base_ref}" \ --cache-from type=registry,ref="${{ needs.metadata.outputs.sdk_image }}:buildcache-sdk-${{ matrix.arch }}" \ --cache-to type=registry,ref="${{ needs.metadata.outputs.sdk_image }}:buildcache-sdk-${{ matrix.arch }}",mode=max \ --push \ - -t "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}-${{ matrix.arch }}" \ - -t "${{ needs.metadata.outputs.sdk_image }}:main-${{ matrix.arch }}" \ + "${tag_args[@]}" \ . \ -f docker/Dockerfile.sdk @@ -377,8 +465,7 @@ jobs: - publish-sdk if: | always() && - github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && needs.metadata.outputs.sdk_changed == 'true' && needs.publish-sdk.result == 'success' permissions: @@ -402,11 +489,16 @@ jobs: shell: bash run: | set -euxo pipefail + tag_args=(-t "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}") + if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then + tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:main") + else + tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:nightly-latest") + fi docker buildx imagetools create \ - -t "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" \ - -t "${{ needs.metadata.outputs.sdk_image }}:main" \ - "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}-amd64" \ - "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}-arm64" + "${tag_args[@]}" \ + "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}-amd64" \ + "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}-arm64" validate-published: name: Validate published Docker image (${{ matrix.name }}) @@ -415,8 +507,7 @@ jobs: - publish-sdk-manifest if: | always() && - github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && needs.metadata.outputs.sdk_changed == 'true' && needs.publish-sdk-manifest.result == 'success' permissions: @@ -450,13 +541,13 @@ jobs: shell: bash run: | set -euxo pipefail - time docker pull "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" + time docker pull "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}" - name: Verify installed SDK inside image shell: bash run: | set -euxo pipefail - docker run --rm "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" bash -c \ + docker run --rm "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}" bash -c \ 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - name: Build cpp-example-collection against installed SDK @@ -464,7 +555,7 @@ jobs: run: | set -euxo pipefail cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" - docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ needs.metadata.outputs.sdk_image }}:sha-${{ github.sha }}" bash -lc ' + docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}" bash -lc ' set -euxo pipefail git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection cd /tmp/cpp-example-collection @@ -473,3 +564,52 @@ jobs: cmake -S . -B build -DLIVEKIT_LOCAL_SDK_DIR=/opt/livekit-sdk cmake --build build --parallel ' + + cleanup-nightly: + name: Cleanup nightly Docker images + needs: + - metadata + - validate-published + if: | + always() && + inputs.cleanup_nightly_images && + needs.metadata.outputs.publish_nightly == 'true' && + needs.validate-published.result == 'success' + permissions: + packages: write + runs-on: ubuntu-latest + + steps: + - name: Delete old nightly package versions + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OWNER: ${{ github.repository_owner }} + RETENTION_DAYS: ${{ inputs.nightly_retention_days }} + run: | + set -euo pipefail + + cutoff="$(date -u -d "${RETENTION_DAYS} days ago" +%s)" + packages=(client-sdk-cpp-base client-sdk-cpp) + + for package in "${packages[@]}"; do + echo "Checking ${package} for nightly image versions older than ${RETENTION_DAYS} days" + gh api --paginate "/orgs/${OWNER}/packages/container/${package}/versions?per_page=100" \ + --jq '.[] | [.id, .created_at, ((.metadata.container.tags // []) | join(","))] | @tsv' | + while IFS=$'\t' read -r version_id created_at tags; do + [[ -n "$version_id" ]] || continue + if [[ ",${tags}," != *,nightly-* && ",${tags}," != *,nightly-latest* ]]; then + continue + fi + + created_epoch="$(date -u -d "${created_at}" +%s)" + if (( created_epoch >= cutoff )); then + continue + fi + + echo "Deleting ${package} version ${version_id} (${created_at}; tags=${tags})" + gh api \ + --method DELETE \ + "/orgs/${OWNER}/packages/container/${package}/versions/${version_id}" + done + done diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..4f66415f --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,122 @@ +name: Nightly + +on: + schedule: + - cron: "23 7 * * *" + workflow_dispatch: + +permissions: + contents: read + actions: read + packages: write + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: false + +jobs: + debug-tests: + name: Debug Tests + uses: ./.github/workflows/tests.yml + with: + build_type: debug + unit_repeat: 100 + integration_repeat: 20 + unit_timeout_minutes: 60 + integration_timeout_minutes: 120 + job_timeout_minutes: 180 + artifact_retention_days: 14 + run_coverage: false + secrets: inherit + + cpp-checks: + name: C++ Checks + uses: ./.github/workflows/cpp-checks.yml + + generate-docs: + name: Generate Docs + uses: ./.github/workflows/generate-docs.yml + with: + upload_artifact: false + + docker-images: + name: Docker Images + uses: ./.github/workflows/docker-images.yml + with: + publish_images: true + cleanup_nightly_images: true + nightly_retention_days: 7 + secrets: inherit + + sanitizer: + name: Sanitizer Checks + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + ASAN_OPTIONS: detect_leaks=0:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + + steps: + - name: Checkout (with submodules) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive + fetch-depth: 1 + + - name: Pull LFS files + run: git lfs pull + + - name: Install deps + run: | + set -eux + sudo apt-get update + sudo apt-get install -y \ + build-essential cmake ninja-build pkg-config \ + llvm-dev libclang-dev clang \ + libva-dev libdrm-dev libgbm-dev libx11-dev libgl1-mesa-dev \ + libxext-dev libxcomposite-dev libxdamage-dev libxfixes-dev \ + libxrandr-dev libxi-dev libxkbcommon-dev \ + libasound2-dev libpulse-dev \ + libssl-dev \ + libprotobuf-dev protobuf-compiler \ + libabsl-dev \ + libwayland-dev libdecor-0-dev + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: stable + + - name: Set build environment + run: | + LLVM_VERSION=$(llvm-config --version | cut -d. -f1) + echo "LIBCLANG_PATH=/usr/lib/llvm-${LLVM_VERSION}/lib" >> "$GITHUB_ENV" + + - name: Configure sanitizer build + run: | + cmake --preset linux-debug-tests \ + -DCMAKE_C_FLAGS="-Wno-deprecated-declarations -fsanitize=address,undefined -fno-omit-frame-pointer" \ + -DCMAKE_CXX_FLAGS="-Wno-deprecated-declarations -fsanitize=address,undefined -fno-omit-frame-pointer" \ + -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \ + -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address,undefined" + + - name: Build sanitizer unit tests + run: cmake --build build-debug --target livekit_unit_tests --parallel 2 + + - name: Run sanitizer unit tests + timeout-minutes: 20 + run: | + build-debug/bin/livekit_unit_tests \ + --gtest_brief=1 \ + --gtest_output=xml:build-debug/sanitizer-unit-test-results.xml + + - name: Upload sanitizer test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sanitizer-test-results + path: build-debug/sanitizer-unit-test-results.xml + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d2118a34..53e884e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,8 +2,93 @@ name: Tests # Called by top-level ci.yml on: - workflow_call: {} - workflow_dispatch: {} + workflow_call: + inputs: + build_type: + description: Debug or release test build. + required: false + type: string + default: release + unit_repeat: + description: Number of times to repeat unit tests. + required: false + type: number + default: 1 + integration_repeat: + description: Number of times to repeat integration tests. + required: false + type: number + default: 1 + unit_timeout_minutes: + description: Unit test step timeout in minutes. + required: false + type: number + default: 10 + integration_timeout_minutes: + description: Integration test step timeout in minutes. + required: false + type: number + default: 5 + job_timeout_minutes: + description: Matrix test job timeout in minutes. + required: false + type: number + default: 60 + artifact_retention_days: + description: Test artifact retention in days. + required: false + type: number + default: 7 + run_coverage: + description: Run the Linux coverage job. + required: false + type: boolean + default: true + workflow_dispatch: + inputs: + build_type: + description: Debug or release test build. + required: false + type: choice + options: + - release + - debug + default: release + unit_repeat: + description: Number of times to repeat unit tests. + required: false + type: number + default: 1 + integration_repeat: + description: Number of times to repeat integration tests. + required: false + type: number + default: 1 + unit_timeout_minutes: + description: Unit test step timeout in minutes. + required: false + type: number + default: 10 + integration_timeout_minutes: + description: Integration test step timeout in minutes. + required: false + type: number + default: 5 + job_timeout_minutes: + description: Matrix test job timeout in minutes. + required: false + type: number + default: 60 + artifact_retention_days: + description: Test artifact retention in days. + required: false + type: number + default: 7 + run_coverage: + description: Run the Linux coverage job. + required: false + type: boolean + default: true permissions: contents: read @@ -35,27 +120,26 @@ jobs: include: - os: ubuntu-latest name: linux-x64 - build_cmd: ./build.sh release-tests e2e-testing: true - os: ubuntu-24.04-arm name: linux-arm64 - build_cmd: ./build.sh release-tests e2e-testing: true - os: macos-26-xlarge name: macos-arm64 - build_cmd: ./build.sh release-tests e2e-testing: true - os: macos-26-large name: macos-x64 - build_cmd: ./build.sh release-tests --macos-arch x86_64 + macos_arch: x86_64 e2e-testing: true # Pinned to Windows 2022 for current VS 17 implementation - os: windows-2022 name: windows-x64 - build_cmd: .\build.cmd release-tests name: Test (${{ matrix.name }}) runs-on: ${{ matrix.os }} + timeout-minutes: ${{ inputs.job_timeout_minutes }} + env: + BUILD_DIR: ${{ inputs.build_type == 'debug' && 'build-debug' || 'build-release' }} steps: - name: Checkout (with submodules) @@ -178,43 +262,131 @@ jobs: if: runner.os != 'Windows' shell: bash run: | + set -euo pipefail chmod +x build.sh - ${{ matrix.build_cmd }} + build_cmd="./build.sh ${{ inputs.build_type }}-tests" + if [[ -n "${{ matrix.macos_arch || '' }}" ]]; then + build_cmd="${build_cmd} --macos-arch ${{ matrix.macos_arch }}" + fi + ${build_cmd} - name: Build tests (Windows) if: runner.os == 'Windows' shell: pwsh - run: ${{ matrix.build_cmd }} + run: .\build.cmd ${{ inputs.build_type }}-tests # ---------- Run unit tests ---------- - name: Run unit tests (Unix) if: runner.os != 'Windows' - timeout-minutes: 10 + timeout-minutes: ${{ inputs.unit_timeout_minutes }} shell: bash run: | - build-release/bin/livekit_unit_tests \ - --gtest_repeat=100 \ + ${{ env.BUILD_DIR }}/bin/livekit_unit_tests \ + --gtest_repeat=${{ inputs.unit_repeat }} \ --gtest_brief=1 \ - --gtest_output=xml:build-release/unit-test-results.xml + --gtest_output=xml:${{ env.BUILD_DIR }}/unit-test-results.xml - name: Run unit tests (Windows) if: runner.os == 'Windows' - timeout-minutes: 10 + timeout-minutes: ${{ inputs.unit_timeout_minutes }} shell: pwsh run: | - build-release\bin\livekit_unit_tests.exe ` - --gtest_repeat=100 ` + ${{ env.BUILD_DIR }}\bin\livekit_unit_tests.exe ` + --gtest_repeat=${{ inputs.unit_repeat }} ` --gtest_brief=1 ` - --gtest_output="xml:build-release\unit-test-results.xml" + --gtest_output="xml:${{ env.BUILD_DIR }}\unit-test-results.xml" # ---------- Start livekit-server for integration tests ---------- - name: Start livekit-server if: matrix.e2e-testing id: livekit_server uses: livekit/dev-server-action@61e2b4dcb170dd3591e0c9b0db3c3fe5db93b500 + continue-on-error: true with: github-token: ${{ github.token }} + - name: Start livekit-server fallback + if: matrix.e2e-testing && steps.livekit_server.outcome == 'failure' + id: livekit_server_fallback + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euxo pipefail + + if [[ "$RUNNER_OS" == "macOS" ]]; then + brew install livekit + livekit_cmd="livekit-server" + else + case "${RUNNER_OS}-${RUNNER_ARCH}" in + Linux-X64) suffix='linux_amd64.tar.gz' ;; + Linux-ARM64) suffix='linux_arm64.tar.gz' ;; + Windows-X64) suffix='windows_amd64.zip' ;; + Windows-ARM64) suffix='windows_arm64.zip' ;; + *) echo "Unsupported platform: ${RUNNER_OS}-${RUNNER_ARCH}"; exit 1 ;; + esac + + tag="$( + gh api repos/livekit/livekit/releases \ + --jq "limit(1; .[] | select([.assets[].name] | any(endswith(\"_${suffix}\"))) | .tag_name)" + )" + if [[ -z "$tag" ]]; then + echo "::error::Could not find a LiveKit release with artifact suffix ${suffix}" + exit 1 + fi + echo "Using LiveKit server ${tag} (${suffix})" + + gh release download "${tag}" \ + --repo livekit/livekit \ + --pattern "*_${suffix}" \ + --output "$RUNNER_TEMP/livekit-server-archive" + + case "${RUNNER_OS}" in + Linux) + tar -xzf "$RUNNER_TEMP/livekit-server-archive" -C "$RUNNER_TEMP" + chmod +x "$RUNNER_TEMP/livekit-server" + livekit_cmd="$RUNNER_TEMP/livekit-server" + ;; + Windows) + unzip -o "$RUNNER_TEMP/livekit-server-archive" -d "$RUNNER_TEMP" + livekit_cmd="$RUNNER_TEMP/livekit-server.exe" + ;; + esac + fi + + "$livekit_cmd" --version + cat > "$RUNNER_TEMP/livekit.yaml" <<'EOF' + logging: { json: true } + EOF + "$livekit_cmd" --config "$RUNNER_TEMP/livekit.yaml" --dev > "$RUNNER_TEMP/livekit.jsonl" 2>&1 & + pid=$! + echo "Running server in the background: pid=$pid" + echo "pid=$pid" >> "$GITHUB_OUTPUT" + echo "log-path=$RUNNER_TEMP/livekit.jsonl" >> "$GITHUB_OUTPUT" + + for i in $(seq 1 30); do + if [[ "$(curl -fsS http://localhost:7880/ || true)" == "OK" ]]; then + echo "Server passed health check" + exit 0 + fi + echo "Waiting for server... (retry $i/30)" + sleep 1 + done + echo "::error::livekit-server fallback did not pass health check" + tail -n 500 "$RUNNER_TEMP/livekit.jsonl" || true + exit 1 + + - name: Resolve livekit-server log path + if: always() && matrix.e2e-testing + id: livekit_server_log + shell: bash + run: | + log_path="${{ steps.livekit_server.outputs.log-path }}" + if [[ -z "$log_path" ]]; then + log_path="${{ steps.livekit_server_fallback.outputs.log-path }}" + fi + echo "log-path=${log_path}" >> "$GITHUB_OUTPUT" + # Needed by token helper script - name: Install livekit-cli if: matrix.e2e-testing @@ -230,20 +402,22 @@ jobs: - name: Run integration tests if: matrix.e2e-testing - timeout-minutes: 5 + timeout-minutes: ${{ inputs.integration_timeout_minutes }} shell: bash env: RUST_LOG: "metrics=debug" run: | set -euo pipefail source .token_helpers/set_data_track_test_tokens.bash - build-release/bin/livekit_integration_tests \ - --gtest_output=xml:build-release/integration-test-results.xml + ${{ env.BUILD_DIR }}/bin/livekit_integration_tests \ + --gtest_repeat=${{ inputs.integration_repeat }} \ + --gtest_recreate_environments_when_repeating=1 \ + --gtest_output=xml:${{ env.BUILD_DIR }}/integration-test-results.xml - name: Dump livekit-server log on failure if: failure() && matrix.e2e-testing shell: bash - run: tail -n 500 "${{ steps.livekit_server.outputs.log-path }}" || true + run: tail -n 500 "${{ steps.livekit_server_log.outputs.log-path }}" || true # ---------- Upload results ---------- - name: Upload test results @@ -252,11 +426,11 @@ jobs: with: name: test-results-${{ matrix.name }} path: | - build-release/unit-test-results.xml - build-release/integration-test-results.xml - ${{ steps.livekit_server.outputs.log-path }} + ${{ env.BUILD_DIR }}/unit-test-results.xml + ${{ env.BUILD_DIR }}/integration-test-results.xml + ${{ steps.livekit_server_log.outputs.log-path }} if-no-files-found: ignore - retention-days: 7 + retention-days: ${{ inputs.artifact_retention_days }} # ============================================================================ # Code Coverage (Linux only) @@ -265,6 +439,7 @@ jobs: # ============================================================================ coverage: name: Code Coverage + if: inputs.run_coverage runs-on: ubuntu-latest # A debug build instrumented with --coverage is far heavier (RAM + disk) # than the release builds. Cap the wall-clock so a stuck/OOM build fails From c213e16e15657cb6b144e938386bb77e3a75b31f Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 9 Jun 2026 17:31:07 -0600 Subject: [PATCH 5/9] Run stress tests in nightly --- .github/workflows/nightly.yml | 3 ++ .github/workflows/tests.yml | 57 +++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4f66415f..901957b4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -22,8 +22,11 @@ jobs: build_type: debug unit_repeat: 100 integration_repeat: 20 + run_stress_tests: true + stress_repeat: 1 unit_timeout_minutes: 60 integration_timeout_minutes: 120 + stress_timeout_minutes: 120 job_timeout_minutes: 180 artifact_retention_days: 14 run_coverage: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 53e884e3..6ea51dbc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,6 +19,16 @@ on: required: false type: number default: 1 + run_stress_tests: + description: Run stress tests that require LiveKit server setup. + required: false + type: boolean + default: false + stress_repeat: + description: Number of times to repeat stress tests. + required: false + type: number + default: 1 unit_timeout_minutes: description: Unit test step timeout in minutes. required: false @@ -29,6 +39,11 @@ on: required: false type: number default: 5 + stress_timeout_minutes: + description: Stress test step timeout in minutes. + required: false + type: number + default: 20 job_timeout_minutes: description: Matrix test job timeout in minutes. required: false @@ -64,6 +79,16 @@ on: required: false type: number default: 1 + run_stress_tests: + description: Run stress tests that require LiveKit server setup. + required: false + type: boolean + default: false + stress_repeat: + description: Number of times to repeat stress tests. + required: false + type: number + default: 1 unit_timeout_minutes: description: Unit test step timeout in minutes. required: false @@ -74,6 +99,11 @@ on: required: false type: number default: 5 + stress_timeout_minutes: + description: Stress test step timeout in minutes. + required: false + type: number + default: 20 job_timeout_minutes: description: Matrix test job timeout in minutes. required: false @@ -298,7 +328,7 @@ jobs: # ---------- Start livekit-server for integration tests ---------- - name: Start livekit-server - if: matrix.e2e-testing + if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) id: livekit_server uses: livekit/dev-server-action@61e2b4dcb170dd3591e0c9b0db3c3fe5db93b500 continue-on-error: true @@ -306,7 +336,7 @@ jobs: github-token: ${{ github.token }} - name: Start livekit-server fallback - if: matrix.e2e-testing && steps.livekit_server.outcome == 'failure' + if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) && steps.livekit_server.outcome == 'failure' id: livekit_server_fallback shell: bash env: @@ -377,7 +407,7 @@ jobs: exit 1 - name: Resolve livekit-server log path - if: always() && matrix.e2e-testing + if: always() && matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) id: livekit_server_log shell: bash run: | @@ -389,7 +419,7 @@ jobs: # Needed by token helper script - name: Install livekit-cli - if: matrix.e2e-testing + if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) shell: bash run: | set -euxo pipefail @@ -401,7 +431,7 @@ jobs: lk --version - name: Run integration tests - if: matrix.e2e-testing + if: matrix.e2e-testing && inputs.integration_repeat > 0 timeout-minutes: ${{ inputs.integration_timeout_minutes }} shell: bash env: @@ -414,8 +444,22 @@ jobs: --gtest_recreate_environments_when_repeating=1 \ --gtest_output=xml:${{ env.BUILD_DIR }}/integration-test-results.xml + - name: Run stress tests + if: matrix.e2e-testing && inputs.run_stress_tests + timeout-minutes: ${{ inputs.stress_timeout_minutes }} + shell: bash + env: + RUST_LOG: "metrics=debug" + run: | + set -euo pipefail + source .token_helpers/set_data_track_test_tokens.bash + ${{ env.BUILD_DIR }}/bin/livekit_stress_tests \ + --gtest_repeat=${{ inputs.stress_repeat }} \ + --gtest_recreate_environments_when_repeating=1 \ + --gtest_output=xml:${{ env.BUILD_DIR }}/stress-test-results.xml + - name: Dump livekit-server log on failure - if: failure() && matrix.e2e-testing + if: failure() && matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) shell: bash run: tail -n 500 "${{ steps.livekit_server_log.outputs.log-path }}" || true @@ -428,6 +472,7 @@ jobs: path: | ${{ env.BUILD_DIR }}/unit-test-results.xml ${{ env.BUILD_DIR }}/integration-test-results.xml + ${{ env.BUILD_DIR }}/stress-test-results.xml ${{ steps.livekit_server_log.outputs.log-path }} if-no-files-found: ignore retention-days: ${{ inputs.artifact_retention_days }} From cab7db66b25323bd97957f6668b43db30ef3cc17 Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 9 Jun 2026 17:33:47 -0600 Subject: [PATCH 6/9] Run nightly nowly --- .github/workflows/nightly.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 901957b4..50fc1b78 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -4,6 +4,11 @@ on: schedule: - cron: "23 7 * * *" workflow_dispatch: + # TEMPORARY: enables validating this new workflow from the PR before it exists + # on the default branch. Remove this pull_request trigger before merging. + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + branches: ["main"] permissions: contents: read From 00914d30ff3f2a2c7e93b43a88b169afbc1b7213 Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 9 Jun 2026 18:53:47 -0600 Subject: [PATCH 7/9] Hopefully clean up docker --- .github/workflows/docker-images.yml | 559 +++++++++++----------------- 1 file changed, 222 insertions(+), 337 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index f4e66972..cba1660f 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -41,74 +41,53 @@ permissions: packages: write jobs: - metadata: - name: Docker Metadata - runs-on: ubuntu-latest - outputs: - base_changed: ${{ steps.changes.outputs.base_changed }} - sdk_changed: ${{ steps.changes.outputs.sdk_changed }} - base_hash: ${{ steps.hash.outputs.base_hash }} - base_image: ${{ steps.refs.outputs.base_image }} - sdk_image: ${{ steps.refs.outputs.sdk_image }} - publish_main: ${{ steps.mode.outputs.publish_main }} - publish_nightly: ${{ steps.mode.outputs.publish_nightly }} - nightly_tag: ${{ steps.mode.outputs.nightly_tag }} - sdk_validation_tag: ${{ steps.mode.outputs.sdk_validation_tag }} + build: + name: Build Docker Images (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-latest + arch: amd64 + - name: linux-arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: - - name: Checkout + - name: Checkout (with submodules) uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + submodules: recursive fetch-depth: 0 - - name: Resolve GHCR image names - id: refs + - name: Resolve Docker metadata + id: meta shell: bash run: | set -euo pipefail - owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - { - echo "base_image=ghcr.io/${owner}/client-sdk-cpp-base" - echo "sdk_image=ghcr.io/${owner}/client-sdk-cpp" - } >> "$GITHUB_OUTPUT" - - name: Hash base Dockerfile - id: hash - shell: bash - run: | - set -euo pipefail + owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" + base_image="ghcr.io/${owner}/client-sdk-cpp-base" + sdk_image="ghcr.io/${owner}/client-sdk-cpp" base_hash="$(shasum -a 256 docker/Dockerfile.base | awk '{print substr($1,1,12)}')" - echo "base_hash=${base_hash}" >> "$GITHUB_OUTPUT" - - name: Resolve publish mode - id: mode - shell: bash - run: | - set -euo pipefail publish_main=false publish_nightly=false + publish_images=false nightly_tag="nightly-${GITHUB_RUN_ID}" sdk_validation_tag="sha-${GITHUB_SHA}" if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then publish_main=true + publish_images=true elif [[ "${{ inputs.publish_images }}" == "true" ]]; then publish_nightly=true + publish_images=true sdk_validation_tag="${nightly_tag}" fi - { - echo "publish_main=${publish_main}" - echo "publish_nightly=${publish_nightly}" - echo "nightly_tag=${nightly_tag}" - echo "sdk_validation_tag=${sdk_validation_tag}" - } >> "$GITHUB_OUTPUT" - - - name: Detect changed inputs - id: changes - shell: bash - run: | - set -euo pipefail - if [[ "${{ github.event_name }}" == "pull_request" ]]; then changed_files="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")" elif [[ "${{ github.event_name }}" == "push" && @@ -130,7 +109,6 @@ jobs: base_changed=false sdk_changed=false - while IFS= read -r path; do [[ -z "${path}" ]] && continue @@ -149,32 +127,18 @@ jobs: { echo "base_changed=${base_changed}" echo "sdk_changed=${sdk_changed}" + echo "base_hash=${base_hash}" + echo "base_image=${base_image}" + echo "sdk_image=${sdk_image}" + echo "publish_images=${publish_images}" + echo "publish_main=${publish_main}" + echo "publish_nightly=${publish_nightly}" + echo "nightly_tag=${nightly_tag}" + echo "sdk_validation_tag=${sdk_validation_tag}" } >> "$GITHUB_OUTPUT" - validate: - name: Validate Docker image (${{ matrix.name }}) - needs: metadata - if: needs.metadata.outputs.sdk_changed == 'true' - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - runner: ubuntu-latest - arch: amd64 - - name: linux-arm64 - runner: ubuntu-24.04-arm - arch: arm64 - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout (with submodules) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - fetch-depth: 0 - - name: Free disk space + if: steps.meta.outputs.sdk_changed == 'true' uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 with: tool-cache: false @@ -186,6 +150,7 @@ jobs: swap-storage: true - name: Login to GHCR + if: steps.meta.outputs.sdk_changed == 'true' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -194,16 +159,16 @@ jobs: echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: Pull base image from GHCR - if: needs.metadata.outputs.base_changed == 'false' + if: steps.meta.outputs.sdk_changed == 'true' && steps.meta.outputs.base_changed == 'false' shell: bash run: | set -euxo pipefail - docker pull "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" - docker tag "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" \ + docker pull "${{ steps.meta.outputs.base_image }}:base-main-${{ matrix.arch }}" + docker tag "${{ steps.meta.outputs.base_image }}:base-main-${{ matrix.arch }}" \ "livekit-cpp-sdk-base-${{ matrix.arch }}:${{ github.sha }}" - name: Build base Docker image - if: needs.metadata.outputs.base_changed == 'true' + if: steps.meta.outputs.sdk_changed == 'true' && steps.meta.outputs.base_changed == 'true' shell: bash run: | set -euxo pipefail @@ -214,6 +179,7 @@ jobs: docker - name: Build SDK Docker image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail @@ -224,6 +190,7 @@ jobs: -f docker/Dockerfile.sdk - name: Verify installed SDK inside image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail @@ -231,6 +198,7 @@ jobs: 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - name: Build cpp-example-collection against installed SDK + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail @@ -245,181 +213,115 @@ jobs: cmake --build build --parallel ' - publish-base: - name: Publish base image (${{ matrix.name }}) - needs: - - metadata - - validate - if: | - (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && - needs.metadata.outputs.base_changed == 'true' && - needs.validate.result == 'success' - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - runner: ubuntu-latest - arch: amd64 - - name: linux-arm64 - runner: ubuntu-24.04-arm - arch: arm64 - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - - name: Login to GHCR - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - - name: Build and push base image + - name: Export images for push + if: steps.meta.outputs.sdk_changed == 'true' && steps.meta.outputs.publish_images == 'true' shell: bash run: | set -euxo pipefail - if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then - tag_args=(-t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}") - tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}") - else - tag_args=() - tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-${{ matrix.arch }}") - tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:nightly-latest-base-${{ matrix.arch }}") + mkdir -p docker-artifacts + if [[ "${{ steps.meta.outputs.base_changed }}" == "true" ]]; then + docker save \ + "livekit-cpp-sdk-base-${{ matrix.arch }}:${{ github.sha }}" \ + -o "docker-artifacts/base-${{ matrix.arch }}.tar" fi - docker buildx build \ - --platform linux/${{ matrix.arch }} \ - --cache-from type=registry,ref="${{ needs.metadata.outputs.base_image }}:buildcache-base-${{ matrix.arch }}" \ - --cache-to type=registry,ref="${{ needs.metadata.outputs.base_image }}:buildcache-base-${{ matrix.arch }}",mode=max \ - --push \ - "${tag_args[@]}" \ - -f docker/Dockerfile.base \ - docker + docker save \ + "livekit-cpp-sdk-${{ matrix.arch }}:${{ github.sha }}" \ + -o "docker-artifacts/sdk-${{ matrix.arch }}.tar" - publish-base-manifest: - name: Publish base manifest - needs: - - metadata - - publish-base - if: | - always() && - (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && - needs.metadata.outputs.base_changed == 'true' && - needs.publish-base.result == 'success' - permissions: - contents: read - packages: write + - name: Upload images for push + if: steps.meta.outputs.sdk_changed == 'true' && steps.meta.outputs.publish_images == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docker-images-${{ matrix.arch }} + path: docker-artifacts/*.tar + if-no-files-found: error + retention-days: 1 + + push: + name: Push Docker Images + needs: build + if: inputs.publish_images || (github.event_name == 'push' && github.ref == 'refs/heads/main') runs-on: ubuntu-latest steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - - name: Login to GHCR + - name: Resolve Docker metadata + id: meta shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Publish base manifest tags - shell: bash - run: | - set -euxo pipefail - if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then - tag_args=(-t "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}") - tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:base-main") - refs=( - "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-amd64" - "${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-arm64" - ) + owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" + base_image="ghcr.io/${owner}/client-sdk-cpp-base" + sdk_image="ghcr.io/${owner}/client-sdk-cpp" + base_hash="$(shasum -a 256 docker/Dockerfile.base | awk '{print substr($1,1,12)}')" + + publish_main=false + publish_nightly=false + nightly_tag="nightly-${GITHUB_RUN_ID}" + sdk_validation_tag="sha-${GITHUB_SHA}" + + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then + publish_main=true else - tag_args=() - tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base") - tag_args+=(-t "${{ needs.metadata.outputs.base_image }}:nightly-latest-base") - refs=( - "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-amd64" - "${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-arm64" - ) + publish_nightly=true + sdk_validation_tag="${nightly_tag}" fi - docker buildx imagetools create \ - "${tag_args[@]}" \ - "${refs[@]}" - - publish-sdk: - name: Publish SDK image (${{ matrix.name }}) - needs: - - metadata - - validate - - publish-base - - publish-base-manifest - if: | - always() && - (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && - needs.metadata.outputs.sdk_changed == 'true' && - needs.validate.result == 'success' && - needs.publish-base.result != 'failure' && - needs.publish-base.result != 'cancelled' && - needs.publish-base-manifest.result != 'failure' && - needs.publish-base-manifest.result != 'cancelled' - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - runner: ubuntu-latest - arch: amd64 - - name: linux-arm64 - runner: ubuntu-24.04-arm - arch: arm64 - runs-on: ${{ matrix.runner }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - fetch-depth: 0 + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + changed_files="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")" + elif [[ "${{ github.event_name }}" == "push" && + "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then + changed_files="$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}")" + else + changed_files="$(git ls-tree -r --name-only "${{ github.sha }}")" + fi - - name: Free disk space - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + base_changed=false + sdk_changed=false + while IFS= read -r path; do + [[ -z "${path}" ]] && continue + if [[ "${path}" == "docker/Dockerfile.base" ]]; then + base_changed=true + sdk_changed=true + fi + case "${path}" in + docker/*|.dockerignore|cmake/*|CMakeLists.txt|CMakePresets.json|build*|.build*|.github/workflows/ci.yml|.github/workflows/docker-images.yml) + sdk_changed=true + ;; + esac + done <<< "${changed_files}" + + { + echo "base_changed=${base_changed}" + echo "sdk_changed=${sdk_changed}" + echo "base_hash=${base_hash}" + echo "base_image=${base_image}" + echo "sdk_image=${sdk_image}" + echo "publish_main=${publish_main}" + echo "publish_nightly=${publish_nightly}" + echo "nightly_tag=${nightly_tag}" + echo "sdk_validation_tag=${sdk_validation_tag}" + } >> "$GITHUB_OUTPUT" + + - name: Download images + if: steps.meta.outputs.sdk_changed == 'true' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true + pattern: docker-images-* + path: docker-artifacts + merge-multiple: true - name: Set up Docker Buildx + if: steps.meta.outputs.sdk_changed == 'true' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to GHCR + if: steps.meta.outputs.sdk_changed == 'true' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -427,135 +329,132 @@ jobs: set -euo pipefail echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Build and push SDK image + - name: Load images + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail - if [[ "${{ needs.metadata.outputs.base_changed }}" == "true" ]]; then - if [[ "${{ needs.metadata.outputs.publish_nightly }}" == "true" ]]; then - base_ref="${{ needs.metadata.outputs.base_image }}:${{ needs.metadata.outputs.nightly_tag }}-base-${{ matrix.arch }}" - else - base_ref="${{ needs.metadata.outputs.base_image }}:base-${{ needs.metadata.outputs.base_hash }}-${{ matrix.arch }}" - fi - else - base_ref="${{ needs.metadata.outputs.base_image }}:base-main-${{ matrix.arch }}" - fi - - tag_args=(-t "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}-${{ matrix.arch }}") - if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then - tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:main-${{ matrix.arch }}") - else - tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:nightly-latest-${{ matrix.arch }}") - fi + for image in docker-artifacts/*.tar; do + docker load -i "${image}" + done - docker buildx build \ - --platform linux/${{ matrix.arch }} \ - --build-arg BASE_IMAGE="${base_ref}" \ - --cache-from type=registry,ref="${{ needs.metadata.outputs.sdk_image }}:buildcache-sdk-${{ matrix.arch }}" \ - --cache-to type=registry,ref="${{ needs.metadata.outputs.sdk_image }}:buildcache-sdk-${{ matrix.arch }}",mode=max \ - --push \ - "${tag_args[@]}" \ - . \ - -f docker/Dockerfile.sdk + - name: Push architecture images + if: steps.meta.outputs.sdk_changed == 'true' + shell: bash + run: | + set -euxo pipefail - publish-sdk-manifest: - name: Publish SDK manifest - needs: - - metadata - - publish-sdk - if: | - always() && - (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && - needs.metadata.outputs.sdk_changed == 'true' && - needs.publish-sdk.result == 'success' - permissions: - contents: read - packages: write - runs-on: ubuntu-latest + for arch in amd64 arm64; do + if [[ "${{ steps.meta.outputs.base_changed }}" == "true" ]]; then + base_local="livekit-cpp-sdk-base-${arch}:${{ github.sha }}" + if [[ "${{ steps.meta.outputs.publish_main }}" == "true" ]]; then + base_tags=( + "${{ steps.meta.outputs.base_image }}:base-${{ steps.meta.outputs.base_hash }}-${arch}" + "${{ steps.meta.outputs.base_image }}:base-main-${arch}" + ) + else + base_tags=( + "${{ steps.meta.outputs.base_image }}:${{ steps.meta.outputs.nightly_tag }}-base-${arch}" + "${{ steps.meta.outputs.base_image }}:nightly-latest-base-${arch}" + ) + fi + + for tag in "${base_tags[@]}"; do + docker tag "${base_local}" "${tag}" + docker push "${tag}" + done + fi - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + sdk_local="livekit-cpp-sdk-${arch}:${{ github.sha }}" + if [[ "${{ steps.meta.outputs.publish_main }}" == "true" ]]; then + sdk_tags=( + "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}-${arch}" + "${{ steps.meta.outputs.sdk_image }}:main-${arch}" + ) + else + sdk_tags=( + "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}-${arch}" + "${{ steps.meta.outputs.sdk_image }}:nightly-latest-${arch}" + ) + fi - - name: Login to GHCR - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + for tag in "${sdk_tags[@]}"; do + docker tag "${sdk_local}" "${tag}" + docker push "${tag}" + done + done - - name: Publish SDK manifest tags + - name: Publish manifest tags + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail - tag_args=(-t "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}") - if [[ "${{ needs.metadata.outputs.publish_main }}" == "true" ]]; then - tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:main") + + if [[ "${{ steps.meta.outputs.base_changed }}" == "true" ]]; then + if [[ "${{ steps.meta.outputs.publish_main }}" == "true" ]]; then + base_tag_args=( + -t "${{ steps.meta.outputs.base_image }}:base-${{ steps.meta.outputs.base_hash }}" + -t "${{ steps.meta.outputs.base_image }}:base-main" + ) + base_refs=( + "${{ steps.meta.outputs.base_image }}:base-${{ steps.meta.outputs.base_hash }}-amd64" + "${{ steps.meta.outputs.base_image }}:base-${{ steps.meta.outputs.base_hash }}-arm64" + ) + else + base_tag_args=( + -t "${{ steps.meta.outputs.base_image }}:${{ steps.meta.outputs.nightly_tag }}-base" + -t "${{ steps.meta.outputs.base_image }}:nightly-latest-base" + ) + base_refs=( + "${{ steps.meta.outputs.base_image }}:${{ steps.meta.outputs.nightly_tag }}-base-amd64" + "${{ steps.meta.outputs.base_image }}:${{ steps.meta.outputs.nightly_tag }}-base-arm64" + ) + fi + + docker buildx imagetools create \ + "${base_tag_args[@]}" \ + "${base_refs[@]}" + fi + + tag_args=(-t "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}") + if [[ "${{ steps.meta.outputs.publish_main }}" == "true" ]]; then + tag_args+=(-t "${{ steps.meta.outputs.sdk_image }}:main") else - tag_args+=(-t "${{ needs.metadata.outputs.sdk_image }}:nightly-latest") + tag_args+=(-t "${{ steps.meta.outputs.sdk_image }}:nightly-latest") fi + docker buildx imagetools create \ "${tag_args[@]}" \ - "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}-amd64" \ - "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}-arm64" - - validate-published: - name: Validate published Docker image (${{ matrix.name }}) - needs: - - metadata - - publish-sdk-manifest - if: | - always() && - (needs.metadata.outputs.publish_main == 'true' || needs.metadata.outputs.publish_nightly == 'true') && - needs.metadata.outputs.sdk_changed == 'true' && - needs.publish-sdk-manifest.result == 'success' - permissions: - contents: read - packages: read - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - runner: ubuntu-latest - - name: linux-arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - - - name: Login to GHCR - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}-amd64" \ + "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}-arm64" - name: Pull SDK image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail - time docker pull "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}" + time docker pull "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}" + + - name: Inspect SDK manifest + if: steps.meta.outputs.sdk_changed == 'true' + shell: bash + run: docker buildx imagetools inspect "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}" - name: Verify installed SDK inside image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail - docker run --rm "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}" bash -c \ + docker run --rm "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}" bash -c \ 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - name: Build cpp-example-collection against installed SDK + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" - docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ needs.metadata.outputs.sdk_image }}:${{ needs.metadata.outputs.sdk_validation_tag }}" bash -lc ' + docker run -e CPP_EX_REF="${cpp_ex_ref}" --rm "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}" bash -lc ' set -euxo pipefail git clone https://github.com/livekit-examples/cpp-example-collection.git /tmp/cpp-example-collection cd /tmp/cpp-example-collection @@ -565,22 +464,8 @@ jobs: cmake --build build --parallel ' - cleanup-nightly: - name: Cleanup nightly Docker images - needs: - - metadata - - validate-published - if: | - always() && - inputs.cleanup_nightly_images && - needs.metadata.outputs.publish_nightly == 'true' && - needs.validate-published.result == 'success' - permissions: - packages: write - runs-on: ubuntu-latest - - steps: - name: Delete old nightly package versions + if: inputs.cleanup_nightly_images && steps.meta.outputs.publish_nightly == 'true' && steps.meta.outputs.sdk_changed == 'true' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 68ada4eddd1dc1c26d9167eebebaa50894198751 Mon Sep 17 00:00:00 2001 From: Alan George Date: Tue, 9 Jun 2026 20:41:29 -0600 Subject: [PATCH 8/9] Try cleaner shutdown for test instability --- src/ffi_client.cpp | 106 ++++++++++++++++++++++++++--- src/ffi_client.h | 16 ++++- src/room.cpp | 4 +- src/tests/unit/test_ffi_client.cpp | 58 ++++++++++++++-- 4 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 0966816f..ae7f0616 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -156,11 +156,47 @@ FfiClient::~FfiClient() { } void FfiClient::shutdown() noexcept { - if (!isInitialized()) { - return; + const bool was_initialized = initialized_.exchange(false, std::memory_order_acq_rel); + + std::vector> listeners_to_drain; + std::vector> pending_to_cancel; + { + const std::scoped_lock guard(lock_); + listeners_to_drain.reserve(listeners_.size()); + for (auto& [id, slot] : listeners_) { + (void)id; + if (slot) { + { + const std::scoped_lock slot_guard(slot->mutex); + slot->removed = true; + } + listeners_to_drain.push_back(std::move(slot)); + } + } + listeners_.clear(); + + pending_to_cancel.reserve(pending_by_id_.size()); + for (auto& [async_id, pending] : pending_by_id_) { + (void)async_id; + if (pending) { + pending_to_cancel.push_back(std::move(pending)); + } + } + pending_by_id_.clear(); + } + + for (auto& pending : pending_to_cancel) { + pending->cancel(); + } + + for (const auto& slot : listeners_to_drain) { + std::unique_lock slot_lock(slot->mutex); + slot->cv.wait(slot_lock, [&slot] { return slot->active_callbacks == 0; }); + } + + if (was_initialized) { + livekit_ffi_dispose(); } - initialized_.store(false, std::memory_order_release); - livekit_ffi_dispose(); } bool FfiClient::initialize(bool capture_logs) { @@ -177,13 +213,29 @@ bool FfiClient::isInitialized() const noexcept { return initialized_.load(std::m FfiClient::ListenerId FfiClient::addListener(const FfiClient::Listener& listener) { const std::scoped_lock guard(lock_); const FfiClient::ListenerId id = next_listener_id++; - listeners_[id] = listener; + listeners_[id] = std::make_shared(listener); return id; } void FfiClient::removeListener(ListenerId id) { - const std::scoped_lock guard(lock_); - listeners_.erase(id); + std::shared_ptr slot; + { + const std::scoped_lock guard(lock_); + auto it = listeners_.find(id); + if (it == listeners_.end()) { + return; + } + slot = std::move(it->second); + listeners_.erase(it); + } + + const auto this_thread = std::this_thread::get_id(); + std::unique_lock slot_lock(slot->mutex); + slot->removed = true; + slot->cv.wait(slot_lock, [&slot, this_thread] { + const auto self_active = slot->active_threads.count(this_thread) != 0; + return slot->active_callbacks == 0 || (self_active && slot->active_callbacks == 1); + }); } proto::FfiResponse FfiClient::sendRequest(const proto::FfiRequest& request) const { @@ -221,9 +273,12 @@ proto::FfiResponse FfiClient::sendRequest(const proto::FfiRequest& request) cons void FfiClient::pushEvent(const proto::FfiEvent& event) const { std::unique_ptr to_complete; - std::vector listeners_copy; + std::vector> listeners_copy; { const std::scoped_lock guard(lock_); + if (!initialized_.load(std::memory_order_acquire)) { + return; + } // Complete pending future if this event is a callback with async_id if (auto async_id = ExtractAsyncId(event)) { @@ -246,8 +301,39 @@ void FfiClient::pushEvent(const proto::FfiEvent& event) const { } // Notify listeners outside lock - for (auto& listener : listeners_copy) { - listener(event); + for (const auto& slot : listeners_copy) { + Listener listener; + const auto this_thread = std::this_thread::get_id(); + { + const std::scoped_lock slot_guard(slot->mutex); + if (slot->removed) { + continue; + } + ++slot->active_callbacks; + ++slot->active_threads[this_thread]; + listener = slot->listener; + } + + try { + listener(event); + } catch (const std::exception& e) { + LK_LOG_ERROR("FfiClient listener threw: {}", e.what()); + } catch (...) { + LK_LOG_ERROR("FfiClient listener threw: unknown exception"); + } + + { + const std::scoped_lock slot_guard(slot->mutex); + const auto thread_it = slot->active_threads.find(this_thread); + if (thread_it != slot->active_threads.end()) { + --thread_it->second; + if (thread_it->second == 0) { + slot->active_threads.erase(thread_it); + } + } + --slot->active_callbacks; + } + slot->cv.notify_all(); } } diff --git a/src/ffi_client.h b/src/ffi_client.h index 5ea0a89a..58f242ae 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -17,6 +17,7 @@ #pragma once #include +#include #include #include #include @@ -24,7 +25,9 @@ #include #include #include +#include #include +#include #include "data_track.pb.h" #include "livekit/data_track_error.h" @@ -176,6 +179,17 @@ class LIVEKIT_INTERNAL_API FfiClient { } }; + struct ListenerSlot { + explicit ListenerSlot(Listener cb) : listener(std::move(cb)) {} + + Listener listener; + std::mutex mutex; + std::condition_variable cv; + std::unordered_map active_threads; + int active_callbacks = 0; + bool removed = false; + }; + template std::future registerAsync(AsyncId async_id, std::function match, std::function&)> handler); @@ -187,7 +201,7 @@ class LIVEKIT_INTERNAL_API FfiClient { // removed. bool cancelPendingByAsyncId(AsyncId async_id); - std::unordered_map listeners_; + std::unordered_map> listeners_; std::atomic next_listener_id{1}; mutable std::mutex lock_; mutable std::unordered_map> pending_by_id_; diff --git a/src/room.cpp b/src/room.cpp index 3ad58938..71680389 100644 --- a/src/room.cpp +++ b/src/room.cpp @@ -436,7 +436,7 @@ void Room::onEvent(const FfiEvent& event) { if (event.message_case() == FfiEvent::kRpcMethodInvocation) { const auto& rpc = event.rpc_method_invocation(); - LocalParticipant* lp = nullptr; + std::shared_ptr lp; { const std::scoped_lock guard(lock_); if (!local_participant_) { @@ -448,7 +448,7 @@ void Room::onEvent(const FfiEvent& event) { // RPC is not targeted at this room's local participant; ignore. return; } - lp = local_participant_.get(); + lp = local_participant_; } // Call outside the lock to avoid deadlocks / re-entrancy issues. diff --git a/src/tests/unit/test_ffi_client.cpp b/src/tests/unit/test_ffi_client.cpp index f6982ebb..96904393 100644 --- a/src/tests/unit/test_ffi_client.cpp +++ b/src/tests/unit/test_ffi_client.cpp @@ -17,9 +17,13 @@ #include #include +#include +#include #include +#include #include #include +#include #include #include "ffi.pb.h" @@ -38,6 +42,18 @@ void handleSignal(int signal) { } } +void emitLogEvent() { + proto::FfiEvent event; + auto* record = event.mutable_logs()->add_records(); + record->set_level(proto::LOG_INFO); + record->set_target("test"); + record->set_message("listener event"); + + std::string bytes; + ASSERT_TRUE(event.SerializeToString(&bytes)); + ffiEventCallback(reinterpret_cast(bytes.data()), bytes.size()); +} + } // namespace class FfiClientTest : public ::testing::Test { @@ -144,15 +160,47 @@ TEST_F(FfiClientTest, RemoveListenerIsIdempotent) { EXPECT_NO_THROW(FfiClient::instance().removeListener(id)); } -TEST_F(FfiClientTest, ListenerRegistrationSurvivesShutdownReinitCycle) { +TEST_F(FfiClientTest, ShutdownClearsListenerRegistrations) { FfiClient::instance().initialize(false); - const auto id = FfiClient::instance().addListener([](const proto::FfiEvent&) {}); + std::atomic listener_calls{0}; + const auto id = FfiClient::instance().addListener([&listener_calls](const proto::FfiEvent&) { ++listener_calls; }); EXPECT_NE(id, 0); - // shutdown() does not clear the C++-side listener map today; document that - // contract here so a future refactor that changes it is a deliberate choice. FfiClient::instance().shutdown(); - EXPECT_NO_THROW(FfiClient::instance().removeListener(id)); + ASSERT_FALSE(FfiClient::instance().isInitialized()); + + ASSERT_TRUE(FfiClient::instance().initialize(false)); + emitLogEvent(); + EXPECT_EQ(listener_calls.load(), 0); +} + +TEST_F(FfiClientTest, RemoveListenerWaitsForInFlightCallback) { + ASSERT_TRUE(FfiClient::instance().initialize(false)); + + std::promise callback_entered; + auto callback_entered_future = callback_entered.get_future(); + std::promise release_callback; + auto release_callback_future = release_callback.get_future(); + std::atomic callback_completed{false}; + + const auto id = FfiClient::instance().addListener([&](const proto::FfiEvent&) { + callback_entered.set_value(); + release_callback_future.wait(); + callback_completed.store(true); + }); + + std::thread callback_thread([] { emitLogEvent(); }); + ASSERT_EQ(callback_entered_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + + auto remove_future = std::async(std::launch::async, [&] { FfiClient::instance().removeListener(id); }); + EXPECT_EQ(remove_future.wait_for(std::chrono::milliseconds(50)), std::future_status::timeout); + EXPECT_FALSE(callback_completed.load()); + + release_callback.set_value(); + callback_thread.join(); + + EXPECT_EQ(remove_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + EXPECT_TRUE(callback_completed.load()); } TEST_F(FfiClientTest, PanicEvent) { From 133d8c53db752a7ccc1519ab742fa1a1a6cb9b65 Mon Sep 17 00:00:00 2001 From: Alan George Date: Wed, 10 Jun 2026 10:43:50 -0600 Subject: [PATCH 9/9] Try lifecycle state --- src/ffi_client.cpp | 121 +++++++++++++++++++---------- src/ffi_client.h | 9 ++- src/tests/unit/test_ffi_client.cpp | 52 +++++++++++++ 3 files changed, 140 insertions(+), 42 deletions(-) diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index ae7f0616..94a6af24 100644 --- a/src/ffi_client.cpp +++ b/src/ffi_client.cpp @@ -18,6 +18,7 @@ #include #include +#include #include "data_track.pb.h" #include "e2ee.pb.h" @@ -146,72 +147,110 @@ FfiClient& FfiClient::instance() noexcept { return instance; } -// clang-tidy flags this as a trivial destructor in release mode -// due to the assert being pre-processed out -// NOLINTNEXTLINE(modernize-use-equals-default) FfiClient::~FfiClient() { - assert(!initialized_.load() && - "LiveKit SDK was not shut down before process exit. " - "Call livekit::shutdown()."); + if (lifecycle_state_.load() == LifecycleState::Initialized) { + // Explicitly use this over spdlog + // spdlog can throw, and wrapping in try/catch also flags "empty catch" clang-tidy check + std::fputs( + "LiveKit SDK was not shut down before process exit. " + "Call livekit::shutdown().\n", + stderr); + std::fflush(stderr); + } } void FfiClient::shutdown() noexcept { - const bool was_initialized = initialized_.exchange(false, std::memory_order_acq_rel); + bool dispose_ffi = false; + try { + // Atomically claim shutdown ownership; only the caller that transitions + // Initialized -> ShuttingDown may drain callbacks and dispose the FFI. + LifecycleState expected = LifecycleState::Initialized; + // Note: compare_exchange_strong transitions Initialized -> ShuttingDown + if (!lifecycle_state_.compare_exchange_strong(expected, LifecycleState::ShuttingDown, std::memory_order_acq_rel)) { + return; + } + dispose_ffi = true; - std::vector> listeners_to_drain; - std::vector> pending_to_cancel; - { - const std::scoped_lock guard(lock_); - listeners_to_drain.reserve(listeners_.size()); - for (auto& [id, slot] : listeners_) { - (void)id; - if (slot) { - { - const std::scoped_lock slot_guard(slot->mutex); - slot->removed = true; + std::vector> listeners_to_drain; + std::vector> pending_to_cancel; + { + const std::scoped_lock guard(lock_); + listeners_to_drain.reserve(listeners_.size()); + for (auto& [id, slot] : listeners_) { + (void)id; + if (slot) { + { + const std::scoped_lock slot_guard(slot->mutex); + slot->removed = true; + } + listeners_to_drain.push_back(std::move(slot)); } - listeners_to_drain.push_back(std::move(slot)); } - } - listeners_.clear(); + listeners_.clear(); - pending_to_cancel.reserve(pending_by_id_.size()); - for (auto& [async_id, pending] : pending_by_id_) { - (void)async_id; - if (pending) { - pending_to_cancel.push_back(std::move(pending)); + pending_to_cancel.reserve(pending_by_id_.size()); + for (auto& [async_id, pending] : pending_by_id_) { + (void)async_id; + if (pending) { + pending_to_cancel.push_back(std::move(pending)); + } } + pending_by_id_.clear(); } - pending_by_id_.clear(); - } - for (auto& pending : pending_to_cancel) { - pending->cancel(); - } + for (auto& pending : pending_to_cancel) { + pending->cancel(); + } - for (const auto& slot : listeners_to_drain) { - std::unique_lock slot_lock(slot->mutex); - slot->cv.wait(slot_lock, [&slot] { return slot->active_callbacks == 0; }); - } + const auto this_thread = std::this_thread::get_id(); + for (const auto& slot : listeners_to_drain) { + std::unique_lock slot_lock(slot->mutex); + slot->cv.wait(slot_lock, [&slot, this_thread] { + const auto thread_it = slot->active_threads.find(this_thread); + const int self_active = thread_it == slot->active_threads.end() ? 0 : thread_it->second; + return slot->active_callbacks == self_active; + }); + } - if (was_initialized) { livekit_ffi_dispose(); + dispose_ffi = false; + lifecycle_state_.store(LifecycleState::Uninitialized, std::memory_order_release); + } catch (...) { + if (dispose_ffi) { + livekit_ffi_dispose(); + lifecycle_state_.store(LifecycleState::Uninitialized, std::memory_order_release); + } + (void)std::fputs("LiveKit SDK shutdown failed during local cleanup.\n", stderr); + (void)std::fflush(stderr); } } bool FfiClient::initialize(bool capture_logs) { - if (isInitialized()) { + LifecycleState expected = LifecycleState::Uninitialized; + if (!lifecycle_state_.compare_exchange_strong(expected, LifecycleState::Initializing, std::memory_order_acq_rel)) { return false; } - initialized_.store(true, std::memory_order_release); - livekit_ffi_initialize(&ffiEventCallback, capture_logs, LIVEKIT_BUILD_FLAVOR, LIVEKIT_BUILD_VERSION); + + try { + livekit_ffi_initialize(&ffiEventCallback, capture_logs, LIVEKIT_BUILD_FLAVOR, LIVEKIT_BUILD_VERSION); + } catch (...) { + lifecycle_state_.store(LifecycleState::Uninitialized, std::memory_order_release); + throw; + } + + lifecycle_state_.store(LifecycleState::Initialized, std::memory_order_release); return true; } -bool FfiClient::isInitialized() const noexcept { return initialized_.load(std::memory_order_acquire); } +bool FfiClient::isInitialized() const noexcept { + return lifecycle_state_.load(std::memory_order_acquire) == LifecycleState::Initialized; +} FfiClient::ListenerId FfiClient::addListener(const FfiClient::Listener& listener) { const std::scoped_lock guard(lock_); + if (lifecycle_state_.load(std::memory_order_acquire) == LifecycleState::ShuttingDown) { + logAndThrow("FfiClient::addListener failed: LiveKit is shutting down"); + } const FfiClient::ListenerId id = next_listener_id++; listeners_[id] = std::make_shared(listener); return id; @@ -276,7 +315,7 @@ void FfiClient::pushEvent(const proto::FfiEvent& event) const { std::vector> listeners_copy; { const std::scoped_lock guard(lock_); - if (!initialized_.load(std::memory_order_acquire)) { + if (lifecycle_state_.load(std::memory_order_acquire) != LifecycleState::Initialized) { return; } diff --git a/src/ffi_client.h b/src/ffi_client.h index 58f242ae..e6f35d83 100644 --- a/src/ffi_client.h +++ b/src/ffi_client.h @@ -150,6 +150,13 @@ class LIVEKIT_INTERNAL_API FfiClient { private: FfiClient() = default; + enum class LifecycleState : std::uint8_t { + Uninitialized, + Initializing, + Initialized, + ShuttingDown, + }; + // Base class for type-erased pending ops struct PendingBase { AsyncId async_id = 0; // Client-generated async ID for cancellation @@ -209,6 +216,6 @@ class LIVEKIT_INTERNAL_API FfiClient { void pushEvent(const proto::FfiEvent& event) const; friend void ffiEventCallback(const uint8_t* buf, size_t len); - std::atomic initialized_{false}; + std::atomic lifecycle_state_{LifecycleState::Uninitialized}; }; } // namespace livekit diff --git a/src/tests/unit/test_ffi_client.cpp b/src/tests/unit/test_ffi_client.cpp index 96904393..dd98e638 100644 --- a/src/tests/unit/test_ffi_client.cpp +++ b/src/tests/unit/test_ffi_client.cpp @@ -203,6 +203,58 @@ TEST_F(FfiClientTest, RemoveListenerWaitsForInFlightCallback) { EXPECT_TRUE(callback_completed.load()); } +TEST_F(FfiClientTest, ShutdownFromListenerDoesNotDeadlock) { + ASSERT_TRUE(FfiClient::instance().initialize(false)); + + std::atomic shutdown_returned{false}; + const auto id = FfiClient::instance().addListener([&shutdown_returned](const proto::FfiEvent&) { + FfiClient::instance().shutdown(); + shutdown_returned.store(true); + }); + ASSERT_NE(id, 0); + + auto callback_future = std::async(std::launch::async, [] { emitLogEvent(); }); + EXPECT_EQ(callback_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + EXPECT_TRUE(shutdown_returned.load()); + EXPECT_FALSE(FfiClient::instance().isInitialized()); +} + +TEST_F(FfiClientTest, ShutdownRejectsReinitializeAndDropsNewEventsWhileDraining) { + ASSERT_TRUE(FfiClient::instance().initialize(false)); + + std::promise callback_entered; + auto callback_entered_future = callback_entered.get_future(); + std::promise release_callback; + auto release_callback_future = release_callback.get_future(); + std::atomic listener_calls{0}; + + const auto id = FfiClient::instance().addListener([&](const proto::FfiEvent&) { + ++listener_calls; + callback_entered.set_value(); + release_callback_future.wait(); + }); + ASSERT_NE(id, 0); + + std::thread callback_thread([] { emitLogEvent(); }); + ASSERT_EQ(callback_entered_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + + auto shutdown_future = std::async(std::launch::async, [] { FfiClient::instance().shutdown(); }); + for (int i = 0; i < 5000 && FfiClient::instance().isInitialized(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_FALSE(FfiClient::instance().isInitialized()); + EXPECT_EQ(shutdown_future.wait_for(std::chrono::milliseconds(50)), std::future_status::timeout); + EXPECT_FALSE(FfiClient::instance().initialize(false)); + + emitLogEvent(); + EXPECT_EQ(listener_calls.load(), 1); + + release_callback.set_value(); + callback_thread.join(); + EXPECT_EQ(shutdown_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + EXPECT_FALSE(FfiClient::instance().isInitialized()); +} + TEST_F(FfiClientTest, PanicEvent) { // Wire up a signal handler to ensure the panic event raises SIGTERM // (and that users can handle it)