snapshot of https://github.com/livekit/livekit.git @ 08c5433449c106943231037d0b892895f6e92d3b (2026-09-15)
This commit is contained in:
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @livekit/cs-devs
|
||||
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: File a bug report and help us improve LiveKit
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Server**
|
||||
- Version: [0.x.x]
|
||||
- Environment: [e.g. local dev, EKS]
|
||||
- any other information about your deployment setup
|
||||
|
||||
**Client**
|
||||
- SDK: [e.g. js, ios]
|
||||
- Version: [0.x.x]
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. two clients are connected to room
|
||||
2. 3rd client joins
|
||||
3. 3rd client does '...'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
BIN
.github/banner_dark.png
vendored
Normal file
BIN
.github/banner_dark.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
.github/banner_light.png
vendored
Normal file
BIN
.github/banner_light.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
50
.github/scripts/go-version.sh
vendored
Executable file
50
.github/scripts/go-version.sh
vendored
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
#
|
||||
# Print the Go toolchain version pinned by a Dockerfile's golang builder image.
|
||||
#
|
||||
# usage: .github/scripts/go-version.sh [dockerfile] (default: Dockerfile)
|
||||
#
|
||||
# The pinned `golang:X.Y.Z-alpineA.B@sha256:...` builder image is the single source
|
||||
# of truth, and Renovate keeps it current via an approvable PR. Each workflow reads
|
||||
# the version out of the Dockerfile it builds against (setup-go cannot parse a
|
||||
# Dockerfile itself), so a job's Go runtime always matches the image it produces.
|
||||
#
|
||||
# Deliberately NOT derived from go.mod's `go` directive: that is the module
|
||||
# minimum, owned by the toolchain and by dependency requirements, and the go
|
||||
# command may rewrite it to a bare major.minor.
|
||||
set -euo pipefail
|
||||
|
||||
dockerfile="${1:-Dockerfile}"
|
||||
|
||||
if [ ! -f "$dockerfile" ]; then
|
||||
echo "$dockerfile: no such file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(sed -nE 's/^FROM[[:space:]]+golang:([0-9]+\.[0-9]+\.[0-9]+)[^[:space:]]*.*/\1/p' "$dockerfile" | sort -u)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "$dockerfile: no pinned 'FROM golang:X.Y.Z-...' base image found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(printf '%s\n' "$version" | wc -l)" -ne 1 ]; then
|
||||
echo "$dockerfile pins more than one Go version: $(echo $version)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
84
.github/workflows/buildtest.yaml
vendored
Normal file
84
.github/workflows/buildtest.yaml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: shogo82148/actions-setup-redis@2f3253b148c73d7a0682eae73e862b777a4fa74e # v1
|
||||
with:
|
||||
redis-version: "6.x"
|
||||
auto-start: true
|
||||
- run: redis-cli ping
|
||||
|
||||
# Test with the same Go toolchain the published image is built with
|
||||
- name: Resolve Go version
|
||||
id: go-version
|
||||
run: |
|
||||
# Assign first so a failed lookup trips `set -e`; inside echo its exit
|
||||
# status would be discarded and an empty version written.
|
||||
version=$(.github/scripts/go-version.sh Dockerfile)
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: ${{ steps.go-version.outputs.version }}
|
||||
|
||||
- name: Replace mutexes
|
||||
run: |
|
||||
go get github.com/sasha-s/go-deadlock
|
||||
grep -rl sync.Mutex ./pkg | xargs sed -i 's/sync\.Mutex/deadlock\.Mutex/g'
|
||||
grep -rl sync.RWMutex ./pkg | xargs sed -i 's/sync\.RWMutex/deadlock\.RWMutex/g'
|
||||
grep -rl deadlock.Mutex ./pkg | xargs go tool goimports -w
|
||||
grep -rl deadlock.RWMutex ./pkg | xargs go tool goimports -w
|
||||
go mod tidy
|
||||
|
||||
# Run mage at the version go.mod pins, rather than the mage-action's
|
||||
# `version: latest`, so the binary matches the mage/mg library the magefile is
|
||||
# compiled against and Renovate owns the version like any other module.
|
||||
- name: Mage Build
|
||||
run: go run github.com/magefile/mage build
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: v2.11.4
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MallocNanoZone=0 go test -race -json -v ./... 2>&1 | tee /tmp/gotest.log | go tool gotestfmt
|
||||
|
||||
# Upload the original go test log as an artifact for later review.
|
||||
- name: Upload test log
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
if: always()
|
||||
with:
|
||||
name: test-log
|
||||
path: /tmp/gotest.log
|
||||
if-no-files-found: error
|
||||
87
.github/workflows/docker.yaml
vendored
Normal file
87
.github/workflows/docker.yaml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Release to Docker
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Controls when the action will run.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master # publish to 'master' tag
|
||||
tags:
|
||||
- 'v*.*.*' # publish on version tags, updates 'latest' tag
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
livekit/livekit-server
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
|
||||
# Generate with the same Go toolchain the published image is built with
|
||||
- name: Resolve Go version
|
||||
id: go-version
|
||||
run: |
|
||||
# Assign first so a failed lookup trips `set -e`; inside echo its exit
|
||||
# status would be discarded and an empty version written.
|
||||
version=$(.github/scripts/go-version.sh Dockerfile)
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: ${{ steps.go-version.outputs.version }}
|
||||
|
||||
- name: Download Go modules
|
||||
run: go mod download
|
||||
|
||||
# Run mage at the version go.mod pins, rather than the mage-action's
|
||||
# `version: latest`, so the binary matches the mage/mg library the magefile is
|
||||
# compiled against and Renovate owns the version like any other module.
|
||||
- name: Generate code
|
||||
run: go run github.com/magefile/mage generate
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
56
.github/workflows/release.yaml
vendored
Normal file
56
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# only publish on version tags
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- name: Fetch all tags
|
||||
run: git fetch --force --tags
|
||||
|
||||
# Release with the same Go toolchain the published image is built with
|
||||
- name: Resolve Go version
|
||||
id: go-version
|
||||
run: |
|
||||
# Assign first so a failed lookup trips `set -e`; inside echo its exit
|
||||
# status would be discarded and an empty version written.
|
||||
version=$(.github/scripts/go-version.sh Dockerfile)
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: ${{ steps.go-version.outputs.version }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
25
.github/workflows/slack-notifier.yaml
vendored
Normal file
25
.github/workflows/slack-notifier.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: PR Slack Notifier
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [review_requested, reopened, closed]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: pr-slack-${{ github.event.pull_request.number }}-${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
notify-devs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: livekit/slack-notifier-action@main
|
||||
with:
|
||||
config_json: ${{ secrets.SLACK_NOTIFY_CONFIG_JSON }}
|
||||
slack_token: ${{ secrets.SLACK_PR_NOTIFIER_TOKEN }}
|
||||
53
.github/workflows/test-server-docker.yaml
vendored
Normal file
53
.github/workflows/test-server-docker.yaml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# Copyright 2026 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Builds and publishes the SDK conformance test server (cmd/test-server) to
|
||||
# Docker Hub as livekit/test-server:latest on every push to master. The server
|
||||
# SDK repos boot this image in their CI to run region-failover tests.
|
||||
name: Release Test Server to Docker
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
with:
|
||||
context: .
|
||||
file: cmd/test-server/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# Each build overrides the latest tag so SDK CI always boots the
|
||||
# current mock server.
|
||||
tags: livekit/test-server:latest
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# checksums of file tree
|
||||
.checksumgo
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
livekit-server
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
bin/
|
||||
proto/
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
dist/
|
||||
25
.golangci.yml
Normal file
25
.golangci.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- depguard
|
||||
- staticcheck
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
protojson:
|
||||
deny:
|
||||
- pkg: google.golang.org/protobuf/encoding/protojson
|
||||
desc: use github.com/livekit/protocol/utils/protojson instead — it sets DiscardUnknown by default to avoid schema-drift footguns
|
||||
staticcheck:
|
||||
checks:
|
||||
- "all"
|
||||
- "-ST1000"
|
||||
- "-ST1003"
|
||||
- "-ST1020"
|
||||
- "-ST1021"
|
||||
- "-ST1022"
|
||||
- "-SA1019"
|
||||
- "-QF1008"
|
||||
exclusions:
|
||||
generated: strict
|
||||
64
.goreleaser.yaml
Normal file
64
.goreleaser.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
builds:
|
||||
- id: livekit
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
main: ./cmd/server
|
||||
binary: livekit-server
|
||||
goarm:
|
||||
- "7"
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
||||
archives:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE
|
||||
release:
|
||||
github:
|
||||
owner: livekit
|
||||
name: livekit
|
||||
draft: true
|
||||
prerelease: auto
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
gomod:
|
||||
# proxy must be disabled: it re-fetches modules from the Go proxy in a clean
|
||||
# directory, which does not honor the replace directive in go.mod
|
||||
# (replace github.com/pion/dtls/v3 => ... pinning v3.1.2 for gst whipsink compat)
|
||||
proxy: false
|
||||
mod: mod
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
2011
CHANGELOG.md
Normal file
2011
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
61
Dockerfile
Normal file
61
Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Pinned by digest so the build is reproducible even if the tag is republished.
|
||||
# The tag is kept alongside it for readability; Renovate updates both together.
|
||||
# This image is also the single source of truth for the Go toolchain: CI reads the
|
||||
# version out of this line (see .github/scripts/go-version.sh) so tests and images
|
||||
# always run the same runtime.
|
||||
FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
RUN echo building for "$TARGETPLATFORM"
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Build with exactly the toolchain in this image; fail (don't silently download)
|
||||
# if go.mod ever requires a newer version, so the pinned image stays authoritative.
|
||||
ENV GOTOOLCHAIN=local
|
||||
|
||||
# Copy the Go Modules manifests
|
||||
COPY go.mod go.mod
|
||||
COPY go.sum go.sum
|
||||
# cache deps before building and copying source so that we don't need to re-download as much
|
||||
# and so that source changes don't invalidate our downloaded layer
|
||||
RUN go mod download
|
||||
|
||||
# Copy the go source
|
||||
COPY cmd/ cmd/
|
||||
COPY pkg/ pkg/
|
||||
COPY test/ test/
|
||||
COPY version/ version/
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-server ./cmd/server
|
||||
|
||||
FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
|
||||
|
||||
# Pull the latest security patches for base packages within the pinned Alpine
|
||||
# release. The digest above is only refreshed by a Renovate PR, so without this
|
||||
# an image can ship base-package CVEs that Alpine has already fixed.
|
||||
# NOTE: this relies on the build starting with a cold layer cache, which is true
|
||||
# today because the release workflow configures no buildx cache. If cache-from/
|
||||
# cache-to is ever added, this layer needs a cache-busting ARG (as ../cloud does
|
||||
# with SECURITY_REFRESH) or it will be served stale.
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
COPY --from=builder /workspace/livekit-server /livekit-server
|
||||
|
||||
# Run the binary.
|
||||
ENTRYPOINT ["/livekit-server"]
|
||||
202
LICENSE
Normal file
202
LICENSE
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
13
NOTICE
Normal file
13
NOTICE
Normal file
@@ -0,0 +1,13 @@
|
||||
Copyright 2023 LiveKit, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
238
README.md
Normal file
238
README.md
Normal file
@@ -0,0 +1,238 @@
|
||||
<!--BEGIN_BANNER_IMAGE-->
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="/.github/banner_dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="/.github/banner_light.png">
|
||||
<img style="width:100%;" alt="The LiveKit icon, the name of the repository and some sample code in the background." src="https://raw.githubusercontent.com/livekit/livekit/main/.github/banner_light.png">
|
||||
</picture>
|
||||
|
||||
<!--END_BANNER_IMAGE-->
|
||||
|
||||
# LiveKit: Realtime infrastructure for voice, video, and AI agents
|
||||
|
||||
[LiveKit](https://livekit.com) is an open source platform for building voice, video, and physical AI agents.
|
||||
This repository is the LiveKit server: a scalable, distributed WebRTC SFU that moves realtime audio, video, and
|
||||
data between people, devices, and AI models. The SDKs, agents frameworks, and companion services are linked in
|
||||
the table at the bottom of this page.
|
||||
|
||||
LiveKit's server is written in Go, using the awesome [Pion WebRTC](https://github.com/pion/webrtc) implementation.
|
||||
|
||||
[](https://github.com/livekit/livekit/stargazers/)
|
||||
[](https://livekit.io/join-slack)
|
||||
[](https://twitter.com/livekit)
|
||||
[](https://deepwiki.com/livekit/livekit)
|
||||
[](https://github.com/livekit/livekit/releases/latest)
|
||||
[](https://github.com/livekit/livekit/actions/workflows/buildtest.yaml)
|
||||
[](https://github.com/livekit/livekit/blob/master/LICENSE)
|
||||
|
||||
<!--BEGIN_AGENTS_INFO-->
|
||||
> [!IMPORTANT]
|
||||
> If you're building Voice AI, [LiveKit Agents](https://github.com/livekit/agents) is the SDK for code-first realtime voice agents. STT, LLM, TTS, turn detection, [expressive speech](https://docs.livekit.io/agents/models/tts/expressive/), [keyterm accuracy](https://docs.livekit.io/agents/models/stt/keyterms/), tool usage, and telephony all come bundled in the framework. It's available in both [Python](https://github.com/livekit/agents) and [Node.js](https://github.com/livekit/agents-js).
|
||||
>
|
||||
> ```python
|
||||
> # agent.py
|
||||
> from livekit import agents
|
||||
> from livekit.agents import Agent, AgentServer, AgentSession, STTContextOptions, TurnHandlingOptions, inference
|
||||
>
|
||||
> server = AgentServer()
|
||||
>
|
||||
>
|
||||
> @server.rtc_session(agent_name="my-agent")
|
||||
> async def my_agent(ctx: agents.JobContext):
|
||||
> session = AgentSession(
|
||||
> stt=inference.STT(model="deepgram/nova-3", language="multi"),
|
||||
> llm=inference.LLM(model="google/gemma-4-31b-it"),
|
||||
> tts=inference.TTS(model="inworld/inworld-tts-2", voice="Ashley"),
|
||||
> turn_handling=TurnHandlingOptions(turn_detection=inference.TurnDetector()),
|
||||
> stt_context_options=STTContextOptions(keyterms=["LiveKit", "Acme Corp"]),
|
||||
> expressive=True,
|
||||
> )
|
||||
> await session.start(room=ctx.room, agent=Agent(instructions="You are a helpful voice AI assistant."))
|
||||
> await session.generate_reply(instructions="Greet the user and offer your assistance.")
|
||||
>
|
||||
>
|
||||
> if __name__ == "__main__":
|
||||
> agents.cli.run_app(server)
|
||||
> ```
|
||||
>
|
||||
> Models come from [LiveKit Inference](https://docs.livekit.io/agents/models/) with no per-provider API keys, and LiveKit Cloud handles [deployment](https://docs.livekit.io/deploy/agents/) and [observability](https://docs.livekit.io/deploy/observability/). Visit the docs for more info at [docs.livekit.io/agents](https://docs.livekit.io/agents/).
|
||||
<!--END_AGENTS_INFO-->
|
||||
|
||||
## Used in production by
|
||||
|
||||
LiveKit carries billions of calls a year for companies including Salesforce, Nvidia, Oracle, SAP,
|
||||
Deutsche Telekom, Spotify, Tinder, Coursera, Headspace, Skydio, Retell, Decagon, Cresta, and HeyGen. Read how
|
||||
[Assort Health](https://livekit.com/customers/assort-health), [Playback](https://livekit.com/customers/playback), and
|
||||
[Polymath Robotics](https://livekit.com/customers/polymath) use it, or see [more customers](https://livekit.com/customers).
|
||||
|
||||
## Features
|
||||
|
||||
- Scalable, distributed WebRTC SFU (Selective Forwarding Unit)
|
||||
- People, devices, and AI agents join the same room as participants, with
|
||||
[agent dispatch](https://docs.livekit.io/agents/server/agent-dispatch/) to route agents in automatically or on demand
|
||||
- Modern, full-featured SDKs for web, mobile, desktop, embedded, and server
|
||||
- Built for production, supports JWT authentication
|
||||
- Robust networking and connectivity, UDP/TCP/TURN
|
||||
- Easy to deploy: single binary, Docker or Kubernetes
|
||||
- Advanced features including:
|
||||
- [speaker detection](https://docs.livekit.io/transport/media/subscribe/)
|
||||
- [simulcast](https://docs.livekit.io/transport/media/publish/)
|
||||
- [selective subscription](https://docs.livekit.io/transport/media/subscribe/)
|
||||
- [moderation APIs](https://docs.livekit.io/intro/basics/rooms-participants-tracks/participants/)
|
||||
- [end-to-end encryption](https://docs.livekit.io/transport/media/encryption/)
|
||||
- SVC codecs (VP9, AV1)
|
||||
- [data tracks](https://docs.livekit.io/transport/data/data-tracks/) for low-latency telemetry and teleoperation
|
||||
- [telephony](https://docs.livekit.io/telephony/) over SIP
|
||||
- [webhooks](https://docs.livekit.io/intro/basics/rooms-participants-tracks/webhooks-events/)
|
||||
- [distributed and multi-region](https://docs.livekit.io/transport/self-hosting/distributed/)
|
||||
|
||||
## Documentation & Guides
|
||||
|
||||
https://docs.livekit.io
|
||||
|
||||
Working with a coding agent? Give it the [LiveKit Docs MCP server](https://docs.livekit.io/mcp/), or start with the
|
||||
[coding agents guide](https://docs.livekit.io/intro/coding-agents/).
|
||||
|
||||
## Live Demos
|
||||
|
||||
- [Talk to a voice agent](https://livekit.com) built with LiveKit Agents
|
||||
- [LiveKit Meet](https://meet.livekit.io) ([source](https://github.com/livekit-examples/meet))
|
||||
- [Spatial Audio](https://spatial-audio-demo.livekit.io/) ([source](https://github.com/livekit-examples/spatial-audio))
|
||||
- Livestreaming from OBS Studio ([source](https://github.com/livekit-examples/livestream))
|
||||
|
||||
## Install
|
||||
|
||||
> [!TIP]
|
||||
> We recommend installing [LiveKit CLI](https://github.com/livekit/livekit-cli) along with the server. It lets you access
|
||||
> server APIs, create tokens, generate test traffic, and scaffold and deploy agents.
|
||||
|
||||
The following will install LiveKit's media server:
|
||||
|
||||
### MacOS
|
||||
|
||||
```shell
|
||||
brew install livekit
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```shell
|
||||
curl -sSL https://get.livekit.io | bash
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Download the [latest release here](https://github.com/livekit/livekit/releases/latest)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Starting LiveKit
|
||||
|
||||
Start LiveKit in development mode by running `livekit-server --dev`. It'll use a placeholder API key/secret pair.
|
||||
|
||||
```
|
||||
API Key: devkey
|
||||
API Secret: secret
|
||||
```
|
||||
|
||||
To customize your setup for production, refer to our [deployment docs](https://docs.livekit.io/transport/self-hosting/deployment/)
|
||||
|
||||
### Creating access token
|
||||
|
||||
A user connecting to a LiveKit room requires an [access token](https://docs.livekit.io/frontends/build/authentication/). Access
|
||||
tokens (JWT) encode the user's identity and the room permissions they've been granted. You can generate a token with our
|
||||
CLI:
|
||||
|
||||
```shell
|
||||
lk token create \
|
||||
--api-key devkey --api-secret secret \
|
||||
--join --room my-first-room --identity user1 \
|
||||
--valid-for 24h
|
||||
```
|
||||
|
||||
### Test with example app
|
||||
|
||||
Head over to our [example app](https://example.livekit.io) and enter a generated token to connect to your LiveKit
|
||||
server.
|
||||
|
||||
Once connected, your video and audio are now being published to your new LiveKit instance!
|
||||
|
||||
### Simulating a test publisher
|
||||
|
||||
```shell
|
||||
lk room join \
|
||||
--url ws://localhost:7880 \
|
||||
--api-key devkey --api-secret secret \
|
||||
--identity bot-user1 \
|
||||
--publish-demo \
|
||||
my-first-room
|
||||
```
|
||||
|
||||
This command publishes a looped demo video to a room. Due to how the video clip was encoded (keyframes every 3s),
|
||||
there's a slight delay before the browser has sufficient data to begin rendering frames. This is an artifact of the
|
||||
simulation.
|
||||
|
||||
### Adding an agent
|
||||
|
||||
Agents join rooms as participants, the same way a browser or a phone does. Follow the
|
||||
[Voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/) to build one. An agent connects to a self-hosted
|
||||
server the same way it connects to LiveKit Cloud; when running without Cloud, use
|
||||
[model plugins](https://docs.livekit.io/agents/models/#plugins) in place of LiveKit Inference.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Use LiveKit Cloud
|
||||
|
||||
LiveKit Cloud is the fastest and most reliable way to run LiveKit. It runs in 19+ regions with 99.99% uptime and adds
|
||||
agent hosting, model inference, telephony, and observability on top of the server. The Build plan is free, with no
|
||||
credit card required.
|
||||
|
||||
Sign up for [LiveKit Cloud](https://cloud.livekit.io/).
|
||||
|
||||
### Self-host
|
||||
|
||||
Read our [deployment docs](https://docs.livekit.io/transport/self-hosting/) for more information. Official
|
||||
[Docker images](https://hub.docker.com/r/livekit/livekit-server) and [Helm charts](https://github.com/livekit/livekit-helm)
|
||||
are available.
|
||||
|
||||
## Building from source
|
||||
|
||||
Pre-requisites:
|
||||
|
||||
- Go 1.26+ is installed
|
||||
- GOPATH/bin is in your PATH
|
||||
|
||||
Then run
|
||||
|
||||
```shell
|
||||
git clone https://github.com/livekit/livekit
|
||||
cd livekit
|
||||
./bootstrap.sh
|
||||
mage
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome your contributions toward improving LiveKit! Please join us
|
||||
[on Slack](http://livekit.io/join-slack) or in the [Developer Community](https://community.livekit.io) to discuss your
|
||||
ideas and/or PRs.
|
||||
|
||||
## License
|
||||
|
||||
LiveKit server is licensed under Apache License v2.0.
|
||||
|
||||
<!--BEGIN_REPO_NAV-->
|
||||
<br/><table>
|
||||
<thead><tr><th colspan="2">LiveKit Ecosystem</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Agents SDKs</td><td><a href="https://github.com/livekit/agents">Python</a> · <a href="https://github.com/livekit/agents-js">Node.js</a></td></tr><tr></tr>
|
||||
<tr><td>LiveKit SDKs</td><td><a href="https://github.com/livekit/client-sdk-js">Browser</a> · <a href="https://github.com/livekit/client-sdk-swift">Swift</a> · <a href="https://github.com/livekit/client-sdk-android">Android</a> · <a href="https://github.com/livekit/client-sdk-flutter">Flutter</a> · <a href="https://github.com/livekit/client-sdk-react-native">React Native</a> · <a href="https://github.com/livekit/rust-sdks">Rust</a> · <a href="https://github.com/livekit/node-sdks">Node.js</a> · <a href="https://github.com/livekit/python-sdks">Python</a> · <a href="https://github.com/livekit/client-sdk-unity">Unity</a> · <a href="https://github.com/livekit/client-sdk-unity-web">Unity (WebGL)</a> · <a href="https://github.com/livekit/client-sdk-esp32">ESP32</a> · <a href="https://github.com/livekit/client-sdk-cpp">C++</a></td></tr><tr></tr>
|
||||
<tr><td>Starter Apps</td><td><a href="https://github.com/livekit-examples/agent-starter-python">Python Agent</a> · <a href="https://github.com/livekit-examples/agent-starter-node">TypeScript Agent</a> · <a href="https://github.com/livekit-examples/agent-starter-react">React App</a> · <a href="https://github.com/livekit-examples/agent-starter-swift">SwiftUI App</a> · <a href="https://github.com/livekit-examples/agent-starter-android">Android App</a> · <a href="https://github.com/livekit-examples/agent-starter-flutter">Flutter App</a> · <a href="https://github.com/livekit-examples/agent-starter-react-native">React Native App</a> · <a href="https://github.com/livekit-examples/agent-starter-embed">Web Embed</a></td></tr><tr></tr>
|
||||
<tr><td>UI Components</td><td><a href="https://github.com/livekit/components-js">React</a> · <a href="https://github.com/livekit/components-android">Android Compose</a> · <a href="https://github.com/livekit/components-swift">SwiftUI</a> · <a href="https://github.com/livekit/components-flutter">Flutter</a></td></tr><tr></tr>
|
||||
<tr><td>Server APIs</td><td><a href="https://github.com/livekit/node-sdks">Node.js</a> · <a href="https://github.com/livekit/server-sdk-go">Golang</a> · <a href="https://github.com/livekit/server-sdk-ruby">Ruby</a> · <a href="https://github.com/livekit/server-sdk-kotlin">Java/Kotlin</a> · <a href="https://github.com/livekit/python-sdks">Python</a> · <a href="https://github.com/livekit/rust-sdks">Rust</a> · <a href="https://github.com/agence104/livekit-server-sdk-php">PHP (community)</a> · <a href="https://github.com/pabloFuente/livekit-server-sdk-dotnet">.NET (community)</a></td></tr><tr></tr>
|
||||
<tr><td>Resources</td><td><a href="https://docs.livekit.io">Docs</a> · <a href="https://docs.livekit.io/mcp">Docs MCP Server</a> · <a href="https://github.com/livekit/livekit-cli">CLI</a> · <a href="https://cloud.livekit.io">LiveKit Cloud</a></td></tr><tr></tr>
|
||||
<tr><td>LiveKit Server OSS</td><td><b>LiveKit server</b> · <a href="https://github.com/livekit/egress">Egress</a> · <a href="https://github.com/livekit/ingress">Ingress</a> · <a href="https://github.com/livekit/sip">SIP</a></td></tr><tr></tr>
|
||||
<tr><td>Community</td><td><a href="https://community.livekit.io">Developer Community</a> · <a href="https://livekit.io/join-slack">Slack</a> · <a href="https://x.com/livekit">X</a> · <a href="https://www.youtube.com/@livekit_io">YouTube</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--END_REPO_NAV-->
|
||||
33
bootstrap.sh
Executable file
33
bootstrap.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
if ! command -v mage &> /dev/null
|
||||
then
|
||||
pushd /tmp
|
||||
git clone https://github.com/magefile/mage
|
||||
cd mage
|
||||
go run bootstrap.go
|
||||
rm -rf /tmp/mage
|
||||
popd
|
||||
fi
|
||||
|
||||
if ! command -v mage &> /dev/null
|
||||
then
|
||||
echo "Ensure `go env GOPATH`/bin is in your \$PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go mod download
|
||||
271
cmd/server/commands.go
Normal file
271
cmd/server/commands.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/urfave/cli/v3"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/service"
|
||||
)
|
||||
|
||||
func generateKeys(_ context.Context, _ *cli.Command) error {
|
||||
apiKey := guid.New(utils.APIKeyPrefix)
|
||||
secret := utils.RandomSecret()
|
||||
fmt.Println("API Key: ", apiKey)
|
||||
fmt.Println("API Secret: ", secret)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPorts(_ context.Context, c *cli.Command) error {
|
||||
conf, err := getConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
udpPorts := make([]string, 0)
|
||||
tcpPorts := make([]string, 0)
|
||||
|
||||
tcpPorts = append(tcpPorts, fmt.Sprintf("%d - HTTP service", conf.Port))
|
||||
if conf.RTC.TCPPort != 0 {
|
||||
tcpPorts = append(tcpPorts, fmt.Sprintf("%d - ICE/TCP", conf.RTC.TCPPort))
|
||||
}
|
||||
if conf.RTC.UDPPort.Valid() {
|
||||
portStr, _ := conf.RTC.UDPPort.MarshalYAML()
|
||||
udpPorts = append(udpPorts, fmt.Sprintf("%s - ICE/UDP", portStr))
|
||||
} else {
|
||||
udpPorts = append(udpPorts, fmt.Sprintf("%d-%d - ICE/UDP range", conf.RTC.ICEPortRangeStart, conf.RTC.ICEPortRangeEnd))
|
||||
}
|
||||
|
||||
if conf.TURN.Enabled {
|
||||
if conf.TURN.TLSPort > 0 {
|
||||
tcpPorts = append(tcpPorts, fmt.Sprintf("%d - TURN/TLS", conf.TURN.TLSPort))
|
||||
}
|
||||
if conf.TURN.UDPPort > 0 {
|
||||
udpPorts = append(udpPorts, fmt.Sprintf("%d - TURN/UDP", conf.TURN.UDPPort))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("TCP Ports")
|
||||
for _, p := range tcpPorts {
|
||||
fmt.Println(p)
|
||||
}
|
||||
|
||||
fmt.Println("UDP Ports")
|
||||
for _, p := range udpPorts {
|
||||
fmt.Println(p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func helpVerbose(_ context.Context, c *cli.Command) error {
|
||||
generatedFlags, err := config.GenerateCLIFlags(baseFlags, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flags := append([]cli.Flag{}, baseFlags...)
|
||||
flags = append(flags, generatedFlags...)
|
||||
root := c.Root()
|
||||
root.Flags = flags
|
||||
c.Flags = flags
|
||||
return cli.ShowAppHelp(c)
|
||||
}
|
||||
|
||||
func createToken(_ context.Context, c *cli.Command) error {
|
||||
room := c.String("room")
|
||||
identity := c.String("identity")
|
||||
|
||||
conf, err := getConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// use the first API key from config
|
||||
if len(conf.Keys) == 0 {
|
||||
// try to load from file
|
||||
if _, err := os.Stat(conf.KeyFile); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(conf.KeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
decoder := yaml.NewDecoder(f)
|
||||
if err = decoder.Decode(conf.Keys); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(conf.Keys) == 0 {
|
||||
return fmt.Errorf("keys are not configured")
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
var apiSecret string
|
||||
for k, v := range conf.Keys {
|
||||
apiKey = k
|
||||
apiSecret = v
|
||||
break
|
||||
}
|
||||
|
||||
grant := &auth.VideoGrant{
|
||||
RoomJoin: true,
|
||||
Room: room,
|
||||
}
|
||||
if c.Bool("recorder") {
|
||||
grant.Hidden = true
|
||||
grant.Recorder = true
|
||||
grant.SetCanPublish(false)
|
||||
grant.SetCanPublishData(false)
|
||||
}
|
||||
|
||||
at := auth.NewAccessToken(apiKey, apiSecret).
|
||||
AddGrant(grant).
|
||||
SetIdentity(identity).
|
||||
SetValidFor(30 * 24 * time.Hour)
|
||||
|
||||
token, err := at.ToJWT()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Token:", token)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listNodes(_ context.Context, c *cli.Command) error {
|
||||
conf, err := getConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentNode, err := routing.NewLocalNode(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := service.InitializeRouter(conf, currentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nodes, err := router.ListNodes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
table := tablewriter.NewTable(os.Stdout,
|
||||
tablewriter.WithHeaderAutoFormat(tw.Off),
|
||||
tablewriter.WithRowAutoWrap(0),
|
||||
tablewriter.WithRendition(tw.Rendition{
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{BetweenRows: tw.On},
|
||||
},
|
||||
}),
|
||||
tablewriter.WithRowAlignmentConfig(tw.CellAlignment{
|
||||
PerColumn: []tw.Align{
|
||||
tw.AlignCenter, tw.AlignCenter, tw.AlignCenter,
|
||||
tw.AlignRight, tw.AlignRight,
|
||||
tw.AlignRight,
|
||||
tw.AlignRight, tw.AlignRight,
|
||||
tw.AlignRight, tw.AlignRight, tw.AlignRight,
|
||||
tw.AlignRight, tw.AlignRight,
|
||||
tw.AlignCenter,
|
||||
},
|
||||
}),
|
||||
)
|
||||
table.Header(
|
||||
"ID", "IP Address", "Region",
|
||||
"CPUs", "CPU Usage\nLoad Avg",
|
||||
"Memory Used/Total",
|
||||
"Rooms", "Clients\nTracks In/Out",
|
||||
"Bytes/s In/Out\nBytes Total", "Packets/s In/Out\nPackets Total", "System Dropped Pkts/s\nPkts/s Out/Dropped",
|
||||
"Nack/s\nNack Total", "Retrans/s\nRetrans Total",
|
||||
"Started At\nUpdated At",
|
||||
)
|
||||
|
||||
for _, node := range nodes {
|
||||
stats := node.Stats
|
||||
rate := &livekit.NodeStatsRate{}
|
||||
if len(stats.Rates) > 0 {
|
||||
rate = stats.Rates[0]
|
||||
}
|
||||
|
||||
// Id and state
|
||||
idAndState := fmt.Sprintf("%s\n(%s)", node.Id, node.State.Enum().String())
|
||||
|
||||
// System stats
|
||||
cpus := strconv.Itoa(int(stats.NumCpus))
|
||||
cpuUsageAndLoadAvg := fmt.Sprintf("%.2f %%\n%.2f %.2f %.2f", stats.CpuLoad*100,
|
||||
stats.LoadAvgLast1Min, stats.LoadAvgLast5Min, stats.LoadAvgLast15Min)
|
||||
memUsage := fmt.Sprintf("%s / %s", humanize.Bytes(stats.MemoryUsed), humanize.Bytes(stats.MemoryTotal))
|
||||
|
||||
// Room stats
|
||||
rooms := strconv.Itoa(int(stats.NumRooms))
|
||||
clientsAndTracks := fmt.Sprintf("%d\n%d / %d", stats.NumClients, stats.NumTracksIn, stats.NumTracksOut)
|
||||
|
||||
// Packet stats
|
||||
bytes := fmt.Sprintf("%sps / %sps\n%s / %s", humanize.Bytes(uint64(rate.BytesIn)), humanize.Bytes(uint64(rate.BytesOut)),
|
||||
humanize.Bytes(stats.BytesIn), humanize.Bytes(stats.BytesOut))
|
||||
packets := fmt.Sprintf("%s / %s\n%s / %s", humanize.Comma(int64(rate.PacketsIn)), humanize.Comma(int64(rate.PacketsOut)),
|
||||
strings.TrimSpace(humanize.SIWithDigits(float64(stats.PacketsIn), 2, "")), strings.TrimSpace(humanize.SIWithDigits(float64(stats.PacketsOut), 2, "")))
|
||||
sysPacketsDroppedPct := float32(0)
|
||||
if rate.SysPacketsOut+rate.SysPacketsDropped > 0 {
|
||||
sysPacketsDroppedPct = float32(rate.SysPacketsDropped) / float32(rate.SysPacketsDropped+rate.SysPacketsOut)
|
||||
}
|
||||
sysPackets := fmt.Sprintf("%.2f %%\n%v / %v", sysPacketsDroppedPct*100, float64(rate.SysPacketsOut), float64(rate.SysPacketsDropped))
|
||||
nacks := fmt.Sprintf("%.2f\n%s", rate.NackTotal, strings.TrimSpace(humanize.SIWithDigits(float64(stats.NackTotal), 2, "")))
|
||||
retransmit := fmt.Sprintf("%.2f\n%s", rate.RetransmitPacketsOut, strings.TrimSpace(humanize.SIWithDigits(float64(stats.RetransmitPacketsOut), 2, "")))
|
||||
|
||||
// Date
|
||||
startedAndUpdated := fmt.Sprintf("%s\n%s", time.Unix(stats.StartedAt, 0).UTC().UTC().Format("2006-01-02 15:04:05"),
|
||||
time.Unix(stats.UpdatedAt, 0).UTC().Format("2006-01-02 15:04:05"))
|
||||
|
||||
table.Append(
|
||||
idAndState, node.Ip, node.Region,
|
||||
cpus, cpuUsageAndLoadAvg,
|
||||
memUsage,
|
||||
rooms, clientsAndTracks,
|
||||
bytes, packets, sysPackets,
|
||||
nacks, retransmit,
|
||||
startedAndUpdated,
|
||||
)
|
||||
}
|
||||
table.Render() //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
334
cmd/server/main.go
Normal file
334
cmd/server/main.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/tracer/jaeger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc"
|
||||
"github.com/livekit/livekit-server/pkg/service"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
"github.com/livekit/livekit-server/version"
|
||||
)
|
||||
|
||||
var baseFlags = []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "bind",
|
||||
Usage: "IP address to listen on, use flag multiple times to specify multiple addresses",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Usage: "path to LiveKit config file",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config-body",
|
||||
Usage: "LiveKit config in YAML, typically passed in as an environment var in a container",
|
||||
Sources: cli.EnvVars("LIVEKIT_CONFIG"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "key-file",
|
||||
Usage: "path to file that contains API keys/secrets",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "keys",
|
||||
Usage: "api keys (key: secret\\n)",
|
||||
Sources: cli.EnvVars("LIVEKIT_KEYS"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "region",
|
||||
Usage: "region of the current node. Used by regionaware node selector",
|
||||
Sources: cli.EnvVars("LIVEKIT_REGION"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "node-ip",
|
||||
Usage: "IP address of the current node, used to advertise to clients. Automatically determined by default",
|
||||
Sources: cli.EnvVars("NODE_IP"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "udp-port",
|
||||
Usage: "UDP port(s) to use for WebRTC traffic",
|
||||
Sources: cli.EnvVars("UDP_PORT"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "redis-host",
|
||||
Usage: "host (incl. port) to redis server",
|
||||
Sources: cli.EnvVars("REDIS_HOST"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "redis-password",
|
||||
Usage: "password to redis",
|
||||
Sources: cli.EnvVars("REDIS_PASSWORD"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "turn-cert",
|
||||
Usage: "tls cert file for TURN server",
|
||||
Sources: cli.EnvVars("LIVEKIT_TURN_CERT"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "turn-key",
|
||||
Usage: "tls key file for TURN server",
|
||||
Sources: cli.EnvVars("LIVEKIT_TURN_KEY"),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "cpuprofile",
|
||||
Usage: "write CPU profile to `file`",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "memprofile",
|
||||
Usage: "write memory profile to `file`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dev",
|
||||
Usage: "sets log-level to debug, console formatter, and /debug/pprof. insecure for production",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "disable-strict-config",
|
||||
Usage: "disables strict config parsing",
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().Unix())
|
||||
}
|
||||
|
||||
func main() {
|
||||
defer func() {
|
||||
if rtc.Recover(logger.GetLogger()) != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
generatedFlags, err := config.GenerateCLIFlags(baseFlags, true)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "livekit-server",
|
||||
Usage: "High performance WebRTC server",
|
||||
Description: "run without subcommands to start the server",
|
||||
Flags: append(baseFlags, generatedFlags...),
|
||||
Action: startServer,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "generate-keys",
|
||||
Usage: "generates an API key and secret pair",
|
||||
Action: generateKeys,
|
||||
},
|
||||
{
|
||||
Name: "ports",
|
||||
Usage: "print ports that server is configured to use",
|
||||
Action: printPorts,
|
||||
},
|
||||
{
|
||||
// this subcommand is deprecated, token generation is provided by CLI
|
||||
Name: "create-join-token",
|
||||
Hidden: true,
|
||||
Usage: "create a room join token for development use",
|
||||
Action: createToken,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "room",
|
||||
Usage: "name of room to join",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "identity",
|
||||
Usage: "identity of participant that holds the token",
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "recorder",
|
||||
Usage: "creates a hidden participant that can only subscribe",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list-nodes",
|
||||
Usage: "list all nodes",
|
||||
Action: listNodes,
|
||||
},
|
||||
{
|
||||
Name: "help-verbose",
|
||||
Usage: "prints app help, including all generated configuration flags",
|
||||
Action: helpVerbose,
|
||||
},
|
||||
},
|
||||
Version: version.Version,
|
||||
}
|
||||
|
||||
if err := cmd.Run(context.Background(), os.Args); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func getConfig(c *cli.Command) (*config.Config, error) {
|
||||
confString, err := getConfigString(c.String("config"), c.String("config-body"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
strictMode := !c.Bool("disable-strict-config")
|
||||
|
||||
conf, err := config.NewConfig(confString, strictMode, c, baseFlags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.InitLoggerFromConfig(&conf.Logging)
|
||||
|
||||
if conf.Development {
|
||||
logger.Infow("starting in development mode")
|
||||
|
||||
if len(conf.Keys) == 0 {
|
||||
logger.Infow("no keys provided, using placeholder keys",
|
||||
"API Key", "devkey",
|
||||
"API Secret", "secret",
|
||||
)
|
||||
conf.Keys = map[string]string{
|
||||
"devkey": "secret",
|
||||
}
|
||||
shouldMatchRTCIP := false
|
||||
// when dev mode and using shared keys, we'll bind to localhost by default
|
||||
if conf.BindAddresses == nil {
|
||||
conf.BindAddresses = []string{
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
}
|
||||
} else {
|
||||
// if non-loopback addresses are provided, then we'll match RTC IP to bind address
|
||||
// our IP discovery ignores loopback addresses
|
||||
for _, addr := range conf.BindAddresses {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip != nil && !ip.IsLoopback() && !ip.IsUnspecified() {
|
||||
shouldMatchRTCIP = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldMatchRTCIP {
|
||||
for _, bindAddr := range conf.BindAddresses {
|
||||
conf.RTC.IPs.Includes = append(conf.RTC.IPs.Includes, bindAddr+"/24")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func startServer(ctx context.Context, c *cli.Command) error {
|
||||
conf, err := getConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if url := conf.Trace.JaegerURL; url != "" {
|
||||
jaeger.Configure(ctx, url, "livekit")
|
||||
}
|
||||
|
||||
// validate API key length
|
||||
err = conf.ValidateKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = conf.LoadTURNSecrets(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cpuProfile := c.String("cpuprofile"); cpuProfile != "" {
|
||||
if f, err := os.Create(cpuProfile); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err := pprof.StartCPUProfile(f); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
pprof.StopCPUProfile()
|
||||
f.Close()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
if memProfile := c.String("memprofile"); memProfile != "" {
|
||||
if f, err := os.Create(memProfile); err != nil {
|
||||
return err
|
||||
} else {
|
||||
defer func() {
|
||||
// run memory profile at termination
|
||||
runtime.GC()
|
||||
_ = pprof.WriteHeapProfile(f)
|
||||
_ = f.Close()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
currentNode, err := routing.NewLocalNode(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := prometheus.Init(string(currentNode.NodeID()), currentNode.NodeType()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server, err := service.InitializeServer(conf, currentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
|
||||
go func() {
|
||||
for i := range 2 {
|
||||
sig := <-sigChan
|
||||
force := i > 0
|
||||
logger.Infow("exit requested, shutting down", "signal", sig, "force", force)
|
||||
go server.Stop(force)
|
||||
}
|
||||
}()
|
||||
|
||||
return server.Start()
|
||||
}
|
||||
|
||||
func getConfigString(configFile string, inConfigBody string) (string, error) {
|
||||
if inConfigBody != "" || configFile == "" {
|
||||
return inConfigBody, nil
|
||||
}
|
||||
|
||||
outConfigBody, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(outConfigBody), nil
|
||||
}
|
||||
63
cmd/server/main_test.go
Normal file
63
cmd/server/main_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testStruct struct {
|
||||
configFileName string
|
||||
configBody string
|
||||
|
||||
expectedError error
|
||||
expectedConfigBody string
|
||||
}
|
||||
|
||||
func TestGetConfigString(t *testing.T) {
|
||||
tests := []testStruct{
|
||||
{"", "", nil, ""},
|
||||
{"", "configBody", nil, "configBody"},
|
||||
{"file", "configBody", nil, "configBody"},
|
||||
{"file", "", nil, "fileContent"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
func() {
|
||||
writeConfigFile(test, t)
|
||||
defer os.Remove(test.configFileName)
|
||||
|
||||
configBody, err := getConfigString(test.configFileName, test.configBody)
|
||||
require.Equal(t, test.expectedError, err)
|
||||
require.Equal(t, test.expectedConfigBody, configBody)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReturnErrorIfConfigFileDoesNotExist(t *testing.T) {
|
||||
configBody, err := getConfigString("notExistingFile", "")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, configBody)
|
||||
}
|
||||
|
||||
func writeConfigFile(test testStruct, t *testing.T) {
|
||||
if test.configFileName != "" {
|
||||
d1 := []byte(test.expectedConfigBody)
|
||||
err := os.WriteFile(test.configFileName, d1, 0o644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
52
cmd/test-server/Dockerfile
Normal file
52
cmd/test-server/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Copyright 2026 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Mock LiveKit API server for SDK conformance testing. Build from the repo
|
||||
# root: docker build -f cmd/test-server/Dockerfile -t livekit/test-server .
|
||||
#
|
||||
# Pinned by digest so the build is reproducible even if the tag is republished.
|
||||
# The tag is kept alongside it for readability; Renovate updates both together.
|
||||
FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Build with exactly the toolchain in this image; fail (don't silently download)
|
||||
# if go.mod ever requires a newer version, so the pinned image stays authoritative.
|
||||
ENV GOTOOLCHAIN=local
|
||||
|
||||
COPY go.mod go.mod
|
||||
COPY go.sum go.sum
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ cmd/
|
||||
COPY pkg/ pkg/
|
||||
COPY version/ version/
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server
|
||||
|
||||
FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
|
||||
|
||||
# Pull the latest security patches for base packages within the pinned Alpine
|
||||
# release; the digest above is only refreshed by a Renovate PR. See the note in
|
||||
# the repo-root Dockerfile about the cold-cache assumption.
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
COPY --from=builder /workspace/livekit-test-server /livekit-test-server
|
||||
|
||||
# region-0 (primary, 9999) + 3 fallback regions
|
||||
EXPOSE 9999 10000 10001 10002
|
||||
|
||||
ENTRYPOINT ["/livekit-test-server"]
|
||||
213
cmd/test-server/README.md
Normal file
213
cmd/test-server/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# LiveKit SDK test server
|
||||
|
||||
A stateless, per-request programmable mock of the LiveKit server HTTP API. It
|
||||
exists so the server SDKs (Go, Rust, Python, Node, Kotlin, Ruby) can exercise
|
||||
client-side behavior against one shared implementation, published as a Docker
|
||||
image and booted by each SDK's CI.
|
||||
|
||||
## Why it looks the way it does
|
||||
|
||||
- **Stateless.** All behavior is selected by a single per-request `X-Lk-Mock`
|
||||
header (a JSON object), so the server holds no mutable state and tests run in
|
||||
parallel.
|
||||
- **Multi-port = multi-region.** The process binds one listener per simulated
|
||||
region (`--ports`). A port's position in the list is its **region index**;
|
||||
index `0` is the primary the SDK is initially pointed at. `GET
|
||||
/settings/regions` advertises all of them in order.
|
||||
- **One header drives every attempt.** The SDK sends the same control header on
|
||||
the initial request *and* every failover retry. Each listener decides what to
|
||||
do from its **own** index, so a single `X-Lk-Mock: {"failRegions":[0]}` makes
|
||||
the primary fail while the first fallback succeeds — no coordination needed.
|
||||
- **Realistic latency.** Methods that block in the real server block here too:
|
||||
`CreateSIPParticipant` with `wait_until_answered` and `TransferSIPParticipant`
|
||||
take ~11s before responding, so SDKs can exercise their timeouts.
|
||||
- **The whole API is mocked with populated responses.** Every RoomService,
|
||||
Egress, Ingress, SIP, and Connector method returns a type-correct, populated
|
||||
response: scalar fields that share a name with the request are echoed (e.g.
|
||||
`name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder
|
||||
values, and list endpoints return one element. Both protobuf and JSON Twirp
|
||||
clients are supported. A client can override the response entirely with the
|
||||
`response` field (see below). Unregistered/future methods fall back
|
||||
to an empty (all-default) message, which still decodes cleanly.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
go run ./cmd/test-server # primary :9999, regions :10000-10002
|
||||
go run ./cmd/test-server --ports 9999,10000 # primary + one fallback
|
||||
|
||||
# Docker
|
||||
docker build -f cmd/test-server/Dockerfile -t livekit/test-server .
|
||||
docker run -p 9999-10002:9999-10002 livekit/test-server
|
||||
```
|
||||
|
||||
| Flag | Env | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `--ports` | `LK_TEST_SERVER_PORTS` | `9999,10000,10001,10002` | listener ports; index = position |
|
||||
| `--advertise-host` | `LK_TEST_SERVER_ADVERTISE_HOST` | `http://127.0.0.1` | base URL used in `/settings/regions` |
|
||||
| `--bind` | `LK_TEST_SERVER_BIND` | `0.0.0.0` | bind address |
|
||||
| `--twirp-prefix` | `LK_TEST_SERVER_TWIRP_PREFIX` | `/twirp` | Twirp path prefix |
|
||||
|
||||
## Control protocol
|
||||
|
||||
All behavior is driven by a single `X-Lk-Mock` request header whose value is a
|
||||
JSON object. The SDK sends the same header on API calls, on the
|
||||
`/settings/regions` fetch, and on every failover retry (it must forward
|
||||
client-configured custom headers onto all of them). Omit the header — or any
|
||||
field — for normal behavior. Every field is optional:
|
||||
|
||||
| Field | Default | Effect |
|
||||
|---|---|---|
|
||||
| `failRegions` | — | array of region indices that fail this request, e.g. `[0]` or `[0,1]`. Each listener fails only if its own index is listed. |
|
||||
| `failMode` | `status` | how a failing region fails: `status` (write a Twirp error), or `drop` (close the connection → transport error). |
|
||||
| `failStatus` | `503` | HTTP status for a `status`-mode failure. |
|
||||
| `failTwirpCode` | derived from status | Twirp error code string in the failure body. |
|
||||
| `delayMs` | — | delay (ms) before responding, on success or failure. Overrides a method's natural latency — use it for timeout tests, or set it to skip a SIP method's built-in ~11s wait. |
|
||||
| `regionsStatus` | `200` | override the status of `GET /settings/regions`. |
|
||||
| `response` | — | the response message for the called method (a JSON object, protojson-shaped); replaces the populated default, giving full control over the returned payload. |
|
||||
| `skipAuth` | `false` | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). |
|
||||
| `sipStatus` | — | fail a SIP dial method (`CreateSIPParticipant`/`TransferSIPParticipant`) with a SIP status, e.g. `{"code":486,"status":"Busy Here"}` (`status` optional). The Twirp error code and `sip_status_code`/`sip_status`/`error_details` metadata are derived from it exactly as the real server does. Composes with `delayMs` to simulate "ring, then fail". |
|
||||
| `pinnedRegions` | — | array of region **names** the project is pinned to (its **allowed** regions), e.g. `["region-1"]`, mirroring cloud's per-project `PinnedRegions` (region names, not indices). When non-empty a pin is in effect: any region whose name is **not** listed rejects the request with **HTTP 451** (a region-pin violation), and `GET /settings/regions` returns **only** the listed regions. The SDK is expected to see the 451, re-fetch `/settings/regions`, and retry against an allowed region — the 451 carries no region hint (its body is the middleware's plain text, not a Twirp error). This redirect is always active in clients; it can't be disabled. Use a name no listener advertises (e.g. `["region-99"]`) to model a pinned region that's unreachable. Names are the mock's region names (`region-0`, `region-1`, …); contrast `failRegions`, which addresses listeners by index. |
|
||||
|
||||
Example: `X-Lk-Mock: {"skipAuth":true,"failRegions":[0],"failStatus":400}`
|
||||
|
||||
> **Deprecated:** the older per-setting headers — `X-Lk-Mock-Fail-Regions`,
|
||||
> `X-Lk-Mock-Fail-Mode` (incl. the `delay` mode), `X-Lk-Mock-Fail-Status`,
|
||||
> `X-Lk-Mock-Fail-Twirp-Code`, `X-Lk-Mock-Delay-Ms`, `X-Lk-Mock-Regions-Status`,
|
||||
> `X-Lk-Mock-Response`, `X-Lk-Mock-Skip-Auth` — are still honored for existing
|
||||
> clients and will be removed later. When `X-Lk-Mock` is also present, its fields
|
||||
> take precedence per-field. New clients should use `X-Lk-Mock` only.
|
||||
|
||||
Response headers:
|
||||
|
||||
| Header | Meaning |
|
||||
|---|---|
|
||||
| `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. |
|
||||
|
||||
## Signal connection (WebSocket) mocking
|
||||
|
||||
The mock also speaks enough of the LiveKit signal protocol for SDKs to run
|
||||
end-to-end signal-connection tests (connect, keepalive, reconnect, leave, and
|
||||
the failure/timeout modes a client must classify). Signal behavior is selected
|
||||
by a participant attribute (`lk.mock`) in the access token (see below) — the
|
||||
WebSocket client can't set request headers, so it can't carry a control header.
|
||||
Selecting via the token means parallel tests need no shared
|
||||
state.
|
||||
|
||||
Endpoints (both protocol versions are supported and behave identically):
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `/rtc`, `/rtc/v1` | WebSocket signal connection |
|
||||
| `/rtc/validate`, `/rtc/v1/validate` | HTTP validate (the client fetches this when the WS fails to open) |
|
||||
|
||||
- The access token is read from the `access_token` query param (or a
|
||||
`Bearer` Authorization header) and verified against the API secret. A
|
||||
missing/malformed/expired/wrongly-signed token makes `validate` return
|
||||
**401** (and the WS refuse the upgrade).
|
||||
- Wire format is **binary protobuf**: `SignalRequest` in, `SignalResponse` out.
|
||||
- The v1 embedded publisher offer (`join_request` connection param) is
|
||||
**ignored** — no valid offer is required.
|
||||
- Keepalive uses a short `pingTimeout=3s` / `pingInterval=1s` in the join so
|
||||
timeout tests run fast.
|
||||
|
||||
**Mode selection is via a participant attribute.** After the token is verified,
|
||||
the server reads the `lk.mock` entry from the token's `attributes` claim
|
||||
(`ClaimGrants.Attributes`, a `map[string]string`). The value of that attribute
|
||||
is a stringified JSON control object whose `signal` field picks the behavior.
|
||||
The `lk.mock` namespace is the attribute **key** (dot notation, matching
|
||||
LiveKit's convention for internal attributes), so the value has no inner parent:
|
||||
|
||||
```
|
||||
attribute key: lk.mock
|
||||
attribute value: {"signal":"no_pong"}
|
||||
```
|
||||
|
||||
The control object also accepts an optional `leaveAction` field — a
|
||||
`LeaveRequest_Action`, given either as the number (`0`=DISCONNECT, `1`=RESUME,
|
||||
`2`=RECONNECT) or the enum name (`"RECONNECT"`, case-insensitive) — that sets
|
||||
the `action` on the `LeaveRequest` the leave-sending modes emit
|
||||
(`leave_when_connected`, `leave_first_message`, `leave_during_reconnect`). When
|
||||
absent it defaults to `0` (DISCONNECT). Examples:
|
||||
|
||||
```
|
||||
attribute value: {"signal":"leave_when_connected","leaveAction":"RECONNECT"}
|
||||
```
|
||||
|
||||
If the `lk.mock` attribute is absent/empty, its value is unparseable, or its
|
||||
`signal` is unknown, the mode defaults to `happy`. Both the WS handlers and the
|
||||
validate handlers read the mode from this same attribute.
|
||||
|
||||
Behavior modes (any unknown/absent `signal` = `happy`):
|
||||
|
||||
| `signal` value | Effect |
|
||||
|---|---|
|
||||
| `happy` | validate → 200; WS sends `JoinResponse` (or `ReconnectResponse` if `reconnect=1`), pongs pings, closes cleanly (1000) on client `LeaveRequest` |
|
||||
| `validate_500` | validate → 500; WS refuses upgrade with 500 |
|
||||
| `validate_service_not_found` | validate → 404 with a body *without* the room marker (client → serviceNotFound); WS refuses with 404 |
|
||||
| `room_not_found` | validate → 404 with body `requested room does not exist` (client → notAllowed); WS refuses with 404 |
|
||||
| `no_first_message` | WS accepted, server sends nothing (client hits connect timeout) |
|
||||
| `no_pong` | WS sends the join, then never pongs (client hits ping timeout) |
|
||||
| `close_before_join` | WS upgrade succeeds, then ~50ms later a clean close (code 1011, empty reason) *before* any first message — unexpected closure during connect |
|
||||
| `close_when_connected` | WS sends join, then ~200ms later closes with code 1011 |
|
||||
| `drop_when_connected` | WS sends join, then ~200ms later abruptly drops the TCP connection with no close handshake — client observes an abnormal closure (code 1006) |
|
||||
| `leave_when_connected` | WS sends join, then ~200ms later sends a `LeaveRequest` |
|
||||
| `leave_first_message` | WS sends a `LeaveRequest` as the first (and only) message |
|
||||
| `leave_during_reconnect` | on a `reconnect=1` connection, sends `LeaveRequest` first; otherwise behaves like `happy` |
|
||||
|
||||
`LeaveRequest`s carry `reason=SERVER_SHUTDOWN` and `action` from the control's
|
||||
optional `leaveAction` (default `DISCONNECT (0)`).
|
||||
|
||||
## Permission enforcement
|
||||
|
||||
Every API method requires the same token grants the real LiveKit server checks
|
||||
(see `pkg/service/auth.go`), so the mock doubles as a conformance check that an
|
||||
SDK attaches the right permissions automatically. Tokens are parsed and verified
|
||||
with the protocol's own `auth` helpers — the same code path the real server uses
|
||||
— against the mock's configured API secret (default `secret`, matching
|
||||
`livekit-server --dev`; override with `--api-secret` / `LK_TEST_SERVER_API_SECRET`).
|
||||
|
||||
- Missing, malformed, or wrongly-signed `Authorization` → `401 unauthenticated`.
|
||||
- Validly-signed token without the required grant → `403 permission_denied`.
|
||||
- `roomAdmin`-scoped methods also require the token's `room` to match the
|
||||
request's room; `ForwardParticipant`/`MoveParticipant` additionally require
|
||||
`destinationRoom` to match.
|
||||
|
||||
SDKs exercising permissions should sign tokens with the same API secret the mock
|
||||
is configured with (`secret` by default).
|
||||
|
||||
| Grant | Methods |
|
||||
|---|---|
|
||||
| `video.roomCreate` | `CreateRoom`, `DeleteRoom`, all `Connector` calls |
|
||||
| `video.roomList` | `ListRooms` |
|
||||
| `video.roomRecord` | all `Egress` methods |
|
||||
| `video.ingressAdmin` | all `Ingress` methods |
|
||||
| `video.roomAdmin` (+ `room`) | room participant/data/metadata methods, `AgentDispatchService` methods |
|
||||
| `video.roomAdmin` (+ `room` + `destinationRoom`) | `ForwardParticipant`, `MoveParticipant` |
|
||||
| `sip.admin` | SIP trunk & dispatch-rule CRUD |
|
||||
| `sip.call` | `CreateSIPParticipant`; `TransferSIPParticipant` (also needs `roomAdmin`) |
|
||||
|
||||
Send `X-Lk-Mock: {"skipAuth":true}` to bypass enforcement for tests that aren't
|
||||
about permissions.
|
||||
|
||||
## Common recipes
|
||||
|
||||
| Goal | `X-Lk-Mock` value |
|
||||
|---|---|
|
||||
| Happy path | (no header) — valid token with the method's grant → 200 from region `0` |
|
||||
| Bypass auth (failover tests) | `{"skipAuth":true}` |
|
||||
| Missing-permission error | (no header) — token without the required grant → 403 |
|
||||
| Failover succeeds on region 1 | `{"failRegions":[0]}` |
|
||||
| Exhaust to region 2 | `{"failRegions":[0,1]}` |
|
||||
| All regions down | `{"failRegions":[0,1,2,3]}` |
|
||||
| 4xx, no retry | `{"failRegions":[0],"failStatus":400}` |
|
||||
| Transport-error failover | `{"failRegions":[0],"failMode":"drop"}` |
|
||||
| Timeout test | `{"delayMs":30000}` |
|
||||
| Region discovery unreachable | `{"regionsStatus":500}` |
|
||||
| Custom response payload | `{"response":{"sid":"RM_x","name":"my-room"}}` |
|
||||
| SIP busy signal | `{"sipStatus":{"code":486,"status":"Busy Here"}}` |
|
||||
| SIP carrier decline | `{"sipStatus":{"code":603}}` |
|
||||
|
||||
Note: SDK region failover normally only engages for `*.livekit.cloud` hosts.
|
||||
Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its
|
||||
forced-on value so failover engages against localhost.
|
||||
224
cmd/test-server/auth.go
Normal file
224
cmd/test-server/auth.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
// The mock enforces the same token permissions the real LiveKit server requires
|
||||
// for each API method (see pkg/service/auth.go in livekit/livekit). The point is
|
||||
// to verify that SDKs attach the correct grants automatically. Tokens are parsed
|
||||
// and verified with the protocol's own auth helpers (the same code the server
|
||||
// uses), against the mock's configured API secret (default "secret", matching
|
||||
// `livekit-server --dev`); set --api-secret / LK_TEST_SERVER_API_SECRET to change it.
|
||||
//
|
||||
// Set `"skipAuth": true` in the X-Lk-Mock header to bypass enforcement for tests
|
||||
// that aren't about permissions (e.g. region-failover tests with a placeholder token).
|
||||
|
||||
// perm describes the grants a method requires. roomAdmin additionally requires
|
||||
// the token's room to match the request's room; destRoom further requires the
|
||||
// token's destinationRoom to match the request's destination_room.
|
||||
type perm struct {
|
||||
roomCreate bool
|
||||
roomList bool
|
||||
roomRecord bool
|
||||
ingressAdmin bool
|
||||
roomAdmin bool
|
||||
destRoom bool
|
||||
sipAdmin bool
|
||||
sipCall bool
|
||||
}
|
||||
|
||||
// methodPerms maps "<package>.<Service>/<Method>" to its required grants,
|
||||
// matching the Ensure*Permission checks in the real server's services.
|
||||
var methodPerms = map[string]perm{
|
||||
// RoomService
|
||||
"livekit.RoomService/CreateRoom": {roomCreate: true},
|
||||
"livekit.RoomService/DeleteRoom": {roomCreate: true},
|
||||
"livekit.RoomService/ListRooms": {roomList: true},
|
||||
"livekit.RoomService/ListParticipants": {roomAdmin: true},
|
||||
"livekit.RoomService/GetParticipant": {roomAdmin: true},
|
||||
"livekit.RoomService/RemoveParticipant": {roomAdmin: true},
|
||||
"livekit.RoomService/MutePublishedTrack": {roomAdmin: true},
|
||||
"livekit.RoomService/UpdateParticipant": {roomAdmin: true},
|
||||
"livekit.RoomService/UpdateSubscriptions": {roomAdmin: true},
|
||||
"livekit.RoomService/SendData": {roomAdmin: true},
|
||||
"livekit.RoomService/UpdateRoomMetadata": {roomAdmin: true},
|
||||
"livekit.RoomService/ForwardParticipant": {destRoom: true},
|
||||
"livekit.RoomService/MoveParticipant": {destRoom: true},
|
||||
"livekit.RoomService/PerformRpc": {roomAdmin: true},
|
||||
|
||||
// Egress — all require record permission
|
||||
"livekit.Egress/StartEgress": {roomRecord: true},
|
||||
"livekit.Egress/StartRoomCompositeEgress": {roomRecord: true},
|
||||
"livekit.Egress/StartWebEgress": {roomRecord: true},
|
||||
"livekit.Egress/StartParticipantEgress": {roomRecord: true},
|
||||
"livekit.Egress/StartTrackCompositeEgress": {roomRecord: true},
|
||||
"livekit.Egress/StartTrackEgress": {roomRecord: true},
|
||||
"livekit.Egress/UpdateLayout": {roomRecord: true},
|
||||
"livekit.Egress/UpdateStream": {roomRecord: true},
|
||||
"livekit.Egress/ListEgress": {roomRecord: true},
|
||||
"livekit.Egress/StopEgress": {roomRecord: true},
|
||||
|
||||
// Ingress — all require ingress admin
|
||||
"livekit.Ingress/CreateIngress": {ingressAdmin: true},
|
||||
"livekit.Ingress/UpdateIngress": {ingressAdmin: true},
|
||||
"livekit.Ingress/ListIngress": {ingressAdmin: true},
|
||||
"livekit.Ingress/DeleteIngress": {ingressAdmin: true},
|
||||
|
||||
// SIP — trunk/dispatch administration requires sip.admin
|
||||
"livekit.SIP/CreateSIPInboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/CreateSIPOutboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/UpdateSIPInboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/UpdateSIPOutboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/GetSIPInboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/GetSIPOutboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/ListSIPTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/ListSIPInboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/ListSIPOutboundTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/DeleteSIPTrunk": {sipAdmin: true},
|
||||
"livekit.SIP/CreateSIPDispatchRule": {sipAdmin: true},
|
||||
"livekit.SIP/UpdateSIPDispatchRule": {sipAdmin: true},
|
||||
"livekit.SIP/ListSIPDispatchRule": {sipAdmin: true},
|
||||
"livekit.SIP/DeleteSIPDispatchRule": {sipAdmin: true},
|
||||
// Placing a call requires sip.call; transfer also requires room admin.
|
||||
"livekit.SIP/CreateSIPParticipant": {sipCall: true},
|
||||
"livekit.SIP/TransferSIPParticipant": {sipCall: true, roomAdmin: true},
|
||||
|
||||
// AgentDispatch — room admin scoped to the dispatch's room
|
||||
"livekit.AgentDispatchService/CreateDispatch": {roomAdmin: true},
|
||||
"livekit.AgentDispatchService/DeleteDispatch": {roomAdmin: true},
|
||||
"livekit.AgentDispatchService/ListDispatch": {roomAdmin: true},
|
||||
|
||||
// Connector (cloud) — initiating a call requires room create
|
||||
"livekit.Connector/DialWhatsAppCall": {roomCreate: true},
|
||||
"livekit.Connector/DisconnectWhatsAppCall": {roomCreate: true},
|
||||
"livekit.Connector/ConnectWhatsAppCall": {roomCreate: true},
|
||||
"livekit.Connector/AcceptWhatsAppCall": {roomCreate: true},
|
||||
"livekit.Connector/ConnectTwilioCall": {roomCreate: true},
|
||||
}
|
||||
|
||||
// authorize enforces the permissions a method requires. It returns the HTTP
|
||||
// status and Twirp error code to send (0, "" means authorized / not enforced).
|
||||
func (h *mockHandler) authorize(key string, r *http.Request, cfg *mockConfig, req proto.Message) (int, string) {
|
||||
if cfg.SkipAuth {
|
||||
return 0, ""
|
||||
}
|
||||
p, known := methodPerms[key]
|
||||
if !known {
|
||||
return 0, "" // unknown/future method: don't enforce
|
||||
}
|
||||
|
||||
grants, err := h.verifyToken(r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
// Missing, malformed, or improperly-signed token, like the real server.
|
||||
return http.StatusUnauthorized, "unauthenticated"
|
||||
}
|
||||
if !p.satisfiedBy(grants, req) {
|
||||
return http.StatusForbidden, "permission_denied"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// verifyToken parses and verifies a "Bearer <jwt>" header using the protocol's
|
||||
// own auth helpers (the same path the real server uses), returning the grants.
|
||||
func (h *mockHandler) verifyToken(authorization string) (*auth.ClaimGrants, error) {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
|
||||
v, err := auth.ParseAPIToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, grants, err := v.Verify(h.apiSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func (p perm) satisfiedBy(g *auth.ClaimGrants, req proto.Message) bool {
|
||||
v, s := g.Video, g.SIP
|
||||
if p.roomCreate && (v == nil || !v.RoomCreate) {
|
||||
return false
|
||||
}
|
||||
if p.roomList && (v == nil || !v.RoomList) {
|
||||
return false
|
||||
}
|
||||
if p.roomRecord && (v == nil || !v.RoomRecord) {
|
||||
return false
|
||||
}
|
||||
if p.ingressAdmin && (v == nil || !v.IngressAdmin) {
|
||||
return false
|
||||
}
|
||||
if p.sipAdmin && (s == nil || !s.Admin) {
|
||||
return false
|
||||
}
|
||||
if p.sipCall && (s == nil || !s.Call) {
|
||||
return false
|
||||
}
|
||||
if p.roomAdmin || p.destRoom {
|
||||
if v == nil || !v.RoomAdmin {
|
||||
return false
|
||||
}
|
||||
if room := requestRoom(req); room != "" && v.Room != room {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if p.destRoom {
|
||||
if dest := requestString(req, "destination_room"); dest != "" && v.DestinationRoom != dest {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// requestRoom reads the room name a request targets, trying the common "room"
|
||||
// and "room_name" fields.
|
||||
func requestRoom(req proto.Message) string {
|
||||
if v := requestString(req, "room"); v != "" {
|
||||
return v
|
||||
}
|
||||
return requestString(req, "room_name")
|
||||
}
|
||||
|
||||
func requestString(req proto.Message, field string) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
m := req.ProtoReflect()
|
||||
fd := m.Descriptor().Fields().ByName(protoreflect.Name(field))
|
||||
if fd == nil || fd.Kind() != protoreflect.StringKind || fd.IsList() {
|
||||
return ""
|
||||
}
|
||||
return m.Get(fd).String()
|
||||
}
|
||||
|
||||
func requestBool(req proto.Message, field string) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
m := req.ProtoReflect()
|
||||
fd := m.Descriptor().Fields().ByName(protoreflect.Name(field))
|
||||
if fd == nil || fd.Kind() != protoreflect.BoolKind || fd.IsList() {
|
||||
return false
|
||||
}
|
||||
return m.Get(fd).Bool()
|
||||
}
|
||||
168
cmd/test-server/config.go
Normal file
168
cmd/test-server/config.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// headerMock carries the whole mock control config as a JSON object; see
|
||||
// mockConfig and the README. Sent by the SDK on API calls (and forwarded onto
|
||||
// the /settings/regions fetch and every failover retry).
|
||||
headerMock = "X-Lk-Mock"
|
||||
// headerRegion is set on responses to the index of the region that served it.
|
||||
headerRegion = "X-Lk-Mock-Region"
|
||||
)
|
||||
|
||||
const (
|
||||
// regionPinStatus is the HTTP status cloud middleware returns for a region-pin
|
||||
// violation — an API call reaching a region the project is not pinned to. It is
|
||||
// 451 (Unavailable For Legal Reasons); clients key off it to rediscover regions
|
||||
// and retry against an allowed one.
|
||||
regionPinStatus = http.StatusUnavailableForLegalReasons
|
||||
// regionPinMessage is the plain-text body the middleware writes with the 451.
|
||||
regionPinMessage = "project not allowed in this region."
|
||||
)
|
||||
|
||||
// Deprecated: the individual X-Lk-Mock-* control headers predate the unified
|
||||
// X-Lk-Mock JSON header. They are still honored for existing clients; new
|
||||
// clients should send X-Lk-Mock instead. When X-Lk-Mock is present its fields
|
||||
// take precedence over any legacy header.
|
||||
const (
|
||||
legacyHeaderFailRegions = "X-Lk-Mock-Fail-Regions"
|
||||
legacyHeaderFailMode = "X-Lk-Mock-Fail-Mode"
|
||||
legacyHeaderFailStatus = "X-Lk-Mock-Fail-Status"
|
||||
legacyHeaderFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code"
|
||||
legacyHeaderDelayMs = "X-Lk-Mock-Delay-Ms"
|
||||
legacyHeaderRegionsStatus = "X-Lk-Mock-Regions-Status"
|
||||
legacyHeaderResponse = "X-Lk-Mock-Response"
|
||||
legacyHeaderSkipAuth = "X-Lk-Mock-Skip-Auth"
|
||||
)
|
||||
|
||||
// legacyDefaultDelayMs is the sleep used by the deprecated "delay" fail mode when
|
||||
// no X-Lk-Mock-Delay-Ms is given (long enough to trip client timeouts).
|
||||
const legacyDefaultDelayMs = 30_000
|
||||
|
||||
// mockConfig is the JSON value of the X-Lk-Mock request header. Every field is
|
||||
// optional; the zero value means "behave normally". A single object keeps the
|
||||
// control protocol simple — the SDK serializes one struct instead of juggling a
|
||||
// header per knob.
|
||||
type mockConfig struct {
|
||||
// FailRegions lists region indices that should fail this request. A listener
|
||||
// fails only if its own region index appears here, so one config can make the
|
||||
// primary fail while a fallback succeeds.
|
||||
FailRegions []int `json:"failRegions,omitempty"`
|
||||
// FailMode selects how a failing region fails: "status" (default) writes a
|
||||
// Twirp error; "drop" closes the connection to force a transport error.
|
||||
// ("delay" is a deprecated legacy mode; new clients use DelayMs instead.)
|
||||
FailMode string `json:"failMode,omitempty"`
|
||||
// FailStatus is the HTTP status for a "status"-mode failure (default 503).
|
||||
FailStatus int `json:"failStatus,omitempty"`
|
||||
// FailTwirpCode overrides the Twirp error code string in the failure body
|
||||
// (default derived from FailStatus).
|
||||
FailTwirpCode string `json:"failTwirpCode,omitempty"`
|
||||
// DelayMs delays the response by this many milliseconds before returning,
|
||||
// whether the region succeeds or fails. It overrides a method's natural
|
||||
// latency (see methodLatency): set it high for timeout tests, or to 0 to skip
|
||||
// a SIP method's built-in wait. Nil means "use the natural latency".
|
||||
DelayMs *int `json:"delayMs,omitempty"`
|
||||
// RegionsStatus overrides the HTTP status of GET /settings/regions (default 200).
|
||||
RegionsStatus int `json:"regionsStatus,omitempty"`
|
||||
// Response is the protojson of the response message for the called method; it
|
||||
// replaces the populated default, giving full control over the payload.
|
||||
Response json.RawMessage `json:"response,omitempty"`
|
||||
// SkipAuth disables permission enforcement for this request (for tests that
|
||||
// aren't about authz, e.g. failover tests with a placeholder token).
|
||||
SkipAuth bool `json:"skipAuth,omitempty"`
|
||||
// SIPStatus, when set on a SIP dial method (CreateSIPParticipant /
|
||||
// TransferSIPParticipant), fails the call with this SIP status. The Twirp
|
||||
// error code and metadata (sip_status_code, sip_status, error_details) are
|
||||
// derived from it exactly as the real server does, so the SDK sees an
|
||||
// identical error. Composes with DelayMs to simulate "ring, then fail".
|
||||
SIPStatus *sipStatusConfig `json:"sipStatus,omitempty"`
|
||||
// PinnedRegions lists the region names a project is pinned to (its allowed
|
||||
// regions), mirroring cloud's per-project PinnedRegions — which are region names,
|
||||
// not indices. When non-empty a pin is in effect: any region whose name is NOT
|
||||
// listed rejects the request with HTTP 451 (see regionPinStatus), exactly as
|
||||
// cloud middleware turns away an API call reaching a region the project is not
|
||||
// pinned to, and GET /settings/regions returns only the listed regions. The
|
||||
// client is expected to see the 451, re-fetch /settings/regions, and retry
|
||||
// against an allowed region — the 451 carries no region hint. An empty/absent
|
||||
// value means "not pinned": every region serves and discovery returns all
|
||||
// regions. Names are this mock's region names (e.g. "region-0"); contrast
|
||||
// FailRegions, which addresses listeners by index.
|
||||
PinnedRegions []string `json:"pinnedRegions,omitempty"`
|
||||
|
||||
// legacyDelayMs is the sleep used by the deprecated "delay" fail mode. It is
|
||||
// populated only from the legacy X-Lk-Mock-Delay-Ms header, never from JSON.
|
||||
legacyDelayMs int
|
||||
}
|
||||
|
||||
// sipStatusConfig is a SIP response to inject; see mockConfig.SIPStatus.
|
||||
type sipStatusConfig struct {
|
||||
// Code is the SIP response code, e.g. 486 (Busy Here) or 603 (Decline).
|
||||
Code int `json:"code"`
|
||||
// Status is the SIP reason phrase; defaults to the code's canonical name.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// parseMockConfig builds the request's config. Deprecated individual X-Lk-Mock-*
|
||||
// headers form the base; the unified X-Lk-Mock JSON header (if present) is
|
||||
// overlaid on top, so its fields win per-field while absent fields keep the
|
||||
// legacy value.
|
||||
func parseMockConfig(r *http.Request) mockConfig {
|
||||
cfg := parseLegacyConfig(r)
|
||||
if v := r.Header.Get(headerMock); v != "" {
|
||||
// Unmarshal overwrites only the fields present in the JSON; the unexported
|
||||
// legacyDelayMs is untouched.
|
||||
_ = json.Unmarshal([]byte(v), &cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseLegacyConfig reads the deprecated per-setting headers into a config.
|
||||
func parseLegacyConfig(r *http.Request) mockConfig {
|
||||
var cfg mockConfig
|
||||
for _, part := range strings.Split(r.Header.Get(legacyHeaderFailRegions), ",") {
|
||||
if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {
|
||||
cfg.FailRegions = append(cfg.FailRegions, idx)
|
||||
}
|
||||
}
|
||||
cfg.FailMode = r.Header.Get(legacyHeaderFailMode)
|
||||
cfg.FailStatus = parseStatus(r.Header.Get(legacyHeaderFailStatus))
|
||||
cfg.FailTwirpCode = r.Header.Get(legacyHeaderFailTwirpCode)
|
||||
cfg.RegionsStatus = parseStatus(r.Header.Get(legacyHeaderRegionsStatus))
|
||||
if resp := r.Header.Get(legacyHeaderResponse); resp != "" {
|
||||
cfg.Response = json.RawMessage(resp)
|
||||
}
|
||||
cfg.SkipAuth = strings.EqualFold(r.Header.Get(legacyHeaderSkipAuth), "true")
|
||||
cfg.legacyDelayMs = legacyDefaultDelayMs
|
||||
if ms, err := strconv.Atoi(r.Header.Get(legacyHeaderDelayMs)); err == nil && ms >= 0 {
|
||||
cfg.legacyDelayMs = ms
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseStatus returns a valid HTTP status from s, or 0 if absent/invalid.
|
||||
func parseStatus(s string) int {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
332
cmd/test-server/handlers.go
Normal file
332
cmd/test-server/handlers.go
Normal file
@@ -0,0 +1,332 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/protojson"
|
||||
"github.com/livekit/protocol/utils/xtwirp"
|
||||
)
|
||||
|
||||
// apiSpec captures the request and response message types for one Twirp method,
|
||||
// so the mock can decode the incoming request and build a typed response.
|
||||
type apiSpec struct {
|
||||
newReq func() proto.Message
|
||||
newResp func() proto.Message
|
||||
}
|
||||
|
||||
// ptrMsg constrains a pointer type that is also a proto.Message, letting reg
|
||||
// construct fresh request/response values generically.
|
||||
type ptrMsg[T any] interface {
|
||||
*T
|
||||
proto.Message
|
||||
}
|
||||
|
||||
// apiHandlers maps "<package>.<Service>/<Method>" to its message types. It
|
||||
// covers the full LiveKit API surface; see init below.
|
||||
var apiHandlers = map[string]apiSpec{}
|
||||
|
||||
func reg[ReqT, RespT any, Req ptrMsg[ReqT], Resp ptrMsg[RespT]](key string) {
|
||||
apiHandlers[key] = apiSpec{
|
||||
newReq: func() proto.Message { return Req(new(ReqT)) },
|
||||
newResp: func() proto.Message { return Resp(new(RespT)) },
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// RoomService
|
||||
reg[livekit.CreateRoomRequest, livekit.Room]("livekit.RoomService/CreateRoom")
|
||||
reg[livekit.ListRoomsRequest, livekit.ListRoomsResponse]("livekit.RoomService/ListRooms")
|
||||
reg[livekit.DeleteRoomRequest, livekit.DeleteRoomResponse]("livekit.RoomService/DeleteRoom")
|
||||
reg[livekit.ListParticipantsRequest, livekit.ListParticipantsResponse]("livekit.RoomService/ListParticipants")
|
||||
reg[livekit.RoomParticipantIdentity, livekit.ParticipantInfo]("livekit.RoomService/GetParticipant")
|
||||
reg[livekit.RoomParticipantIdentity, livekit.RemoveParticipantResponse]("livekit.RoomService/RemoveParticipant")
|
||||
reg[livekit.MuteRoomTrackRequest, livekit.MuteRoomTrackResponse]("livekit.RoomService/MutePublishedTrack")
|
||||
reg[livekit.UpdateParticipantRequest, livekit.ParticipantInfo]("livekit.RoomService/UpdateParticipant")
|
||||
reg[livekit.UpdateSubscriptionsRequest, livekit.UpdateSubscriptionsResponse]("livekit.RoomService/UpdateSubscriptions")
|
||||
reg[livekit.SendDataRequest, livekit.SendDataResponse]("livekit.RoomService/SendData")
|
||||
reg[livekit.UpdateRoomMetadataRequest, livekit.Room]("livekit.RoomService/UpdateRoomMetadata")
|
||||
reg[livekit.ForwardParticipantRequest, livekit.ForwardParticipantResponse]("livekit.RoomService/ForwardParticipant")
|
||||
reg[livekit.MoveParticipantRequest, livekit.MoveParticipantResponse]("livekit.RoomService/MoveParticipant")
|
||||
reg[livekit.PerformRpcRequest, livekit.PerformRpcResponse]("livekit.RoomService/PerformRpc")
|
||||
|
||||
// Egress
|
||||
reg[livekit.StartEgressRequest, livekit.EgressInfo]("livekit.Egress/StartEgress")
|
||||
reg[livekit.UpdateLayoutRequest, livekit.EgressInfo]("livekit.Egress/UpdateLayout")
|
||||
reg[livekit.UpdateStreamRequest, livekit.EgressInfo]("livekit.Egress/UpdateStream")
|
||||
reg[livekit.ListEgressRequest, livekit.ListEgressResponse]("livekit.Egress/ListEgress")
|
||||
reg[livekit.StopEgressRequest, livekit.EgressInfo]("livekit.Egress/StopEgress")
|
||||
reg[livekit.RoomCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartRoomCompositeEgress")
|
||||
reg[livekit.WebEgressRequest, livekit.EgressInfo]("livekit.Egress/StartWebEgress")
|
||||
reg[livekit.ParticipantEgressRequest, livekit.EgressInfo]("livekit.Egress/StartParticipantEgress")
|
||||
reg[livekit.TrackCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackCompositeEgress")
|
||||
reg[livekit.TrackEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackEgress")
|
||||
|
||||
// Ingress
|
||||
reg[livekit.CreateIngressRequest, livekit.IngressInfo]("livekit.Ingress/CreateIngress")
|
||||
reg[livekit.UpdateIngressRequest, livekit.IngressInfo]("livekit.Ingress/UpdateIngress")
|
||||
reg[livekit.ListIngressRequest, livekit.ListIngressResponse]("livekit.Ingress/ListIngress")
|
||||
reg[livekit.DeleteIngressRequest, livekit.IngressInfo]("livekit.Ingress/DeleteIngress")
|
||||
|
||||
// SIP
|
||||
reg[livekit.ListSIPTrunkRequest, livekit.ListSIPTrunkResponse]("livekit.SIP/ListSIPTrunk")
|
||||
reg[livekit.CreateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/CreateSIPInboundTrunk")
|
||||
reg[livekit.CreateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/CreateSIPOutboundTrunk")
|
||||
reg[livekit.UpdateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/UpdateSIPInboundTrunk")
|
||||
reg[livekit.UpdateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/UpdateSIPOutboundTrunk")
|
||||
reg[livekit.GetSIPInboundTrunkRequest, livekit.GetSIPInboundTrunkResponse]("livekit.SIP/GetSIPInboundTrunk")
|
||||
reg[livekit.GetSIPOutboundTrunkRequest, livekit.GetSIPOutboundTrunkResponse]("livekit.SIP/GetSIPOutboundTrunk")
|
||||
reg[livekit.ListSIPInboundTrunkRequest, livekit.ListSIPInboundTrunkResponse]("livekit.SIP/ListSIPInboundTrunk")
|
||||
reg[livekit.ListSIPOutboundTrunkRequest, livekit.ListSIPOutboundTrunkResponse]("livekit.SIP/ListSIPOutboundTrunk")
|
||||
reg[livekit.DeleteSIPTrunkRequest, livekit.SIPTrunkInfo]("livekit.SIP/DeleteSIPTrunk")
|
||||
reg[livekit.CreateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/CreateSIPDispatchRule")
|
||||
reg[livekit.UpdateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/UpdateSIPDispatchRule")
|
||||
reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule")
|
||||
reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule")
|
||||
reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant")
|
||||
reg[livekit.TransferSIPParticipantRequest, livekit.TransferSIPParticipantResponse]("livekit.SIP/TransferSIPParticipant")
|
||||
|
||||
// Connector
|
||||
reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall")
|
||||
reg[livekit.DisconnectWhatsAppCallRequest, livekit.DisconnectWhatsAppCallResponse]("livekit.Connector/DisconnectWhatsAppCall")
|
||||
reg[livekit.ConnectWhatsAppCallRequest, livekit.ConnectWhatsAppCallResponse]("livekit.Connector/ConnectWhatsAppCall")
|
||||
reg[livekit.AcceptWhatsAppCallRequest, livekit.AcceptWhatsAppCallResponse]("livekit.Connector/AcceptWhatsAppCall")
|
||||
reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall")
|
||||
}
|
||||
|
||||
// serveAPI handles a Twirp call end to end: decode the request, enforce the
|
||||
// method's required permissions (like the real server's auth middleware), apply
|
||||
// any region-failure injection, then serve a populated response.
|
||||
func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
json := strings.Contains(r.Header.Get("Content-Type"), "json")
|
||||
key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/")
|
||||
spec, known := apiHandlers[key]
|
||||
|
||||
// Decode the request up front — needed both to enforce room-scoped grants
|
||||
// and to build the echoed response.
|
||||
var req proto.Message
|
||||
if known {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
req = spec.newReq()
|
||||
if json {
|
||||
_ = protojson.Unmarshal(body, req)
|
||||
} else {
|
||||
_ = proto.Unmarshal(body, req)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := parseMockConfig(r)
|
||||
|
||||
// Permission enforcement comes first, mirroring the real server.
|
||||
if status, code := h.authorize(key, r, &cfg, req); status != 0 {
|
||||
writeTwirpErrorCode(w, status, code, "mock: "+code)
|
||||
return
|
||||
}
|
||||
|
||||
// A project pinned to other regions is turned away by cloud middleware up front
|
||||
// — before the request is served and with no latency — when it reaches a region
|
||||
// it isn't pinned to. The check is by region name, as it is on the real server.
|
||||
if len(cfg.PinnedRegions) > 0 && !slices.Contains(cfg.PinnedRegions, h.regionName()) {
|
||||
h.failRegionPin(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Delay before responding (success or failure). An explicit delayMs overrides
|
||||
// the method's natural latency — e.g. CreateSIPParticipant blocking until the
|
||||
// callee answers.
|
||||
delay := methodLatency(key, req)
|
||||
if cfg.DelayMs != nil {
|
||||
delay = time.Duration(*cfg.DelayMs) * time.Millisecond
|
||||
}
|
||||
if delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
// A SIP dial that fails carries a SIP status; the Twirp code and metadata are
|
||||
// derived from it exactly as the real server does.
|
||||
if cfg.SIPStatus != nil && isSIPDialMethod(key) {
|
||||
h.failSIP(w, &cfg)
|
||||
return
|
||||
}
|
||||
|
||||
if h.shouldFail(&cfg) {
|
||||
h.fail(w, &cfg)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeAPIResponse(w, json, known, req, spec, &cfg)
|
||||
}
|
||||
|
||||
// methodLatency returns the realistic time a method blocks before responding, so
|
||||
// the mock approximates the real server's behavior. CreateSIPParticipant blocks
|
||||
// until the callee answers when wait_until_answered is set; TransferSIPParticipant
|
||||
// always blocks until the transfer (REFER) completes.
|
||||
func methodLatency(key string, req proto.Message) time.Duration {
|
||||
switch key {
|
||||
case "livekit.SIP/CreateSIPParticipant":
|
||||
if requestBool(req, "wait_until_answered") {
|
||||
return sipAnswerLatency
|
||||
}
|
||||
case "livekit.SIP/TransferSIPParticipant":
|
||||
return sipAnswerLatency
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// sipAnswerLatency is how long a SIP call takes to be answered/transferred in the
|
||||
// mock — long enough to exercise client-side timeouts around these calls.
|
||||
const sipAnswerLatency = 11 * time.Second
|
||||
|
||||
// isSIPDialMethod reports whether key places a call that can fail with a SIP status.
|
||||
func isSIPDialMethod(key string) bool {
|
||||
switch key {
|
||||
case "livekit.SIP/CreateSIPParticipant", "livekit.SIP/TransferSIPParticipant":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// failSIP fails the request with the configured SIP status, mirroring the real
|
||||
// server: the status maps to a Twirp error code and attaches sip_status_code,
|
||||
// sip_status, and error_details metadata via xtwirp.
|
||||
func (h *mockHandler) failSIP(w http.ResponseWriter, cfg *mockConfig) {
|
||||
st := &livekit.SIPStatus{
|
||||
Code: livekit.SIPStatusCode(cfg.SIPStatus.Code),
|
||||
Status: cfg.SIPStatus.Status,
|
||||
}
|
||||
writeTwirpErr(w, xtwirp.ToError(st))
|
||||
}
|
||||
|
||||
// failRegionPin rejects the request with HTTP 451, mirroring cloud middleware
|
||||
// turning away an API call from a project pinned to a different region. The body
|
||||
// is the middleware's plain-text message (not a Twirp error): clients key off the
|
||||
// 451 status to rediscover regions and retry against an allowed one.
|
||||
func (h *mockHandler) failRegionPin(w http.ResponseWriter) {
|
||||
w.Header().Set(headerRegion, "")
|
||||
w.WriteHeader(regionPinStatus)
|
||||
_, _ = w.Write([]byte(regionPinMessage))
|
||||
}
|
||||
|
||||
// writeAPIResponse serves a populated, type-correct response for a known API
|
||||
// method. The response is the reflection-populated default unless the mock
|
||||
// config carries a `response` (protojson), which overrides it entirely. Content
|
||||
// type (protobuf vs JSON) mirrors the request.
|
||||
func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, json, known bool, req proto.Message, spec apiSpec, cfg *mockConfig) {
|
||||
w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex))
|
||||
|
||||
if !known {
|
||||
// Unknown/future method: an empty body still decodes to a valid default
|
||||
// message in every Twirp client.
|
||||
writeEmptySuccess(w, json)
|
||||
return
|
||||
}
|
||||
|
||||
resp := spec.newResp()
|
||||
if len(cfg.Response) > 0 {
|
||||
if err := protojson.Unmarshal(cfg.Response, resp); err != nil {
|
||||
// Malformed override: fall back to the populated default.
|
||||
resp = spec.newResp()
|
||||
populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1)
|
||||
}
|
||||
} else {
|
||||
populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1)
|
||||
}
|
||||
|
||||
if json {
|
||||
out, _ := protojson.Marshal(resp)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(out)
|
||||
} else {
|
||||
out, _ := proto.Marshal(resp)
|
||||
w.Header().Set("Content-Type", "application/protobuf")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
}
|
||||
|
||||
func writeEmptySuccess(w http.ResponseWriter, json bool) {
|
||||
if json {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/protobuf")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// populateMessage fills a response message with plausible values: it echoes
|
||||
// scalar fields that share a name with the request, assigns placeholder values
|
||||
// to id/sid fields, and adds one element to repeated-message (list) fields so
|
||||
// list endpoints return non-empty results. depth bounds list-element nesting.
|
||||
func populateMessage(m protoreflect.Message, req protoreflect.Message, depth int) {
|
||||
fields := m.Descriptor().Fields()
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
fd := fields.Get(i)
|
||||
|
||||
// Echo a same-named scalar field from the request (e.g. name, metadata,
|
||||
// identity, room, timeouts).
|
||||
if req != nil && fd.Cardinality() != protoreflect.Repeated && isScalarKind(fd.Kind()) {
|
||||
if rf := req.Descriptor().Fields().ByName(fd.Name()); rf != nil &&
|
||||
rf.Kind() == fd.Kind() && rf.Cardinality() != protoreflect.Repeated && req.Has(rf) {
|
||||
m.Set(fd, req.Get(rf))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Give id/sid-like string fields a deterministic placeholder.
|
||||
if fd.Kind() == protoreflect.StringKind && fd.Cardinality() != protoreflect.Repeated && !m.Has(fd) {
|
||||
n := string(fd.Name())
|
||||
if n == "id" || n == "sid" || strings.HasSuffix(n, "_id") || strings.HasSuffix(n, "_sid") {
|
||||
m.Set(fd, protoreflect.ValueOfString("MOCK_"+strings.ToUpper(n)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Populate list endpoints with a single element so clients see results.
|
||||
if depth > 0 && fd.IsList() && fd.Kind() == protoreflect.MessageKind {
|
||||
list := m.Mutable(fd).List()
|
||||
elem := list.NewElement()
|
||||
populateMessage(elem.Message(), nil, depth-1)
|
||||
list.Append(elem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isScalarKind(k protoreflect.Kind) bool {
|
||||
switch k {
|
||||
case protoreflect.BoolKind,
|
||||
protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Uint32Kind,
|
||||
protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Uint64Kind,
|
||||
protoreflect.Sfixed32Kind, protoreflect.Fixed32Kind,
|
||||
protoreflect.Sfixed64Kind, protoreflect.Fixed64Kind,
|
||||
protoreflect.FloatKind, protoreflect.DoubleKind,
|
||||
protoreflect.StringKind, protoreflect.BytesKind:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
273
cmd/test-server/main.go
Normal file
273
cmd/test-server/main.go
Normal file
@@ -0,0 +1,273 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Command test-server is a programmable mock of the LiveKit server HTTP API,
|
||||
// used by the server SDKs to test client behavior. See cmd/test-server/README.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/twitchtv/twirp"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/protojson"
|
||||
)
|
||||
|
||||
func main() {
|
||||
portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002")
|
||||
advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1")
|
||||
bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0")
|
||||
twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp")
|
||||
// API secret used to verify request tokens for permission enforcement.
|
||||
// Defaults to the `livekit-server --dev` secret.
|
||||
apiSecret := flagValue("--api-secret", "LK_TEST_SERVER_API_SECRET", "secret")
|
||||
|
||||
ports, err := parsePorts(portsFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid --ports: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
advertiseHost = strings.TrimRight(advertiseHost, "/")
|
||||
|
||||
regions := &livekit.RegionSettings{}
|
||||
for i, p := range ports {
|
||||
regions.Regions = append(regions.Regions, &livekit.RegionInfo{
|
||||
Region: fmt.Sprintf("region-%d", i),
|
||||
Url: fmt.Sprintf("%s:%d", advertiseHost, p),
|
||||
Distance: int64(i),
|
||||
})
|
||||
}
|
||||
|
||||
errCh := make(chan error, len(ports))
|
||||
for i, p := range ports {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", bindAddr, p),
|
||||
Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix, apiSecret: apiSecret},
|
||||
}
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-errCh:
|
||||
fmt.Fprintf(os.Stderr, "listener failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
case <-sigCh:
|
||||
fmt.Println("test-server: shutting down")
|
||||
}
|
||||
}
|
||||
|
||||
type mockHandler struct {
|
||||
regionIndex int
|
||||
regions *livekit.RegionSettings
|
||||
twirpPrefix string
|
||||
apiSecret string
|
||||
}
|
||||
|
||||
func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/settings/regions":
|
||||
h.handleRegions(w, r)
|
||||
case isValidatePath(r.URL.Path):
|
||||
h.handleValidate(w, r)
|
||||
case isSignalPath(r.URL.Path):
|
||||
h.handleSignal(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"):
|
||||
h.handleTwirp(w, r)
|
||||
case r.URL.Path == "/" || r.URL.Path == "/_test/health":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := parseMockConfig(r)
|
||||
if cfg.RegionsStatus != 0 && cfg.RegionsStatus != http.StatusOK {
|
||||
w.WriteHeader(cfg.RegionsStatus)
|
||||
return
|
||||
}
|
||||
regions := h.regions
|
||||
if len(cfg.PinnedRegions) > 0 {
|
||||
// A pinned project's discovery returns only its allowed regions, so the
|
||||
// client redirects straight to one after a 451 — mirroring cloud filtering
|
||||
// /settings/regions by the project's PinnedRegions.
|
||||
regions = pinnedRegionSettings(h.regions, cfg.PinnedRegions)
|
||||
}
|
||||
body, err := protojson.Marshal(regions)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "max-age=0")
|
||||
w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) {
|
||||
h.serveAPI(w, r)
|
||||
}
|
||||
|
||||
// regionName is the advertised name of the region this listener represents.
|
||||
func (h *mockHandler) regionName() string {
|
||||
if h.regionIndex < len(h.regions.Regions) {
|
||||
return h.regions.Regions[h.regionIndex].Region
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pinnedRegionSettings returns only the regions whose name appears in pinned,
|
||||
// mirroring how cloud filters /settings/regions to a project's PinnedRegions.
|
||||
// Names outside the region list are ignored.
|
||||
func pinnedRegionSettings(all *livekit.RegionSettings, pinned []string) *livekit.RegionSettings {
|
||||
out := &livekit.RegionSettings{}
|
||||
for _, region := range all.Regions {
|
||||
if slices.Contains(pinned, region.Region) {
|
||||
out.Regions = append(out.Regions, region)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *mockHandler) shouldFail(cfg *mockConfig) bool {
|
||||
return slices.Contains(cfg.FailRegions, h.regionIndex)
|
||||
}
|
||||
|
||||
func (h *mockHandler) fail(w http.ResponseWriter, cfg *mockConfig) {
|
||||
switch strings.ToLower(cfg.FailMode) {
|
||||
case "drop":
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
if conn, _, err := hj.Hijack(); err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
case "delay":
|
||||
// Deprecated legacy mode: sleep, then status-fail. New clients should set
|
||||
// DelayMs (which delays every response) instead.
|
||||
time.Sleep(time.Duration(cfg.legacyDelayMs) * time.Millisecond)
|
||||
}
|
||||
status := cfg.FailStatus
|
||||
if status < 100 || status > 599 {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeTwirpError(w, cfg, status)
|
||||
}
|
||||
|
||||
func writeTwirpError(w http.ResponseWriter, cfg *mockConfig, status int) {
|
||||
code := cfg.FailTwirpCode
|
||||
if code == "" {
|
||||
code = twirpCodeForStatus(status)
|
||||
}
|
||||
writeTwirpErrorCode(w, status, code, fmt.Sprintf("mock failure (status %d)", status))
|
||||
}
|
||||
|
||||
// writeTwirpErrorCode writes a Twirp JSON error with an explicit code and message.
|
||||
func writeTwirpErrorCode(w http.ResponseWriter, status int, code, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set(headerRegion, "")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, msg)
|
||||
}
|
||||
|
||||
// writeTwirpErr writes a full Twirp JSON error — code, message, and metadata —
|
||||
// using the HTTP status Twirp derives from the error code.
|
||||
func writeTwirpErr(w http.ResponseWriter, terr twirp.Error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set(headerRegion, "")
|
||||
w.WriteHeader(twirp.ServerHTTPStatusFromErrorCode(terr.Code()))
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}{
|
||||
Code: string(terr.Code()),
|
||||
Msg: terr.Msg(),
|
||||
Meta: terr.MetaMap(),
|
||||
})
|
||||
}
|
||||
|
||||
func twirpCodeForStatus(status int) string {
|
||||
switch {
|
||||
case status == http.StatusBadRequest:
|
||||
return "invalid_argument"
|
||||
case status == http.StatusUnauthorized:
|
||||
return "unauthenticated"
|
||||
case status == http.StatusForbidden:
|
||||
return "permission_denied"
|
||||
case status == http.StatusNotFound:
|
||||
return "not_found"
|
||||
case status == http.StatusTooManyRequests:
|
||||
return "resource_exhausted"
|
||||
case status >= 500:
|
||||
return "unavailable"
|
||||
default:
|
||||
return "internal"
|
||||
}
|
||||
}
|
||||
|
||||
func parsePorts(s string) ([]int, error) {
|
||||
var ports []int
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a port number", part)
|
||||
}
|
||||
ports = append(ports, v)
|
||||
}
|
||||
if len(ports) == 0 {
|
||||
return nil, errors.New("at least one port is required")
|
||||
}
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
// flagValue resolves a setting from a --flag, then an environment variable, then a default.
|
||||
func flagValue(flag, env, def string) string {
|
||||
prefix := flag + "="
|
||||
for i, arg := range os.Args[1:] {
|
||||
if arg == flag {
|
||||
if i+2 <= len(os.Args[1:]) {
|
||||
return os.Args[1:][i+1]
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix)
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(env); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
471
cmd/test-server/signal.go
Normal file
471
cmd/test-server/signal.go
Normal file
@@ -0,0 +1,471 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// Signal endpoints (/rtc, /rtc/v1 and their /validate counterparts) let SDKs
|
||||
// exercise end-to-end WebSocket signal behavior. Per-connection behavior is
|
||||
// selected by the `lk.mock` participant attribute (see signalControl), so tests
|
||||
// need no shared state. The client fetches validate only when the WS fails to
|
||||
// open, so validate-error modes refuse the upgrade with the matching status and
|
||||
// let that fetch return the definitive status/body.
|
||||
|
||||
const (
|
||||
// Short keepalive (seconds, sent in the JoinResponse) so timeout tests run fast.
|
||||
signalPingInterval = 1
|
||||
signalPingTimeout = 3
|
||||
|
||||
// Delay after join before close_when_connected / leave_when_connected act,
|
||||
// giving the client time to mark the connection established.
|
||||
connectedDelay = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// Behavior modes, selected by the `lk.mock` attribute's `signal` field. Any
|
||||
// unknown/absent signal behaves as the happy path.
|
||||
const (
|
||||
// Validate-endpoint modes (the WS upgrade is refused with the same status
|
||||
// so the client falls back to the validate fetch).
|
||||
modeValidate500 = "validate_500" // validate → 500
|
||||
modeServiceNotFound = "validate_service_not_found" // validate → 404, generic body
|
||||
modeRoomNotFound = "room_not_found" // validate → 404, "requested room does not exist"
|
||||
|
||||
// WebSocket signal modes (validate → 200; behavior is on the WS).
|
||||
modeHappy = "happy" // join, pong, clean close on client leave
|
||||
modeNoFirstMessage = "no_first_message" // accept WS, send nothing
|
||||
modeNoPong = "no_pong" // send join, never pong
|
||||
modeCloseBeforeJoin = "close_before_join" // clean close 1011 before any first message
|
||||
modeCloseWhenConnected = "close_when_connected" // send join, then clean close 1011
|
||||
modeDropWhenConnected = "drop_when_connected" // send join, then abrupt TCP drop (1006)
|
||||
modeLeaveWhenConnected = "leave_when_connected" // send join, then LeaveRequest
|
||||
modeLeaveFirstMessage = "leave_first_message" // LeaveRequest as first message
|
||||
modeLeaveDuringReconnect = "leave_during_reconnect" // on reconnect=1, LeaveRequest first
|
||||
modeDropOnClose = "drop_on_close" // happy, but drop TCP on the client's close frame instead of completing the handshake
|
||||
)
|
||||
|
||||
const signalControlAttribute = "lk.mock"
|
||||
|
||||
// signalControl is the JSON value of the `lk.mock` attribute:
|
||||
// {"signal":"<mode>","leaveAction":<int|name>}. leaveAction is optional
|
||||
// (a LeaveRequest_Action, given as the number or the enum name e.g.
|
||||
// "RECONNECT"; absent/0 = DISCONNECT) and sets the action on emitted leaves.
|
||||
type signalControl struct {
|
||||
Signal string `json:"signal"`
|
||||
LeaveAction leaveActionValue `json:"leaveAction"`
|
||||
}
|
||||
|
||||
// leaveActionValue is a LeaveRequest_Action that unmarshals from either a JSON
|
||||
// number (2) or an enum name ("RECONNECT", case-insensitive). Anything
|
||||
// unrecognized decodes to 0 (DISCONNECT) rather than failing the whole control.
|
||||
type leaveActionValue livekit.LeaveRequest_Action
|
||||
|
||||
func (v *leaveActionValue) UnmarshalJSON(b []byte) error {
|
||||
var n int32
|
||||
if json.Unmarshal(b, &n) == nil {
|
||||
*v = leaveActionValue(n)
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = leaveActionValue(livekit.LeaveRequest_Action_value[strings.ToUpper(s)])
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSignalControl parses the `lk.mock` attribute value; absent/invalid → zero.
|
||||
func parseSignalControl(grants *auth.ClaimGrants) signalControl {
|
||||
if grants == nil {
|
||||
return signalControl{}
|
||||
}
|
||||
raw := grants.Attributes[signalControlAttribute]
|
||||
if raw == "" {
|
||||
return signalControl{}
|
||||
}
|
||||
var ctrl signalControl
|
||||
if err := json.Unmarshal([]byte(raw), &ctrl); err != nil {
|
||||
return signalControl{}
|
||||
}
|
||||
return ctrl
|
||||
}
|
||||
|
||||
// signalMode returns the mode from the `lk.mock` `signal` field; unknown/absent → happy.
|
||||
func signalMode(grants *auth.ClaimGrants) string {
|
||||
switch ctrl := parseSignalControl(grants); ctrl.Signal {
|
||||
case modeValidate500, modeServiceNotFound, modeRoomNotFound,
|
||||
modeHappy, modeNoFirstMessage, modeNoPong,
|
||||
modeCloseBeforeJoin, modeCloseWhenConnected, modeDropWhenConnected,
|
||||
modeLeaveWhenConnected, modeLeaveFirstMessage, modeLeaveDuringReconnect,
|
||||
modeDropOnClose:
|
||||
return ctrl.Signal
|
||||
default:
|
||||
return modeHappy
|
||||
}
|
||||
}
|
||||
|
||||
func isSignalPath(path string) bool {
|
||||
return path == "/rtc" || path == "/rtc/v1"
|
||||
}
|
||||
|
||||
func isValidatePath(path string) bool {
|
||||
return path == "/rtc/validate" || path == "/rtc/v1/validate"
|
||||
}
|
||||
|
||||
var signalUpgrader = websocket.Upgrader{
|
||||
EnableCompression: true,
|
||||
// Auth is via the access token, so allow any origin.
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// verifySignalToken reads the access token (access_token query param or Bearer
|
||||
// header) and verifies it against the mock's API secret.
|
||||
func (h *mockHandler) verifySignalToken(r *http.Request) (*auth.ClaimGrants, error) {
|
||||
token := r.FormValue("access_token")
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
}
|
||||
v, err := auth.ParseAPIToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, grants, err := v.Verify(h.apiSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func grantRoom(grants *auth.ClaimGrants) string {
|
||||
if grants == nil || grants.Video == nil {
|
||||
return ""
|
||||
}
|
||||
return grants.Video.Room
|
||||
}
|
||||
|
||||
// handleValidate verifies the JWT (bad/expired/missing → 401), then returns the
|
||||
// status the mode dictates.
|
||||
func (h *mockHandler) handleValidate(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
grants, err := h.verifySignalToken(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("invalid token: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
switch signalMode(grants) {
|
||||
case modeValidate500:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("internal server error"))
|
||||
case modeServiceNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("404 page not found"))
|
||||
case modeRoomNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("requested room does not exist"))
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("success"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleSignal verifies the token, applies validate-error modes by refusing the
|
||||
// upgrade, else upgrades and runs the selected behavior. The v1 publisher offer
|
||||
// is ignored.
|
||||
func (h *mockHandler) handleSignal(w http.ResponseWriter, r *http.Request) {
|
||||
grants, err := h.verifySignalToken(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("invalid token"))
|
||||
return
|
||||
}
|
||||
|
||||
mode := signalMode(grants)
|
||||
|
||||
switch mode {
|
||||
case modeValidate500:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
case modeServiceNotFound, modeRoomNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
reconnect := r.URL.Query().Get("reconnect") == "1" || joinRequestSaysReconnect(r.URL.Query().Get("join_request"))
|
||||
|
||||
conn, err := signalUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
h.runSignal(conn, mode, reconnect, grants)
|
||||
}
|
||||
|
||||
// runSignal drives one WebSocket connection according to mode.
|
||||
func (h *mockHandler) runSignal(conn *websocket.Conn, mode string, reconnect bool, grants *auth.ClaimGrants) {
|
||||
writeResp := func(msg *livekit.SignalResponse) error {
|
||||
payload, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.WriteMessage(websocket.BinaryMessage, payload)
|
||||
}
|
||||
|
||||
leaveAction := livekit.LeaveRequest_Action(parseSignalControl(grants).LeaveAction)
|
||||
|
||||
// drainUntilClosed reads/discards until the peer closes, keeping the socket open.
|
||||
drainUntilClosed := func() {
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modes that decide the very first message.
|
||||
switch mode {
|
||||
case modeNoFirstMessage:
|
||||
drainUntilClosed()
|
||||
return
|
||||
case modeCloseBeforeJoin:
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
msg := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "")
|
||||
_ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second))
|
||||
return
|
||||
case modeLeaveFirstMessage:
|
||||
_ = writeResp(leaveResponse(leaveAction))
|
||||
drainUntilClosed()
|
||||
return
|
||||
case modeLeaveDuringReconnect:
|
||||
if reconnect {
|
||||
_ = writeResp(leaveResponse(leaveAction))
|
||||
drainUntilClosed()
|
||||
return
|
||||
}
|
||||
// Non-reconnect connections fall through to the happy path.
|
||||
}
|
||||
|
||||
// First message: reconnect response on a resume, join otherwise.
|
||||
if reconnect {
|
||||
if err := writeResp(reconnectResponse(h.regionIndex)); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := writeResp(joinResponse(h.regionIndex, grants)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Post-join behaviors.
|
||||
switch mode {
|
||||
case modeCloseWhenConnected:
|
||||
time.Sleep(connectedDelay)
|
||||
msg := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "mock close_when_connected")
|
||||
_ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second))
|
||||
return
|
||||
case modeDropWhenConnected:
|
||||
time.Sleep(connectedDelay)
|
||||
_ = conn.UnderlyingConn().Close()
|
||||
return
|
||||
case modeLeaveWhenConnected:
|
||||
time.Sleep(connectedDelay)
|
||||
_ = writeResp(leaveResponse(leaveAction))
|
||||
}
|
||||
|
||||
// drop_on_close: when the client starts a clean close handshake, drop the
|
||||
// TCP connection instead of replying with a close frame, so the client
|
||||
// observes an abnormal closure while it is disconnecting.
|
||||
if mode == modeDropOnClose {
|
||||
conn.SetCloseHandler(func(code int, text string) error {
|
||||
return conn.UnderlyingConn().Close()
|
||||
})
|
||||
}
|
||||
|
||||
// Read loop: pong to pings (unless no_pong), clean close on client leave.
|
||||
for {
|
||||
mt, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if mt != websocket.BinaryMessage {
|
||||
continue
|
||||
}
|
||||
req := &livekit.SignalRequest{}
|
||||
if err := proto.Unmarshal(payload, req); err != nil {
|
||||
continue
|
||||
}
|
||||
switch m := req.Message.(type) {
|
||||
case *livekit.SignalRequest_Ping:
|
||||
if mode != modeNoPong {
|
||||
_ = writeResp(&livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Pong{Pong: time.Now().UnixMilli()},
|
||||
})
|
||||
}
|
||||
case *livekit.SignalRequest_PingReq:
|
||||
if mode != modeNoPong {
|
||||
_ = writeResp(&livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_PongResp{
|
||||
PongResp: &livekit.Pong{
|
||||
LastPingTimestamp: m.PingReq.Timestamp,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
case *livekit.SignalRequest_UpdateMetadata:
|
||||
// Ack like the real server does, so SDK tests can observe that a
|
||||
// (possibly queued) request actually reached the server.
|
||||
_ = writeResp(&livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_RequestResponse{
|
||||
RequestResponse: &livekit.RequestResponse{
|
||||
RequestId: m.UpdateMetadata.RequestId,
|
||||
Reason: livekit.RequestResponse_OK,
|
||||
},
|
||||
},
|
||||
})
|
||||
case *livekit.SignalRequest_Leave:
|
||||
msg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")
|
||||
_ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serverInfo(regionIndex int) *livekit.ServerInfo {
|
||||
return &livekit.ServerInfo{
|
||||
Edition: livekit.ServerInfo_Standard,
|
||||
Version: "mock",
|
||||
Protocol: 15,
|
||||
Region: regionName(regionIndex),
|
||||
NodeId: "MOCK_NODE",
|
||||
}
|
||||
}
|
||||
|
||||
func regionName(regionIndex int) string {
|
||||
return "region-" + strconv.Itoa(regionIndex)
|
||||
}
|
||||
|
||||
// joinResponse builds the initial JoinResponse (non-zero ping config so the
|
||||
// client arms keepalive).
|
||||
func joinResponse(regionIndex int, grants *auth.ClaimGrants) *livekit.SignalResponse {
|
||||
room := grantRoom(grants)
|
||||
identity := "mock-participant"
|
||||
name := ""
|
||||
if grants != nil {
|
||||
if grants.Identity != "" {
|
||||
identity = grants.Identity
|
||||
}
|
||||
name = grants.Name
|
||||
}
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Join{
|
||||
Join: &livekit.JoinResponse{
|
||||
Room: &livekit.Room{
|
||||
Sid: "RM_MOCK",
|
||||
Name: room,
|
||||
},
|
||||
Participant: &livekit.ParticipantInfo{
|
||||
Sid: "PA_MOCK",
|
||||
Identity: identity,
|
||||
Name: name,
|
||||
State: livekit.ParticipantInfo_JOINED,
|
||||
},
|
||||
PingInterval: signalPingInterval,
|
||||
PingTimeout: signalPingTimeout,
|
||||
ServerInfo: serverInfo(regionIndex),
|
||||
ServerVersion: "mock",
|
||||
ServerRegion: regionName(regionIndex),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectResponse(regionIndex int) *livekit.SignalResponse {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Reconnect{
|
||||
Reconnect: &livekit.ReconnectResponse{
|
||||
ServerInfo: serverInfo(regionIndex),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// joinRequestSaysReconnect unpacks the v1 `join_request` param and reports its
|
||||
// Reconnect flag. Any decode error is ignored (treated as non-reconnect); this
|
||||
// is a test mock, not a validator.
|
||||
func joinRequestSaysReconnect(param string) bool {
|
||||
if param == "" {
|
||||
return false
|
||||
}
|
||||
wrappedBytes, err := base64.URLEncoding.DecodeString(param)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
wrapped := &livekit.WrappedJoinRequest{}
|
||||
if err := proto.Unmarshal(wrappedBytes, wrapped); err != nil {
|
||||
return false
|
||||
}
|
||||
var joinBytes []byte
|
||||
switch wrapped.Compression {
|
||||
case livekit.WrappedJoinRequest_NONE:
|
||||
joinBytes = wrapped.JoinRequest
|
||||
case livekit.WrappedJoinRequest_GZIP:
|
||||
gz, err := gzip.NewReader(bytes.NewReader(wrapped.JoinRequest))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
joinBytes, err = io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
join := &livekit.JoinRequest{}
|
||||
if err := proto.Unmarshal(joinBytes, join); err != nil {
|
||||
return false
|
||||
}
|
||||
return join.Reconnect
|
||||
}
|
||||
|
||||
func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Leave{
|
||||
Leave: &livekit.LeaveRequest{
|
||||
Reason: livekit.DisconnectReason_SERVER_SHUTDOWN,
|
||||
Action: action,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
451
cmd/test-server/signal_test.go
Normal file
451
cmd/test-server/signal_test.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
const testSecret = "secret"
|
||||
|
||||
func newTestServer() *httptest.Server {
|
||||
return httptest.NewServer(&mockHandler{regionIndex: 0, apiSecret: testSecret})
|
||||
}
|
||||
|
||||
// mintToken signs a token whose `lk.mock` attribute selects mode (empty → happy).
|
||||
func mintToken(t *testing.T, mode string) string {
|
||||
t.Helper()
|
||||
if mode == "" {
|
||||
return mintTokenControl(t, nil)
|
||||
}
|
||||
return mintTokenControl(t, &signalControl{Signal: mode})
|
||||
}
|
||||
|
||||
// mintTokenControl signs a token carrying ctrl as the `lk.mock` attribute (nil → none).
|
||||
func mintTokenControl(t *testing.T, ctrl *signalControl) string {
|
||||
t.Helper()
|
||||
at := auth.NewAccessToken("APItest", testSecret).
|
||||
SetIdentity("tester").
|
||||
SetValidFor(time.Hour).
|
||||
SetVideoGrant(&auth.VideoGrant{Room: "test-room", RoomJoin: true})
|
||||
if ctrl != nil {
|
||||
raw, err := json.Marshal(ctrl)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal control: %v", err)
|
||||
}
|
||||
at.SetAttributes(map[string]string{signalControlAttribute: string(raw)})
|
||||
}
|
||||
tok, err := at.ToJWT()
|
||||
if err != nil {
|
||||
t.Fatalf("mint token: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// mintTokenAttr signs a token whose `lk.mock` attribute is the given raw value.
|
||||
func mintTokenAttr(t *testing.T, attrValue string) string {
|
||||
t.Helper()
|
||||
at := auth.NewAccessToken("APItest", testSecret).
|
||||
SetIdentity("tester").
|
||||
SetValidFor(time.Hour).
|
||||
SetVideoGrant(&auth.VideoGrant{Room: "test-room", RoomJoin: true}).
|
||||
SetAttributes(map[string]string{signalControlAttribute: attrValue})
|
||||
tok, err := at.ToJWT()
|
||||
if err != nil {
|
||||
t.Fatalf("mint token: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func wsURL(base, path, token string) string {
|
||||
u := strings.Replace(base, "http://", "ws://", 1)
|
||||
sep := "?"
|
||||
if strings.Contains(path, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return u + path + sep + "access_token=" + token
|
||||
}
|
||||
|
||||
func dial(t *testing.T, base, path, token string) *websocket.Conn {
|
||||
t.Helper()
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL(base, path, token), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s: %v", path, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func readResp(t *testing.T, c *websocket.Conn, timeout time.Duration) *livekit.SignalResponse {
|
||||
t.Helper()
|
||||
_ = c.SetReadDeadline(time.Now().Add(timeout))
|
||||
mt, payload, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if mt != websocket.BinaryMessage {
|
||||
t.Fatalf("expected binary message, got %d", mt)
|
||||
}
|
||||
resp := &livekit.SignalResponse{}
|
||||
if err := proto.Unmarshal(payload, resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func writeReq(t *testing.T, c *websocket.Conn, req *livekit.SignalRequest) {
|
||||
t.Helper()
|
||||
payload, err := proto.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal req: %v", err)
|
||||
}
|
||||
if err := c.WriteMessage(websocket.BinaryMessage, payload); err != nil {
|
||||
t.Fatalf("write req: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHappyJoinAndPingPong(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "happy"))
|
||||
defer c.Close()
|
||||
|
||||
resp := readResp(t, c, 2*time.Second)
|
||||
join := resp.GetJoin()
|
||||
if join == nil {
|
||||
t.Fatalf("first message not join: %T", resp.Message)
|
||||
}
|
||||
if join.PingTimeout == 0 || join.PingInterval == 0 {
|
||||
t.Fatalf("ping timeout/interval must be non-zero: %d/%d", join.PingTimeout, join.PingInterval)
|
||||
}
|
||||
if join.ServerInfo == nil || join.Room == nil || join.Participant == nil {
|
||||
t.Fatalf("join missing serverInfo/room/participant")
|
||||
}
|
||||
if join.Room.Name != "test-room" {
|
||||
t.Fatalf("room name = %q, want test-room", join.Room.Name)
|
||||
}
|
||||
|
||||
// pingReq -> pongResp echoing timestamp
|
||||
writeReq(t, c, &livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_PingReq{PingReq: &livekit.Ping{Timestamp: 12345}},
|
||||
})
|
||||
pr := readResp(t, c, 2*time.Second)
|
||||
if pr.GetPongResp() == nil || pr.GetPongResp().LastPingTimestamp != 12345 {
|
||||
t.Fatalf("expected pongResp echoing 12345, got %+v", pr.Message)
|
||||
}
|
||||
|
||||
// legacy ping -> pong
|
||||
writeReq(t, c, &livekit.SignalRequest{Message: &livekit.SignalRequest_Ping{Ping: 999}})
|
||||
pong := readResp(t, c, 2*time.Second)
|
||||
if pong.GetPong() == 0 {
|
||||
t.Fatalf("expected pong, got %+v", pong.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectResponse(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc?reconnect=1", mintToken(t, "happy"))
|
||||
defer c.Close()
|
||||
resp := readResp(t, c, 2*time.Second)
|
||||
if resp.GetReconnect() == nil {
|
||||
t.Fatalf("expected reconnect response, got %T", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1PathHappy(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc/v1", mintToken(t, "happy"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("v1 first message not join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoPong(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "no_pong"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join")
|
||||
}
|
||||
writeReq(t, c, &livekit.SignalRequest{
|
||||
Message: &livekit.SignalRequest_PingReq{PingReq: &livekit.Ping{Timestamp: 1}},
|
||||
})
|
||||
_ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
if _, _, err := c.ReadMessage(); err == nil {
|
||||
t.Fatal("expected no pong (timeout), but got a message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveFirstMessage(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "leave_first_message"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetLeave() == nil {
|
||||
t.Fatal("expected leave as first message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseWhenConnected(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "close_when_connected"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join")
|
||||
}
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _, err := c.ReadMessage()
|
||||
ce, ok := err.(*websocket.CloseError)
|
||||
if !ok {
|
||||
t.Fatalf("expected close error, got %v", err)
|
||||
}
|
||||
if ce.Code != websocket.CloseInternalServerErr {
|
||||
t.Fatalf("expected close code 1011, got %d", ce.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveWhenConnected(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "leave_when_connected"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join")
|
||||
}
|
||||
leave := readResp(t, c, 2*time.Second).GetLeave()
|
||||
if leave == nil {
|
||||
t.Fatal("expected leave after join")
|
||||
}
|
||||
// Default action is DISCONNECT.
|
||||
if leave.Action != livekit.LeaveRequest_DISCONNECT {
|
||||
t.Fatalf("default leave action = %v, want DISCONNECT", leave.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveActionOverride(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
tok := mintTokenControl(t, &signalControl{
|
||||
Signal: "leave_when_connected",
|
||||
LeaveAction: leaveActionValue(livekit.LeaveRequest_RECONNECT),
|
||||
})
|
||||
c := dial(t, srv.URL, "/rtc", tok)
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join")
|
||||
}
|
||||
leave := readResp(t, c, 2*time.Second).GetLeave()
|
||||
if leave == nil {
|
||||
t.Fatal("expected leave after join")
|
||||
}
|
||||
if leave.Action != livekit.LeaveRequest_RECONNECT {
|
||||
t.Fatalf("leave action = %v, want RECONNECT", leave.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveActionByName(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
// leaveAction may be the enum name instead of the number.
|
||||
tok := mintTokenAttr(t, `{"signal":"leave_when_connected","leaveAction":"RECONNECT"}`)
|
||||
c := dial(t, srv.URL, "/rtc", tok)
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join")
|
||||
}
|
||||
leave := readResp(t, c, 2*time.Second).GetLeave()
|
||||
if leave == nil {
|
||||
t.Fatal("expected leave after join")
|
||||
}
|
||||
if leave.Action != livekit.LeaveRequest_RECONNECT {
|
||||
t.Fatalf("leave action = %v, want RECONNECT", leave.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFirstMessage(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "no_first_message"))
|
||||
defer c.Close()
|
||||
_ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
if _, _, err := c.ReadMessage(); err == nil {
|
||||
t.Fatal("expected no first message (timeout)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseBeforeJoin(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "close_before_join"))
|
||||
defer c.Close()
|
||||
// First read must be the close, never a join.
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _, err := c.ReadMessage()
|
||||
ce, ok := err.(*websocket.CloseError)
|
||||
if !ok {
|
||||
t.Fatalf("expected close error before any message, got %v", err)
|
||||
}
|
||||
if ce.Code != websocket.CloseInternalServerErr {
|
||||
t.Fatalf("expected close code 1011, got %d", ce.Code)
|
||||
}
|
||||
if ce.Text != "" {
|
||||
t.Fatalf("expected empty close reason, got %q", ce.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropWhenConnected(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc", mintToken(t, "drop_when_connected"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join")
|
||||
}
|
||||
// Abrupt TCP drop → abnormal closure (1006 to a browser): a read error that
|
||||
// is not a normal (1000) close.
|
||||
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _, err := c.ReadMessage()
|
||||
if err == nil {
|
||||
t.Fatal("expected read error after abrupt drop")
|
||||
}
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
t.Fatalf("expected abnormal (non-1000) closure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getStatusBody(t *testing.T, url string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func TestValidateModes(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
|
||||
// happy → 200
|
||||
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "happy")); st != 200 {
|
||||
t.Fatalf("happy validate = %d, want 200", st)
|
||||
}
|
||||
// v1 validate happy → 200
|
||||
if st, _ := getStatusBody(t, srv.URL+"/rtc/v1/validate?access_token="+mintToken(t, "happy")); st != 200 {
|
||||
t.Fatalf("v1 happy validate = %d, want 200", st)
|
||||
}
|
||||
// validate_500 → 500
|
||||
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "validate_500")); st != 500 {
|
||||
t.Fatalf("validate_500 = %d, want 500", st)
|
||||
}
|
||||
// validate_service_not_found → 404, no marker
|
||||
st, body := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "validate_service_not_found"))
|
||||
if st != 404 || strings.Contains(body, "requested room does not exist") {
|
||||
t.Fatalf("service_not_found = %d body=%q", st, body)
|
||||
}
|
||||
// room_not_found → 404 with marker
|
||||
st, body = getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "room_not_found"))
|
||||
if st != 404 || !strings.Contains(body, "requested room does not exist") {
|
||||
t.Fatalf("room_not_found = %d body=%q", st, body)
|
||||
}
|
||||
// bad token → 401
|
||||
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token=not-a-jwt"); st != 401 {
|
||||
t.Fatalf("bad token validate = %d, want 401", st)
|
||||
}
|
||||
// missing token → 401
|
||||
if st, _ := getStatusBody(t, srv.URL+"/rtc/validate"); st != 401 {
|
||||
t.Fatalf("missing token validate = %d, want 401", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrorModesRefuseWS(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
// Validate-error modes must refuse the upgrade so the client falls back to validate.
|
||||
_, resp, err := websocket.DefaultDialer.Dial(wsURL(srv.URL, "/rtc", mintToken(t, "validate_500")), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected WS dial to fail for validate_500")
|
||||
}
|
||||
if resp == nil || resp.StatusCode != 500 {
|
||||
t.Fatalf("expected 500 on WS refuse, got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCORSHeader(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
// ACAO must be present on every status so a browser fetch can read it.
|
||||
cases := map[string]string{
|
||||
"happy": mintToken(t, "happy"), // 200
|
||||
"bad": "bad", // 401
|
||||
"room_not_found": mintToken(t, "room_not_found"), // 404
|
||||
"validate_500": mintToken(t, "validate_500"), // 500
|
||||
}
|
||||
for name, tok := range cases {
|
||||
resp, err := http.Get(srv.URL + "/rtc/validate?access_token=" + tok)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: get: %v", name, err)
|
||||
}
|
||||
got := resp.Header.Get("Access-Control-Allow-Origin")
|
||||
resp.Body.Close()
|
||||
if got != "*" {
|
||||
t.Fatalf("%s (status %d): ACAO = %q, want *", name, resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSCrossOrigin(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
// A mismatched browser Origin must still upgrade (CheckOrigin allows any).
|
||||
hdr := http.Header{}
|
||||
hdr.Set("Origin", "http://localhost:5173")
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL(srv.URL, "/rtc", mintToken(t, "happy")), hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("cross-origin dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetJoin() == nil {
|
||||
t.Fatal("expected join on cross-origin WS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveDuringReconnect(t *testing.T) {
|
||||
srv := newTestServer()
|
||||
defer srv.Close()
|
||||
c := dial(t, srv.URL, "/rtc?reconnect=1", mintToken(t, "leave_during_reconnect"))
|
||||
defer c.Close()
|
||||
if readResp(t, c, 2*time.Second).GetLeave() == nil {
|
||||
t.Fatal("expected leave on reconnect")
|
||||
}
|
||||
}
|
||||
423
config-sample.yaml
Normal file
423
config-sample.yaml
Normal file
@@ -0,0 +1,423 @@
|
||||
# Copyright 2024 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# main TCP port for RoomService and RTC endpoint
|
||||
# for production setups, this port should be placed behind a load balancer with TLS
|
||||
port: 7880
|
||||
|
||||
# when redis is set, LiveKit will automatically operate in a fully distributed fashion
|
||||
# clients could connect to any node and be routed to the same room
|
||||
redis:
|
||||
address: redis.host:6379
|
||||
# db: 0
|
||||
# username: myuser
|
||||
# password: mypassword
|
||||
# To use sentinel remove the address key above and add the following
|
||||
# sentinel_master_name: livekit
|
||||
# sentinel_addresses:
|
||||
# - livekit-redis-node-0.livekit-redis-headless:26379
|
||||
# - livekit-redis-node-1.livekit-redis-headless:26379
|
||||
# If you use a different set of credentials for sentinel add
|
||||
# sentinel_username: user
|
||||
# sentinel_password: pass
|
||||
#
|
||||
# To use TLS with redis
|
||||
# tls:
|
||||
# enabled: true
|
||||
# # when set to true, LiveKit will not verify the server's certificate, defaults to true
|
||||
# insecure: false
|
||||
# server_name: myserver.com
|
||||
# # file containing trusted root certificates for verification
|
||||
# ca_cert_file: /path/to/ca.crt
|
||||
# client_cert_file: /path/to/client.crt
|
||||
# client_key_file: /path/to/client.key
|
||||
#
|
||||
# To use cluster remove the address key above and add the following
|
||||
# cluster_addresses:
|
||||
# - livekit-redis-node-0.livekit-redis-headless:6379
|
||||
# - livekit-redis-node-1.livekit-redis-headless:6380
|
||||
# And it will use the password key above as cluster password
|
||||
# And the db key will not be used due to cluster mode not support it.
|
||||
|
||||
# WebRTC configuration
|
||||
rtc:
|
||||
# UDP ports to use for client traffic.
|
||||
# this port range should be open for inbound traffic on the firewall
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
# when set, LiveKit enable WebRTC ICE over TCP when UDP isn't available
|
||||
# this port *cannot* be behind load balancer or TLS, and must be exposed on the node
|
||||
# WebRTC transports are encrypted and do not require additional encryption
|
||||
# only 80/443 on public IP are allowed if less than 1024
|
||||
tcp_port: 7881
|
||||
# when set to true, attempts to discover the host's public IP via STUN
|
||||
# this is useful for cloud environments such as AWS & Google where hosts have an internal IP
|
||||
# that maps to an external one
|
||||
use_external_ip: true
|
||||
# # when set to true, advertises both mapped external and internal IPs to clients as server candidates.
|
||||
# # useful when clients connect from both private and public networks.
|
||||
# # works when `use_external_ip` is set to true, and also when `node_ip` is set explicitly below.
|
||||
# # in both cases the node's local candidate is kept alongside the mapped one instead of being replaced by it.
|
||||
# # when both this and `external_ip_only` are set, SFU advertises all private IPs with their mapped external IPs and skips
|
||||
# # private IPs that do not have a mapped external IP.
|
||||
# advertise_internal_ip: true
|
||||
# # when set to true, SFU skips external IP accessibility checks via self-ping.
|
||||
# # enable this when SFU is behind NAT that does not allow self-ping. only works when `use_external_ip` is set to true.
|
||||
# skip_external_ip_validation: true
|
||||
# # there are cases where the public IP determined via STUN is not the correct one
|
||||
# # in such cases, use this setting to set the public IP of the node
|
||||
# # use_external_ip takes precedence, for this to take effect, set use_external_ip to false
|
||||
# node_ip: <external-ip-of-node>
|
||||
# # when set, LiveKit will attempt to use a UDP mux so all UDP traffic goes through
|
||||
# # listed port(s). To maximize system performance, we recommend using a range of ports
|
||||
# # greater or equal to the number of vCPUs on the machine.
|
||||
# # port_range_start & end must not be set for this config to take effect
|
||||
# udp_port: 7882-7892
|
||||
# # when set to true, server will use a lite ice agent, that will speed up ice connection, but
|
||||
# # might cause connect issue if server running behind NAT.
|
||||
# use_ice_lite: true
|
||||
# # optional STUN servers for LiveKit clients to use. Clients will be configured to use these STUN servers automatically.
|
||||
# # by default LiveKit clients use Google's public STUN servers
|
||||
# stun_servers:
|
||||
# - server1
|
||||
# # optional TURN servers for clients. This isn't necessary if using embedded TURN server (see below).
|
||||
# turn_servers:
|
||||
# - host: myhost.com
|
||||
# port: 443
|
||||
# # tls, tcp, or udp
|
||||
# protocol: tls
|
||||
# # Shared secret for TURN server authentication
|
||||
# secret: ""
|
||||
# # Path for file containing shared secret for TURN server authentication
|
||||
# # When both secret and secret_file are set, secret takes precedence
|
||||
# # An empty/whitespace-only secret file fails startup rather than silently
|
||||
# # falling back to static credentials
|
||||
# secret_file: "/var/lib/coturn/secret"
|
||||
# # TTL of generated credentials in seconds. Defaults to 14400 (4h) when 0.
|
||||
# # Negative values fall back to the 300s (5m) default; values are capped at 86400 (24h).
|
||||
# ttl: 14400 # seconds
|
||||
# # Insecure username/password authentication
|
||||
# username: ""
|
||||
# credential: ""
|
||||
# # allows LiveKit to monitor congestion when sending streams and automatically
|
||||
# # manage bandwidth utilization to avoid congestion/loss. Enabled by default
|
||||
# congestion_control:
|
||||
# enabled: true
|
||||
# # in the unlikely event of highly congested networks, SFU may choose to pause some tracks
|
||||
# # in order to allow others to stream smoothly. You can disable this behavior here
|
||||
# allow_pause: true
|
||||
# # allows automatic connection fallback to TCP and TURN/TLS (if configured) when UDP has been unstable, default true
|
||||
# allow_tcp_fallback: true
|
||||
# # signaling RTT (in milliseconds) below which ICE/TCP is attempted on a UDP failure; at or above it,
|
||||
# # supporting clients fall back directly to TURN/TLS. 0 (default) disables the check (ICE/TCP always tried).
|
||||
# # a positive value also enables allow_udp_unstable_fallback.
|
||||
# tcp_fallback_rtt_threshold: 0
|
||||
# # migrate an established-but-lossy UDP connection to ICE/TCP or TURN/TLS. requires tcp_fallback_rtt_threshold > 0, default false
|
||||
# allow_udp_unstable_fallback: false
|
||||
# # number of packets to buffer in the SFU for video, defaults to 500
|
||||
# packet_buffer_size_video: 500
|
||||
# # number of packets to buffer in the SFU for audio, defaults to 200
|
||||
# packet_buffer_size_audio: 200
|
||||
# # minimum amount of time between pli/fir rtcp packets being sent to an individual
|
||||
# # producer. Increasing these times can lead to longer black screens when new participants join,
|
||||
# # while reducing them can lead to higher stream bitrate.
|
||||
# pli_throttle:
|
||||
# low_quality: 500ms
|
||||
# mid_quality: 1s
|
||||
# high_quality: 1s
|
||||
# # when set, Livekit will collect loopback candidates, it is useful for some VM have public address mapped to its loopback interface.
|
||||
# enable_loopback_candidate: true
|
||||
# # network interface filter. If the machine has more than one network interface and you'd like it to use or skip specific interfaces
|
||||
# # both inclusion and exclusion filters can be used together. If neither is defined (default), all interfaces on the machine will be used.
|
||||
# # If both of them are set, then only include takes effect.
|
||||
# interfaces:
|
||||
# includes:
|
||||
# - en0
|
||||
# excludes:
|
||||
# - docker0
|
||||
# # ip address filter. If the machine has more than one ip address and you'd like it to use or skip specific ips,
|
||||
# # both inclusion and exclusion CIDR filters can be used together. If neither is defined (default), all ip on the machine will be used.
|
||||
# # If both of them are set, then only include takes effect.
|
||||
# ips:
|
||||
# includes:
|
||||
# - 10.0.0.0/16
|
||||
# excludes:
|
||||
# - 192.168.1.0/24
|
||||
# # Set to true to enable mDNS name candidate. This should be left disabled for most users.
|
||||
# # when enabled, it will impact performance since each PeerConnection will process the same mDNS message independently
|
||||
# use_mdns: true
|
||||
# # Set to false to disable strict ACKs for peer connections where LiveKit is the dialing side,
|
||||
# # ie. subscriber peer connections. Disabling strict ACKs will prevent clients that do not ACK
|
||||
# # peer connections from getting kicked out of rooms by the monitor. Note that if strict ACKs
|
||||
# # are disabled and clients don't ACK opened peer connections, only reliable, ordered delivery
|
||||
# # will be available.
|
||||
# strict_acks: true
|
||||
# # enable batch write to merge network write system calls to reduce cpu usage. Outgoing packets
|
||||
# # will be queued until length of queue equal to `batch_size` or time elapsed since last write exceeds `max_flush_interval`.
|
||||
# batch_io:
|
||||
# batch_size: 128
|
||||
# max_flush_interval: 2ms
|
||||
# # max number of bytes to buffer for data channel. 0 means unlimited.
|
||||
# # when this limit is breached, data messages will be dropped till the buffered amount drops below this limit.
|
||||
# data_channel_max_buffered_amount: 0
|
||||
# # target latency for data track channels. under downlink congestion, frames are dropped
|
||||
# # to keep the per-subscriber buffer bounded (a fresh frame is preferable to a stale queued one).
|
||||
# # 0 disables buffering control (unbounded buffering). defaults to 100ms.
|
||||
# datachannel_data_track_target_latency: 100ms
|
||||
|
||||
# when enabled, LiveKit will expose prometheus metrics on :6789/metrics
|
||||
# prometheus_port: 6789
|
||||
|
||||
# expose /debug/pprof (and /debug/goroutine, /debug/rooms) on a dedicated port,
|
||||
# separate from the public signalling port. only enabled when port is set.
|
||||
# debug_handler_port:
|
||||
# port: 7070
|
||||
|
||||
# API key / secret pairs.
|
||||
# Keys are used for JWT authentication, server APIs would require a keypair in order to generate access tokens
|
||||
# and make calls to the server
|
||||
keys:
|
||||
key1: secret1
|
||||
key2: secret2
|
||||
# Logging config
|
||||
# logging:
|
||||
# # log level, valid values: debug, info, warn, error
|
||||
# level: info
|
||||
# # log level for pion, default error
|
||||
# pion_level: error
|
||||
# # when set to true, emit json fields
|
||||
# json: false
|
||||
# # for production setups, enables sampling algorithm
|
||||
# # https://github.com/uber-go/zap/blob/master/FAQ.md#why-sample-application-logs
|
||||
# sample: false
|
||||
|
||||
# Default room config
|
||||
# Each room created will inherit these settings. If rooms are created explicitly with CreateRoom, they will take
|
||||
# precedence over defaults
|
||||
# room:
|
||||
# # allow rooms to be automatically created when participants join, defaults to true
|
||||
# # auto_create: false
|
||||
# # number of seconds to keep the room open if no one joins
|
||||
# empty_timeout: 300
|
||||
# # number of seconds to keep the room open after everyone leaves
|
||||
# departure_timeout: 20
|
||||
# # limit number of participants that can be in a room, 0 for no limit
|
||||
# max_participants: 0
|
||||
# # only accept specific codecs for clients publishing to this room
|
||||
# # this is useful to standardize codecs across clients
|
||||
# # other supported codecs are video/h264, video/vp9, video/av1, audio/red
|
||||
# enabled_codecs:
|
||||
# - mime: audio/opus
|
||||
# - mime: video/vp8
|
||||
# # allow tracks to be unmuted remotely, defaults to false
|
||||
# # tracks can always be muted from the Room Service APIs
|
||||
# enable_remote_unmute: true
|
||||
# # control playout delay in ms of video track (and associated audio track)
|
||||
# playout_delay:
|
||||
# enabled: true
|
||||
# min: 100
|
||||
# max: 2000
|
||||
# # improves A/V sync when playout_delay set to a value larger than 200ms. It will disables transceiver re-use
|
||||
# # so not recommended for rooms with frequent subscription changes
|
||||
# sync_streams: true
|
||||
|
||||
# Webhooks
|
||||
# when configured, LiveKit notifies your URL handler with room events
|
||||
# webhook:
|
||||
# # the API key to use in order to sign the message
|
||||
# # this must match one of the keys LiveKit is configured with
|
||||
# api_key: <api_key>
|
||||
# # list of URLs to be notified of room events
|
||||
# urls:
|
||||
# - https://your-host.com/handler
|
||||
|
||||
# Signal Relay
|
||||
# since v1.4.0, a more reliable, psrpc based signal relay is available
|
||||
# this gives us the ability to reliably proxy messages between a signal server and RTC node
|
||||
# signal_relay:
|
||||
# # amount of time a message delivery is tried before giving up
|
||||
# retry_timeout: 30s
|
||||
# # minimum amount of time to wait for RTC node to ack,
|
||||
# # retries use exponentially increasing wait on every subsequent try
|
||||
# # with an upper bound of max_retry_interval
|
||||
# min_retry_interval: 500ms
|
||||
# # maximum amount of time to wait for RTC node to ack
|
||||
# max_retry_interval: 5s
|
||||
# # number of messages to buffer before dropping
|
||||
# stream_buffer_size: 1000
|
||||
|
||||
# PSRPC
|
||||
# since v1.5.1, a more reliable, psrpc based internal rpc
|
||||
# psrpc:
|
||||
# # maximum number of rpc attempts
|
||||
# max_attempts: 3
|
||||
# # initial time to wait for calls to complete
|
||||
# timeout: 500ms
|
||||
# # amount of time added to the timeout after each failure
|
||||
# backoff: 500ms
|
||||
# # number of messages to buffer before dropping
|
||||
# buffer_size: 1000
|
||||
# # optional gzip compression of bus payloads
|
||||
# compression:
|
||||
# # gzip level 1-9; 0 disables. every node on the bus must support
|
||||
# # compression before enabling it
|
||||
# quality: 0
|
||||
# # payload bytes below which compression is skipped
|
||||
# threshold: 1024
|
||||
# # cap on an inbound payload after decompression, 0 for unlimited
|
||||
# max_decompressed_size: 0
|
||||
|
||||
# customize audio level sensitivity
|
||||
# audio:
|
||||
# # minimum level to be considered active, 0-127, where 0 is loudest
|
||||
# # defaults to 30
|
||||
# active_level: 30
|
||||
# # percentile to measure, a participant is considered active if it has exceeded the
|
||||
# # ActiveLevel more than MinPercentile% of the time
|
||||
# # defaults to 40
|
||||
# min_percentile: 40
|
||||
# # frequency in ms to notify changes to clients, defaults to 500
|
||||
# update_interval: 500
|
||||
# # to prevent speaker updates from too jumpy, smooth out values over N samples
|
||||
# smooth_intervals: 4
|
||||
# # enable red encoding downtrack for opus only audio up track
|
||||
# active_red_encoding: true
|
||||
|
||||
# turn server
|
||||
# turn:
|
||||
# # Uses TLS. Requires cert and key pem files by either:
|
||||
# # - using turn.secretName if deploying with our helm chart, or
|
||||
# # - setting LIVEKIT_TURN_CERT and LIVEKIT_TURN_KEY env vars with file locations, or
|
||||
# # - using cert_file and key_file below
|
||||
# # defaults to false
|
||||
# enabled: false
|
||||
# # defaults to 3478 - recommended to 443 if not running HTTP3/QUIC server
|
||||
# # only 53/80/443 are allowed if less than 1024
|
||||
# udp_port: 3478
|
||||
# # defaults to 5349 - if not using a load balancer, this must be set to 443
|
||||
# tls_port: 5349
|
||||
# # set UDP port range for TURN relay to connect to LiveKit SFU, by default it uses a any available port
|
||||
# relay_range_start: 1024
|
||||
# relay_range_end: 30000
|
||||
# # caps the number of concurrent relay allocations a single participant credential may hold.
|
||||
# # prevents one authenticated participant from exhausting the shared relay-port range for everyone else.
|
||||
# # defaults to 12; a value <= 0 disables the quota.
|
||||
# per_user_relay_allocation_limit: 12
|
||||
# # set external_tls to true if using a L4 load balancer to terminate TLS. when enabled,
|
||||
# # LiveKit expects unencrypted traffic on tls_port, and still advertise tls_port as a TURN/TLS candidate.
|
||||
# external_tls: true
|
||||
# # set proxy_protocol to true if the proxy or load balancer in front of tls_port does not preserve
|
||||
# # the client address (it terminates TLS, or dials LiveKit from its own IP) and can send a
|
||||
# # PROXY protocol v1/v2 header instead. Without it TURN reports the proxy's address to the
|
||||
# # client as XOR-MAPPED-ADDRESS, which Firefox rejects when it is loopback or wildcard.
|
||||
# # every connection on tls_port must then carry the header; connections without it are rejected.
|
||||
# # prefer PROXY protocol v2 on the proxy side: the v1 text header must arrive in a single read.
|
||||
# proxy_protocol: false
|
||||
# # proxies whose PROXY header is trusted. connections from any other address are closed, so a
|
||||
# # client that reaches tls_port directly cannot claim an arbitrary source address.
|
||||
# # defaults to loopback, for a proxy running on the same host.
|
||||
# proxy_protocol_trusted_cidrs:
|
||||
# - 127.0.0.0/8
|
||||
# - ::1/128
|
||||
# # needs to match tls cert domain
|
||||
# domain: turn.myhost.com
|
||||
# # optional (set only if not using external TLS termination)
|
||||
# # cert_file: /path/to/cert.pem
|
||||
# # key_file: /path/to/key.pem
|
||||
# # TTL of the TURN credentials in seconds - defaults to 300.
|
||||
# # Values <= 0 fall back to the 300s (5m) default; values are capped at 86400 (24h).
|
||||
# ttl_seconds: 300
|
||||
# # list of restricted peer CIDRs (loopback, link-local (unicast, multicast), multicast, private, unspecified) to allow access to.
|
||||
# # By default (i. e. empty list), all restricted peer CIDRs are denied access.
|
||||
# # When not empty, only the specified CIDRs are allowed access.
|
||||
# # Note that this check is applied to restricted peer CIDRs only.
|
||||
# allow_restricted_peer_cidrs:
|
||||
# - 10.0.0.0/8
|
||||
# - 192.168.0.0/16
|
||||
# # list of peer CIDRs to deny access to.
|
||||
# # This applies to all peer CIDRs, including restricted ones.
|
||||
# # Deny list takes precedence over allow list.
|
||||
# # A malformed CIDR in either list fails startup rather than being silently ignored.
|
||||
# deny_peer_cidrs:
|
||||
# - 10.0.0.0/8
|
||||
# - 192.168.0.0/16
|
||||
|
||||
# ingress server
|
||||
# ingress:
|
||||
# # Prefix used to generate RTMP URLs for RTMP ingress.
|
||||
# rtmp_base_url: "rtmp://my.domain.com/live"
|
||||
# # Prefix used to generate WHIP URLs for WHIP ingress.
|
||||
# whip_base_url: "http://my.domain.com/whip"
|
||||
# # Allow URL pull ingress from udp:// source URLs. Disabled by default.
|
||||
# # Only enable this if you trust both the callers allowed to create ingresses and the network the
|
||||
# # ingress handlers run on. Unlike an http or srt source url, a udp source url doesn't make the
|
||||
# # handler connect out to the url host: the handler binds a local socket on the address and port
|
||||
# # taken from the url, and joins the multicast group if one is given. This lets the caller choose
|
||||
# # which local port the handler binds, feed the session unauthenticated and easily spoofed traffic,
|
||||
# # and make the handler join arbitrary multicast groups and republish whatever it receives into a
|
||||
# # room, using the ingress as a relay for streams on the handler's local network.
|
||||
# enable_udp_url_pull: false
|
||||
|
||||
# Region of the current node. Required if using regionaware node selector
|
||||
# region: us-west-2
|
||||
|
||||
# # node selector
|
||||
# node_selector:
|
||||
# # default: any. valid values: any, sysload, cpuload, regionaware
|
||||
# kind: sysload
|
||||
# # priority used for selection of node when multiple are available
|
||||
# # default: random. valid values: random, sysload, cpuload, rooms, clients, tracks, bytespersec
|
||||
# sort_by: sysload
|
||||
# # algorithm used to govern selecting from sorted nodes
|
||||
# # default: lowest. valid values: lowest, twochoice
|
||||
# algorithm: lowest
|
||||
# # used in sysload and regionaware
|
||||
# # do not assign room to node if load per CPU exceeds sysload_limit
|
||||
# sysload_limit: 0.7
|
||||
# # used in regionaware
|
||||
# # list of regions and their lat/lon coordinates
|
||||
# regions:
|
||||
# - name: us-west-2
|
||||
# lat: 44.19434095976287
|
||||
# lon: -123.0674908379146
|
||||
|
||||
# # node limits
|
||||
# # set to -1 to disable a limit
|
||||
# limit:
|
||||
# # defaults to 400 tracks in & out per CPU, up to 8000
|
||||
# num_tracks: -1
|
||||
# # defaults to 1 GB/s, or just under 10 Gbps
|
||||
# bytes_per_sec: 1_000_000_000
|
||||
# # how many tracks (audio / video) that a single participant can subscribe at same time.
|
||||
# # if the limit is exceeded, subscriptions will be pending until any subscribed track has been unsubscribed.
|
||||
# # value less or equal than 0 means no limit.
|
||||
# subscription_limit_video: 0
|
||||
# subscription_limit_audio: 0
|
||||
# # limit size of room and participant's metadata, 0 for no limit
|
||||
# max_metadata_size: 0
|
||||
# # limit size of participant attributes, 0 for no limit
|
||||
# max_attributes_size: 0
|
||||
# # limit length of room names
|
||||
# max_room_name_length: 0
|
||||
# # limit length of participant identity
|
||||
# max_participant_identity_length: 0
|
||||
# # max size (bytes) of a single client-facing signalling WebSocket message; oversized
|
||||
# # frames are rejected by the transport before being buffered. defaults to 2 MiB, 0 for no limit
|
||||
# signal_message_size_limit: 2097152
|
||||
# # same as above, but for agent worker WebSocket connections. defaults to 2 MiB, 0 for no limit
|
||||
# agent_signal_message_size_limit: 2097152
|
||||
# # max size (bytes) of an HTTP request body on the main API listener (Twirp room/egress/ingress/SIP
|
||||
# # routes). requests larger than this are rejected before their body is decoded, so large messages
|
||||
# # cannot exhaust memory. defaults to 10 MiB, 0 for no limit
|
||||
# max_api_request_body_size: 10485760
|
||||
8
deploy/README.md
Normal file
8
deploy/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# LiveKit Server Deployment
|
||||
|
||||
Deployment Guides:
|
||||
|
||||
- [Deploy to a VM](https://docs.livekit.io/deploy/vm)
|
||||
- [Deploy to Kubernetes](https://docs.livekit.io/deploy/kubernetes)
|
||||
|
||||
Also included are Grafana charts for metrics gathered in Prometheus.
|
||||
531
deploy/grafana/livekit-server-overview.json
Normal file
531
deploy/grafana/livekit-server-overview.json
Normal file
@@ -0,0 +1,531 @@
|
||||
{
|
||||
"__inputs": [],
|
||||
"__requires": [
|
||||
{
|
||||
"type": "grafana",
|
||||
"id": "grafana",
|
||||
"name": "Grafana",
|
||||
"version": "8.2.2"
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "timeseries",
|
||||
"name": "Time series",
|
||||
"version": ""
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"target": {
|
||||
"limit": 100,
|
||||
"matchAny": false,
|
||||
"tags": [],
|
||||
"type": "dashboard"
|
||||
},
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(livekit_room_total)",
|
||||
"interval": "",
|
||||
"legendFormat": "Rooms",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"title": "Rooms",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(livekit_participant_total)",
|
||||
"interval": "",
|
||||
"legendFormat": "Participants",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Participants",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_node_messages{type=\"signal\"}[5m]))",
|
||||
"interval": "",
|
||||
"legendFormat": "Signal",
|
||||
"refId": "Signal"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_node_messages{type=\"rtc\"}[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "RTC",
|
||||
"refId": "RTC"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_node_messages{status=\"failure\"}[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "Failure",
|
||||
"refId": "Failure"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_messagebus_messages{type=\"out\"}[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "Out",
|
||||
"refId": "Out"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_messagebus_messages{type=\"out\", status=\"failure\"}[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "Out Failure",
|
||||
"refId": "Out Failure"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"op": "gt",
|
||||
"value": 0,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"title": "Message Rate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 11,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(livekit_track_published_total)",
|
||||
"interval": "",
|
||||
"legendFormat": "Tracks",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Tracks Published",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
},
|
||||
"id": 13,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_packet_total[5m]))",
|
||||
"interval": "",
|
||||
"legendFormat": "Packets",
|
||||
"refId": "Packets"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_nack_total[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "NACK",
|
||||
"refId": "NACK"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_pli_total[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "PLI",
|
||||
"refId": "PLI"
|
||||
},
|
||||
{
|
||||
"exemplar": true,
|
||||
"expr": "sum(irate(livekit_fir_total[5m]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "FIR",
|
||||
"refId": "FIR"
|
||||
}
|
||||
],
|
||||
"title": "Network Rate",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "5m",
|
||||
"schemaVersion": 31,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "LiveKit Server Overview",
|
||||
"uid": "z_GO3t5nz",
|
||||
"version": 2
|
||||
}
|
||||
171
go.mod
Normal file
171
go.mod
Normal file
@@ -0,0 +1,171 @@
|
||||
module github.com/livekit/livekit-server
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/d5/tengo/v2 v2.17.0
|
||||
github.com/dennwc/iters v1.2.2
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.1
|
||||
github.com/florianl/go-tc v0.4.8
|
||||
github.com/frostbyte73/core v0.1.1
|
||||
github.com/gammazero/deque v1.2.1
|
||||
github.com/gammazero/workerpool v1.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/go-version v1.9.0
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/jxskiss/base62 v1.1.0
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731
|
||||
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095
|
||||
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f
|
||||
github.com/livekit/psrpc v0.7.7
|
||||
github.com/mackerelio/go-osstat v0.2.8
|
||||
github.com/magefile/mage v1.17.2
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/olekukonko/tablewriter v1.1.4
|
||||
github.com/ory/dockertest/v4 v4.0.0
|
||||
github.com/pion/datachannel v1.6.2
|
||||
github.com/pion/dtls/v3 v3.1.5
|
||||
github.com/pion/ice/v4 v4.4.0
|
||||
github.com/pion/interceptor v0.1.47
|
||||
github.com/pion/rtcp v1.2.17
|
||||
github.com/pion/rtp v1.10.5
|
||||
github.com/pion/sctp v1.11.1
|
||||
github.com/pion/sdp/v3 v3.0.19
|
||||
github.com/pion/transport/v4 v4.1.0
|
||||
github.com/pion/turn/v5 v5.0.13
|
||||
github.com/pion/webrtc/v4 v4.2.18
|
||||
github.com/pires/go-proxyproto v0.15.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/thoas/go-funk v0.9.3
|
||||
github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible
|
||||
github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c
|
||||
github.com/urfave/negroni/v3 v3.1.1
|
||||
go.uber.org/atomic v1.11.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/sync v0.22.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/cel-go v0.32.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cilium/ebpf v0.22.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 // indirect
|
||||
github.com/moby/moby/api v1.55.0 // indirect
|
||||
github.com/nyaruka/phonenumbers v1.8.1 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.3.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.8 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 // indirect
|
||||
buf.build/go/protovalidate v1.4.0 // indirect
|
||||
buf.build/go/protoyaml v0.7.0 // indirect
|
||||
cel.dev/expr v0.25.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/lithammer/shortuuid/v4 v4.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/mdlayher/netlink v1.11.2 // indirect
|
||||
github.com/mdlayher/socket v0.6.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.53.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/mdns/v2 v2.2.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.13 // indirect
|
||||
github.com/pion/stun/v3 v3.1.7
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.3 // indirect
|
||||
github.com/prometheus/common v0.71.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/urfave/cli/v3 v3.11.0
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/google/wire/cmd/wire
|
||||
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
|
||||
github.com/maxbrunsfeld/counterfeiter/v6
|
||||
golang.org/x/tools/cmd/goimports
|
||||
)
|
||||
|
||||
replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1
|
||||
|
||||
replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1
|
||||
|
||||
replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.2
|
||||
445
go.sum
Normal file
445
go.sum
Normal file
@@ -0,0 +1,445 @@
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 h1:Slv0uGxx219srASyiaI5C9cDlyG8kNDcXpTSYcuAeE4=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
|
||||
buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM=
|
||||
buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
|
||||
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
|
||||
buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
|
||||
cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
|
||||
cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
|
||||
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
|
||||
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
|
||||
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cilium/ebpf v0.5.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
|
||||
github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
|
||||
github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk=
|
||||
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
|
||||
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/d5/tengo/v2 v2.17.0 h1:BWUN9NoJzw48jZKiYDXDIF3QrIVZRm1uV1gTzeZ2lqM=
|
||||
github.com/d5/tengo/v2 v2.17.0/go.mod h1:XRGjEs5I9jYIKTxly6HCF8oiiilk5E/RYXOZ5b0DZC8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
|
||||
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.1 h1:eV7lfZ5fVL8d36b8Wogqi/eqm7R/kZcftA9Yiyj+63M=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.1/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/florianl/go-tc v0.4.8 h1:hgmakUX1Nm0Ba1I0ZkbUl9CH6HbRwqSiwipnpmYp3Es=
|
||||
github.com/florianl/go-tc v0.4.8/go.mod h1:B8GeOEnmrbOnxZtaCvsYJcgIzzmM8c/AIhtfCZsDj3Q=
|
||||
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
|
||||
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
|
||||
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
|
||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||
github.com/gammazero/workerpool v1.2.1 h1:MEDvUJsNYGuCvl1RwIXNKu2YtQtHqCSF9XWF04N7lqs=
|
||||
github.com/gammazero/workerpool v1.2.1/go.mod h1:E32GVRUanF4d6QtRmdss3AScgaDkIyrvPtgRQUWgmx4=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
|
||||
github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20201216134343-bde56ed16391/go.mod h1:cR77jAZG3Y3bsb8hF6fHJbFoyFukLFOkQ98S0pQz3xw=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20201220180245-69540ac93943/go.mod h1:z4c53zj6Eex712ROyh8WI0ihysb5j2ROyV42iNogmAs=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20210122163228-8d122574c736/go.mod h1:ZXpIyOK59ZnN7J0BV99cZUPmsqDRZ3eq5X+st7u/oSA=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20210212075122-66c871082f2b/go.mod h1:8w9Rh8m+aHZIG69YPGGem1i5VzoyRC8nw2kA8B+ik5U=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20210525051524-4cc836578190/go.mod h1:NmKSdU4VGSiv1bMsdqNALI4RSvvjtz65tTMCnD05qLo=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20211022192332-93da33804786 h1:N527AHMa793TP5z5GNAn/VLPzlc0ewzWdeP/25gDfgQ=
|
||||
github.com/jsimonetti/rtnetlink v0.0.0-20211022192332-93da33804786/go.mod h1:v4hqbTdfQngbVSZJVWUhGE/lbTFf9jb+ygmNUDQMuOs=
|
||||
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2AvpXGdWA=
|
||||
github.com/lithammer/shortuuid/v4 v4.3.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
github.com/livekit/dtls/v3 v3.1.5-warp.1 h1:5jo2qQreDnUrgw6Al1F+9xVyXHvWQ2IaQkjF53FHEyU=
|
||||
github.com/livekit/dtls/v3 v3.1.5-warp.1/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/livekit/ice/v4 v4.4.0-warp.2 h1:AYfwxksKNs2LGgLIifsrB/K4ixaQDuURxBgrA5dJBo4=
|
||||
github.com/livekit/ice/v4 v4.4.0-warp.2/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
|
||||
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY=
|
||||
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
|
||||
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc=
|
||||
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs=
|
||||
github.com/livekit/psrpc v0.7.7 h1:eZ/jYlayQ3Y+C/3+NPtwTJBXZo/aN4vJf2MmBK6AfT4=
|
||||
github.com/livekit/psrpc v0.7.7/go.mod h1:Twno03W8gpTNRpmc2cQwSoxYEo/nJxf+65iMq+9IuHo=
|
||||
github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s=
|
||||
github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc=
|
||||
github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4=
|
||||
github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k=
|
||||
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 h1:V23nK2R2B63g2GhygF9zVGpnigmhvoZoH8d0hrZwMGY=
|
||||
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2/go.mod h1:Mr897yU9FmyKaQDPtRlVKibrjz40XXyOHUfyZBPSyZU=
|
||||
github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43/go.mod h1:+t7E0lkKfbBsebllff1xdTmyJt8lH37niI6kwFk9OTo=
|
||||
github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc=
|
||||
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
|
||||
github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
|
||||
github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY=
|
||||
github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o=
|
||||
github.com/mdlayher/netlink v1.2.0/go.mod h1:kwVW1io0AZy9A1E2YYgaD4Cj+C+GPkU6klXCMzIJ9p8=
|
||||
github.com/mdlayher/netlink v1.2.1/go.mod h1:bacnNlfhqHqqLo4WsYeXSqfyXkInQ9JneWI68v1KwSU=
|
||||
github.com/mdlayher/netlink v1.2.2-0.20210123213345-5cc92139ae3e/go.mod h1:bacnNlfhqHqqLo4WsYeXSqfyXkInQ9JneWI68v1KwSU=
|
||||
github.com/mdlayher/netlink v1.3.0/go.mod h1:xK/BssKuwcRXHrtN04UBkwQ6dY9VviGGuriDdoPSWys=
|
||||
github.com/mdlayher/netlink v1.4.0/go.mod h1:dRJi5IABcZpBD2A3D0Mv/AiX8I9uDEu5oGkAVrekmf8=
|
||||
github.com/mdlayher/netlink v1.4.1/go.mod h1:e4/KuJ+s8UhfUpO9z00/fDZZmhSrs+oxyqAS9cNgn6Q=
|
||||
github.com/mdlayher/netlink v1.6.2/go.mod h1:O1HXX2sIWSMJ3Qn1BYZk1yZM+7iMki/uYGGiwGyq/iU=
|
||||
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
|
||||
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
|
||||
github.com/mdlayher/socket v0.0.0-20210307095302-262dc9984e00/go.mod h1:GAFlyu4/XV68LkQKYzKhIo/WW7j3Zi0YRAz/BOoanUc=
|
||||
github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY=
|
||||
github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A=
|
||||
github.com/mdlayher/socket v0.6.1/go.mod h1:+/SGtqc9V+5dAuRgQsU0fGBI+oRDiW7O2Obx10OIWfg=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo=
|
||||
github.com/nats-io/nats.go v1.53.1/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU=
|
||||
github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
|
||||
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
|
||||
github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
|
||||
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E=
|
||||
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||
github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
|
||||
github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.2.0 h1:AlAZ9MTUKtWgO+4itk35JdNak4sk5k7G/X4xnIBWHyA=
|
||||
github.com/pion/mdns/v2 v2.2.0/go.mod h1:IJddx58QMlojqhQYjHcOUmvuBQ5MnLNetkb80VMvk2Y=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
|
||||
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
|
||||
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
|
||||
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
|
||||
github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
|
||||
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
|
||||
github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q=
|
||||
github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY=
|
||||
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
|
||||
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
|
||||
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4=
|
||||
github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
|
||||
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
|
||||
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
|
||||
github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw=
|
||||
github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
|
||||
github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA=
|
||||
github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e/go.mod h1:krvJ5AY/MjdPkTeRgMYbIDhbbbVvnPQPzsIsDJO8xrY=
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
||||
github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c h1:XbG4n3OWA1PcRTpbBA22E2ChPLvJCuwYRXO12tIyVL0=
|
||||
github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c/go.mod h1:gwANdYmo9R8LLwGnyDFWK2PMsaXXX2HhAvCnb/UhZsM=
|
||||
github.com/urfave/cli/v3 v3.11.0 h1:P/euJp99kb9p0tlVY+iYTLYYTAQlfl0hR2gUO1Img1Q=
|
||||
github.com/urfave/cli/v3 v3.11.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw=
|
||||
github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201118182958-a01c418693c7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210216163648-f7da38b97c65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
|
||||
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
101
install-livekit.sh
Executable file
101
install-livekit.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2023 LiveKit, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# LiveKit install script for Linux
|
||||
|
||||
set -u
|
||||
set -o errtrace
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
REPO="livekit"
|
||||
INSTALL_PATH="${INSTALL_PATH:-/usr/local/bin}"
|
||||
|
||||
log() { printf "%b\n" "$*"; }
|
||||
abort() {
|
||||
printf "%s\n" "$@" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# returns the latest version according to GH
|
||||
# i.e. 1.0.0
|
||||
get_latest_version()
|
||||
{
|
||||
latest_version=$(curl -s https://api.github.com/repos/livekit/$REPO/releases/latest | grep -oP '"tarball_url": ".*/tarball/v\K([^/]*)(?=")')
|
||||
printf "%s" "$latest_version"
|
||||
}
|
||||
|
||||
# Ensure bash is used
|
||||
if [ -z "${BASH_VERSION:-}" ]
|
||||
then
|
||||
abort "This script requires bash"
|
||||
fi
|
||||
|
||||
# Check if $INSTALL_PATH exists
|
||||
if [ ! -d ${INSTALL_PATH} ]
|
||||
then
|
||||
abort "Could not install, ${INSTALL_PATH} doesn't exist"
|
||||
fi
|
||||
|
||||
# Needs SUDO if no permissions to write
|
||||
SUDO_PREFIX=""
|
||||
if [ ! -w ${INSTALL_PATH} ]
|
||||
then
|
||||
SUDO_PREFIX="sudo"
|
||||
log "sudo is required to install to ${INSTALL_PATH}"
|
||||
fi
|
||||
|
||||
# Check cURL is installed
|
||||
if ! command -v curl >/dev/null
|
||||
then
|
||||
abort "cURL is required and is not found"
|
||||
fi
|
||||
|
||||
# OS check
|
||||
OS="$(uname)"
|
||||
if [[ "${OS}" == "Darwin" ]]
|
||||
then
|
||||
abort "Installer not supported on MacOS, please install using Homebrew."
|
||||
elif [[ "${OS}" != "Linux" ]]
|
||||
then
|
||||
abort "Installer is only supported on Linux."
|
||||
fi
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
# fix arch on linux
|
||||
if [[ "${ARCH}" == "aarch64" ]]
|
||||
then
|
||||
ARCH="arm64"
|
||||
elif [[ "${ARCH}" == "x86_64" ]]
|
||||
then
|
||||
ARCH="amd64"
|
||||
fi
|
||||
|
||||
VERSION=$(get_latest_version)
|
||||
ARCHIVE_URL="https://github.com/livekit/$REPO/releases/download/v${VERSION}/${REPO}_${VERSION}_linux_${ARCH}.tar.gz"
|
||||
|
||||
# Ensure version follows SemVer
|
||||
if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
then
|
||||
abort "Invalid version: ${VERSION}"
|
||||
fi
|
||||
|
||||
log "Installing ${REPO} ${VERSION}"
|
||||
log "Downloading from ${ARCHIVE_URL}..."
|
||||
|
||||
curl -s -L "${ARCHIVE_URL}" | ${SUDO_PREFIX} tar xzf - -C "${INSTALL_PATH}" --wildcards --no-anchored "$REPO*"
|
||||
|
||||
log "\nlivekit-server is installed to $INSTALL_PATH\n"
|
||||
221
magefile.go
Normal file
221
magefile.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build mage
|
||||
// +build mage
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/magefile/mage/mg"
|
||||
|
||||
"github.com/livekit/livekit-server/version"
|
||||
"github.com/livekit/mageutil"
|
||||
_ "github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
goChecksumFile = ".checksumgo"
|
||||
imageName = "livekit/livekit-server"
|
||||
)
|
||||
|
||||
// Default target to run when none is specified
|
||||
// If not set, running mage will list available targets
|
||||
var (
|
||||
Default = Build
|
||||
checksummer = mageutil.NewChecksummer(".", goChecksumFile, ".go", ".mod")
|
||||
)
|
||||
|
||||
func init() {
|
||||
checksummer.IgnoredPaths = []string{
|
||||
"pkg/service/wire_gen.go",
|
||||
"pkg/rtc/types/typesfakes",
|
||||
}
|
||||
}
|
||||
|
||||
// downloads module deps at the versions pinned in go.mod
|
||||
//
|
||||
// Code generators are not installed here: they run as `go run <pkg>` from their
|
||||
// //go:generate directives (see pkg/service/wire_gen.go and the counterfeiter
|
||||
// directives under pkg/), so they always execute at the version go.mod pins and
|
||||
// Renovate keeps them current alongside every other module.
|
||||
func Deps() error {
|
||||
return mageutil.Run(context.Background(), "go mod download")
|
||||
}
|
||||
|
||||
// builds LiveKit server
|
||||
func Build() error {
|
||||
mg.Deps(generateWire)
|
||||
if !checksummer.IsChanged() {
|
||||
fmt.Println("up to date")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("building...")
|
||||
if err := os.MkdirAll("bin", 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.RunDir(context.Background(), "cmd/server", "go build -o ../../bin/livekit-server"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checksummer.WriteChecksum()
|
||||
return nil
|
||||
}
|
||||
|
||||
// builds binary that runs on linux
|
||||
func BuildLinux() error {
|
||||
mg.Deps(generateWire)
|
||||
if !checksummer.IsChanged() {
|
||||
fmt.Println("up to date")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("building...")
|
||||
if err := os.MkdirAll("bin", 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
buildArch := os.Getenv("GOARCH")
|
||||
if len(buildArch) == 0 {
|
||||
buildArch = "amd64"
|
||||
}
|
||||
cmd := mageutil.CommandDir(context.Background(), "cmd/server", "go build -buildvcs=false -o ../../bin/livekit-server-" + buildArch)
|
||||
cmd.Env = []string{
|
||||
"GOOS=linux",
|
||||
"GOARCH=" + buildArch,
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
"GOPATH=" + os.Getenv("GOPATH"),
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checksummer.WriteChecksum()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Deadlock() error {
|
||||
ctx := context.Background()
|
||||
if err := mageutil.Run(ctx, "go get github.com/sasha-s/go-deadlock"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Pipe("grep -rl sync.Mutex ./pkg", "xargs sed -i -e s/sync.Mutex/deadlock.Mutex/g"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Pipe("grep -rl sync.RWMutex ./pkg", "xargs sed -i -e s/sync.RWMutex/deadlock.RWMutex/g"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Pipe("grep -rl deadlock.Mutex\\|deadlock.RWMutex ./pkg", "xargs go tool goimports -w"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Run(ctx, "go mod tidy"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Sync() error {
|
||||
if err := mageutil.Pipe("grep -rl deadlock.Mutex ./pkg", "xargs sed -i -e s/deadlock.Mutex/sync.Mutex/g"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Pipe("grep -rl deadlock.RWMutex ./pkg", "xargs sed -i -e s/deadlock.RWMutex/sync.RWMutex/g"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Pipe("grep -rl sync.Mutex\\|sync.RWMutex ./pkg", "xargs go tool goimports -w"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mageutil.Run(context.Background(), "go mod tidy"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// builds and publish snapshot docker image
|
||||
func PublishDocker() error {
|
||||
// don't publish snapshot versions as latest or minor version
|
||||
if !strings.Contains(version.Version, "SNAPSHOT") {
|
||||
return errors.New("Cannot publish non-snapshot versions")
|
||||
}
|
||||
|
||||
versionImg := fmt.Sprintf("%s:v%s", imageName, version.Version)
|
||||
cmd := exec.Command("docker", "buildx", "build",
|
||||
"--push", "--platform", "linux/amd64,linux/arm64",
|
||||
"--tag", versionImg,
|
||||
".")
|
||||
mageutil.ConnectStd(cmd)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// run unit tests, skipping integration
|
||||
func Test() error {
|
||||
mg.Deps(generateWire, setULimit)
|
||||
return mageutil.Run(context.Background(), "go test -short ./... -count=1")
|
||||
}
|
||||
|
||||
// run all tests including integration
|
||||
func TestAll() error {
|
||||
mg.Deps(generateWire, setULimit)
|
||||
return mageutil.Run(context.Background(), "go test ./... -count=1 -timeout=4m -v")
|
||||
}
|
||||
|
||||
// runs the SDK test server (cmd/test-server) in the foreground
|
||||
func TestServer() error {
|
||||
return mageutil.Run(context.Background(), "go run ./cmd/test-server")
|
||||
}
|
||||
|
||||
// runs golangci-lint
|
||||
func Lint() error {
|
||||
if _, err := exec.LookPath("golangci-lint"); err != nil {
|
||||
return errors.New("golangci-lint is not installed, install instructions: https://golangci-lint.run/docs/welcome/install/")
|
||||
}
|
||||
return mageutil.Run(context.Background(), "golangci-lint run ./...")
|
||||
}
|
||||
|
||||
// cleans up builds
|
||||
func Clean() {
|
||||
fmt.Println("cleaning...")
|
||||
os.RemoveAll("bin")
|
||||
os.Remove(goChecksumFile)
|
||||
}
|
||||
|
||||
// regenerate code
|
||||
func Generate() error {
|
||||
mg.Deps(generateWire)
|
||||
|
||||
fmt.Println("generating...")
|
||||
return mageutil.Run(context.Background(), "go generate ./...")
|
||||
}
|
||||
|
||||
// code generation for wiring
|
||||
func generateWire() error {
|
||||
if !checksummer.IsChanged() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("wiring...")
|
||||
|
||||
// Matches the //go:generate directive in pkg/service/wire_gen.go, so running
|
||||
// wire here and running `go generate ./...` produce the same output.
|
||||
return mageutil.RunDir(context.Background(), "pkg/service", "go run github.com/google/wire/cmd/wire")
|
||||
}
|
||||
34
magefile_unix.go
Normal file
34
magefile_unix.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build mage && !windows
|
||||
// +build mage,!windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func setULimit() error {
|
||||
// raise ulimit on unix
|
||||
var rLimit syscall.Rlimit
|
||||
err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rLimit.Max = 10000
|
||||
rLimit.Cur = 10000
|
||||
return syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
|
||||
}
|
||||
22
magefile_windows.go
Normal file
22
magefile_windows.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build mage
|
||||
// +build mage
|
||||
|
||||
package main
|
||||
|
||||
func setULimit() error {
|
||||
return nil
|
||||
}
|
||||
258
pkg/agent/agent_test.go
Normal file
258
pkg/agent/agent_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/agent"
|
||||
"github.com/livekit/livekit-server/pkg/agent/testutils"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
func TestAgent(t *testing.T) {
|
||||
testAgentName := "test_agent"
|
||||
t.Run("dispatched jobs are assigned to a worker", func(t *testing.T) {
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
|
||||
client := must.Get(rpc.NewAgentInternalClient(bus))
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
worker := server.SimulateAgentWorker()
|
||||
worker.Register(testAgentName, livekit.JobType_JT_ROOM)
|
||||
jobAssignments := worker.JobAssignments.Observe()
|
||||
|
||||
job := &livekit.Job{
|
||||
Id: guid.New(guid.AgentJobPrefix),
|
||||
DispatchId: guid.New(guid.AgentDispatchPrefix),
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Room: &livekit.Room{},
|
||||
AgentName: testAgentName,
|
||||
}
|
||||
_, err := client.JobRequest(context.Background(), testAgentName, agent.RoomAgentTopic, job)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case a := <-jobAssignments.Events():
|
||||
require.EqualValues(t, job.Id, a.Job.Id)
|
||||
v, err := auth.ParseAPIToken(a.Token)
|
||||
require.NoError(t, err)
|
||||
_, claims, err := v.Verify(server.TestAPISecret)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testAgentName, claims.Attributes[agent.AgentNameAttributeKey])
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "job assignment timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testBatchJobRequest(t require.TestingT, batchSize int, totalJobs int, client rpc.AgentInternalClient, workers []*testutils.AgentWorker) <-chan struct{} {
|
||||
var assigned atomic.Uint32
|
||||
done := make(chan struct{})
|
||||
for _, w := range workers {
|
||||
assignments := w.JobAssignments.Observe()
|
||||
go func() {
|
||||
defer assignments.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
case <-assignments.Events():
|
||||
if assigned.Inc() == uint32(totalJobs) {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// wait for agent registration
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < totalJobs; i += batchSize {
|
||||
wg.Add(1)
|
||||
go func(start int) {
|
||||
defer wg.Done()
|
||||
for j := start; j < start+batchSize && j < totalJobs; j++ {
|
||||
job := &livekit.Job{
|
||||
Id: guid.New(guid.AgentJobPrefix),
|
||||
DispatchId: guid.New(guid.AgentDispatchPrefix),
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Room: &livekit.Room{},
|
||||
AgentName: "test",
|
||||
}
|
||||
_, err := client.JobRequest(context.Background(), "test", agent.RoomAgentTopic, job)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
func TestAgentLoadBalancing(t *testing.T) {
|
||||
t.Run("jobs are distributed normally with baseline worker load", func(t *testing.T) {
|
||||
totalWorkers := 5
|
||||
totalJobs := 100
|
||||
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
|
||||
client := must.Get(rpc.NewAgentInternalClient(bus))
|
||||
t.Cleanup(client.Close)
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
agents := make([]*testutils.AgentWorker, totalWorkers)
|
||||
for i := range totalWorkers {
|
||||
agents[i] = server.SimulateAgentWorker(
|
||||
testutils.WithLabel(fmt.Sprintf("agent-%d", i)),
|
||||
testutils.WithJobLoad(testutils.NewStableJobLoad(0.01)),
|
||||
)
|
||||
agents[i].Register("test", livekit.JobType_JT_ROOM)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testBatchJobRequest(t, 10, totalJobs, client, agents):
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "job assignment timeout")
|
||||
}
|
||||
|
||||
jobCount := make(map[string]int)
|
||||
for _, w := range agents {
|
||||
jobCount[w.Label] = len(w.Jobs())
|
||||
}
|
||||
|
||||
// check that jobs are distributed normally
|
||||
for i := range totalWorkers {
|
||||
label := fmt.Sprintf("agent-%d", i)
|
||||
require.GreaterOrEqual(t, jobCount[label], 0)
|
||||
require.Less(t, jobCount[label], 35) // three std deviations from the mean is 32
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jobs are distributed with variable and overloaded worker load", func(t *testing.T) {
|
||||
totalWorkers := 4
|
||||
totalJobs := 15
|
||||
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
|
||||
client := must.Get(rpc.NewAgentInternalClient(bus))
|
||||
t.Cleanup(client.Close)
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
agents := make([]*testutils.AgentWorker, totalWorkers)
|
||||
for i := range totalWorkers {
|
||||
label := fmt.Sprintf("agent-%d", i)
|
||||
if i%2 == 0 {
|
||||
// make sure we have some workers that can accept jobs
|
||||
agents[i] = server.SimulateAgentWorker(testutils.WithLabel(label))
|
||||
} else {
|
||||
agents[i] = server.SimulateAgentWorker(testutils.WithLabel(label), testutils.WithDefaultWorkerLoad(0.9))
|
||||
}
|
||||
agents[i].Register("test", livekit.JobType_JT_ROOM)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testBatchJobRequest(t, 1, totalJobs, client, agents):
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "job assignment timeout")
|
||||
}
|
||||
|
||||
jobCount := make(map[string]int)
|
||||
for _, w := range agents {
|
||||
jobCount[w.Label] = len(w.Jobs())
|
||||
}
|
||||
|
||||
for i := range totalWorkers {
|
||||
label := fmt.Sprintf("agent-%d", i)
|
||||
|
||||
if i%2 == 0 {
|
||||
require.GreaterOrEqual(t, jobCount[label], 2)
|
||||
} else {
|
||||
require.Equal(t, 0, jobCount[label])
|
||||
}
|
||||
require.GreaterOrEqual(t, jobCount[label], 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectionClosedOnDispatchError(t *testing.T) {
|
||||
t.Run("connection closed when unknown message type received", func(t *testing.T) {
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// register agent
|
||||
worker := server.SimulateAgentWorker()
|
||||
worker.Register("test_agent", livekit.JobType_JT_ROOM)
|
||||
responses := worker.RegisterWorkerResponses.Observe()
|
||||
select {
|
||||
case <-responses.Events():
|
||||
// registered
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "registration timeout")
|
||||
}
|
||||
responses.Stop()
|
||||
|
||||
// send invalid message (nil Message field triggers ErrUnknownWorkerSignal)
|
||||
worker.SendMessage(&livekit.WorkerMessage{Message: nil})
|
||||
|
||||
select {
|
||||
case <-worker.Closed():
|
||||
// connection closed
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "connection should have been closed after dispatch error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrainConnectionsDoesNotDeadlock(t *testing.T) {
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("force=%v", force), func(t *testing.T) {
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
worker := server.SimulateAgentWorker()
|
||||
worker.Register("drain_agent", livekit.JobType_JT_ROOM)
|
||||
responses := worker.RegisterWorkerResponses.Observe()
|
||||
select {
|
||||
case <-responses.Events():
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "registration timeout")
|
||||
}
|
||||
responses.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
server.DrainConnections(time.Millisecond, force)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
require.Fail(t, "DrainConnections deadlocked while closing workers")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-worker.Closed():
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "worker should be closed after drain")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
349
pkg/agent/client.go
Normal file
349
pkg/agent/client.go
Normal file
@@ -0,0 +1,349 @@
|
||||
// Copyright 2024 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gammazero/workerpool"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
serverutils "github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
EnabledCacheTTL = 1 * time.Minute
|
||||
RoomAgentTopic = "room"
|
||||
PublisherAgentTopic = "publisher"
|
||||
ParticipantAgentTopic = "participant"
|
||||
DefaultHandlerNamespace = ""
|
||||
|
||||
CheckEnabledTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var jobTypeTopics = map[livekit.JobType]string{
|
||||
livekit.JobType_JT_ROOM: RoomAgentTopic,
|
||||
livekit.JobType_JT_PUBLISHER: PublisherAgentTopic,
|
||||
livekit.JobType_JT_PARTICIPANT: ParticipantAgentTopic,
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
// LaunchJob starts a room or participant job on an agent.
|
||||
// it will launch a job once for each worker in each namespace
|
||||
LaunchJob(ctx context.Context, desc *JobRequest) *serverutils.IncrementalDispatcher[*livekit.Job]
|
||||
TerminateJob(ctx context.Context, jobID string, reason rpc.JobTerminateReason) (*livekit.JobState, error)
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type JobRequest struct {
|
||||
DispatchId string
|
||||
JobType livekit.JobType
|
||||
Room *livekit.Room
|
||||
// only set for participant jobs
|
||||
Participant *livekit.ParticipantInfo
|
||||
Metadata string
|
||||
AgentName string
|
||||
Deployment string
|
||||
Attributes map[string]string
|
||||
}
|
||||
|
||||
type agentClient struct {
|
||||
client rpc.AgentInternalClient
|
||||
config Config
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
// cache response to avoid constantly checking with controllers
|
||||
// cache is invalidated with AgentRegistered updates
|
||||
roomNamespaces *serverutils.IncrementalDispatcher[string] // deprecated
|
||||
publisherNamespaces *serverutils.IncrementalDispatcher[string] // deprecated
|
||||
participantNamespaces *serverutils.IncrementalDispatcher[string] // deprecated
|
||||
roomAgentNames *serverutils.IncrementalDispatcher[string]
|
||||
publisherAgentNames *serverutils.IncrementalDispatcher[string]
|
||||
participantAgentNames *serverutils.IncrementalDispatcher[string]
|
||||
|
||||
enabledExpiresAt time.Time
|
||||
|
||||
workers *workerpool.WorkerPool
|
||||
|
||||
invalidateSub psrpc.Subscription[*emptypb.Empty]
|
||||
subDone chan struct{}
|
||||
}
|
||||
|
||||
func NewAgentClient(bus psrpc.MessageBus, config Config) (Client, error) {
|
||||
client, err := rpc.NewAgentInternalClient(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &agentClient{
|
||||
client: client,
|
||||
config: config,
|
||||
workers: workerpool.New(50),
|
||||
subDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
sub, err := c.client.SubscribeWorkerRegistered(context.Background(), DefaultHandlerNamespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.invalidateSub = sub
|
||||
|
||||
go func() {
|
||||
// invalidate cache
|
||||
for range sub.Channel() {
|
||||
c.mu.Lock()
|
||||
c.roomNamespaces = nil
|
||||
c.publisherNamespaces = nil
|
||||
c.participantNamespaces = nil
|
||||
c.roomAgentNames = nil
|
||||
c.publisherAgentNames = nil
|
||||
c.participantAgentNames = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
c.subDone <- struct{}{}
|
||||
}()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *agentClient) LaunchJob(ctx context.Context, desc *JobRequest) *serverutils.IncrementalDispatcher[*livekit.Job] {
|
||||
var wg sync.WaitGroup
|
||||
ret := serverutils.NewIncrementalDispatcher[*livekit.Job]()
|
||||
defer func() {
|
||||
c.workers.Submit(func() {
|
||||
wg.Wait()
|
||||
ret.Done()
|
||||
})
|
||||
}()
|
||||
|
||||
jobTypeTopic, ok := jobTypeTopics[desc.JobType]
|
||||
if !ok {
|
||||
return ret
|
||||
}
|
||||
|
||||
dispatcher := c.getDispatcher(desc.AgentName, desc.JobType)
|
||||
|
||||
if dispatcher == nil {
|
||||
logger.Infow("not dispatching agent job since no worker is available",
|
||||
"agentName", desc.AgentName,
|
||||
"jobType", desc.JobType,
|
||||
"room", desc.Room.Name,
|
||||
"roomID", desc.Room.Sid)
|
||||
return ret
|
||||
}
|
||||
|
||||
dispatcher.ForEach(func(curNs string) {
|
||||
topic := GetAgentTopic(desc.AgentName, curNs, desc.Deployment)
|
||||
|
||||
wg.Add(1)
|
||||
c.workers.Submit(func() {
|
||||
defer wg.Done()
|
||||
// The cached agent parameters do not provide the exact combination of available job type/agent name/namespace, so some of the JobRequest RPC may not trigger any worker
|
||||
job := &livekit.Job{
|
||||
Id: utils.NewGuid(utils.AgentJobPrefix),
|
||||
DispatchId: desc.DispatchId,
|
||||
Type: desc.JobType,
|
||||
Room: desc.Room,
|
||||
Participant: desc.Participant,
|
||||
Namespace: curNs,
|
||||
AgentName: desc.AgentName,
|
||||
Metadata: desc.Metadata,
|
||||
EnableRecording: c.config.EnableUserDataRecording,
|
||||
EnableRedaction: c.config.EnableUserDataRedaction,
|
||||
Deployment: desc.Deployment,
|
||||
Attributes: desc.Attributes,
|
||||
}
|
||||
resp, err := c.client.JobRequest(context.Background(), topic, jobTypeTopic, job)
|
||||
if err != nil {
|
||||
logger.Infow("failed to send job request", "error", err, "namespace", curNs, "jobType", desc.JobType, "agentName", desc.AgentName)
|
||||
return
|
||||
}
|
||||
job.State = resp.State
|
||||
ret.Add(job)
|
||||
})
|
||||
})
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *agentClient) TerminateJob(ctx context.Context, jobID string, reason rpc.JobTerminateReason) (*livekit.JobState, error) {
|
||||
resp, err := c.client.JobTerminate(context.Background(), jobID, &rpc.JobTerminateRequest{
|
||||
JobId: jobID,
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Infow("failed to send job request", "error", err, "jobID", jobID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp.State, nil
|
||||
}
|
||||
|
||||
func (c *agentClient) getDispatcher(agName string, jobType livekit.JobType) *serverutils.IncrementalDispatcher[string] {
|
||||
c.mu.Lock()
|
||||
|
||||
if time.Since(c.enabledExpiresAt) > EnabledCacheTTL || c.roomNamespaces == nil ||
|
||||
c.publisherNamespaces == nil || c.participantNamespaces == nil || c.roomAgentNames == nil || c.publisherAgentNames == nil || c.participantAgentNames == nil {
|
||||
c.enabledExpiresAt = time.Now()
|
||||
c.roomNamespaces = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.publisherNamespaces = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.participantNamespaces = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.roomAgentNames = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.publisherAgentNames = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.participantAgentNames = serverutils.NewIncrementalDispatcher[string]()
|
||||
|
||||
go c.checkEnabled(c.roomNamespaces, c.publisherNamespaces, c.participantNamespaces, c.roomAgentNames, c.publisherAgentNames, c.participantAgentNames)
|
||||
}
|
||||
|
||||
var target *serverutils.IncrementalDispatcher[string]
|
||||
var agentNames *serverutils.IncrementalDispatcher[string]
|
||||
switch jobType {
|
||||
case livekit.JobType_JT_ROOM:
|
||||
target = c.roomNamespaces
|
||||
agentNames = c.roomAgentNames
|
||||
case livekit.JobType_JT_PUBLISHER:
|
||||
target = c.publisherNamespaces
|
||||
agentNames = c.publisherAgentNames
|
||||
case livekit.JobType_JT_PARTICIPANT:
|
||||
target = c.participantNamespaces
|
||||
agentNames = c.participantAgentNames
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if agName == "" {
|
||||
// if no agent name is given, we would need to dispatch backwards compatible mode
|
||||
// which means dispatching to each of the namespaces
|
||||
return target
|
||||
}
|
||||
|
||||
done := make(chan *serverutils.IncrementalDispatcher[string], 1)
|
||||
c.workers.Submit(func() {
|
||||
agentNames.ForEach(func(ag string) {
|
||||
if ag == agName {
|
||||
select {
|
||||
case done <- target:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
select {
|
||||
case done <- nil:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
return <-done
|
||||
}
|
||||
|
||||
func (c *agentClient) checkEnabled(roomNamespaces, publisherNamespaces, participantNamespaces, roomAgentNames, publisherAgentNames, participantAgentNames *serverutils.IncrementalDispatcher[string]) {
|
||||
defer roomNamespaces.Done()
|
||||
defer publisherNamespaces.Done()
|
||||
defer participantNamespaces.Done()
|
||||
defer roomAgentNames.Done()
|
||||
defer publisherAgentNames.Done()
|
||||
defer participantAgentNames.Done()
|
||||
|
||||
resChan, err := c.client.CheckEnabled(context.Background(), &rpc.CheckEnabledRequest{}, psrpc.WithRequestTimeout(CheckEnabledTimeout))
|
||||
if err != nil {
|
||||
logger.Errorw("failed to check enabled", err)
|
||||
return
|
||||
}
|
||||
|
||||
roomNSMap := make(map[string]bool)
|
||||
publisherNSMap := make(map[string]bool)
|
||||
participantNSMap := make(map[string]bool)
|
||||
roomAgMap := make(map[string]bool)
|
||||
publisherAgMap := make(map[string]bool)
|
||||
participantAgMap := make(map[string]bool)
|
||||
|
||||
for r := range resChan {
|
||||
if r.Result.GetRoomEnabled() {
|
||||
for _, ns := range r.Result.GetNamespaces() {
|
||||
if _, ok := roomNSMap[ns]; !ok {
|
||||
roomNamespaces.Add(ns)
|
||||
roomNSMap[ns] = true
|
||||
}
|
||||
}
|
||||
for _, ag := range r.Result.GetAgentNames() {
|
||||
if _, ok := roomAgMap[ag]; !ok {
|
||||
roomAgentNames.Add(ag)
|
||||
roomAgMap[ag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Result.GetPublisherEnabled() {
|
||||
for _, ns := range r.Result.GetNamespaces() {
|
||||
if _, ok := publisherNSMap[ns]; !ok {
|
||||
publisherNamespaces.Add(ns)
|
||||
publisherNSMap[ns] = true
|
||||
}
|
||||
}
|
||||
for _, ag := range r.Result.GetAgentNames() {
|
||||
if _, ok := publisherAgMap[ag]; !ok {
|
||||
publisherAgentNames.Add(ag)
|
||||
publisherAgMap[ag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Result.GetParticipantEnabled() {
|
||||
for _, ns := range r.Result.GetNamespaces() {
|
||||
if _, ok := participantNSMap[ns]; !ok {
|
||||
participantNamespaces.Add(ns)
|
||||
participantNSMap[ns] = true
|
||||
}
|
||||
}
|
||||
for _, ag := range r.Result.GetAgentNames() {
|
||||
if _, ok := participantAgMap[ag]; !ok {
|
||||
participantAgentNames.Add(ag)
|
||||
participantAgMap[ag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentClient) Stop() error {
|
||||
_ = c.invalidateSub.Close()
|
||||
<-c.subDone
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAgentTopic(agentName, namespace, deployment string) string {
|
||||
var topic string
|
||||
if agentName == "" {
|
||||
// Backward compatibility
|
||||
topic = namespace
|
||||
} else if namespace == "" {
|
||||
// Forward compatibility once the namespace field is removed from the worker SDK
|
||||
topic = agentName
|
||||
} else {
|
||||
topic = fmt.Sprintf("%s_%s", agentName, namespace)
|
||||
}
|
||||
if deployment != "" {
|
||||
topic += "_" + deployment
|
||||
}
|
||||
return topic
|
||||
}
|
||||
9
pkg/agent/config.go
Normal file
9
pkg/agent/config.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package agent
|
||||
|
||||
const DefaultTargetLoad = 0.7
|
||||
|
||||
type Config struct {
|
||||
EnableUserDataRecording bool `yaml:"enable_user_data_recording"`
|
||||
EnableUserDataRedaction bool `yaml:"enable_user_data_redaction"`
|
||||
TargetLoad float32 `yaml:"target_load,omitempty"`
|
||||
}
|
||||
500
pkg/agent/testutils/server.go
Normal file
500
pkg/agent/testutils/server.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"github.com/gammazero/deque"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/agent"
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/service"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/events"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
"github.com/livekit/protocol/utils/options"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
type AgentService interface {
|
||||
HandleConnection(context.Context, agent.SignalConn, agent.WorkerRegistration)
|
||||
DrainConnections(time.Duration, bool)
|
||||
}
|
||||
|
||||
type TestServer struct {
|
||||
AgentService
|
||||
TestAPIKey string
|
||||
TestAPISecret string
|
||||
}
|
||||
|
||||
func NewTestServer(bus psrpc.MessageBus) *TestServer {
|
||||
localNode, _ := routing.NewLocalNode(nil)
|
||||
return NewTestServerWithService(must.Get(service.NewAgentService(
|
||||
&config.Config{
|
||||
Region: "test",
|
||||
Agents: agent.Config{
|
||||
TargetLoad: agent.DefaultTargetLoad,
|
||||
},
|
||||
},
|
||||
localNode,
|
||||
bus,
|
||||
auth.NewSimpleKeyProvider("test", "verysecretsecret"),
|
||||
)))
|
||||
}
|
||||
|
||||
func NewTestServerWithService(s AgentService) *TestServer {
|
||||
return &TestServer{s, "test", "verysecretsecret"}
|
||||
}
|
||||
|
||||
type SimulatedWorkerOptions struct {
|
||||
Context context.Context
|
||||
Label string
|
||||
SupportResume bool
|
||||
DefaultJobLoad float32
|
||||
JobLoadThreshold float32
|
||||
DefaultWorkerLoad float32
|
||||
HandleAvailability func(AgentJobRequest)
|
||||
HandleAssignment func(*livekit.Job) JobLoad
|
||||
}
|
||||
|
||||
type SimulatedWorkerOption func(*SimulatedWorkerOptions)
|
||||
|
||||
func WithContext(ctx context.Context) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
func WithLabel(label string) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.Label = label
|
||||
}
|
||||
}
|
||||
|
||||
func WithJobAvailabilityHandler(h func(AgentJobRequest)) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.HandleAvailability = h
|
||||
}
|
||||
}
|
||||
|
||||
func WithJobAssignmentHandler(h func(*livekit.Job) JobLoad) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.HandleAssignment = h
|
||||
}
|
||||
}
|
||||
|
||||
func WithJobLoad(l JobLoad) SimulatedWorkerOption {
|
||||
return WithJobAssignmentHandler(func(j *livekit.Job) JobLoad { return l })
|
||||
}
|
||||
|
||||
func WithDefaultWorkerLoad(load float32) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.DefaultWorkerLoad = load
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TestServer) SimulateAgentWorker(opts ...SimulatedWorkerOption) *AgentWorker {
|
||||
o := &SimulatedWorkerOptions{
|
||||
Context: context.Background(),
|
||||
Label: guid.New("TEST_AGENT_"),
|
||||
DefaultJobLoad: 0.1,
|
||||
JobLoadThreshold: 0.8,
|
||||
DefaultWorkerLoad: 0.0,
|
||||
HandleAvailability: func(r AgentJobRequest) { r.Accept() },
|
||||
HandleAssignment: func(j *livekit.Job) JobLoad { return nil },
|
||||
}
|
||||
options.Apply(o, opts)
|
||||
|
||||
w := &AgentWorker{
|
||||
workerMessages: make(chan *livekit.WorkerMessage, 1),
|
||||
jobs: map[string]*AgentJob{},
|
||||
SimulatedWorkerOptions: o,
|
||||
|
||||
RegisterWorkerResponses: events.NewObserverList[*livekit.RegisterWorkerResponse](),
|
||||
AvailabilityRequests: events.NewObserverList[*livekit.AvailabilityRequest](),
|
||||
JobAssignments: events.NewObserverList[*livekit.JobAssignment](),
|
||||
JobTerminations: events.NewObserverList[*livekit.JobTermination](),
|
||||
WorkerPongs: events.NewObserverList[*livekit.WorkerPong](),
|
||||
}
|
||||
w.ctx, w.cancel = context.WithCancel(context.Background())
|
||||
|
||||
if o.DefaultWorkerLoad > 0.0 {
|
||||
w.sendStatus()
|
||||
}
|
||||
|
||||
ctx := service.WithAPIKey(o.Context, &auth.ClaimGrants{}, "test")
|
||||
go h.HandleConnection(ctx, w, agent.MakeWorkerRegistration())
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (h *TestServer) Close() {
|
||||
h.DrainConnections(1, false)
|
||||
}
|
||||
|
||||
var _ agent.SignalConn = (*AgentWorker)(nil)
|
||||
|
||||
type JobLoad interface {
|
||||
Load() float32
|
||||
}
|
||||
|
||||
type AgentJob struct {
|
||||
*livekit.Job
|
||||
JobLoad
|
||||
}
|
||||
|
||||
type AgentJobRequest struct {
|
||||
w *AgentWorker
|
||||
*livekit.AvailabilityRequest
|
||||
}
|
||||
|
||||
func (r AgentJobRequest) Accept() {
|
||||
identity := guid.New("PI_")
|
||||
r.w.SendAvailability(&livekit.AvailabilityResponse{
|
||||
JobId: r.Job.Id,
|
||||
Available: true,
|
||||
SupportsResume: r.w.SupportResume,
|
||||
ParticipantName: identity,
|
||||
ParticipantIdentity: identity,
|
||||
})
|
||||
}
|
||||
|
||||
func (r AgentJobRequest) Reject() {
|
||||
r.w.SendAvailability(&livekit.AvailabilityResponse{
|
||||
JobId: r.Job.Id,
|
||||
Available: false,
|
||||
})
|
||||
}
|
||||
|
||||
type AgentWorker struct {
|
||||
*SimulatedWorkerOptions
|
||||
|
||||
fuse core.Fuse
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
workerMessages chan *livekit.WorkerMessage
|
||||
serverMessages deque.Deque[*livekit.ServerMessage]
|
||||
jobs map[string]*AgentJob
|
||||
|
||||
RegisterWorkerResponses *events.ObserverList[*livekit.RegisterWorkerResponse]
|
||||
AvailabilityRequests *events.ObserverList[*livekit.AvailabilityRequest]
|
||||
JobAssignments *events.ObserverList[*livekit.JobAssignment]
|
||||
JobTerminations *events.ObserverList[*livekit.JobTermination]
|
||||
WorkerPongs *events.ObserverList[*livekit.WorkerPong]
|
||||
}
|
||||
|
||||
func (w *AgentWorker) statusWorker() {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
for !w.fuse.IsBroken() {
|
||||
w.sendStatus()
|
||||
<-t.C
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.fuse.Break()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *AgentWorker) CloseWithReason(_ string) error {
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// Closed returns a channel that is closed when the connection is closed by the server
|
||||
func (w *AgentWorker) Closed() <-chan struct{} {
|
||||
return w.fuse.Watch()
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SetReadDeadline(t time.Time) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.fuse.IsBroken() {
|
||||
cancel := w.cancel
|
||||
if t.IsZero() {
|
||||
w.ctx, w.cancel = context.WithCancel(context.Background())
|
||||
} else {
|
||||
w.ctx, w.cancel = context.WithDeadline(context.Background(), t)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *AgentWorker) ReadWorkerMessage() (*livekit.WorkerMessage, int, error) {
|
||||
for {
|
||||
w.mu.Lock()
|
||||
ctx := w.ctx
|
||||
w.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-w.fuse.Watch():
|
||||
return nil, 0, io.EOF
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, 0, err
|
||||
}
|
||||
case m := <-w.workerMessages:
|
||||
return m, 0, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) WriteServerMessage(m *livekit.ServerMessage) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.serverMessages.PushBack(m)
|
||||
if w.serverMessages.Len() == 1 {
|
||||
go w.handleServerMessages()
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleServerMessages() {
|
||||
w.mu.Lock()
|
||||
for w.serverMessages.Len() != 0 {
|
||||
m := w.serverMessages.Front()
|
||||
w.mu.Unlock()
|
||||
|
||||
switch m := m.Message.(type) {
|
||||
case *livekit.ServerMessage_Register:
|
||||
w.handleRegister(m.Register)
|
||||
case *livekit.ServerMessage_Availability:
|
||||
w.handleAvailability(m.Availability)
|
||||
case *livekit.ServerMessage_Assignment:
|
||||
w.handleAssignment(m.Assignment)
|
||||
case *livekit.ServerMessage_Termination:
|
||||
w.handleTermination(m.Termination)
|
||||
case *livekit.ServerMessage_Pong:
|
||||
w.handlePong(m.Pong)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.serverMessages.PopFront()
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleRegister(m *livekit.RegisterWorkerResponse) {
|
||||
w.RegisterWorkerResponses.Emit(m)
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleAvailability(m *livekit.AvailabilityRequest) {
|
||||
w.AvailabilityRequests.Emit(m)
|
||||
if w.HandleAvailability != nil {
|
||||
w.HandleAvailability(AgentJobRequest{w, m})
|
||||
} else {
|
||||
AgentJobRequest{w, m}.Accept()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleAssignment(m *livekit.JobAssignment) {
|
||||
w.JobAssignments.Emit(m)
|
||||
|
||||
var load JobLoad
|
||||
if w.HandleAssignment != nil {
|
||||
load = w.HandleAssignment(m.Job)
|
||||
}
|
||||
|
||||
if load == nil {
|
||||
load = NewStableJobLoad(w.DefaultJobLoad)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.jobs[m.Job.Id] = &AgentJob{m.Job, load}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleTermination(m *livekit.JobTermination) {
|
||||
w.JobTerminations.Emit(m)
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
delete(w.jobs, m.JobId)
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handlePong(m *livekit.WorkerPong) {
|
||||
w.WorkerPongs.Emit(m)
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendMessage(m *livekit.WorkerMessage) {
|
||||
select {
|
||||
case <-w.fuse.Watch():
|
||||
case w.workerMessages <- m:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendRegister(m *livekit.RegisterWorkerRequest) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_Register{
|
||||
Register: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendAvailability(m *livekit.AvailabilityResponse) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_Availability{
|
||||
Availability: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendUpdateWorker(m *livekit.UpdateWorkerStatus) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_UpdateWorker{
|
||||
UpdateWorker: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendUpdateJob(m *livekit.UpdateJobStatus) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_UpdateJob{
|
||||
UpdateJob: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendPing(m *livekit.WorkerPing) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_Ping{
|
||||
Ping: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendSimulateJob(m *livekit.SimulateJobRequest) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_SimulateJob{
|
||||
SimulateJob: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendMigrateJob(m *livekit.MigrateJobRequest) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_MigrateJob{
|
||||
MigrateJob: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) sendStatus() {
|
||||
w.mu.Lock()
|
||||
var load float32
|
||||
jobCount := len(w.jobs)
|
||||
|
||||
if len(w.jobs) == 0 {
|
||||
load = w.DefaultWorkerLoad
|
||||
} else {
|
||||
for _, j := range w.jobs {
|
||||
load += j.Load()
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
status := livekit.WorkerStatus_WS_AVAILABLE
|
||||
if load > w.JobLoadThreshold {
|
||||
status = livekit.WorkerStatus_WS_FULL
|
||||
}
|
||||
|
||||
w.SendUpdateWorker(&livekit.UpdateWorkerStatus{
|
||||
Status: &status,
|
||||
Load: load,
|
||||
JobCount: uint32(jobCount),
|
||||
})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) Register(agentName string, jobType livekit.JobType) {
|
||||
w.SendRegister(&livekit.RegisterWorkerRequest{
|
||||
Type: jobType,
|
||||
AgentName: agentName,
|
||||
})
|
||||
go w.statusWorker()
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SimulateRoomJob(roomName string) {
|
||||
w.SendSimulateJob(&livekit.SimulateJobRequest{
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Room: &livekit.Room{
|
||||
Sid: guid.New(guid.RoomPrefix),
|
||||
Name: roomName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) Jobs() []*AgentJob {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return slices.Collect(maps.Values(w.jobs))
|
||||
}
|
||||
|
||||
type stableJobLoad struct {
|
||||
load float32
|
||||
}
|
||||
|
||||
func NewStableJobLoad(load float32) JobLoad {
|
||||
return stableJobLoad{load}
|
||||
}
|
||||
|
||||
func (s stableJobLoad) Load() float32 {
|
||||
return s.load
|
||||
}
|
||||
|
||||
type periodicJobLoad struct {
|
||||
amplitude float64
|
||||
period time.Duration
|
||||
epoch time.Time
|
||||
}
|
||||
|
||||
func NewPeriodicJobLoad(max float32, period time.Duration) JobLoad {
|
||||
return periodicJobLoad{
|
||||
amplitude: float64(max / 2),
|
||||
period: period,
|
||||
epoch: time.Now().Add(-time.Duration(rand.Int64N(int64(period)))),
|
||||
}
|
||||
}
|
||||
|
||||
func (s periodicJobLoad) Load() float32 {
|
||||
a := math.Sin(time.Since(s.epoch).Seconds() / s.period.Seconds() * math.Pi * 2)
|
||||
return float32(s.amplitude + a*s.amplitude)
|
||||
}
|
||||
|
||||
type uniformRandomJobLoad struct {
|
||||
min, max float32
|
||||
rng func() float64
|
||||
}
|
||||
|
||||
func NewUniformRandomJobLoad(min, max float32) JobLoad {
|
||||
return uniformRandomJobLoad{min, max, rand.Float64}
|
||||
}
|
||||
|
||||
func NewUniformRandomJobLoadWithRNG(min, max float32, rng *rand.Rand) JobLoad {
|
||||
return uniformRandomJobLoad{min, max, rng.Float64}
|
||||
}
|
||||
|
||||
func (s uniformRandomJobLoad) Load() float32 {
|
||||
return rand.Float32()*(s.max-s.min) + s.min
|
||||
}
|
||||
|
||||
type normalRandomJobLoad struct {
|
||||
mean, stddev float64
|
||||
rng func() float64
|
||||
}
|
||||
|
||||
func NewNormalRandomJobLoad(mean, stddev float64) JobLoad {
|
||||
return normalRandomJobLoad{mean, stddev, rand.Float64}
|
||||
}
|
||||
|
||||
func NewNormalRandomJobLoadWithRNG(mean, stddev float64, rng *rand.Rand) JobLoad {
|
||||
return normalRandomJobLoad{mean, stddev, rng.Float64}
|
||||
}
|
||||
|
||||
func (s normalRandomJobLoad) Load() float32 {
|
||||
u := 1 - s.rng()
|
||||
v := s.rng()
|
||||
z := math.Sqrt(-2*math.Log(u)) * math.Cos(2*math.Pi*v)
|
||||
return float32(max(0, z*s.stddev+s.mean))
|
||||
}
|
||||
604
pkg/agent/worker.go
Normal file
604
pkg/agent/worker.go
Normal file
@@ -0,0 +1,604 @@
|
||||
// Copyright 2024 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
protoagent "github.com/livekit/protocol/agent"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnimplementedWrorkerSignal = errors.New("unimplemented worker signal")
|
||||
ErrUnknownWorkerSignal = errors.New("unknown worker signal")
|
||||
ErrUnknownJobType = errors.New("unknown job type")
|
||||
ErrJobNotFound = psrpc.NewErrorf(psrpc.NotFound, "no running job for given jobID")
|
||||
ErrWorkerClosed = errors.New("worker closed")
|
||||
ErrWorkerNotAvailable = errors.New("worker not available")
|
||||
ErrAvailabilityTimeout = errors.New("agent worker availability timeout")
|
||||
ErrDuplicateJobAssignment = errors.New("duplicate job assignment")
|
||||
)
|
||||
|
||||
const AgentNameAttributeKey = "lk.agent_name"
|
||||
|
||||
type WorkerProtocolVersion int
|
||||
|
||||
const CurrentProtocol = 1
|
||||
|
||||
const (
|
||||
RegisterTimeout = 10 * time.Second
|
||||
AssignJobTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type SignalConn interface {
|
||||
WriteServerMessage(msg *livekit.ServerMessage) (int, error)
|
||||
ReadWorkerMessage() (*livekit.WorkerMessage, int, error)
|
||||
SetReadDeadline(time.Time) error
|
||||
Close() error
|
||||
CloseWithReason(reason string) error
|
||||
}
|
||||
|
||||
func JobStatusIsEnded(s livekit.JobStatus) bool {
|
||||
return s == livekit.JobStatus_JS_SUCCESS || s == livekit.JobStatus_JS_FAILED
|
||||
}
|
||||
|
||||
type AssignmentHook func(next func(*livekit.JobAssignment) error) func(*livekit.JobAssignment) error
|
||||
|
||||
type WorkerSignalHandler interface {
|
||||
HandleRegister(*livekit.RegisterWorkerRequest) error
|
||||
HandleAvailability(*livekit.AvailabilityResponse) error
|
||||
HandleUpdateJob(*livekit.UpdateJobStatus) error
|
||||
HandleSimulateJob(*livekit.SimulateJobRequest) error
|
||||
HandlePing(*livekit.WorkerPing) error
|
||||
HandleUpdateWorker(*livekit.UpdateWorkerStatus) error
|
||||
HandleMigrateJob(*livekit.MigrateJobRequest) error
|
||||
}
|
||||
|
||||
func DispatchWorkerSignal(req *livekit.WorkerMessage, h WorkerSignalHandler) error {
|
||||
switch m := req.Message.(type) {
|
||||
case *livekit.WorkerMessage_Register:
|
||||
return h.HandleRegister(m.Register)
|
||||
case *livekit.WorkerMessage_Availability:
|
||||
return h.HandleAvailability(m.Availability)
|
||||
case *livekit.WorkerMessage_UpdateJob:
|
||||
return h.HandleUpdateJob(m.UpdateJob)
|
||||
case *livekit.WorkerMessage_SimulateJob:
|
||||
return h.HandleSimulateJob(m.SimulateJob)
|
||||
case *livekit.WorkerMessage_Ping:
|
||||
return h.HandlePing(m.Ping)
|
||||
case *livekit.WorkerMessage_UpdateWorker:
|
||||
return h.HandleUpdateWorker(m.UpdateWorker)
|
||||
case *livekit.WorkerMessage_MigrateJob:
|
||||
return h.HandleMigrateJob(m.MigrateJob)
|
||||
default:
|
||||
return ErrUnknownWorkerSignal
|
||||
}
|
||||
}
|
||||
|
||||
var _ WorkerSignalHandler = (*UnimplementedWorkerSignalHandler)(nil)
|
||||
|
||||
type UnimplementedWorkerSignalHandler struct{}
|
||||
|
||||
func (UnimplementedWorkerSignalHandler) HandleRegister(*livekit.RegisterWorkerRequest) error {
|
||||
return fmt.Errorf("%w: Register", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleAvailability(*livekit.AvailabilityResponse) error {
|
||||
return fmt.Errorf("%w: Availability", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleUpdateJob(*livekit.UpdateJobStatus) error {
|
||||
return fmt.Errorf("%w: UpdateJob", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleSimulateJob(*livekit.SimulateJobRequest) error {
|
||||
return fmt.Errorf("%w: SimulateJob", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandlePing(*livekit.WorkerPing) error {
|
||||
return fmt.Errorf("%w: Ping", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleUpdateWorker(*livekit.UpdateWorkerStatus) error {
|
||||
return fmt.Errorf("%w: UpdateWorker", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleMigrateJob(*livekit.MigrateJobRequest) error {
|
||||
return fmt.Errorf("%w: MigrateJob", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
|
||||
type WorkerPingHandler struct {
|
||||
UnimplementedWorkerSignalHandler
|
||||
conn SignalConn
|
||||
}
|
||||
|
||||
func (h WorkerPingHandler) HandlePing(ping *livekit.WorkerPing) error {
|
||||
_, err := h.conn.WriteServerMessage(&livekit.ServerMessage{
|
||||
Message: &livekit.ServerMessage_Pong{
|
||||
Pong: &livekit.WorkerPong{
|
||||
LastTimestamp: ping.Timestamp,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
},
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type WorkerRegistration struct {
|
||||
Protocol WorkerProtocolVersion
|
||||
ID string
|
||||
Version string
|
||||
AgentID string
|
||||
AgentName string
|
||||
Namespace string
|
||||
JobType livekit.JobType
|
||||
Permissions *livekit.ParticipantPermission
|
||||
ClientIP string
|
||||
Deployment string
|
||||
// KindDetails, when set by the server, are stamped onto the participant
|
||||
// join token minted for every job assigned to this worker (e.g. marking
|
||||
// hosted/cloud agents so they can be distinguished from self-hosted ones in
|
||||
// observability). This is server-controlled and never populated from the
|
||||
// worker's register request.
|
||||
KindDetails []livekit.ParticipantInfo_KindDetail
|
||||
}
|
||||
|
||||
func MakeWorkerRegistration() WorkerRegistration {
|
||||
return WorkerRegistration{
|
||||
ID: guid.New(guid.AgentWorkerPrefix),
|
||||
Protocol: CurrentProtocol,
|
||||
}
|
||||
}
|
||||
|
||||
var _ WorkerSignalHandler = (*WorkerRegisterer)(nil)
|
||||
|
||||
type WorkerRegisterer struct {
|
||||
WorkerPingHandler
|
||||
serverInfo *livekit.ServerInfo
|
||||
deadline time.Time
|
||||
|
||||
registration WorkerRegistration
|
||||
registered bool
|
||||
}
|
||||
|
||||
func NewWorkerRegisterer(conn SignalConn, serverInfo *livekit.ServerInfo, base WorkerRegistration) *WorkerRegisterer {
|
||||
return &WorkerRegisterer{
|
||||
WorkerPingHandler: WorkerPingHandler{conn: conn},
|
||||
serverInfo: serverInfo,
|
||||
registration: base,
|
||||
deadline: time.Now().Add(RegisterTimeout),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) Deadline() time.Time {
|
||||
return h.deadline
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) Registration() WorkerRegistration {
|
||||
return h.registration
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) Registered() bool {
|
||||
return h.registered
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) HandleRegister(req *livekit.RegisterWorkerRequest) error {
|
||||
if !livekit.IsJobType(req.GetType()) {
|
||||
return ErrUnknownJobType
|
||||
}
|
||||
|
||||
if err := protoagent.ValidateDeployment(req.GetDeployment()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissions := req.AllowedPermissions
|
||||
if permissions == nil {
|
||||
permissions = &livekit.ParticipantPermission{
|
||||
CanSubscribe: true,
|
||||
CanPublish: true,
|
||||
CanPublishData: true,
|
||||
CanUpdateMetadata: true,
|
||||
}
|
||||
}
|
||||
|
||||
h.registration.Version = req.Version
|
||||
h.registration.AgentName = req.AgentName
|
||||
h.registration.Namespace = req.GetNamespace()
|
||||
h.registration.JobType = req.GetType()
|
||||
h.registration.Permissions = permissions
|
||||
h.registration.Deployment = req.GetDeployment()
|
||||
h.registered = true
|
||||
|
||||
_, err := h.conn.WriteServerMessage(&livekit.ServerMessage{
|
||||
Message: &livekit.ServerMessage_Register{
|
||||
Register: &livekit.RegisterWorkerResponse{
|
||||
WorkerId: h.registration.ID,
|
||||
ServerInfo: h.serverInfo,
|
||||
},
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
var _ WorkerSignalHandler = (*Worker)(nil)
|
||||
|
||||
type Worker struct {
|
||||
WorkerPingHandler
|
||||
WorkerRegistration
|
||||
|
||||
apiKey string
|
||||
apiSecret string
|
||||
logger logger.Logger
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
closed chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
load float32
|
||||
status livekit.WorkerStatus
|
||||
|
||||
runningJobs map[livekit.JobID]*livekit.Job
|
||||
availability map[livekit.JobID]chan *livekit.AvailabilityResponse
|
||||
}
|
||||
|
||||
func NewWorker(
|
||||
registration WorkerRegistration,
|
||||
apiKey string,
|
||||
apiSecret string,
|
||||
conn SignalConn,
|
||||
logger logger.Logger,
|
||||
) *Worker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Worker{
|
||||
WorkerPingHandler: WorkerPingHandler{conn: conn},
|
||||
WorkerRegistration: registration,
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
logger: logger.WithValues(
|
||||
"workerID", registration.ID,
|
||||
"agentName", registration.AgentName,
|
||||
"deployment", registration.Deployment,
|
||||
"agentID", registration.AgentID,
|
||||
"version", registration.Version,
|
||||
"jobType", registration.JobType.String(),
|
||||
),
|
||||
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
closed: make(chan struct{}),
|
||||
|
||||
runningJobs: make(map[livekit.JobID]*livekit.Job),
|
||||
availability: make(map[livekit.JobID]chan *livekit.AvailabilityResponse),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) APIKey() string {
|
||||
return w.apiKey
|
||||
}
|
||||
|
||||
func (w *Worker) Status() livekit.WorkerStatus {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (w *Worker) Load() float32 {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.load
|
||||
}
|
||||
|
||||
func (w *Worker) Logger() logger.Logger {
|
||||
return w.logger
|
||||
}
|
||||
|
||||
func (w *Worker) RunningJobs() map[livekit.JobID]*livekit.Job {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return maps.Clone(w.runningJobs)
|
||||
}
|
||||
|
||||
func (w *Worker) RunningJobCount() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.runningJobs)
|
||||
}
|
||||
|
||||
func (w *Worker) GetJobState(jobID livekit.JobID) (*livekit.JobState, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
j, ok := w.runningJobs[jobID]
|
||||
if !ok {
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
return utils.CloneProto(j.State), nil
|
||||
}
|
||||
|
||||
func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook AssignmentHook) (*livekit.JobState, error) {
|
||||
availCh := make(chan *livekit.AvailabilityResponse, 1)
|
||||
job = utils.CloneProto(job)
|
||||
jobID := livekit.JobID(job.Id)
|
||||
|
||||
w.mu.Lock()
|
||||
if _, ok := w.availability[jobID]; ok {
|
||||
w.mu.Unlock()
|
||||
return nil, ErrDuplicateJobAssignment
|
||||
}
|
||||
|
||||
w.availability[jobID] = availCh
|
||||
w.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
w.mu.Lock()
|
||||
delete(w.availability, jobID)
|
||||
w.mu.Unlock()
|
||||
}()
|
||||
|
||||
if job.State == nil {
|
||||
job.State = &livekit.JobState{}
|
||||
}
|
||||
now := time.Now()
|
||||
job.State.WorkerId = w.ID
|
||||
job.State.AgentId = w.AgentID
|
||||
job.State.UpdatedAt = now.UnixNano()
|
||||
job.State.StartedAt = now.UnixNano()
|
||||
job.State.Status = livekit.JobStatus_JS_RUNNING
|
||||
|
||||
if _, err := w.conn.WriteServerMessage(&livekit.ServerMessage{Message: &livekit.ServerMessage_Availability{
|
||||
Availability: &livekit.AvailabilityRequest{Job: job},
|
||||
}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := time.NewTimer(AssignJobTimeout)
|
||||
defer timeout.Stop()
|
||||
|
||||
// See handleAvailability for the response
|
||||
select {
|
||||
case res := <-availCh:
|
||||
if res.Terminate {
|
||||
job.State.EndedAt = now.UnixNano()
|
||||
job.State.Status = livekit.JobStatus_JS_SUCCESS
|
||||
return job.State, nil
|
||||
}
|
||||
|
||||
if !res.Available {
|
||||
return nil, ErrWorkerNotAvailable
|
||||
}
|
||||
|
||||
job.State.ParticipantIdentity = res.ParticipantIdentity
|
||||
attributes := res.ParticipantAttributes
|
||||
if attributes == nil {
|
||||
attributes = make(map[string]string)
|
||||
}
|
||||
attributes[AgentNameAttributeKey] = w.AgentName
|
||||
|
||||
token, err := protoagent.BuildAgentToken(
|
||||
w.apiKey,
|
||||
w.apiSecret,
|
||||
job.Room.Name,
|
||||
res.ParticipantIdentity,
|
||||
res.ParticipantName,
|
||||
res.ParticipantMetadata,
|
||||
attributes,
|
||||
w.Permissions,
|
||||
w.KindDetails...,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Errorw("failed to build agent token", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
send := func(a *livekit.JobAssignment) error {
|
||||
_, err := w.conn.WriteServerMessage(&livekit.ServerMessage{
|
||||
Message: &livekit.ServerMessage_Assignment{Assignment: a},
|
||||
})
|
||||
return err
|
||||
}
|
||||
if hook != nil {
|
||||
send = hook(send)
|
||||
}
|
||||
if err := send(&livekit.JobAssignment{Job: job, Token: token}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := utils.CloneProto(job.State)
|
||||
|
||||
w.mu.Lock()
|
||||
w.runningJobs[jobID] = job
|
||||
w.mu.Unlock()
|
||||
|
||||
// TODO sweep jobs that are never started. We can't do this until all SDKs actually update the the JOB state
|
||||
|
||||
return state, nil
|
||||
case <-timeout.C:
|
||||
return nil, ErrAvailabilityTimeout
|
||||
case <-w.ctx.Done():
|
||||
return nil, ErrWorkerClosed
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) TerminateJob(jobID livekit.JobID, reason rpc.JobTerminateReason) (*livekit.JobState, error) {
|
||||
w.mu.Lock()
|
||||
_, ok := w.runningJobs[jobID]
|
||||
w.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
|
||||
_, writeErr := w.conn.WriteServerMessage(&livekit.ServerMessage{Message: &livekit.ServerMessage_Termination{
|
||||
Termination: &livekit.JobTermination{
|
||||
JobId: string(jobID),
|
||||
},
|
||||
}})
|
||||
|
||||
status := livekit.JobStatus_JS_SUCCESS
|
||||
errorStr := ""
|
||||
if reason == rpc.JobTerminateReason_AGENT_LEFT_ROOM {
|
||||
status = livekit.JobStatus_JS_FAILED
|
||||
errorStr = "agent worker left the room"
|
||||
}
|
||||
|
||||
state, updateErr := w.UpdateJobStatus(&livekit.UpdateJobStatus{
|
||||
JobId: string(jobID),
|
||||
Status: status,
|
||||
Error: errorStr,
|
||||
})
|
||||
return state, multierr.Combine(writeErr, updateErr)
|
||||
}
|
||||
|
||||
func (w *Worker) UpdateMetadata(metadata string) {
|
||||
w.logger.Debugw("worker metadata updated", nil, "metadata", metadata)
|
||||
}
|
||||
|
||||
func (w *Worker) IsClosed() bool {
|
||||
select {
|
||||
case <-w.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Close() {
|
||||
w.mu.Lock()
|
||||
if w.IsClosed() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
w.logger.Infow("closing worker", "workerID", w.ID, "jobType", w.JobType, "agentName", w.AgentName)
|
||||
|
||||
close(w.closed)
|
||||
w.cancel()
|
||||
_ = w.conn.Close()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *Worker) HandleAvailability(res *livekit.AvailabilityResponse) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
jobID := livekit.JobID(res.JobId)
|
||||
availCh, ok := w.availability[jobID]
|
||||
if !ok {
|
||||
w.logger.Warnw("received availability response for unknown job", nil, "jobID", jobID)
|
||||
return nil
|
||||
}
|
||||
|
||||
availCh <- res
|
||||
delete(w.availability, jobID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleUpdateJob(update *livekit.UpdateJobStatus) error {
|
||||
_, err := w.UpdateJobStatus(update)
|
||||
if err != nil {
|
||||
// treating this as a debug message only
|
||||
// this can happen if the Room closes first, which would delete the agent dispatch
|
||||
// that would mark the job as successful. subsequent updates from the same worker
|
||||
// would not be able to find the same jobID.
|
||||
w.logger.Debugw("received job update for unknown job", "jobID", update.JobId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) UpdateJobStatus(update *livekit.UpdateJobStatus) (*livekit.JobState, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
jobID := livekit.JobID(update.JobId)
|
||||
job, ok := w.runningJobs[jobID]
|
||||
if !ok {
|
||||
return nil, psrpc.NewErrorf(psrpc.NotFound, "received job update for unknown job")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
job.State.UpdatedAt = now.UnixNano()
|
||||
|
||||
if job.State.Status == livekit.JobStatus_JS_PENDING && update.Status != livekit.JobStatus_JS_PENDING {
|
||||
job.State.StartedAt = now.UnixNano()
|
||||
}
|
||||
|
||||
job.State.Status = update.Status
|
||||
job.State.Error = update.Error
|
||||
|
||||
if JobStatusIsEnded(update.Status) {
|
||||
job.State.EndedAt = now.UnixNano()
|
||||
delete(w.runningJobs, jobID)
|
||||
|
||||
w.logger.Infow("job ended", "jobID", update.JobId, "status", update.Status, "error", update.Error)
|
||||
}
|
||||
|
||||
return proto.Clone(job.State).(*livekit.JobState), nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleSimulateJob(simulate *livekit.SimulateJobRequest) error {
|
||||
jobType := livekit.JobType_JT_ROOM
|
||||
if simulate.Participant != nil {
|
||||
jobType = livekit.JobType_JT_PUBLISHER
|
||||
}
|
||||
|
||||
job := &livekit.Job{
|
||||
Id: guid.New(guid.AgentJobPrefix),
|
||||
Type: jobType,
|
||||
Room: simulate.Room,
|
||||
Participant: simulate.Participant,
|
||||
Namespace: w.Namespace,
|
||||
AgentName: w.AgentName,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := w.AssignJob(w.ctx, job, nil)
|
||||
if err != nil {
|
||||
w.logger.Errorw("unable to simulate job", err, "jobID", job.Id)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleUpdateWorker(update *livekit.UpdateWorkerStatus) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if status := update.Status; status != nil && w.status != *status {
|
||||
w.status = *status
|
||||
w.Logger().Debugw("worker status changed", "status", w.status)
|
||||
}
|
||||
w.load = update.GetLoad()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleMigrateJob(req *livekit.MigrateJobRequest) error {
|
||||
// TODO(theomonnom): On OSS this is not implemented
|
||||
// We could maybe just move a specific job to another worker
|
||||
return nil
|
||||
}
|
||||
64
pkg/clientconfiguration/conf.go
Normal file
64
pkg/clientconfiguration/conf.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package clientconfiguration
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
)
|
||||
|
||||
// StaticConfigurations list specific device-side limitations that should be disabled at a global level
|
||||
var StaticConfigurations = []ConfigurationItem{
|
||||
// {
|
||||
// Match: must.Get(NewScriptMatch(`c.protocol <= 5 || c.browser == "firefox"`)),
|
||||
// Configuration: &livekit.ClientConfiguration{ResumeConnection: livekit.ClientConfigSetting_DISABLED},
|
||||
// Merge: false,
|
||||
// },
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.browser == "safari"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
DisabledCodecs: &livekit.DisabledCodecs{
|
||||
Codecs: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeAV1.String()},
|
||||
},
|
||||
},
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.browser == "safari" && c.browser_version > "18.3"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
DisabledCodecs: &livekit.DisabledCodecs{
|
||||
Publish: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeVP9.String()},
|
||||
},
|
||||
},
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`(c.device_model == "xiaomi 2201117ti" && c.os == "android") ||
|
||||
((c.browser == "firefox" || c.browser == "firefox mobile") && (c.os == "linux" || c.os == "android"))`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
DisabledCodecs: &livekit.DisabledCodecs{
|
||||
Publish: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeH264.String()},
|
||||
},
|
||||
},
|
||||
},
|
||||
Merge: false,
|
||||
},
|
||||
}
|
||||
134
pkg/clientconfiguration/conf_test.go
Normal file
134
pkg/clientconfiguration/conf_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package clientconfiguration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
)
|
||||
|
||||
func TestScriptMatchConfiguration(t *testing.T) {
|
||||
t.Run("no merge", func(t *testing.T) {
|
||||
confs := []ConfigurationItem{
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.protocol > 5 && c.browser != "firefox"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
ResumeConnection: livekit.ClientConfigSetting_ENABLED,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cm := NewStaticClientConfigurationManager(confs)
|
||||
|
||||
conf := cm.GetConfiguration(&livekit.ClientInfo{Protocol: 4})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "firefox"})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "chrome"})
|
||||
require.Equal(t, conf.ResumeConnection, livekit.ClientConfigSetting_ENABLED)
|
||||
})
|
||||
|
||||
t.Run("merge", func(t *testing.T) {
|
||||
confs := []ConfigurationItem{
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.protocol > 5 && c.browser != "firefox"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
ResumeConnection: livekit.ClientConfigSetting_ENABLED,
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.sdk == "android"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
Video: &livekit.VideoConfiguration{
|
||||
HardwareEncoder: livekit.ClientConfigSetting_DISABLED,
|
||||
},
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
}
|
||||
|
||||
cm := NewStaticClientConfigurationManager(confs)
|
||||
|
||||
conf := cm.GetConfiguration(&livekit.ClientInfo{Protocol: 4})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "firefox"})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "chrome", Sdk: 3})
|
||||
require.Equal(t, conf.ResumeConnection, livekit.ClientConfigSetting_ENABLED)
|
||||
require.Equal(t, conf.Video.HardwareEncoder, livekit.ClientConfigSetting_DISABLED)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScriptMatch(t *testing.T) {
|
||||
client := &livekit.ClientInfo{
|
||||
Protocol: 6,
|
||||
Browser: "chrome",
|
||||
Sdk: 3, // android
|
||||
DeviceModel: "12345",
|
||||
BrowserVersion: "13.2",
|
||||
Version: "2.17.1",
|
||||
}
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
expr string
|
||||
result bool
|
||||
err bool
|
||||
}
|
||||
|
||||
cases := []testcase{
|
||||
{name: "simple match", expr: `c.protocol > 5`, result: true},
|
||||
{name: "invalid expr", expr: `cc.protocol > 5`, err: true},
|
||||
{name: "unexist field", expr: `c.protocols > 5`, err: true},
|
||||
{name: "combined condition", expr: `c.protocol > 5 && (c.sdk=="android" || c.sdk=="ios")`, result: true},
|
||||
{name: "combined condition2", expr: `(c.device_model == "xiaomi 2201117ti" && c.os == "android") || ((c.browser == "firefox" || c.browser == "firefox mobile") && (c.os == "linux" || c.os == "android"))`, result: false},
|
||||
{name: "string lesser", expr: `c.browser_version < "11.3"`, result: false},
|
||||
{name: "string lesser eq", expr: `c.browser_version <= "13.2"`, result: true},
|
||||
{name: "string greater", expr: `c.browser_version > "11.3"`, result: true},
|
||||
{name: "string greater eq", expr: `c.browser_version >= "13.2"`, result: true},
|
||||
{name: "semantic lesser", expr: `c.version < "2.16.10"`, result: false},
|
||||
{name: "semantic lesser eq", expr: `c.version <= "2.17.1"`, result: true},
|
||||
{name: "semantic greater", expr: `c.version > "2.16.10"`, result: true},
|
||||
{name: "semantic greater eq", expr: `c.version >= "2.17.1"`, result: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
match, err := NewScriptMatch(c.expr)
|
||||
if err != nil {
|
||||
if !c.err {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
m, err := match.Match(client)
|
||||
if c.err {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.Equal(t, c.result, m)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
177
pkg/clientconfiguration/match.go
Normal file
177
pkg/clientconfiguration/match.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package clientconfiguration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/d5/tengo/v2"
|
||||
"github.com/d5/tengo/v2/token"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type Match interface {
|
||||
Match(clientInfo *livekit.ClientInfo) (bool, error)
|
||||
}
|
||||
|
||||
type ScriptMatch struct {
|
||||
compiled *tengo.Compiled
|
||||
}
|
||||
|
||||
func NewScriptMatch(expr string) (*ScriptMatch, error) {
|
||||
script := tengo.NewScript(fmt.Appendf(nil, "__res__ := (%s)", expr))
|
||||
if err := script.Add("c", &clientObject{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiled, err := script.Compile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScriptMatch{compiled}, nil
|
||||
}
|
||||
|
||||
// use result of eval script expression for match.
|
||||
// expression examples:
|
||||
// protocol bigger than 5 : c.protocol > 5
|
||||
// browser if firefox: c.browser == "firefox"
|
||||
// combined rule : c.protocol > 5 && c.browser == "firefox"
|
||||
func (m *ScriptMatch) Match(clientInfo *livekit.ClientInfo) (bool, error) {
|
||||
clone := m.compiled.Clone()
|
||||
if err := clone.Set("c", &clientObject{info: clientInfo}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := clone.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
res := clone.Get("__res__").Value()
|
||||
if val, ok := res.(bool); ok {
|
||||
return val, nil
|
||||
}
|
||||
return false, errors.New("invalid match expression result")
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type clientObject struct {
|
||||
tengo.ObjectImpl
|
||||
info *livekit.ClientInfo
|
||||
}
|
||||
|
||||
func (c *clientObject) TypeName() string {
|
||||
return "clientObject"
|
||||
}
|
||||
|
||||
func (c *clientObject) String() string {
|
||||
return c.info.String()
|
||||
}
|
||||
|
||||
func (c *clientObject) IndexGet(index tengo.Object) (res tengo.Object, err error) {
|
||||
field, ok := index.(*tengo.String)
|
||||
if !ok {
|
||||
return nil, tengo.ErrInvalidIndexType
|
||||
}
|
||||
|
||||
switch field.Value {
|
||||
case "sdk":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.Sdk.String())}, nil
|
||||
case "version":
|
||||
return &ruleSdkVersion{sdkVersion: c.info.Version}, nil
|
||||
case "protocol":
|
||||
return &tengo.Int{Value: int64(c.info.Protocol)}, nil
|
||||
case "os":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.Os)}, nil
|
||||
case "os_version":
|
||||
return &tengo.String{Value: c.info.OsVersion}, nil
|
||||
case "device_model":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.DeviceModel)}, nil
|
||||
case "browser":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.Browser)}, nil
|
||||
case "browser_version":
|
||||
return &ruleSdkVersion{sdkVersion: c.info.BrowserVersion}, nil
|
||||
case "address":
|
||||
return &tengo.String{Value: c.info.Address}, nil
|
||||
}
|
||||
return &tengo.Undefined{}, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
|
||||
type ruleSdkVersion struct {
|
||||
tengo.ObjectImpl
|
||||
sdkVersion string
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) TypeName() string {
|
||||
return "sdkVersion"
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) String() string {
|
||||
return r.sdkVersion
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) BinaryOp(op token.Token, rhs tengo.Object) (tengo.Object, error) {
|
||||
if rhs, ok := rhs.(*tengo.String); ok {
|
||||
cmp := r.compare(rhs.Value)
|
||||
|
||||
isMatch := false
|
||||
switch op {
|
||||
case token.Greater:
|
||||
isMatch = cmp > 0
|
||||
case token.GreaterEq:
|
||||
isMatch = cmp >= 0
|
||||
case token.Less:
|
||||
isMatch = cmp < 0
|
||||
case token.LessEq:
|
||||
isMatch = cmp <= 0
|
||||
default:
|
||||
return nil, tengo.ErrInvalidOperator
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
return tengo.TrueValue, nil
|
||||
}
|
||||
return tengo.FalseValue, nil
|
||||
}
|
||||
|
||||
return nil, tengo.ErrInvalidOperator
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) Equals(rhs tengo.Object) bool {
|
||||
if rhs, ok := rhs.(*tengo.String); ok {
|
||||
return r.compare(rhs.Value) == 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) compare(rhsSdkVersion string) int {
|
||||
if !semver.IsValid("v"+r.sdkVersion) || !semver.IsValid("v"+rhsSdkVersion) {
|
||||
// if not valid semver, do string compare
|
||||
switch {
|
||||
case r.sdkVersion < rhsSdkVersion:
|
||||
return -1
|
||||
case r.sdkVersion > rhsSdkVersion:
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
return semver.Compare("v"+r.sdkVersion, "v"+rhsSdkVersion)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
71
pkg/clientconfiguration/staticconfiguration.go
Normal file
71
pkg/clientconfiguration/staticconfiguration.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package clientconfiguration
|
||||
|
||||
import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
protoutils "github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type ConfigurationItem struct {
|
||||
Match
|
||||
Configuration *livekit.ClientConfiguration
|
||||
Merge bool
|
||||
}
|
||||
|
||||
type StaticClientConfigurationManager struct {
|
||||
confs []ConfigurationItem
|
||||
}
|
||||
|
||||
func NewStaticClientConfigurationManager(confs []ConfigurationItem) *StaticClientConfigurationManager {
|
||||
return &StaticClientConfigurationManager{confs: confs}
|
||||
}
|
||||
|
||||
func (s *StaticClientConfigurationManager) GetConfiguration(clientInfo *livekit.ClientInfo) *livekit.ClientConfiguration {
|
||||
var matchedConf []*livekit.ClientConfiguration
|
||||
for _, c := range s.confs {
|
||||
matched, err := c.Match.Match(clientInfo)
|
||||
if err != nil {
|
||||
logger.Errorw("matchrule failed", err,
|
||||
"clientInfo", logger.Proto(utils.ClientInfoWithoutAddress(clientInfo)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if !c.Merge {
|
||||
return c.Configuration
|
||||
}
|
||||
matchedConf = append(matchedConf, c.Configuration)
|
||||
}
|
||||
|
||||
var conf *livekit.ClientConfiguration
|
||||
for k, v := range matchedConf {
|
||||
if k == 0 {
|
||||
conf = protoutils.CloneProto(matchedConf[0])
|
||||
} else {
|
||||
// TODO : there is a problem use protobuf merge, we don't have flag to indicate 'no value',
|
||||
// don't override default behavior or other configuration's field. So a bool value = false or
|
||||
// a int value = 0 will override same field in other configuration
|
||||
proto.Merge(conf, v)
|
||||
}
|
||||
}
|
||||
return conf
|
||||
}
|
||||
23
pkg/clientconfiguration/types.go
Normal file
23
pkg/clientconfiguration/types.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package clientconfiguration
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type ClientConfigurationManager interface {
|
||||
GetConfiguration(clientInfo *livekit.ClientInfo) *livekit.ClientConfiguration
|
||||
}
|
||||
1113
pkg/config/config.go
Normal file
1113
pkg/config/config.go
Normal file
File diff suppressed because it is too large
Load Diff
249
pkg/config/config_test.go
Normal file
249
pkg/config/config_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config/configtest"
|
||||
)
|
||||
|
||||
func TestConfig_UnmarshalKeys(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conf.unmarshalKeys("key1: secret1"))
|
||||
require.Equal(t, "secret1", conf.Keys["key1"])
|
||||
}
|
||||
|
||||
func TestConfig_DefaultsKept(t *testing.T) {
|
||||
const content = `room:
|
||||
empty_timeout: 10`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, conf.Room.AutoCreate)
|
||||
require.Equal(t, uint32(10), conf.Room.EmptyTimeout)
|
||||
}
|
||||
|
||||
func TestConfig_SignalMessageSizeLimitDefaults(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
// both default to 2 MiB when not specified
|
||||
require.Equal(t, int64(2<<20), conf.Limit.SignalMessageSizeLimit)
|
||||
require.Equal(t, int64(2<<20), conf.Limit.AgentSignalMessageSizeLimit)
|
||||
}
|
||||
|
||||
func TestConfig_SignalMessageSizeLimitOverride(t *testing.T) {
|
||||
const content = `limit:
|
||||
signal_message_size_limit: 1024
|
||||
agent_signal_message_size_limit: 0`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1024), conf.Limit.SignalMessageSizeLimit)
|
||||
// 0 explicitly disables the limit
|
||||
require.Equal(t, int64(0), conf.Limit.AgentSignalMessageSizeLimit)
|
||||
}
|
||||
|
||||
func TestConfig_PSRPCCompressionDefaults(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, conf.PSRPC.Compression.Quality)
|
||||
require.Equal(t, 1024, conf.PSRPC.Compression.Threshold)
|
||||
require.Equal(t, 0, conf.PSRPC.Compression.MaxDecompressedSize)
|
||||
}
|
||||
|
||||
func TestConfig_PSRPCCompressionOverride(t *testing.T) {
|
||||
const content = `psrpc:
|
||||
compression:
|
||||
quality: 6
|
||||
max_decompressed_size: 4096`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 6, conf.PSRPC.Compression.Quality)
|
||||
require.Equal(t, 4096, conf.PSRPC.Compression.MaxDecompressedSize)
|
||||
require.Equal(t, 1024, conf.PSRPC.Compression.Threshold)
|
||||
require.Equal(t, 3, conf.PSRPC.MaxAttempts)
|
||||
}
|
||||
|
||||
func TestConfig_UnknownKeys(t *testing.T) {
|
||||
const content = `unknown: 10
|
||||
room:
|
||||
empty_timeout: 10`
|
||||
_, err := NewConfig(content, true, nil, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGeneratedFlags(t *testing.T) {
|
||||
generatedFlags, err := GenerateCLIFlags(nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := &cli.Command{}
|
||||
c.Name = "test"
|
||||
c.Flags = append(c.Flags, generatedFlags...)
|
||||
|
||||
c.Set("rtc.use_ice_lite", "true")
|
||||
c.Set("redis.address", "localhost:6379")
|
||||
c.Set("prometheus.port", "9999")
|
||||
c.Set("rtc.allow_tcp_fallback", "true")
|
||||
c.Set("rtc.reconnect_on_publication_error", "true")
|
||||
c.Set("rtc.reconnect_on_subscription_error", "false")
|
||||
|
||||
conf, err := NewConfig("", true, c, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, conf.RTC.UseICELite)
|
||||
require.Equal(t, "localhost:6379", conf.Redis.Address)
|
||||
require.Equal(t, uint32(9999), conf.Prometheus.Port)
|
||||
|
||||
require.NotNil(t, conf.RTC.AllowTCPFallback)
|
||||
require.True(t, *conf.RTC.AllowTCPFallback)
|
||||
|
||||
require.NotNil(t, conf.RTC.ReconnectOnPublicationError)
|
||||
require.True(t, *conf.RTC.ReconnectOnPublicationError)
|
||||
|
||||
require.NotNil(t, conf.RTC.ReconnectOnSubscriptionError)
|
||||
require.False(t, *conf.RTC.ReconnectOnSubscriptionError)
|
||||
}
|
||||
|
||||
func TestYAMLTag(t *testing.T) {
|
||||
require.NoError(t, configtest.CheckYAMLTags(Config{}))
|
||||
}
|
||||
|
||||
func TestClampTURNTTLSeconds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in int
|
||||
want int
|
||||
changed bool
|
||||
}{
|
||||
{"negative falls back to default", -1, DefaultTURNTTLSeconds, true},
|
||||
{"large negative falls back to default", -1 << 40, DefaultTURNTTLSeconds, true},
|
||||
{"zero falls back to default", 0, DefaultTURNTTLSeconds, true},
|
||||
{"in range preserved", 600, 600, false},
|
||||
{"max preserved", TURNMaxTTLSeconds, TURNMaxTTLSeconds, false},
|
||||
{"over max clamps to max", TURNMaxTTLSeconds + 1, TURNMaxTTLSeconds, true},
|
||||
{"overflowing value clamps to max", 1<<62 + 1, TURNMaxTTLSeconds, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, changed := ClampTURNTTLSeconds(c.in)
|
||||
require.Equal(t, c.want, got)
|
||||
require.Equal(t, c.changed, changed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTURNTTLs(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conf.TURN.TTLSeconds = -5
|
||||
conf.RTC.TURNServers = []TURNServer{
|
||||
{Host: "over", TTL: TURNMaxTTLSeconds + 100},
|
||||
{Host: "negative", TTL: -1},
|
||||
{Host: "default", TTL: 0},
|
||||
{Host: "ok", TTL: 600},
|
||||
}
|
||||
|
||||
conf.NormalizeTURNTTLs()
|
||||
|
||||
// embedded TTL <= 0 falls back to the 5m default
|
||||
require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds)
|
||||
// external TTLs: only the upper bound is capped at load; 0/negative keep their
|
||||
// "use the default" meaning and are resolved when credentials are generated
|
||||
require.Equal(t, TURNMaxTTLSeconds, conf.RTC.TURNServers[0].TTL)
|
||||
require.Equal(t, -1, conf.RTC.TURNServers[1].TTL)
|
||||
require.Equal(t, 0, conf.RTC.TURNServers[2].TTL)
|
||||
require.Equal(t, 600, conf.RTC.TURNServers[3].TTL)
|
||||
}
|
||||
|
||||
func TestNewConfigNormalizesTURNTTL(t *testing.T) {
|
||||
const content = `turn:
|
||||
ttl_seconds: -10`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds)
|
||||
}
|
||||
|
||||
func writeSecretFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "secret")
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadTURNSecrets(t *testing.T) {
|
||||
t.Run("empty secret file is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, "")}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNSecretEmpty)
|
||||
})
|
||||
|
||||
t.Run("whitespace-only secret file is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, " \n\t ")}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNSecretEmpty)
|
||||
})
|
||||
|
||||
t.Run("valid secret file is loaded and trimmed", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, " topsecret\n")}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
require.Equal(t, "topsecret", conf.RTC.TURNServers[0].Secret)
|
||||
})
|
||||
|
||||
t.Run("inline whitespace secret without static creds is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " "}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
|
||||
})
|
||||
|
||||
t.Run("no credentials at all is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h"}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
|
||||
})
|
||||
|
||||
t.Run("static credentials are accepted", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Username: "u", Credential: "c"}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
})
|
||||
|
||||
t.Run("partial static credentials are rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Username: "u"}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
|
||||
})
|
||||
|
||||
t.Run("inline secret takes precedence and is trimmed", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " inline ", SecretFile: writeSecretFile(t, "fromfile")}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
require.Equal(t, "inline", conf.RTC.TURNServers[0].Secret)
|
||||
})
|
||||
|
||||
t.Run("blank inline secret falls back to secret file", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " ", SecretFile: writeSecretFile(t, "fromfile")}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
require.Equal(t, "fromfile", conf.RTC.TURNServers[0].Secret)
|
||||
})
|
||||
}
|
||||
69
pkg/config/configtest/checkyamltag.go
Normal file
69
pkg/config/configtest/checkyamltag.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package configtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var protoMessageType = reflect.TypeOf((*proto.Message)(nil)).Elem()
|
||||
|
||||
func checkYAMLTags(t reflect.Type, seen map[reflect.Type]struct{}) error {
|
||||
if _, ok := seen[t]; ok {
|
||||
return nil
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.Pointer:
|
||||
return checkYAMLTags(t.Elem(), seen)
|
||||
case reflect.Struct:
|
||||
if reflect.PointerTo(t).Implements(protoMessageType) {
|
||||
// ignore protobuf messages
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs error
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
if !field.IsExported() {
|
||||
// ignore unexported fields
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Type.Kind() == reflect.Bool {
|
||||
// ignore boolean fields
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Tag.Get("config") == "allowempty" {
|
||||
// ignore configured exceptions
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(field.Tag.Get("yaml"), ",")
|
||||
if parts[0] == "-" {
|
||||
// ignore unparsed fields
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(parts, "omitempty") && !slices.Contains(parts, "inline") {
|
||||
errs = multierr.Append(errs, fmt.Errorf("%s/%s.%s missing omitempty tag", t.PkgPath(), t.Name(), field.Name))
|
||||
}
|
||||
|
||||
errs = multierr.Append(errs, checkYAMLTags(field.Type, seen))
|
||||
}
|
||||
return errs
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func CheckYAMLTags(config any) error {
|
||||
return checkYAMLTags(reflect.TypeOf(config), map[reflect.Type]struct{}{})
|
||||
}
|
||||
31
pkg/metric/metric_config.go
Normal file
31
pkg/metric/metric_config.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package metric
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type MetricConfig struct {
|
||||
Timestamper MetricTimestamperConfig `yaml:"timestamper_config,omitempty"`
|
||||
Collector MetricsCollectorConfig `yaml:"collector,omitempty"`
|
||||
Reporter MetricsReporterConfig `yaml:"reporter,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricConfig = MetricConfig{
|
||||
Timestamper: DefaultMetricTimestamperConfig,
|
||||
Collector: DefaultMetricsCollectorConfig,
|
||||
Reporter: DefaultMetricsReporterConfig,
|
||||
}
|
||||
)
|
||||
119
pkg/metric/metric_timestamper.go
Normal file
119
pkg/metric/metric_timestamper.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package metric
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/mediatransportutil/pkg/latency"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type MetricTimestamperConfig struct {
|
||||
OneWayDelayEstimatorMinInterval time.Duration `yaml:"one_way_delay_estimator_min_interval,omitempty"`
|
||||
OneWayDelayEstimatorMaxBatch int `yaml:"one_way_delay_estimator_max_batch,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricTimestamperConfig = MetricTimestamperConfig{
|
||||
OneWayDelayEstimatorMinInterval: 5 * time.Second,
|
||||
OneWayDelayEstimatorMaxBatch: 100,
|
||||
}
|
||||
)
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type MetricTimestamperParams struct {
|
||||
Config MetricTimestamperConfig
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MetricTimestamper struct {
|
||||
params MetricTimestamperParams
|
||||
lock sync.Mutex
|
||||
owdEstimator *latency.OWDEstimator
|
||||
lastOWDEstimatorRunAt time.Time
|
||||
batchesSinceLastOWDEstimatorRun int
|
||||
}
|
||||
|
||||
func NewMetricTimestamper(params MetricTimestamperParams) *MetricTimestamper {
|
||||
return &MetricTimestamper{
|
||||
params: params,
|
||||
owdEstimator: latency.NewOWDEstimator(latency.OWDEstimatorParamsDefault),
|
||||
lastOWDEstimatorRunAt: time.Now().Add(-params.Config.OneWayDelayEstimatorMinInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MetricTimestamper) Process(batch *livekit.MetricsBatch) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// run OWD estimation periodically
|
||||
estimatedOWDNanos := m.maybeRunOWDEstimator(batch)
|
||||
|
||||
// normalize all time stamps and add estimated OWD
|
||||
// NOTE: all timestamps will be re-mapped. If the time series or event happened some time
|
||||
// in the past and the OWD estimation has changed since, those samples will get the updated
|
||||
// OWD estimation applied. So, they may have more uncertainty in addition to the uncertainty
|
||||
// of OWD estimation process.
|
||||
batch.NormalizedTimestamp = timestamppb.New(time.Unix(0, batch.TimestampMs*1e6+estimatedOWDNanos))
|
||||
|
||||
for _, ts := range batch.TimeSeries {
|
||||
for _, sample := range ts.Samples {
|
||||
sample.NormalizedTimestamp = timestamppb.New(time.Unix(0, sample.TimestampMs*1e6+estimatedOWDNanos))
|
||||
}
|
||||
}
|
||||
|
||||
for _, ev := range batch.Events {
|
||||
ev.NormalizedStartTimestamp = timestamppb.New(time.Unix(0, ev.StartTimestampMs*1e6+estimatedOWDNanos))
|
||||
|
||||
endTimestampMs := ev.GetEndTimestampMs()
|
||||
if endTimestampMs != 0 {
|
||||
ev.NormalizedEndTimestamp = timestamppb.New(time.Unix(0, endTimestampMs*1e6+estimatedOWDNanos))
|
||||
}
|
||||
}
|
||||
|
||||
m.params.Logger.Debugw("timestamped metrics batch", "batch", logger.Proto(batch))
|
||||
}
|
||||
|
||||
func (m *MetricTimestamper) maybeRunOWDEstimator(batch *livekit.MetricsBatch) int64 {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if time.Since(m.lastOWDEstimatorRunAt) < m.params.Config.OneWayDelayEstimatorMinInterval &&
|
||||
m.batchesSinceLastOWDEstimatorRun < m.params.Config.OneWayDelayEstimatorMaxBatch {
|
||||
m.batchesSinceLastOWDEstimatorRun++
|
||||
return m.owdEstimator.EstimatedPropagationDelay()
|
||||
}
|
||||
|
||||
senderClockTime := batch.GetTimestampMs()
|
||||
if senderClockTime == 0 {
|
||||
m.batchesSinceLastOWDEstimatorRun++
|
||||
return m.owdEstimator.EstimatedPropagationDelay()
|
||||
}
|
||||
|
||||
m.lastOWDEstimatorRunAt = time.Now()
|
||||
m.batchesSinceLastOWDEstimatorRun = 1
|
||||
|
||||
estimatedOWDNs, _ := m.owdEstimator.Update(senderClockTime*1e6, mono.UnixNano())
|
||||
return estimatedOWDNs
|
||||
}
|
||||
225
pkg/metric/metrics_collector.go
Normal file
225
pkg/metric/metrics_collector.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package metric
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type MetricsCollectorProvider interface {
|
||||
MetricsCollectorTimeToCollectMetrics()
|
||||
MetricsCollectorBatchReady(mb *livekit.MetricsBatch)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsCollectorConfig struct {
|
||||
SamplingIntervalMs uint32 `yaml:"sampling_interval_ms,omitempty" json:"sampling_interval_ms,omitempty"`
|
||||
BatchIntervalMs uint32 `yaml:"batch_interval_ms,omitempty" json:"batch_interval_ms,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricsCollectorConfig = MetricsCollectorConfig{
|
||||
SamplingIntervalMs: 3 * 1000,
|
||||
BatchIntervalMs: 10 * 1000,
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsCollectorParams struct {
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
Config MetricsCollectorConfig
|
||||
Provider MetricsCollectorProvider
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MetricsCollector struct {
|
||||
params MetricsCollectorParams
|
||||
|
||||
lock sync.RWMutex
|
||||
mbb *utils.MetricsBatchBuilder
|
||||
publisherRTTMetricId map[livekit.ParticipantIdentity]int
|
||||
subscriberRTTMetricId int
|
||||
relayRTTMetricId map[livekit.ParticipantIdentity]int
|
||||
|
||||
stop core.Fuse
|
||||
}
|
||||
|
||||
func NewMetricsCollector(params MetricsCollectorParams) *MetricsCollector {
|
||||
mc := &MetricsCollector{
|
||||
params: params,
|
||||
}
|
||||
mc.reset()
|
||||
|
||||
go mc.worker()
|
||||
return mc
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) Stop() {
|
||||
if mc != nil {
|
||||
mc.stop.Break()
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) AddPublisherRTT(participantIdentity livekit.ParticipantIdentity, rtt float32) {
|
||||
mc.lock.Lock()
|
||||
defer mc.lock.Unlock()
|
||||
|
||||
metricId, ok := mc.publisherRTTMetricId[participantIdentity]
|
||||
if !ok {
|
||||
var err error
|
||||
metricId, err = mc.createTimeSeriesMetric(livekit.MetricLabel_PUBLISHER_RTT, participantIdentity)
|
||||
if err != nil {
|
||||
mc.params.Logger.Warnw("could not add time series metric for publisher RTT", err)
|
||||
return
|
||||
}
|
||||
|
||||
mc.publisherRTTMetricId[participantIdentity] = metricId
|
||||
}
|
||||
|
||||
mc.addTimeSeriesMetricSample(metricId, rtt)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) AddSubscriberRTT(rtt float32) {
|
||||
mc.lock.Lock()
|
||||
defer mc.lock.Unlock()
|
||||
|
||||
if mc.subscriberRTTMetricId == utils.MetricsBatchBuilderInvalidTimeSeriesMetricId {
|
||||
var err error
|
||||
mc.subscriberRTTMetricId, err = mc.createTimeSeriesMetric(livekit.MetricLabel_SUBSCRIBER_RTT, mc.params.ParticipantIdentity)
|
||||
if err != nil {
|
||||
mc.params.Logger.Warnw("could not add time series metric for publisher RTT", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mc.addTimeSeriesMetricSample(mc.subscriberRTTMetricId, rtt)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) AddRelayRTT(participantIdentity livekit.ParticipantIdentity, rtt float32) {
|
||||
mc.lock.Lock()
|
||||
defer mc.lock.Unlock()
|
||||
|
||||
metricId, ok := mc.relayRTTMetricId[participantIdentity]
|
||||
if !ok {
|
||||
var err error
|
||||
metricId, err = mc.createTimeSeriesMetric(livekit.MetricLabel_SERVER_MESH_RTT, participantIdentity)
|
||||
if err != nil {
|
||||
mc.params.Logger.Warnw("could not add time series metric for server mesh RTT", err)
|
||||
return
|
||||
}
|
||||
|
||||
mc.relayRTTMetricId[participantIdentity] = metricId
|
||||
}
|
||||
|
||||
mc.addTimeSeriesMetricSample(metricId, rtt)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) getMetricsBatchAndReset() *livekit.MetricsBatch {
|
||||
mc.lock.Lock()
|
||||
mbb := mc.mbb
|
||||
|
||||
mc.reset()
|
||||
mc.lock.Unlock()
|
||||
|
||||
if mbb.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := mono.Now()
|
||||
mbb.SetTime(now, now)
|
||||
return mbb.ToProto()
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) reset() {
|
||||
mc.mbb = utils.NewMetricsBatchBuilder()
|
||||
mc.mbb.SetRestrictedLabels(utils.MetricRestrictedLabels{
|
||||
LabelRanges: []utils.MetricLabelRange{
|
||||
{
|
||||
StartInclusive: livekit.MetricLabel_CLIENT_VIDEO_SUBSCRIBER_FREEZE_COUNT,
|
||||
EndInclusive: livekit.MetricLabel_CLIENT_VIDEO_PUBLISHER_QUALITY_LIMITATION_DURATION_OTHER,
|
||||
},
|
||||
},
|
||||
ParticipantIdentity: mc.params.ParticipantIdentity,
|
||||
})
|
||||
|
||||
mc.publisherRTTMetricId = make(map[livekit.ParticipantIdentity]int)
|
||||
mc.subscriberRTTMetricId = utils.MetricsBatchBuilderInvalidTimeSeriesMetricId
|
||||
mc.relayRTTMetricId = make(map[livekit.ParticipantIdentity]int)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) createTimeSeriesMetric(
|
||||
label livekit.MetricLabel,
|
||||
participantIdentity livekit.ParticipantIdentity,
|
||||
) (int, error) {
|
||||
return mc.mbb.AddTimeSeriesMetric(utils.TimeSeriesMetric{
|
||||
MetricLabel: label,
|
||||
ParticipantIdentity: participantIdentity,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) addTimeSeriesMetricSample(metricId int, value float32) {
|
||||
now := mono.Now()
|
||||
if err := mc.mbb.AddMetricSamplesToTimeSeriesMetric(metricId, []utils.MetricSample{
|
||||
{
|
||||
At: now,
|
||||
NormalizedAt: now,
|
||||
Value: value,
|
||||
},
|
||||
}); err != nil {
|
||||
mc.params.Logger.Warnw("could not add metric sample", err, "metricId", metricId)
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) worker() {
|
||||
samplingIntervalMs := mc.params.Config.SamplingIntervalMs
|
||||
if samplingIntervalMs == 0 {
|
||||
samplingIntervalMs = DefaultMetricsCollectorConfig.SamplingIntervalMs
|
||||
}
|
||||
samplingTicker := time.NewTicker(time.Duration(samplingIntervalMs) * time.Millisecond)
|
||||
defer samplingTicker.Stop()
|
||||
|
||||
batchIntervalMs := mc.params.Config.BatchIntervalMs
|
||||
if batchIntervalMs < samplingIntervalMs {
|
||||
batchIntervalMs = samplingIntervalMs
|
||||
}
|
||||
batchTicker := time.NewTicker(time.Duration(batchIntervalMs) * time.Millisecond)
|
||||
defer batchTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-samplingTicker.C:
|
||||
mc.params.Provider.MetricsCollectorTimeToCollectMetrics()
|
||||
|
||||
case <-batchTicker.C:
|
||||
if mb := mc.getMetricsBatchAndReset(); mb != nil {
|
||||
mc.params.Provider.MetricsCollectorBatchReady(mb)
|
||||
}
|
||||
|
||||
case <-mc.stop.Watch():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
138
pkg/metric/metrics_reporter.go
Normal file
138
pkg/metric/metrics_reporter.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package metric
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type MetricsReporterConsumer interface {
|
||||
MetricsReporterBatchReady(mb *livekit.MetricsBatch)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsReporterConfig struct {
|
||||
ReportingIntervalMs uint32 `yaml:"reporting_interval_ms,omitempty" json:"reporting_interval_ms,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricsReporterConfig = MetricsReporterConfig{
|
||||
ReportingIntervalMs: 10 * 1000,
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsReporterParams struct {
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
Config MetricsReporterConfig
|
||||
Consumer MetricsReporterConsumer
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MetricsReporter struct {
|
||||
params MetricsReporterParams
|
||||
|
||||
lock sync.RWMutex
|
||||
mbb *utils.MetricsBatchBuilder
|
||||
|
||||
stop core.Fuse
|
||||
}
|
||||
|
||||
func NewMetricsReporter(params MetricsReporterParams) *MetricsReporter {
|
||||
mr := &MetricsReporter{
|
||||
params: params,
|
||||
}
|
||||
mr.reset()
|
||||
|
||||
go mr.worker()
|
||||
return mr
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) Stop() {
|
||||
if mr != nil {
|
||||
mr.stop.Break()
|
||||
}
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) Merge(other *livekit.MetricsBatch) {
|
||||
if mr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mr.lock.Lock()
|
||||
defer mr.lock.Unlock()
|
||||
|
||||
mr.mbb.Merge(other)
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) getMetricsBatchAndReset() *livekit.MetricsBatch {
|
||||
mr.lock.Lock()
|
||||
mbb := mr.mbb
|
||||
|
||||
mr.reset()
|
||||
mr.lock.Unlock()
|
||||
|
||||
if mbb.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := mono.Now()
|
||||
mbb.SetTime(now, now)
|
||||
return mbb.ToProto()
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) reset() {
|
||||
mr.mbb = utils.NewMetricsBatchBuilder()
|
||||
mr.mbb.SetRestrictedLabels(utils.MetricRestrictedLabels{
|
||||
LabelRanges: []utils.MetricLabelRange{
|
||||
{
|
||||
StartInclusive: livekit.MetricLabel_CLIENT_VIDEO_SUBSCRIBER_FREEZE_COUNT,
|
||||
EndInclusive: livekit.MetricLabel_CLIENT_VIDEO_PUBLISHER_QUALITY_LIMITATION_DURATION_OTHER,
|
||||
},
|
||||
},
|
||||
ParticipantIdentity: mr.params.ParticipantIdentity,
|
||||
})
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) worker() {
|
||||
reportingIntervalMs := mr.params.Config.ReportingIntervalMs
|
||||
if reportingIntervalMs == 0 {
|
||||
reportingIntervalMs = DefaultMetricsReporterConfig.ReportingIntervalMs
|
||||
}
|
||||
reportingTicker := time.NewTicker(time.Duration(reportingIntervalMs) * time.Millisecond)
|
||||
defer reportingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-reportingTicker.C:
|
||||
if mb := mr.getMetricsBatchAndReset(); mb != nil {
|
||||
mr.params.Consumer.MetricsReporterBatchReady(mb)
|
||||
}
|
||||
|
||||
case <-mr.stop.Watch():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
36
pkg/routing/errors.go
Normal file
36
pkg/routing/errors.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("could not find object")
|
||||
ErrIPNotSet = errors.New("ip address is required and not set")
|
||||
ErrHandlerNotDefined = errors.New("handler not defined")
|
||||
ErrIncorrectRTCNode = errors.New("current node isn't the RTC node for the room")
|
||||
ErrNodeNotFound = errors.New("could not locate the node")
|
||||
ErrNodeLimitReached = errors.New("reached configured limit for node")
|
||||
ErrInvalidRouterMessage = errors.New("invalid router message")
|
||||
ErrChannelClosed = errors.New("channel closed")
|
||||
ErrChannelFull = errors.New("channel is full")
|
||||
|
||||
// errors when starting signal connection
|
||||
ErrRequestChannelClosed = errors.New("request channel closed")
|
||||
ErrCouldNotMigrateParticipant = errors.New("could not migrate participant")
|
||||
ErrClientInfoNotSet = errors.New("client info not set")
|
||||
)
|
||||
334
pkg/routing/interfaces.go
Normal file
334
pkg/routing/interfaces.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/atomic"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
)
|
||||
|
||||
//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
|
||||
|
||||
// MessageSink is an abstraction for writing protobuf messages and having them read by a MessageSource,
|
||||
// potentially on a different node via a transport
|
||||
//
|
||||
//counterfeiter:generate . MessageSink
|
||||
type MessageSink interface {
|
||||
WriteMessage(msg proto.Message) error
|
||||
IsClosed() bool
|
||||
Close()
|
||||
ConnectionID() livekit.ConnectionID
|
||||
}
|
||||
|
||||
// ----------
|
||||
|
||||
type NullMessageSink struct {
|
||||
connID livekit.ConnectionID
|
||||
isClosed atomic.Bool
|
||||
}
|
||||
|
||||
func NewNullMessageSink(connID livekit.ConnectionID) *NullMessageSink {
|
||||
return &NullMessageSink{
|
||||
connID: connID,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) WriteMessage(_msg proto.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) IsClosed() bool {
|
||||
return n.isClosed.Load()
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) Close() {
|
||||
n.isClosed.Store(true)
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) ConnectionID() livekit.ConnectionID {
|
||||
return n.connID
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
//counterfeiter:generate . MessageSource
|
||||
type MessageSource interface {
|
||||
// ReadChan exposes a one way channel to make it easier to use with select
|
||||
ReadChan() <-chan proto.Message
|
||||
IsClosed() bool
|
||||
Close()
|
||||
ConnectionID() livekit.ConnectionID
|
||||
}
|
||||
|
||||
// ----------
|
||||
|
||||
type NullMessageSource struct {
|
||||
connID livekit.ConnectionID
|
||||
msgChan chan proto.Message
|
||||
isClosed atomic.Bool
|
||||
}
|
||||
|
||||
func NewNullMessageSource(connID livekit.ConnectionID) *NullMessageSource {
|
||||
return &NullMessageSource{
|
||||
connID: connID,
|
||||
msgChan: make(chan proto.Message),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) ReadChan() <-chan proto.Message {
|
||||
return n.msgChan
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) IsClosed() bool {
|
||||
return n.isClosed.Load()
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) Close() {
|
||||
if !n.isClosed.Swap(true) {
|
||||
close(n.msgChan)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) ConnectionID() livekit.ConnectionID {
|
||||
return n.connID
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
// Router allows multiple nodes to coordinate the participant session
|
||||
//
|
||||
//counterfeiter:generate . Router
|
||||
type Router interface {
|
||||
MessageRouter
|
||||
|
||||
RegisterNode() error
|
||||
UnregisterNode() error
|
||||
RemoveDeadNodes() error
|
||||
|
||||
ListNodes() ([]*livekit.Node, error)
|
||||
|
||||
GetNodeForRoom(ctx context.Context, roomName livekit.RoomName) (*livekit.Node, error)
|
||||
SetNodeForRoom(ctx context.Context, roomName livekit.RoomName, nodeId livekit.NodeID) error
|
||||
ClearRoomState(ctx context.Context, roomName livekit.RoomName) error
|
||||
|
||||
GetRegion() string
|
||||
|
||||
Start() error
|
||||
Drain()
|
||||
Stop()
|
||||
}
|
||||
|
||||
type StartParticipantSignalResults struct {
|
||||
ConnectionID livekit.ConnectionID
|
||||
RequestSink MessageSink
|
||||
ResponseSource MessageSource
|
||||
NodeID livekit.NodeID
|
||||
NodeSelectionReason string
|
||||
}
|
||||
|
||||
type MessageRouter interface {
|
||||
// CreateRoom starts an rtc room
|
||||
CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (res *livekit.Room, err error)
|
||||
|
||||
// StartParticipantSignal participant signal connection is ready to start
|
||||
StartParticipantSignal(
|
||||
ctx context.Context,
|
||||
roomName livekit.RoomName,
|
||||
pi ParticipantInit,
|
||||
) (res StartParticipantSignalResults, err error)
|
||||
}
|
||||
|
||||
func CreateRouter(
|
||||
rc redis.UniversalClient,
|
||||
node LocalNode,
|
||||
signalClient SignalClient,
|
||||
roomManagerClient RoomManagerClient,
|
||||
kps rpc.KeepalivePubSub,
|
||||
nodeStatsConfig config.NodeStatsConfig,
|
||||
) Router {
|
||||
lr := NewLocalRouter(node, signalClient, roomManagerClient, nodeStatsConfig)
|
||||
|
||||
if rc != nil {
|
||||
return NewRedisRouter(lr, rc, kps)
|
||||
}
|
||||
|
||||
// local routing and store
|
||||
logger.Infow("using single-node routing")
|
||||
return lr
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type ParticipantInit struct {
|
||||
Identity livekit.ParticipantIdentity
|
||||
Name livekit.ParticipantName
|
||||
Reconnect bool
|
||||
ReconnectReason livekit.ReconnectReason
|
||||
AutoSubscribe bool
|
||||
AutoSubscribeDataTrack *bool
|
||||
Client *livekit.ClientInfo
|
||||
Grants *auth.ClaimGrants
|
||||
TokenExpiresAt time.Time
|
||||
Region string
|
||||
AdaptiveStream bool
|
||||
ID livekit.ParticipantID
|
||||
SubscriberAllowPause *bool
|
||||
DisableICELite bool
|
||||
CreateRoom *livekit.CreateRoomRequest
|
||||
AddTrackRequests []*livekit.AddTrackRequest
|
||||
PublisherOffer *livekit.SessionDescription
|
||||
SyncState *livekit.SyncState
|
||||
UseSinglePeerConnection bool
|
||||
}
|
||||
|
||||
func (pi *ParticipantInit) MarshalLogObject(e zapcore.ObjectEncoder) error {
|
||||
if pi == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logBoolPtr := func(prop string, val *bool) {
|
||||
if val == nil {
|
||||
e.AddString(prop, "not-set")
|
||||
} else {
|
||||
e.AddBool(prop, *val)
|
||||
}
|
||||
}
|
||||
|
||||
e.AddString("Identity", string(pi.Identity))
|
||||
logBoolPtr("Reconnect", &pi.Reconnect)
|
||||
e.AddString("ReconnectReason", pi.ReconnectReason.String())
|
||||
logBoolPtr("AutoSubscribe", &pi.AutoSubscribe)
|
||||
logBoolPtr("AutoSubscribeDataTrack", pi.AutoSubscribeDataTrack)
|
||||
e.AddObject("Client", logger.Proto(utils.ClientInfoWithoutAddress(pi.Client)))
|
||||
e.AddObject("Grants", pi.Grants)
|
||||
if !pi.TokenExpiresAt.IsZero() {
|
||||
e.AddTime("TokenExpiresAt", pi.TokenExpiresAt)
|
||||
}
|
||||
e.AddString("Region", pi.Region)
|
||||
logBoolPtr("AdaptiveStream", &pi.AdaptiveStream)
|
||||
e.AddString("ID", string(pi.ID))
|
||||
logBoolPtr("SubscriberAllowPause", pi.SubscriberAllowPause)
|
||||
logBoolPtr("DisableICELite", &pi.DisableICELite)
|
||||
e.AddObject("CreateRoom", logger.Proto(pi.CreateRoom))
|
||||
e.AddArray("AddTrackRequests", logger.ProtoSlice(pi.AddTrackRequests))
|
||||
e.AddObject("PublisherOffer", logger.Proto(pi.PublisherOffer))
|
||||
e.AddObject("SyncState", logger.Proto(pi.SyncState))
|
||||
logBoolPtr("UseSinglePeerConnection", &pi.UseSinglePeerConnection)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pi *ParticipantInit) ToStartSession(roomName livekit.RoomName, connectionID livekit.ConnectionID) (*livekit.StartSession, error) {
|
||||
claims, err := json.Marshal(pi.Grants)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tokenExpiresAt int64
|
||||
if !pi.TokenExpiresAt.IsZero() {
|
||||
tokenExpiresAt = pi.TokenExpiresAt.Unix()
|
||||
}
|
||||
|
||||
ss := &livekit.StartSession{
|
||||
RoomName: string(roomName),
|
||||
Identity: string(pi.Identity),
|
||||
Name: string(pi.Name),
|
||||
ConnectionId: string(connectionID),
|
||||
Reconnect: pi.Reconnect,
|
||||
ReconnectReason: pi.ReconnectReason,
|
||||
AutoSubscribe: pi.AutoSubscribe,
|
||||
Client: pi.Client,
|
||||
GrantsJson: string(claims),
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
AdaptiveStream: pi.AdaptiveStream,
|
||||
ParticipantId: string(pi.ID),
|
||||
DisableIceLite: pi.DisableICELite,
|
||||
CreateRoom: pi.CreateRoom,
|
||||
AddTrackRequests: pi.AddTrackRequests,
|
||||
PublisherOffer: pi.PublisherOffer,
|
||||
SyncState: pi.SyncState,
|
||||
UseSinglePeerConnection: pi.UseSinglePeerConnection,
|
||||
}
|
||||
if pi.AutoSubscribeDataTrack != nil {
|
||||
autoSubscribeDataTrack := *pi.AutoSubscribeDataTrack
|
||||
ss.AutoSubscribeDataTrack = &autoSubscribeDataTrack
|
||||
}
|
||||
if pi.SubscriberAllowPause != nil {
|
||||
subscriberAllowPause := *pi.SubscriberAllowPause
|
||||
ss.SubscriberAllowPause = &subscriberAllowPause
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
func ParticipantInitFromStartSession(ss *livekit.StartSession, region string) (*ParticipantInit, error) {
|
||||
claims := &auth.ClaimGrants{}
|
||||
if err := json.Unmarshal([]byte(ss.GrantsJson), claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tokenExpiresAt time.Time
|
||||
if ss.TokenExpiresAt > 0 {
|
||||
tokenExpiresAt = time.Unix(ss.TokenExpiresAt, 0)
|
||||
}
|
||||
|
||||
pi := &ParticipantInit{
|
||||
Identity: livekit.ParticipantIdentity(ss.Identity),
|
||||
Name: livekit.ParticipantName(ss.Name),
|
||||
Reconnect: ss.Reconnect,
|
||||
ReconnectReason: ss.ReconnectReason,
|
||||
Client: ss.Client,
|
||||
AutoSubscribe: ss.AutoSubscribe,
|
||||
Grants: claims,
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
Region: region,
|
||||
AdaptiveStream: ss.AdaptiveStream,
|
||||
ID: livekit.ParticipantID(ss.ParticipantId),
|
||||
DisableICELite: ss.DisableIceLite,
|
||||
CreateRoom: ss.CreateRoom,
|
||||
AddTrackRequests: ss.AddTrackRequests,
|
||||
PublisherOffer: ss.PublisherOffer,
|
||||
SyncState: ss.SyncState,
|
||||
UseSinglePeerConnection: ss.UseSinglePeerConnection,
|
||||
}
|
||||
if ss.AutoSubscribeDataTrack != nil {
|
||||
autoSubscribeDataTrack := *ss.AutoSubscribeDataTrack
|
||||
pi.AutoSubscribeDataTrack = &autoSubscribeDataTrack
|
||||
}
|
||||
if ss.SubscriberAllowPause != nil {
|
||||
subscriberAllowPause := *ss.SubscriberAllowPause
|
||||
pi.SubscriberAllowPause = &subscriberAllowPause
|
||||
}
|
||||
|
||||
// TODO: clean up after 1.7 eol
|
||||
if pi.CreateRoom == nil {
|
||||
pi.CreateRoom = &livekit.CreateRoomRequest{
|
||||
Name: ss.RoomName,
|
||||
}
|
||||
}
|
||||
|
||||
return pi, nil
|
||||
}
|
||||
173
pkg/routing/localrouter.go
Normal file
173
pkg/routing/localrouter.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ Router = (*LocalRouter)(nil)
|
||||
|
||||
// a router of messages on the same node, basic implementation for local testing
|
||||
type LocalRouter struct {
|
||||
currentNode LocalNode
|
||||
signalClient SignalClient
|
||||
roomManagerClient RoomManagerClient
|
||||
nodeStatsConfig config.NodeStatsConfig
|
||||
|
||||
// channels for each participant
|
||||
requestChannels map[string]*MessageChannel
|
||||
responseChannels map[string]*MessageChannel
|
||||
isStarted atomic.Bool
|
||||
}
|
||||
|
||||
func NewLocalRouter(
|
||||
currentNode LocalNode,
|
||||
signalClient SignalClient,
|
||||
roomManagerClient RoomManagerClient,
|
||||
nodeStatsConfig config.NodeStatsConfig,
|
||||
) *LocalRouter {
|
||||
return &LocalRouter{
|
||||
currentNode: currentNode,
|
||||
signalClient: signalClient,
|
||||
roomManagerClient: roomManagerClient,
|
||||
nodeStatsConfig: nodeStatsConfig,
|
||||
requestChannels: make(map[string]*MessageChannel),
|
||||
responseChannels: make(map[string]*MessageChannel),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LocalRouter) GetNodeForRoom(_ context.Context, _ livekit.RoomName) (*livekit.Node, error) {
|
||||
return r.currentNode.Clone(), nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) SetNodeForRoom(_ context.Context, _ livekit.RoomName, _ livekit.NodeID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) ClearRoomState(_ context.Context, _ livekit.RoomName) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) RegisterNode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) UnregisterNode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) RemoveDeadNodes() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) GetNode(nodeID livekit.NodeID) (*livekit.Node, error) {
|
||||
if nodeID == r.currentNode.NodeID() {
|
||||
return r.currentNode.Clone(), nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (r *LocalRouter) ListNodes() ([]*livekit.Node, error) {
|
||||
return []*livekit.Node{
|
||||
r.currentNode.Clone(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (res *livekit.Room, err error) {
|
||||
return r.CreateRoomWithNodeID(ctx, req, r.currentNode.NodeID())
|
||||
}
|
||||
|
||||
func (r *LocalRouter) CreateRoomWithNodeID(ctx context.Context, req *livekit.CreateRoomRequest, nodeID livekit.NodeID) (res *livekit.Room, err error) {
|
||||
return r.roomManagerClient.CreateRoom(ctx, nodeID, req)
|
||||
}
|
||||
|
||||
func (r *LocalRouter) StartParticipantSignal(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit) (res StartParticipantSignalResults, err error) {
|
||||
return r.StartParticipantSignalWithNodeID(ctx, roomName, pi, r.currentNode.NodeID())
|
||||
}
|
||||
|
||||
func (r *LocalRouter) StartParticipantSignalWithNodeID(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit, nodeID livekit.NodeID) (res StartParticipantSignalResults, err error) {
|
||||
connectionID, reqSink, resSource, err := r.signalClient.StartParticipantSignal(ctx, roomName, pi, nodeID)
|
||||
if err != nil {
|
||||
logger.Errorw(
|
||||
"could not handle new participant", err,
|
||||
"room", roomName,
|
||||
"participant", pi.Identity,
|
||||
"connID", connectionID,
|
||||
)
|
||||
} else {
|
||||
return StartParticipantSignalResults{
|
||||
ConnectionID: connectionID,
|
||||
RequestSink: reqSink,
|
||||
ResponseSource: resSource,
|
||||
NodeID: nodeID,
|
||||
}, nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *LocalRouter) Start() error {
|
||||
if r.isStarted.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
go r.statsWorker()
|
||||
// go r.memStatsWorker()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) Drain() {
|
||||
r.currentNode.SetState(livekit.NodeState_SHUTTING_DOWN)
|
||||
}
|
||||
|
||||
func (r *LocalRouter) Stop() {}
|
||||
|
||||
func (r *LocalRouter) GetRegion() string {
|
||||
return r.currentNode.Region()
|
||||
}
|
||||
|
||||
func (r *LocalRouter) statsWorker() {
|
||||
for {
|
||||
if !r.isStarted.Load() {
|
||||
return
|
||||
}
|
||||
<-time.After(r.nodeStatsConfig.StatsUpdateInterval)
|
||||
r.currentNode.UpdateNodeStats()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
func (r *LocalRouter) memStatsWorker() {
|
||||
ticker := time.NewTicker(time.Second * 30)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
<-ticker.C
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
logger.Infow("memstats",
|
||||
"mallocs", m.Mallocs, "frees", m.Frees, "m-f", m.Mallocs-m.Frees,
|
||||
"hinuse", m.HeapInuse, "halloc", m.HeapAlloc, "frag", m.HeapInuse-m.HeapAlloc,
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
94
pkg/routing/messagechannel.go
Normal file
94
pkg/routing/messagechannel.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const DefaultMessageChannelSize = 200
|
||||
|
||||
type MessageChannel struct {
|
||||
connectionID livekit.ConnectionID
|
||||
msgChan chan proto.Message
|
||||
onClose func()
|
||||
isClosed bool
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func NewDefaultMessageChannel(connectionID livekit.ConnectionID) *MessageChannel {
|
||||
return NewMessageChannel(connectionID, DefaultMessageChannelSize)
|
||||
}
|
||||
|
||||
func NewMessageChannel(connectionID livekit.ConnectionID, size int) *MessageChannel {
|
||||
return &MessageChannel{
|
||||
connectionID: connectionID,
|
||||
// allow some buffer to avoid blocked writes
|
||||
msgChan: make(chan proto.Message, size),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MessageChannel) OnClose(f func()) {
|
||||
m.onClose = f
|
||||
}
|
||||
|
||||
func (m *MessageChannel) IsClosed() bool {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
return m.isClosed
|
||||
}
|
||||
|
||||
func (m *MessageChannel) WriteMessage(msg proto.Message) error {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
if m.isClosed {
|
||||
return ErrChannelClosed
|
||||
}
|
||||
|
||||
select {
|
||||
case m.msgChan <- msg:
|
||||
// published
|
||||
return nil
|
||||
default:
|
||||
// channel is full
|
||||
return ErrChannelFull
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MessageChannel) ReadChan() <-chan proto.Message {
|
||||
return m.msgChan
|
||||
}
|
||||
|
||||
func (m *MessageChannel) Close() {
|
||||
m.lock.Lock()
|
||||
if m.isClosed {
|
||||
m.lock.Unlock()
|
||||
return
|
||||
}
|
||||
m.isClosed = true
|
||||
close(m.msgChan)
|
||||
m.lock.Unlock()
|
||||
|
||||
if m.onClose != nil {
|
||||
m.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MessageChannel) ConnectionID() livekit.ConnectionID {
|
||||
return m.connectionID
|
||||
}
|
||||
50
pkg/routing/messagechannel_test.go
Normal file
50
pkg/routing/messagechannel_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
)
|
||||
|
||||
func TestMessageChannel_WriteMessageClosed(t *testing.T) {
|
||||
// ensure it doesn't panic when written to after closing
|
||||
m := routing.NewMessageChannel(livekit.ConnectionID("test"), routing.DefaultMessageChannelSize)
|
||||
go func() {
|
||||
for msg := range m.ReadChan() {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
_ = m.WriteMessage(&livekit.SignalRequest{})
|
||||
}
|
||||
}()
|
||||
_ = m.WriteMessage(&livekit.SignalRequest{})
|
||||
m.Close()
|
||||
_ = m.WriteMessage(&livekit.SignalRequest{})
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
158
pkg/routing/node.go
Normal file
158
pkg/routing/node.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
type LocalNode interface {
|
||||
Clone() *livekit.Node
|
||||
SetNodeID(nodeID livekit.NodeID)
|
||||
NodeID() livekit.NodeID
|
||||
NodeType() livekit.NodeType
|
||||
NodeIP() string
|
||||
Region() string
|
||||
SetState(state livekit.NodeState)
|
||||
SetStats(stats *livekit.NodeStats)
|
||||
UpdateNodeStats() bool
|
||||
SecondsSinceNodeStatsUpdate() float64
|
||||
}
|
||||
|
||||
type LocalNodeImpl struct {
|
||||
lock sync.RWMutex
|
||||
node *livekit.Node
|
||||
|
||||
nodeStats *NodeStats
|
||||
}
|
||||
|
||||
func NewLocalNode(conf *config.Config) (*LocalNodeImpl, error) {
|
||||
nodeID := guid.New(utils.NodePrefix)
|
||||
if conf != nil && conf.RTC.NodeIP.IsEmpty() {
|
||||
return nil, ErrIPNotSet
|
||||
}
|
||||
nowUnix := time.Now().Unix()
|
||||
l := &LocalNodeImpl{
|
||||
node: &livekit.Node{
|
||||
Id: nodeID,
|
||||
NumCpus: uint32(runtime.NumCPU()),
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
StartedAt: nowUnix,
|
||||
UpdatedAt: nowUnix,
|
||||
},
|
||||
},
|
||||
}
|
||||
var nsc *config.NodeStatsConfig
|
||||
if conf != nil {
|
||||
l.node.Ip = conf.RTC.NodeIP.PrimaryIP()
|
||||
l.node.Region = conf.Region
|
||||
|
||||
nsc = &conf.NodeStats
|
||||
}
|
||||
l.nodeStats = NewNodeStats(nsc, nowUnix)
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func NewLocalNodeFromNodeProto(node *livekit.Node) (*LocalNodeImpl, error) {
|
||||
return &LocalNodeImpl{node: utils.CloneProto(node)}, nil
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) Clone() *livekit.Node {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return utils.CloneProto(l.node)
|
||||
}
|
||||
|
||||
// for testing only
|
||||
func (l *LocalNodeImpl) SetNodeID(nodeID livekit.NodeID) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.node.Id = string(nodeID)
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) NodeID() livekit.NodeID {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return livekit.NodeID(l.node.Id)
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) NodeType() livekit.NodeType {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return l.node.Type
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) NodeIP() string {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return l.node.Ip
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) Region() string {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return l.node.Region
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) SetState(state livekit.NodeState) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.node.State = state
|
||||
}
|
||||
|
||||
// for testing only
|
||||
func (l *LocalNodeImpl) SetStats(stats *livekit.NodeStats) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.node.Stats = utils.CloneProto(stats)
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) UpdateNodeStats() bool {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
stats, err := l.nodeStats.UpdateAndGetNodeStats()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
l.node.Stats = stats
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) SecondsSinceNodeStatsUpdate() float64 {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return time.Since(time.Unix(l.node.Stats.UpdatedAt, 0)).Seconds()
|
||||
}
|
||||
91
pkg/routing/nodestats.go
Normal file
91
pkg/routing/nodestats.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
)
|
||||
|
||||
type NodeStats struct {
|
||||
config config.NodeStatsConfig
|
||||
startedAt int64
|
||||
|
||||
lock sync.RWMutex
|
||||
statsHistory []*livekit.NodeStats
|
||||
statsHistoryWritePtr int
|
||||
}
|
||||
|
||||
func NewNodeStats(conf *config.NodeStatsConfig, startedAt int64) *NodeStats {
|
||||
n := &NodeStats{
|
||||
startedAt: startedAt,
|
||||
}
|
||||
n.UpdateConfig(conf)
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *NodeStats) UpdateConfig(conf *config.NodeStatsConfig) {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
|
||||
if conf == nil {
|
||||
conf = &config.DefaultNodeStatsConfig
|
||||
}
|
||||
n.config = *conf
|
||||
|
||||
// set up stats history to be able to measure different rate windows
|
||||
var maxInterval time.Duration
|
||||
for _, rateInterval := range conf.StatsRateMeasurementIntervals {
|
||||
if rateInterval > maxInterval {
|
||||
maxInterval = rateInterval
|
||||
}
|
||||
}
|
||||
n.statsHistory = make([]*livekit.NodeStats, (maxInterval+conf.StatsUpdateInterval-1)/conf.StatsUpdateInterval)
|
||||
n.statsHistoryWritePtr = 0
|
||||
}
|
||||
|
||||
func (n *NodeStats) UpdateAndGetNodeStats() (*livekit.NodeStats, error) {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
|
||||
stats, err := prometheus.GetNodeStats(
|
||||
n.startedAt,
|
||||
append(n.statsHistory[n.statsHistoryWritePtr:], n.statsHistory[0:n.statsHistoryWritePtr]...),
|
||||
n.config.StatsRateMeasurementIntervals,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Errorw("could not update node stats", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.statsHistory[n.statsHistoryWritePtr] = stats
|
||||
n.statsHistoryWritePtr = (n.statsHistoryWritePtr + 1) % len(n.statsHistory)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (n *NodeStats) GetLatestNodeStats() *livekit.NodeStats {
|
||||
n.lock.RLock()
|
||||
defer n.lock.RUnlock()
|
||||
|
||||
latestPtr := (n.statsHistoryWritePtr - 1 + len(n.statsHistory)) % len(n.statsHistory)
|
||||
return utils.CloneProto(n.statsHistory[latestPtr])
|
||||
}
|
||||
252
pkg/routing/redisrouter.go
Normal file
252
pkg/routing/redisrouter.go
Normal file
@@ -0,0 +1,252 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"runtime/pprof"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
const (
|
||||
// hash of node_id => Node proto
|
||||
NodesKey = "nodes"
|
||||
|
||||
// hash of room_name => node_id
|
||||
NodeRoomKey = "room_node_map"
|
||||
)
|
||||
|
||||
var _ Router = (*RedisRouter)(nil)
|
||||
|
||||
// RedisRouter uses Redis pub/sub to route signaling messages across different nodes
|
||||
// It relies on the RTC node to be the primary driver of the participant connection.
|
||||
// Because
|
||||
type RedisRouter struct {
|
||||
*LocalRouter
|
||||
|
||||
rc redis.UniversalClient
|
||||
kps rpc.KeepalivePubSub
|
||||
ctx context.Context
|
||||
isStarted atomic.Bool
|
||||
|
||||
cancel func()
|
||||
}
|
||||
|
||||
func NewRedisRouter(lr *LocalRouter, rc redis.UniversalClient, kps rpc.KeepalivePubSub) *RedisRouter {
|
||||
rr := &RedisRouter{
|
||||
LocalRouter: lr,
|
||||
rc: rc,
|
||||
kps: kps,
|
||||
}
|
||||
rr.ctx, rr.cancel = context.WithCancel(context.Background())
|
||||
return rr
|
||||
}
|
||||
|
||||
func (r *RedisRouter) RegisterNode() error {
|
||||
data, err := proto.Marshal(r.currentNode.Clone())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.rc.HSet(r.ctx, NodesKey, string(r.currentNode.NodeID()), data).Err(); err != nil {
|
||||
return errors.Wrap(err, "could not register node")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) UnregisterNode() error {
|
||||
// could be called after Stop(), so we'd want to use an unrelated context
|
||||
return r.rc.HDel(context.Background(), NodesKey, string(r.currentNode.NodeID())).Err()
|
||||
}
|
||||
|
||||
func (r *RedisRouter) RemoveDeadNodes() error {
|
||||
nodes, err := r.ListNodes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if !selector.IsAvailable(n) {
|
||||
if err := r.rc.HDel(context.Background(), NodesKey, n.Id).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNodeForRoom finds the node where the room is hosted at
|
||||
func (r *RedisRouter) GetNodeForRoom(_ context.Context, roomName livekit.RoomName) (*livekit.Node, error) {
|
||||
nodeID, err := r.rc.HGet(r.ctx, NodeRoomKey, string(roomName)).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get node for room")
|
||||
}
|
||||
|
||||
return r.GetNode(livekit.NodeID(nodeID))
|
||||
}
|
||||
|
||||
func (r *RedisRouter) SetNodeForRoom(_ context.Context, roomName livekit.RoomName, nodeID livekit.NodeID) error {
|
||||
return r.rc.HSet(r.ctx, NodeRoomKey, string(roomName), string(nodeID)).Err()
|
||||
}
|
||||
|
||||
func (r *RedisRouter) ClearRoomState(_ context.Context, roomName livekit.RoomName) error {
|
||||
if err := r.rc.HDel(context.Background(), NodeRoomKey, string(roomName)).Err(); err != nil {
|
||||
return errors.Wrap(err, "could not clear room state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) GetNode(nodeID livekit.NodeID) (*livekit.Node, error) {
|
||||
data, err := r.rc.HGet(r.ctx, NodesKey, string(nodeID)).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := livekit.Node{}
|
||||
if err = proto.Unmarshal([]byte(data), &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) ListNodes() ([]*livekit.Node, error) {
|
||||
items, err := r.rc.HVals(r.ctx, NodesKey).Result()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not list nodes")
|
||||
}
|
||||
nodes := make([]*livekit.Node, 0, len(items))
|
||||
for _, item := range items {
|
||||
n := livekit.Node{}
|
||||
if err := proto.Unmarshal([]byte(item), &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, &n)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (res *livekit.Room, err error) {
|
||||
rtcNode, err := r.GetNodeForRoom(ctx, livekit.RoomName(req.Name))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return r.CreateRoomWithNodeID(ctx, req, livekit.NodeID(rtcNode.Id))
|
||||
}
|
||||
|
||||
// StartParticipantSignal signal connection sets up paths to the RTC node, and starts to route messages to that message queue
|
||||
func (r *RedisRouter) StartParticipantSignal(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit) (res StartParticipantSignalResults, err error) {
|
||||
rtcNode, err := r.GetNodeForRoom(ctx, roomName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return r.StartParticipantSignalWithNodeID(ctx, roomName, pi, livekit.NodeID(rtcNode.Id))
|
||||
}
|
||||
|
||||
func (r *RedisRouter) Start() error {
|
||||
if r.isStarted.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
|
||||
workerStarted := make(chan error)
|
||||
go r.statsWorker()
|
||||
go r.keepaliveWorker(workerStarted)
|
||||
|
||||
// wait until worker is running
|
||||
return <-workerStarted
|
||||
}
|
||||
|
||||
func (r *RedisRouter) Drain() {
|
||||
r.currentNode.SetState(livekit.NodeState_SHUTTING_DOWN)
|
||||
if err := r.RegisterNode(); err != nil {
|
||||
logger.Errorw("failed to mark as draining", err, "nodeID", r.currentNode.NodeID())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RedisRouter) Stop() {
|
||||
if !r.isStarted.Swap(false) {
|
||||
return
|
||||
}
|
||||
logger.Debugw("stopping RedisRouter")
|
||||
_ = r.UnregisterNode()
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
// update node stats and cleanup
|
||||
func (r *RedisRouter) statsWorker() {
|
||||
goroutineDumped := false
|
||||
for r.ctx.Err() == nil {
|
||||
// update periodically
|
||||
select {
|
||||
case <-time.After(r.nodeStatsConfig.StatsUpdateInterval):
|
||||
r.kps.PublishPing(r.ctx, r.currentNode.NodeID(), &rpc.KeepalivePing{Timestamp: time.Now().Unix()})
|
||||
|
||||
delaySeconds := r.currentNode.SecondsSinceNodeStatsUpdate()
|
||||
if delaySeconds > r.nodeStatsConfig.StatsMaxDelay.Seconds() {
|
||||
if !goroutineDumped {
|
||||
goroutineDumped = true
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = pprof.Lookup("goroutine").WriteTo(buf, 2)
|
||||
logger.Errorw("status update delayed, possible deadlock", nil,
|
||||
"delay", delaySeconds,
|
||||
"goroutines", buf.String())
|
||||
}
|
||||
} else {
|
||||
goroutineDumped = false
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RedisRouter) keepaliveWorker(startedChan chan error) {
|
||||
pings, err := r.kps.SubscribePing(r.ctx, r.currentNode.NodeID())
|
||||
if err != nil {
|
||||
startedChan <- err
|
||||
return
|
||||
}
|
||||
close(startedChan)
|
||||
|
||||
for ping := range pings.Channel() {
|
||||
if time.Since(time.Unix(ping.Timestamp, 0)) > r.nodeStatsConfig.StatsUpdateInterval {
|
||||
logger.Infow("keep alive too old, skipping", "timestamp", ping.Timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if !r.currentNode.UpdateNodeStats() {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: check stats against config.Limit values
|
||||
if err := r.RegisterNode(); err != nil {
|
||||
logger.Errorw("could not update node", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
pkg/routing/roommanager.go
Normal file
63
pkg/routing/roommanager.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/psrpc"
|
||||
"github.com/livekit/psrpc/pkg/middleware"
|
||||
)
|
||||
|
||||
//counterfeiter:generate . RoomManagerClient
|
||||
type RoomManagerClient interface {
|
||||
rpc.TypedRoomManagerClient
|
||||
}
|
||||
|
||||
type roomManagerClient struct {
|
||||
config config.RoomConfig
|
||||
client rpc.TypedRoomManagerClient
|
||||
}
|
||||
|
||||
func NewRoomManagerClient(clientParams rpc.ClientParams, config config.RoomConfig) (RoomManagerClient, error) {
|
||||
c, err := rpc.NewTypedRoomManagerClient(
|
||||
clientParams.Bus,
|
||||
psrpc.WithClientChannelSize(clientParams.BufferSize),
|
||||
middleware.WithClientMetrics(clientParams.Observer),
|
||||
rpc.WithClientLogger(clientParams.Logger),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomManagerClient{
|
||||
config: config,
|
||||
client: c,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *roomManagerClient) CreateRoom(ctx context.Context, nodeID livekit.NodeID, req *livekit.CreateRoomRequest, opts ...psrpc.RequestOption) (*livekit.Room, error) {
|
||||
return c.client.CreateRoom(ctx, nodeID, req, append(opts, psrpc.WithRequestInterceptors(middleware.NewRPCRetryInterceptor(middleware.RetryOptions{
|
||||
MaxAttempts: c.config.CreateRoomAttempts,
|
||||
Timeout: c.config.CreateRoomTimeout,
|
||||
})))...)
|
||||
}
|
||||
|
||||
func (c *roomManagerClient) Close() {
|
||||
c.client.Close()
|
||||
}
|
||||
265
pkg/routing/routingfakes/fake_message_sink.go
Normal file
265
pkg/routing/routingfakes/fake_message_sink.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type FakeMessageSink struct {
|
||||
CloseStub func()
|
||||
closeMutex sync.RWMutex
|
||||
closeArgsForCall []struct {
|
||||
}
|
||||
ConnectionIDStub func() livekit.ConnectionID
|
||||
connectionIDMutex sync.RWMutex
|
||||
connectionIDArgsForCall []struct {
|
||||
}
|
||||
connectionIDReturns struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
connectionIDReturnsOnCall map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
IsClosedStub func() bool
|
||||
isClosedMutex sync.RWMutex
|
||||
isClosedArgsForCall []struct {
|
||||
}
|
||||
isClosedReturns struct {
|
||||
result1 bool
|
||||
}
|
||||
isClosedReturnsOnCall map[int]struct {
|
||||
result1 bool
|
||||
}
|
||||
WriteMessageStub func(proto.Message) error
|
||||
writeMessageMutex sync.RWMutex
|
||||
writeMessageArgsForCall []struct {
|
||||
arg1 proto.Message
|
||||
}
|
||||
writeMessageReturns struct {
|
||||
result1 error
|
||||
}
|
||||
writeMessageReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) Close() {
|
||||
fake.closeMutex.Lock()
|
||||
fake.closeArgsForCall = append(fake.closeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.CloseStub
|
||||
fake.recordInvocation("Close", []interface{}{})
|
||||
fake.closeMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.CloseStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) CloseCallCount() int {
|
||||
fake.closeMutex.RLock()
|
||||
defer fake.closeMutex.RUnlock()
|
||||
return len(fake.closeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) CloseCalls(stub func()) {
|
||||
fake.closeMutex.Lock()
|
||||
defer fake.closeMutex.Unlock()
|
||||
fake.CloseStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionID() livekit.ConnectionID {
|
||||
fake.connectionIDMutex.Lock()
|
||||
ret, specificReturn := fake.connectionIDReturnsOnCall[len(fake.connectionIDArgsForCall)]
|
||||
fake.connectionIDArgsForCall = append(fake.connectionIDArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ConnectionIDStub
|
||||
fakeReturns := fake.connectionIDReturns
|
||||
fake.recordInvocation("ConnectionID", []interface{}{})
|
||||
fake.connectionIDMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDCallCount() int {
|
||||
fake.connectionIDMutex.RLock()
|
||||
defer fake.connectionIDMutex.RUnlock()
|
||||
return len(fake.connectionIDArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDCalls(stub func() livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDReturns(result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
fake.connectionIDReturns = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDReturnsOnCall(i int, result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
if fake.connectionIDReturnsOnCall == nil {
|
||||
fake.connectionIDReturnsOnCall = make(map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
})
|
||||
}
|
||||
fake.connectionIDReturnsOnCall[i] = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosed() bool {
|
||||
fake.isClosedMutex.Lock()
|
||||
ret, specificReturn := fake.isClosedReturnsOnCall[len(fake.isClosedArgsForCall)]
|
||||
fake.isClosedArgsForCall = append(fake.isClosedArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.IsClosedStub
|
||||
fakeReturns := fake.isClosedReturns
|
||||
fake.recordInvocation("IsClosed", []interface{}{})
|
||||
fake.isClosedMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedCallCount() int {
|
||||
fake.isClosedMutex.RLock()
|
||||
defer fake.isClosedMutex.RUnlock()
|
||||
return len(fake.isClosedArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedCalls(stub func() bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedReturns(result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
fake.isClosedReturns = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedReturnsOnCall(i int, result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
if fake.isClosedReturnsOnCall == nil {
|
||||
fake.isClosedReturnsOnCall = make(map[int]struct {
|
||||
result1 bool
|
||||
})
|
||||
}
|
||||
fake.isClosedReturnsOnCall[i] = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessage(arg1 proto.Message) error {
|
||||
fake.writeMessageMutex.Lock()
|
||||
ret, specificReturn := fake.writeMessageReturnsOnCall[len(fake.writeMessageArgsForCall)]
|
||||
fake.writeMessageArgsForCall = append(fake.writeMessageArgsForCall, struct {
|
||||
arg1 proto.Message
|
||||
}{arg1})
|
||||
stub := fake.WriteMessageStub
|
||||
fakeReturns := fake.writeMessageReturns
|
||||
fake.recordInvocation("WriteMessage", []interface{}{arg1})
|
||||
fake.writeMessageMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageCallCount() int {
|
||||
fake.writeMessageMutex.RLock()
|
||||
defer fake.writeMessageMutex.RUnlock()
|
||||
return len(fake.writeMessageArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageCalls(stub func(proto.Message) error) {
|
||||
fake.writeMessageMutex.Lock()
|
||||
defer fake.writeMessageMutex.Unlock()
|
||||
fake.WriteMessageStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageArgsForCall(i int) proto.Message {
|
||||
fake.writeMessageMutex.RLock()
|
||||
defer fake.writeMessageMutex.RUnlock()
|
||||
argsForCall := fake.writeMessageArgsForCall[i]
|
||||
return argsForCall.arg1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageReturns(result1 error) {
|
||||
fake.writeMessageMutex.Lock()
|
||||
defer fake.writeMessageMutex.Unlock()
|
||||
fake.WriteMessageStub = nil
|
||||
fake.writeMessageReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageReturnsOnCall(i int, result1 error) {
|
||||
fake.writeMessageMutex.Lock()
|
||||
defer fake.writeMessageMutex.Unlock()
|
||||
fake.WriteMessageStub = nil
|
||||
if fake.writeMessageReturnsOnCall == nil {
|
||||
fake.writeMessageReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.writeMessageReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.MessageSink = new(FakeMessageSink)
|
||||
256
pkg/routing/routingfakes/fake_message_source.go
Normal file
256
pkg/routing/routingfakes/fake_message_source.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type FakeMessageSource struct {
|
||||
CloseStub func()
|
||||
closeMutex sync.RWMutex
|
||||
closeArgsForCall []struct {
|
||||
}
|
||||
ConnectionIDStub func() livekit.ConnectionID
|
||||
connectionIDMutex sync.RWMutex
|
||||
connectionIDArgsForCall []struct {
|
||||
}
|
||||
connectionIDReturns struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
connectionIDReturnsOnCall map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
IsClosedStub func() bool
|
||||
isClosedMutex sync.RWMutex
|
||||
isClosedArgsForCall []struct {
|
||||
}
|
||||
isClosedReturns struct {
|
||||
result1 bool
|
||||
}
|
||||
isClosedReturnsOnCall map[int]struct {
|
||||
result1 bool
|
||||
}
|
||||
ReadChanStub func() <-chan proto.Message
|
||||
readChanMutex sync.RWMutex
|
||||
readChanArgsForCall []struct {
|
||||
}
|
||||
readChanReturns struct {
|
||||
result1 <-chan proto.Message
|
||||
}
|
||||
readChanReturnsOnCall map[int]struct {
|
||||
result1 <-chan proto.Message
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) Close() {
|
||||
fake.closeMutex.Lock()
|
||||
fake.closeArgsForCall = append(fake.closeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.CloseStub
|
||||
fake.recordInvocation("Close", []interface{}{})
|
||||
fake.closeMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.CloseStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) CloseCallCount() int {
|
||||
fake.closeMutex.RLock()
|
||||
defer fake.closeMutex.RUnlock()
|
||||
return len(fake.closeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) CloseCalls(stub func()) {
|
||||
fake.closeMutex.Lock()
|
||||
defer fake.closeMutex.Unlock()
|
||||
fake.CloseStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionID() livekit.ConnectionID {
|
||||
fake.connectionIDMutex.Lock()
|
||||
ret, specificReturn := fake.connectionIDReturnsOnCall[len(fake.connectionIDArgsForCall)]
|
||||
fake.connectionIDArgsForCall = append(fake.connectionIDArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ConnectionIDStub
|
||||
fakeReturns := fake.connectionIDReturns
|
||||
fake.recordInvocation("ConnectionID", []interface{}{})
|
||||
fake.connectionIDMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDCallCount() int {
|
||||
fake.connectionIDMutex.RLock()
|
||||
defer fake.connectionIDMutex.RUnlock()
|
||||
return len(fake.connectionIDArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDCalls(stub func() livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDReturns(result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
fake.connectionIDReturns = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDReturnsOnCall(i int, result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
if fake.connectionIDReturnsOnCall == nil {
|
||||
fake.connectionIDReturnsOnCall = make(map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
})
|
||||
}
|
||||
fake.connectionIDReturnsOnCall[i] = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosed() bool {
|
||||
fake.isClosedMutex.Lock()
|
||||
ret, specificReturn := fake.isClosedReturnsOnCall[len(fake.isClosedArgsForCall)]
|
||||
fake.isClosedArgsForCall = append(fake.isClosedArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.IsClosedStub
|
||||
fakeReturns := fake.isClosedReturns
|
||||
fake.recordInvocation("IsClosed", []interface{}{})
|
||||
fake.isClosedMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedCallCount() int {
|
||||
fake.isClosedMutex.RLock()
|
||||
defer fake.isClosedMutex.RUnlock()
|
||||
return len(fake.isClosedArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedCalls(stub func() bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedReturns(result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
fake.isClosedReturns = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedReturnsOnCall(i int, result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
if fake.isClosedReturnsOnCall == nil {
|
||||
fake.isClosedReturnsOnCall = make(map[int]struct {
|
||||
result1 bool
|
||||
})
|
||||
}
|
||||
fake.isClosedReturnsOnCall[i] = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChan() <-chan proto.Message {
|
||||
fake.readChanMutex.Lock()
|
||||
ret, specificReturn := fake.readChanReturnsOnCall[len(fake.readChanArgsForCall)]
|
||||
fake.readChanArgsForCall = append(fake.readChanArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ReadChanStub
|
||||
fakeReturns := fake.readChanReturns
|
||||
fake.recordInvocation("ReadChan", []interface{}{})
|
||||
fake.readChanMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanCallCount() int {
|
||||
fake.readChanMutex.RLock()
|
||||
defer fake.readChanMutex.RUnlock()
|
||||
return len(fake.readChanArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanCalls(stub func() <-chan proto.Message) {
|
||||
fake.readChanMutex.Lock()
|
||||
defer fake.readChanMutex.Unlock()
|
||||
fake.ReadChanStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanReturns(result1 <-chan proto.Message) {
|
||||
fake.readChanMutex.Lock()
|
||||
defer fake.readChanMutex.Unlock()
|
||||
fake.ReadChanStub = nil
|
||||
fake.readChanReturns = struct {
|
||||
result1 <-chan proto.Message
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanReturnsOnCall(i int, result1 <-chan proto.Message) {
|
||||
fake.readChanMutex.Lock()
|
||||
defer fake.readChanMutex.Unlock()
|
||||
fake.ReadChanStub = nil
|
||||
if fake.readChanReturnsOnCall == nil {
|
||||
fake.readChanReturnsOnCall = make(map[int]struct {
|
||||
result1 <-chan proto.Message
|
||||
})
|
||||
}
|
||||
fake.readChanReturnsOnCall[i] = struct {
|
||||
result1 <-chan proto.Message
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.MessageSource = new(FakeMessageSource)
|
||||
151
pkg/routing/routingfakes/fake_room_manager_client.go
Normal file
151
pkg/routing/routingfakes/fake_room_manager_client.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
type FakeRoomManagerClient struct {
|
||||
CloseStub func()
|
||||
closeMutex sync.RWMutex
|
||||
closeArgsForCall []struct {
|
||||
}
|
||||
CreateRoomStub func(context.Context, livekit.NodeID, *livekit.CreateRoomRequest, ...psrpc.RequestOption) (*livekit.Room, error)
|
||||
createRoomMutex sync.RWMutex
|
||||
createRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.NodeID
|
||||
arg3 *livekit.CreateRoomRequest
|
||||
arg4 []psrpc.RequestOption
|
||||
}
|
||||
createRoomReturns struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
createRoomReturnsOnCall map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) Close() {
|
||||
fake.closeMutex.Lock()
|
||||
fake.closeArgsForCall = append(fake.closeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.CloseStub
|
||||
fake.recordInvocation("Close", []interface{}{})
|
||||
fake.closeMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.CloseStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CloseCallCount() int {
|
||||
fake.closeMutex.RLock()
|
||||
defer fake.closeMutex.RUnlock()
|
||||
return len(fake.closeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CloseCalls(stub func()) {
|
||||
fake.closeMutex.Lock()
|
||||
defer fake.closeMutex.Unlock()
|
||||
fake.CloseStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoom(arg1 context.Context, arg2 livekit.NodeID, arg3 *livekit.CreateRoomRequest, arg4 ...psrpc.RequestOption) (*livekit.Room, error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
ret, specificReturn := fake.createRoomReturnsOnCall[len(fake.createRoomArgsForCall)]
|
||||
fake.createRoomArgsForCall = append(fake.createRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.NodeID
|
||||
arg3 *livekit.CreateRoomRequest
|
||||
arg4 []psrpc.RequestOption
|
||||
}{arg1, arg2, arg3, arg4})
|
||||
stub := fake.CreateRoomStub
|
||||
fakeReturns := fake.createRoomReturns
|
||||
fake.recordInvocation("CreateRoom", []interface{}{arg1, arg2, arg3, arg4})
|
||||
fake.createRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3, arg4...)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomCallCount() int {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
return len(fake.createRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomCalls(stub func(context.Context, livekit.NodeID, *livekit.CreateRoomRequest, ...psrpc.RequestOption) (*livekit.Room, error)) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomArgsForCall(i int) (context.Context, livekit.NodeID, *livekit.CreateRoomRequest, []psrpc.RequestOption) {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
argsForCall := fake.createRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomReturns(result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
fake.createRoomReturns = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
if fake.createRoomReturnsOnCall == nil {
|
||||
fake.createRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.createRoomReturnsOnCall[i] = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.RoomManagerClient = new(FakeRoomManagerClient)
|
||||
867
pkg/routing/routingfakes/fake_router.go
Normal file
867
pkg/routing/routingfakes/fake_router.go
Normal file
@@ -0,0 +1,867 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type FakeRouter struct {
|
||||
ClearRoomStateStub func(context.Context, livekit.RoomName) error
|
||||
clearRoomStateMutex sync.RWMutex
|
||||
clearRoomStateArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}
|
||||
clearRoomStateReturns struct {
|
||||
result1 error
|
||||
}
|
||||
clearRoomStateReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
CreateRoomStub func(context.Context, *livekit.CreateRoomRequest) (*livekit.Room, error)
|
||||
createRoomMutex sync.RWMutex
|
||||
createRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 *livekit.CreateRoomRequest
|
||||
}
|
||||
createRoomReturns struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
createRoomReturnsOnCall map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
DrainStub func()
|
||||
drainMutex sync.RWMutex
|
||||
drainArgsForCall []struct {
|
||||
}
|
||||
GetNodeForRoomStub func(context.Context, livekit.RoomName) (*livekit.Node, error)
|
||||
getNodeForRoomMutex sync.RWMutex
|
||||
getNodeForRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}
|
||||
getNodeForRoomReturns struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}
|
||||
getNodeForRoomReturnsOnCall map[int]struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}
|
||||
GetRegionStub func() string
|
||||
getRegionMutex sync.RWMutex
|
||||
getRegionArgsForCall []struct {
|
||||
}
|
||||
getRegionReturns struct {
|
||||
result1 string
|
||||
}
|
||||
getRegionReturnsOnCall map[int]struct {
|
||||
result1 string
|
||||
}
|
||||
ListNodesStub func() ([]*livekit.Node, error)
|
||||
listNodesMutex sync.RWMutex
|
||||
listNodesArgsForCall []struct {
|
||||
}
|
||||
listNodesReturns struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}
|
||||
listNodesReturnsOnCall map[int]struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}
|
||||
RegisterNodeStub func() error
|
||||
registerNodeMutex sync.RWMutex
|
||||
registerNodeArgsForCall []struct {
|
||||
}
|
||||
registerNodeReturns struct {
|
||||
result1 error
|
||||
}
|
||||
registerNodeReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
RemoveDeadNodesStub func() error
|
||||
removeDeadNodesMutex sync.RWMutex
|
||||
removeDeadNodesArgsForCall []struct {
|
||||
}
|
||||
removeDeadNodesReturns struct {
|
||||
result1 error
|
||||
}
|
||||
removeDeadNodesReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
SetNodeForRoomStub func(context.Context, livekit.RoomName, livekit.NodeID) error
|
||||
setNodeForRoomMutex sync.RWMutex
|
||||
setNodeForRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 livekit.NodeID
|
||||
}
|
||||
setNodeForRoomReturns struct {
|
||||
result1 error
|
||||
}
|
||||
setNodeForRoomReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
StartStub func() error
|
||||
startMutex sync.RWMutex
|
||||
startArgsForCall []struct {
|
||||
}
|
||||
startReturns struct {
|
||||
result1 error
|
||||
}
|
||||
startReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
StartParticipantSignalStub func(context.Context, livekit.RoomName, routing.ParticipantInit) (routing.StartParticipantSignalResults, error)
|
||||
startParticipantSignalMutex sync.RWMutex
|
||||
startParticipantSignalArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
}
|
||||
startParticipantSignalReturns struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}
|
||||
startParticipantSignalReturnsOnCall map[int]struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}
|
||||
StopStub func()
|
||||
stopMutex sync.RWMutex
|
||||
stopArgsForCall []struct {
|
||||
}
|
||||
UnregisterNodeStub func() error
|
||||
unregisterNodeMutex sync.RWMutex
|
||||
unregisterNodeArgsForCall []struct {
|
||||
}
|
||||
unregisterNodeReturns struct {
|
||||
result1 error
|
||||
}
|
||||
unregisterNodeReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomState(arg1 context.Context, arg2 livekit.RoomName) error {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
ret, specificReturn := fake.clearRoomStateReturnsOnCall[len(fake.clearRoomStateArgsForCall)]
|
||||
fake.clearRoomStateArgsForCall = append(fake.clearRoomStateArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}{arg1, arg2})
|
||||
stub := fake.ClearRoomStateStub
|
||||
fakeReturns := fake.clearRoomStateReturns
|
||||
fake.recordInvocation("ClearRoomState", []interface{}{arg1, arg2})
|
||||
fake.clearRoomStateMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateCallCount() int {
|
||||
fake.clearRoomStateMutex.RLock()
|
||||
defer fake.clearRoomStateMutex.RUnlock()
|
||||
return len(fake.clearRoomStateArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateCalls(stub func(context.Context, livekit.RoomName) error) {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
defer fake.clearRoomStateMutex.Unlock()
|
||||
fake.ClearRoomStateStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateArgsForCall(i int) (context.Context, livekit.RoomName) {
|
||||
fake.clearRoomStateMutex.RLock()
|
||||
defer fake.clearRoomStateMutex.RUnlock()
|
||||
argsForCall := fake.clearRoomStateArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateReturns(result1 error) {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
defer fake.clearRoomStateMutex.Unlock()
|
||||
fake.ClearRoomStateStub = nil
|
||||
fake.clearRoomStateReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateReturnsOnCall(i int, result1 error) {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
defer fake.clearRoomStateMutex.Unlock()
|
||||
fake.ClearRoomStateStub = nil
|
||||
if fake.clearRoomStateReturnsOnCall == nil {
|
||||
fake.clearRoomStateReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.clearRoomStateReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoom(arg1 context.Context, arg2 *livekit.CreateRoomRequest) (*livekit.Room, error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
ret, specificReturn := fake.createRoomReturnsOnCall[len(fake.createRoomArgsForCall)]
|
||||
fake.createRoomArgsForCall = append(fake.createRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 *livekit.CreateRoomRequest
|
||||
}{arg1, arg2})
|
||||
stub := fake.CreateRoomStub
|
||||
fakeReturns := fake.createRoomReturns
|
||||
fake.recordInvocation("CreateRoom", []interface{}{arg1, arg2})
|
||||
fake.createRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomCallCount() int {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
return len(fake.createRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomCalls(stub func(context.Context, *livekit.CreateRoomRequest) (*livekit.Room, error)) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomArgsForCall(i int) (context.Context, *livekit.CreateRoomRequest) {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
argsForCall := fake.createRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomReturns(result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
fake.createRoomReturns = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
if fake.createRoomReturnsOnCall == nil {
|
||||
fake.createRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.createRoomReturnsOnCall[i] = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Drain() {
|
||||
fake.drainMutex.Lock()
|
||||
fake.drainArgsForCall = append(fake.drainArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.DrainStub
|
||||
fake.recordInvocation("Drain", []interface{}{})
|
||||
fake.drainMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.DrainStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) DrainCallCount() int {
|
||||
fake.drainMutex.RLock()
|
||||
defer fake.drainMutex.RUnlock()
|
||||
return len(fake.drainArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) DrainCalls(stub func()) {
|
||||
fake.drainMutex.Lock()
|
||||
defer fake.drainMutex.Unlock()
|
||||
fake.DrainStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoom(arg1 context.Context, arg2 livekit.RoomName) (*livekit.Node, error) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
ret, specificReturn := fake.getNodeForRoomReturnsOnCall[len(fake.getNodeForRoomArgsForCall)]
|
||||
fake.getNodeForRoomArgsForCall = append(fake.getNodeForRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}{arg1, arg2})
|
||||
stub := fake.GetNodeForRoomStub
|
||||
fakeReturns := fake.getNodeForRoomReturns
|
||||
fake.recordInvocation("GetNodeForRoom", []interface{}{arg1, arg2})
|
||||
fake.getNodeForRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomCallCount() int {
|
||||
fake.getNodeForRoomMutex.RLock()
|
||||
defer fake.getNodeForRoomMutex.RUnlock()
|
||||
return len(fake.getNodeForRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomCalls(stub func(context.Context, livekit.RoomName) (*livekit.Node, error)) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
defer fake.getNodeForRoomMutex.Unlock()
|
||||
fake.GetNodeForRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomArgsForCall(i int) (context.Context, livekit.RoomName) {
|
||||
fake.getNodeForRoomMutex.RLock()
|
||||
defer fake.getNodeForRoomMutex.RUnlock()
|
||||
argsForCall := fake.getNodeForRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomReturns(result1 *livekit.Node, result2 error) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
defer fake.getNodeForRoomMutex.Unlock()
|
||||
fake.GetNodeForRoomStub = nil
|
||||
fake.getNodeForRoomReturns = struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomReturnsOnCall(i int, result1 *livekit.Node, result2 error) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
defer fake.getNodeForRoomMutex.Unlock()
|
||||
fake.GetNodeForRoomStub = nil
|
||||
if fake.getNodeForRoomReturnsOnCall == nil {
|
||||
fake.getNodeForRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.getNodeForRoomReturnsOnCall[i] = struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegion() string {
|
||||
fake.getRegionMutex.Lock()
|
||||
ret, specificReturn := fake.getRegionReturnsOnCall[len(fake.getRegionArgsForCall)]
|
||||
fake.getRegionArgsForCall = append(fake.getRegionArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.GetRegionStub
|
||||
fakeReturns := fake.getRegionReturns
|
||||
fake.recordInvocation("GetRegion", []interface{}{})
|
||||
fake.getRegionMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionCallCount() int {
|
||||
fake.getRegionMutex.RLock()
|
||||
defer fake.getRegionMutex.RUnlock()
|
||||
return len(fake.getRegionArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionCalls(stub func() string) {
|
||||
fake.getRegionMutex.Lock()
|
||||
defer fake.getRegionMutex.Unlock()
|
||||
fake.GetRegionStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionReturns(result1 string) {
|
||||
fake.getRegionMutex.Lock()
|
||||
defer fake.getRegionMutex.Unlock()
|
||||
fake.GetRegionStub = nil
|
||||
fake.getRegionReturns = struct {
|
||||
result1 string
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionReturnsOnCall(i int, result1 string) {
|
||||
fake.getRegionMutex.Lock()
|
||||
defer fake.getRegionMutex.Unlock()
|
||||
fake.GetRegionStub = nil
|
||||
if fake.getRegionReturnsOnCall == nil {
|
||||
fake.getRegionReturnsOnCall = make(map[int]struct {
|
||||
result1 string
|
||||
})
|
||||
}
|
||||
fake.getRegionReturnsOnCall[i] = struct {
|
||||
result1 string
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodes() ([]*livekit.Node, error) {
|
||||
fake.listNodesMutex.Lock()
|
||||
ret, specificReturn := fake.listNodesReturnsOnCall[len(fake.listNodesArgsForCall)]
|
||||
fake.listNodesArgsForCall = append(fake.listNodesArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ListNodesStub
|
||||
fakeReturns := fake.listNodesReturns
|
||||
fake.recordInvocation("ListNodes", []interface{}{})
|
||||
fake.listNodesMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesCallCount() int {
|
||||
fake.listNodesMutex.RLock()
|
||||
defer fake.listNodesMutex.RUnlock()
|
||||
return len(fake.listNodesArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesCalls(stub func() ([]*livekit.Node, error)) {
|
||||
fake.listNodesMutex.Lock()
|
||||
defer fake.listNodesMutex.Unlock()
|
||||
fake.ListNodesStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesReturns(result1 []*livekit.Node, result2 error) {
|
||||
fake.listNodesMutex.Lock()
|
||||
defer fake.listNodesMutex.Unlock()
|
||||
fake.ListNodesStub = nil
|
||||
fake.listNodesReturns = struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesReturnsOnCall(i int, result1 []*livekit.Node, result2 error) {
|
||||
fake.listNodesMutex.Lock()
|
||||
defer fake.listNodesMutex.Unlock()
|
||||
fake.ListNodesStub = nil
|
||||
if fake.listNodesReturnsOnCall == nil {
|
||||
fake.listNodesReturnsOnCall = make(map[int]struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.listNodesReturnsOnCall[i] = struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNode() error {
|
||||
fake.registerNodeMutex.Lock()
|
||||
ret, specificReturn := fake.registerNodeReturnsOnCall[len(fake.registerNodeArgsForCall)]
|
||||
fake.registerNodeArgsForCall = append(fake.registerNodeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.RegisterNodeStub
|
||||
fakeReturns := fake.registerNodeReturns
|
||||
fake.recordInvocation("RegisterNode", []interface{}{})
|
||||
fake.registerNodeMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeCallCount() int {
|
||||
fake.registerNodeMutex.RLock()
|
||||
defer fake.registerNodeMutex.RUnlock()
|
||||
return len(fake.registerNodeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeCalls(stub func() error) {
|
||||
fake.registerNodeMutex.Lock()
|
||||
defer fake.registerNodeMutex.Unlock()
|
||||
fake.RegisterNodeStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeReturns(result1 error) {
|
||||
fake.registerNodeMutex.Lock()
|
||||
defer fake.registerNodeMutex.Unlock()
|
||||
fake.RegisterNodeStub = nil
|
||||
fake.registerNodeReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeReturnsOnCall(i int, result1 error) {
|
||||
fake.registerNodeMutex.Lock()
|
||||
defer fake.registerNodeMutex.Unlock()
|
||||
fake.RegisterNodeStub = nil
|
||||
if fake.registerNodeReturnsOnCall == nil {
|
||||
fake.registerNodeReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.registerNodeReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodes() error {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
ret, specificReturn := fake.removeDeadNodesReturnsOnCall[len(fake.removeDeadNodesArgsForCall)]
|
||||
fake.removeDeadNodesArgsForCall = append(fake.removeDeadNodesArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.RemoveDeadNodesStub
|
||||
fakeReturns := fake.removeDeadNodesReturns
|
||||
fake.recordInvocation("RemoveDeadNodes", []interface{}{})
|
||||
fake.removeDeadNodesMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesCallCount() int {
|
||||
fake.removeDeadNodesMutex.RLock()
|
||||
defer fake.removeDeadNodesMutex.RUnlock()
|
||||
return len(fake.removeDeadNodesArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesCalls(stub func() error) {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
defer fake.removeDeadNodesMutex.Unlock()
|
||||
fake.RemoveDeadNodesStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesReturns(result1 error) {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
defer fake.removeDeadNodesMutex.Unlock()
|
||||
fake.RemoveDeadNodesStub = nil
|
||||
fake.removeDeadNodesReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesReturnsOnCall(i int, result1 error) {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
defer fake.removeDeadNodesMutex.Unlock()
|
||||
fake.RemoveDeadNodesStub = nil
|
||||
if fake.removeDeadNodesReturnsOnCall == nil {
|
||||
fake.removeDeadNodesReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.removeDeadNodesReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoom(arg1 context.Context, arg2 livekit.RoomName, arg3 livekit.NodeID) error {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
ret, specificReturn := fake.setNodeForRoomReturnsOnCall[len(fake.setNodeForRoomArgsForCall)]
|
||||
fake.setNodeForRoomArgsForCall = append(fake.setNodeForRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 livekit.NodeID
|
||||
}{arg1, arg2, arg3})
|
||||
stub := fake.SetNodeForRoomStub
|
||||
fakeReturns := fake.setNodeForRoomReturns
|
||||
fake.recordInvocation("SetNodeForRoom", []interface{}{arg1, arg2, arg3})
|
||||
fake.setNodeForRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomCallCount() int {
|
||||
fake.setNodeForRoomMutex.RLock()
|
||||
defer fake.setNodeForRoomMutex.RUnlock()
|
||||
return len(fake.setNodeForRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomCalls(stub func(context.Context, livekit.RoomName, livekit.NodeID) error) {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
defer fake.setNodeForRoomMutex.Unlock()
|
||||
fake.SetNodeForRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomArgsForCall(i int) (context.Context, livekit.RoomName, livekit.NodeID) {
|
||||
fake.setNodeForRoomMutex.RLock()
|
||||
defer fake.setNodeForRoomMutex.RUnlock()
|
||||
argsForCall := fake.setNodeForRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomReturns(result1 error) {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
defer fake.setNodeForRoomMutex.Unlock()
|
||||
fake.SetNodeForRoomStub = nil
|
||||
fake.setNodeForRoomReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomReturnsOnCall(i int, result1 error) {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
defer fake.setNodeForRoomMutex.Unlock()
|
||||
fake.SetNodeForRoomStub = nil
|
||||
if fake.setNodeForRoomReturnsOnCall == nil {
|
||||
fake.setNodeForRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.setNodeForRoomReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Start() error {
|
||||
fake.startMutex.Lock()
|
||||
ret, specificReturn := fake.startReturnsOnCall[len(fake.startArgsForCall)]
|
||||
fake.startArgsForCall = append(fake.startArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.StartStub
|
||||
fakeReturns := fake.startReturns
|
||||
fake.recordInvocation("Start", []interface{}{})
|
||||
fake.startMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartCallCount() int {
|
||||
fake.startMutex.RLock()
|
||||
defer fake.startMutex.RUnlock()
|
||||
return len(fake.startArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartCalls(stub func() error) {
|
||||
fake.startMutex.Lock()
|
||||
defer fake.startMutex.Unlock()
|
||||
fake.StartStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartReturns(result1 error) {
|
||||
fake.startMutex.Lock()
|
||||
defer fake.startMutex.Unlock()
|
||||
fake.StartStub = nil
|
||||
fake.startReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartReturnsOnCall(i int, result1 error) {
|
||||
fake.startMutex.Lock()
|
||||
defer fake.startMutex.Unlock()
|
||||
fake.StartStub = nil
|
||||
if fake.startReturnsOnCall == nil {
|
||||
fake.startReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.startReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignal(arg1 context.Context, arg2 livekit.RoomName, arg3 routing.ParticipantInit) (routing.StartParticipantSignalResults, error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
ret, specificReturn := fake.startParticipantSignalReturnsOnCall[len(fake.startParticipantSignalArgsForCall)]
|
||||
fake.startParticipantSignalArgsForCall = append(fake.startParticipantSignalArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
}{arg1, arg2, arg3})
|
||||
stub := fake.StartParticipantSignalStub
|
||||
fakeReturns := fake.startParticipantSignalReturns
|
||||
fake.recordInvocation("StartParticipantSignal", []interface{}{arg1, arg2, arg3})
|
||||
fake.startParticipantSignalMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalCallCount() int {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
return len(fake.startParticipantSignalArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalCalls(stub func(context.Context, livekit.RoomName, routing.ParticipantInit) (routing.StartParticipantSignalResults, error)) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalArgsForCall(i int) (context.Context, livekit.RoomName, routing.ParticipantInit) {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
argsForCall := fake.startParticipantSignalArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalReturns(result1 routing.StartParticipantSignalResults, result2 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
fake.startParticipantSignalReturns = struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalReturnsOnCall(i int, result1 routing.StartParticipantSignalResults, result2 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
if fake.startParticipantSignalReturnsOnCall == nil {
|
||||
fake.startParticipantSignalReturnsOnCall = make(map[int]struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.startParticipantSignalReturnsOnCall[i] = struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Stop() {
|
||||
fake.stopMutex.Lock()
|
||||
fake.stopArgsForCall = append(fake.stopArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.StopStub
|
||||
fake.recordInvocation("Stop", []interface{}{})
|
||||
fake.stopMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.StopStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StopCallCount() int {
|
||||
fake.stopMutex.RLock()
|
||||
defer fake.stopMutex.RUnlock()
|
||||
return len(fake.stopArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StopCalls(stub func()) {
|
||||
fake.stopMutex.Lock()
|
||||
defer fake.stopMutex.Unlock()
|
||||
fake.StopStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNode() error {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
ret, specificReturn := fake.unregisterNodeReturnsOnCall[len(fake.unregisterNodeArgsForCall)]
|
||||
fake.unregisterNodeArgsForCall = append(fake.unregisterNodeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.UnregisterNodeStub
|
||||
fakeReturns := fake.unregisterNodeReturns
|
||||
fake.recordInvocation("UnregisterNode", []interface{}{})
|
||||
fake.unregisterNodeMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeCallCount() int {
|
||||
fake.unregisterNodeMutex.RLock()
|
||||
defer fake.unregisterNodeMutex.RUnlock()
|
||||
return len(fake.unregisterNodeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeCalls(stub func() error) {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
defer fake.unregisterNodeMutex.Unlock()
|
||||
fake.UnregisterNodeStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeReturns(result1 error) {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
defer fake.unregisterNodeMutex.Unlock()
|
||||
fake.UnregisterNodeStub = nil
|
||||
fake.unregisterNodeReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeReturnsOnCall(i int, result1 error) {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
defer fake.unregisterNodeMutex.Unlock()
|
||||
fake.UnregisterNodeStub = nil
|
||||
if fake.unregisterNodeReturnsOnCall == nil {
|
||||
fake.unregisterNodeReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.unregisterNodeReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.Router = new(FakeRouter)
|
||||
195
pkg/routing/routingfakes/fake_signal_client.go
Normal file
195
pkg/routing/routingfakes/fake_signal_client.go
Normal file
@@ -0,0 +1,195 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type FakeSignalClient struct {
|
||||
ActiveCountStub func() int
|
||||
activeCountMutex sync.RWMutex
|
||||
activeCountArgsForCall []struct {
|
||||
}
|
||||
activeCountReturns struct {
|
||||
result1 int
|
||||
}
|
||||
activeCountReturnsOnCall map[int]struct {
|
||||
result1 int
|
||||
}
|
||||
StartParticipantSignalStub func(context.Context, livekit.RoomName, routing.ParticipantInit, livekit.NodeID) (livekit.ConnectionID, routing.MessageSink, routing.MessageSource, error)
|
||||
startParticipantSignalMutex sync.RWMutex
|
||||
startParticipantSignalArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
arg4 livekit.NodeID
|
||||
}
|
||||
startParticipantSignalReturns struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}
|
||||
startParticipantSignalReturnsOnCall map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCount() int {
|
||||
fake.activeCountMutex.Lock()
|
||||
ret, specificReturn := fake.activeCountReturnsOnCall[len(fake.activeCountArgsForCall)]
|
||||
fake.activeCountArgsForCall = append(fake.activeCountArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ActiveCountStub
|
||||
fakeReturns := fake.activeCountReturns
|
||||
fake.recordInvocation("ActiveCount", []interface{}{})
|
||||
fake.activeCountMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountCallCount() int {
|
||||
fake.activeCountMutex.RLock()
|
||||
defer fake.activeCountMutex.RUnlock()
|
||||
return len(fake.activeCountArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountCalls(stub func() int) {
|
||||
fake.activeCountMutex.Lock()
|
||||
defer fake.activeCountMutex.Unlock()
|
||||
fake.ActiveCountStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountReturns(result1 int) {
|
||||
fake.activeCountMutex.Lock()
|
||||
defer fake.activeCountMutex.Unlock()
|
||||
fake.ActiveCountStub = nil
|
||||
fake.activeCountReturns = struct {
|
||||
result1 int
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountReturnsOnCall(i int, result1 int) {
|
||||
fake.activeCountMutex.Lock()
|
||||
defer fake.activeCountMutex.Unlock()
|
||||
fake.ActiveCountStub = nil
|
||||
if fake.activeCountReturnsOnCall == nil {
|
||||
fake.activeCountReturnsOnCall = make(map[int]struct {
|
||||
result1 int
|
||||
})
|
||||
}
|
||||
fake.activeCountReturnsOnCall[i] = struct {
|
||||
result1 int
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignal(arg1 context.Context, arg2 livekit.RoomName, arg3 routing.ParticipantInit, arg4 livekit.NodeID) (livekit.ConnectionID, routing.MessageSink, routing.MessageSource, error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
ret, specificReturn := fake.startParticipantSignalReturnsOnCall[len(fake.startParticipantSignalArgsForCall)]
|
||||
fake.startParticipantSignalArgsForCall = append(fake.startParticipantSignalArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
arg4 livekit.NodeID
|
||||
}{arg1, arg2, arg3, arg4})
|
||||
stub := fake.StartParticipantSignalStub
|
||||
fakeReturns := fake.startParticipantSignalReturns
|
||||
fake.recordInvocation("StartParticipantSignal", []interface{}{arg1, arg2, arg3, arg4})
|
||||
fake.startParticipantSignalMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3, arg4)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2, ret.result3, ret.result4
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalCallCount() int {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
return len(fake.startParticipantSignalArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalCalls(stub func(context.Context, livekit.RoomName, routing.ParticipantInit, livekit.NodeID) (livekit.ConnectionID, routing.MessageSink, routing.MessageSource, error)) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalArgsForCall(i int) (context.Context, livekit.RoomName, routing.ParticipantInit, livekit.NodeID) {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
argsForCall := fake.startParticipantSignalArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalReturns(result1 livekit.ConnectionID, result2 routing.MessageSink, result3 routing.MessageSource, result4 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
fake.startParticipantSignalReturns = struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}{result1, result2, result3, result4}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalReturnsOnCall(i int, result1 livekit.ConnectionID, result2 routing.MessageSink, result3 routing.MessageSource, result4 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
if fake.startParticipantSignalReturnsOnCall == nil {
|
||||
fake.startParticipantSignalReturnsOnCall = make(map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
})
|
||||
}
|
||||
fake.startParticipantSignalReturnsOnCall[i] = struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}{result1, result2, result3, result4}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.SignalClient = new(FakeSignalClient)
|
||||
34
pkg/routing/selector/any.go
Normal file
34
pkg/routing/selector/any.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// AnySelector selects any available node with no limitations
|
||||
type AnySelector struct {
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func (s *AnySelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes = GetAvailableNodes(nodes)
|
||||
if len(nodes) == 0 {
|
||||
return nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
287
pkg/routing/selector/any_test.go
Normal file
287
pkg/routing/selector/any_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
func createTestNode(id string, cpuLoad float32, numRooms int32, numClients int32, state livekit.NodeState) *livekit.Node {
|
||||
return &livekit.Node{
|
||||
Id: id,
|
||||
State: state,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 1, // Recent update to be considered available
|
||||
CpuLoad: cpuLoad,
|
||||
NumRooms: numRooms,
|
||||
NumClients: numClients,
|
||||
NumCpus: 4,
|
||||
LoadAvgLast1Min: cpuLoad * 4, // Simulate system load
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnySelector_SelectNode_TwoChoice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sortBy string
|
||||
algorithm string
|
||||
nodes []*livekit.Node
|
||||
wantErr string
|
||||
expected string
|
||||
notExpected string
|
||||
}{
|
||||
{
|
||||
name: "successful selection with cpuload sorting",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.8, 5, 10, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.3, 2, 5, livekit.NodeState_SERVING),
|
||||
createTestNode("node3", 0.6, 3, 8, livekit.NodeState_SERVING),
|
||||
createTestNode("node4", 0.9, 6, 12, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "", // Not deterministic selection, so no specific expected node
|
||||
notExpected: "node4", // Node with highest load should not be selected
|
||||
},
|
||||
{
|
||||
name: "successful selection with rooms sorting",
|
||||
sortBy: "rooms",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 8, 15, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.4, 2, 5, livekit.NodeState_SERVING),
|
||||
createTestNode("node3", 0.6, 12, 20, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "", // Not deterministic selection, so no specific expected node
|
||||
notExpected: "node3", // Node with highest room count should not be selected
|
||||
},
|
||||
{
|
||||
name: "successful selection with clients sorting",
|
||||
sortBy: "clients",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 3, 25, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.4, 2, 5, livekit.NodeState_SERVING),
|
||||
createTestNode("node3", 0.6, 4, 30, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "", // Not deterministic selection, so no specific expected node
|
||||
notExpected: "node3", // Node with highest clients should not be selected
|
||||
},
|
||||
{
|
||||
name: "empty nodes list",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{},
|
||||
wantErr: "could not find any available nodes",
|
||||
},
|
||||
{
|
||||
name: "no available nodes - all unavailable",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
{
|
||||
Id: "node1",
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 10, // Too old
|
||||
CpuLoad: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "could not find any available nodes",
|
||||
},
|
||||
{
|
||||
name: "no available nodes - not serving",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
{
|
||||
Id: "node1",
|
||||
State: livekit.NodeState_SHUTTING_DOWN,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 1,
|
||||
CpuLoad: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "could not find any available nodes",
|
||||
},
|
||||
{
|
||||
name: "single available node",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 3, 10, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "node1", // Should select the only available node
|
||||
notExpected: "", // No other nodes to compare against
|
||||
},
|
||||
{
|
||||
name: "two available nodes",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.8, 5, 15, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.3, 2, 5, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "node2", // Should select the node with lower load
|
||||
notExpected: "node1", // Should not select the node with higher load
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector := &AnySelector{
|
||||
SortBy: tt.sortBy,
|
||||
Algorithm: tt.algorithm,
|
||||
}
|
||||
|
||||
node, err := selector.SelectNode(tt.nodes)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
require.Nil(t, node)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
require.NotEmpty(t, node.Id)
|
||||
|
||||
// Verify the selected node is one of the available nodes
|
||||
found := false
|
||||
availableNodes := GetAvailableNodes(tt.nodes)
|
||||
for _, availableNode := range availableNodes {
|
||||
if availableNode.Id == node.Id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Selected node should be one of the available nodes")
|
||||
|
||||
if tt.expected != "" {
|
||||
require.Equal(t, tt.expected, node.Id, "Selected node should match expected")
|
||||
}
|
||||
if tt.notExpected != "" {
|
||||
require.NotEqual(t, tt.notExpected, node.Id, "Selected node should not match not expected")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnySelector_SelectNode_TwoChoice_Probabilistic_Behavior(t *testing.T) {
|
||||
// Test that two-choice algorithm favors nodes with lower metrics
|
||||
// This test runs multiple iterations to increase confidence in the probabilistic behavior
|
||||
selector := &AnySelector{
|
||||
SortBy: "cpuload",
|
||||
Algorithm: "twochoice",
|
||||
}
|
||||
|
||||
// Create nodes where node2 has significantly lower CPU load
|
||||
nodes := []*livekit.Node{
|
||||
createTestNode("node1", 0.95, 10, 20, livekit.NodeState_SERVING), // Very high load
|
||||
createTestNode("node2", 0.1, 1, 2, livekit.NodeState_SERVING), // Low load
|
||||
createTestNode("node3", 0.5, 9, 18, livekit.NodeState_SERVING), // Medium load
|
||||
createTestNode("node4", 0.85, 8, 16, livekit.NodeState_SERVING), // High load
|
||||
}
|
||||
|
||||
// Run multiple selections and count how often the low-load node is selected
|
||||
iterations := 1000
|
||||
lowLoadSelections := 0
|
||||
higestLoadSelections := 0
|
||||
|
||||
for range iterations {
|
||||
node, err := selector.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
|
||||
if node.Id == "node2" {
|
||||
lowLoadSelections++
|
||||
}
|
||||
if node.Id == "node1" {
|
||||
higestLoadSelections++
|
||||
}
|
||||
}
|
||||
|
||||
// The low-load node should be selected more often than pure random (25%)
|
||||
// Due to the two-choice algorithm favoring the better node
|
||||
selectionRate := float64(lowLoadSelections) / float64(iterations)
|
||||
require.Greater(t, selectionRate, 0.4, "Two-choice algorithm should favor the low-load node more than random selection")
|
||||
require.Equal(t, higestLoadSelections, 0, "Two-choice algorithm should never favor the highest load node")
|
||||
}
|
||||
|
||||
func TestAnySelector_SelectNode_InvalidParameters(t *testing.T) {
|
||||
nodes := []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 3, 10, livekit.NodeState_SERVING),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sortBy string
|
||||
algorithm string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty sortBy",
|
||||
sortBy: "",
|
||||
algorithm: "twochoice",
|
||||
wantErr: "sort by option cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "empty algorithm",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "",
|
||||
wantErr: "node selector algorithm option cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "unknown sortBy",
|
||||
sortBy: "invalid",
|
||||
algorithm: "twochoice",
|
||||
wantErr: "unknown sort by option",
|
||||
},
|
||||
{
|
||||
name: "unknown algorithm",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "invalid",
|
||||
wantErr: "unknown node selector algorithm option",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector := &AnySelector{
|
||||
SortBy: tt.sortBy,
|
||||
Algorithm: tt.algorithm,
|
||||
}
|
||||
|
||||
node, err := selector.SelectNode(nodes)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
require.Nil(t, node)
|
||||
})
|
||||
}
|
||||
}
|
||||
47
pkg/routing/selector/cpuload.go
Normal file
47
pkg/routing/selector/cpuload.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// CPULoadSelector eliminates nodes that have CPU usage higher than CPULoadLimit
|
||||
// then selects a node from nodes that are not overloaded
|
||||
type CPULoadSelector struct {
|
||||
CPULoadLimit float32
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func (s *CPULoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) {
|
||||
nodes, err := FilterNodesByCriteria(nodes, s.CPULoadLimit, func(node *livekit.Node) float32 {
|
||||
return node.Stats.CpuLoad
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (s *CPULoadSelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes, err := s.filterNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
51
pkg/routing/selector/cpuload_test.go
Normal file
51
pkg/routing/selector/cpuload_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
func TestCPULoadSelector_SelectNode(t *testing.T) {
|
||||
sel := selector.CPULoadSelector{CPULoadLimit: 0.8, SortBy: "random", Algorithm: "lowest"}
|
||||
|
||||
var nodes []*livekit.Node
|
||||
_, err := sel.SelectNode(nodes)
|
||||
require.Error(t, err, "should error no available nodes")
|
||||
|
||||
// Select a node with high load when no nodes with low load are available
|
||||
nodes = []*livekit.Node{nodeLoadHigh}
|
||||
if _, err := sel.SelectNode(nodes); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Select a node with low load when available
|
||||
nodes = []*livekit.Node{nodeLoadLow, nodeLoadHigh}
|
||||
for range 5 {
|
||||
node, err := sel.SelectNode(nodes)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node != nodeLoadLow {
|
||||
t.Error("selected the wrong node")
|
||||
}
|
||||
}
|
||||
}
|
||||
27
pkg/routing/selector/errors.go
Normal file
27
pkg/routing/selector/errors.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoAvailableNodes = errors.New("could not find any available nodes")
|
||||
ErrCurrentRegionNotSet = errors.New("current region cannot be blank")
|
||||
ErrCurrentRegionUnknownLatLon = errors.New("unknown lat and lon for the current region")
|
||||
ErrSortByNotSet = errors.New("sort by option cannot be blank")
|
||||
ErrAlgorithmNotSet = errors.New("node selector algorithm option cannot be blank")
|
||||
ErrSortByUnknown = errors.New("unknown sort by option")
|
||||
ErrAlgorithmUnknown = errors.New("unknown node selector algorithm option")
|
||||
)
|
||||
66
pkg/routing/selector/interfaces.go
Normal file
66
pkg/routing/selector/interfaces.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
var ErrUnsupportedSelector = errors.New("unsupported node selector")
|
||||
|
||||
// NodeSelector selects an appropriate node to run the current session
|
||||
type NodeSelector interface {
|
||||
SelectNode(nodes []*livekit.Node) (*livekit.Node, error)
|
||||
}
|
||||
|
||||
func CreateNodeSelector(conf *config.Config) (NodeSelector, error) {
|
||||
kind := conf.NodeSelector.Kind
|
||||
if kind == "" {
|
||||
kind = "any"
|
||||
}
|
||||
switch kind {
|
||||
case "any":
|
||||
return &AnySelector{conf.NodeSelector.SortBy, conf.NodeSelector.Algorithm}, nil
|
||||
case "cpuload":
|
||||
return &CPULoadSelector{
|
||||
CPULoadLimit: conf.NodeSelector.CPULoadLimit,
|
||||
SortBy: conf.NodeSelector.SortBy,
|
||||
Algorithm: conf.NodeSelector.Algorithm,
|
||||
}, nil
|
||||
case "sysload":
|
||||
return &SystemLoadSelector{
|
||||
SysloadLimit: conf.NodeSelector.SysloadLimit,
|
||||
SortBy: conf.NodeSelector.SortBy,
|
||||
Algorithm: conf.NodeSelector.Algorithm,
|
||||
}, nil
|
||||
case "regionaware":
|
||||
s, err := NewRegionAwareSelector(conf.Region, conf.NodeSelector.Regions, conf.NodeSelector.SortBy, conf.NodeSelector.Algorithm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.SysloadLimit = conf.NodeSelector.SysloadLimit
|
||||
return s, nil
|
||||
case "random":
|
||||
logger.Warnw("random node selector is deprecated, please switch to \"any\" or another selector", nil)
|
||||
return &AnySelector{conf.NodeSelector.SortBy, conf.NodeSelector.Algorithm}, nil
|
||||
default:
|
||||
return nil, ErrUnsupportedSelector
|
||||
}
|
||||
}
|
||||
21
pkg/routing/selector/nodesfilter.go
Normal file
21
pkg/routing/selector/nodesfilter.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package selector
|
||||
|
||||
import "github.com/livekit/protocol/livekit"
|
||||
|
||||
func FilterNodesByCriteria(nodes []*livekit.Node, criteriaThreshold float32, calculateCriteriaFunc func(*livekit.Node) float32) ([]*livekit.Node, error) {
|
||||
nodes = GetAvailableNodes(nodes)
|
||||
if len(nodes) == 0 {
|
||||
return nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
filteredNodes := make([]*livekit.Node, 0)
|
||||
for _, node := range nodes {
|
||||
if calculateCriteriaFunc(node) < criteriaThreshold {
|
||||
filteredNodes = append(filteredNodes, node)
|
||||
}
|
||||
}
|
||||
if len(filteredNodes) > 0 {
|
||||
nodes = filteredNodes
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
127
pkg/routing/selector/regionaware.go
Normal file
127
pkg/routing/selector/regionaware.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
// RegionAwareSelector prefers available nodes that are closest to the region of the current instance
|
||||
type RegionAwareSelector struct {
|
||||
SystemLoadSelector
|
||||
CurrentRegion string
|
||||
regionDistances map[string]float64
|
||||
regions []config.RegionConfig
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func NewRegionAwareSelector(currentRegion string, regions []config.RegionConfig, sortBy string, algorithm string) (*RegionAwareSelector, error) {
|
||||
if currentRegion == "" {
|
||||
return nil, ErrCurrentRegionNotSet
|
||||
}
|
||||
// build internal map of distances
|
||||
s := &RegionAwareSelector{
|
||||
CurrentRegion: currentRegion,
|
||||
regionDistances: make(map[string]float64),
|
||||
regions: regions,
|
||||
SortBy: sortBy,
|
||||
Algorithm: algorithm,
|
||||
}
|
||||
|
||||
var currentRC *config.RegionConfig
|
||||
|
||||
for _, region := range regions {
|
||||
if region.Name == currentRegion {
|
||||
currentRC = ®ion
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentRC == nil && len(regions) > 0 {
|
||||
return nil, ErrCurrentRegionUnknownLatLon
|
||||
}
|
||||
|
||||
if currentRC != nil {
|
||||
for _, region := range regions {
|
||||
s.regionDistances[region.Name] = distanceBetween(currentRC.Lat, currentRC.Lon, region.Lat, region.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *RegionAwareSelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes, err := s.SystemLoadSelector.filterNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// find nodes nearest to current region
|
||||
var nearestNodes []*livekit.Node
|
||||
nearestRegion := ""
|
||||
minDist := math.MaxFloat64
|
||||
for _, node := range nodes {
|
||||
if node.Region == nearestRegion {
|
||||
nearestNodes = append(nearestNodes, node)
|
||||
continue
|
||||
}
|
||||
if dist, ok := s.regionDistances[node.Region]; ok {
|
||||
if dist < minDist {
|
||||
minDist = dist
|
||||
nearestRegion = node.Region
|
||||
nearestNodes = nearestNodes[:0]
|
||||
nearestNodes = append(nearestNodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(nearestNodes) > 0 {
|
||||
nodes = nearestNodes
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
|
||||
// haversine(θ) function
|
||||
func hsin(theta float64) float64 {
|
||||
return math.Pow(math.Sin(theta/2), 2)
|
||||
}
|
||||
|
||||
var piBy180 = math.Pi / 180
|
||||
|
||||
// Haversine Distance Formula
|
||||
// http://en.wikipedia.org/wiki/Haversine_formula
|
||||
// from https://gist.github.com/cdipaolo/d3f8db3848278b49db68
|
||||
func distanceBetween(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
// convert to radians
|
||||
// must cast radius as float to multiply later
|
||||
var la1, lo1, la2, lo2, r float64
|
||||
la1 = lat1 * piBy180
|
||||
lo1 = lon1 * piBy180
|
||||
la2 = lat2 * piBy180
|
||||
lo2 = lon2 * piBy180
|
||||
|
||||
r = 6378100 // Earth radius in METERS
|
||||
|
||||
// calculate
|
||||
h := hsin(la2-la1) + math.Cos(la1)*math.Cos(la2)*hsin(lo2-lo1)
|
||||
|
||||
return 2 * r * math.Asin(math.Sqrt(h))
|
||||
}
|
||||
166
pkg/routing/selector/regionaware_test.go
Normal file
166
pkg/routing/selector/regionaware_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
const (
|
||||
loadLimit = 0.5
|
||||
regionWest = "us-west"
|
||||
regionEast = "us-east"
|
||||
regionSeattle = "seattle"
|
||||
sortBy = "random"
|
||||
algorithm = "lowest"
|
||||
)
|
||||
|
||||
func TestRegionAwareRouting(t *testing.T) {
|
||||
rc := []config.RegionConfig{
|
||||
{
|
||||
Name: regionWest,
|
||||
Lat: 37.64046607830567,
|
||||
Lon: -120.88026233189062,
|
||||
},
|
||||
{
|
||||
Name: regionEast,
|
||||
Lat: 40.68914362140307,
|
||||
Lon: -74.04445748616385,
|
||||
},
|
||||
{
|
||||
Name: regionSeattle,
|
||||
Lat: 47.620426730945454,
|
||||
Lon: -122.34938468973702,
|
||||
},
|
||||
}
|
||||
t.Run("works without region config", func(t *testing.T) {
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion("", false),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, nil, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
})
|
||||
|
||||
t.Run("picks available nodes in same region", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionEast, true)
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionSeattle, true),
|
||||
newTestNodeInRegion(regionWest, true),
|
||||
expectedNode,
|
||||
newTestNodeInRegion(regionEast, false),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("picks available nodes in same region when current node is first in the list", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionEast, true)
|
||||
nodes := []*livekit.Node{
|
||||
expectedNode,
|
||||
newTestNodeInRegion(regionSeattle, true),
|
||||
newTestNodeInRegion(regionWest, true),
|
||||
newTestNodeInRegion(regionEast, false),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("picks closest node in a diff region", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionWest, true)
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionSeattle, false),
|
||||
expectedNode,
|
||||
newTestNodeInRegion(regionEast, true),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionSeattle, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("handles multiple nodes in same region", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionWest, true)
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionSeattle, false),
|
||||
newTestNodeInRegion(regionEast, true),
|
||||
newTestNodeInRegion(regionEast, true),
|
||||
expectedNode,
|
||||
expectedNode,
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionSeattle, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("functions when current region is full", func(t *testing.T) {
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionWest, true),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
})
|
||||
}
|
||||
|
||||
func newTestNodeInRegion(region string, available bool) *livekit.Node {
|
||||
load := float32(0.4)
|
||||
if !available {
|
||||
load = 1.0
|
||||
}
|
||||
return &livekit.Node{
|
||||
Id: guid.New(utils.NodePrefix),
|
||||
Region: region,
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
LoadAvgLast1Min: load,
|
||||
},
|
||||
}
|
||||
}
|
||||
64
pkg/routing/selector/sortby_test.go
Normal file
64
pkg/routing/selector/sortby_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
func SortByTest(t *testing.T, sortBy string) {
|
||||
sel := selector.SystemLoadSelector{SortBy: sortBy, Algorithm: "lowest"}
|
||||
nodes := []*livekit.Node{nodeLoadLow, nodeLoadMedium, nodeLoadHigh}
|
||||
|
||||
for range 5 {
|
||||
node, err := sel.SelectNode(nodes)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node != nodeLoadLow {
|
||||
t.Error("selected the wrong node for SortBy:", sortBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByErrors(t *testing.T) {
|
||||
sel := selector.SystemLoadSelector{Algorithm: "lowest"}
|
||||
nodes := []*livekit.Node{nodeLoadLow, nodeLoadMedium, nodeLoadHigh}
|
||||
|
||||
// Test unset sort by option error
|
||||
_, err := sel.SelectNode(nodes)
|
||||
if err != selector.ErrSortByNotSet {
|
||||
t.Error("shouldn't allow empty sortBy")
|
||||
}
|
||||
|
||||
// Test unknown sort by option error
|
||||
sel.SortBy = "testFail"
|
||||
_, err = sel.SelectNode(nodes)
|
||||
if err != selector.ErrSortByUnknown {
|
||||
t.Error("shouldn't allow unknown sortBy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortBy(t *testing.T) {
|
||||
sortByTests := []string{"sysload", "cpuload", "rooms", "clients", "tracks", "bytespersec"}
|
||||
|
||||
for _, sortBy := range sortByTests {
|
||||
SortByTest(t, sortBy)
|
||||
}
|
||||
}
|
||||
45
pkg/routing/selector/sysload.go
Normal file
45
pkg/routing/selector/sysload.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// SystemLoadSelector eliminates nodes that surpass has a per-cpu node higher than SysloadLimit
|
||||
// then selects a node from nodes that are not overloaded
|
||||
type SystemLoadSelector struct {
|
||||
SysloadLimit float32
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func (s *SystemLoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) {
|
||||
nodes, err := FilterNodesByCriteria(nodes, s.SysloadLimit, GetNodeSysload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (s *SystemLoadSelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes, err := s.filterNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
114
pkg/routing/selector/sysload_test.go
Normal file
114
pkg/routing/selector/sysload_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
var (
|
||||
nodeLoadLow = &livekit.Node{
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
CpuLoad: 0.1,
|
||||
LoadAvgLast1Min: 0.0,
|
||||
NumRooms: 1,
|
||||
NumClients: 2,
|
||||
NumTracksIn: 4,
|
||||
NumTracksOut: 8,
|
||||
Rates: []*livekit.NodeStatsRate{
|
||||
{
|
||||
BytesIn: 1000,
|
||||
BytesOut: 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodeLoadMedium = &livekit.Node{
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
CpuLoad: 0.5,
|
||||
LoadAvgLast1Min: 0.5,
|
||||
NumRooms: 5,
|
||||
NumClients: 10,
|
||||
NumTracksIn: 20,
|
||||
NumTracksOut: 200,
|
||||
Rates: []*livekit.NodeStatsRate{
|
||||
{
|
||||
BytesIn: 5000,
|
||||
BytesOut: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodeLoadHigh = &livekit.Node{
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
CpuLoad: 0.99,
|
||||
LoadAvgLast1Min: 2.0,
|
||||
NumRooms: 10,
|
||||
NumClients: 20,
|
||||
NumTracksIn: 40,
|
||||
NumTracksOut: 800,
|
||||
Rates: []*livekit.NodeStatsRate{
|
||||
{
|
||||
BytesIn: 10000,
|
||||
BytesOut: 40000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestSystemLoadSelector_SelectNode(t *testing.T) {
|
||||
sel := selector.SystemLoadSelector{SysloadLimit: 1.0, SortBy: "random", Algorithm: "lowest"}
|
||||
|
||||
var nodes []*livekit.Node
|
||||
_, err := sel.SelectNode(nodes)
|
||||
require.Error(t, err, "should error no available nodes")
|
||||
|
||||
// Select a node with high load when no nodes with low load are available
|
||||
nodes = []*livekit.Node{nodeLoadHigh}
|
||||
if _, err := sel.SelectNode(nodes); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Select a node with low load when available
|
||||
nodes = []*livekit.Node{nodeLoadLow, nodeLoadHigh}
|
||||
for range 5 {
|
||||
node, err := sel.SelectNode(nodes)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node != nodeLoadLow {
|
||||
t.Error("selected the wrong node")
|
||||
}
|
||||
}
|
||||
}
|
||||
179
pkg/routing/selector/utils.go
Normal file
179
pkg/routing/selector/utils.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/thoas/go-funk"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
)
|
||||
|
||||
const AvailableSeconds = 5
|
||||
|
||||
// checks if a node has been updated recently to be considered for selection
|
||||
func IsAvailable(node *livekit.Node) bool {
|
||||
if node.Stats == nil {
|
||||
// available till stats are available
|
||||
return true
|
||||
}
|
||||
|
||||
delta := time.Now().Unix() - node.Stats.UpdatedAt
|
||||
return int(delta) < AvailableSeconds
|
||||
}
|
||||
|
||||
func GetAvailableNodes(nodes []*livekit.Node) []*livekit.Node {
|
||||
return funk.Filter(nodes, func(node *livekit.Node) bool {
|
||||
return IsAvailable(node) && node.State == livekit.NodeState_SERVING
|
||||
}).([]*livekit.Node)
|
||||
}
|
||||
|
||||
func GetNodeSysload(node *livekit.Node) float32 {
|
||||
stats := node.Stats
|
||||
numCpus := stats.NumCpus
|
||||
if numCpus == 0 {
|
||||
numCpus = 1
|
||||
}
|
||||
return stats.LoadAvgLast1Min / float32(numCpus)
|
||||
}
|
||||
|
||||
// TODO: check remote node configured limit, instead of this node's config
|
||||
func LimitsReached(limitConfig config.LimitConfig, nodeStats *livekit.NodeStats) bool {
|
||||
if nodeStats == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if limitConfig.NumTracks > 0 && limitConfig.NumTracks <= nodeStats.NumTracksIn+nodeStats.NumTracksOut {
|
||||
return true
|
||||
}
|
||||
|
||||
rate := &livekit.NodeStatsRate{}
|
||||
if len(nodeStats.Rates) > 0 {
|
||||
rate = nodeStats.Rates[0]
|
||||
}
|
||||
if limitConfig.BytesPerSec > 0 && limitConfig.BytesPerSec <= rate.BytesIn+rate.BytesOut {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SelectSortedNode(nodes []*livekit.Node, sortBy string, algorithm string) (*livekit.Node, error) {
|
||||
if sortBy == "" {
|
||||
return nil, ErrSortByNotSet
|
||||
}
|
||||
if algorithm == "" {
|
||||
return nil, ErrAlgorithmNotSet
|
||||
}
|
||||
|
||||
switch algorithm {
|
||||
case "lowest": // examine all nodes and select the lowest based on sort criteria
|
||||
return selectLowestSortedNode(nodes, sortBy)
|
||||
case "twochoice": // randomly select two nodes and return the lowest based on sort criteria "Power of Two Random Choices"
|
||||
return selectTwoChoiceSortedNode(nodes, sortBy)
|
||||
default:
|
||||
return nil, ErrAlgorithmUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func selectTwoChoiceSortedNode(nodes []*livekit.Node, sortBy string) (*livekit.Node, error) {
|
||||
if len(nodes) <= 2 {
|
||||
return selectLowestSortedNode(nodes, sortBy)
|
||||
}
|
||||
|
||||
// randomly select two nodes
|
||||
node1, node2, err := selectTwoRandomNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// compare the two nodes based on the sort criteria
|
||||
if node1 == nil || node2 == nil {
|
||||
return nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
selectedNode, err := selectLowestSortedNode([]*livekit.Node{node1, node2}, sortBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return selectedNode, nil
|
||||
}
|
||||
|
||||
func selectLowestSortedNode(nodes []*livekit.Node, sortBy string) (*livekit.Node, error) {
|
||||
// Return a node based on what it should be sorted by for priority
|
||||
switch sortBy {
|
||||
case "random":
|
||||
idx := funk.RandomInt(0, len(nodes))
|
||||
return nodes[idx], nil
|
||||
case "sysload":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(GetNodeSysload(a) - GetNodeSysload(b))
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "cpuload":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(a.Stats.CpuLoad - b.Stats.CpuLoad)
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "rooms":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(a.Stats.NumRooms - b.Stats.NumRooms)
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "clients":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(a.Stats.NumClients - b.Stats.NumClients)
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "tracks":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum((a.Stats.NumTracksIn + a.Stats.NumTracksOut) - (b.Stats.NumTracksIn + b.Stats.NumTracksOut))
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "bytespersec":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
ratea := &livekit.NodeStatsRate{}
|
||||
if len(a.Stats.Rates) > 0 {
|
||||
ratea = a.Stats.Rates[0]
|
||||
}
|
||||
|
||||
rateb := &livekit.NodeStatsRate{}
|
||||
if len(b.Stats.Rates) > 0 {
|
||||
rateb = b.Stats.Rates[0]
|
||||
}
|
||||
return utils.Signum((ratea.BytesIn + ratea.BytesOut) - (rateb.BytesIn + rateb.BytesOut))
|
||||
})
|
||||
return nodes[0], nil
|
||||
default:
|
||||
return nil, ErrSortByUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func selectTwoRandomNodes(nodes []*livekit.Node) (*livekit.Node, *livekit.Node, error) {
|
||||
if len(nodes) < 2 {
|
||||
return nil, nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
shuffledIndices := rand.Perm(len(nodes))
|
||||
|
||||
return nodes[shuffledIndices[0]], nodes[shuffledIndices[1]], nil
|
||||
}
|
||||
46
pkg/routing/selector/utils_test.go
Normal file
46
pkg/routing/selector/utils_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
func TestIsAvailable(t *testing.T) {
|
||||
t.Run("still available", func(t *testing.T) {
|
||||
n := &livekit.Node{
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 3,
|
||||
},
|
||||
}
|
||||
require.True(t, selector.IsAvailable(n))
|
||||
})
|
||||
|
||||
t.Run("expired", func(t *testing.T) {
|
||||
n := &livekit.Node{
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 20,
|
||||
},
|
||||
}
|
||||
require.False(t, selector.IsAvailable(n))
|
||||
})
|
||||
}
|
||||
385
pkg/routing/signal.go
Normal file
385
pkg/routing/signal.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/psrpc"
|
||||
"github.com/livekit/psrpc/pkg/middleware"
|
||||
)
|
||||
|
||||
var ErrSignalWriteFailed = errors.New("signal write failed")
|
||||
var ErrSignalMessageDropped = errors.New("signal message dropped")
|
||||
|
||||
//counterfeiter:generate . SignalClient
|
||||
type SignalClient interface {
|
||||
ActiveCount() int
|
||||
StartParticipantSignal(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit, nodeID livekit.NodeID) (connectionID livekit.ConnectionID, reqSink MessageSink, resSource MessageSource, err error)
|
||||
}
|
||||
|
||||
type signalClient struct {
|
||||
nodeID livekit.NodeID
|
||||
config config.SignalRelayConfig
|
||||
client rpc.TypedSignalClient
|
||||
active atomic.Int32
|
||||
}
|
||||
|
||||
func NewSignalClient(nodeID livekit.NodeID, bus psrpc.MessageBus, config config.SignalRelayConfig) (SignalClient, error) {
|
||||
client, err := rpc.NewTypedSignalClient(
|
||||
nodeID,
|
||||
bus,
|
||||
middleware.WithClientMetrics(rpc.PSRPCMetricsObserver{}),
|
||||
psrpc.WithClientChannelSize(config.StreamBufferSize),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &signalClient{
|
||||
nodeID: nodeID,
|
||||
config: config,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *signalClient) ActiveCount() int {
|
||||
return int(r.active.Load())
|
||||
}
|
||||
|
||||
func (r *signalClient) StartParticipantSignal(
|
||||
ctx context.Context,
|
||||
roomName livekit.RoomName,
|
||||
pi ParticipantInit,
|
||||
nodeID livekit.NodeID,
|
||||
) (
|
||||
connectionID livekit.ConnectionID,
|
||||
reqSink MessageSink,
|
||||
resSource MessageSource,
|
||||
err error,
|
||||
) {
|
||||
connectionID = livekit.ConnectionID(guid.New("CO_"))
|
||||
ss, err := pi.ToStartSession(roomName, connectionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
l := utils.GetLogger(ctx).WithValues(
|
||||
"room", roomName,
|
||||
"reqNodeID", nodeID,
|
||||
"participant", pi.Identity,
|
||||
"connID", connectionID,
|
||||
"participantInit", &pi,
|
||||
"startSession", logger.Proto(ss),
|
||||
)
|
||||
|
||||
l.Debugw("starting signal connection")
|
||||
|
||||
stream, err := r.client.RelaySignal(ctx, nodeID)
|
||||
if err != nil {
|
||||
prometheus.RecordSignalRequestFailure()
|
||||
return
|
||||
}
|
||||
|
||||
err = stream.Send(&rpc.RelaySignalRequest{StartSession: ss})
|
||||
if err != nil {
|
||||
stream.Close(err)
|
||||
prometheus.RecordSignalRequestFailure()
|
||||
return
|
||||
}
|
||||
|
||||
sink := NewSignalMessageSink(SignalSinkParams[*rpc.RelaySignalRequest, *rpc.RelaySignalResponse]{
|
||||
Logger: l,
|
||||
Stream: stream,
|
||||
Config: r.config,
|
||||
Writer: signalRequestMessageWriter{},
|
||||
CloseOnFailure: true,
|
||||
BlockOnClose: true,
|
||||
ConnectionID: connectionID,
|
||||
})
|
||||
resChan := NewDefaultMessageChannel(connectionID)
|
||||
|
||||
go func() {
|
||||
r.active.Inc()
|
||||
defer r.active.Dec()
|
||||
|
||||
err := CopySignalStreamToMessageChannel[*rpc.RelaySignalRequest, *rpc.RelaySignalResponse](
|
||||
stream,
|
||||
resChan,
|
||||
signalResponseMessageReader{},
|
||||
r.config,
|
||||
prometheus.RecordSignalResponseSuccess,
|
||||
prometheus.RecordSignalResponseFailure,
|
||||
)
|
||||
l.Debugw("signal stream closed", "error", err)
|
||||
|
||||
resChan.Close()
|
||||
}()
|
||||
|
||||
return connectionID, sink, resChan, nil
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
|
||||
type signalRequestMessageWriter struct{}
|
||||
|
||||
func (e signalRequestMessageWriter) Write(seq uint64, close bool, msgs []proto.Message) *rpc.RelaySignalRequest {
|
||||
r := &rpc.RelaySignalRequest{
|
||||
Seq: seq,
|
||||
Requests: make([]*livekit.SignalRequest, 0, len(msgs)),
|
||||
Close: close,
|
||||
}
|
||||
for _, m := range msgs {
|
||||
r.Requests = append(r.Requests, m.(*livekit.SignalRequest))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
|
||||
type signalResponseMessageReader struct{}
|
||||
|
||||
func (e signalResponseMessageReader) Read(rm *rpc.RelaySignalResponse) ([]proto.Message, error) {
|
||||
msgs := make([]proto.Message, 0, len(rm.Responses))
|
||||
for _, m := range rm.Responses {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
|
||||
type RelaySignalMessage interface {
|
||||
proto.Message
|
||||
GetSeq() uint64
|
||||
GetClose() bool
|
||||
}
|
||||
|
||||
type SignalMessageWriter[SendType RelaySignalMessage] interface {
|
||||
Write(seq uint64, close bool, msgs []proto.Message) SendType
|
||||
}
|
||||
|
||||
type SignalMessageReader[RecvType RelaySignalMessage] interface {
|
||||
Read(msg RecvType) ([]proto.Message, error)
|
||||
}
|
||||
|
||||
func CopySignalStreamToMessageChannel[SendType, RecvType RelaySignalMessage](
|
||||
stream psrpc.Stream[SendType, RecvType],
|
||||
ch *MessageChannel,
|
||||
reader SignalMessageReader[RecvType],
|
||||
config config.SignalRelayConfig,
|
||||
promSignalSuccess func(),
|
||||
promSignalFailure func(),
|
||||
) error {
|
||||
r := &signalMessageReader[SendType, RecvType]{
|
||||
reader: reader,
|
||||
config: config,
|
||||
}
|
||||
for msg := range stream.Channel() {
|
||||
res, err := r.Read(msg)
|
||||
if err != nil {
|
||||
promSignalFailure()
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range res {
|
||||
if err = ch.WriteMessage(r); err != nil {
|
||||
promSignalFailure()
|
||||
return err
|
||||
}
|
||||
promSignalSuccess()
|
||||
}
|
||||
|
||||
if msg.GetClose() {
|
||||
return stream.Close(nil)
|
||||
}
|
||||
}
|
||||
return stream.Err()
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
type signalMessageReader[SendType, RecvType RelaySignalMessage] struct {
|
||||
seq uint64
|
||||
reader SignalMessageReader[RecvType]
|
||||
config config.SignalRelayConfig
|
||||
}
|
||||
|
||||
func (r *signalMessageReader[SendType, RecvType]) Read(msg RecvType) ([]proto.Message, error) {
|
||||
res, err := r.reader.Read(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.seq < msg.GetSeq() {
|
||||
return nil, ErrSignalMessageDropped
|
||||
}
|
||||
if r.seq > msg.GetSeq() {
|
||||
n := int(r.seq - msg.GetSeq())
|
||||
if n > len(res) {
|
||||
n = len(res)
|
||||
}
|
||||
res = res[n:]
|
||||
}
|
||||
r.seq += uint64(len(res))
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
type SignalSinkParams[SendType, RecvType RelaySignalMessage] struct {
|
||||
Stream psrpc.Stream[SendType, RecvType]
|
||||
Logger logger.Logger
|
||||
Config config.SignalRelayConfig
|
||||
Writer SignalMessageWriter[SendType]
|
||||
CloseOnFailure bool
|
||||
BlockOnClose bool
|
||||
ConnectionID livekit.ConnectionID
|
||||
}
|
||||
|
||||
func NewSignalMessageSink[SendType, RecvType RelaySignalMessage](params SignalSinkParams[SendType, RecvType]) MessageSink {
|
||||
return &signalMessageSink[SendType, RecvType]{
|
||||
SignalSinkParams: params,
|
||||
}
|
||||
}
|
||||
|
||||
type signalMessageSink[SendType, RecvType RelaySignalMessage] struct {
|
||||
SignalSinkParams[SendType, RecvType]
|
||||
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
queue []proto.Message
|
||||
writing bool
|
||||
draining bool
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) Close() {
|
||||
s.mu.Lock()
|
||||
s.draining = true
|
||||
if !s.writing {
|
||||
s.writing = true
|
||||
go s.write()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// conditionally block while closing to wait for outgoing messages to drain
|
||||
//
|
||||
// on media the signal sink shares a goroutine with other signal connection
|
||||
// attempts from the same participant so blocking delays establishing new
|
||||
// sessions during reconnect.
|
||||
//
|
||||
// on controller closing without waiting for the outstanding messages to
|
||||
// drain causes leave messages to be dropped from the write queue. when
|
||||
// this happens other participants in the room aren't notified about the
|
||||
// departure until the participant times out.
|
||||
if s.BlockOnClose {
|
||||
<-s.Stream.Context().Done()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) IsClosed() bool {
|
||||
return s.Stream.Err() != nil
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) write() {
|
||||
interval := s.Config.MinRetryInterval
|
||||
deadline := time.Now().Add(s.Config.RetryTimeout)
|
||||
var err error
|
||||
|
||||
s.mu.Lock()
|
||||
for {
|
||||
close := s.draining
|
||||
if (!close && len(s.queue) == 0) || s.IsClosed() {
|
||||
break
|
||||
}
|
||||
msg, n := s.Writer.Write(s.seq, close, s.queue), len(s.queue)
|
||||
s.mu.Unlock()
|
||||
|
||||
err = s.Stream.Send(msg, psrpc.WithTimeout(interval))
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
s.Logger.Warnw("could not send signal message", err)
|
||||
|
||||
s.mu.Lock()
|
||||
s.seq += uint64(len(s.queue))
|
||||
s.queue = nil
|
||||
break
|
||||
}
|
||||
|
||||
interval *= 2
|
||||
if interval > s.Config.MaxRetryInterval {
|
||||
interval = s.Config.MaxRetryInterval
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if err == nil {
|
||||
interval = s.Config.MinRetryInterval
|
||||
deadline = time.Now().Add(s.Config.RetryTimeout)
|
||||
|
||||
s.seq += uint64(n)
|
||||
s.queue = s.queue[n:]
|
||||
|
||||
if close {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.writing = false
|
||||
if s.draining {
|
||||
s.Stream.Close(nil)
|
||||
}
|
||||
if err != nil && s.CloseOnFailure {
|
||||
s.Stream.Close(ErrSignalWriteFailed)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) WriteMessage(msg proto.Message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.Stream.Err(); err != nil {
|
||||
return err
|
||||
} else if s.draining {
|
||||
return psrpc.ErrStreamClosed
|
||||
}
|
||||
|
||||
s.queue = append(s.queue, msg)
|
||||
if !s.writing {
|
||||
s.writing = true
|
||||
go s.write()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) ConnectionID() livekit.ConnectionID {
|
||||
return s.SignalSinkParams.ConnectionID
|
||||
}
|
||||
158
pkg/rtc/clientinfo.go
Normal file
158
pkg/rtc/clientinfo.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type ClientInfo struct {
|
||||
*livekit.ClientInfo
|
||||
}
|
||||
|
||||
func (c ClientInfo) isFirefox() bool {
|
||||
return c.ClientInfo != nil && (strings.EqualFold(c.ClientInfo.Browser, "firefox") || strings.EqualFold(c.ClientInfo.Browser, "firefox mobile"))
|
||||
}
|
||||
|
||||
func (c ClientInfo) isSafari() bool {
|
||||
return c.ClientInfo != nil && strings.EqualFold(c.ClientInfo.Browser, "safari")
|
||||
}
|
||||
|
||||
func (c ClientInfo) isGo() bool {
|
||||
return c.ClientInfo != nil && c.ClientInfo.Sdk == livekit.ClientInfo_GO
|
||||
}
|
||||
|
||||
func (c ClientInfo) isLinux() bool {
|
||||
return c.ClientInfo != nil && strings.EqualFold(c.ClientInfo.Os, "linux")
|
||||
}
|
||||
|
||||
func (c ClientInfo) isAndroid() bool {
|
||||
return c.ClientInfo != nil && strings.EqualFold(c.ClientInfo.Os, "android")
|
||||
}
|
||||
|
||||
func (c ClientInfo) isOBS() bool {
|
||||
return c.ClientInfo != nil && strings.Contains(c.ClientInfo.Browser, "OBS")
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsAudioRED() bool {
|
||||
return !c.isFirefox() && !c.isSafari()
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsPrflxOverRelay() bool {
|
||||
return !c.isFirefox()
|
||||
}
|
||||
|
||||
// GoSDK(pion) relies on rtp packets to fire ontrack event, browsers and native (libwebrtc) rely on sdp
|
||||
func (c ClientInfo) FireTrackByRTPPacket() bool {
|
||||
return c.isGo()
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsCodecChange() bool {
|
||||
return c.ClientInfo != nil && c.ClientInfo.Sdk != livekit.ClientInfo_GO && c.ClientInfo.Sdk != livekit.ClientInfo_UNKNOWN
|
||||
}
|
||||
|
||||
func (c ClientInfo) CanHandleReconnectResponse() bool {
|
||||
if c.Sdk == livekit.ClientInfo_JS {
|
||||
// JS handles Reconnect explicitly in 1.6.3, prior to 1.6.4 it could not handle unknown responses
|
||||
if c.compareVersion("1.6.3") < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsICETCP() bool {
|
||||
if c.ClientInfo == nil {
|
||||
return false
|
||||
}
|
||||
if c.ClientInfo.Sdk == livekit.ClientInfo_GO {
|
||||
// Go does not support active TCP
|
||||
return false
|
||||
}
|
||||
if c.ClientInfo.Sdk == livekit.ClientInfo_SWIFT {
|
||||
// ICE/TCP added in 1.0.5
|
||||
return c.compareVersion("1.0.5") >= 0
|
||||
}
|
||||
// most SDKs support ICE/TCP
|
||||
return true
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsChangeRTPSenderEncodingActive() bool {
|
||||
return !c.isFirefox()
|
||||
}
|
||||
|
||||
func (c ClientInfo) ComplyWithCodecOrderInSDPAnswer() bool {
|
||||
return (!c.isLinux() && !c.isAndroid()) || !c.isFirefox()
|
||||
}
|
||||
|
||||
// Rust SDK can't decode unknown signal message (TrackSubscribed and ErrorResponse)
|
||||
func (c ClientInfo) SupportsTrackSubscribedEvent() bool {
|
||||
return c.ClientInfo.GetSdk() != livekit.ClientInfo_RUST || c.ClientInfo.GetProtocol() >= 10
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsRequestResponse() bool {
|
||||
return c.SupportsTrackSubscribedEvent()
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsSctpZeroChecksum() bool {
|
||||
return c.ClientInfo.GetSdk() != livekit.ClientInfo_UNKNOWN &&
|
||||
(!c.isGo() || c.compareVersion("2.4.0") >= 0)
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsTransceiverReuse() bool {
|
||||
return !c.isSafari()
|
||||
}
|
||||
|
||||
func (c ClientInfo) HasCapability(cap livekit.ClientInfo_Capability) bool {
|
||||
if c.ClientInfo == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(c.ClientInfo.Capabilities, cap)
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsPacketTrailer() bool {
|
||||
return c.HasCapability(livekit.ClientInfo_CAP_PACKET_TRAILER)
|
||||
}
|
||||
|
||||
// compareVersion compares a semver against the current client SDK version
|
||||
// returning 1 if current version is greater than version
|
||||
// 0 if they are the same, and -1 if it's an earlier version
|
||||
func (c ClientInfo) compareVersion(version string) int {
|
||||
if c.ClientInfo == nil {
|
||||
return -1
|
||||
}
|
||||
parts0 := strings.Split(c.ClientInfo.Version, ".")
|
||||
parts1 := strings.Split(version, ".")
|
||||
ints0 := make([]int, 3)
|
||||
ints1 := make([]int, 3)
|
||||
for i := range 3 {
|
||||
if len(parts0) > i {
|
||||
ints0[i], _ = strconv.Atoi(parts0[i])
|
||||
}
|
||||
if len(parts1) > i {
|
||||
ints1[i], _ = strconv.Atoi(parts1[i])
|
||||
}
|
||||
if ints0[i] > ints1[i] {
|
||||
return 1
|
||||
} else if ints0[i] < ints1[i] {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
59
pkg/rtc/clientinfo_test.go
Normal file
59
pkg/rtc/clientinfo_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2022 LiveKit, Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
func TestClientInfo_CompareVersion(t *testing.T) {
|
||||
c := ClientInfo{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Version: "1",
|
||||
},
|
||||
}
|
||||
require.Equal(t, 1, c.compareVersion("0.1.0"))
|
||||
require.Equal(t, 0, c.compareVersion("1.0.0"))
|
||||
require.Equal(t, -1, c.compareVersion("1.0.5"))
|
||||
}
|
||||
|
||||
func TestClientInfo_SupportsICETCP(t *testing.T) {
|
||||
t.Run("GO SDK cannot support TCP", func(t *testing.T) {
|
||||
c := ClientInfo{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Sdk: livekit.ClientInfo_GO,
|
||||
},
|
||||
}
|
||||
require.False(t, c.SupportsICETCP())
|
||||
})
|
||||
|
||||
t.Run("Swift SDK cannot support TCP before 1.0.5", func(t *testing.T) {
|
||||
c := ClientInfo{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Sdk: livekit.ClientInfo_SWIFT,
|
||||
Version: "1.0.4",
|
||||
},
|
||||
}
|
||||
require.False(t, c.SupportsICETCP())
|
||||
c.Version = "1.0.5"
|
||||
require.True(t, c.SupportsICETCP())
|
||||
})
|
||||
}
|
||||
207
pkg/rtc/config.go
Normal file
207
pkg/rtc/config.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
act "github.com/livekit/livekit-server/pkg/sfu/rtpextension/abscapturetime"
|
||||
dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor"
|
||||
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
|
||||
)
|
||||
|
||||
const (
|
||||
frameMarkingURI = "urn:ietf:params:rtp-hdrext:framemarking"
|
||||
repairedRTPStreamIDURI = "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id"
|
||||
)
|
||||
|
||||
type WebRTCConfig struct {
|
||||
rtcconfig.WebRTCConfig
|
||||
|
||||
BufferFactory *buffer.Factory
|
||||
Receiver ReceiverConfig
|
||||
Publisher DirectionConfig
|
||||
Subscriber DirectionConfig
|
||||
}
|
||||
|
||||
type ReceiverConfig struct {
|
||||
PacketBufferSizeVideo int
|
||||
PacketBufferSizeAudio int
|
||||
}
|
||||
|
||||
type RTPHeaderExtensionConfig struct {
|
||||
Audio []string
|
||||
Video []string
|
||||
}
|
||||
|
||||
type RTCPFeedbackConfig struct {
|
||||
Audio []webrtc.RTCPFeedback
|
||||
Video []webrtc.RTCPFeedback
|
||||
}
|
||||
|
||||
type DirectionConfig struct {
|
||||
RTPHeaderExtension RTPHeaderExtensionConfig
|
||||
RTCPFeedback RTCPFeedbackConfig
|
||||
}
|
||||
|
||||
func NewWebRTCConfig(conf *config.Config) (*WebRTCConfig, error) {
|
||||
rtcConf := conf.RTC
|
||||
|
||||
webRTCConfig, err := rtcconfig.NewWebRTCConfig(&rtcConf.RTCConfig, conf.Development)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// we don't want to use active TCP on a server, clients should be dialing
|
||||
webRTCConfig.SettingEngine.DisableActiveTCP(true)
|
||||
|
||||
if rtcConf.PacketBufferSize == 0 {
|
||||
rtcConf.PacketBufferSize = 500
|
||||
}
|
||||
if rtcConf.PacketBufferSizeVideo == 0 {
|
||||
rtcConf.PacketBufferSizeVideo = rtcConf.PacketBufferSize
|
||||
}
|
||||
if rtcConf.PacketBufferSizeAudio == 0 {
|
||||
rtcConf.PacketBufferSizeAudio = rtcConf.PacketBufferSize
|
||||
}
|
||||
|
||||
return &WebRTCConfig{
|
||||
WebRTCConfig: *webRTCConfig,
|
||||
Receiver: ReceiverConfig{
|
||||
PacketBufferSizeVideo: rtcConf.PacketBufferSizeVideo,
|
||||
PacketBufferSizeAudio: rtcConf.PacketBufferSizeAudio,
|
||||
},
|
||||
Publisher: getPublisherConfig(false),
|
||||
Subscriber: getSubscriberConfig(rtcConf.CongestionControl.UseSendSideBWEInterceptor || rtcConf.CongestionControl.UseSendSideBWE),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *WebRTCConfig) UpdatePublisherConfig(consolidated bool) {
|
||||
c.Publisher = getPublisherConfig(consolidated)
|
||||
}
|
||||
|
||||
func (c *WebRTCConfig) UpdateSubscriberConfig(ccConf config.CongestionControlConfig) {
|
||||
c.Subscriber = getSubscriberConfig(ccConf.UseSendSideBWEInterceptor || ccConf.UseSendSideBWE)
|
||||
}
|
||||
|
||||
func (c *WebRTCConfig) SetBufferFactory(factory *buffer.Factory) {
|
||||
c.BufferFactory = factory
|
||||
c.SettingEngine.BufferFactory = factory.GetOrNew
|
||||
}
|
||||
|
||||
func getPublisherConfig(consolidated bool) DirectionConfig {
|
||||
if consolidated {
|
||||
return DirectionConfig{
|
||||
RTPHeaderExtension: RTPHeaderExtensionConfig{
|
||||
Audio: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.AudioLevelURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
Video: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.TransportCCURI,
|
||||
sdp.ABSSendTimeURI,
|
||||
frameMarkingURI,
|
||||
dd.ExtensionURI,
|
||||
repairedRTPStreamIDURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
},
|
||||
RTCPFeedback: RTCPFeedbackConfig{
|
||||
Audio: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
},
|
||||
Video: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBTransportCC},
|
||||
{Type: webrtc.TypeRTCPFBGoogREMB},
|
||||
{Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"},
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return DirectionConfig{
|
||||
RTPHeaderExtension: RTPHeaderExtensionConfig{
|
||||
Audio: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.AudioLevelURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
Video: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.TransportCCURI,
|
||||
frameMarkingURI,
|
||||
dd.ExtensionURI,
|
||||
repairedRTPStreamIDURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
},
|
||||
RTCPFeedback: RTCPFeedbackConfig{
|
||||
Audio: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
},
|
||||
Video: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBTransportCC},
|
||||
{Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"},
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getSubscriberConfig(enableTWCC bool) DirectionConfig {
|
||||
subscriberConfig := DirectionConfig{
|
||||
RTPHeaderExtension: RTPHeaderExtensionConfig{
|
||||
Video: []string{
|
||||
dd.ExtensionURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
Audio: []string{
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
},
|
||||
RTCPFeedback: RTCPFeedbackConfig{
|
||||
Audio: []webrtc.RTCPFeedback{
|
||||
// always enable NACK for audio but disable it later for red enabled transceiver. https://github.com/pion/webrtc/pull/2972
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
},
|
||||
Video: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"},
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if enableTWCC {
|
||||
subscriberConfig.RTPHeaderExtension.Video = append(subscriberConfig.RTPHeaderExtension.Video, sdp.TransportCCURI)
|
||||
subscriberConfig.RTCPFeedback.Video = append(subscriberConfig.RTCPFeedback.Video, webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBTransportCC})
|
||||
} else {
|
||||
subscriberConfig.RTPHeaderExtension.Video = append(subscriberConfig.RTPHeaderExtension.Video, sdp.ABSSendTimeURI)
|
||||
subscriberConfig.RTCPFeedback.Video = append(subscriberConfig.RTCPFeedback.Video, webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBGoogREMB})
|
||||
}
|
||||
|
||||
return subscriberConfig
|
||||
}
|
||||
137
pkg/rtc/datadowntrack.go
Normal file
137
pkg/rtc/datadowntrack.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ types.DataDownTrack = (*DataDownTrack)(nil)
|
||||
var _ types.DataTrackSender = (*DataDownTrack)(nil)
|
||||
|
||||
type DataDownTrackParams struct {
|
||||
Logger logger.Logger
|
||||
SubscriberID livekit.ParticipantID
|
||||
PublishDataTrack types.DataTrack
|
||||
Handle uint16
|
||||
Transport types.DataTrackTransport
|
||||
BytesTrackStats *BytesTrackStats
|
||||
}
|
||||
|
||||
type DataDownTrack struct {
|
||||
params DataDownTrackParams
|
||||
logger logger.Logger
|
||||
createdAt int64
|
||||
|
||||
lock sync.Mutex
|
||||
closed bool
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func NewDataDownTrack(params DataDownTrackParams) (*DataDownTrack, error) {
|
||||
d := &DataDownTrack{
|
||||
params: params,
|
||||
createdAt: time.Now().UnixNano(),
|
||||
}
|
||||
d.logger = params.Logger.WithValues("name", d.Name(), "handle", d.Handle())
|
||||
|
||||
if err := d.params.PublishDataTrack.AddDataDownTrack(d); err != nil {
|
||||
d.logger.Warnw("could not add data down track", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.logger.Infow("created data down track")
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) Close() {
|
||||
d.lock.Lock()
|
||||
onClose := d.onClose
|
||||
if d.closed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
d.closed = true
|
||||
d.lock.Unlock()
|
||||
|
||||
d.logger.Infow("closing data down track")
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.Stop()
|
||||
}
|
||||
d.params.PublishDataTrack.DeleteDataDownTrack(d.SubscriberID())
|
||||
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) OnClose(fn func()) {
|
||||
d.lock.Lock()
|
||||
d.onClose = fn
|
||||
closed := d.closed
|
||||
d.lock.Unlock()
|
||||
if closed && fn != nil {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) Handle() uint16 {
|
||||
return d.params.Handle
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) PublishDataTrack() types.DataTrack {
|
||||
return d.params.PublishDataTrack
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) ID() livekit.TrackID {
|
||||
return d.params.PublishDataTrack.ID()
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) Name() string {
|
||||
return d.params.PublishDataTrack.Name()
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) SubscriberID() livekit.ParticipantID {
|
||||
// add `createdAt` to ensure repeated subscriptions from same subscriber to same publisher does not collide
|
||||
return livekit.ParticipantID(fmt.Sprintf("%s:%d", d.params.SubscriberID, d.createdAt))
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) WritePacket(data []byte, packet *datatrack.Packet, _arrivalTime int64) {
|
||||
forwardedPacket := *packet
|
||||
forwardedPacket.Handle = d.params.Handle
|
||||
buf, err := forwardedPacket.Marshal()
|
||||
if err != nil {
|
||||
d.logger.Warnw("could not marshal data track message", err)
|
||||
return
|
||||
}
|
||||
if err := d.params.Transport.SendDataTrackMessage(buf); err != nil {
|
||||
d.logger.Warnw("could not send data track message", err)
|
||||
return
|
||||
}
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.AddBytes(uint64(len(buf)), true)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) UpdateSubscriptionOptions(subscriptionOptions *livekit.DataTrackSubscriptionOptions) {
|
||||
// DT-TODO
|
||||
}
|
||||
228
pkg/rtc/datatrack.go
Normal file
228
pkg/rtc/datatrack.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
sfuutils "github.com/livekit/livekit-server/pkg/sfu/utils"
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
errReceiverClosed = errors.New("datatrack is closed")
|
||||
)
|
||||
|
||||
var _ types.DataTrack = (*DataTrack)(nil)
|
||||
|
||||
type DataTrackParams struct {
|
||||
Logger logger.Logger
|
||||
ParticipantID func() livekit.ParticipantID
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
BytesTrackStats *BytesTrackStats
|
||||
}
|
||||
|
||||
type subscribedDataTrack struct {
|
||||
subscriber types.LocalParticipant
|
||||
dataDownTrack types.DataDownTrack
|
||||
}
|
||||
|
||||
type DataTrack struct {
|
||||
params DataTrackParams
|
||||
|
||||
logger logger.Logger
|
||||
|
||||
lock sync.Mutex
|
||||
dti *livekit.DataTrackInfo
|
||||
subscribedTracks map[livekit.ParticipantID]subscribedDataTrack
|
||||
|
||||
downTrackSpreader *sfuutils.DownTrackSpreader[types.DataTrackSender]
|
||||
|
||||
stats *dataTrackStats
|
||||
|
||||
closed core.Fuse
|
||||
}
|
||||
|
||||
func NewDataTrack(params DataTrackParams, dti *livekit.DataTrackInfo) *DataTrack {
|
||||
d := &DataTrack{
|
||||
params: params,
|
||||
dti: dti,
|
||||
subscribedTracks: make(map[livekit.ParticipantID]subscribedDataTrack),
|
||||
}
|
||||
d.logger = params.Logger.WithValues("name", d.Name(), "handle", dti.PubHandle)
|
||||
d.downTrackSpreader = sfuutils.NewDownTrackSpreader[types.DataTrackSender](sfuutils.DownTrackSpreaderParams{
|
||||
Threshold: 20,
|
||||
Logger: d.logger,
|
||||
})
|
||||
d.stats = newDataTrackStats(dataTrackStatsParams{Logger: d.logger})
|
||||
d.logger.Infow("created data track", "dataTrackInfo", logger.Proto(d.dti))
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DataTrack) Close() {
|
||||
d.logger.Infow("closing data track")
|
||||
d.closed.Break()
|
||||
|
||||
d.stats.Close()
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataTrack) PublisherID() livekit.ParticipantID {
|
||||
return d.params.ParticipantID()
|
||||
}
|
||||
|
||||
func (d *DataTrack) PublisherIdentity() livekit.ParticipantIdentity {
|
||||
return d.params.ParticipantIdentity
|
||||
}
|
||||
|
||||
func (d *DataTrack) ToProto() *livekit.DataTrackInfo {
|
||||
return utils.CloneProto(d.dti)
|
||||
}
|
||||
|
||||
func (d *DataTrack) PubHandle() uint16 {
|
||||
return uint16(d.dti.PubHandle)
|
||||
}
|
||||
|
||||
func (d *DataTrack) ID() livekit.TrackID {
|
||||
return livekit.TrackID(d.dti.Sid)
|
||||
}
|
||||
|
||||
func (d *DataTrack) Name() string {
|
||||
return d.dti.Name
|
||||
}
|
||||
|
||||
func (d *DataTrack) AddSubscriber(sub types.LocalParticipant) (types.DataDownTrack, error) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if _, ok := d.subscribedTracks[sub.ID()]; ok {
|
||||
return nil, errAlreadySubscribed
|
||||
}
|
||||
|
||||
bytesStats := NewBytesTrackStats(
|
||||
sub.GetCountry(),
|
||||
d.ID(),
|
||||
sub.ID(),
|
||||
sub.Kind(),
|
||||
sub.KindDetails(),
|
||||
sub.GetTelemetryListener,
|
||||
sub.GetReporter(),
|
||||
)
|
||||
dataDownTrack, err := NewDataDownTrack(DataDownTrackParams{
|
||||
Logger: sub.GetLogger().WithValues("trackID", d.ID()),
|
||||
SubscriberID: sub.ID(),
|
||||
PublishDataTrack: d,
|
||||
Handle: sub.GetNextSubscribedDataTrackHandle(),
|
||||
Transport: sub.GetDataTrackTransport(),
|
||||
BytesTrackStats: bytesStats,
|
||||
})
|
||||
if err != nil {
|
||||
bytesStats.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.subscribedTracks[sub.ID()] = subscribedDataTrack{
|
||||
subscriber: sub,
|
||||
dataDownTrack: dataDownTrack,
|
||||
}
|
||||
return dataDownTrack, nil
|
||||
}
|
||||
|
||||
func (d *DataTrack) RemoveSubscriber(subID livekit.ParticipantID) {
|
||||
d.lock.Lock()
|
||||
subscribedTrack, ok := d.subscribedTracks[subID]
|
||||
delete(d.subscribedTracks, subID)
|
||||
d.lock.Unlock()
|
||||
|
||||
if ok {
|
||||
subscribedTrack.dataDownTrack.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataTrack) IsSubscriber(subID livekit.ParticipantID) bool {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
_, ok := d.subscribedTracks[subID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d *DataTrack) RevokeDisallowedSubscribers(allowedSubscriberIdentities []livekit.ParticipantIdentity) []livekit.ParticipantIdentity {
|
||||
var revokedSubscriberIdentities []livekit.ParticipantIdentity
|
||||
|
||||
d.lock.Lock()
|
||||
disallowed := make(map[livekit.ParticipantID]livekit.ParticipantIdentity)
|
||||
for subID, subscribedTrack := range d.subscribedTracks {
|
||||
if IsParticipantExemptFromTrackPermissionsRestrictions(subscribedTrack.subscriber) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(allowedSubscriberIdentities, subscribedTrack.subscriber.Identity()) {
|
||||
disallowed[subID] = subscribedTrack.subscriber.Identity()
|
||||
}
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
for subID, subIdentity := range disallowed {
|
||||
d.logger.Infow("revoking data track subscription",
|
||||
"subscriber", subIdentity,
|
||||
"subscriberID", subID,
|
||||
)
|
||||
d.RemoveSubscriber(subID)
|
||||
revokedSubscriberIdentities = append(revokedSubscriberIdentities, subIdentity)
|
||||
}
|
||||
|
||||
return revokedSubscriberIdentities
|
||||
}
|
||||
|
||||
func (d *DataTrack) AddDataDownTrack(dts types.DataTrackSender) error {
|
||||
if d.closed.IsBroken() {
|
||||
return errReceiverClosed
|
||||
}
|
||||
|
||||
if d.downTrackSpreader.HasDownTrack(dts.SubscriberID()) {
|
||||
d.logger.Infow("subscriberID already exists, replacing data downtrack", "subscriberID", dts.SubscriberID())
|
||||
}
|
||||
|
||||
d.downTrackSpreader.Store(dts)
|
||||
d.logger.Infow("data downtrack added", "subscriberID", dts.SubscriberID())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DataTrack) DeleteDataDownTrack(subscriberID livekit.ParticipantID) {
|
||||
d.downTrackSpreader.Free(subscriberID)
|
||||
d.logger.Infow("data downtrack deleted", "subscriberID", subscriberID)
|
||||
}
|
||||
|
||||
func (d *DataTrack) HandlePacket(data []byte, packet *datatrack.Packet, arrivalTime int64) {
|
||||
d.stats.Update(packet, arrivalTime, len(data))
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.AddBytes(uint64(len(data)), false)
|
||||
}
|
||||
|
||||
d.downTrackSpreader.Broadcast(func(dts types.DataTrackSender) {
|
||||
dts.WritePacket(data, packet, arrivalTime)
|
||||
})
|
||||
}
|
||||
110
pkg/rtc/datatrack_stats.go
Normal file
110
pkg/rtc/datatrack_stats.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
)
|
||||
|
||||
type dataTrackStatsParams struct {
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type dataTrackStats struct {
|
||||
params dataTrackStatsParams
|
||||
|
||||
lock sync.Mutex
|
||||
startTime int64
|
||||
endTime int64
|
||||
highestSequenceNumber uint16
|
||||
numPackets int
|
||||
numPacketsLost int
|
||||
numPacketsOutOfOrder int
|
||||
numFrames int // count of `F` tagged packets, i. e. packets with final packet of frame marker
|
||||
numBytes int
|
||||
}
|
||||
|
||||
func newDataTrackStats(params dataTrackStatsParams) *dataTrackStats {
|
||||
return &dataTrackStats{
|
||||
params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dataTrackStats) Update(packet *datatrack.Packet, arrivalTime int64, payloadLength int) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.numBytes += payloadLength
|
||||
|
||||
if d.endTime != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if d.startTime == 0 {
|
||||
d.startTime = arrivalTime
|
||||
d.highestSequenceNumber = packet.SequenceNumber
|
||||
d.numPackets = 1
|
||||
} else {
|
||||
diff := packet.SequenceNumber - d.highestSequenceNumber
|
||||
switch {
|
||||
case diff == 0: // duplicate
|
||||
return
|
||||
|
||||
case diff > (1 << 15): // out of order
|
||||
d.numPackets++
|
||||
d.numPacketsOutOfOrder++
|
||||
if d.numPacketsLost > 0 {
|
||||
d.numPacketsLost--
|
||||
}
|
||||
|
||||
default: // in order
|
||||
d.numPackets++
|
||||
d.numPacketsLost += int(diff) - 1
|
||||
d.highestSequenceNumber = packet.SequenceNumber
|
||||
}
|
||||
}
|
||||
|
||||
if packet.IsFinalOfFrame {
|
||||
d.numFrames++
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dataTrackStats) Close() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.endTime = mono.UnixNano()
|
||||
|
||||
if d.startTime != 0 {
|
||||
duration := time.Duration(d.endTime - d.startTime).Seconds()
|
||||
fps := float64(d.numFrames) / duration
|
||||
|
||||
d.params.Logger.Infow(
|
||||
"data track stats",
|
||||
"duration", duration,
|
||||
"numPackets", d.numPackets,
|
||||
"numPacketsLost", d.numPacketsLost,
|
||||
"numPacketsOutOfOrder", d.numPacketsOutOfOrder,
|
||||
"numFrames", d.numFrames,
|
||||
"fps", fps,
|
||||
"numBytes", d.numBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
71
pkg/rtc/datatrack_test.go
Normal file
71
pkg/rtc/datatrack_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/observability/roomobs"
|
||||
)
|
||||
|
||||
func newTestDataTrackSubscriber(id livekit.ParticipantID, identity livekit.ParticipantIdentity, isRecorder bool) *typesfakes.FakeLocalParticipant {
|
||||
sub := &typesfakes.FakeLocalParticipant{}
|
||||
sub.IDReturns(id)
|
||||
sub.IdentityReturns(identity)
|
||||
sub.IsRecorderReturns(isRecorder)
|
||||
sub.GetLoggerReturns(logger.GetLogger())
|
||||
sub.GetReporterReturns(roomobs.NewNoopParticipantSessionReporter())
|
||||
sub.GetTelemetryListenerReturns(&typesfakes.FakeParticipantTelemetryListener{})
|
||||
return sub
|
||||
}
|
||||
|
||||
func TestDataTrackRevokeDisallowedSubscribers(t *testing.T) {
|
||||
dt := NewDataTrack(
|
||||
DataTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
ParticipantID: func() livekit.ParticipantID { return "pubID" },
|
||||
ParticipantIdentity: "pub",
|
||||
},
|
||||
&livekit.DataTrackInfo{
|
||||
PubHandle: 1,
|
||||
Sid: "DTR_test",
|
||||
Name: "test",
|
||||
},
|
||||
)
|
||||
defer dt.Close()
|
||||
|
||||
allowed := newTestDataTrackSubscriber("allowedID", "allowed", false)
|
||||
disallowed := newTestDataTrackSubscriber("disallowedID", "disallowed", false)
|
||||
recorder := newTestDataTrackSubscriber("recorderID", "recorder", true)
|
||||
|
||||
for _, sub := range []*typesfakes.FakeLocalParticipant{allowed, disallowed, recorder} {
|
||||
_, err := dt.AddSubscriber(sub)
|
||||
require.NoError(t, err)
|
||||
require.True(t, dt.IsSubscriber(sub.ID()))
|
||||
}
|
||||
|
||||
revoked := dt.RevokeDisallowedSubscribers([]livekit.ParticipantIdentity{"allowed"})
|
||||
require.Equal(t, []livekit.ParticipantIdentity{"disallowed"}, revoked)
|
||||
|
||||
// disallowed subscriber is removed, allowed and permission exempt (recorder) subscribers are kept
|
||||
require.True(t, dt.IsSubscriber(allowed.ID()))
|
||||
require.False(t, dt.IsSubscriber(disallowed.ID()))
|
||||
require.True(t, dt.IsSubscriber(recorder.ID()))
|
||||
}
|
||||
523
pkg/rtc/dynacast/dynacastmanager_test.go
Normal file
523
pkg/rtc/dynacast/dynacastmanager_test.go
Normal file
@@ -0,0 +1,523 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type testDynacastManagerListener struct {
|
||||
onSubscribedMaxQualityChange func(subscribedQualties []*livekit.SubscribedCodec)
|
||||
onSubscribedAudioCodecChange func(subscribedCodecs []*livekit.SubscribedAudioCodec)
|
||||
}
|
||||
|
||||
func (t *testDynacastManagerListener) OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
_maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
t.onSubscribedMaxQualityChange(subscribedQualities)
|
||||
}
|
||||
|
||||
func (t *testDynacastManagerListener) OnDynacastSubscribedAudioCodecChange(
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
t.onSubscribedAudioCodecChange(codecs)
|
||||
}
|
||||
|
||||
func TestSubscribedMaxQuality(t *testing.T) {
|
||||
t.Run("subscribers muted", func(t *testing.T) {
|
||||
var lock sync.Mutex
|
||||
actualSubscribedQualities := make([]*livekit.SubscribedCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerVideo(DynacastManagerVideoParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedQualities = subscribedQualities
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_HIGH)
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeAV1, livekit.VideoQuality_HIGH)
|
||||
|
||||
// mute all subscribers of vp8
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_OFF)
|
||||
|
||||
expectedSubscribedQualities := []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("subscribers max quality", func(t *testing.T) {
|
||||
lock := sync.RWMutex{}
|
||||
actualSubscribedQualities := make([]*livekit.SubscribedCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerVideo(DynacastManagerVideoParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedQualities = subscribedQualities
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.(*dynacastManagerVideo).maxSubscribedQuality = map[mime.MimeType]livekit.VideoQuality{
|
||||
mime.MimeTypeVP8: livekit.VideoQuality_LOW,
|
||||
mime.MimeTypeAV1: livekit.VideoQuality_LOW,
|
||||
}
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_HIGH)
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeVP8, livekit.VideoQuality_MEDIUM)
|
||||
dm.NotifySubscriberMaxQuality("s3", mime.MimeTypeAV1, livekit.VideoQuality_MEDIUM)
|
||||
|
||||
expectedSubscribedQualities := []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// "s1" dropping to MEDIUM should disable HIGH layer
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_MEDIUM)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// "s1" , "s2" , "s3" dropping to LOW should disable HIGH & MEDIUM
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_LOW)
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeVP8, livekit.VideoQuality_LOW)
|
||||
dm.NotifySubscriberMaxQuality("s3", mime.MimeTypeAV1, livekit.VideoQuality_LOW)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// muting "s2" only should not disable all qualities of vp8, no change of expected qualities
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeVP8, livekit.VideoQuality_OFF)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// muting "s1" and s3 also should disable all qualities
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_OFF)
|
||||
dm.NotifySubscriberMaxQuality("s3", mime.MimeTypeAV1, livekit.VideoQuality_OFF)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// unmuting "s1" should enable vp8 previously set max quality
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_LOW)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// a higher quality from a different node should trigger that quality
|
||||
dm.NotifySubscriberNodeMaxQuality("n1", []types.SubscribedCodecQuality{
|
||||
{CodecMime: mime.MimeTypeVP8, Quality: livekit.VideoQuality_HIGH},
|
||||
{CodecMime: mime.MimeTypeAV1, Quality: livekit.VideoQuality_MEDIUM},
|
||||
})
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodecRegression(t *testing.T) {
|
||||
t.Run("codec regression video", func(t *testing.T) {
|
||||
var lock sync.Mutex
|
||||
actualSubscribedQualities := make([]*livekit.SubscribedCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerVideo(DynacastManagerVideoParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedQualities = subscribedQualities
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeAV1, livekit.VideoQuality_HIGH)
|
||||
|
||||
expectedSubscribedQualities := []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
dm.HandleCodecRegression(mime.MimeTypeAV1, mime.MimeTypeVP8)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// av1 quality change should be forwarded to vp8
|
||||
// av1 quality change of node should be ignored
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeAV1, livekit.VideoQuality_MEDIUM)
|
||||
dm.NotifySubscriberNodeMaxQuality("n1", []types.SubscribedCodecQuality{
|
||||
{CodecMime: mime.MimeTypeAV1, Quality: livekit.VideoQuality_HIGH},
|
||||
})
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("codec regression audio", func(t *testing.T) {
|
||||
var lock sync.Mutex
|
||||
actualSubscribedCodecs := make([]*livekit.SubscribedAudioCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerAudio(DynacastManagerAudioParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedAudioCodecChange: func(subscribedCodecs []*livekit.SubscribedAudioCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedCodecs = subscribedCodecs
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.NotifySubscription("s1", mime.MimeTypeRED, true)
|
||||
|
||||
expectedSubscribedCodecs := []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
dm.HandleCodecRegression(mime.MimeTypeRED, mime.MimeTypeOpus)
|
||||
|
||||
expectedSubscribedCodecs = []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// RED disable as subscriber or subscriber node should be ignored as it has been regressed
|
||||
dm.NotifySubscription("s1", mime.MimeTypeRED, false)
|
||||
dm.NotifySubscriptionNode("n1", []*livekit.SubscribedAudioCodec{
|
||||
{Codec: mime.MimeTypeRED.String(), Enabled: false},
|
||||
})
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// `s1` unsubscription should turn off `opus`
|
||||
dm.NotifySubscription("s1", mime.MimeTypeOpus, false)
|
||||
expectedSubscribedCodecs = []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// a node subscription should turn `opus` back on
|
||||
dm.NotifySubscriptionNode("n1", []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
})
|
||||
expectedSubscribedCodecs = []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func subscribedCodecsAsString(c1 []*livekit.SubscribedCodec) string {
|
||||
slices.SortFunc(c1, func(a, b *livekit.SubscribedCodec) int {
|
||||
return strings.Compare(a.Codec, b.Codec)
|
||||
})
|
||||
|
||||
var s1 strings.Builder
|
||||
for _, c := range c1 {
|
||||
s1.WriteString(c.String())
|
||||
}
|
||||
return s1.String()
|
||||
}
|
||||
|
||||
func subscribedAudioCodecsAsString(c1 []*livekit.SubscribedAudioCodec) string {
|
||||
slices.SortFunc(c1, func(a, b *livekit.SubscribedAudioCodec) int {
|
||||
return strings.Compare(a.Codec, b.Codec)
|
||||
})
|
||||
|
||||
var s1 strings.Builder
|
||||
for _, c := range c1 {
|
||||
s1.WriteString(c.String())
|
||||
}
|
||||
return s1.String()
|
||||
}
|
||||
198
pkg/rtc/dynacast/dynacastmanageraudio.go
Normal file
198
pkg/rtc/dynacast/dynacastmanageraudio.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
)
|
||||
|
||||
var _ DynacastManager = (*dynacastManagerAudio)(nil)
|
||||
var _ dynacastQualityListener = (*dynacastManagerAudio)(nil)
|
||||
|
||||
type DynacastManagerAudioParams struct {
|
||||
Listener DynacastManagerListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type dynacastManagerAudio struct {
|
||||
params DynacastManagerAudioParams
|
||||
|
||||
subscribedCodecs map[mime.MimeType]bool
|
||||
committedSubscribedCodecs map[mime.MimeType]bool
|
||||
|
||||
isClosed bool
|
||||
|
||||
*dynacastManagerBase
|
||||
}
|
||||
|
||||
func NewDynacastManagerAudio(params DynacastManagerAudioParams) DynacastManager {
|
||||
if params.Logger == nil {
|
||||
params.Logger = logger.GetLogger()
|
||||
}
|
||||
d := &dynacastManagerAudio{
|
||||
params: params,
|
||||
subscribedCodecs: make(map[mime.MimeType]bool),
|
||||
committedSubscribedCodecs: make(map[mime.MimeType]bool),
|
||||
}
|
||||
d.dynacastManagerBase = newDynacastManagerBase(dynacastManagerBaseParams{
|
||||
Logger: params.Logger,
|
||||
OpsQueueDepth: 4,
|
||||
OnRestart: func() {
|
||||
d.committedSubscribedCodecs = make(map[mime.MimeType]bool)
|
||||
},
|
||||
OnDynacastQualityCreate: func(mimeType mime.MimeType) dynacastQuality {
|
||||
dq := newDynacastQualityAudio(dynacastQualityAudioParams{
|
||||
MimeType: mimeType,
|
||||
Listener: d,
|
||||
Logger: d.params.Logger,
|
||||
})
|
||||
return dq
|
||||
},
|
||||
OnRegressCodec: func(fromMime, toMime mime.MimeType) {
|
||||
d.subscribedCodecs[fromMime] = false
|
||||
|
||||
// if the new codec is not added, notify the publisher to start publishing
|
||||
if _, ok := d.subscribedCodecs[toMime]; !ok {
|
||||
d.subscribedCodecs[toMime] = true
|
||||
}
|
||||
},
|
||||
OnUpdateNeeded: d.update,
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
// It is possible for tracks to be in pending close state. When track
|
||||
// is waiting to be closed, a node is not streaming a track. This can
|
||||
// be used to force an update announcing that subscribed codec is disabled,
|
||||
// i.e. indicating not pulling track any more.
|
||||
func (d *dynacastManagerAudio) ForceEnable(enabled bool) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for mime := range d.committedSubscribedCodecs {
|
||||
d.committedSubscribedCodecs[mime] = enabled
|
||||
}
|
||||
|
||||
d.enqueueSubscribedChange()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) NotifySubscription(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
enabled bool,
|
||||
) {
|
||||
dq := d.getOrCreateDynacastQuality(mime)
|
||||
if dq != nil {
|
||||
dq.NotifySubscription(subscriberID, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) NotifySubscriptionNode(
|
||||
nodeID livekit.NodeID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
for _, codec := range codecs {
|
||||
dq := d.getOrCreateDynacastQuality(mime.NormalizeMimeType(codec.Codec))
|
||||
if dq != nil {
|
||||
dq.NotifySubscriptionNode(nodeID, codec.Enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) OnUpdateAudioCodecForMime(mime mime.MimeType, enabled bool) {
|
||||
d.lock.Lock()
|
||||
if _, ok := d.regressedCodec[mime]; !ok {
|
||||
d.subscribedCodecs[mime] = enabled
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.update(false)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) update(force bool) {
|
||||
d.lock.Lock()
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"processing subscribed codec change",
|
||||
"force", force,
|
||||
"committedSubscribedCodecs", d.committedSubscribedCodecs,
|
||||
"subscribedCodecs", d.subscribedCodecs,
|
||||
)
|
||||
|
||||
if len(d.subscribedCodecs) == 0 {
|
||||
// no mime has been added, nothing to update
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// add or remove of a mime triggers an update
|
||||
changed := len(d.subscribedCodecs) != len(d.committedSubscribedCodecs)
|
||||
if !changed {
|
||||
for mime, enabled := range d.subscribedCodecs {
|
||||
if ce, ok := d.committedSubscribedCodecs[mime]; ok {
|
||||
if ce != enabled {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !force && !changed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"committing subscribed codec change",
|
||||
"force", force,
|
||||
"committedSubscribedCoecs", d.committedSubscribedCodecs,
|
||||
"subscribedcodecs", d.subscribedCodecs,
|
||||
)
|
||||
|
||||
// commit change
|
||||
d.committedSubscribedCodecs = make(map[mime.MimeType]bool, len(d.subscribedCodecs))
|
||||
for mime, enabled := range d.subscribedCodecs {
|
||||
d.committedSubscribedCodecs[mime] = enabled
|
||||
}
|
||||
|
||||
d.enqueueSubscribedChange()
|
||||
d.lock.Unlock()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) enqueueSubscribedChange() {
|
||||
if d.isClosed || d.params.Listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedCodecs := make([]*livekit.SubscribedAudioCodec, 0, len(d.committedSubscribedCodecs))
|
||||
for mime, enabled := range d.committedSubscribedCodecs {
|
||||
subscribedCodecs = append(subscribedCodecs, &livekit.SubscribedAudioCodec{
|
||||
Codec: mime.String(),
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"subscribedAudioCodecChange",
|
||||
"subscribedCodecs", logger.ProtoSlice(subscribedCodecs),
|
||||
)
|
||||
d.notifyOpsQueue.Enqueue(func() {
|
||||
d.params.Listener.OnDynacastSubscribedAudioCodecChange(subscribedCodecs)
|
||||
})
|
||||
}
|
||||
165
pkg/rtc/dynacast/dynacastmanagerbase.go
Normal file
165
pkg/rtc/dynacast/dynacastmanagerbase.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
)
|
||||
|
||||
type dynacastManagerBaseParams struct {
|
||||
Logger logger.Logger
|
||||
OpsQueueDepth uint
|
||||
OnRestart func()
|
||||
OnDynacastQualityCreate func(mimeType mime.MimeType) dynacastQuality
|
||||
OnRegressCodec func(fromMime, toMime mime.MimeType)
|
||||
OnUpdateNeeded func(force bool)
|
||||
}
|
||||
|
||||
type dynacastManagerBase struct {
|
||||
params dynacastManagerBaseParams
|
||||
|
||||
lock sync.RWMutex
|
||||
regressedCodec map[mime.MimeType]struct{}
|
||||
dynacastQuality map[mime.MimeType]dynacastQuality
|
||||
|
||||
notifyOpsQueue *utils.OpsQueue
|
||||
|
||||
isClosed bool
|
||||
|
||||
dynacastManagerNull
|
||||
dynacastQualityListenerNull
|
||||
}
|
||||
|
||||
func newDynacastManagerBase(params dynacastManagerBaseParams) *dynacastManagerBase {
|
||||
if params.OpsQueueDepth == 0 {
|
||||
params.OpsQueueDepth = 4
|
||||
}
|
||||
d := &dynacastManagerBase{
|
||||
params: params,
|
||||
regressedCodec: make(map[mime.MimeType]struct{}),
|
||||
dynacastQuality: make(map[mime.MimeType]dynacastQuality),
|
||||
notifyOpsQueue: utils.NewOpsQueue(utils.OpsQueueParams{
|
||||
Name: "dynacast-notify",
|
||||
MinSize: params.OpsQueueDepth,
|
||||
FlushOnStop: true,
|
||||
Logger: params.Logger,
|
||||
}),
|
||||
}
|
||||
d.notifyOpsQueue.Start()
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) AddCodec(mime mime.MimeType) {
|
||||
d.getOrCreateDynacastQuality(mime)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) HandleCodecRegression(fromMime, toMime mime.MimeType) {
|
||||
fromDq := d.getOrCreateDynacastQuality(fromMime)
|
||||
|
||||
d.lock.Lock()
|
||||
if d.isClosed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if fromDq == nil {
|
||||
// should not happen as we have added the codec on setup receiver
|
||||
d.params.Logger.Warnw("regression from codec not found", nil, "mime", fromMime, "toMime", toMime)
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
d.regressedCodec[fromMime] = struct{}{}
|
||||
d.params.OnRegressCodec(fromMime, toMime)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.params.OnUpdateNeeded(false)
|
||||
|
||||
fromDq.Stop()
|
||||
fromDq.RegressTo(d.getOrCreateDynacastQuality(toMime))
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) Restart() {
|
||||
d.lock.Lock()
|
||||
d.params.OnRestart()
|
||||
|
||||
dqs := d.getDynacastQualitiesLocked()
|
||||
d.lock.Unlock()
|
||||
|
||||
for _, dq := range dqs {
|
||||
dq.Restart()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) Close() {
|
||||
d.notifyOpsQueue.Stop()
|
||||
|
||||
d.lock.Lock()
|
||||
dqs := d.getDynacastQualitiesLocked()
|
||||
d.dynacastQuality = make(map[mime.MimeType]dynacastQuality)
|
||||
|
||||
d.isClosed = true
|
||||
d.lock.Unlock()
|
||||
|
||||
for _, dq := range dqs {
|
||||
dq.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// There are situations like track unmute or streaming from a different node
|
||||
// where subscription changes needs to sent to the provider immediately.
|
||||
// This bypasses any debouncing and forces a subscription change update
|
||||
// with immediate effect.
|
||||
func (d *dynacastManagerBase) ForceUpdate() {
|
||||
d.params.OnUpdateNeeded(true)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) ClearSubscriberNodes() {
|
||||
d.lock.Lock()
|
||||
dqs := d.getDynacastQualitiesLocked()
|
||||
d.lock.Unlock()
|
||||
for _, dq := range dqs {
|
||||
dq.ClearSubscriberNodes()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) getOrCreateDynacastQuality(mimeType mime.MimeType) dynacastQuality {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.isClosed || mimeType == mime.MimeTypeUnknown {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dq := d.dynacastQuality[mimeType]; dq != nil {
|
||||
return dq
|
||||
}
|
||||
|
||||
dq := d.params.OnDynacastQualityCreate(mimeType)
|
||||
dq.Start()
|
||||
|
||||
d.dynacastQuality[mimeType] = dq
|
||||
return dq
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) getDynacastQualitiesLocked() []dynacastQuality {
|
||||
return slices.Collect(maps.Values(d.dynacastQuality))
|
||||
}
|
||||
271
pkg/rtc/dynacast/dynacastmanagervideo.go
Normal file
271
pkg/rtc/dynacast/dynacastmanagervideo.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/bep/debounce"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
var _ DynacastManager = (*dynacastManagerVideo)(nil)
|
||||
var _ dynacastQualityListener = (*dynacastManagerVideo)(nil)
|
||||
|
||||
type DynacastManagerVideoParams struct {
|
||||
DynacastPauseDelay time.Duration
|
||||
Listener DynacastManagerListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type dynacastManagerVideo struct {
|
||||
params DynacastManagerVideoParams
|
||||
|
||||
maxSubscribedQuality map[mime.MimeType]livekit.VideoQuality
|
||||
committedMaxSubscribedQuality map[mime.MimeType]livekit.VideoQuality
|
||||
|
||||
maxSubscribedQualityDebounce func(func())
|
||||
maxSubscribedQualityDebouncePending bool
|
||||
|
||||
isClosed bool
|
||||
|
||||
*dynacastManagerBase
|
||||
}
|
||||
|
||||
func NewDynacastManagerVideo(params DynacastManagerVideoParams) DynacastManager {
|
||||
if params.Logger == nil {
|
||||
params.Logger = logger.GetLogger()
|
||||
}
|
||||
d := &dynacastManagerVideo{
|
||||
params: params,
|
||||
maxSubscribedQuality: make(map[mime.MimeType]livekit.VideoQuality),
|
||||
committedMaxSubscribedQuality: make(map[mime.MimeType]livekit.VideoQuality),
|
||||
}
|
||||
if params.DynacastPauseDelay > 0 {
|
||||
d.maxSubscribedQualityDebounce = debounce.New(params.DynacastPauseDelay)
|
||||
}
|
||||
d.dynacastManagerBase = newDynacastManagerBase(dynacastManagerBaseParams{
|
||||
Logger: params.Logger,
|
||||
OpsQueueDepth: 64,
|
||||
OnRestart: func() {
|
||||
d.committedMaxSubscribedQuality = make(map[mime.MimeType]livekit.VideoQuality)
|
||||
},
|
||||
OnDynacastQualityCreate: func(mimeType mime.MimeType) dynacastQuality {
|
||||
dq := newDynacastQualityVideo(dynacastQualityVideoParams{
|
||||
MimeType: mimeType,
|
||||
Listener: d,
|
||||
Logger: d.params.Logger,
|
||||
})
|
||||
return dq
|
||||
},
|
||||
OnRegressCodec: func(fromMime, toMime mime.MimeType) {
|
||||
d.maxSubscribedQuality[fromMime] = livekit.VideoQuality_OFF
|
||||
|
||||
// if the new codec is not added, notify the publisher to start publishing
|
||||
if _, ok := d.maxSubscribedQuality[toMime]; !ok {
|
||||
d.maxSubscribedQuality[toMime] = livekit.VideoQuality_HIGH
|
||||
}
|
||||
},
|
||||
OnUpdateNeeded: d.update,
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
// It is possible for tracks to be in pending close state. When track
|
||||
// is waiting to be closed, a node is not streaming a track. This can
|
||||
// be used to force an update announcing that subscribed quality is OFF,
|
||||
// i.e. indicating not pulling track any more.
|
||||
func (d *dynacastManagerVideo) ForceQuality(quality livekit.VideoQuality) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for mime := range d.committedMaxSubscribedQuality {
|
||||
d.committedMaxSubscribedQuality[mime] = quality
|
||||
}
|
||||
|
||||
d.enqueueSubscribedQualityChange()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) NotifySubscriberMaxQuality(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
quality livekit.VideoQuality,
|
||||
) {
|
||||
dq := d.getOrCreateDynacastQuality(mime)
|
||||
if dq != nil {
|
||||
dq.NotifySubscriberMaxQuality(subscriberID, quality)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) NotifySubscriberNodeMaxQuality(
|
||||
nodeID livekit.NodeID,
|
||||
qualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
for _, quality := range qualities {
|
||||
dq := d.getOrCreateDynacastQuality(quality.CodecMime)
|
||||
if dq != nil {
|
||||
dq.NotifySubscriberNodeMaxQuality(nodeID, quality.Quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) OnUpdateMaxQualityForMime(
|
||||
mime mime.MimeType,
|
||||
maxQuality livekit.VideoQuality,
|
||||
) {
|
||||
d.lock.Lock()
|
||||
if _, ok := d.regressedCodec[mime]; !ok {
|
||||
d.maxSubscribedQuality[mime] = maxQuality
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.update(false)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) update(force bool) {
|
||||
d.lock.Lock()
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"processing quality change",
|
||||
"force", force,
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
|
||||
if len(d.maxSubscribedQuality) == 0 {
|
||||
// no mime has been added, nothing to update
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// add or remove of a mime triggers an update
|
||||
changed := len(d.maxSubscribedQuality) != len(d.committedMaxSubscribedQuality)
|
||||
downgradesOnly := !changed
|
||||
if !changed {
|
||||
for mime, quality := range d.maxSubscribedQuality {
|
||||
if cq, ok := d.committedMaxSubscribedQuality[mime]; ok {
|
||||
if cq != quality {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (cq == livekit.VideoQuality_OFF && quality != livekit.VideoQuality_OFF) || (cq != livekit.VideoQuality_OFF && quality != livekit.VideoQuality_OFF && cq < quality) {
|
||||
downgradesOnly = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !force {
|
||||
if !changed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if downgradesOnly && d.maxSubscribedQualityDebounce != nil {
|
||||
if !d.maxSubscribedQualityDebouncePending {
|
||||
d.params.Logger.Debugw(
|
||||
"debouncing quality downgrade",
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
d.maxSubscribedQualityDebounce(func() {
|
||||
d.update(true)
|
||||
})
|
||||
d.maxSubscribedQualityDebouncePending = true
|
||||
} else {
|
||||
d.params.Logger.Debugw(
|
||||
"quality downgrade waiting for debounce",
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
}
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// clear debounce on send
|
||||
if d.maxSubscribedQualityDebounce != nil {
|
||||
d.maxSubscribedQualityDebounce(func() {})
|
||||
d.maxSubscribedQualityDebouncePending = false
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"committing quality change",
|
||||
"force", force,
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
|
||||
// commit change
|
||||
d.committedMaxSubscribedQuality = make(map[mime.MimeType]livekit.VideoQuality, len(d.maxSubscribedQuality))
|
||||
maps.Copy(d.committedMaxSubscribedQuality, d.maxSubscribedQuality)
|
||||
|
||||
d.enqueueSubscribedQualityChange()
|
||||
d.lock.Unlock()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) enqueueSubscribedQualityChange() {
|
||||
if d.isClosed || d.params.Listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedCodecs := make([]*livekit.SubscribedCodec, 0, len(d.committedMaxSubscribedQuality))
|
||||
maxSubscribedQualities := make([]types.SubscribedCodecQuality, 0, len(d.committedMaxSubscribedQuality))
|
||||
for mime, quality := range d.committedMaxSubscribedQuality {
|
||||
maxSubscribedQualities = append(maxSubscribedQualities, types.SubscribedCodecQuality{
|
||||
CodecMime: mime,
|
||||
Quality: quality,
|
||||
})
|
||||
|
||||
if quality == livekit.VideoQuality_OFF {
|
||||
subscribedCodecs = append(subscribedCodecs, &livekit.SubscribedCodec{
|
||||
Codec: mime.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
var subscribedQualities []*livekit.SubscribedQuality
|
||||
for q := livekit.VideoQuality_LOW; q <= livekit.VideoQuality_HIGH; q++ {
|
||||
subscribedQualities = append(subscribedQualities, &livekit.SubscribedQuality{
|
||||
Quality: q,
|
||||
Enabled: q <= quality,
|
||||
})
|
||||
}
|
||||
subscribedCodecs = append(subscribedCodecs, &livekit.SubscribedCodec{
|
||||
Codec: mime.String(),
|
||||
Qualities: subscribedQualities,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"subscribedMaxQualityChange",
|
||||
"subscribedCodecs", subscribedCodecs,
|
||||
"maxSubscribedQualities", maxSubscribedQualities,
|
||||
)
|
||||
d.notifyOpsQueue.Enqueue(func() {
|
||||
d.params.Listener.OnDynacastSubscribedMaxQualityChange(subscribedCodecs, maxSubscribedQualities)
|
||||
})
|
||||
}
|
||||
168
pkg/rtc/dynacast/dynacastqualityaudio.go
Normal file
168
pkg/rtc/dynacast/dynacastqualityaudio.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ dynacastQuality = (*dynacastQualityAudio)(nil)
|
||||
|
||||
type dynacastQualityAudioParams struct {
|
||||
MimeType mime.MimeType
|
||||
Listener dynacastQualityListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// dynacastQualityAudio manages enable a single receiver of a media track
|
||||
type dynacastQualityAudio struct {
|
||||
params dynacastQualityAudioParams
|
||||
|
||||
// quality level enable/disable
|
||||
lock sync.RWMutex
|
||||
initialized bool
|
||||
subscriberEnables map[livekit.ParticipantID]bool
|
||||
subscriberNodeEnables map[livekit.NodeID]bool
|
||||
enabled bool
|
||||
regressTo dynacastQuality
|
||||
|
||||
dynacastQualityNull
|
||||
}
|
||||
|
||||
func newDynacastQualityAudio(params dynacastQualityAudioParams) dynacastQuality {
|
||||
return &dynacastQualityAudio{
|
||||
params: params,
|
||||
subscriberEnables: make(map[livekit.ParticipantID]bool),
|
||||
subscriberNodeEnables: make(map[livekit.NodeID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Start() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Restart() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Stop() {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) NotifySubscription(subscriberID livekit.ParticipantID, enabled bool) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber codec enable",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberID", subscriberID,
|
||||
"enabled", enabled,
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
delete(d.subscriberEnables, subscriberID)
|
||||
} else {
|
||||
d.subscriberEnables[subscriberID] = true
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) NotifySubscriptionNode(nodeID livekit.NodeID, enabled bool) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber node codec enabled",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberNodeID", nodeID,
|
||||
"enabled", enabled,
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
// the downstream node will synthesize correct enable (its dynacast manager has codec regression), just ignore it
|
||||
d.params.Logger.Debugw(
|
||||
"ignoring node codec change, regressed to another dynacast quality",
|
||||
"mime", d.params.MimeType,
|
||||
"regressedMime", d.regressTo.Mime(),
|
||||
)
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
delete(d.subscriberNodeEnables, nodeID)
|
||||
} else {
|
||||
d.subscriberNodeEnables[nodeID] = true
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) ClearSubscriberNodes() {
|
||||
d.lock.Lock()
|
||||
d.subscriberNodeEnables = make(map[livekit.NodeID]bool)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Mime() mime.MimeType {
|
||||
return d.params.MimeType
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) RegressTo(other dynacastQuality) {
|
||||
d.lock.Lock()
|
||||
d.regressTo = other
|
||||
d.lock.Unlock()
|
||||
|
||||
other.Restart()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) reset() {
|
||||
d.lock.Lock()
|
||||
d.initialized = false
|
||||
d.lock.Unlock()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) updateQualityChange(force bool) {
|
||||
d.lock.Lock()
|
||||
enabled := len(d.subscriberEnables) != 0 || len(d.subscriberNodeEnables) != 0
|
||||
if enabled == d.enabled && d.initialized && !force {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.initialized = true
|
||||
d.enabled = enabled
|
||||
d.params.Logger.Debugw(
|
||||
"notifying enabled change",
|
||||
"mime", d.params.MimeType,
|
||||
"enabled", d.enabled,
|
||||
"subscriberNodeEnables", d.subscriberNodeEnables,
|
||||
"subscribedEnables", d.subscriberEnables,
|
||||
"force", force,
|
||||
)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.params.Listener.OnUpdateAudioCodecForMime(d.params.MimeType, enabled)
|
||||
}
|
||||
249
pkg/rtc/dynacast/dynacastqualityvideo.go
Normal file
249
pkg/rtc/dynacast/dynacastqualityvideo.go
Normal file
@@ -0,0 +1,249 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ dynacastQuality = (*dynacastQualityVideo)(nil)
|
||||
|
||||
const (
|
||||
initialQualityUpdateWait = 10 * time.Second
|
||||
)
|
||||
|
||||
type dynacastQualityVideoParams struct {
|
||||
MimeType mime.MimeType
|
||||
Listener dynacastQualityListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// dynacastQualityVideo manages max subscribed quality of a single receiver of a media track
|
||||
type dynacastQualityVideo struct {
|
||||
params dynacastQualityVideoParams
|
||||
|
||||
// quality level enable/disable
|
||||
lock sync.RWMutex
|
||||
initialized bool
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality
|
||||
maxSubscribedQuality livekit.VideoQuality
|
||||
maxQualityTimer *time.Timer
|
||||
regressTo dynacastQuality
|
||||
|
||||
dynacastQualityNull
|
||||
}
|
||||
|
||||
func newDynacastQualityVideo(params dynacastQualityVideoParams) dynacastQuality {
|
||||
return &dynacastQualityVideo{
|
||||
params: params,
|
||||
maxSubscriberQuality: make(map[livekit.ParticipantID]livekit.VideoQuality),
|
||||
maxSubscriberNodeQuality: make(map[livekit.NodeID]livekit.VideoQuality),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Start() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Restart() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Stop() {
|
||||
d.stopMaxQualityTimer()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) NotifySubscriberMaxQuality(subscriberID livekit.ParticipantID, quality livekit.VideoQuality) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber max quality",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberID", subscriberID,
|
||||
"quality", quality.String(),
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
d.lock.Unlock()
|
||||
r.NotifySubscriberMaxQuality(subscriberID, quality)
|
||||
return
|
||||
}
|
||||
|
||||
if quality == livekit.VideoQuality_OFF {
|
||||
delete(d.maxSubscriberQuality, subscriberID)
|
||||
} else {
|
||||
d.maxSubscriberQuality[subscriberID] = quality
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, quality livekit.VideoQuality) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber node max quality",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberNodeID", nodeID,
|
||||
"quality", quality.String(),
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
// the downstream node will synthesize correct quality notify (its dynacast manager has codec regression), just ignore it
|
||||
d.params.Logger.Debugw(
|
||||
"ignoring node quality change, regressed to another dynacast quality",
|
||||
"mime", d.params.MimeType,
|
||||
"regressedMime", d.regressTo.Mime(),
|
||||
)
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if quality == livekit.VideoQuality_OFF {
|
||||
delete(d.maxSubscriberNodeQuality, nodeID)
|
||||
} else {
|
||||
d.maxSubscriberNodeQuality[nodeID] = quality
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) ClearSubscriberNodes() {
|
||||
d.lock.Lock()
|
||||
d.maxSubscriberNodeQuality = make(map[livekit.NodeID]livekit.VideoQuality)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Mime() mime.MimeType {
|
||||
return d.params.MimeType
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) RegressTo(other dynacastQuality) {
|
||||
d.lock.Lock()
|
||||
d.regressTo = other
|
||||
maxSubscriberQuality := d.maxSubscriberQuality
|
||||
maxSubscriberNodeQuality := d.maxSubscriberNodeQuality
|
||||
d.maxSubscriberQuality = make(map[livekit.ParticipantID]livekit.VideoQuality)
|
||||
d.maxSubscriberNodeQuality = make(map[livekit.NodeID]livekit.VideoQuality)
|
||||
d.lock.Unlock()
|
||||
|
||||
other.Replace(maxSubscriberQuality, maxSubscriberNodeQuality)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Replace(
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality,
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality,
|
||||
) {
|
||||
d.lock.Lock()
|
||||
for subID, quality := range maxSubscriberQuality {
|
||||
if oldQuality, ok := d.maxSubscriberQuality[subID]; ok {
|
||||
// no QUALITY_OFF in the map
|
||||
if quality > oldQuality {
|
||||
d.maxSubscriberQuality[subID] = quality
|
||||
}
|
||||
} else {
|
||||
d.maxSubscriberQuality[subID] = quality
|
||||
}
|
||||
}
|
||||
|
||||
for nodeID, quality := range maxSubscriberNodeQuality {
|
||||
if oldQuality, ok := d.maxSubscriberNodeQuality[nodeID]; ok {
|
||||
// no QUALITY_OFF in the map
|
||||
if quality > oldQuality {
|
||||
d.maxSubscriberNodeQuality[nodeID] = quality
|
||||
}
|
||||
} else {
|
||||
d.maxSubscriberNodeQuality[nodeID] = quality
|
||||
}
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.Restart()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) reset() {
|
||||
d.lock.Lock()
|
||||
d.initialized = false
|
||||
d.lock.Unlock()
|
||||
|
||||
d.startMaxQualityTimer()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) updateQualityChange(force bool) {
|
||||
d.lock.Lock()
|
||||
maxSubscribedQuality := livekit.VideoQuality_OFF
|
||||
for _, subQuality := range d.maxSubscriberQuality {
|
||||
if maxSubscribedQuality == livekit.VideoQuality_OFF || (subQuality != livekit.VideoQuality_OFF && subQuality > maxSubscribedQuality) {
|
||||
maxSubscribedQuality = subQuality
|
||||
}
|
||||
}
|
||||
for _, nodeQuality := range d.maxSubscriberNodeQuality {
|
||||
if maxSubscribedQuality == livekit.VideoQuality_OFF || (nodeQuality != livekit.VideoQuality_OFF && nodeQuality > maxSubscribedQuality) {
|
||||
maxSubscribedQuality = nodeQuality
|
||||
}
|
||||
}
|
||||
|
||||
if maxSubscribedQuality == d.maxSubscribedQuality && d.initialized && !force {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.initialized = true
|
||||
d.maxSubscribedQuality = maxSubscribedQuality
|
||||
d.params.Logger.Debugw(
|
||||
"notifying quality change",
|
||||
"mime", d.params.MimeType,
|
||||
"maxSubscriberQuality", d.maxSubscriberQuality,
|
||||
"maxSubscriberNodeQuality", d.maxSubscriberNodeQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
"force", force,
|
||||
)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.params.Listener.OnUpdateMaxQualityForMime(d.params.MimeType, maxSubscribedQuality)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) startMaxQualityTimer() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.maxQualityTimer != nil {
|
||||
d.maxQualityTimer.Stop()
|
||||
d.maxQualityTimer = nil
|
||||
}
|
||||
|
||||
d.maxQualityTimer = time.AfterFunc(initialQualityUpdateWait, func() {
|
||||
d.stopMaxQualityTimer()
|
||||
d.updateQualityChange(true)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) stopMaxQualityTimer() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.maxQualityTimer != nil {
|
||||
d.maxQualityTimer.Stop()
|
||||
d.maxQualityTimer = nil
|
||||
}
|
||||
}
|
||||
185
pkg/rtc/dynacast/interfaces.go
Normal file
185
pkg/rtc/dynacast/interfaces.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package dynacast
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
type DynacastManagerListener interface {
|
||||
OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
)
|
||||
|
||||
OnDynacastSubscribedAudioCodecChange(codecs []*livekit.SubscribedAudioCodec)
|
||||
}
|
||||
|
||||
var _ DynacastManagerListener = (*DynacastManagerListenerNull)(nil)
|
||||
|
||||
type DynacastManagerListenerNull struct {
|
||||
}
|
||||
|
||||
func (d *DynacastManagerListenerNull) OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
}
|
||||
func (d *DynacastManagerListenerNull) OnDynacastSubscribedAudioCodecChange(
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
|
||||
type DynacastManager interface {
|
||||
AddCodec(mime mime.MimeType)
|
||||
HandleCodecRegression(fromMime, toMime mime.MimeType)
|
||||
Restart()
|
||||
Close()
|
||||
ForceUpdate()
|
||||
ForceQuality(quality livekit.VideoQuality)
|
||||
ForceEnable(enabled bool)
|
||||
|
||||
NotifySubscriberMaxQuality(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
quality livekit.VideoQuality,
|
||||
)
|
||||
NotifySubscription(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
enabled bool,
|
||||
)
|
||||
|
||||
NotifySubscriberNodeMaxQuality(
|
||||
nodeID livekit.NodeID,
|
||||
qualities []types.SubscribedCodecQuality,
|
||||
)
|
||||
NotifySubscriptionNode(
|
||||
nodeID livekit.NodeID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
)
|
||||
ClearSubscriberNodes()
|
||||
}
|
||||
|
||||
var _ DynacastManager = (*dynacastManagerNull)(nil)
|
||||
|
||||
type dynacastManagerNull struct {
|
||||
}
|
||||
|
||||
func (d *dynacastManagerNull) AddCodec(mime mime.MimeType) {}
|
||||
func (d *dynacastManagerNull) HandleCodecRegression(fromMime, toMime mime.MimeType) {}
|
||||
func (d *dynacastManagerNull) Restart() {}
|
||||
func (d *dynacastManagerNull) Close() {}
|
||||
func (d *dynacastManagerNull) ForceUpdate() {}
|
||||
func (d *dynacastManagerNull) ForceQuality(quality livekit.VideoQuality) {}
|
||||
func (d *dynacastManagerNull) ForceEnable(enabled bool) {}
|
||||
func (d *dynacastManagerNull) NotifySubscriberMaxQuality(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
quality livekit.VideoQuality,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) NotifySubscription(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
enabled bool,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) NotifySubscriberNodeMaxQuality(
|
||||
nodeID livekit.NodeID,
|
||||
qualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) NotifySubscriptionNode(
|
||||
nodeID livekit.NodeID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) ClearSubscriberNodes() {}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type dynacastQualityListener interface {
|
||||
OnUpdateMaxQualityForMime(mimeType mime.MimeType, maxQuality livekit.VideoQuality)
|
||||
OnUpdateAudioCodecForMime(mimeType mime.MimeType, enabled bool)
|
||||
}
|
||||
|
||||
var _ dynacastQualityListener = (*dynacastQualityListenerNull)(nil)
|
||||
|
||||
type dynacastQualityListenerNull struct {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityListenerNull) OnUpdateMaxQualityForMime(
|
||||
mimeType mime.MimeType,
|
||||
maxQuality livekit.VideoQuality,
|
||||
) {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityListenerNull) OnUpdateAudioCodecForMime(
|
||||
mimeType mime.MimeType,
|
||||
enabled bool,
|
||||
) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type dynacastQuality interface {
|
||||
Start()
|
||||
Restart()
|
||||
Stop()
|
||||
|
||||
NotifySubscriberMaxQuality(subscriberID livekit.ParticipantID, quality livekit.VideoQuality)
|
||||
NotifySubscription(subscriberID livekit.ParticipantID, enabled bool)
|
||||
|
||||
NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, quality livekit.VideoQuality)
|
||||
NotifySubscriptionNode(nodeID livekit.NodeID, enabled bool)
|
||||
ClearSubscriberNodes()
|
||||
|
||||
Replace(
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality,
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality,
|
||||
)
|
||||
|
||||
Mime() mime.MimeType
|
||||
RegressTo(other dynacastQuality)
|
||||
}
|
||||
|
||||
var _ dynacastQuality = (*dynacastQualityNull)(nil)
|
||||
|
||||
type dynacastQualityNull struct {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityNull) Start() {}
|
||||
func (d *dynacastQualityNull) Restart() {}
|
||||
func (d *dynacastQualityNull) Stop() {}
|
||||
func (d *dynacastQualityNull) NotifySubscriberMaxQuality(subscriberID livekit.ParticipantID, quality livekit.VideoQuality) {
|
||||
}
|
||||
func (d *dynacastQualityNull) NotifySubscription(subscriberID livekit.ParticipantID, enabled bool) {}
|
||||
func (d *dynacastQualityNull) NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, quality livekit.VideoQuality) {
|
||||
}
|
||||
func (d *dynacastQualityNull) NotifySubscriptionNode(nodeID livekit.NodeID, enabled bool) {}
|
||||
func (d *dynacastQualityNull) ClearSubscriberNodes() {}
|
||||
func (d *dynacastQualityNull) Replace(
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality,
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastQualityNull) Mime() mime.MimeType { return mime.MimeTypeUnknown }
|
||||
func (d *dynacastQualityNull) RegressTo(other dynacastQuality) {}
|
||||
176
pkg/rtc/egress.go
Normal file
176
pkg/rtc/egress.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright 2023 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/webhook"
|
||||
)
|
||||
|
||||
type EgressLauncher interface {
|
||||
StartEgress(context.Context, *rpc.StartEgressRequest) (*livekit.EgressInfo, error)
|
||||
StopEgress(context.Context, *livekit.StopEgressRequest) (*livekit.EgressInfo, error)
|
||||
}
|
||||
|
||||
func StartParticipantEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
ts telemetry.TelemetryService,
|
||||
opts *livekit.AutoParticipantEgress,
|
||||
identity livekit.ParticipantIdentity,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) error {
|
||||
if req, err := startParticipantEgress(ctx, launcher, opts, identity, roomName, roomID); err != nil {
|
||||
// send egress failed webhook
|
||||
|
||||
info := &livekit.EgressInfo{
|
||||
RoomId: string(roomID),
|
||||
RoomName: string(roomName),
|
||||
Status: livekit.EgressStatus_EGRESS_FAILED,
|
||||
Error: err.Error(),
|
||||
Request: &livekit.EgressInfo_Participant{Participant: req},
|
||||
}
|
||||
|
||||
ts.NotifyEgressEvent(ctx, webhook.EventEgressEnded, info)
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startParticipantEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
opts *livekit.AutoParticipantEgress,
|
||||
identity livekit.ParticipantIdentity,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) (*livekit.ParticipantEgressRequest, error) {
|
||||
req := &livekit.ParticipantEgressRequest{
|
||||
RoomName: string(roomName),
|
||||
Identity: string(identity),
|
||||
FileOutputs: opts.FileOutputs,
|
||||
SegmentOutputs: opts.SegmentOutputs,
|
||||
}
|
||||
|
||||
switch o := opts.Options.(type) {
|
||||
case *livekit.AutoParticipantEgress_Preset:
|
||||
req.Options = &livekit.ParticipantEgressRequest_Preset{Preset: o.Preset}
|
||||
case *livekit.AutoParticipantEgress_Advanced:
|
||||
req.Options = &livekit.ParticipantEgressRequest_Advanced{Advanced: o.Advanced}
|
||||
}
|
||||
|
||||
if launcher == nil {
|
||||
return req, errors.New("egress launcher not found")
|
||||
}
|
||||
|
||||
_, err := launcher.StartEgress(ctx, &rpc.StartEgressRequest{
|
||||
Request: &rpc.StartEgressRequest_Participant{
|
||||
Participant: req,
|
||||
},
|
||||
RoomId: string(roomID),
|
||||
})
|
||||
return req, err
|
||||
}
|
||||
|
||||
func StartTrackEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
ts telemetry.TelemetryService,
|
||||
opts *livekit.AutoTrackEgress,
|
||||
track types.MediaTrack,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) error {
|
||||
if req, err := startTrackEgress(ctx, launcher, opts, track, roomName, roomID); err != nil {
|
||||
// send egress failed webhook
|
||||
|
||||
info := &livekit.EgressInfo{
|
||||
RoomId: string(roomID),
|
||||
RoomName: string(roomName),
|
||||
Status: livekit.EgressStatus_EGRESS_FAILED,
|
||||
Error: err.Error(),
|
||||
Request: &livekit.EgressInfo_Track{Track: req},
|
||||
}
|
||||
ts.NotifyEgressEvent(ctx, webhook.EventEgressEnded, info)
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startTrackEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
opts *livekit.AutoTrackEgress,
|
||||
track types.MediaTrack,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) (*livekit.TrackEgressRequest, error) {
|
||||
output := &livekit.DirectFileOutput{
|
||||
Filepath: getFilePath(opts.Filepath),
|
||||
}
|
||||
|
||||
switch out := opts.Output.(type) {
|
||||
case *livekit.AutoTrackEgress_Azure:
|
||||
output.Output = &livekit.DirectFileOutput_Azure{Azure: out.Azure}
|
||||
case *livekit.AutoTrackEgress_Gcp:
|
||||
output.Output = &livekit.DirectFileOutput_Gcp{Gcp: out.Gcp}
|
||||
case *livekit.AutoTrackEgress_S3:
|
||||
output.Output = &livekit.DirectFileOutput_S3{S3: out.S3}
|
||||
}
|
||||
|
||||
req := &livekit.TrackEgressRequest{
|
||||
RoomName: string(roomName),
|
||||
TrackId: string(track.ID()),
|
||||
Output: &livekit.TrackEgressRequest_File{
|
||||
File: output,
|
||||
},
|
||||
}
|
||||
|
||||
if launcher == nil {
|
||||
return req, errors.New("egress launcher not found")
|
||||
}
|
||||
|
||||
_, err := launcher.StartEgress(ctx, &rpc.StartEgressRequest{
|
||||
Request: &rpc.StartEgressRequest_Track{
|
||||
Track: req,
|
||||
},
|
||||
RoomId: string(roomID),
|
||||
})
|
||||
return req, err
|
||||
}
|
||||
|
||||
func getFilePath(filepath string) string {
|
||||
if filepath == "" || strings.HasSuffix(filepath, "/") || strings.Contains(filepath, "{track_id}") {
|
||||
return filepath
|
||||
}
|
||||
|
||||
idx := strings.Index(filepath, ".")
|
||||
if idx == -1 {
|
||||
return fmt.Sprintf("%s-{track_id}", filepath)
|
||||
} else {
|
||||
return fmt.Sprintf("%s-%s%s", filepath[:idx], "{track_id}", filepath[idx:])
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user