Merge remote-tracking branch 'origin/master' into dc/features/fec

# Conflicts:
#	pkg/rtc/transport.go
This commit is contained in:
David Chen
2026-07-12 22:59:14 -07:00
72 changed files with 5819 additions and 448 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: shogo82148/actions-setup-redis@2f3253b148c73d7a0682eae73e862b777a4fa74e # v1
with:
redis-version: "6.x"
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Fetch all tags
run: git fetch --force --tags
+53
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
+52
View File
@@ -2,6 +2,58 @@
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.13.3] - 2026-07-03
### Added
- feat: mock API server for testing server SDKs (#4627)
- support auth checks with mock server (#4629)
- Data track schema metadata (#4622)
- Report average bitrates for whip ingress (#4634)
### Changed
- Update webrtc to fix interop issue with bundled datachannel (#4631)
- Update module github.com/urfave/cli/v3 to v3.10.0 (#4612)
- Use camel case log name in `DataBlobKey` (#4633)
### Fixed
- Stop WHIP session notifier when participant leaves (#4637)
## [1.13.2] - 2026-06-27
### Added
- Add Prometheus metrics for join latency and peer connection state (#4574, #4616)
- Preserve original expiry when refreshing token (#4580)
- Add grants expiry to Auth context (#4581)
- Add ability to run pprof on dedicated HTTP server (#4584)
- Add API to get latest node stats (#4589)
- Enforce subscription permission to data track (#4588)
- egress v2 api (#4592)
- agent: thread attributes map from dispatch to job (#4598)
- Log subscription limit breaches (#4603)
- Acquire requested video layer directly at HIGH quality by default (#4595)
- Report participant capabilities in ParticipantInfo (#4606)
- Add option to force drain rtcService/agentService connections (#4618)
- Add support for data blob (a. k. a. async participant attributes) (#4619)
### Changed
- Update dependencies: pion/sctp, DTLS v3.1.4, protocol (#4587, #4601, #4623)
- rtc: add RestartSessionTimer to re-anchor participant session duration (#4566)
- Record more RTC cancellation points (#4600)
- Cap all metadata at 512 KiB; enforce on join, agent dispatch, and embedded agents (#4602)
- Tighten up publish latency stat (#4615)
- Echo offered audio payload types in single-PC subscriber answer (#4614)
### Fixed
- Fix skipped packets accounting (#4604)
- Do not log due to negative getting interpreted as large unsigned positive (#4605)
- Do not call nil callback (#4607)
## [1.13.1] - 2026-06-08
### Fixed
+40
View File
@@ -0,0 +1,40 @@
# 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 .
FROM golang:1.26-alpine AS builder
ARG TARGETARCH
WORKDIR /workspace
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
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"]
+212
View File
@@ -0,0 +1,212 @@
# 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". |
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
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()
}
+146
View File
@@ -0,0 +1,146 @@
// 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"
)
// 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"`
// 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
}
+314
View File
@@ -0,0 +1,314 @@
// 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"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/emptypb"
"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, emptypb.Empty]("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
}
// 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))
}
// 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
}
}
+245
View File
@@ -0,0 +1,245 @@
// 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
}
body, err := protojson.Marshal(h.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)
}
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
}
+406
View File
@@ -0,0 +1,406 @@
// 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"
"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
)
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:
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"
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))
}
// 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_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),
},
},
}
}
func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse {
return &livekit.SignalResponse{
Message: &livekit.SignalResponse_Leave{
Leave: &livekit.LeaveRequest{
Reason: livekit.DisconnectReason_SERVER_SHUTDOWN,
Action: action,
},
},
}
}
+451
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")
}
}
+30 -30
View File
@@ -21,7 +21,7 @@ require (
github.com/jxskiss/base62 v1.1.0
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0
github.com/livekit/protocol v1.46.7-0.20260611165352-04a0fe5b5051
github.com/livekit/protocol v1.49.1-0.20260712085342-8a3c109dc3c6
github.com/livekit/psrpc v0.7.2
github.com/mackerelio/go-osstat v0.2.7
github.com/magefile/mage v1.17.2
@@ -30,20 +30,20 @@ require (
github.com/moby/moby/client v0.4.1
github.com/olekukonko/tablewriter v1.1.4
github.com/ory/dockertest/v4 v4.0.0
github.com/pion/datachannel v1.6.0
github.com/pion/dtls/v3 v3.1.4
github.com/pion/datachannel v1.6.2
github.com/pion/dtls/v3 v3.1.5
github.com/pion/ice/v4 v4.2.7
github.com/pion/interceptor v0.1.45
github.com/pion/rtcp v1.2.16
github.com/pion/rtp v1.10.2
github.com/pion/sctp v1.9.5
github.com/pion/sdp/v3 v3.0.18
github.com/pion/rtcp v1.2.17
github.com/pion/rtp v1.10.3
github.com/pion/sctp v1.10.3
github.com/pion/sdp/v3 v3.0.19
github.com/pion/transport/v4 v4.0.2
github.com/pion/turn/v5 v5.0.8
github.com/pion/webrtc/v4 v4.2.11
github.com/pion/turn/v5 v5.0.12
github.com/pion/webrtc/v4 v4.2.16
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.20.0
github.com/redis/go-redis/v9 v9.21.0
github.com/rs/cors v1.11.1
github.com/stretchr/testify v1.11.1
github.com/thoas/go-funk v0.9.3
@@ -54,8 +54,8 @@ require (
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.36.0
golang.org/x/sync v0.20.0
golang.org/x/mod v0.38.0
golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -92,12 +92,12 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
golang.org/x/time v0.15.0 // indirect
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect
buf.build/go/protovalidate v1.2.0 // indirect
buf.build/go/protoyaml v0.7.0 // indirect
cel.dev/expr v0.25.2 // indirect
@@ -111,15 +111,15 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/google/cel-go v0.28.1 // indirect
github.com/google/cel-go v0.29.2 // 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.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
@@ -134,22 +134,22 @@ require (
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/srtp/v3 v3.0.11 // indirect
github.com/pion/stun/v3 v3.1.4
github.com/pion/srtp/v3 v3.0.12 // indirect
github.com/pion/stun/v3 v3.1.6
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/urfave/cli/v3 v3.9.0
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/urfave/cli/v3 v3.10.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.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
google.golang.org/grpc v1.82.0 // indirect
)
+60 -62
View File
@@ -1,5 +1,5 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
@@ -85,8 +85,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
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/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k=
github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
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=
@@ -139,10 +139,10 @@ github.com/jsimonetti/rtnetlink v0.0.0-20211022192332-93da33804786 h1:N527AHMa79
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.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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=
@@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I=
github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.46.7-0.20260611165352-04a0fe5b5051 h1:IYqiW7z5pblZBn6o0OHNz8MHd3wJ/TLJG4gh6lCI0/s=
github.com/livekit/protocol v1.46.7-0.20260611165352-04a0fe5b5051/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/protocol v1.49.1-0.20260712085342-8a3c109dc3c6 h1:ANFafwVDRMeB3bGva/C9BFcWp/iAvSCwbTYWWVuners=
github.com/livekit/protocol v1.49.1-0.20260712085342-8a3c109dc3c6/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc=
github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94=
@@ -229,10 +229,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
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/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
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/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
@@ -243,28 +243,26 @@ github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
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.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY=
github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U=
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.3 h1:r5nJQdtM9Dc4ZYxtTcPPz7PIFArKJIf/DMlIUxU7+1c=
github.com/pion/rtp v1.10.3/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI=
github.com/pion/sctp v1.10.3/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.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
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.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8=
github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU=
github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU=
github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY=
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q=
github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ=
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=
@@ -274,14 +272,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
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.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
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=
@@ -305,8 +303,8 @@ github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJX
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.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM=
github.com/urfave/cli/v3 v3.10.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=
@@ -351,12 +349,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
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=
@@ -371,12 +369,12 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
@@ -400,31 +398,31 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4=
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+5
View File
@@ -177,6 +177,11 @@ func TestAll() error {
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 {
+2
View File
@@ -64,6 +64,7 @@ type JobRequest struct {
Metadata string
AgentName string
Deployment string
Attributes map[string]string
}
type agentClient struct {
@@ -172,6 +173,7 @@ func (c *agentClient) LaunchJob(ctx context.Context, desc *JobRequest) *serverut
Metadata: desc.Metadata,
EnableRecording: c.config.EnableUserDataRecording,
Deployment: desc.Deployment,
Attributes: desc.Attributes,
}
resp, err := c.client.JobRequest(context.Background(), topic, jobTypeTopic, job)
if err != nil {
+2 -2
View File
@@ -29,7 +29,7 @@ import (
type AgentService interface {
HandleConnection(context.Context, agent.SignalConn, agent.WorkerRegistration)
DrainConnections(time.Duration)
DrainConnections(time.Duration, bool)
}
type TestServer struct {
@@ -140,7 +140,7 @@ func (h *TestServer) SimulateAgentWorker(opts ...SimulatedWorkerOption) *AgentWo
}
func (h *TestServer) Close() {
h.DrainConnections(1)
h.DrainConnections(1, false)
}
var _ agent.SignalConn = (*AgentWorker)(nil)
+67 -7
View File
@@ -91,6 +91,8 @@ type Config struct {
EnableDataTracks bool `yaml:"enable_data_tracks,omitempty"`
EnableParticipantDataBlob bool `yaml:"enable_participant_data_blob,omitempty"`
API APIConfig `yaml:"api,omitempty"`
}
@@ -262,7 +264,6 @@ type RoomConfig struct {
EnableRemoteUnmute bool `yaml:"enable_remote_unmute,omitempty"`
PlayoutDelay PlayoutDelayConfig `yaml:"playout_delay,omitempty"`
SyncStreams bool `yaml:"sync_streams,omitempty"`
CreateRoomEnabled bool `yaml:"create_room_enabled,omitempty"`
CreateRoomTimeout time.Duration `yaml:"create_room_timeout,omitempty"`
CreateRoomAttempts int `yaml:"create_room_attempts,omitempty"`
// target room participant update batch chunk size in bytes
@@ -335,6 +336,8 @@ type RegionConfig struct {
Lon float64 `yaml:"lon,omitempty"`
}
// ---------------------------------
type LimitConfig struct {
NumTracks int32 `yaml:"num_tracks,omitempty"`
BytesPerSec float32 `yaml:"bytes_per_sec,omitempty"`
@@ -346,6 +349,11 @@ type LimitConfig struct {
MaxRoomNameLength int `yaml:"max_room_name_length,omitempty"`
MaxParticipantIdentityLength int `yaml:"max_participant_identity_length,omitempty"`
MaxParticipantNameLength int `yaml:"max_participant_name_length,omitempty"`
MaxDataBlobKeyLength int `yaml:"max_data_blob_key_length,omitempty"`
MaxDataBlobSize uint32 `yaml:"max_data_blobs_size,omitempty"`
MaxDataTrackCustomEncodingLength int `yaml:"max_data_track_custom_encoding_length,omitempty"`
}
func (l LimitConfig) CheckRoomNameLength(name string) bool {
@@ -376,6 +384,56 @@ func (l LimitConfig) CheckAttributesSize(attributes map[string]string) bool {
return uint32(total) <= l.MaxAttributesSize
}
func (l LimitConfig) CheckDataBlobKeyLength(key string) bool {
return l.MaxDataBlobKeyLength == 0 || len(key) <= l.MaxDataBlobKeyLength
}
func (l LimitConfig) CheckDataTrackCustomEncodingLength(identifier string) bool {
return l.MaxDataTrackCustomEncodingLength == 0 || len(identifier) <= l.MaxDataTrackCustomEncodingLength
}
func (l LimitConfig) CheckDataTrackFrameEncoding(encoding *livekit.DataTrackFrameEncoding) bool {
custom, ok := encoding.GetValue().(*livekit.DataTrackFrameEncoding_Custom)
if !ok {
return true
}
return len(custom.Custom) != 0 && l.CheckDataTrackCustomEncodingLength(custom.Custom)
}
func (l LimitConfig) CheckDataTrackSchemaID(schema *livekit.DataTrackSchemaId) bool {
custom, ok := schema.GetEncoding().GetValue().(*livekit.DataTrackSchemaEncoding_Custom)
if !ok {
return true
}
return len(custom.Custom) != 0 && l.CheckDataTrackCustomEncodingLength(custom.Custom)
}
func (l LimitConfig) CheckDataBlobsSize(dataBlobs []*livekit.DataBlob) bool {
if l.MaxDataBlobSize == 0 {
return true
}
total := 0
for _, dataBlob := range dataBlobs {
total += len(dataBlob.GetKey().String()) + len(dataBlob.Contents)
}
return uint32(total) <= l.MaxDataBlobSize
}
func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *livekit.DataBlob) bool {
if l.MaxDataBlobSize == 0 {
return true
}
total := 0
for _, dataBlob := range dataBlobs {
total += len(dataBlob.Key.String()) + len(dataBlob.Contents)
}
return uint32(total+len(toAdd.GetKey().String())+len(toAdd.Contents)) <= l.MaxDataBlobSize
}
// ---------------------------------
type IngressConfig struct {
RTMPBaseURL string `yaml:"rtmp_base_url,omitempty"`
WHIPBaseURL string `yaml:"whip_base_url,omitempty"`
@@ -489,17 +547,19 @@ var DefaultConfig = Config{
},
EmptyTimeout: 5 * 60,
DepartureTimeout: 20,
CreateRoomEnabled: true,
CreateRoomTimeout: 10 * time.Second,
CreateRoomAttempts: 3,
UpdateBatchTargetSize: 128 * 1024,
},
Limit: LimitConfig{
MaxMetadataSize: 64000,
MaxAttributesSize: 64000,
MaxRoomNameLength: 256,
MaxParticipantIdentityLength: 256,
MaxParticipantNameLength: 256,
MaxMetadataSize: 512 * 1024,
MaxAttributesSize: 64 * 1024,
MaxRoomNameLength: 256,
MaxParticipantIdentityLength: 256,
MaxParticipantNameLength: 256,
MaxDataBlobKeyLength: 256,
MaxDataBlobSize: 64000,
MaxDataTrackCustomEncodingLength: 32,
},
Logging: LoggingConfig{
PionLevel: "error",
+10 -9
View File
@@ -104,15 +104,16 @@ func (t *MediaTrackSubscriptions) AddSubscriber(sub types.LocalParticipant, wr *
t.subscribedTracksMu.Unlock()
subTrack, err := NewSubscribedTrack(SubscribedTrackParams{
ReceiverConfig: t.params.ReceiverConfig,
SubscriberConfig: t.params.SubscriberConfig,
Subscriber: sub,
MediaTrack: t.params.MediaTrack,
AdaptiveStream: sub.GetAdaptiveStream(),
TelemetryListener: sub.GetTelemetryListener(),
WrappedReceiver: wr,
IsRelayed: t.params.IsRelayed,
OnDownTrackCreated: t.onDownTrackCreated,
ReceiverConfig: t.params.ReceiverConfig,
SubscriberConfig: t.params.SubscriberConfig,
Subscriber: sub,
MediaTrack: t.params.MediaTrack,
AdaptiveStream: sub.GetAdaptiveStream(),
EnableStartAtDesiredQuality: sub.GetEnableStartAtDesiredQuality(),
TelemetryListener: sub.GetTelemetryListener(),
WrappedReceiver: wr,
IsRelayed: t.params.IsRelayed,
OnDownTrackCreated: t.onDownTrackCreated,
OnDownTrackClosed: func(subscriberID livekit.ParticipantID) {
t.subscribedTracksMu.Lock()
delete(t.subscribedTracks, subscriberID)
+57 -10
View File
@@ -225,6 +225,10 @@ type ParticipantParams struct {
EnableRTPStreamRestartDetection bool
ForceBackupCodecPolicySimulcast bool
DisableTransceiverReuseForE2EE bool
EnableParticipantDataBlob bool
EnableStartAtDesiredQuality bool
MigrationWaitDuration time.Duration
ExcludeIPv6LocalCandidates bool
}
type ParticipantImpl struct {
@@ -333,6 +337,8 @@ type ParticipantImpl struct {
rpcLock sync.Mutex
rpcPendingAcks map[string]*utils.DataChannelRpcPendingAckHandler
rpcPendingResponses map[string]*utils.DataChannelRpcPendingResponseHandler
dataBlob *ParticipantDataBlob
}
func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) {
@@ -371,6 +377,9 @@ func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) {
telemetryGuard: &telemetry.ReferenceGuard{},
nextSubscribedDataTrackHandle: uint16(rand.Intn(256)),
requireBroadcast: params.Grants.Metadata != "" || len(params.Grants.Attributes) != 0,
dataBlob: NewParticipantDataBlob(ParticipantDataBlobParams{
Logger: params.Logger,
}),
}
p.setupSignalling()
@@ -505,6 +514,10 @@ func (p *ParticipantImpl) GetAdaptiveStream() bool {
return p.params.AdaptiveStream
}
func (p *ParticipantImpl) GetEnableStartAtDesiredQuality() bool {
return p.params.EnableStartAtDesiredQuality
}
func (p *ParticipantImpl) GetPacer() pacer.Pacer {
return p.TransportManager.GetSubscriberPacer()
}
@@ -906,6 +919,7 @@ func (p *ParticipantImpl) ToProtoWithVersion() (*livekit.ParticipantInfo, utils.
KindDetails: grants.GetKindDetails(),
DisconnectReason: p.CloseReason().ToDisconnectReason(),
ClientProtocol: clientProtocol,
Capabilities: p.params.ClientInfo.GetCapabilities(),
}
p.lock.RUnlock()
@@ -966,7 +980,9 @@ func (p *ParticipantImpl) GetTelemetryListener() types.ParticipantTelemetryListe
func (p *ParticipantImpl) AddOnClose(key string, callback func(types.LocalParticipant)) {
if p.isClosed.Load() {
go callback(p)
if callback != nil {
go callback(p)
}
return
}
@@ -1332,9 +1348,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) {
return
}
p.pendingTracksLock.Lock()
ti := p.addPendingTrackLocked(req)
p.pendingTracksLock.Unlock()
ti := p.addPendingTrack(req)
if ti == nil {
return
}
@@ -1413,12 +1427,41 @@ func (p *ParticipantImpl) IsReconnect() bool {
return p.params.Reconnect
}
func (p *ParticipantImpl) IsMigration() bool {
return p.params.Migration
}
func (p *ParticipantImpl) recordRTCState(closeReason types.ParticipantCloseReason) {
if p.HasConnected() {
prometheus.IncrementParticipantRtcSuccess(1)
} else {
if p.IsConnectionCanceled(closeReason) {
prometheus.IncrementParticipantRtcCanceled(1)
} else {
prometheus.IncrementParticipantRtcFailure(1)
}
}
}
func (p *ParticipantImpl) IsConnectionCanceled(closeReason types.ParticipantCloseReason) bool {
return closeReason == types.ParticipantCloseReasonClientRequestLeave ||
closeReason == types.ParticipantCloseReasonDuplicateIdentity ||
closeReason == types.ParticipantCloseReasonRoomClosed ||
closeReason == types.ParticipantCloseReasonMigrationRequested ||
closeReason == types.ParticipantCloseReasonMigrationComplete ||
// client closing signal connection too quickly, a quick close could be an indication of client leaving before a timeout
// something longer could be clients timing out without sending a leave message and is usually a sign of failed connection
(time.Since(p.params.SessionStartTime) < 3*time.Second && closeReason == types.ParticipantCloseReasonSignalSourceClose)
}
func (p *ParticipantImpl) Close(sendLeave bool, reason types.ParticipantCloseReason, isExpectedToResume bool) error {
if p.isClosed.Swap(true) {
// already closed
return nil
}
p.recordRTCState(reason)
var sessionDuration time.Duration
if activeAt := p.ActiveAt(); !activeAt.IsZero() {
sessionDuration = time.Since(activeAt)
@@ -1542,7 +1585,7 @@ func (p *ParticipantImpl) setupMigrationTimerLocked() {
// to try and succeed. If not, close the subscriber peer connection
// and help the remote side to narrow down its ICE candidate pool.
//
p.migrationTimer = time.AfterFunc(migrationWaitDuration, func() {
p.migrationTimer = time.AfterFunc(max(p.params.MigrationWaitDuration, migrationWaitDuration), func() {
p.clearMigrationTimer()
if p.IsClosed() || p.IsDisconnected() {
@@ -2018,6 +2061,7 @@ func (p *ParticipantImpl) setupTransportManager() error {
UseOneShotSignallingMode: p.params.UseOneShotSignallingMode,
FireOnTrackBySdp: p.params.FireOnTrackBySdp,
EnableDataTracks: p.params.EnableDataTracks,
ExcludeIPv6LocalCandidates: p.params.ExcludeIPv6LocalCandidates,
}
if p.params.SyncStreams && p.params.PlayoutDelay.GetEnabled() && p.params.ClientInfo.isFirefox() {
// we will disable playout delay for Firefox if the user is expecting
@@ -2826,7 +2870,10 @@ func (p *ParticipantImpl) onSubscribedAudioCodecChange(
return p.sendSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate)
}
func (p *ParticipantImpl) addPendingTrackLocked(req *livekit.AddTrackRequest) *livekit.TrackInfo {
func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit.TrackInfo {
p.pendingTracksLock.Lock()
defer p.pendingTracksLock.Unlock()
if req.Sid != "" {
track := p.GetPublishedTrack(livekit.TrackID(req.Sid))
if track == nil {
@@ -3223,11 +3270,11 @@ func (p *ParticipantImpl) mediaTrackReceived(
mt = p.addMediaTrack(signalCid, ti)
newTrack = true
// if the addTrackRequest is sent before participant active then it means the client tries to publish
// before fully connected, in this case we only record the time when the participant is active since
// if the addTrackRequest is sent before publisher peer connection is established, then it means the client tries to publish
// before fully connected, in this case we only record the time when publisher peer connection is established since
// we want this metric to represent the time cost by publishing.
if activeAt := p.lastActiveAt.Load(); activeAt != nil && createdAt.Before(*activeAt) {
createdAt = *activeAt
if connectedAt := p.TransportManager.PublisherFirstConnectedAt(); !connectedAt.IsZero() && createdAt.Before(connectedAt) {
createdAt = connectedAt
}
pubTime = time.Since(createdAt)
p.dirty.Store(true)
+90
View File
@@ -0,0 +1,90 @@
// 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 rtc
import (
"sync"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
type ParticipantDataBlobParams struct {
Logger logger.Logger
}
type ParticipantDataBlob struct {
params ParticipantDataBlobParams
lock sync.Mutex
blobs map[string]*livekit.DataBlob
}
func NewParticipantDataBlob(params ParticipantDataBlobParams) *ParticipantDataBlob {
return &ParticipantDataBlob{
params: params,
blobs: make(map[string]*livekit.DataBlob),
}
}
func (p *ParticipantDataBlob) Add(db *livekit.DataBlob) {
p.lock.Lock()
defer p.lock.Unlock()
if db.Key == nil {
return
}
p.blobs[db.Key.String()] = db
}
func (p *ParticipantDataBlob) Delete(dbKey *livekit.DataBlobKey) {
p.lock.Lock()
defer p.lock.Unlock()
if dbKey == nil {
return
}
delete(p.blobs, dbKey.String())
}
func (p *ParticipantDataBlob) Get(dbKey *livekit.DataBlobKey) *livekit.DataBlob {
p.lock.Lock()
defer p.lock.Unlock()
if dbKey == nil {
return nil
}
db, ok := p.blobs[dbKey.String()]
if !ok {
return nil
}
return db
}
func (p *ParticipantDataBlob) GetAll() []*livekit.DataBlob {
p.lock.Lock()
defer p.lock.Unlock()
all := make([]*livekit.DataBlob, 0, len(p.blobs))
for _, db := range p.blobs {
all = append(all, db)
}
return all
}
// -------------------------------
+123
View File
@@ -0,0 +1,123 @@
// 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 rtc
import (
"github.com/livekit/livekit-server/pkg/rtc/types"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
func (p *ParticipantImpl) HandleStoreDataBlobRequest(req *livekit.StoreDataBlobRequest) {
if !p.params.EnableParticipantDataBlob {
p.pubLogger.Warnw("data blob not enabled", nil, "req", logger.Proto(req))
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_NOT_ALLOWED,
Message: "data blob not enabled",
})
return
}
if req.Blob == nil || req.Blob.Key == nil || len(req.Blob.Key.String()) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key.String()) {
p.pubLogger.Warnw("data blob is invalid", nil, "req", logger.Proto(req))
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_INVALID_REQUEST,
Message: "data blob is invalid",
})
return
}
if !p.params.LimitConfig.CheckDataTrackSchemaID(req.Blob.Key.GetSchemaId()) {
p.pubLogger.Warnw("data blob key schema id is invalid", nil, "req", logger.Proto(req))
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_INVALID_REQUEST,
Message: "encoding identifier is empty or exceeds the maximum length",
})
return
}
if len(req.Blob.Contents) == 0 {
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_INVALID_REQUEST,
Message: "data blob is empty",
})
return
}
if !p.params.LimitConfig.CanAddDataBlob(p.dataBlob.GetAll(), req.Blob) {
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_LIMIT_EXCEEDED,
Message: "async attribute definition exceeds limit",
})
return
}
p.AddDataBlob(req.Blob)
p.listener().OnStoreDataBlob(p, req.Blob)
p.sendStoreDataBlobResponse(req.RequestId, req.Blob.Key)
}
func (p *ParticipantImpl) HandleGetDataBlobRequest(req *livekit.GetDataBlobRequest) {
if req.Key == nil {
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_INVALID_REQUEST,
Message: "data blob key is required",
})
return
}
p.listener().OnGetDataBlob(p, req)
}
func (p *ParticipantImpl) AddDataBlob(dataBlob *livekit.DataBlob) {
p.dataBlob.Add(dataBlob)
}
func (p *ParticipantImpl) GetDataBlob(key *livekit.DataBlobKey) *livekit.DataBlob {
return p.dataBlob.Get(key)
}
func (p *ParticipantImpl) ProcessGetDataBlobRequest(req *livekit.GetDataBlobRequest, publisher types.Participant) {
if publisher == nil {
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_NOT_FOUND,
Message: "participant not found",
})
return
}
dataBlob := publisher.GetDataBlob(req.Key)
if dataBlob == nil {
p.sendRequestResponse(&livekit.RequestResponse{
RequestId: req.RequestId,
Reason: livekit.RequestResponse_NOT_FOUND,
Message: "data blob not found",
})
return
}
p.sendGetDataBlobResponse(req.RequestId, dataBlob)
}
func (p *ParticipantImpl) GetAllDataBlob() []*livekit.DataBlob {
return p.dataBlob.GetAll()
}
@@ -0,0 +1,300 @@
// 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 rtc
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/livekit/protocol/livekit"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing/routingfakes"
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
)
func newParticipantWithDataBlob(t *testing.T, enabled bool, maxKeyLength int, maxSize uint32) *ParticipantImpl {
t.Helper()
p := newParticipantForTest("test")
p.params.EnableParticipantDataBlob = enabled
p.params.LimitConfig = config.LimitConfig{
MaxDataBlobKeyLength: maxKeyLength,
MaxDataBlobSize: maxSize,
}
return p
}
func lastRequestResponse(t *testing.T, sink *routingfakes.FakeMessageSink, idx int) *livekit.RequestResponse {
t.Helper()
msg := sink.WriteMessageArgsForCall(idx).(*livekit.SignalResponse)
rr, ok := msg.Message.(*livekit.SignalResponse_RequestResponse)
require.True(t, ok, "expected SignalResponse_RequestResponse, got %T", msg.Message)
return rr.RequestResponse
}
func TestHandleStoreDataBlobRequest(t *testing.T) {
t.Run("returns NOT_ALLOWED when feature not enabled", func(t *testing.T) {
p := newParticipantWithDataBlob(t, false, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
req := &livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Key: genericKey("blob-1"),
Contents: []byte("def"),
},
}
p.HandleStoreDataBlobRequest(req)
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_NOT_ALLOWED, rr.Reason)
require.Empty(t, p.dataBlob.GetAll())
})
t.Run("returns INVALID_REQUEST when blob is nil", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
require.Empty(t, p.dataBlob.GetAll())
})
t.Run("returns INVALID_REQUEST when key is nil", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Contents: []byte("def"),
},
})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
})
t.Run("returns INVALID_REQUEST when key has no oneof set", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Key: &livekit.DataBlobKey{},
Contents: []byte("def"),
},
})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
})
t.Run("returns INVALID_REQUEST when key exceeds length limit", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 5, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Key: genericKey(strings.Repeat("a", 64)),
Contents: []byte("def"),
},
})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
})
t.Run("returns INVALID_REQUEST when contents is empty", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Key: genericKey("blob-1"),
},
})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
require.Empty(t, p.dataBlob.GetAll())
})
t.Run("returns LIMIT_EXCEEDED when adding would breach the limit", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 16)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Key: genericKey("blob-1"),
Contents: []byte(strings.Repeat("x", 32)),
},
})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_LIMIT_EXCEEDED, rr.Reason)
require.Empty(t, p.dataBlob.GetAll())
})
t.Run("stores a valid blob, notifies listener, and sends response", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
listener := p.params.ParticipantListener.(*typesfakes.FakeLocalParticipantListener)
key := genericKey("blob-1")
contents := []byte("definition-bytes")
blob := &livekit.DataBlob{Key: key, Contents: contents}
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
RequestId: 42,
Blob: blob,
})
require.Equal(t, 1, sink.WriteMessageCallCount())
msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
response, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse)
require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message)
require.Equal(t, uint32(42), response.StoreDataBlobResponse.RequestId)
require.Equal(t, key, response.StoreDataBlobResponse.Key)
stored := p.dataBlob.Get(key)
require.NotNil(t, stored)
require.Equal(t, contents, stored.Contents)
require.Equal(t, 1, listener.OnStoreDataBlobCallCount())
gotParticipant, gotBlob := listener.OnStoreDataBlobArgsForCall(0)
require.Equal(t, p, gotParticipant)
require.Equal(t, blob, gotBlob)
})
}
func TestHandleGetDataBlobRequest(t *testing.T) {
t.Run("returns INVALID_REQUEST when key is missing", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.HandleGetDataBlobRequest(&livekit.GetDataBlobRequest{
ParticipantIdentity: "other",
})
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
})
t.Run("forwards request to listener when key is provided", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
listener := p.params.ParticipantListener.(*typesfakes.FakeLocalParticipantListener)
req := &livekit.GetDataBlobRequest{
ParticipantIdentity: "other",
Key: genericKey("blob-1"),
}
p.HandleGetDataBlobRequest(req)
require.Equal(t, 1, listener.OnGetDataBlobCallCount())
gotParticipant, gotReq := listener.OnGetDataBlobArgsForCall(0)
require.Equal(t, p, gotParticipant)
require.Equal(t, req, gotReq)
})
}
func TestGetDataBlob(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
key := genericKey("blob-1")
require.Nil(t, p.GetDataBlob(key))
blob := &livekit.DataBlob{
Key: key,
Contents: []byte("definition"),
}
p.dataBlob.Add(blob)
got := p.GetDataBlob(key)
require.NotNil(t, got)
require.Equal(t, key.String(), got.Key.String())
require.Equal(t, []byte("definition"), got.Contents)
}
func TestProcessGetDataBlobRequest(t *testing.T) {
t.Run("returns NOT_FOUND when publisher is nil", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
p.ProcessGetDataBlobRequest(&livekit.GetDataBlobRequest{
Key: genericKey("blob-1"),
}, nil)
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_NOT_FOUND, rr.Reason)
require.Contains(t, rr.Message, "participant")
})
t.Run("returns NOT_FOUND when publisher has no matching blob", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
publisher := &typesfakes.FakeParticipant{}
publisher.GetDataBlobReturns(nil)
req := &livekit.GetDataBlobRequest{
Key: genericKey("blob-1"),
}
p.ProcessGetDataBlobRequest(req, publisher)
require.Equal(t, 1, publisher.GetDataBlobCallCount())
require.Equal(t, req.Key, publisher.GetDataBlobArgsForCall(0))
require.Equal(t, 1, sink.WriteMessageCallCount())
rr := lastRequestResponse(t, sink, 0)
require.Equal(t, livekit.RequestResponse_NOT_FOUND, rr.Reason)
})
t.Run("sends blob response when publisher has a matching blob", func(t *testing.T) {
p := newParticipantWithDataBlob(t, true, 0, 0)
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
key := genericKey("blob-1")
blob := &livekit.DataBlob{
Key: key,
Contents: []byte("definition-bytes"),
}
publisher := &typesfakes.FakeParticipant{}
publisher.GetDataBlobReturns(blob)
p.ProcessGetDataBlobRequest(&livekit.GetDataBlobRequest{
RequestId: 42,
Key: key,
}, publisher)
require.Equal(t, 1, sink.WriteMessageCallCount())
msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
response, ok := msg.Message.(*livekit.SignalResponse_GetDataBlobResponse)
require.True(t, ok, "expected SignalResponse_GetDataBlobResponse, got %T", msg.Message)
require.Equal(t, uint32(42), response.GetDataBlobResponse.RequestId)
require.Equal(t, blob, response.GetDataBlobResponse.Blob)
})
}
+175
View File
@@ -0,0 +1,175 @@
// 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 rtc
import (
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
func newTestDataBlob() *ParticipantDataBlob {
return NewParticipantDataBlob(ParticipantDataBlobParams{
Logger: logger.GetLogger(),
})
}
func genericKey(name string) *livekit.DataBlobKey {
return &livekit.DataBlobKey{
Key: &livekit.DataBlobKey_Generic{
Generic: name,
},
}
}
func TestParticipantDataBlob_AddAndGet(t *testing.T) {
a := newTestDataBlob()
key := genericKey("blob-1")
contents := []byte("definition-bytes")
a.Add(&livekit.DataBlob{Key: key, Contents: contents})
got := a.Get(key)
require.NotNil(t, got)
require.Equal(t, key.String(), got.Key.String())
require.Equal(t, contents, got.Contents)
}
func TestParticipantDataBlob_AddOverwrites(t *testing.T) {
a := newTestDataBlob()
key := genericKey("blob-1")
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v1")})
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v2")})
got := a.Get(key)
require.NotNil(t, got)
require.Equal(t, []byte("v2"), got.Contents)
require.Len(t, a.GetAll(), 1)
}
func TestParticipantDataBlob_DistinctKeys(t *testing.T) {
a := newTestDataBlob()
key1 := genericKey("blob-1")
key2 := genericKey("blob-2")
a.Add(&livekit.DataBlob{Key: key1, Contents: []byte("c1")})
a.Add(&livekit.DataBlob{Key: key2, Contents: []byte("c2")})
got1 := a.Get(key1)
require.NotNil(t, got1)
require.Equal(t, []byte("c1"), got1.Contents)
got2 := a.Get(key2)
require.NotNil(t, got2)
require.Equal(t, []byte("c2"), got2.Contents)
require.Len(t, a.GetAll(), 2)
}
func TestParticipantDataBlob_Delete(t *testing.T) {
a := newTestDataBlob()
key := genericKey("blob-1")
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("definition")})
a.Delete(key)
require.Nil(t, a.Get(key))
require.Empty(t, a.GetAll())
// deleting a non-existent key is a no-op
a.Delete(key)
require.Empty(t, a.GetAll())
}
func TestParticipantDataBlob_NilKey(t *testing.T) {
a := newTestDataBlob()
// nil key should be silently ignored, not panic
a.Add(&livekit.DataBlob{Contents: []byte("definition")})
require.Empty(t, a.GetAll())
require.Nil(t, a.Get(nil))
a.Delete(nil)
require.Empty(t, a.GetAll())
}
func TestParticipantDataBlob_GetMissing(t *testing.T) {
a := newTestDataBlob()
require.Nil(t, a.Get(genericKey("missing")))
}
func TestParticipantDataBlob_GetAllContents(t *testing.T) {
a := newTestDataBlob()
key1 := genericKey("blob-1")
key2 := genericKey("blob-2")
a.Add(&livekit.DataBlob{Key: key1, Contents: []byte("def-1")})
a.Add(&livekit.DataBlob{Key: key2, Contents: []byte("def-2")})
all := a.GetAll()
require.Len(t, all, 2)
for _, db := range all {
switch key := db.Key.Key.(type) {
case *livekit.DataBlobKey_Generic:
switch key.Generic {
case "blob-1":
require.Equal(t, []byte("def-1"), db.Contents)
case "blob-2":
require.Equal(t, []byte("def-2"), db.Contents)
default:
require.Fail(t, "unexpected key", key.Generic)
}
default:
require.Fail(t, "unexpected key type", "Generic")
}
}
}
func TestParticipantDataBlob_ConcurrentAccess(t *testing.T) {
a := newTestDataBlob()
const numGoroutines = 16
const opsPerGoroutine = 100
var wg sync.WaitGroup
wg.Add(numGoroutines)
for g := 0; g < numGoroutines; g++ {
go func(g int) {
defer wg.Done()
for i := 0; i < opsPerGoroutine; i++ {
key := genericKey(fmt.Sprintf("blob-%d", g%8))
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v")})
_ = a.Get(key)
_ = a.GetAll()
if i%3 == 0 {
a.Delete(key)
}
}
}(g)
}
wg.Wait()
}
+15
View File
@@ -60,6 +60,19 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData
return
}
if !p.params.LimitConfig.CheckDataTrackFrameEncoding(req.FrameEncoding) ||
!p.params.LimitConfig.CheckDataTrackSchemaID(req.Schema) {
p.pubLogger.Warnw("invalid encoding identifier", nil, "req", logger.Proto(req))
p.sendRequestResponse(&livekit.RequestResponse{
Reason: livekit.RequestResponse_INVALID_REQUEST,
Message: "encoding identifier is empty or exceeds the maximum length",
Request: &livekit.RequestResponse_PublishDataTrack{
PublishDataTrack: utils.CloneProto(req),
},
})
return
}
publishedDataTracks := p.UpDataTrackManager.GetPublishedDataTracks()
for _, dt := range publishedDataTracks {
message := ""
@@ -95,6 +108,8 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData
Name: req.Name,
Encryption: req.Encryption,
}
dti.FrameEncoding = utils.CloneProto(req.GetFrameEncoding())
dti.Schema = utils.CloneProto(req.GetSchema())
dt := NewDataTrack(
DataTrackParams{
Logger: p.params.Logger.WithValues("trackID", dti.Sid),
+14
View File
@@ -368,3 +368,17 @@ func (p *ParticipantImpl) SendDataTrackSubscriberHandles(handles map[uint32]*liv
SubHandles: handles,
}))
}
func (p *ParticipantImpl) sendStoreDataBlobResponse(requestId uint32, key *livekit.DataBlobKey) error {
return p.signaller.WriteMessage(p.signalling.SignalStoreDataBlobResponse(&livekit.StoreDataBlobResponse{
RequestId: requestId,
Key: key,
}))
}
func (p *ParticipantImpl) sendGetDataBlobResponse(requestId uint32, dataBlob *livekit.DataBlob) error {
return p.signaller.WriteMessage(p.signalling.SignalGetDataBlobResponse(&livekit.GetDataBlobResponse{
RequestId: requestId,
Blob: dataBlob,
}))
}
+15
View File
@@ -1384,6 +1384,11 @@ func (r *Room) onUpdateDataSubscriptions(participant types.LocalParticipant, req
}
}
func (r *Room) onGetDataBlob(participant types.LocalParticipant, req *livekit.GetDataBlobRequest) {
publisher := r.GetParticipant(livekit.ParticipantIdentity(req.ParticipantIdentity))
participant.ProcessGetDataBlobRequest(req, publisher)
}
func (r *Room) onLeave(p types.LocalParticipant, reason types.ParticipantCloseReason) {
r.RemoveParticipant(p.Identity(), p.ID(), reason)
}
@@ -1779,6 +1784,7 @@ func (r *Room) launchRoomAgents(ads []*agentDispatch) {
AgentName: ad.AgentName,
DispatchId: ad.Id,
Deployment: ad.Deployment,
Attributes: ad.Attributes,
})
r.handleNewJobs(ad.AgentDispatch, inc)
done()
@@ -1803,6 +1809,7 @@ func (r *Room) launchTargetAgents(ads []*agentDispatch, p types.Participant, job
AgentName: ad.AgentName,
DispatchId: ad.Id,
Deployment: ad.Deployment,
Attributes: ad.Attributes,
})
r.handleNewJobs(ad.AgentDispatch, inc)
done()
@@ -1869,6 +1876,7 @@ func (r *Room) createAgentDispatchFromRoomDispatch(rad *livekit.RoomAgentDispatc
Room: r.protoRoom.Name,
RestartPolicy: rad.GetRestartPolicy(),
Deployment: rad.GetDeployment(),
Attributes: rad.GetAttributes(),
})
}
@@ -1993,6 +2001,13 @@ func (l *localParticipantListener) OnUpdateDataSubscriptions(p types.LocalPartic
l.room.onUpdateDataSubscriptions(p, req)
}
func (l *localParticipantListener) OnStoreDataBlob(_p types.LocalParticipant, _dataBlob *livekit.DataBlob) {
}
func (l *localParticipantListener) OnGetDataBlob(p types.LocalParticipant, req *livekit.GetDataBlobRequest) {
l.room.onGetDataBlob(p, req)
}
func (l *localParticipantListener) OnSyncState(p types.LocalParticipant, state *livekit.SyncState) error {
return l.room.onSyncState(p, state)
}
+2
View File
@@ -62,4 +62,6 @@ type ParticipantSignalling interface {
SignalPublishDataTrackResponse(publishDataTrackResponse *livekit.PublishDataTrackResponse) proto.Message
SignalUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.UnpublishDataTrackResponse) proto.Message
SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message
SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message
SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message
}
+6
View File
@@ -151,6 +151,12 @@ func (s *signalhandler) HandleMessage(msg proto.Message) error {
case *livekit.SignalRequest_UpdateDataSubscription:
s.params.Participant.HandleUpdateDataSubscription(msg.UpdateDataSubscription)
case *livekit.SignalRequest_StoreDataBlobRequest:
s.params.Participant.HandleStoreDataBlobRequest(msg.StoreDataBlobRequest)
case *livekit.SignalRequest_GetDataBlobRequest:
s.params.Participant.HandleGetDataBlobRequest(msg.GetDataBlobRequest)
}
return nil
+16
View File
@@ -258,3 +258,19 @@ func (s *signalling) SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles
},
}
}
func (s *signalling) SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message {
return &livekit.SignalResponse{
Message: &livekit.SignalResponse_StoreDataBlobResponse{
StoreDataBlobResponse: storeDataBlobResponse,
},
}
}
func (s *signalling) SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message {
return &livekit.SignalResponse{
Message: &livekit.SignalResponse_GetDataBlobResponse{
GetDataBlobResponse: getDataBlobResponse,
},
}
}
@@ -127,3 +127,11 @@ func (u *signallingUnimplemented) SignalUnpublishDataTrackResponse(unpublishData
func (u *signallingUnimplemented) SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message {
return nil
}
func (u *signallingUnimplemented) SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message {
return nil
}
func (u *signallingUnimplemented) SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message {
return nil
}
+8 -1
View File
@@ -48,6 +48,7 @@ type SubscribedTrackParams struct {
Subscriber types.LocalParticipant
MediaTrack types.MediaTrack
AdaptiveStream bool
EnableStartAtDesiredQuality bool
TelemetryListener types.ParticipantTelemetryListener
WrappedReceiver *WrappedReceiver
IsRelayed bool
@@ -154,6 +155,7 @@ func NewSubscribedTrack(params SubscribedTrackParams) (*SubscribedTrack, error)
RTCPWriter: params.Subscriber.WriteSubscriberRTCP,
DisableSenderReportPassThrough: params.Subscriber.GetDisableSenderReportPassThrough(),
SupportsCodecChange: params.Subscriber.SupportsCodecChange(),
EnableStartAtDesiredQuality: params.EnableStartAtDesiredQuality,
Listener: s,
FlexFEC: sfu.FlexFECParams{
Enabled: params.SubscriberConfig.FlexFEC.Enabled,
@@ -217,7 +219,12 @@ func (t *SubscribedTrack) Bound(err error) {
t.logger.Debugw("enabling subscriber track settings on bind", "settings", logger.Proto(t.settings))
}
} else {
if t.params.AdaptiveStream {
if t.params.EnableStartAtDesiredQuality {
// default to HIGH quality so the subscriber acquires the top layer directly instead of
// ramping up from a lower layer. adaptive stream clients can still scale down afterwards
// based on viewport.
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_HIGH}
} else if t.params.AdaptiveStream {
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_LOW}
} else {
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_HIGH}
+2
View File
@@ -728,11 +728,13 @@ func (m *SubscriptionManager) hasCapacityForSubscription(kind livekit.TrackType)
switch kind {
case livekit.TrackType_VIDEO:
if m.params.SubscriptionLimitVideo > 0 && m.subscribedVideoCount.Load() >= m.params.SubscriptionLimitVideo {
m.params.Logger.Infow("subcription limit exceeded for video", "limit", m.params.SubscriptionLimitVideo, "subscriptions", m.subscribedVideoCount.Load())
return false
}
case livekit.TrackType_AUDIO:
if m.params.SubscriptionLimitAudio > 0 && m.subscribedAudioCount.Load() >= m.params.SubscriptionLimitAudio {
m.params.Logger.Infow("subcription limit exceeded for audio", "limit", m.params.SubscriptionLimitAudio, "subscriptions", m.subscribedAudioCount.Load())
return false
}
}
+146 -68
View File
@@ -319,6 +319,7 @@ type TransportParams struct {
IsSendSide bool
AllowPlayoutDelay bool
UseOneShotSignallingMode bool
ExcludeIPv6LocalCandidates bool
FireOnTrackBySdp bool
DataChannelMaxBufferedAmount uint64
DatachannelSlowThreshold int
@@ -1007,13 +1008,13 @@ func (t *PCTransport) queueOrConfigureSender(
enableAudioNACK bool,
) {
params := configureSenderParams{
transceiver,
enabledCodecs,
rtcpFeedbackConfig,
!t.params.IsOfferer,
enableAudioStereo,
enableAudioNACK,
t.params.DirectionConfig.FlexFEC.Enabled,
transceiver: transceiver,
enabledCodecs: enabledCodecs,
rtcpFeedbackConfig: rtcpFeedbackConfig,
filterOutH264HighProfile: !t.params.IsOfferer,
enableAudioStereo: enableAudioStereo,
enableAudioNACK: enableAudioNACK,
keepFlexFEC: t.params.DirectionConfig.FlexFEC.Enabled,
}
if !t.params.IsOfferer {
t.sendersPendingConfigMu.Lock()
@@ -1022,10 +1023,17 @@ func (t *PCTransport) queueOrConfigureSender(
return
}
configureSender(params)
// Offerer: no remote offer to echo payload types from.
configureSender(params, nil)
}
func (t *PCTransport) processSendersPendingConfig() {
// processSendersPendingConfig configures the senders queued while answering the
// remote's offer (single peer connection mode). offerAudioPT (mime type -> the
// payload type the offer assigned) is parsed from the offer by the caller before
// SetRemoteDescription, so the answer echoes the offered payload types for audio
// codecs (and stays consistent with the forwarded RTP). It is nil when there is
// nothing to echo.
func (t *PCTransport) processSendersPendingConfig(offerAudioPT map[mime.MimeType]webrtc.PayloadType) {
t.sendersPendingConfigMu.Lock()
pending := t.sendersPendingConfig
t.sendersPendingConfig = nil
@@ -1038,7 +1046,7 @@ func (t *PCTransport) processSendersPendingConfig() {
continue
}
configureSender(p)
configureSender(p, offerAudioPT)
}
if len(unprocessed) != 0 {
@@ -1433,6 +1441,13 @@ func (t *PCTransport) HasEverConnected() bool {
return !t.firstConnectedAt.IsZero()
}
func (t *PCTransport) FirstConnectedAt() time.Time {
t.lock.RLock()
defer t.lock.RUnlock()
return t.firstConnectedAt
}
func (t *PCTransport) GetICEConnectionInfo() *types.ICEConnectionInfo {
return t.connectionDetails.GetInfo()
}
@@ -1708,31 +1723,26 @@ func (t *PCTransport) GetAnswer() (webrtc.SessionDescription, uint32, error) {
cld := t.pc.CurrentLocalDescription()
// add local candidates to ICE connection details
parsed, err := cld.Unmarshal()
if err == nil {
addLocalICECandidates := func(attrs []sdp.Attribute) {
for _, a := range attrs {
if a.IsICECandidate() {
c, err := ice.UnmarshalCandidate(a.Value)
if err != nil {
continue
}
t.connectionDetails.AddLocalICECandidate(c, false, false)
}
}
}
preferTCP := t.preferTCP.Load()
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local answer (unfiltered)", "sdp", cld.SDP)
}
addLocalICECandidates(parsed.Attributes)
for _, m := range parsed.MediaDescriptions {
addLocalICECandidates(m.Attributes)
}
//
// Filter after setting local description as pion expects the answer
// to match between CreateAnswer and SetLocalDescription.
// Filtered answer is sent to remote so that remote does not
// see filtered candidates.
//
filteredAnswer := t.filterCandidates(*cld, preferTCP, true)
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local answer (filtered)", "sdp", filteredAnswer.SDP)
}
answerId := t.remoteOfferId.Load()
t.localAnswerId.Store(answerId)
return *cld, answerId, nil
return filteredAnswer, answerId, nil
}
func (t *PCTransport) GetICESessionUfrag() (string, error) {
@@ -1895,13 +1905,13 @@ func (t *PCTransport) HandleICERestartSDPFragment(sdpFragment string) (string, e
t.connectionDetails.AddRemoteICECandidate(c, false, false, false)
}
ans, err := t.pc.CreateAnswer(nil)
answer, err := t.pc.CreateAnswer(nil)
if err != nil {
t.params.Logger.Warnw("could not create answer", err)
return "", err
}
if err = t.pc.SetLocalDescription(ans); err != nil {
if err = t.pc.SetLocalDescription(answer); err != nil {
t.params.Logger.Warnw("could not set local description", err)
return "", err
}
@@ -1911,31 +1921,30 @@ func (t *PCTransport) HandleICERestartSDPFragment(sdpFragment string) (string, e
cld := t.pc.CurrentLocalDescription()
preferTCP := t.preferTCP.Load()
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local answer (unfiltered)", "sdp", cld.SDP)
}
//
// Filter after setting local description as pion expects the answer
// to match between CreateAnswer and SetLocalDescription.
// Filtered answer is sent to remote so that remote does not
// see filtered candidates.
//
filteredAnswer := t.filterCandidates(*cld, preferTCP, true)
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local answer (filtered)", "sdp", filteredAnswer.SDP)
}
// add local candidates to ICE connection details
parsedAnswer, err := cld.Unmarshal()
parsedFilteredAnswer, err := filteredAnswer.Unmarshal()
if err != nil {
t.params.Logger.Warnw("could not parse local description", err)
return "", err
}
addLocalICECandidates := func(attrs []sdp.Attribute) {
for _, a := range attrs {
if a.IsICECandidate() {
c, err := ice.UnmarshalCandidate(a.Value)
if err != nil {
continue
}
t.connectionDetails.AddLocalICECandidate(c, false, false)
}
}
}
addLocalICECandidates(parsedAnswer.Attributes)
for _, m := range parsedAnswer.MediaDescriptions {
addLocalICECandidates(m.Attributes)
}
parsedFragmentAnswer, err := lksdp.ExtractSDPFragment(parsedAnswer)
parsedFragmentAnswer, err := lksdp.ExtractSDPFragment(parsedFilteredAnswer)
if err != nil {
t.params.Logger.Warnw("could not extract SDP fragment", err)
return "", err
@@ -2350,11 +2359,19 @@ func (t *PCTransport) handleICEGatheringCompleteAnswerer() error {
t.pendingRestartIceOffer = nil
t.params.Logger.Debugw("accept remote restart ice offer after ICE gathering")
// Parse the offer payload types before SetRemoteDescription so this does not
// race with pion's use of the same description.
var offerAudioPT map[mime.MimeType]webrtc.PayloadType
if parsed, err := offer.Unmarshal(); err == nil {
offerAudioPT = offerAudioPayloadTypes(parsed)
}
if err := t.setRemoteDescription(offer); err != nil {
return err
}
t.params.Handler.OnSetRemoteDescriptionOffer()
t.processSendersPendingConfig()
t.processSendersPendingConfig(offerAudioPT)
return t.createAndSendAnswer()
}
@@ -2390,9 +2407,15 @@ func (t *PCTransport) handleLocalICECandidate(e event) error {
filtered := false
if c != nil {
if t.preferTCP.Load() && c.Protocol != webrtc.ICEProtocolTCP {
t.params.Logger.Debugw("filtering out local candidate", "candidate", c.String())
t.params.Logger.Debugw("filtering out local candidate, TCP prefered", "candidate", c.String())
filtered = true
}
if !filtered && t.params.ExcludeIPv6LocalCandidates {
if IsIPv6(c.Address) {
t.params.Logger.Debugw("filtering out local candidate, IPv6 excluded", "candidate", c.String())
filtered = true
}
}
t.connectionDetails.AddLocalCandidate(c, filtered, true)
}
@@ -2457,6 +2480,10 @@ func (t *PCTransport) setNegotiationState(state transport.NegotiationState) {
}
}
func (t *PCTransport) isCandidateFilterActive(preferTCP bool) bool {
return preferTCP || t.params.ExcludeIPv6LocalCandidates
}
func (t *PCTransport) filterCandidates(sd webrtc.SessionDescription, preferTCP, isLocal bool) webrtc.SessionDescription {
parsed, err := sd.Unmarshal()
if err != nil {
@@ -2474,12 +2501,10 @@ func (t *PCTransport) filterCandidates(sd webrtc.SessionDescription, preferTCP,
filteredAttrs = append(filteredAttrs, a)
continue
}
excluded := preferTCP && !c.NetworkType().IsTCP()
if !excluded {
if !t.params.Config.UseMDNS && types.IsICECandidateMDNS(c) {
excluded = true
}
}
excluded :=
(preferTCP && !c.NetworkType().IsTCP()) ||
(t.params.ExcludeIPv6LocalCandidates && isLocal && c.NetworkType().IsIPv6()) ||
(!t.params.Config.UseMDNS && types.IsICECandidateMDNS(c))
if !excluded {
filteredAttrs = append(filteredAttrs, a)
}
@@ -2622,7 +2647,7 @@ func (t *PCTransport) createAndSendOffer(options *webrtc.OfferOptions) error {
}
preferTCP := t.preferTCP.Load()
if preferTCP {
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local offer (unfiltered)", "sdp", offer.SDP)
}
@@ -2650,7 +2675,7 @@ func (t *PCTransport) createAndSendOffer(options *webrtc.OfferOptions) error {
// see filtered candidates.
//
offer = t.filterCandidates(offer, preferTCP, true)
if preferTCP {
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local offer (filtered)", "sdp", offer.SDP)
}
@@ -2716,11 +2741,11 @@ func (t *PCTransport) isRemoteOfferRestartICE(parsed *sdp.SessionDescription) (s
func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription) error {
// filter before setting remote description so that pion does not see filtered remote candidates
preferTCP := t.preferTCP.Load()
if preferTCP {
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("remote description (unfiltered)", "type", sd.Type, "sdp", sd.SDP)
}
sd = t.filterCandidates(sd, preferTCP, false)
if preferTCP {
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("remote description (filtered)", "type", sd.Type, "sdp", sd.SDP)
}
@@ -2780,7 +2805,7 @@ func (t *PCTransport) createAndSendAnswer() error {
}
preferTCP := t.preferTCP.Load()
if preferTCP {
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local answer (unfiltered)", "sdp", answer.SDP)
}
@@ -2796,7 +2821,7 @@ func (t *PCTransport) createAndSendAnswer() error {
// see filtered candidates.
//
answer = t.filterCandidates(answer, preferTCP, true)
if preferTCP {
if t.isCandidateFilterActive(preferTCP) {
t.params.Logger.Debugw("local answer (filtered)", "sdp", answer.SDP)
}
@@ -2899,7 +2924,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o
}
t.params.Handler.OnSetRemoteDescriptionOffer()
t.processSendersPendingConfig()
t.processSendersPendingConfig(offerAudioPayloadTypes(parsed))
rtxRepairs := nonSimulcastRTXRepairsFromSDP(parsed, t.params.Logger)
if len(rtxRepairs) > 0 {
@@ -3094,7 +3119,7 @@ type configureSenderParams struct {
keepFlexFEC bool
}
func configureSender(params configureSenderParams) {
func configureSender(params configureSenderParams, offerAudioPT map[mime.MimeType]webrtc.PayloadType) {
configureSenderCodecs(
params.transceiver,
params.enabledCodecs,
@@ -3104,14 +3129,14 @@ func configureSender(params configureSenderParams) {
)
if params.transceiver.Kind() == webrtc.RTPCodecTypeAudio {
configureSenderAudio(params.transceiver, params.enableAudioStereo, params.enableAudioNACK)
configureSenderAudio(params.transceiver, params.enableAudioStereo, params.enableAudioNACK, offerAudioPT)
}
}
// configure subscriber transceiver for audio stereo and nack
// pion doesn't support per transciver codec configuration, so the nack of this session will be disabled
// forever once it is first disabled by a transceiver.
func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool) {
func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool, offerAudioPT map[mime.MimeType]webrtc.PayloadType) {
sender := tr.Sender()
if sender == nil {
return
@@ -3135,12 +3160,65 @@ func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool) {
}
}
}
// When answering a subscriber's offer (single peer connection mode), echo
// the payload type the offer assigned for this codec instead of the server's
// MediaEngine payload type. Otherwise the answer can advertise e.g. Opus on a
// PT that was never offered, which Firefox rejects (received packets decode to
// 0 samples / silence). The forwarded RTP already uses the offered PT.
if len(offerAudioPT) > 0 {
if pt, ok := offerAudioPT[mime.NormalizeMimeType(c.MimeType)]; ok {
c.PayloadType = pt
}
}
configCodecs = append(configCodecs, c)
}
tr.SetCodecPreferences(configCodecs)
}
// offerAudioPayloadTypes returns mime type -> payload type for the audio codecs
// in a remote offer, so the subscriber answer can echo the offered payload types
// (RFC 3264 6.1). The caller parses the offer before SetRemoteDescription, so this
// does not race with pion's use of the same description.
func offerAudioPayloadTypes(parsed *sdp.SessionDescription) map[mime.MimeType]webrtc.PayloadType {
if parsed == nil {
return nil
}
out := map[mime.MimeType]webrtc.PayloadType{}
for _, md := range parsed.MediaDescriptions {
if !strings.EqualFold(md.MediaName.Media, "audio") {
continue
}
for _, a := range md.Attributes {
if a.Key != "rtpmap" {
continue
}
// value e.g. "109 opus/48000/2"
fields := strings.Fields(a.Value)
if len(fields) < 2 {
continue
}
pt, err := strconv.Atoi(fields[0])
if err != nil {
continue
}
codecName := fields[1]
if i := strings.Index(codecName, "/"); i >= 0 {
codecName = codecName[:i]
}
mt := mime.NormalizeMimeTypeCodec(codecName).ToMimeType()
if mt == mime.MimeTypeUnknown {
continue
}
out[mt] = webrtc.PayloadType(pt)
}
}
if len(out) == 0 {
return nil
}
return out
}
// In single peer connection mode, set up enebled codecs for sender.
// The config provides config of direction.
// For publisher peer connection those are publish enabled codecs
+27 -1
View File
@@ -617,7 +617,7 @@ func TestConfigureAudioTransceiver(t *testing.T) {
tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
require.NoError(t, err)
configureSenderAudio(tr, testcase.stereo, testcase.nack)
configureSenderAudio(tr, testcase.stereo, testcase.nack, nil)
codecs := tr.Sender().GetParameters().Codecs
for _, codec := range codecs {
if mime.IsMimeTypeStringOpus(codec.MimeType) {
@@ -636,6 +636,32 @@ func TestConfigureAudioTransceiver(t *testing.T) {
}
}
// When answering a subscriber offer, the sender's audio payload type must echo
// the payload type the offer assigned (RFC 3264), otherwise Firefox decodes no
// audio. See https://github.com/livekit/livekit/issues/4599.
func TestConfigureAudioTransceiverEchoesOfferPayloadType(t *testing.T) {
var me webrtc.MediaEngine
registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}, false)
pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(&me)).NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer pc.Close()
tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
require.NoError(t, err)
// offer mapped Opus to a payload type different from the server MediaEngine's.
const offeredOpusPT = webrtc.PayloadType(109)
configureSenderAudio(tr, false, true, map[mime.MimeType]webrtc.PayloadType{mime.MimeTypeOpus: offeredOpusPT})
var found bool
for _, codec := range tr.Sender().GetParameters().Codecs {
if mime.IsMimeTypeStringOpus(codec.MimeType) {
require.Equal(t, offeredOpusPT, codec.PayloadType)
found = true
}
}
require.True(t, found, "opus codec must be present in sender preferences")
}
// In single-PC mode the publisher PC carries both publish and subscribe
// directions. If the MediaEngine were built only from the publish codec list,
// the SDP offer would not advertise some codecs in the m-section even though
+15
View File
@@ -98,6 +98,7 @@ type TransportManagerParams struct {
UseOneShotSignallingMode bool
FireOnTrackBySdp bool
EnableDataTracks bool
ExcludeIPv6LocalCandidates bool
}
type TransportManager struct {
@@ -168,6 +169,7 @@ func NewTransportManager(params TransportManagerParams) (*TransportManager, erro
DatachannelLossyTargetLatency: params.DatachannelLossyTargetLatency,
FireOnTrackBySdp: params.FireOnTrackBySdp,
EnableDataTracks: params.EnableDataTracks,
ExcludeIPv6LocalCandidates: params.ExcludeIPv6LocalCandidates,
})
if err != nil {
return nil, err
@@ -194,6 +196,7 @@ func NewTransportManager(params TransportManagerParams) (*TransportManager, erro
Handler: TransportManagerTransportHandler{params.SubscriberHandler, t, lgr},
FireOnTrackBySdp: params.FireOnTrackBySdp,
EnableDataTracks: params.EnableDataTracks,
ExcludeIPv6LocalCandidates: params.ExcludeIPv6LocalCandidates,
})
if err != nil {
return nil, err
@@ -227,6 +230,10 @@ func (t *TransportManager) HasPublisherEverConnected() bool {
return t.publisher.HasEverConnected()
}
func (t *TransportManager) PublisherFirstConnectedAt() time.Time {
return t.publisher.FirstConnectedAt()
}
func (t *TransportManager) IsPublisherEstablished() bool {
return t.publisher.IsEstablished()
}
@@ -267,6 +274,14 @@ func (t *TransportManager) HasSubscriberEverConnected() bool {
}
}
func (t *TransportManager) SubscriberFirstConnectedAt() time.Time {
if t.params.UseOneShotSignallingMode || t.params.UseSinglePeerConnection {
return t.publisher.FirstConnectedAt()
} else {
return t.subscriber.FirstConnectedAt()
}
}
func (t *TransportManager) AddTrackLocal(
trackLocal webrtc.TrackLocal,
params types.AddTrackParams,
+17
View File
@@ -355,6 +355,9 @@ type Participant interface {
HandleReceivedDataTrackMessage([]byte, *datatrack.Packet, int64)
GetParticipantListener() ParticipantListener
AddDataBlob(dataBlob *livekit.DataBlob)
GetDataBlob(key *livekit.DataBlobKey) *livekit.DataBlob
}
// -------------------------------------------------------
@@ -403,6 +406,7 @@ type LocalParticipant interface {
GetReporter() roomobs.ParticipantSessionReporter
GetReporterResolver() roomobs.ParticipantReporterResolver
GetAdaptiveStream() bool
GetEnableStartAtDesiredQuality() bool
ProtocolVersion() ProtocolVersion
SupportsSyncStreamID() bool
SupportsTransceiverReuse(mt MediaTrack) bool
@@ -410,6 +414,7 @@ type LocalParticipant interface {
IsReady() bool
ActiveAt() time.Time
Disconnected() <-chan struct{}
IsConnectionCanceled(closeReason ParticipantCloseReason) bool
IsIdle() bool
SubscriberAsPrimary() bool
GetClientInfo() *livekit.ClientInfo
@@ -522,6 +527,7 @@ type LocalParticipant interface {
dataTracks []*livekit.PublishDataTrackResponse,
)
IsReconnect() bool
IsMigration() bool
MoveToRoom(params MoveToRoomParams)
UpdateMediaRTT(rtt uint32)
@@ -561,6 +567,9 @@ type LocalParticipant interface {
HandlePublishDataTrackRequest(*livekit.PublishDataTrackRequest)
HandleUnpublishDataTrackRequest(*livekit.UnpublishDataTrackRequest)
HandleUpdateDataSubscription(*livekit.UpdateDataSubscription)
HandleStoreDataBlobRequest(*livekit.StoreDataBlobRequest)
HandleGetDataBlobRequest(*livekit.GetDataBlobRequest)
ProcessGetDataBlobRequest(*livekit.GetDataBlobRequest, Participant)
HandleSignalMessage(msg proto.Message) error
@@ -571,6 +580,8 @@ type LocalParticipant interface {
ClearParticipantListener()
GetNextSubscribedDataTrackHandle() uint16
GetAllDataBlob() []*livekit.DataBlob
}
// ---------------------------------------------
@@ -620,6 +631,8 @@ type LocalParticipantListener interface {
)
OnUpdateSubscriptionPermission(LocalParticipant, *livekit.SubscriptionPermission) error
OnUpdateDataSubscriptions(LocalParticipant, *livekit.UpdateDataSubscription)
OnStoreDataBlob(LocalParticipant, *livekit.DataBlob)
OnGetDataBlob(LocalParticipant, *livekit.GetDataBlobRequest)
OnSyncState(LocalParticipant, *livekit.SyncState) error
OnSimulateScenario(LocalParticipant, *livekit.SimulateScenario) error
OnLeave(LocalParticipant, ParticipantCloseReason)
@@ -651,6 +664,10 @@ func (*NullLocalParticipantListener) OnUpdateSubscriptionPermission(LocalPartici
}
func (*NullLocalParticipantListener) OnUpdateDataSubscriptions(LocalParticipant, *livekit.UpdateDataSubscription) {
}
func (*NullLocalParticipantListener) OnStoreDataBlob(LocalParticipant, *livekit.DataBlob) {
}
func (*NullLocalParticipantListener) OnGetDataBlob(LocalParticipant, *livekit.GetDataBlobRequest) {
}
func (*NullLocalParticipantListener) OnSyncState(LocalParticipant, *livekit.SyncState) error {
return nil
}
@@ -33,6 +33,11 @@ type FakeLocalParticipant struct {
activeAtReturnsOnCall map[int]struct {
result1 time.Time
}
AddDataBlobStub func(*livekit.DataBlob)
addDataBlobMutex sync.RWMutex
addDataBlobArgsForCall []struct {
arg1 *livekit.DataBlob
}
AddOnCloseStub func(string, func(types.LocalParticipant))
addOnCloseMutex sync.RWMutex
addOnCloseArgsForCall []struct {
@@ -216,6 +221,16 @@ type FakeLocalParticipant struct {
getAdaptiveStreamReturnsOnCall map[int]struct {
result1 bool
}
GetAllDataBlobStub func() []*livekit.DataBlob
getAllDataBlobMutex sync.RWMutex
getAllDataBlobArgsForCall []struct {
}
getAllDataBlobReturns struct {
result1 []*livekit.DataBlob
}
getAllDataBlobReturnsOnCall map[int]struct {
result1 []*livekit.DataBlob
}
GetAnswerStub func() (webrtc.SessionDescription, uint32, error)
getAnswerMutex sync.RWMutex
getAnswerArgsForCall []struct {
@@ -305,6 +320,17 @@ type FakeLocalParticipant struct {
getCountryReturnsOnCall map[int]struct {
result1 string
}
GetDataBlobStub func(*livekit.DataBlobKey) *livekit.DataBlob
getDataBlobMutex sync.RWMutex
getDataBlobArgsForCall []struct {
arg1 *livekit.DataBlobKey
}
getDataBlobReturns struct {
result1 *livekit.DataBlob
}
getDataBlobReturnsOnCall map[int]struct {
result1 *livekit.DataBlob
}
GetDataTrackTransportStub func() types.DataTrackTransport
getDataTrackTransportMutex sync.RWMutex
getDataTrackTransportArgsForCall []struct {
@@ -325,6 +351,16 @@ type FakeLocalParticipant struct {
getDisableSenderReportPassThroughReturnsOnCall map[int]struct {
result1 bool
}
GetEnableStartAtDesiredQualityStub func() bool
getEnableStartAtDesiredQualityMutex sync.RWMutex
getEnableStartAtDesiredQualityArgsForCall []struct {
}
getEnableStartAtDesiredQualityReturns struct {
result1 bool
}
getEnableStartAtDesiredQualityReturnsOnCall map[int]struct {
result1 bool
}
GetEnabledPublishCodecsStub func() []*livekit.Codec
getEnabledPublishCodecsMutex sync.RWMutex
getEnabledPublishCodecsArgsForCall []struct {
@@ -566,6 +602,11 @@ type FakeLocalParticipant struct {
handleAnswerArgsForCall []struct {
arg1 *livekit.SessionDescription
}
HandleGetDataBlobRequestStub func(*livekit.GetDataBlobRequest)
handleGetDataBlobRequestMutex sync.RWMutex
handleGetDataBlobRequestArgsForCall []struct {
arg1 *livekit.GetDataBlobRequest
}
HandleICERestartSDPFragmentStub func(string) (string, error)
handleICERestartSDPFragmentMutex sync.RWMutex
handleICERestartSDPFragmentArgsForCall []struct {
@@ -679,6 +720,11 @@ type FakeLocalParticipant struct {
handleSimulateScenarioReturnsOnCall map[int]struct {
result1 error
}
HandleStoreDataBlobRequestStub func(*livekit.StoreDataBlobRequest)
handleStoreDataBlobRequestMutex sync.RWMutex
handleStoreDataBlobRequestArgsForCall []struct {
arg1 *livekit.StoreDataBlobRequest
}
HandleSyncStateStub func(*livekit.SyncState) error
handleSyncStateMutex sync.RWMutex
handleSyncStateArgsForCall []struct {
@@ -795,6 +841,17 @@ type FakeLocalParticipant struct {
isClosedReturnsOnCall map[int]struct {
result1 bool
}
IsConnectionCanceledStub func(types.ParticipantCloseReason) bool
isConnectionCanceledMutex sync.RWMutex
isConnectionCanceledArgsForCall []struct {
arg1 types.ParticipantCloseReason
}
isConnectionCanceledReturns struct {
result1 bool
}
isConnectionCanceledReturnsOnCall map[int]struct {
result1 bool
}
IsDependentStub func() bool
isDependentMutex sync.RWMutex
isDependentArgsForCall []struct {
@@ -825,6 +882,16 @@ type FakeLocalParticipant struct {
isIdleReturnsOnCall map[int]struct {
result1 bool
}
IsMigrationStub func() bool
isMigrationMutex sync.RWMutex
isMigrationArgsForCall []struct {
}
isMigrationReturns struct {
result1 bool
}
isMigrationReturnsOnCall map[int]struct {
result1 bool
}
IsPublisherStub func() bool
isPublisherMutex sync.RWMutex
isPublisherArgsForCall []struct {
@@ -976,6 +1043,12 @@ type FakeLocalParticipant struct {
arg2 chan string
arg3 chan error
}
ProcessGetDataBlobRequestStub func(*livekit.GetDataBlobRequest, types.Participant)
processGetDataBlobRequestMutex sync.RWMutex
processGetDataBlobRequestArgsForCall []struct {
arg1 *livekit.GetDataBlobRequest
arg2 types.Participant
}
ProtocolVersionStub func() types.ProtocolVersion
protocolVersionMutex sync.RWMutex
protocolVersionArgsForCall []struct {
@@ -1584,6 +1657,38 @@ func (fake *FakeLocalParticipant) ActiveAtReturnsOnCall(i int, result1 time.Time
}{result1}
}
func (fake *FakeLocalParticipant) AddDataBlob(arg1 *livekit.DataBlob) {
fake.addDataBlobMutex.Lock()
fake.addDataBlobArgsForCall = append(fake.addDataBlobArgsForCall, struct {
arg1 *livekit.DataBlob
}{arg1})
stub := fake.AddDataBlobStub
fake.recordInvocation("AddDataBlob", []interface{}{arg1})
fake.addDataBlobMutex.Unlock()
if stub != nil {
fake.AddDataBlobStub(arg1)
}
}
func (fake *FakeLocalParticipant) AddDataBlobCallCount() int {
fake.addDataBlobMutex.RLock()
defer fake.addDataBlobMutex.RUnlock()
return len(fake.addDataBlobArgsForCall)
}
func (fake *FakeLocalParticipant) AddDataBlobCalls(stub func(*livekit.DataBlob)) {
fake.addDataBlobMutex.Lock()
defer fake.addDataBlobMutex.Unlock()
fake.AddDataBlobStub = stub
}
func (fake *FakeLocalParticipant) AddDataBlobArgsForCall(i int) *livekit.DataBlob {
fake.addDataBlobMutex.RLock()
defer fake.addDataBlobMutex.RUnlock()
argsForCall := fake.addDataBlobArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeLocalParticipant) AddOnClose(arg1 string, arg2 func(types.LocalParticipant)) {
fake.addOnCloseMutex.Lock()
fake.addOnCloseArgsForCall = append(fake.addOnCloseArgsForCall, struct {
@@ -2529,6 +2634,59 @@ func (fake *FakeLocalParticipant) GetAdaptiveStreamReturnsOnCall(i int, result1
}{result1}
}
func (fake *FakeLocalParticipant) GetAllDataBlob() []*livekit.DataBlob {
fake.getAllDataBlobMutex.Lock()
ret, specificReturn := fake.getAllDataBlobReturnsOnCall[len(fake.getAllDataBlobArgsForCall)]
fake.getAllDataBlobArgsForCall = append(fake.getAllDataBlobArgsForCall, struct {
}{})
stub := fake.GetAllDataBlobStub
fakeReturns := fake.getAllDataBlobReturns
fake.recordInvocation("GetAllDataBlob", []interface{}{})
fake.getAllDataBlobMutex.Unlock()
if stub != nil {
return stub()
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeLocalParticipant) GetAllDataBlobCallCount() int {
fake.getAllDataBlobMutex.RLock()
defer fake.getAllDataBlobMutex.RUnlock()
return len(fake.getAllDataBlobArgsForCall)
}
func (fake *FakeLocalParticipant) GetAllDataBlobCalls(stub func() []*livekit.DataBlob) {
fake.getAllDataBlobMutex.Lock()
defer fake.getAllDataBlobMutex.Unlock()
fake.GetAllDataBlobStub = stub
}
func (fake *FakeLocalParticipant) GetAllDataBlobReturns(result1 []*livekit.DataBlob) {
fake.getAllDataBlobMutex.Lock()
defer fake.getAllDataBlobMutex.Unlock()
fake.GetAllDataBlobStub = nil
fake.getAllDataBlobReturns = struct {
result1 []*livekit.DataBlob
}{result1}
}
func (fake *FakeLocalParticipant) GetAllDataBlobReturnsOnCall(i int, result1 []*livekit.DataBlob) {
fake.getAllDataBlobMutex.Lock()
defer fake.getAllDataBlobMutex.Unlock()
fake.GetAllDataBlobStub = nil
if fake.getAllDataBlobReturnsOnCall == nil {
fake.getAllDataBlobReturnsOnCall = make(map[int]struct {
result1 []*livekit.DataBlob
})
}
fake.getAllDataBlobReturnsOnCall[i] = struct {
result1 []*livekit.DataBlob
}{result1}
}
func (fake *FakeLocalParticipant) GetAnswer() (webrtc.SessionDescription, uint32, error) {
fake.getAnswerMutex.Lock()
ret, specificReturn := fake.getAnswerReturnsOnCall[len(fake.getAnswerArgsForCall)]
@@ -2973,6 +3131,67 @@ func (fake *FakeLocalParticipant) GetCountryReturnsOnCall(i int, result1 string)
}{result1}
}
func (fake *FakeLocalParticipant) GetDataBlob(arg1 *livekit.DataBlobKey) *livekit.DataBlob {
fake.getDataBlobMutex.Lock()
ret, specificReturn := fake.getDataBlobReturnsOnCall[len(fake.getDataBlobArgsForCall)]
fake.getDataBlobArgsForCall = append(fake.getDataBlobArgsForCall, struct {
arg1 *livekit.DataBlobKey
}{arg1})
stub := fake.GetDataBlobStub
fakeReturns := fake.getDataBlobReturns
fake.recordInvocation("GetDataBlob", []interface{}{arg1})
fake.getDataBlobMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeLocalParticipant) GetDataBlobCallCount() int {
fake.getDataBlobMutex.RLock()
defer fake.getDataBlobMutex.RUnlock()
return len(fake.getDataBlobArgsForCall)
}
func (fake *FakeLocalParticipant) GetDataBlobCalls(stub func(*livekit.DataBlobKey) *livekit.DataBlob) {
fake.getDataBlobMutex.Lock()
defer fake.getDataBlobMutex.Unlock()
fake.GetDataBlobStub = stub
}
func (fake *FakeLocalParticipant) GetDataBlobArgsForCall(i int) *livekit.DataBlobKey {
fake.getDataBlobMutex.RLock()
defer fake.getDataBlobMutex.RUnlock()
argsForCall := fake.getDataBlobArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeLocalParticipant) GetDataBlobReturns(result1 *livekit.DataBlob) {
fake.getDataBlobMutex.Lock()
defer fake.getDataBlobMutex.Unlock()
fake.GetDataBlobStub = nil
fake.getDataBlobReturns = struct {
result1 *livekit.DataBlob
}{result1}
}
func (fake *FakeLocalParticipant) GetDataBlobReturnsOnCall(i int, result1 *livekit.DataBlob) {
fake.getDataBlobMutex.Lock()
defer fake.getDataBlobMutex.Unlock()
fake.GetDataBlobStub = nil
if fake.getDataBlobReturnsOnCall == nil {
fake.getDataBlobReturnsOnCall = make(map[int]struct {
result1 *livekit.DataBlob
})
}
fake.getDataBlobReturnsOnCall[i] = struct {
result1 *livekit.DataBlob
}{result1}
}
func (fake *FakeLocalParticipant) GetDataTrackTransport() types.DataTrackTransport {
fake.getDataTrackTransportMutex.Lock()
ret, specificReturn := fake.getDataTrackTransportReturnsOnCall[len(fake.getDataTrackTransportArgsForCall)]
@@ -3079,6 +3298,59 @@ func (fake *FakeLocalParticipant) GetDisableSenderReportPassThroughReturnsOnCall
}{result1}
}
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQuality() bool {
fake.getEnableStartAtDesiredQualityMutex.Lock()
ret, specificReturn := fake.getEnableStartAtDesiredQualityReturnsOnCall[len(fake.getEnableStartAtDesiredQualityArgsForCall)]
fake.getEnableStartAtDesiredQualityArgsForCall = append(fake.getEnableStartAtDesiredQualityArgsForCall, struct {
}{})
stub := fake.GetEnableStartAtDesiredQualityStub
fakeReturns := fake.getEnableStartAtDesiredQualityReturns
fake.recordInvocation("GetEnableStartAtDesiredQuality", []interface{}{})
fake.getEnableStartAtDesiredQualityMutex.Unlock()
if stub != nil {
return stub()
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityCallCount() int {
fake.getEnableStartAtDesiredQualityMutex.RLock()
defer fake.getEnableStartAtDesiredQualityMutex.RUnlock()
return len(fake.getEnableStartAtDesiredQualityArgsForCall)
}
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityCalls(stub func() bool) {
fake.getEnableStartAtDesiredQualityMutex.Lock()
defer fake.getEnableStartAtDesiredQualityMutex.Unlock()
fake.GetEnableStartAtDesiredQualityStub = stub
}
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityReturns(result1 bool) {
fake.getEnableStartAtDesiredQualityMutex.Lock()
defer fake.getEnableStartAtDesiredQualityMutex.Unlock()
fake.GetEnableStartAtDesiredQualityStub = nil
fake.getEnableStartAtDesiredQualityReturns = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityReturnsOnCall(i int, result1 bool) {
fake.getEnableStartAtDesiredQualityMutex.Lock()
defer fake.getEnableStartAtDesiredQualityMutex.Unlock()
fake.GetEnableStartAtDesiredQualityStub = nil
if fake.getEnableStartAtDesiredQualityReturnsOnCall == nil {
fake.getEnableStartAtDesiredQualityReturnsOnCall = make(map[int]struct {
result1 bool
})
}
fake.getEnableStartAtDesiredQualityReturnsOnCall[i] = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) GetEnabledPublishCodecs() []*livekit.Codec {
fake.getEnabledPublishCodecsMutex.Lock()
ret, specificReturn := fake.getEnabledPublishCodecsReturnsOnCall[len(fake.getEnabledPublishCodecsArgsForCall)]
@@ -4365,6 +4637,38 @@ func (fake *FakeLocalParticipant) HandleAnswerArgsForCall(i int) *livekit.Sessio
return argsForCall.arg1
}
func (fake *FakeLocalParticipant) HandleGetDataBlobRequest(arg1 *livekit.GetDataBlobRequest) {
fake.handleGetDataBlobRequestMutex.Lock()
fake.handleGetDataBlobRequestArgsForCall = append(fake.handleGetDataBlobRequestArgsForCall, struct {
arg1 *livekit.GetDataBlobRequest
}{arg1})
stub := fake.HandleGetDataBlobRequestStub
fake.recordInvocation("HandleGetDataBlobRequest", []interface{}{arg1})
fake.handleGetDataBlobRequestMutex.Unlock()
if stub != nil {
fake.HandleGetDataBlobRequestStub(arg1)
}
}
func (fake *FakeLocalParticipant) HandleGetDataBlobRequestCallCount() int {
fake.handleGetDataBlobRequestMutex.RLock()
defer fake.handleGetDataBlobRequestMutex.RUnlock()
return len(fake.handleGetDataBlobRequestArgsForCall)
}
func (fake *FakeLocalParticipant) HandleGetDataBlobRequestCalls(stub func(*livekit.GetDataBlobRequest)) {
fake.handleGetDataBlobRequestMutex.Lock()
defer fake.handleGetDataBlobRequestMutex.Unlock()
fake.HandleGetDataBlobRequestStub = stub
}
func (fake *FakeLocalParticipant) HandleGetDataBlobRequestArgsForCall(i int) *livekit.GetDataBlobRequest {
fake.handleGetDataBlobRequestMutex.RLock()
defer fake.handleGetDataBlobRequestMutex.RUnlock()
argsForCall := fake.handleGetDataBlobRequestArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeLocalParticipant) HandleICERestartSDPFragment(arg1 string) (string, error) {
fake.handleICERestartSDPFragmentMutex.Lock()
ret, specificReturn := fake.handleICERestartSDPFragmentReturnsOnCall[len(fake.handleICERestartSDPFragmentArgsForCall)]
@@ -4989,6 +5293,38 @@ func (fake *FakeLocalParticipant) HandleSimulateScenarioReturnsOnCall(i int, res
}{result1}
}
func (fake *FakeLocalParticipant) HandleStoreDataBlobRequest(arg1 *livekit.StoreDataBlobRequest) {
fake.handleStoreDataBlobRequestMutex.Lock()
fake.handleStoreDataBlobRequestArgsForCall = append(fake.handleStoreDataBlobRequestArgsForCall, struct {
arg1 *livekit.StoreDataBlobRequest
}{arg1})
stub := fake.HandleStoreDataBlobRequestStub
fake.recordInvocation("HandleStoreDataBlobRequest", []interface{}{arg1})
fake.handleStoreDataBlobRequestMutex.Unlock()
if stub != nil {
fake.HandleStoreDataBlobRequestStub(arg1)
}
}
func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestCallCount() int {
fake.handleStoreDataBlobRequestMutex.RLock()
defer fake.handleStoreDataBlobRequestMutex.RUnlock()
return len(fake.handleStoreDataBlobRequestArgsForCall)
}
func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestCalls(stub func(*livekit.StoreDataBlobRequest)) {
fake.handleStoreDataBlobRequestMutex.Lock()
defer fake.handleStoreDataBlobRequestMutex.Unlock()
fake.HandleStoreDataBlobRequestStub = stub
}
func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestArgsForCall(i int) *livekit.StoreDataBlobRequest {
fake.handleStoreDataBlobRequestMutex.RLock()
defer fake.handleStoreDataBlobRequestMutex.RUnlock()
argsForCall := fake.handleStoreDataBlobRequestArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeLocalParticipant) HandleSyncState(arg1 *livekit.SyncState) error {
fake.handleSyncStateMutex.Lock()
ret, specificReturn := fake.handleSyncStateReturnsOnCall[len(fake.handleSyncStateArgsForCall)]
@@ -5631,6 +5967,67 @@ func (fake *FakeLocalParticipant) IsClosedReturnsOnCall(i int, result1 bool) {
}{result1}
}
func (fake *FakeLocalParticipant) IsConnectionCanceled(arg1 types.ParticipantCloseReason) bool {
fake.isConnectionCanceledMutex.Lock()
ret, specificReturn := fake.isConnectionCanceledReturnsOnCall[len(fake.isConnectionCanceledArgsForCall)]
fake.isConnectionCanceledArgsForCall = append(fake.isConnectionCanceledArgsForCall, struct {
arg1 types.ParticipantCloseReason
}{arg1})
stub := fake.IsConnectionCanceledStub
fakeReturns := fake.isConnectionCanceledReturns
fake.recordInvocation("IsConnectionCanceled", []interface{}{arg1})
fake.isConnectionCanceledMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeLocalParticipant) IsConnectionCanceledCallCount() int {
fake.isConnectionCanceledMutex.RLock()
defer fake.isConnectionCanceledMutex.RUnlock()
return len(fake.isConnectionCanceledArgsForCall)
}
func (fake *FakeLocalParticipant) IsConnectionCanceledCalls(stub func(types.ParticipantCloseReason) bool) {
fake.isConnectionCanceledMutex.Lock()
defer fake.isConnectionCanceledMutex.Unlock()
fake.IsConnectionCanceledStub = stub
}
func (fake *FakeLocalParticipant) IsConnectionCanceledArgsForCall(i int) types.ParticipantCloseReason {
fake.isConnectionCanceledMutex.RLock()
defer fake.isConnectionCanceledMutex.RUnlock()
argsForCall := fake.isConnectionCanceledArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeLocalParticipant) IsConnectionCanceledReturns(result1 bool) {
fake.isConnectionCanceledMutex.Lock()
defer fake.isConnectionCanceledMutex.Unlock()
fake.IsConnectionCanceledStub = nil
fake.isConnectionCanceledReturns = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) IsConnectionCanceledReturnsOnCall(i int, result1 bool) {
fake.isConnectionCanceledMutex.Lock()
defer fake.isConnectionCanceledMutex.Unlock()
fake.IsConnectionCanceledStub = nil
if fake.isConnectionCanceledReturnsOnCall == nil {
fake.isConnectionCanceledReturnsOnCall = make(map[int]struct {
result1 bool
})
}
fake.isConnectionCanceledReturnsOnCall[i] = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) IsDependent() bool {
fake.isDependentMutex.Lock()
ret, specificReturn := fake.isDependentReturnsOnCall[len(fake.isDependentArgsForCall)]
@@ -5790,6 +6187,59 @@ func (fake *FakeLocalParticipant) IsIdleReturnsOnCall(i int, result1 bool) {
}{result1}
}
func (fake *FakeLocalParticipant) IsMigration() bool {
fake.isMigrationMutex.Lock()
ret, specificReturn := fake.isMigrationReturnsOnCall[len(fake.isMigrationArgsForCall)]
fake.isMigrationArgsForCall = append(fake.isMigrationArgsForCall, struct {
}{})
stub := fake.IsMigrationStub
fakeReturns := fake.isMigrationReturns
fake.recordInvocation("IsMigration", []interface{}{})
fake.isMigrationMutex.Unlock()
if stub != nil {
return stub()
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeLocalParticipant) IsMigrationCallCount() int {
fake.isMigrationMutex.RLock()
defer fake.isMigrationMutex.RUnlock()
return len(fake.isMigrationArgsForCall)
}
func (fake *FakeLocalParticipant) IsMigrationCalls(stub func() bool) {
fake.isMigrationMutex.Lock()
defer fake.isMigrationMutex.Unlock()
fake.IsMigrationStub = stub
}
func (fake *FakeLocalParticipant) IsMigrationReturns(result1 bool) {
fake.isMigrationMutex.Lock()
defer fake.isMigrationMutex.Unlock()
fake.IsMigrationStub = nil
fake.isMigrationReturns = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) IsMigrationReturnsOnCall(i int, result1 bool) {
fake.isMigrationMutex.Lock()
defer fake.isMigrationMutex.Unlock()
fake.IsMigrationStub = nil
if fake.isMigrationReturnsOnCall == nil {
fake.isMigrationReturnsOnCall = make(map[int]struct {
result1 bool
})
}
fake.isMigrationReturnsOnCall[i] = struct {
result1 bool
}{result1}
}
func (fake *FakeLocalParticipant) IsPublisher() bool {
fake.isPublisherMutex.Lock()
ret, specificReturn := fake.isPublisherReturnsOnCall[len(fake.isPublisherArgsForCall)]
@@ -6617,6 +7067,39 @@ func (fake *FakeLocalParticipant) PerformRpcArgsForCall(i int) (*livekit.Perform
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
}
func (fake *FakeLocalParticipant) ProcessGetDataBlobRequest(arg1 *livekit.GetDataBlobRequest, arg2 types.Participant) {
fake.processGetDataBlobRequestMutex.Lock()
fake.processGetDataBlobRequestArgsForCall = append(fake.processGetDataBlobRequestArgsForCall, struct {
arg1 *livekit.GetDataBlobRequest
arg2 types.Participant
}{arg1, arg2})
stub := fake.ProcessGetDataBlobRequestStub
fake.recordInvocation("ProcessGetDataBlobRequest", []interface{}{arg1, arg2})
fake.processGetDataBlobRequestMutex.Unlock()
if stub != nil {
fake.ProcessGetDataBlobRequestStub(arg1, arg2)
}
}
func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestCallCount() int {
fake.processGetDataBlobRequestMutex.RLock()
defer fake.processGetDataBlobRequestMutex.RUnlock()
return len(fake.processGetDataBlobRequestArgsForCall)
}
func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestCalls(stub func(*livekit.GetDataBlobRequest, types.Participant)) {
fake.processGetDataBlobRequestMutex.Lock()
defer fake.processGetDataBlobRequestMutex.Unlock()
fake.ProcessGetDataBlobRequestStub = stub
}
func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestArgsForCall(i int) (*livekit.GetDataBlobRequest, types.Participant) {
fake.processGetDataBlobRequestMutex.RLock()
defer fake.processGetDataBlobRequestMutex.RUnlock()
argsForCall := fake.processGetDataBlobRequestArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeLocalParticipant) ProtocolVersion() types.ProtocolVersion {
fake.protocolVersionMutex.Lock()
ret, specificReturn := fake.protocolVersionReturnsOnCall[len(fake.protocolVersionArgsForCall)]
@@ -42,6 +42,12 @@ type FakeLocalParticipantListener struct {
arg1 types.Participant
arg2 types.DataTrack
}
OnGetDataBlobStub func(types.LocalParticipant, *livekit.GetDataBlobRequest)
onGetDataBlobMutex sync.RWMutex
onGetDataBlobArgsForCall []struct {
arg1 types.LocalParticipant
arg2 *livekit.GetDataBlobRequest
}
OnLeaveStub func(types.LocalParticipant, types.ParticipantCloseReason)
onLeaveMutex sync.RWMutex
onLeaveArgsForCall []struct {
@@ -82,6 +88,12 @@ type FakeLocalParticipantListener struct {
onStateChangeArgsForCall []struct {
arg1 types.LocalParticipant
}
OnStoreDataBlobStub func(types.LocalParticipant, *livekit.DataBlob)
onStoreDataBlobMutex sync.RWMutex
onStoreDataBlobArgsForCall []struct {
arg1 types.LocalParticipant
arg2 *livekit.DataBlob
}
OnSubscribeStatusChangedStub func(types.LocalParticipant, livekit.ParticipantID, bool)
onSubscribeStatusChangedMutex sync.RWMutex
onSubscribeStatusChangedArgsForCall []struct {
@@ -331,6 +343,39 @@ func (fake *FakeLocalParticipantListener) OnDataTrackUnpublishedArgsForCall(i in
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeLocalParticipantListener) OnGetDataBlob(arg1 types.LocalParticipant, arg2 *livekit.GetDataBlobRequest) {
fake.onGetDataBlobMutex.Lock()
fake.onGetDataBlobArgsForCall = append(fake.onGetDataBlobArgsForCall, struct {
arg1 types.LocalParticipant
arg2 *livekit.GetDataBlobRequest
}{arg1, arg2})
stub := fake.OnGetDataBlobStub
fake.recordInvocation("OnGetDataBlob", []interface{}{arg1, arg2})
fake.onGetDataBlobMutex.Unlock()
if stub != nil {
fake.OnGetDataBlobStub(arg1, arg2)
}
}
func (fake *FakeLocalParticipantListener) OnGetDataBlobCallCount() int {
fake.onGetDataBlobMutex.RLock()
defer fake.onGetDataBlobMutex.RUnlock()
return len(fake.onGetDataBlobArgsForCall)
}
func (fake *FakeLocalParticipantListener) OnGetDataBlobCalls(stub func(types.LocalParticipant, *livekit.GetDataBlobRequest)) {
fake.onGetDataBlobMutex.Lock()
defer fake.onGetDataBlobMutex.Unlock()
fake.OnGetDataBlobStub = stub
}
func (fake *FakeLocalParticipantListener) OnGetDataBlobArgsForCall(i int) (types.LocalParticipant, *livekit.GetDataBlobRequest) {
fake.onGetDataBlobMutex.RLock()
defer fake.onGetDataBlobMutex.RUnlock()
argsForCall := fake.onGetDataBlobArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeLocalParticipantListener) OnLeave(arg1 types.LocalParticipant, arg2 types.ParticipantCloseReason) {
fake.onLeaveMutex.Lock()
fake.onLeaveArgsForCall = append(fake.onLeaveArgsForCall, struct {
@@ -556,6 +601,39 @@ func (fake *FakeLocalParticipantListener) OnStateChangeArgsForCall(i int) types.
return argsForCall.arg1
}
func (fake *FakeLocalParticipantListener) OnStoreDataBlob(arg1 types.LocalParticipant, arg2 *livekit.DataBlob) {
fake.onStoreDataBlobMutex.Lock()
fake.onStoreDataBlobArgsForCall = append(fake.onStoreDataBlobArgsForCall, struct {
arg1 types.LocalParticipant
arg2 *livekit.DataBlob
}{arg1, arg2})
stub := fake.OnStoreDataBlobStub
fake.recordInvocation("OnStoreDataBlob", []interface{}{arg1, arg2})
fake.onStoreDataBlobMutex.Unlock()
if stub != nil {
fake.OnStoreDataBlobStub(arg1, arg2)
}
}
func (fake *FakeLocalParticipantListener) OnStoreDataBlobCallCount() int {
fake.onStoreDataBlobMutex.RLock()
defer fake.onStoreDataBlobMutex.RUnlock()
return len(fake.onStoreDataBlobArgsForCall)
}
func (fake *FakeLocalParticipantListener) OnStoreDataBlobCalls(stub func(types.LocalParticipant, *livekit.DataBlob)) {
fake.onStoreDataBlobMutex.Lock()
defer fake.onStoreDataBlobMutex.Unlock()
fake.OnStoreDataBlobStub = stub
}
func (fake *FakeLocalParticipantListener) OnStoreDataBlobArgsForCall(i int) (types.LocalParticipant, *livekit.DataBlob) {
fake.onStoreDataBlobMutex.RLock()
defer fake.onStoreDataBlobMutex.RUnlock()
argsForCall := fake.onStoreDataBlobArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeLocalParticipantListener) OnSubscribeStatusChanged(arg1 types.LocalParticipant, arg2 livekit.ParticipantID, arg3 bool) {
fake.onSubscribeStatusChangedMutex.Lock()
fake.onSubscribeStatusChangedArgsForCall = append(fake.onSubscribeStatusChangedArgsForCall, struct {
@@ -13,6 +13,11 @@ import (
)
type FakeParticipant struct {
AddDataBlobStub func(*livekit.DataBlob)
addDataBlobMutex sync.RWMutex
addDataBlobArgsForCall []struct {
arg1 *livekit.DataBlob
}
CanSkipBroadcastStub func() bool
canSkipBroadcastMutex sync.RWMutex
canSkipBroadcastArgsForCall []struct {
@@ -78,6 +83,17 @@ type FakeParticipant struct {
result1 float64
result2 bool
}
GetDataBlobStub func(*livekit.DataBlobKey) *livekit.DataBlob
getDataBlobMutex sync.RWMutex
getDataBlobArgsForCall []struct {
arg1 *livekit.DataBlobKey
}
getDataBlobReturns struct {
result1 *livekit.DataBlob
}
getDataBlobReturnsOnCall map[int]struct {
result1 *livekit.DataBlob
}
GetLoggerStub func() logger.Logger
getLoggerMutex sync.RWMutex
getLoggerArgsForCall []struct {
@@ -361,6 +377,38 @@ type FakeParticipant struct {
invocationsMutex sync.RWMutex
}
func (fake *FakeParticipant) AddDataBlob(arg1 *livekit.DataBlob) {
fake.addDataBlobMutex.Lock()
fake.addDataBlobArgsForCall = append(fake.addDataBlobArgsForCall, struct {
arg1 *livekit.DataBlob
}{arg1})
stub := fake.AddDataBlobStub
fake.recordInvocation("AddDataBlob", []interface{}{arg1})
fake.addDataBlobMutex.Unlock()
if stub != nil {
fake.AddDataBlobStub(arg1)
}
}
func (fake *FakeParticipant) AddDataBlobCallCount() int {
fake.addDataBlobMutex.RLock()
defer fake.addDataBlobMutex.RUnlock()
return len(fake.addDataBlobArgsForCall)
}
func (fake *FakeParticipant) AddDataBlobCalls(stub func(*livekit.DataBlob)) {
fake.addDataBlobMutex.Lock()
defer fake.addDataBlobMutex.Unlock()
fake.AddDataBlobStub = stub
}
func (fake *FakeParticipant) AddDataBlobArgsForCall(i int) *livekit.DataBlob {
fake.addDataBlobMutex.RLock()
defer fake.addDataBlobMutex.RUnlock()
argsForCall := fake.addDataBlobArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeParticipant) CanSkipBroadcast() bool {
fake.canSkipBroadcastMutex.Lock()
ret, specificReturn := fake.canSkipBroadcastReturnsOnCall[len(fake.canSkipBroadcastArgsForCall)]
@@ -692,6 +740,67 @@ func (fake *FakeParticipant) GetAudioLevelReturnsOnCall(i int, result1 float64,
}{result1, result2}
}
func (fake *FakeParticipant) GetDataBlob(arg1 *livekit.DataBlobKey) *livekit.DataBlob {
fake.getDataBlobMutex.Lock()
ret, specificReturn := fake.getDataBlobReturnsOnCall[len(fake.getDataBlobArgsForCall)]
fake.getDataBlobArgsForCall = append(fake.getDataBlobArgsForCall, struct {
arg1 *livekit.DataBlobKey
}{arg1})
stub := fake.GetDataBlobStub
fakeReturns := fake.getDataBlobReturns
fake.recordInvocation("GetDataBlob", []interface{}{arg1})
fake.getDataBlobMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeParticipant) GetDataBlobCallCount() int {
fake.getDataBlobMutex.RLock()
defer fake.getDataBlobMutex.RUnlock()
return len(fake.getDataBlobArgsForCall)
}
func (fake *FakeParticipant) GetDataBlobCalls(stub func(*livekit.DataBlobKey) *livekit.DataBlob) {
fake.getDataBlobMutex.Lock()
defer fake.getDataBlobMutex.Unlock()
fake.GetDataBlobStub = stub
}
func (fake *FakeParticipant) GetDataBlobArgsForCall(i int) *livekit.DataBlobKey {
fake.getDataBlobMutex.RLock()
defer fake.getDataBlobMutex.RUnlock()
argsForCall := fake.getDataBlobArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeParticipant) GetDataBlobReturns(result1 *livekit.DataBlob) {
fake.getDataBlobMutex.Lock()
defer fake.getDataBlobMutex.Unlock()
fake.GetDataBlobStub = nil
fake.getDataBlobReturns = struct {
result1 *livekit.DataBlob
}{result1}
}
func (fake *FakeParticipant) GetDataBlobReturnsOnCall(i int, result1 *livekit.DataBlob) {
fake.getDataBlobMutex.Lock()
defer fake.getDataBlobMutex.Unlock()
fake.GetDataBlobStub = nil
if fake.getDataBlobReturnsOnCall == nil {
fake.getDataBlobReturnsOnCall = make(map[int]struct {
result1 *livekit.DataBlob
})
}
fake.getDataBlobReturnsOnCall[i] = struct {
result1 *livekit.DataBlob
}{result1}
}
func (fake *FakeParticipant) GetLogger() logger.Logger {
fake.getLoggerMutex.Lock()
ret, specificReturn := fake.getLoggerReturnsOnCall[len(fake.getLoggerArgsForCall)]
+5
View File
@@ -173,6 +173,11 @@ func MaybeTruncateIP(addr string) string {
return addr[:len(addr)-3] + "..."
}
func IsIPv6(addr string) bool {
ipAddr := net.ParseIP(addr)
return ipAddr != nil && ipAddr.To4() == nil
}
func ChunkProtoBatch[T proto.Message](batch []T, target int) [][]T {
var chunks [][]T
var start, size int
+12
View File
@@ -18,6 +18,7 @@ import (
"context"
"fmt"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/protocol/agent"
"github.com/livekit/protocol/livekit"
@@ -29,6 +30,7 @@ import (
)
type AgentDispatchService struct {
limitConf config.LimitConfig
agentDispatchClient rpc.TypedAgentDispatchInternalClient
topicFormatter rpc.TopicFormatter
roomAllocator RoomAllocator
@@ -36,12 +38,14 @@ type AgentDispatchService struct {
}
func NewAgentDispatchService(
limitConf config.LimitConfig,
agentDispatchClient rpc.TypedAgentDispatchInternalClient,
topicFormatter rpc.TopicFormatter,
roomAllocator RoomAllocator,
router routing.MessageRouter,
) *AgentDispatchService {
return &AgentDispatchService{
limitConf: limitConf,
agentDispatchClient: agentDispatchClient,
topicFormatter: topicFormatter,
roomAllocator: roomAllocator,
@@ -60,6 +64,13 @@ func (ag *AgentDispatchService) CreateDispatch(ctx context.Context, req *livekit
return nil, psrpc.NewError(psrpc.InvalidArgument, err)
}
if !ag.limitConf.CheckMetadataSize(req.Metadata) {
return nil, ErrMetadataExceedsLimits
}
if !ag.limitConf.CheckAttributesSize(req.Attributes) {
return nil, ErrAttributeExceedsLimits
}
if ag.roomAllocator.AutoCreateEnabled(ctx) {
err := ag.roomAllocator.SelectRoomNode(ctx, livekit.RoomName(req.Room), "")
if err != nil {
@@ -79,6 +90,7 @@ func (ag *AgentDispatchService) CreateDispatch(ctx context.Context, req *livekit
Metadata: req.Metadata,
RestartPolicy: req.RestartPolicy,
Deployment: req.Deployment,
Attributes: req.Attributes,
}
return ag.agentDispatchClient.CreateDispatch(ctx, ag.topicFormatter.RoomTopic(ctx, livekit.RoomName(req.Room)), dispatch)
}
+20 -10
View File
@@ -485,19 +485,29 @@ func (h *AgentHandler) CheckEnabled(ctx context.Context, req *rpc.CheckEnabledRe
}, nil
}
func (h *AgentHandler) DrainConnections(interval time.Duration) {
// jitter drain start
time.Sleep(time.Duration(rand.Int63n(int64(interval))))
func (h *AgentHandler) DrainConnections(interval time.Duration, force bool) {
if !force {
// jitter drain start
time.Sleep(time.Duration(rand.Int63n(int64(interval))))
t := time.NewTicker(interval)
defer t.Stop()
t := time.NewTicker(interval)
defer t.Stop()
h.mu.Lock()
defer h.mu.Unlock()
h.mu.Lock()
defer h.mu.Unlock()
for _, w := range h.workers {
w.Close()
<-t.C
for _, w := range h.workers {
w.Close()
<-t.C
}
} else {
// drain as quickly as possible when forced
h.mu.Lock()
defer h.mu.Unlock()
for _, w := range h.workers {
w.Close()
}
}
}
+19
View File
@@ -293,9 +293,21 @@ func (r *RoomManager) StartSession(
) error {
sessionStartTime := time.Now()
if pi.Identity != "" && pi.Grants != nil {
if !r.config.Limit.CheckMetadataSize(pi.Grants.Metadata) {
return ErrMetadataExceedsLimits
}
if !r.config.Limit.CheckAttributesSize(pi.Grants.Attributes) {
return ErrAttributeExceedsLimits
}
}
createRoom := pi.CreateRoom
room, err := r.getOrCreateRoom(ctx, createRoom)
if err != nil {
if pi.Identity != "" {
prometheus.IncrementParticipantRtcCanceled(1)
}
return err
}
defer room.Release()
@@ -371,9 +383,11 @@ func (r *RoomManager) StartSession(
pi.ReconnectReason,
); err != nil {
participant.GetLogger().Warnw("could not resume participant", err)
prometheus.IncrementParticipantRtcCanceled(1)
return err
}
r.telemetry.ParticipantResumed(ctx, room.ToProto(), participant.ToProto(), r.currentNode.NodeID(), pi.ReconnectReason)
prometheus.IncrementParticipantRtcActive(1)
go room.HandleSyncState(participant, pi.SyncState)
@@ -524,9 +538,11 @@ func (r *RoomManager) StartSession(
FireOnTrackBySdp: true,
UseSinglePeerConnection: pi.UseSinglePeerConnection,
EnableDataTracks: r.config.EnableDataTracks,
EnableParticipantDataBlob: r.config.EnableParticipantDataBlob,
EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection,
})
if err != nil {
prometheus.IncrementParticipantRtcCanceled(1)
return err
}
iceConfig := r.setIceConfig(room.Name(), participant)
@@ -542,6 +558,7 @@ func (r *RoomManager) StartSession(
if err = room.Join(participant, requestSource, &opts, iceServers); err != nil {
pLogger.Errorw("could not join room", err)
_ = participant.Close(true, types.ParticipantCloseReasonJoinFailed, false)
prometheus.IncrementParticipantRtcCanceled(1)
return err
}
@@ -553,6 +570,7 @@ func (r *RoomManager) StartSession(
participantServerClosers.Close()
pLogger.Errorw("could not join register participant topic", err)
_ = participant.Close(true, types.ParticipantCloseReasonMessageBusFailed, false)
prometheus.IncrementParticipantRtcCanceled(1)
return err
}
@@ -563,6 +581,7 @@ func (r *RoomManager) StartSession(
participantServerClosers.Close()
pLogger.Errorw("could not join register participant topic for rtc rest participant server", err)
_ = participant.Close(true, types.ParticipantCloseReasonMessageBusFailed, false)
prometheus.IncrementParticipantRtcCanceled(1)
return err
}
}
+58 -33
View File
@@ -12,6 +12,7 @@ import (
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/rtc/types"
"github.com/livekit/livekit-server/pkg/sfu/rtpstats"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
@@ -19,9 +20,8 @@ import (
"github.com/livekit/psrpc"
)
const (
whipSessionNotifyInterval = 10 * time.Second
)
// whipSessionNotifyInterval is a var (rather than a const) so tests can shorten it.
var whipSessionNotifyInterval = 10 * time.Second
type whipService struct {
*RoomManager
@@ -180,7 +180,7 @@ func (s whipService) notifySession(ctx context.Context, participant types.Partic
case <-ticker.C:
err := s.sendConnectionNotify(ctx, participant)
if err != nil {
if errors.Is(err, context.Canceled) {
if errors.Is(err, context.Canceled) || errors.Is(err, ErrParticipantNotFound) {
return nil
}
}
@@ -192,6 +192,10 @@ func (s whipService) notifySession(ctx context.Context, participant types.Partic
}
func (s whipService) sendConnectionNotify(ctx context.Context, participant types.Participant) error {
if participant.IsClosed() {
return ErrParticipantNotFound
}
video, audio := getMediaStateForParticipant(participant)
_, err := s.ingressRpcCli.WHIPRTCConnectionNotify(ctx, string(participant.ID()), &rpc.WHIPRTCConnectionNotifyRequest{
@@ -204,52 +208,73 @@ func (s whipService) sendConnectionNotify(ctx context.Context, participant types
}
func getMediaStateForParticipant(participant types.Participant) (*livekit.InputVideoState, *livekit.InputAudioState) {
pParticipant := participant.ToProto()
var video *livekit.InputVideoState
var audio *livekit.InputAudioState
for _, v := range pParticipant.Tracks {
if v == nil {
for _, t := range participant.GetPublishedTracks() {
if t == nil {
continue
}
if v.Type != livekit.TrackType_VIDEO {
ti := t.ToProto()
if ti == nil {
continue
}
video = &livekit.InputVideoState{}
switch t.Kind() {
case livekit.TrackType_VIDEO:
if video != nil {
continue
}
video.MimeType = v.MimeType
video.Height = v.Height
video.Width = v.Width
video = &livekit.InputVideoState{
MimeType: ti.MimeType,
Width: ti.Width,
Height: ti.Height,
AverageBitrate: trackAverageBitrate(t),
}
break
}
case livekit.TrackType_AUDIO:
if audio != nil {
continue
}
for _, a := range pParticipant.Tracks {
if a == nil {
continue
channels := uint32(1)
if ti.Stereo {
channels = 2
}
audio = &livekit.InputAudioState{
MimeType: ti.MimeType,
Channels: channels,
AverageBitrate: trackAverageBitrate(t),
}
}
if a.Type != livekit.TrackType_AUDIO {
continue
}
audio = &livekit.InputAudioState{}
audio.MimeType = a.MimeType
audio.Channels = 1
if a.Stereo {
audio.Channels = 2
}
break
}
return video, audio
}
func trackAverageBitrate(t types.MediaTrack) uint32 {
var allStats []*livekit.RTPStats
for _, r := range t.Receivers() {
if r == nil {
continue
}
if s := r.GetTrackStats(); s != nil {
allStats = append(allStats, s)
}
}
agg := rtpstats.AggregateRTPStats(allStats)
if agg == nil {
return 0
}
return uint32(agg.Bitrate)
}
// -------------------------------------------
type whipParticipantService struct {
@@ -323,12 +348,12 @@ func (r whipParticipantService) DeleteSession(ctx context.Context, req *rpc.WHIP
lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId))
if lp != nil {
lp.AddOnClose(types.ParticipantCloseKeyWHIP, nil)
room.RemoveParticipant(
lp.Identity(),
lp.ID(),
types.ParticipantCloseReasonClientRequestLeave,
)
lp.AddOnClose(types.ParticipantCloseKeyWHIP, nil)
}
return &emptypb.Empty{}, nil
+125
View File
@@ -0,0 +1,125 @@
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/rpc"
"github.com/livekit/psrpc"
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
)
// fakeIngressHandlerClient records WHIPRTCConnectionNotify calls. It embeds the
// interface so only the method under test needs to be implemented; any other
// call would panic (and we assert none happen).
type fakeIngressHandlerClient struct {
rpc.IngressHandlerClient
notifyCount atomic.Int32
}
func (f *fakeIngressHandlerClient) WHIPRTCConnectionNotify(
_ context.Context,
_ string,
_ *rpc.WHIPRTCConnectionNotifyRequest,
_ ...psrpc.RequestOption,
) (*emptypb.Empty, error) {
f.notifyCount.Inc()
return &emptypb.Empty{}, nil
}
// TestWhipNotifySessionStopsWhenParticipantLeaves verifies the notifier loop
// terminates once the WHIP participant leaves the room (i.e. IsClosed becomes
// true), and stops issuing further connection notifications.
func TestWhipNotifySessionStopsWhenParticipantLeaves(t *testing.T) {
origInterval := whipSessionNotifyInterval
whipSessionNotifyInterval = 5 * time.Millisecond
t.Cleanup(func() { whipSessionNotifyInterval = origInterval })
var closed atomic.Bool
participant := &typesfakes.FakeParticipant{}
participant.IsClosedStub = func() bool { return closed.Load() }
participant.IDReturns(livekit.ParticipantID("PA_test"))
participant.ToProtoReturns(&livekit.ParticipantInfo{})
cli := &fakeIngressHandlerClient{}
s := whipService{ingressRpcCli: cli}
done := make(chan error, 1)
go func() {
done <- s.notifySession(context.Background(), participant)
}()
// while the participant is connected the loop should keep notifying
require.Eventually(t, func() bool {
return cli.notifyCount.Load() > 0
}, time.Second, time.Millisecond, "expected notifications while participant is connected")
// the participant leaves the room
closed.Store(true)
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("notifySession did not stop after the participant left the room")
}
// no further notifications should be attempted after it stops
countAtStop := cli.notifyCount.Load()
time.Sleep(50 * time.Millisecond)
require.Equal(t, countAtStop, cli.notifyCount.Load(), "should not notify after the participant left")
}
// TestWhipNotifySessionStopsOnContextCancel verifies the loop exits when the
// aliveCtx (cancelled from the participant's OnClose callback) is done.
func TestWhipNotifySessionStopsOnContextCancel(t *testing.T) {
origInterval := whipSessionNotifyInterval
whipSessionNotifyInterval = 5 * time.Millisecond
t.Cleanup(func() { whipSessionNotifyInterval = origInterval })
participant := &typesfakes.FakeParticipant{}
participant.IsClosedReturns(false)
participant.IDReturns(livekit.ParticipantID("PA_test"))
participant.ToProtoReturns(&livekit.ParticipantInfo{})
cli := &fakeIngressHandlerClient{}
s := whipService{ingressRpcCli: cli}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- s.notifySession(ctx, participant)
}()
cancel()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("notifySession did not stop after context was cancelled")
}
}
// TestWhipSendConnectionNotifySkipsClosedParticipant verifies the guard that
// short-circuits the RPC (and drives loop termination) for a closed participant.
func TestWhipSendConnectionNotifySkipsClosedParticipant(t *testing.T) {
participant := &typesfakes.FakeParticipant{}
participant.IsClosedReturns(true)
participant.IDReturns(livekit.ParticipantID("PA_test"))
participant.ToProtoReturns(&livekit.ParticipantInfo{})
cli := &fakeIngressHandlerClient{}
s := whipService{ingressRpcCli: cli}
err := s.sendConnectionNotify(context.Background(), participant)
require.ErrorIs(t, err, ErrParticipantNotFound)
require.Zero(t, cli.notifyCount.Load(), "should not issue an RPC for a closed participant")
}
+15 -3
View File
@@ -84,6 +84,19 @@ func (s *RoomService) CreateRoom(ctx context.Context, req *livekit.CreateRoomReq
return nil, fmt.Errorf("%w: max length %d", ErrRoomNameExceedsLimits, s.limitConf.MaxRoomNameLength)
}
if !s.limitConf.CheckMetadataSize(req.Metadata) {
return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxMetadataSize)))
}
for _, ad := range req.Agents {
if !s.limitConf.CheckMetadataSize(ad.Metadata) {
return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxMetadataSize)))
}
if !s.limitConf.CheckAttributesSize(ad.Attributes) {
return nil, twirp.InvalidArgumentError(ErrAttributeExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxAttributesSize)))
}
}
err := s.roomAllocator.SelectRoomNode(ctx, livekit.RoomName(req.Name), livekit.NodeID(req.NodeId))
if err != nil {
return nil, err
@@ -320,9 +333,8 @@ func (s *RoomService) UpdateRoomMetadata(ctx context.Context, req *livekit.Updat
RecordRequest(ctx, req)
AppendLogFields(ctx, "room", req.Room, "size", len(req.Metadata))
maxMetadataSize := int(s.limitConf.MaxMetadataSize)
if maxMetadataSize > 0 && len(req.Metadata) > maxMetadataSize {
return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(maxMetadataSize))
if !s.limitConf.CheckMetadataSize(req.Metadata) {
return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxMetadataSize)))
}
if err := EnsureAdminPermission(ctx, livekit.RoomName(req.Room)); err != nil {
+95 -24
View File
@@ -47,43 +47,72 @@ func TestDeleteRoom(t *testing.T) {
}
func TestMetaDataLimits(t *testing.T) {
t.Run("metadata exceed limits", func(t *testing.T) {
adminCtx := func() context.Context {
return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{}}, "")
}
createCtx := func() context.Context {
return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{RoomCreate: true}}, "")
}
requireInvalidArg := func(t *testing.T, err error) {
t.Helper()
terr, ok := err.(twirp.Error)
require.True(t, ok, "expected twirp error, got %T (%v)", err, err)
require.Equal(t, twirp.InvalidArgument, terr.Code())
}
t.Run("metadata exceeds limit", func(t *testing.T) {
svc := newTestRoomService(config.LimitConfig{MaxMetadataSize: 5})
grant := &auth.ClaimGrants{
Video: &auth.VideoGrant{},
}
ctx := service.WithGrants(context.Background(), grant, "")
_, err := svc.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{
_, err := svc.UpdateParticipant(adminCtx(), &livekit.UpdateParticipantRequest{
Room: "testroom",
Identity: "123",
Metadata: "abcdefg",
})
terr, ok := err.(twirp.Error)
require.True(t, ok)
require.Equal(t, twirp.InvalidArgument, terr.Code())
requireInvalidArg(t, err)
_, err = svc.UpdateRoomMetadata(ctx, &livekit.UpdateRoomMetadataRequest{
_, err = svc.UpdateRoomMetadata(adminCtx(), &livekit.UpdateRoomMetadataRequest{
Room: "testroom",
Metadata: "abcdefg",
})
terr, ok = err.(twirp.Error)
require.True(t, ok)
require.Equal(t, twirp.InvalidArgument, terr.Code())
requireInvalidArg(t, err)
_, err = svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{
Name: "testroom",
Metadata: "abcdefg",
})
requireInvalidArg(t, err)
_, err = svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{
Name: "testroom",
Agents: []*livekit.RoomAgentDispatch{
{AgentName: "bot", Metadata: "abcdefg"},
},
})
requireInvalidArg(t, err)
})
t.Run("embedded agent dispatch in CreateRoom exceeds attributes limit", func(t *testing.T) {
svc := newTestRoomService(config.LimitConfig{MaxAttributesSize: 5})
_, err := svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{
Name: "testroom",
Agents: []*livekit.RoomAgentDispatch{
{AgentName: "bot", Attributes: map[string]string{"key": "abcdefg"}},
},
})
requireInvalidArg(t, err)
})
notExceedsLimitsSvc := map[string]*TestRoomService{
"metadata exceeds limits": newTestRoomService(config.LimitConfig{MaxMetadataSize: 5}),
"metadata no limits": newTestRoomService(config.LimitConfig{}), // no limits
"metadata exceeds limits": newTestRoomService(config.LimitConfig{
MaxMetadataSize: 5,
MaxAttributesSize: 5,
}),
"metadata no limits": newTestRoomService(config.LimitConfig{}),
}
for n, s := range notExceedsLimitsSvc {
svc := s
for n, svc := range notExceedsLimitsSvc {
t.Run(n, func(t *testing.T) {
grant := &auth.ClaimGrants{
Video: &auth.VideoGrant{},
}
ctx := service.WithGrants(context.Background(), grant, "")
_, err := svc.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{
_, err := svc.UpdateParticipant(adminCtx(), &livekit.UpdateParticipantRequest{
Room: "testroom",
Identity: "123",
Metadata: "abc",
@@ -92,18 +121,60 @@ func TestMetaDataLimits(t *testing.T) {
require.True(t, ok)
require.NotEqual(t, twirp.InvalidArgument, terr.Code())
_, err = svc.UpdateRoomMetadata(ctx, &livekit.UpdateRoomMetadataRequest{
_, err = svc.UpdateRoomMetadata(adminCtx(), &livekit.UpdateRoomMetadataRequest{
Room: "testroom",
Metadata: "abc",
})
terr, ok = err.(twirp.Error)
require.True(t, ok)
require.NotEqual(t, twirp.InvalidArgument, terr.Code())
})
_, err = svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{
Name: "testroom",
Metadata: "abc",
Agents: []*livekit.RoomAgentDispatch{
{AgentName: "bot", Metadata: "abc", Attributes: map[string]string{"k": "v"}},
},
})
if err != nil {
terr, ok = err.(twirp.Error)
require.True(t, ok)
require.NotEqual(t, twirp.InvalidArgument, terr.Code())
}
})
}
}
func TestAgentDispatchMetadataLimits(t *testing.T) {
ctx := service.WithGrants(context.Background(), &auth.ClaimGrants{
Video: &auth.VideoGrant{Room: "testroom", RoomAdmin: true},
}, "")
t.Run("metadata exceeds limits", func(t *testing.T) {
svc := newTestAgentDispatchService(config.LimitConfig{MaxMetadataSize: 5})
_, err := svc.CreateDispatch(ctx, &livekit.CreateAgentDispatchRequest{
Room: "testroom",
Metadata: "abcdefg",
})
require.ErrorIs(t, err, service.ErrMetadataExceedsLimits)
})
t.Run("attributes exceeds limits", func(t *testing.T) {
svc := newTestAgentDispatchService(config.LimitConfig{MaxAttributesSize: 5})
_, err := svc.CreateDispatch(ctx, &livekit.CreateAgentDispatchRequest{
Room: "testroom",
Attributes: map[string]string{"key": "abcdefg"},
})
require.ErrorIs(t, err, service.ErrAttributeExceedsLimits)
})
}
func newTestAgentDispatchService(limitConf config.LimitConfig) *service.AgentDispatchService {
allocator := &servicefakes.FakeRoomAllocator{}
allocator.AutoCreateEnabledReturns(false)
return service.NewAgentDispatchService(limitConf, nil, rpc.NewTopicFormatter(), allocator, &routingfakes.FakeRouter{})
}
func newTestRoomService(limitConf config.LimitConfig) *TestRoomService {
router := &routingfakes.FakeRouter{}
allocator := &servicefakes.FakeRoomAllocator{}
+21 -11
View File
@@ -401,9 +401,6 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
return
}
prometheus.IncrementParticipantJoin(1)
joinDuration = time.Since(startedAt)
pLogger = pLogger.WithValues("connID", cr.ConnectionID)
if !pi.Reconnect && initialResponse.GetJoin() != nil {
joinRoomID := livekit.RoomID(initialResponse.GetJoin().GetRoom().GetSid())
@@ -445,6 +442,7 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
// upgrade only once the basics are good to go
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
prometheus.IncrementParticipantJoinUpgradeFail(1)
resolveLogger(true)
HandleError(w, r, http.StatusInternalServerError, err, getLoggerFields()...)
return
@@ -465,12 +463,17 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
pLogger.Debugw("sending initial response", "response", logger.Proto(initialResponse))
count, err := sigConn.WriteResponse(initialResponse)
if err != nil {
prometheus.IncrementParticipantJoinWriteInitialResponseFail(1)
resolveLogger(true)
pLogger.Warnw("could not write initial response", err)
return
}
signalStats.AddBytes(uint64(count), true)
prometheus.IncrementParticipantJoin(1)
joinDuration = time.Since(startedAt)
prometheus.RecordSessionJoinLatency(int(pi.Client.GetProtocol()), joinDuration)
pLogger.Debugw(
"new client WS connected",
"reconnect", pi.Reconnect,
@@ -625,20 +628,27 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ
}
}
func (s *RTCService) DrainConnections(interval time.Duration) {
func (s *RTCService) DrainConnections(interval time.Duration, force bool) {
s.mu.Lock()
conns := maps.Clone(s.connections)
s.mu.Unlock()
// jitter drain start
time.Sleep(time.Duration(rand.Int63n(int64(interval))))
if !force {
// jitter drain start
time.Sleep(time.Duration(rand.Int63n(int64(interval))))
t := time.NewTicker(interval)
defer t.Stop()
t := time.NewTicker(interval)
defer t.Stop()
for c := range conns {
_ = c.Close()
<-t.C
for c := range conns {
_ = c.Close()
<-t.C
}
} else {
// drain as quickly as possible when forced
for c := range conns {
_ = c.Close()
}
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live
if err != nil {
return nil, err
}
agentDispatchService := NewAgentDispatchService(agentDispatchInternalClient, topicFormatter, roomAllocator, router)
agentDispatchService := NewAgentDispatchService(limitConfig, agentDispatchInternalClient, topicFormatter, roomAllocator, router)
egressService := NewEgressService(egressClient, rtcEgressLauncher, ioInfoService, roomService)
ingressConfig := getIngressConfig(conf)
ingressClient, err := rpc.NewIngressClient(clientParams)
+11 -16
View File
@@ -751,25 +751,20 @@ func (b *BufferBase) HandleIncomingPacketLocked(
b.processAudioSsrcLevelHeaderExtension(rtpPacket, arrivalTime)
if len(skippedSeqs) > 0 {
skippedRtpPkt := rtp.Packet{
Header: rtpPacket.Header,
}
skippedRtpPkt.Marker = false
// Use the current highest timestamp to prevent the case of old sequence number and newer timestamp.
// It is possible that the skipped packet is older. An example sequence
// - Packet 10, skipped 6, 7, 9 -> Packet 8 is unknown at this point
// - Packet 11, skipped 8 -> this would cause sequence number be older, but using timestamp from Packet 11 will make time stamp diff +ve
skippedRtpPkt.Timestamp = b.rtpStats.HighestTimestamp()
// - Packet 11, skipped 8 -> this would cause sequence number to be older, but using timestamp from Packet 11 will make time stamp diff +ve
ts := b.rtpStats.HighestTimestamp()
for _, sn := range skippedSeqs {
skippedRtpPkt.SequenceNumber = sn
flowState := b.rtpStats.Update(
arrivalTime,
skippedRtpPkt.Header.SequenceNumber,
skippedRtpPkt.Header.Timestamp,
skippedRtpPkt.Header.Marker,
skippedRtpPkt.Header.MarshalSize(),
len(skippedRtpPkt.Payload),
int(skippedRtpPkt.PaddingSize),
sn,
ts,
false, // no marker
0, // no header for skipped packet, so 0 size
0, // no payload
0, // no padding
)
if flowState.UnhandledReason == rtpstats.RTPFlowUnhandledReasonNone && !flowState.IsOutOfOrder {
if err := b.snRangeMap.ExcludeRange(flowState.ExtSequenceNumber, flowState.ExtSequenceNumber+1); err != nil {
@@ -799,7 +794,7 @@ func (b *BufferBase) HandleIncomingPacketLocked(
rtpPacket.Header.Marker,
rtpPacket.Header.MarshalSize(),
len(rtpPacket.Payload),
int(rtpPacket.PaddingSize),
int(rtpPacket.Header.PaddingSize),
)
switch flowState.UnhandledReason {
case rtpstats.RTPFlowUnhandledReasonNone:
@@ -817,7 +812,7 @@ func (b *BufferBase) HandleIncomingPacketLocked(
rtpPacket.Header.Marker,
rtpPacket.Header.MarshalSize(),
len(rtpPacket.Payload),
int(rtpPacket.PaddingSize),
int(rtpPacket.Header.PaddingSize),
)
default:
return 0, fmt.Errorf("unhandled reason: %s", flowState.UnhandledReason.String())
@@ -875,7 +870,7 @@ func (b *BufferBase) HandleIncomingPacketLocked(
"timestamp", rtpPacket.Timestamp,
"extTimestamp", flowState.ExtTimestamp,
"payloadSize", len(rtpPacket.Payload),
"paddingSize", rtpPacket.PaddingSize,
"paddingSize", rtpPacket.Header.PaddingSize,
"rtpStats", b.rtpStats,
"rtpStatsLite", b.rtpStatsLite,
"snRangeMap", b.snRangeMap,
+5 -1
View File
@@ -482,7 +482,11 @@ func (q *qualityScorer) updateAtLocked(stat *windowStat, at time.Time) {
ulgr.Debugw("quality rise")
default:
packets := stat.packets + stat.packetsPadding
if packets != 0 && ((stat.packetsLost-stat.packetsMissing-stat.packetsOutOfOrder)*100/packets) > 10 {
lost := stat.packetsLost - stat.packetsMissing - stat.packetsOutOfOrder
if int32(lost) < 0 {
lost = 0
}
if packets != 0 && lost*100/packets > 10 {
ulgr.Debugw("quality hold - high loss")
}
}
@@ -2,10 +2,11 @@ package datachannel
import (
"context"
"sync/atomic"
"testing"
"time"
"go.uber.org/atomic"
"github.com/pion/datachannel"
"github.com/pion/transport/v4/deadline"
"github.com/stretchr/testify/require"
+11
View File
@@ -324,6 +324,7 @@ type DownTrackParams struct {
DisableSenderReportPassThrough bool
SupportsCodecChange bool
StripPacketTrailer bool
EnableStartAtDesiredQuality bool
Listener DownTrackListener
FlexFEC FlexFECParams
}
@@ -480,6 +481,7 @@ func NewDownTrack(params DownTrackParams) (*DownTrack, error) {
d.params.Logger,
false, // skipReferenceTS
false, // disableOpportunisticAllocation
d.params.EnableStartAtDesiredQuality,
d.rtpStats,
)
@@ -1053,6 +1055,15 @@ func (d *DownTrack) keyFrameRequester() {
d.Receiver().SendPLI(layer, false)
d.rtpStats.UpdateLayerLockPliAndTime(1)
}
// if the initial-acquisition grace expired without latching the requested layer, force a
// re-allocation so the target falls back to the highest layer actually seen (rather than
// stalling while waiting for a requested layer that never showed up)
if d.forwarder.MaybeExpireAcquireGrace() {
if sal := d.getStreamAllocatorListener(); sal != nil {
sal.OnAvailableLayersChanged(d)
}
}
}
}
+65 -2
View File
@@ -52,6 +52,15 @@ const (
ResumeBehindHighThresholdSeconds = float64(2.0) // 2 seconds
LayerSwitchBehindThresholdSeconds = float64(0.05) // 50ms
SwitchAheadThresholdSeconds = float64(0.025) // 25ms
// While a subscriber is acquiring its first layer and the requested (max) layer has not been
// seen on the wire yet, aim straight for the requested layer for this long instead of latching
// onto a lower layer that is detected first. Avoids a visible low -> high quality ramp,
// notably when the subscriber joined before the publisher started (so layers are detected, and
// `maxSeen` climbs, gradually). If the requested layer does not show up within this window the
// grace expires and forwarding falls back to the highest layer actually seen.
// See Forwarder.opportunisticAlloc / withinAcquireGraceLocked / MaybeExpireAcquireGrace.
initialLayerAcquisitionGrace = time.Second
)
var (
@@ -222,6 +231,7 @@ type Forwarder struct {
logger logger.Logger
skipReferenceTS bool
disableOpportunisticAllocation bool
enableStartAtDesiredQuality bool
rtpStats *rtpstats.RTPStatsSender
muted bool
@@ -230,6 +240,7 @@ type Forwarder struct {
started bool
preStartTime time.Time
acquireDeadline int64 // mono nanos; initial-acquisition grace deadline, 0 = inactive
extFirstTS uint64
lastSSRC uint32
lastReferencePayloadType int8
@@ -256,6 +267,7 @@ func NewForwarder(
logger logger.Logger,
skipReferenceTS bool,
disableOpportunisticAllocation bool,
enableStartAtDesiredQuality bool,
rtpStats *rtpstats.RTPStatsSender,
) *Forwarder {
f := &Forwarder{
@@ -264,6 +276,7 @@ func NewForwarder(
logger: logger,
skipReferenceTS: skipReferenceTS,
disableOpportunisticAllocation: disableOpportunisticAllocation,
enableStartAtDesiredQuality: enableStartAtDesiredQuality,
rtpStats: rtpStats,
referenceLayerSpatial: buffer.InvalidLayerSpatial,
lastAllocation: VideoAllocationDefault,
@@ -276,6 +289,7 @@ func NewForwarder(
if f.kind == webrtc.RTPCodecTypeVideo {
f.vls.SetMaxTemporal(buffer.DefaultMaxLayerTemporal)
}
f.vls.SetEnableStartAtDesiredQuality(enableStartAtDesiredQuality)
return f
}
@@ -289,10 +303,38 @@ func (f *Forwarder) SetMaxPublishedLayer(maxPublishedLayer int32) bool {
}
f.vls.SetMaxSeenSpatial(maxPublishedLayer)
if f.enableStartAtDesiredQuality && !f.vls.GetCurrent().IsValid() {
// A (higher) layer just became available while nothing is being forwarded yet.
// (Re)start the initial-acquisition grace so the target aims for the requested layer
// instead of ramping up gradually as more layers are detected. See opportunisticAlloc.
f.acquireDeadline = mono.UnixNano() + initialLayerAcquisitionGrace.Nanoseconds()
}
f.logger.Debugw("setting max published layer", "layer", maxPublishedLayer)
return true
}
// withinAcquireGraceLocked reports whether the initial-acquisition grace window is still open.
func (f *Forwarder) withinAcquireGraceLocked() bool {
return f.acquireDeadline != 0 && mono.UnixNano() < f.acquireDeadline
}
// MaybeExpireAcquireGrace returns true once when the initial-acquisition grace has expired while
// the forwarder is still not streaming any layer. The caller should trigger a re-allocation so the
// target falls back from the requested layer to the highest layer actually seen, avoiding a stall
// if the requested layer never shows up. The deadline is cleared so it fires at most once.
func (f *Forwarder) MaybeExpireAcquireGrace() bool {
f.lock.Lock()
defer f.lock.Unlock()
if f.acquireDeadline == 0 || mono.UnixNano() < f.acquireDeadline {
return false
}
f.acquireDeadline = 0
f.enableStartAtDesiredQuality = false
f.vls.SetEnableStartAtDesiredQuality(false)
return !f.vls.GetCurrent().IsValid()
}
func (f *Forwarder) SetMaxTemporalLayerSeen(maxTemporalLayerSeen int32) bool {
f.lock.Lock()
defer f.lock.Unlock()
@@ -832,6 +874,17 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow
return maxTemporal
}
// inAcquireGrace reports that we are acquiring the first layer, the requested layer has not
// been seen on the wire yet, and the grace window is still open. While true, aim straight for
// the requested layer instead of the highest seen so far, so acquisition does not ramp up
// gradually as layers are detected (`maxSeen` climbs). See initialLayerAcquisitionGrace.
inAcquireGrace := func(maxSpatial int32) bool {
return !currentLayer.IsValid() && maxSeenLayer.Spatial < maxSpatial && f.withinAcquireGraceLocked()
}
// set when opportunisticAlloc aimed the target at the requested layer due to the acquisition
// grace (as opposed to overshoot), so the key frame request can be pointed at it too
acquireGraceApplied := false
opportunisticAlloc := func() {
// opportunistically latch on to anything
maxSpatial := maxLayer.Spatial
@@ -839,8 +892,14 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow
maxSpatial = maxSeenLayer.Spatial
}
targetSpatial := min(maxSeenLayer.Spatial, maxSpatial)
if inAcquireGrace(maxSpatial) {
targetSpatial = maxSpatial
acquireGraceApplied = true
}
alloc.TargetLayer = buffer.VideoLayer{
Spatial: min(maxSeenLayer.Spatial, maxSpatial),
Spatial: targetSpatial,
Temporal: getMaxTemporal(),
}
}
@@ -935,7 +994,11 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow
} else {
// opportunistically latch on to anything
opportunisticAlloc()
if requestLayerSpatial == buffer.InvalidLayerSpatial {
if acquireGraceApplied {
// in the acquisition grace, request a key frame for the requested layer we
// are waiting for (above what has been seen so far)
alloc.RequestLayerSpatial = alloc.TargetLayer.Spatial
} else if requestLayerSpatial == buffer.InvalidLayerSpatial {
alloc.RequestLayerSpatial = maxLayerSpatialLimit
} else {
alloc.RequestLayerSpatial = requestLayerSpatial
+42
View File
@@ -39,6 +39,7 @@ func newForwarder(codec webrtc.RTPCodecCapability, kind webrtc.RTPCodecType) *Fo
logger.GetLogger(),
true, // skipReferenceTS
true, // disableOpportunisticAllocation
true, // enableStartAtDesiredQuality
nil,
)
f.DetermineCodec(codec, nil, livekit.VideoLayer_MODE_UNUSED)
@@ -2145,3 +2146,44 @@ func TestForwarderGetPaddingVP8(t *testing.T) {
require.NoError(t, err)
require.Equal(t, marshalledVP8, buf)
}
func TestForwarderInitialAcquisitionGrace(t *testing.T) {
f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo)
// subscriber requested the top spatial layer
f.SetMaxSpatialLayer(buffer.DefaultMaxLayerSpatial)
f.SetMaxTemporalLayer(buffer.DefaultMaxLayerTemporal)
f.SetMaxTemporalLayerSeen(buffer.DefaultMaxLayerTemporal)
bitrates := Bitrates{
{2, 3, 0, 0},
{4, 0, 0, 5},
{0, 7, 0, 0},
}
// subscriber-first: only layer 1 has been seen so far and nothing is being forwarded yet
// (current invalid). this arms the initial-acquisition grace.
require.True(t, f.SetMaxPublishedLayer(1))
f.lock.RLock()
require.True(t, f.withinAcquireGraceLocked())
f.lock.RUnlock()
// during the grace, the target aims straight at the requested layer (2) and requests a key
// frame for it, even though only layer 1 has been seen - so acquisition does not ramp up
// gradually as higher layers are detected
alloc := f.AllocateOptimal([]int32{0, 1}, bitrates, false, false)
require.Equal(t, int32(2), alloc.TargetLayer.Spatial)
require.Equal(t, int32(2), alloc.RequestLayerSpatial)
// force the grace to expire while still not streaming: must signal that a re-allocation is
// needed so the target can fall back
f.acquireDeadline = 1 // a deadline far in the past
require.True(t, f.MaybeExpireAcquireGrace())
require.False(t, f.MaybeExpireAcquireGrace()) // only fires once
// after the grace, the target falls back to the highest layer actually seen (1) instead of
// stalling while waiting for a requested layer that never showed up
alloc = f.AllocateOptimal([]int32{0, 1}, bitrates, false, false)
require.Equal(t, int32(1), alloc.TargetLayer.Spatial)
require.Equal(t, int32(1), alloc.RequestLayerSpatial)
}
+267 -72
View File
@@ -1,13 +1,13 @@
package sfu
import (
"math"
"sync"
"time"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/utils"
"github.com/livekit/protocol/utils/mono"
"go.uber.org/atomic"
)
const (
@@ -15,97 +15,292 @@ const (
cSkewFactor = 10
)
type ForwardStats struct {
lock sync.Mutex
latency *utils.LatencyAggregate
lowest int64
highest int64
lastUpdateAt int64
closeCh chan struct{}
const (
// A summary interval's worth of samples across all tracks must fit without
// dropping (ForwardStats is a singleton). Shard count spreads the per-packet
// atomic; shard capacity bounds memory (numShards*shardCap*16 bytes = 2MiB).
forwardSampleNumShards = 16
forwardSampleShardCap = 8192
forwardSampleShardMask = forwardSampleShardCap - 1
forwardSampleShardSel = forwardSampleNumShards - 1
)
// forwardSampleShard is a ring of transit samples with multiple producers and a
// single consumer. A producer reserves a slot, stores the value, then publishes
// the slot's epoch (reserved index + 1). The consumer reads a slot only once its
// epoch marks the value committed for that index.
type forwardSampleShard struct {
writeIdx atomic.Uint64 // advanced by producers to reserve a slot
readIdx uint64 // consumer-only cursor
ring [forwardSampleShardCap]atomic.Int64
seq [forwardSampleShardCap]atomic.Uint64 // per-slot publish epoch
}
func NewForwardStats(latencyUpdateInterval, reportInterval, latencyWindowLength time.Duration) *ForwardStats {
s := &ForwardStats{
latency: utils.NewLatencyAggregate(latencyUpdateInterval, latencyWindowLength),
lowest: time.Second.Nanoseconds(),
closeCh: make(chan struct{}),
// forwardSampleBuffer holds per-packet transit samples produced on the packet
// path and consumed by the background worker, which performs metric emission.
type forwardSampleBuffer struct {
shards [forwardSampleNumShards]forwardSampleShard
dropped atomic.Uint64
}
// push records a sample: reserve a slot, store the value, then publish the
// slot's epoch. The shard is selected from arrival time bits.
func (b *forwardSampleBuffer) push(arrival, transitNs int64) {
sh := &b.shards[(uint64(arrival)>>6)&forwardSampleShardSel]
i := sh.writeIdx.Add(1) - 1
slot := i & forwardSampleShardMask
sh.ring[slot].Store(transitNs)
sh.seq[slot].Store(i + 1)
}
// drain passes every committed sample to fn and advances the read cursor. Only
// the background worker calls this.
//
// A slot holds index r's value once its epoch equals r+1. If the slot at the
// cursor is still uncommitted (a producer reserved it but has not published),
// draining stops and resumes from there on the next call, so no sample is read
// stale or skipped. When producers get a shard's capacity ahead, or overwrite a
// slot before it is read, the affected samples are counted as dropped.
func (b *forwardSampleBuffer) drain(fn func(transitNs int64)) {
for si := range b.shards {
sh := &b.shards[si]
w := sh.writeIdx.Load()
r := sh.readIdx
if w-r > forwardSampleShardCap {
b.dropped.Add(w - r - forwardSampleShardCap)
r = w - forwardSampleShardCap
}
for r < w {
slot := r & forwardSampleShardMask
if sh.seq[slot].Load() < r+1 {
// reserved but not yet published; resume here next drain
break
}
v := sh.ring[slot].Load()
if sh.seq[slot].Load() != r+1 {
// overwritten by a newer sample during the read; original lost
b.dropped.Add(1)
r++
continue
}
fn(v)
r++
}
sh.readIdx = r
}
}
func (b *forwardSampleBuffer) takeDropped() uint64 {
return b.dropped.Swap(0)
}
// forwardSummary is a mergeable summary of forwarding transit over an interval.
// The sum of squares is kept in microseconds so it does not overflow int64.
type forwardSummary struct {
count int64
sumUs int64
sumSqUs int64
minNs int64
maxNs int64
}
func (s forwardSummary) addSample(transitNs int64) forwardSummary {
us := transitNs / 1000
if s.count == 0 {
return forwardSummary{count: 1, sumUs: us, sumSqUs: us * us, minNs: transitNs, maxNs: transitNs}
}
go s.report(reportInterval)
s.count++
s.sumUs += us
s.sumSqUs += us * us
if transitNs < s.minNs {
s.minNs = transitNs
}
if transitNs > s.maxNs {
s.maxNs = transitNs
}
return s
}
func (s forwardSummary) merge(o forwardSummary) forwardSummary {
if o.count == 0 {
return s
}
if s.count == 0 {
return o
}
return forwardSummary{
count: s.count + o.count,
sumUs: s.sumUs + o.sumUs,
sumSqUs: s.sumSqUs + o.sumSqUs,
minNs: min(s.minNs, o.minNs),
maxNs: max(s.maxNs, o.maxNs),
}
}
func (s forwardSummary) meanStdDev() (mean, stdDev time.Duration) {
if s.count == 0 {
return 0, 0
}
meanUs := float64(s.sumUs) / float64(s.count)
mean = time.Duration(meanUs * float64(time.Microsecond))
if s.count < 2 {
return mean, 0
}
// sample variance (divisor count-1)
m2 := float64(s.sumSqUs) - float64(s.sumUs)*meanUs
varUs2 := m2 / float64(s.count-1)
if varUs2 < 0 {
// floating point rounding can push a (near-zero) variance slightly negative
varUs2 = 0
}
stdDev = time.Duration(math.Sqrt(varUs2) * float64(time.Microsecond))
return mean, stdDev
}
type ForwardStats struct {
samples forwardSampleBuffer
// ring of per-summary-interval summaries covering the report window.
// written by the background worker (flush) and read both by the worker
// (report) and by external callers (GetStats), so it is guarded by lock.
lock sync.Mutex
ring []forwardSummary
ringHead int
ringLen int
summaryInterval time.Duration
reportInterval time.Duration
closeCh chan struct{}
}
func NewForwardStats(summaryInterval, reportInterval, reportWindow time.Duration) *ForwardStats {
ringCap := int((reportWindow + summaryInterval - 1) / summaryInterval)
if ringCap < 1 {
ringCap = 1
}
s := &ForwardStats{
ring: make([]forwardSummary, ringCap),
summaryInterval: summaryInterval,
reportInterval: reportInterval,
closeCh: make(chan struct{}),
}
go s.run()
return s
}
// Update records a forwarded packet's transit latency. It buffers the sample
// and returns the transit and whether it exceeds the high-latency threshold.
// The sample is aggregated and emitted by the background worker.
func (s *ForwardStats) Update(arrival, left int64) (int64, bool) {
transit := left - arrival
isHighForwardingLatency := time.Duration(transit) > cHighForwardingLatency
s.lock.Lock()
s.latency.Update(time.Duration(arrival), float64(transit))
s.lowest = min(transit, s.lowest)
s.highest = max(transit, s.highest)
s.lastUpdateAt = arrival
s.lock.Unlock()
prometheus.RecordForwardLatencySample(transit)
return transit, isHighForwardingLatency
}
func (s *ForwardStats) GetStats(shortDuration time.Duration) (time.Duration, time.Duration) {
s.lock.Lock()
// a dummy sample to flush the pipe to current time
now := mono.UnixNano()
if (now - s.lastUpdateAt) > shortDuration.Nanoseconds() {
s.latency.Update(time.Duration(now), 0)
}
wLong := s.latency.Summarize()
lowest := s.lowest
s.lowest = time.Second.Nanoseconds()
highest := s.highest
s.highest = 0
s.lock.Unlock()
latencyLong, jitterLong := time.Duration(wLong.Mean()), time.Duration(wLong.StdDev())
if jitterLong > latencyLong*cSkewFactor {
logger.Infow(
"high jitter in forwarding path",
"lowest", time.Duration(lowest),
"highest", time.Duration(highest),
"countLong", wLong.Count(),
"latencyLong", latencyLong,
"jitterLong", jitterLong,
)
}
return latencyLong, jitterLong
}
func (s *ForwardStats) GetShortStats(shortDuration time.Duration) (time.Duration, time.Duration) {
s.lock.Lock()
wShort := s.latency.SummarizeLast(shortDuration)
s.lock.Unlock()
return time.Duration(wShort.Mean()), time.Duration(wShort.StdDev())
s.samples.push(arrival, transit)
return transit, time.Duration(transit) > cHighForwardingLatency
}
func (s *ForwardStats) Stop() {
close(s.closeCh)
}
func (s *ForwardStats) report(reportInterval time.Duration) {
ticker := time.NewTicker(reportInterval)
defer ticker.Stop()
func (s *ForwardStats) run() {
summaryTicker := time.NewTicker(s.summaryInterval)
defer summaryTicker.Stop()
reportTicker := time.NewTicker(s.reportInterval)
defer reportTicker.Stop()
for {
select {
case <-s.closeCh:
return
case <-ticker.C:
latencyLong, jitterLong := s.GetStats(reportInterval)
prometheus.RecordForwardJitter(uint32(jitterLong.Nanoseconds()))
prometheus.RecordForwardLatency(uint32(latencyLong.Nanoseconds()))
case <-summaryTicker.C:
s.flush()
case <-reportTicker.C:
// the summary ticker keeps the window ring current to within one
// summary interval; report over it without advancing the ring.
s.report()
}
}
}
// flush drains the buffered samples, observes each into the Prometheus
// histogram, and folds the interval summary into the window ring used for the
// latency/jitter gauges.
func (s *ForwardStats) flush() {
var summ forwardSummary
s.samples.drain(func(transitNs int64) {
prometheus.RecordForwardLatencySample(transitNs)
summ = summ.addSample(transitNs)
})
s.lock.Lock()
s.ring[s.ringHead] = summ
s.ringHead = (s.ringHead + 1) % len(s.ring)
if s.ringLen < len(s.ring) {
s.ringLen++
}
s.lock.Unlock()
}
// summarize merges the ring summaries covering the most recent window. A
// window <= 0 (or >= the report window) covers the entire ring.
func (s *ForwardStats) summarize(window time.Duration) forwardSummary {
s.lock.Lock()
defer s.lock.Unlock()
n := s.ringLen
if window > 0 && s.summaryInterval > 0 {
want := int((window + s.summaryInterval - 1) / s.summaryInterval)
if want < 1 {
want = 1
}
if want < n {
n = want
}
}
// walk backwards from the most recent entry (ringHead-1) over n entries.
var w forwardSummary
for i := 0; i < n; i++ {
idx := (s.ringHead - 1 - i + len(s.ring)) % len(s.ring)
w = w.merge(s.ring[idx])
}
return w
}
// GetStats returns the mean latency and jitter (std dev) of the forwarding
// transit over the most recent duration. The duration is rounded up to a whole
// number of summary intervals (the smallest bucket span that covers it). A
// duration <= 0, or one that meets/exceeds the report window, covers the full
// window.
func (s *ForwardStats) GetStats(duration time.Duration) (time.Duration, time.Duration) {
return s.summarize(duration).meanStdDev()
}
func (s *ForwardStats) report() {
w := s.summarize(0)
latency, jitter := w.meanStdDev()
if dropped := s.samples.takeDropped(); dropped > 0 {
logger.Warnw("forward stats sample buffer overflow", nil, "dropped", dropped)
}
if w.count > 0 && jitter > latency*cSkewFactor {
logger.Infow(
"high jitter in forwarding path",
"lowest", time.Duration(w.minNs),
"highest", time.Duration(w.maxNs),
"count", w.count,
"latency", latency,
"jitter", jitter,
)
}
prometheus.RecordForwardJitter(uint32(jitter.Nanoseconds()))
prometheus.RecordForwardLatency(uint32(latency.Nanoseconds()))
}
+365
View File
@@ -0,0 +1,365 @@
package sfu
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/atomic"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/protocol/livekit"
)
// initPrometheus initializes the global forward-latency collectors so that the
// worker's metric emission has non-nil targets. Init returns early if already
// initialized, so it is safe to call from multiple tests.
func initPrometheus(t *testing.T) {
t.Helper()
require.NoError(t, prometheus.Init("test", livekit.NodeType_SERVER))
}
// ---------------------------------------------------------------------------
// forwardSummary
// ---------------------------------------------------------------------------
func TestForwardSummary_AddSample(t *testing.T) {
var s forwardSummary
// empty summary
require.Equal(t, int64(0), s.count)
// microsecond-aligned transits so the /1000 truncation is exact
s = s.addSample(3000) // 3us
s = s.addSample(1000) // 1us
s = s.addSample(2000) // 2us
require.Equal(t, int64(3), s.count)
require.Equal(t, int64(1+2+3), s.sumUs)
require.Equal(t, int64(1+4+9), s.sumSqUs)
require.Equal(t, int64(1000), s.minNs)
require.Equal(t, int64(3000), s.maxNs)
}
func TestForwardSummary_Merge(t *testing.T) {
var empty forwardSummary
a := forwardSummary{}.addSample(1000).addSample(2000)
b := forwardSummary{}.addSample(5000).addSample(3000)
// merging with empty is identity, in both directions
require.Equal(t, a, a.merge(empty))
require.Equal(t, a, empty.merge(a))
m := a.merge(b)
require.Equal(t, int64(4), m.count)
require.Equal(t, a.sumUs+b.sumUs, m.sumUs)
require.Equal(t, a.sumSqUs+b.sumSqUs, m.sumSqUs)
require.Equal(t, int64(1000), m.minNs)
require.Equal(t, int64(5000), m.maxNs)
}
func TestForwardSummary_MeanStdDev(t *testing.T) {
// empty -> zero
mean, stdDev := forwardSummary{}.meanStdDev()
require.Zero(t, mean)
require.Zero(t, stdDev)
// single sample -> mean set, stddev zero (needs >= 2 for variance)
mean, stdDev = forwardSummary{}.addSample(4000).meanStdDev()
require.Equal(t, 4*time.Microsecond, mean)
require.Zero(t, stdDev)
// identical samples -> zero variance
s := forwardSummary{}.addSample(2000).addSample(2000).addSample(2000)
mean, stdDev = s.meanStdDev()
require.Equal(t, 2*time.Microsecond, mean)
require.Zero(t, stdDev)
// known dataset [1us, 2us, 3us]: mean 2us, sample variance 1us^2 -> stddev 1us
s = forwardSummary{}.addSample(1000).addSample(2000).addSample(3000)
mean, stdDev = s.meanStdDev()
require.Equal(t, 2*time.Microsecond, mean)
require.InDelta(t, float64(time.Microsecond), float64(stdDev), float64(50*time.Nanosecond))
}
// ---------------------------------------------------------------------------
// forwardSampleBuffer
// ---------------------------------------------------------------------------
func shardOf(arrival int64) int {
return int((uint64(arrival) >> 6) & forwardSampleShardSel)
}
// arrivalForShard returns the n-th arrival value that maps to a fixed shard.
// Incrementing arrival by (1<<10) advances (arrival>>6) by 16, leaving the low
// 4 selection bits unchanged.
func arrivalForShard(n int) int64 {
return int64(n) << 10
}
func TestForwardSampleBuffer_PushDrain(t *testing.T) {
var b forwardSampleBuffer
const n = 1000
for i := 0; i < n; i++ {
b.push(int64(i), int64((i+1)*1000))
}
got := map[int64]int{}
total := 0
b.drain(func(v int64) {
got[v]++
total++
})
require.Equal(t, n, total)
require.Equal(t, uint64(0), b.dropped.Load())
for i := 0; i < n; i++ {
require.Equal(t, 1, got[int64((i+1)*1000)], "sample %d missing", i)
}
// draining again yields nothing (read cursor advanced)
total = 0
b.drain(func(v int64) { total++ })
require.Equal(t, 0, total)
}
func TestForwardSampleBuffer_Overflow(t *testing.T) {
var b forwardSampleBuffer
const extra = 100
const n = forwardSampleShardCap + extra
// pin every push to a single shard so it overflows
for i := 0; i < n; i++ {
b.push(arrivalForShard(i), int64(i)*1000)
}
require.Equal(t, 0, shardOf(arrivalForShard(0)))
require.Equal(t, shardOf(arrivalForShard(0)), shardOf(arrivalForShard(n-1)))
var drained []int64
b.drain(func(v int64) { drained = append(drained, v) })
// exactly a shard's worth survives; the oldest `extra` are dropped and counted
require.Len(t, drained, forwardSampleShardCap)
require.Equal(t, uint64(extra), b.dropped.Load())
// survivors are the most recent cap samples, in order
for j, v := range drained {
require.Equal(t, int64(extra+j)*1000, v)
}
}
func TestForwardSampleBuffer_DefersUncommitted(t *testing.T) {
var b forwardSampleBuffer
sh := &b.shards[0]
// simulate a producer that reserved index 0 but has not published its value
sh.writeIdx.Store(1)
got := 0
b.drain(func(int64) { got++ })
require.Equal(t, 0, got, "uncommitted slot must not be read")
require.Equal(t, uint64(0), sh.readIdx, "cursor must not advance past an uncommitted slot")
require.Equal(t, uint64(0), b.dropped.Load())
// producer publishes the value; next drain picks it up
sh.ring[0].Store(1234)
sh.seq[0].Store(1)
var vals []int64
b.drain(func(v int64) { vals = append(vals, v) })
require.Equal(t, []int64{1234}, vals)
require.Equal(t, uint64(1), sh.readIdx)
require.Equal(t, uint64(0), b.dropped.Load())
}
func TestForwardSampleBuffer_Concurrent(t *testing.T) {
var b forwardSampleBuffer
var stop atomic.Bool
var consumed int64
done := make(chan struct{})
go func() {
defer close(done)
for !stop.Load() {
b.drain(func(int64) { consumed++ })
time.Sleep(time.Millisecond)
}
b.drain(func(int64) { consumed++ }) // final sweep
}()
const producers = 8
const perProducer = 100_000
var wg sync.WaitGroup
for p := 0; p < producers; p++ {
wg.Add(1)
go func(seed int64) {
defer wg.Done()
for i := int64(0); i < perProducer; i++ {
b.push(seed*7+i, (i%50)*int64(time.Microsecond))
}
}(int64(p))
}
wg.Wait()
stop.Store(true)
<-done
// with a consumer keeping pace no samples should be lost
require.Equal(t, int64(producers*perProducer), consumed+int64(b.dropped.Load()))
}
// ---------------------------------------------------------------------------
// ForwardStats
// ---------------------------------------------------------------------------
func TestForwardStats_Update(t *testing.T) {
s := &ForwardStats{ring: make([]forwardSummary, 1)}
// below threshold
transit, isHigh := s.Update(1000, 1000+int64(5*time.Millisecond))
require.Equal(t, int64(5*time.Millisecond), transit)
require.False(t, isHigh)
// above threshold
transit, isHigh = s.Update(1000, 1000+int64(25*time.Millisecond))
require.Equal(t, int64(25*time.Millisecond), transit)
require.True(t, isHigh)
// exactly at threshold is not "high" (strictly greater)
_, isHigh = s.Update(0, int64(cHighForwardingLatency))
require.False(t, isHigh)
}
func TestForwardStats_Flush(t *testing.T) {
initPrometheus(t)
s := &ForwardStats{ring: make([]forwardSummary, 4)}
for i := 0; i < 10; i++ {
s.Update(0, int64((i+1)*1000)) // 1us..10us
}
s.flush()
require.Equal(t, 1, s.ringLen)
summ := s.ring[0]
require.Equal(t, int64(10), summ.count)
require.Equal(t, int64(1000), summ.minNs)
require.Equal(t, int64(10000), summ.maxNs)
require.Equal(t, uint64(0), s.samples.dropped.Load())
// a subsequent flush with no new samples appends an empty summary
s.flush()
require.Equal(t, 2, s.ringLen)
require.Equal(t, int64(0), s.ring[1].count)
}
func TestForwardStats_ReportWindow(t *testing.T) {
initPrometheus(t)
// window of 3 summary buckets
s := &ForwardStats{ring: make([]forwardSummary, 3)}
s.Update(0, 1000)
s.flush()
s.Update(0, 3000)
s.flush()
// report merges the whole window without panicking and reflects both samples
var w forwardSummary
for i := 0; i < s.ringLen; i++ {
w = w.merge(s.ring[i])
}
require.Equal(t, int64(2), w.count)
require.Equal(t, int64(1000), w.minNs)
require.Equal(t, int64(3000), w.maxNs)
require.NotPanics(t, s.report)
}
func TestForwardStats_GetStats(t *testing.T) {
initPrometheus(t)
// 5 buckets, each covering one 100ms summary interval.
s := &ForwardStats{ring: make([]forwardSummary, 5), summaryInterval: 100 * time.Millisecond}
// fold five 100ms buckets, one sample each: 1ms, 2ms, 3ms, 4ms, 5ms.
for i := 1; i <= 5; i++ {
s.Update(0, int64(i)*int64(time.Millisecond))
s.flush()
}
require.Equal(t, 5, s.ringLen)
// a duration <= 0 covers the whole window: mean of 1..5ms == 3ms.
latency, jitter := s.GetStats(0)
require.InDelta(t, float64(3*time.Millisecond), float64(latency), float64(50*time.Microsecond))
require.Greater(t, jitter, time.Duration(0))
// a duration meeting/exceeding the window also covers it.
fullLatency, _ := s.GetStats(time.Second)
require.InDelta(t, float64(3*time.Millisecond), float64(fullLatency), float64(50*time.Microsecond))
// ~200ms rounds up to the two most recent buckets (4ms, 5ms): mean == 4.5ms.
shortLatency, _ := s.GetStats(200 * time.Millisecond)
require.InDelta(t, float64(4500*time.Microsecond), float64(shortLatency), float64(50*time.Microsecond))
// a sub-interval duration still yields at least the most recent bucket (5ms).
lastLatency, _ := s.GetStats(time.Nanosecond)
require.InDelta(t, float64(5*time.Millisecond), float64(lastLatency), float64(50*time.Microsecond))
}
func TestForwardStats_Lifecycle(t *testing.T) {
initPrometheus(t)
s := NewForwardStats(5*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond)
for i := 0; i < 1000; i++ {
s.Update(int64(i), int64(i)+int64(time.Millisecond))
}
time.Sleep(60 * time.Millisecond) // let the worker flush/report a few times
require.NotPanics(t, s.Stop)
}
func TestNewForwardStats_RingSizing(t *testing.T) {
// ringCap = ceil(window / summaryInterval)
s := NewForwardStats(100*time.Millisecond, time.Second, time.Second)
require.Equal(t, 10, len(s.ring))
s.Stop()
// rounds up a partial interval
s = NewForwardStats(100*time.Millisecond, time.Second, 250*time.Millisecond)
require.Equal(t, 3, len(s.ring))
s.Stop()
// never smaller than one bucket, even if window < summaryInterval
s = NewForwardStats(time.Second, time.Second, 100*time.Millisecond)
require.Equal(t, 1, len(s.ring))
s.Stop()
}
// ---------------------------------------------------------------------------
// benchmark: per-packet cost of Update (run with -cpu 1,8).
// ---------------------------------------------------------------------------
// benchArrival advances the arrival timestamp by 64ns per packet so that
// consecutive packets from one goroutine map to successive shards
// ((arrival>>6)&mask increments each step). A distinct per-goroutine base
// spreads goroutines across shards.
func benchArrival(base, i int64) int64 {
return base + i*64
}
func BenchmarkForwardStatsUpdate(b *testing.B) {
s := &ForwardStats{ring: make([]forwardSummary, 1)}
var gid atomic.Int64
b.RunParallel(func(pb *testing.PB) {
base := gid.Add(1) * 1_000_003
var i int64
for pb.Next() {
i++
arrival := benchArrival(base, i)
s.Update(arrival, arrival+int64(2*time.Millisecond))
}
})
}
+7 -1
View File
@@ -1036,7 +1036,13 @@ func (r *ReceiverBase) forwardRTP(
}
// track delay/jitter
if writeCount.Load() > 0 && r.forwardStats != nil && !extPkt.IsBuffered {
//
// Out-of-order packets (retransmissions/late arrivals) are excluded. They
// tend to arrive in bursts (e.g. a NACK triggers a batch of retransmissions
// delivered back-to-back) which the single forwarder goroutine drains
// serially, inflating the measured transit for the tail of the burst. That
// reflects loss recovery rather than steady-state forwarding health.
if writeCount.Load() > 0 && r.forwardStats != nil && !extPkt.IsBuffered && !extPkt.IsOutOfOrder {
if latency, isHigh := r.forwardStats.Update(extPkt.Arrival, mono.UnixNano()); isHigh {
r.params.Logger.Debugw(
"high forwarding latency",
+2 -2
View File
@@ -54,9 +54,9 @@ func GetTestExtPacket(params *TestExtPacketParams) (*buffer.ExtPacket, error) {
SequenceNumber: params.SequenceNumber,
Timestamp: params.Timestamp,
SSRC: params.SSRC,
PaddingSize: params.PaddingSize,
},
Payload: make([]byte, params.PayloadSize),
PaddingSize: params.PaddingSize,
Payload: make([]byte, params.PayloadSize),
}
raw, err := packet.Marshal()
+8
View File
@@ -35,6 +35,10 @@ type Base struct {
currentLayer buffer.VideoLayer
previousLayer buffer.VideoLayer
// when set, on initial acquisition latch directly onto the target (requested) layer instead of
// opportunistically latching onto the first lower-layer key frame that arrives (see Simulcast.Select)
enableStartAtDesiredQuality bool
}
func NewBase(logger logger.Logger) *Base {
@@ -66,6 +70,10 @@ func (b *Base) SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerS
b.tls = tls
}
func (b *Base) SetEnableStartAtDesiredQuality(enable bool) {
b.enableStartAtDesiredQuality = enable
}
func (b *Base) SetMax(maxLayer buffer.VideoLayer) {
b.maxLayer = maxLayer
}
+25 -7
View File
@@ -94,14 +94,32 @@ func (s *Simulcast) Select(extPkt *buffer.ExtPacket, layer int32) (result VideoL
found := false
reason := ""
if extPkt.IsKeyFrame {
if layer > s.currentLayer.Spatial && layer <= s.targetLayer.Spatial {
reason = "upgrading layer"
found = true
}
if s.enableStartAtDesiredQuality && !isActive {
// Initial acquisition: latch directly onto the target layer instead of
// opportunistically latching onto the first key frame of any lower layer that
// happens to arrive first. This avoids a visible low-quality -> high-quality ramp
// (e.g. briefly decoding layer 0 before settling on a requested layer 2) for a
// subscriber that requested the higher layer.
//
// The target is chosen by the allocator: during the initial-acquisition grace it
// points at the requested layer (so we wait for it); if that layer never shows
// up the grace expires and the allocator drops the target to the highest layer
// actually seen, so we always end up latching onto a layer that is flowing.
if layer == s.targetLayer.Spatial {
reason = "acquiring target layer"
found = true
}
} else {
// default: opportunistically latch on to / step towards the target layer
if layer > s.currentLayer.Spatial && layer <= s.targetLayer.Spatial {
reason = "upgrading layer"
found = true
}
if layer < s.currentLayer.Spatial && layer >= s.targetLayer.Spatial {
reason = "downgrading layer"
found = true
if layer < s.currentLayer.Spatial && layer >= s.targetLayer.Spatial {
reason = "downgrading layer"
found = true
}
}
if found {
@@ -0,0 +1,77 @@
// 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 videolayerselector
import (
"testing"
"github.com/pion/rtp"
"github.com/stretchr/testify/require"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/protocol/logger"
)
func keyFrameOnLayer(spatial, temporal int32) *buffer.ExtPacket {
return &buffer.ExtPacket{
VideoLayer: buffer.VideoLayer{Spatial: spatial, Temporal: temporal},
Packet: &rtp.Packet{},
IsKeyFrame: true,
}
}
// On initial acquisition the selector must latch directly onto the target layer and ignore
// lower-layer key frames that arrive first, so a subscriber requesting the top layer does not
// briefly decode a lower layer (a visible quality ramp).
func TestSimulcastSelectAcquiresTargetLayerDirectly(t *testing.T) {
s := NewSimulcast(logger.GetLogger())
s.SetEnableStartAtDesiredQuality(true)
s.SetMax(buffer.VideoLayer{Spatial: 2, Temporal: 2})
s.SetMaxSeen(buffer.VideoLayer{Spatial: 2, Temporal: 2})
s.SetTarget(buffer.VideoLayer{Spatial: 2, Temporal: 2})
s.SetRequestSpatial(2)
s.SetCurrent(buffer.InvalidLayer)
// lower-layer key frames arriving first must NOT be latched
require.False(t, s.Select(keyFrameOnLayer(0, 2), 0).IsSelected)
require.False(t, s.GetCurrent().IsValid())
require.False(t, s.Select(keyFrameOnLayer(1, 2), 1).IsSelected)
require.False(t, s.GetCurrent().IsValid())
// the target layer key frame latches directly
require.True(t, s.Select(keyFrameOnLayer(2, 2), 2).IsSelected)
require.Equal(t, int32(2), s.GetCurrent().Spatial)
}
// Acquisition follows whatever target the allocator set: when the target is lowered (e.g. the
// acquisition grace expired and the allocator fell back to the highest layer seen), the selector
// latches that lower layer directly. This is the fallback path that prevents a stall when the
// originally requested layer never shows up.
func TestSimulcastSelectAcquiresLoweredTarget(t *testing.T) {
s := NewSimulcast(logger.GetLogger())
s.SetEnableStartAtDesiredQuality(true)
s.SetMax(buffer.VideoLayer{Spatial: 2, Temporal: 2})
s.SetMaxSeen(buffer.VideoLayer{Spatial: 1, Temporal: 2})
// allocator dropped the target to the highest layer actually seen
s.SetTarget(buffer.VideoLayer{Spatial: 1, Temporal: 2})
s.SetRequestSpatial(1)
s.SetCurrent(buffer.InvalidLayer)
require.False(t, s.Select(keyFrameOnLayer(0, 2), 0).IsSelected)
require.False(t, s.GetCurrent().IsValid())
require.True(t, s.Select(keyFrameOnLayer(1, 2), 1).IsSelected)
require.Equal(t, int32(1), s.GetCurrent().Spatial)
}
@@ -37,6 +37,7 @@ type VideoLayerSelector interface {
IsOvershootOkay() bool
SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerSelector)
SetEnableStartAtDesiredQuality(enable bool)
SetMax(maxLayer buffer.VideoLayer)
SetMaxSpatial(layer int32)
+35 -37
View File
@@ -149,43 +149,41 @@ func GetNodeStats(nodeStartedAt int64, prevStats []*livekit.NodeStats, rateInter
promSysPacketGauge.WithLabelValues("dropped").Set(float64(sysDroppedPackets - sysDroppedPacketsStart))
stats := &livekit.NodeStats{
StartedAt: nodeStartedAt,
UpdatedAt: time.Now().Unix(),
NumRooms: roomCurrent.Load(),
NumClients: participantCurrent.Load(),
NumTracksIn: trackPublishedCurrent.Load(),
NumTracksOut: trackSubscribedCurrent.Load(),
NumTrackPublishAttempts: trackPublishAttempts.Load(),
NumTrackPublishSuccess: trackPublishSuccess.Load(),
NumTrackPublishCancels: trackPublishCancels.Load(),
NumTrackSubscribeAttempts: trackSubscribeAttempts.Load(),
NumTrackSubscribeSuccess: trackSubscribeSuccess.Load(),
NumTrackSubscribeCancels: trackSubscribeCancels.Load(),
BytesIn: bytesIn.Load(),
BytesOut: bytesOut.Load(),
PacketsIn: packetsIn.Load(),
PacketsOut: packetsOut.Load(),
RetransmitBytesOut: retransmitBytes.Load(),
RetransmitPacketsOut: retransmitPackets.Load(),
NackTotal: nackTotal.Load(),
ParticipantSignalConnected: participantSignalConnected.Load(),
ParticipantSignalFailed: participantSignalFailed.Load(),
ParticipantSignalValidationFailed: participantSignalValidationFailed.Load(),
ParticipantRtcInit: participantRTCInit.Load(),
ParticipantRtcConnected: participantRTCConnected.Load(),
ParticipantRtcCanceled: participantRTCCanceled.Load(),
ParticipantRtcActive: participantRTCActive.Load(),
ForwardLatency: forwardLatency.Load(),
ForwardJitter: forwardJitter.Load(),
NumCpus: uint32(cpuStats.NumCPU()), // this will round down to the nearest integer
CpuLoad: float32(cpuStats.GetCPULoad()),
MemoryTotal: memTotal,
MemoryUsed: memUsed,
LoadAvgLast1Min: float32(loadAvg.Loadavg1),
LoadAvgLast5Min: float32(loadAvg.Loadavg5),
LoadAvgLast15Min: float32(loadAvg.Loadavg15),
SysPacketsOut: sysPackets,
SysPacketsDropped: sysDroppedPackets,
StartedAt: nodeStartedAt,
UpdatedAt: time.Now().Unix(),
NumRooms: roomCurrent.Load(),
NumClients: participantCurrent.Load(),
NumTracksIn: trackPublishedCurrent.Load(),
NumTracksOut: trackSubscribedCurrent.Load(),
NumTrackPublishAttempts: trackPublishAttempts.Load(),
NumTrackPublishSuccess: trackPublishSuccess.Load(),
NumTrackPublishCancels: trackPublishCancels.Load(),
NumTrackSubscribeAttempts: trackSubscribeAttempts.Load(),
NumTrackSubscribeSuccess: trackSubscribeSuccess.Load(),
NumTrackSubscribeCancels: trackSubscribeCancels.Load(),
BytesIn: bytesIn.Load(),
BytesOut: bytesOut.Load(),
PacketsIn: packetsIn.Load(),
PacketsOut: packetsOut.Load(),
RetransmitBytesOut: retransmitBytes.Load(),
RetransmitPacketsOut: retransmitPackets.Load(),
NackTotal: nackTotal.Load(),
ParticipantSignalConnected: participantSignalConnected.Load(),
ParticipantRtcInit: participantRTCInit.Load(),
ParticipantRtcConnected: participantRTCConnected.Load(),
ParticipantRtcCanceled: participantRTCCanceled.Load(),
ParticipantRtcActive: participantRTCActive.Load(),
ForwardLatency: forwardLatency.Load(),
ForwardJitter: forwardJitter.Load(),
NumCpus: uint32(cpuStats.NumCPU()), // this will round down to the nearest integer
CpuLoad: float32(cpuStats.GetCPULoad()),
MemoryTotal: memTotal,
MemoryUsed: memUsed,
LoadAvgLast1Min: float32(loadAvg.Loadavg1),
LoadAvgLast5Min: float32(loadAvg.Loadavg5),
LoadAvgLast15Min: float32(loadAvg.Loadavg15),
SysPacketsOut: sysPackets,
SysPacketsDropped: sysDroppedPackets,
}
for _, rateInterval := range rateIntervals {
+50 -30
View File
@@ -36,22 +36,20 @@ const (
)
var (
bytesIn atomic.Uint64
bytesOut atomic.Uint64
packetsIn atomic.Uint64
packetsOut atomic.Uint64
nackTotal atomic.Uint64
retransmitBytes atomic.Uint64
retransmitPackets atomic.Uint64
participantSignalConnected atomic.Uint64
participantSignalFailed atomic.Uint64
participantSignalValidationFailed atomic.Uint64
participantRTCConnected atomic.Uint64
participantRTCInit atomic.Uint64
participantRTCCanceled atomic.Uint64
participantRTCActive atomic.Uint64
forwardLatency atomic.Uint32
forwardJitter atomic.Uint32
bytesIn atomic.Uint64
bytesOut atomic.Uint64
packetsIn atomic.Uint64
packetsOut atomic.Uint64
nackTotal atomic.Uint64
retransmitBytes atomic.Uint64
retransmitPackets atomic.Uint64
participantSignalConnected atomic.Uint64
participantRTCConnected atomic.Uint64
participantRTCInit atomic.Uint64
participantRTCCanceled atomic.Uint64
participantRTCActive atomic.Uint64
forwardLatency atomic.Uint32
forwardJitter atomic.Uint32
promPacketLabels = []string{"direction", "transmission", "country"}
promPacketTotal *prometheus.CounterVec
@@ -351,29 +349,39 @@ func IncrementParticipantJoin(join uint32) {
func IncrementParticipantJoinFail(fail uint32) {
if fail > 0 {
participantSignalFailed.Add(uint64(fail))
promParticipantJoin.WithLabelValues("signal_failed").Add(float64(fail))
}
}
func IncrementParticipantJoinValidationFail(validationFail uint32) {
if validationFail > 0 {
participantSignalValidationFailed.Add(uint64(validationFail))
promParticipantJoin.WithLabelValues("signal_validation_failed").Add(float64(validationFail))
}
}
func IncrementParticipantRtcInit(join uint32) {
if join > 0 {
participantRTCInit.Add(uint64(join))
promParticipantJoin.WithLabelValues("rtc_init").Add(float64(join))
func IncrementParticipantJoinUpgradeFail(upgradeFail uint32) {
if upgradeFail > 0 {
promParticipantJoin.WithLabelValues("signal_upgrade_failed").Add(float64(upgradeFail))
}
}
func IncrementParticipantRtcConnected(join uint32) {
if join > 0 {
participantRTCConnected.Add(uint64(join))
promParticipantJoin.WithLabelValues("rtc_connected").Add(float64(join))
func IncrementParticipantJoinWriteInitialResponseFail(writeInitialResponseFail uint32) {
if writeInitialResponseFail > 0 {
promParticipantJoin.WithLabelValues("signal_write_initial_response_failed").Add(float64(writeInitialResponseFail))
}
}
func IncrementParticipantRtcInit(init uint32) {
if init > 0 {
participantRTCInit.Add(uint64(init))
promParticipantJoin.WithLabelValues("rtc_init").Add(float64(init))
}
}
func IncrementParticipantRtcConnected(connected uint32) {
if connected > 0 {
participantRTCConnected.Add(uint64(connected))
promParticipantJoin.WithLabelValues("rtc_connected").Add(float64(connected))
}
}
@@ -384,10 +392,22 @@ func IncrementParticipantRtcActive(active uint32) {
}
}
func IncrementParticipantRtcCanceled(numCancels uint64) {
if numCancels > 0 {
participantRTCCanceled.Add(numCancels)
promParticipantJoin.WithLabelValues("rtc_canceled").Add(float64(numCancels))
func IncrementParticipantRtcCanceled(canceled uint64) {
if canceled > 0 {
participantRTCCanceled.Add(canceled)
promParticipantJoin.WithLabelValues("rtc_canceled").Add(float64(canceled))
}
}
func IncrementParticipantRtcSuccess(success uint64) {
if success > 0 {
promParticipantJoin.WithLabelValues("rtc_success").Add(float64(success))
}
}
func IncrementParticipantRtcFailure(failure uint64) {
if failure > 0 {
promParticipantJoin.WithLabelValues("rtc_failure").Add(float64(failure))
}
}
+13
View File
@@ -46,6 +46,7 @@ var (
promTrackSubscribedCurrent *prometheus.GaugeVec
promTrackPublishCounter *prometheus.CounterVec
promTrackSubscribeCounter *prometheus.CounterVec
promSessionJoinLatency *prometheus.HistogramVec
promSessionStartTime *prometheus.HistogramVec
promSessionDuration *prometheus.HistogramVec
promPubSubTime *prometheus.HistogramVec
@@ -99,6 +100,13 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) {
Name: "subscribe_counter",
ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()},
}, []string{"state", "error"})
promSessionJoinLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: livekitNamespace,
Subsystem: "session",
Name: "join_latency_ms",
ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()},
Buckets: prometheus.ExponentialBucketsRange(10, 10000, 15),
}, []string{"protocol_version"})
promSessionStartTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: livekitNamespace,
Subsystem: "session",
@@ -134,6 +142,7 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) {
prometheus.MustRegister(promTrackSubscribedCurrent)
prometheus.MustRegister(promTrackPublishCounter)
prometheus.MustRegister(promTrackSubscribeCounter)
prometheus.MustRegister(promSessionJoinLatency)
prometheus.MustRegister(promSessionStartTime)
prometheus.MustRegister(promSessionDuration)
prometheus.MustRegister(promPubSubTime)
@@ -271,6 +280,10 @@ func RecordTrackSubscribeCancels(numCancels int32) {
promTrackSubscribeCounter.WithLabelValues("cancel", "").Add(float64(numCancels))
}
func RecordSessionJoinLatency(protocolVersion int, d time.Duration) {
promSessionJoinLatency.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds()))
}
func RecordSessionStartTime(protocolVersion int, d time.Duration) {
promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds()))
}
+10 -3
View File
@@ -80,9 +80,13 @@ func setupSingleNodeTest(name string) (*service.LivekitServer, func()) {
}
func setupMultiNodeTest(name string) (*service.LivekitServer, *service.LivekitServer, func()) {
return setupMultiNodeTestWithConfig(name, nil)
}
func setupMultiNodeTestWithConfig(name string, configUpdater func(*config.Config)) (*service.LivekitServer, *service.LivekitServer, func()) {
logger.Infow("----------------STARTING TEST----------------", "test", name)
s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort)
s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort)
s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort, configUpdater)
s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort, configUpdater)
go s1.Start()
go s2.Start()
@@ -190,7 +194,7 @@ func createSingleNodeServer(configUpdater func(*config.Config)) *service.Livekit
return s
}
func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer {
func createMultiNodeServer(nodeID string, port uint32, configUpdater func(*config.Config)) *service.LivekitServer {
var err error
conf, err := config.NewConfig("", true, nil, nil)
if err != nil {
@@ -202,6 +206,9 @@ func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer {
conf.Redis.Address = "localhost:6379"
conf.Keys = map[string]string{testApiKey: testApiSecret}
conf.EnableDataTracks = true
if configUpdater != nil {
configUpdater(conf)
}
currentNode, err := routing.NewLocalNode(conf)
if err != nil {
+127
View File
@@ -24,6 +24,7 @@ import (
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/testutils"
"github.com/livekit/livekit-server/test/client"
@@ -425,3 +426,129 @@ func TestCloseDisconnectedParticipantOnSignalClose(t *testing.T) {
})
}
}
func TestMultiNodeDataBlob(t *testing.T) {
if testing.Short() {
t.SkipNow()
return
}
_, _, finish := setupMultiNodeTestWithConfig("TestMultiNodeDataBlob", func(c *config.Config) {
c.EnableParticipantDataBlob = true
c.Limit.MaxDataBlobSize = 1024
})
defer finish()
for _, testRTCServicePath := range testRTCServicePaths {
t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) {
pubCapture := &dataBlobCapture{}
subCapture := &dataBlobCapture{}
// publisher on node 1, subscriber on node 2
pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &client.Options{
AutoSubscribe: true,
SignalResponseInterceptor: pubCapture.interceptor(),
})
sub := createRTCClient("sub", secondServerPort, testRTCServicePath, &client.Options{
AutoSubscribe: true,
SignalResponseInterceptor: subCapture.interceptor(),
})
waitUntilConnected(t, pub, sub)
defer stopClients(pub, sub)
// wait for both nodes to see each other so the get request routes correctly
testutils.WithTimeout(t, func() string {
if sub.GetRemoteParticipant(pub.ID()) == nil {
return "sub does not see pub yet"
}
return ""
})
key := &livekit.DataBlobKey{
Key: &livekit.DataBlobKey_Generic{
Generic: "blob-multinode",
},
}
contents := []byte("multinode-content")
require.NoError(t, pub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_StoreDataBlobRequest{
StoreDataBlobRequest: &livekit.StoreDataBlobRequest{
RequestId: 1,
Blob: &livekit.DataBlob{
Key: key,
Contents: contents,
},
},
},
}))
testutils.WithTimeout(t, func() string {
resp := pubCapture.takeStoreResponse()
if resp == nil {
return "publisher did not receive store response"
}
if resp.RequestId != 1 {
return fmt.Sprintf("expected store response request id 1, got %d", resp.RequestId)
}
if resp.Key == nil {
return "store response missing key"
}
if resp.Key.String() != key.String() {
return fmt.Sprintf("expected stored blob key %s, got %s", key.String(), resp.Key.String())
}
return ""
})
require.Equal(t, 0, pubCapture.requestResponseCount(), "publisher should not receive an error response on success")
// subscriber on a different node asks for the blob; the request routes
// across nodes to the publisher.
require.NoError(t, sub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_GetDataBlobRequest{
GetDataBlobRequest: &livekit.GetDataBlobRequest{
ParticipantIdentity: "pub",
Key: key,
},
},
}))
testutils.WithTimeout(t, func() string {
resp := subCapture.takeBlobResponse()
if resp == nil {
return "subscriber did not receive blob response"
}
if resp.Blob == nil {
return "blob response missing blob"
}
if resp.Blob.Key.String() != key.String() {
return fmt.Sprintf("expected data blob key %s, got %s", key.String(), resp.Blob.Key.String())
}
if string(resp.Blob.Contents) != string(contents) {
return fmt.Sprintf("expected contents %q, got %q", contents, resp.Blob.Contents)
}
return ""
})
// requesting an unknown publisher identity should return NOT_FOUND
require.NoError(t, sub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_GetDataBlobRequest{
GetDataBlobRequest: &livekit.GetDataBlobRequest{
ParticipantIdentity: "unknown-publisher",
Key: key,
},
},
}))
testutils.WithTimeout(t, func() string {
rr := subCapture.takeRequestResponse()
if rr == nil {
return "subscriber did not receive RequestResponse for unknown publisher"
}
if rr.Reason != livekit.RequestResponse_NOT_FOUND {
return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason)
}
return ""
})
})
}
}
+296
View File
@@ -1526,3 +1526,299 @@ func TestTurnAuthFailure(t *testing.T) {
})
}
}
// dataBlobCapture buffers RequestResponse, StoreDataBlobResponse, and GetDataBlobResponse messages
// sent to a test client so they can be asserted on. Other messages flow through to the
// default handler.
type dataBlobCapture struct {
mu sync.Mutex
requestResponses []*livekit.RequestResponse
storeResponses []*livekit.StoreDataBlobResponse
blobResponses []*livekit.GetDataBlobResponse
}
func (c *dataBlobCapture) interceptor() testclient.SignalResponseInterceptor {
return func(msg *livekit.SignalResponse, next testclient.SignalResponseHandler) error {
switch m := msg.Message.(type) {
case *livekit.SignalResponse_RequestResponse:
c.mu.Lock()
c.requestResponses = append(c.requestResponses, m.RequestResponse)
c.mu.Unlock()
case *livekit.SignalResponse_StoreDataBlobResponse:
c.mu.Lock()
c.storeResponses = append(c.storeResponses, m.StoreDataBlobResponse)
c.mu.Unlock()
case *livekit.SignalResponse_GetDataBlobResponse:
c.mu.Lock()
c.blobResponses = append(c.blobResponses, m.GetDataBlobResponse)
c.mu.Unlock()
}
return next(msg)
}
}
func (c *dataBlobCapture) takeRequestResponse() *livekit.RequestResponse {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.requestResponses) == 0 {
return nil
}
rr := c.requestResponses[0]
c.requestResponses = c.requestResponses[1:]
return rr
}
func (c *dataBlobCapture) takeStoreResponse() *livekit.StoreDataBlobResponse {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.storeResponses) == 0 {
return nil
}
sr := c.storeResponses[0]
c.storeResponses = c.storeResponses[1:]
return sr
}
func (c *dataBlobCapture) takeBlobResponse() *livekit.GetDataBlobResponse {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.blobResponses) == 0 {
return nil
}
sr := c.blobResponses[0]
c.blobResponses = c.blobResponses[1:]
return sr
}
func (c *dataBlobCapture) requestResponseCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.requestResponses)
}
func setupDataBlobServer(t *testing.T, name string, enable bool) (*service.LivekitServer, func()) {
logger.Infow("----------------STARTING TEST----------------", "test", name)
s := createSingleNodeServer(func(c *config.Config) {
c.EnableParticipantDataBlob = enable
c.Limit.MaxDataBlobSize = 1024
})
go func() {
if err := s.Start(); err != nil {
logger.Errorw("server returned error", err)
}
}()
waitForServerToStart(s)
return s, func() {
s.Stop(true)
logger.Infow("----------------FINISHING TEST----------------", "test", name)
}
}
func TestSingleNodeDataBlob(t *testing.T) {
if testing.Short() {
t.SkipNow()
return
}
_, finish := setupDataBlobServer(t, "TestSingleNodeDataBlob", true)
defer finish()
for _, testRTCServicePath := range testRTCServicePaths {
t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) {
pubCapture := &dataBlobCapture{}
subCapture := &dataBlobCapture{}
pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &testclient.Options{
AutoSubscribe: true,
SignalResponseInterceptor: pubCapture.interceptor(),
})
sub := createRTCClient("sub", defaultServerPort, testRTCServicePath, &testclient.Options{
AutoSubscribe: true,
SignalResponseInterceptor: subCapture.interceptor(),
})
waitUntilConnected(t, pub, sub)
defer stopClients(pub, sub)
key := &livekit.DataBlobKey{
Key: &livekit.DataBlobKey_Generic{
Generic: "blob-1",
},
}
contents := []byte("definition-bytes")
// publisher stores a blob
require.NoError(t, pub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_StoreDataBlobRequest{
StoreDataBlobRequest: &livekit.StoreDataBlobRequest{
RequestId: 1,
Blob: &livekit.DataBlob{
Key: key,
Contents: contents,
},
},
},
}))
testutils.WithTimeout(t, func() string {
resp := pubCapture.takeStoreResponse()
if resp == nil {
return "publisher did not receive store response"
}
if resp.RequestId != 1 {
return fmt.Sprintf("expected store response request id 1, got %d", resp.RequestId)
}
if resp.Key == nil {
return "store response missing key"
}
if resp.Key.String() != key.String() {
return fmt.Sprintf("expected stored blob key %s, got %s", key.String(), resp.Key.String())
}
return ""
})
require.Equal(t, 0, pubCapture.requestResponseCount(), "publisher should not receive an error response on success")
// subscriber asks for the blob
require.NoError(t, sub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_GetDataBlobRequest{
GetDataBlobRequest: &livekit.GetDataBlobRequest{
ParticipantIdentity: "pub",
Key: key,
},
},
}))
testutils.WithTimeout(t, func() string {
resp := subCapture.takeBlobResponse()
if resp == nil {
return "subscriber did not receive blob response"
}
if resp.Blob == nil {
return "blob response missing blob"
}
if resp.Blob.Key.String() != key.String() {
return fmt.Sprintf("expected blob key %s, got %s", key.String(), resp.Blob.Key.String())
}
if string(resp.Blob.Contents) != string(contents) {
return fmt.Sprintf("expected contents %q, got %q", contents, resp.Blob.Contents)
}
return ""
})
// subscriber asks for an unknown blob on a known publisher
require.NoError(t, sub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_GetDataBlobRequest{
GetDataBlobRequest: &livekit.GetDataBlobRequest{
ParticipantIdentity: "pub",
Key: &livekit.DataBlobKey{
Key: &livekit.DataBlobKey_Generic{
Generic: "does-not-exist",
},
},
},
},
}))
testutils.WithTimeout(t, func() string {
rr := subCapture.takeRequestResponse()
if rr == nil {
return "subscriber did not receive RequestResponse for missing blob"
}
if rr.Reason != livekit.RequestResponse_NOT_FOUND {
return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason)
}
return ""
})
// subscriber asks for a blob on an unknown publisher identity
require.NoError(t, sub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_GetDataBlobRequest{
GetDataBlobRequest: &livekit.GetDataBlobRequest{
ParticipantIdentity: "unknown-publisher",
Key: key,
},
},
}))
testutils.WithTimeout(t, func() string {
rr := subCapture.takeRequestResponse()
if rr == nil {
return "subscriber did not receive RequestResponse for unknown publisher"
}
if rr.Reason != livekit.RequestResponse_NOT_FOUND {
return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason)
}
return ""
})
// publisher sends an invalid blob (empty key)
require.NoError(t, pub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_StoreDataBlobRequest{
StoreDataBlobRequest: &livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Contents: contents,
},
},
},
}))
testutils.WithTimeout(t, func() string {
rr := pubCapture.takeRequestResponse()
if rr == nil {
return "publisher did not receive RequestResponse for invalid define"
}
if rr.Reason != livekit.RequestResponse_INVALID_REQUEST {
return fmt.Sprintf("expected INVALID_REQUEST, got %s", rr.Reason)
}
return ""
})
})
}
}
func TestSingleNodeDataBlobDisabled(t *testing.T) {
if testing.Short() {
t.SkipNow()
return
}
_, finish := setupDataBlobServer(t, "TestSingleNodeDataBlobDisabled", false)
defer finish()
for _, testRTCServicePath := range testRTCServicePaths {
t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) {
pubCapture := &dataBlobCapture{}
pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &testclient.Options{
AutoSubscribe: true,
SignalResponseInterceptor: pubCapture.interceptor(),
})
waitUntilConnected(t, pub)
defer stopClients(pub)
require.NoError(t, pub.SendRequest(&livekit.SignalRequest{
Message: &livekit.SignalRequest_StoreDataBlobRequest{
StoreDataBlobRequest: &livekit.StoreDataBlobRequest{
Blob: &livekit.DataBlob{
Key: &livekit.DataBlobKey{
Key: &livekit.DataBlobKey_Generic{
Generic: "blob-1",
},
},
Contents: []byte("definition-bytes"),
},
},
},
}))
testutils.WithTimeout(t, func() string {
rr := pubCapture.takeRequestResponse()
if rr == nil {
return "publisher did not receive RequestResponse"
}
if rr.Reason != livekit.RequestResponse_NOT_ALLOWED {
return fmt.Sprintf("expected NOT_ALLOWED, got %s", rr.Reason)
}
return ""
})
})
}
}
+1 -1
View File
@@ -14,4 +14,4 @@
package version
const Version = "1.13.1"
const Version = "1.13.3"