mirror of
https://github.com/livekit/livekit.git
synced 2026-08-28 07:14:12 +00:00
Merge remote-tracking branch 'origin/master' into dc/features/fec
# Conflicts: # pkg/rtc/transport.go
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -14,4 +14,4 @@
|
||||
|
||||
package version
|
||||
|
||||
const Version = "1.13.1"
|
||||
const Version = "1.13.3"
|
||||
|
||||
Reference in New Issue
Block a user