snapshot of https://github.com/livekit/livekit.git @ 08c5433449c106943231037d0b892895f6e92d3b (2026-09-15)
Some checks failed
Release to Docker / docker (push) Failing after 2s
Release Test Server to Docker / docker (push) Failing after 3s
Test / test (push) Failing after 16s

This commit is contained in:
upstream-mirror
2026-09-15 16:14:22 +05:00
commit 78704103fc
422 changed files with 154805 additions and 0 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @livekit/cs-devs

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

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
View 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
View 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
View 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
View 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
View 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 }}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

61
Dockerfile Normal file
View 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
View 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
View 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
View 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.
[![GitHub stars](https://img.shields.io/github/stars/livekit/livekit?style=social&label=Star&maxAge=2592000)](https://github.com/livekit/livekit/stargazers/)
[![Slack community](https://img.shields.io/endpoint?url=https%3A%2F%2Flivekit.io%2Fbadges%2Fslack)](https://livekit.io/join-slack)
[![Twitter Follow](https://img.shields.io/twitter/follow/livekit)](https://twitter.com/livekit)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/livekit/livekit)
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/livekit/livekit)](https://github.com/livekit/livekit/releases/latest)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/livekit/livekit/buildtest.yaml?branch=master)](https://github.com/livekit/livekit/actions/workflows/buildtest.yaml)
[![License](https://img.shields.io/github/license/livekit/livekit)](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
View 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
View 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
View 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
View 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)
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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,
},
},
}
}

View 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
View 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
View 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.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"`
}

View 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
View 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
}

View 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,
},
}

View 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)
}
})
}
}

View 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
}

View 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
}

View 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

File diff suppressed because it is too large Load Diff

249
pkg/config/config_test.go Normal file
View 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)
})
}

View 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{}{})
}

View 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,
}
)

View 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
}

View 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
}
}
}

View 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
View 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
View 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
View 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,
)
}
}
*/

View 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
}

View 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
View 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
View 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
View 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)
}
}
}

View 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()
}

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)
}

View 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)
})
}
}

View 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)
}

View 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")
}
}
}

View 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")
)

View 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
}
}

View 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
}

View 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 = &region
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))
}

View 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,
},
}
}

View 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)
}
}

View 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)
}

View 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")
}
}
}

View 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
}

View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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()))
}

View 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()
}

View 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)
})
}

View 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))
}

View 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)
})
}

View 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)
}

View 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
}
}

View 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
View 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