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 1deadcac..694e6cfe 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/** @@ -61,6 +71,7 @@ jobs: - vcpkg.json - .github/workflows/ci.yml - .github/workflows/tests.yml + - .github/workflows/nightly.yml docs: - README.md - include/** @@ -91,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 @@ -125,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 9c48faf0..cba1660f 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -1,73 +1,100 @@ 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: + 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 packages: write jobs: - detect-changes: - name: Detect Docker image changes - 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 }} + 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" >> "$GITHUB_OUTPUT" - 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: Detect changed inputs - id: changes - 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 - 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:" @@ -82,7 +109,6 @@ jobs: base_changed=false sdk_changed=false - while IFS= read -r path; do [[ -z "${path}" ]] && continue @@ -92,26 +118,27 @@ 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|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}" >> "$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' - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + { + 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" - name: Free disk space + if: steps.meta.outputs.sdk_changed == 'true' uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 with: tool-cache: false @@ -122,10 +149,8 @@ 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 + if: steps.meta.outputs.sdk_changed == 'true' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -133,127 +158,170 @@ 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: steps.meta.outputs.sdk_changed == 'true' && steps.meta.outputs.base_changed == 'false' shell: bash run: | set -euxo pipefail - docker buildx build \ - --platform linux/amd64 \ - --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 "${{ 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: steps.meta.outputs.sdk_changed == 'true' && steps.meta.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' - - 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 + - name: Build SDK Docker image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + 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: Build and push base image + - name: Verify installed SDK inside image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash run: | set -euxo pipefail - docker buildx build \ - --platform linux/arm64 \ - --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" \ - -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' + docker run --rm "livekit-cpp-sdk-${{ matrix.arch }}:${{ github.sha }}" bash -c \ + 'test -f /opt/livekit-sdk/lib/cmake/LiveKit/LiveKitConfig.cmake' - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - - name: Login to GHCR + - name: Build cpp-example-collection against installed SDK + if: steps.meta.outputs.sdk_changed == 'true' 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 + 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 + ' + + - name: Export images for push + if: steps.meta.outputs.sdk_changed == 'true' && steps.meta.outputs.publish_images == 'true' shell: bash 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" + 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 save \ + "livekit-cpp-sdk-${{ matrix.arch }}:${{ github.sha }}" \ + -o "docker-artifacts/sdk-${{ matrix.arch }}.tar" - build-sdk-amd64: - name: Publish SDK image (amd64) + - 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 - needs: - - detect-changes - - build-base-amd64 - if: | - always() && - needs.detect-changes.outputs.sdk_changed == 'true' && - needs.build-base-amd64.result != 'failure' && - needs.build-base-amd64.result != 'cancelled' 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 + - name: Resolve Docker metadata + id: meta + 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)}')" + + 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 + publish_nightly=true + sdk_validation_tag="${nightly_tag}" + fi + + 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 + + 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 }} @@ -261,106 +329,172 @@ 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 - docker buildx build \ - --platform linux/amd64 \ - --build-arg BASE_IMAGE="${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-amd64" \ - --push \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:sha-${{ github.sha }}-amd64" \ - -t "${{ needs.detect-changes.outputs.sdk_image }}:main-amd64" \ - . \ - -f docker/Dockerfile.sdk + for image in docker-artifacts/*.tar; do + docker load -i "${image}" + done - build-sdk-arm64: - name: Publish SDK image (arm64) - runs-on: ubuntu-24.04-arm - needs: - - detect-changes - - build-base-arm64 - if: | - always() && - needs.detect-changes.outputs.sdk_changed == 'true' && - needs.build-base-arm64.result != 'failure' && - needs.build-base-arm64.result != 'cancelled' + - name: Push architecture images + if: steps.meta.outputs.sdk_changed == 'true' + shell: bash + run: | + set -euxo pipefail - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: recursive - fetch-depth: 0 + 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 - - 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 + 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: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + for tag in "${sdk_tags[@]}"; do + docker tag "${sdk_local}" "${tag}" + docker push "${tag}" + done + done - - name: Login to GHCR + - name: Publish manifest tags + if: steps.meta.outputs.sdk_changed == 'true' shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + set -euxo pipefail + + 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 "${{ steps.meta.outputs.sdk_image }}:nightly-latest") + fi - - name: Build and push SDK image + docker buildx imagetools create \ + "${tag_args[@]}" \ + "${{ 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 - docker buildx build \ - --platform linux/arm64 \ - --build-arg BASE_IMAGE="${{ needs.detect-changes.outputs.base_image }}:base-${{ needs.detect-changes.outputs.base_hash }}-arm64" \ - --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 - - publish-sdk-manifest: - name: Publish SDK manifest - runs-on: ubuntu-latest - needs: - - detect-changes - - build-sdk-amd64 - - build-sdk-arm64 - 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' + time docker pull "${{ steps.meta.outputs.sdk_image }}:${{ steps.meta.outputs.sdk_validation_tag }}" - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - 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: Login to GHCR + - name: Verify installed SDK inside image + if: steps.meta.outputs.sdk_changed == 'true' shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - echo "${GITHUB_TOKEN}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + set -euxo pipefail + 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: Publish SDK manifest tags + - name: Build cpp-example-collection against installed SDK + if: steps.meta.outputs.sdk_changed == 'true' 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" + cpp_ex_ref="$(git rev-parse HEAD:cpp-example-collection)" + 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 + 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 + ' + + - 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 }} + 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/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/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..50fc1b78 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,130 @@ +name: Nightly + +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 + 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 + 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 + 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..6ea51dbc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,8 +2,123 @@ 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 + 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 + type: number + default: 10 + integration_timeout_minutes: + description: Integration test step timeout in minutes. + 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 + 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 + 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 + type: number + default: 10 + integration_timeout_minutes: + description: Integration test step timeout in minutes. + 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 + 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 +150,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,46 +292,134 @@ 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 + 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 with: github-token: ${{ github.token }} + - name: Start livekit-server fallback + if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) && 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 && (inputs.integration_repeat > 0 || inputs.run_stress_tests) + 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 + if: matrix.e2e-testing && (inputs.integration_repeat > 0 || inputs.run_stress_tests) shell: bash run: | set -euxo pipefail @@ -229,21 +431,37 @@ jobs: lk --version - name: Run integration tests - if: matrix.e2e-testing - timeout-minutes: 5 + if: matrix.e2e-testing && inputs.integration_repeat > 0 + 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 + ${{ 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: 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 - build-release/bin/livekit_integration_tests \ - --gtest_output=xml:build-release/integration-test-results.xml + ${{ 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.outputs.log-path }}" || true + run: tail -n 500 "${{ steps.livekit_server_log.outputs.log-path }}" || true # ---------- Upload results ---------- - name: Upload test results @@ -252,11 +470,12 @@ 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 + ${{ env.BUILD_DIR }}/stress-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 +484,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 diff --git a/AGENTS.md b/AGENTS.md index c03c458a..e5fe89e8 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. 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" \ diff --git a/src/ffi_client.cpp b/src/ffi_client.cpp index 0966816f..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,44 +147,134 @@ 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 { - if (!isInitialized()) { - return; + 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; + } + 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(); + } + + 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; + }); + } + + 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); } - initialized_.store(false, std::memory_order_release); - livekit_ffi_dispose(); } 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] = 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 +312,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 (lifecycle_state_.load(std::memory_order_acquire) != LifecycleState::Initialized) { + return; + } // Complete pending future if this event is a callback with async_id if (auto async_id = ExtractAsyncId(event)) { @@ -246,8 +340,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..e6f35d83 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" @@ -147,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 @@ -176,6 +186,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 +208,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_; @@ -195,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/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..dd98e638 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,99 @@ 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, 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) {