diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index 284ecf87a..eabddd15a 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -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" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 1ee39fe6f..1c0f64802 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 78b7ef75a..0fe7bb6c6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml new file mode 100644 index 000000000..184f0de04 --- /dev/null +++ b/.github/workflows/test-server-docker.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 233153522..9ad30c3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile new file mode 100644 index 000000000..0182aadea --- /dev/null +++ b/cmd/test-server/Dockerfile @@ -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"] diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md new file mode 100644 index 000000000..00649f8fb --- /dev/null +++ b/cmd/test-server/README.md @@ -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. diff --git a/cmd/test-server/auth.go b/cmd/test-server/auth.go new file mode 100644 index 000000000..6e270cd00 --- /dev/null +++ b/cmd/test-server/auth.go @@ -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 "./" 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 " 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() +} diff --git a/cmd/test-server/config.go b/cmd/test-server/config.go new file mode 100644 index 000000000..bca4ee251 --- /dev/null +++ b/cmd/test-server/config.go @@ -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 +} diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go new file mode 100644 index 000000000..9d14fd2b3 --- /dev/null +++ b/cmd/test-server/handlers.go @@ -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 "./" 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 + } +} diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go new file mode 100644 index 000000000..7a2e4f52d --- /dev/null +++ b/cmd/test-server/main.go @@ -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 +} diff --git a/cmd/test-server/signal.go b/cmd/test-server/signal.go new file mode 100644 index 000000000..439d669e7 --- /dev/null +++ b/cmd/test-server/signal.go @@ -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":"","leaveAction":}. 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, + }, + }, + } +} diff --git a/cmd/test-server/signal_test.go b/cmd/test-server/signal_test.go new file mode 100644 index 000000000..3ccd97a95 --- /dev/null +++ b/cmd/test-server/signal_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index cd376e81e..b68de126a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 454ef37c0..9427e7447 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/magefile.go b/magefile.go index 4423b01c9..d4d42fa68 100644 --- a/magefile.go +++ b/magefile.go @@ -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 { diff --git a/pkg/agent/client.go b/pkg/agent/client.go index 51129d033..10c0abbed 100644 --- a/pkg/agent/client.go +++ b/pkg/agent/client.go @@ -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 { diff --git a/pkg/agent/testutils/server.go b/pkg/agent/testutils/server.go index 090e37680..3f490918c 100644 --- a/pkg/agent/testutils/server.go +++ b/pkg/agent/testutils/server.go @@ -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) diff --git a/pkg/config/config.go b/pkg/config/config.go index fdaf8e38f..d62aba5fe 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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", diff --git a/pkg/rtc/mediatracksubscriptions.go b/pkg/rtc/mediatracksubscriptions.go index 69575fe6b..c8720eade 100644 --- a/pkg/rtc/mediatracksubscriptions.go +++ b/pkg/rtc/mediatracksubscriptions.go @@ -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) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index f5f459e4e..d6130cfce 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -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) diff --git a/pkg/rtc/participant_data_blob.go b/pkg/rtc/participant_data_blob.go new file mode 100644 index 000000000..74512d569 --- /dev/null +++ b/pkg/rtc/participant_data_blob.go @@ -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 +} + +// ------------------------------- diff --git a/pkg/rtc/participant_data_blob_handler.go b/pkg/rtc/participant_data_blob_handler.go new file mode 100644 index 000000000..8c19ee765 --- /dev/null +++ b/pkg/rtc/participant_data_blob_handler.go @@ -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() +} diff --git a/pkg/rtc/participant_data_blob_handler_test.go b/pkg/rtc/participant_data_blob_handler_test.go new file mode 100644 index 000000000..411a6a203 --- /dev/null +++ b/pkg/rtc/participant_data_blob_handler_test.go @@ -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) + }) +} diff --git a/pkg/rtc/participant_data_blob_test.go b/pkg/rtc/participant_data_blob_test.go new file mode 100644 index 000000000..a6d28fb20 --- /dev/null +++ b/pkg/rtc/participant_data_blob_test.go @@ -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() +} diff --git a/pkg/rtc/participant_data_track.go b/pkg/rtc/participant_data_track.go index dbda1ede0..df9854744 100644 --- a/pkg/rtc/participant_data_track.go +++ b/pkg/rtc/participant_data_track.go @@ -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), diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 3fa388adc..8f5c3dd2b 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -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, + })) +} diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 2062b5179..66e922ec3 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -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) } diff --git a/pkg/rtc/signalling/interfaces.go b/pkg/rtc/signalling/interfaces.go index 2470e9d31..99c3a0acc 100644 --- a/pkg/rtc/signalling/interfaces.go +++ b/pkg/rtc/signalling/interfaces.go @@ -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 } diff --git a/pkg/rtc/signalling/signalhandler.go b/pkg/rtc/signalling/signalhandler.go index 7e69aeee2..740e65899 100644 --- a/pkg/rtc/signalling/signalhandler.go +++ b/pkg/rtc/signalling/signalhandler.go @@ -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 diff --git a/pkg/rtc/signalling/signalling.go b/pkg/rtc/signalling/signalling.go index fffc2ba29..c13cee1be 100644 --- a/pkg/rtc/signalling/signalling.go +++ b/pkg/rtc/signalling/signalling.go @@ -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, + }, + } +} diff --git a/pkg/rtc/signalling/signallingunimplemented.go b/pkg/rtc/signalling/signallingunimplemented.go index dca48779f..8210c6aac 100644 --- a/pkg/rtc/signalling/signallingunimplemented.go +++ b/pkg/rtc/signalling/signallingunimplemented.go @@ -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 +} diff --git a/pkg/rtc/subscribedtrack.go b/pkg/rtc/subscribedtrack.go index fbc9e4457..8000f2c98 100644 --- a/pkg/rtc/subscribedtrack.go +++ b/pkg/rtc/subscribedtrack.go @@ -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} diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index d421d0805..566c8b91b 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -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 } } diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index e64c7e7a2..d474bd05d 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -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 diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index ab328d7c5..3c0ea425e 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -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 diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 528cd72c7..6259d1a7c 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -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, diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index 194f529dd..484f707e2 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -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 } diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index 76c32d76b..6996a81e3 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -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)] diff --git a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go index 4cd0fef83..4c0a3a07a 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go @@ -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 { diff --git a/pkg/rtc/types/typesfakes/fake_participant.go b/pkg/rtc/types/typesfakes/fake_participant.go index 19498c9c3..652c8bf14 100644 --- a/pkg/rtc/types/typesfakes/fake_participant.go +++ b/pkg/rtc/types/typesfakes/fake_participant.go @@ -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)] diff --git a/pkg/rtc/utils.go b/pkg/rtc/utils.go index 83d9b786d..fab143262 100644 --- a/pkg/rtc/utils.go +++ b/pkg/rtc/utils.go @@ -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 diff --git a/pkg/service/agent_dispatch_service.go b/pkg/service/agent_dispatch_service.go index b261c2d4b..b15bc55a3 100644 --- a/pkg/service/agent_dispatch_service.go +++ b/pkg/service/agent_dispatch_service.go @@ -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) } diff --git a/pkg/service/agentservice.go b/pkg/service/agentservice.go index 8481a1e3d..5f95f0722 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agentservice.go @@ -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() + } } } diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 172a7752e..ce850df6e 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -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 } } diff --git a/pkg/service/roommanager_service.go b/pkg/service/roommanager_service.go index 0b5ce6d79..d8ea05433 100644 --- a/pkg/service/roommanager_service.go +++ b/pkg/service/roommanager_service.go @@ -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 diff --git a/pkg/service/roommanager_service_test.go b/pkg/service/roommanager_service_test.go new file mode 100644 index 000000000..e0d588080 --- /dev/null +++ b/pkg/service/roommanager_service_test.go @@ -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") +} diff --git a/pkg/service/roomservice.go b/pkg/service/roomservice.go index 3579eecc2..2cdf373a2 100644 --- a/pkg/service/roomservice.go +++ b/pkg/service/roomservice.go @@ -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 { diff --git a/pkg/service/roomservice_test.go b/pkg/service/roomservice_test.go index d34d5103e..5b27f2fdf 100644 --- a/pkg/service/roomservice_test.go +++ b/pkg/service/roomservice_test.go @@ -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{} diff --git a/pkg/service/rtcservice.go b/pkg/service/rtcservice.go index d4f83e7cb..84082f355 100644 --- a/pkg/service/rtcservice.go +++ b/pkg/service/rtcservice.go @@ -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() + } } } diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 45ebcf4d2..b64fbb063 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -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) diff --git a/pkg/sfu/buffer/buffer_base.go b/pkg/sfu/buffer/buffer_base.go index 12a264c48..2a1376da9 100644 --- a/pkg/sfu/buffer/buffer_base.go +++ b/pkg/sfu/buffer/buffer_base.go @@ -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, diff --git a/pkg/sfu/connectionquality/scorer.go b/pkg/sfu/connectionquality/scorer.go index b415f75a0..471b291b6 100644 --- a/pkg/sfu/connectionquality/scorer.go +++ b/pkg/sfu/connectionquality/scorer.go @@ -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") } } diff --git a/pkg/sfu/datachannel/datachannel_writer_test.go b/pkg/sfu/datachannel/datachannel_writer_test.go index 9b99b8929..3bdcefbf4 100644 --- a/pkg/sfu/datachannel/datachannel_writer_test.go +++ b/pkg/sfu/datachannel/datachannel_writer_test.go @@ -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" diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 71832629d..088084804 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -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) + } + } } } diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 4fac35962..398a2d2b7 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -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 diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index cca1e69be..513fbe40e 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -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) +} diff --git a/pkg/sfu/forwardstats.go b/pkg/sfu/forwardstats.go index dd41d0bb0..2ad8497a7 100644 --- a/pkg/sfu/forwardstats.go +++ b/pkg/sfu/forwardstats.go @@ -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())) +} diff --git a/pkg/sfu/forwardstats_test.go b/pkg/sfu/forwardstats_test.go new file mode 100644 index 000000000..152404978 --- /dev/null +++ b/pkg/sfu/forwardstats_test.go @@ -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)) + } + }) +} diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index d056d252a..5bfcaf3e5 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -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", diff --git a/pkg/sfu/testutils/data.go b/pkg/sfu/testutils/data.go index 34b1fca62..9b8a1386e 100644 --- a/pkg/sfu/testutils/data.go +++ b/pkg/sfu/testutils/data.go @@ -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() diff --git a/pkg/sfu/videolayerselector/base.go b/pkg/sfu/videolayerselector/base.go index 181e5d3fb..9fc74c16a 100644 --- a/pkg/sfu/videolayerselector/base.go +++ b/pkg/sfu/videolayerselector/base.go @@ -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 } diff --git a/pkg/sfu/videolayerselector/simulcast.go b/pkg/sfu/videolayerselector/simulcast.go index 664b2a706..9f0770b7a 100644 --- a/pkg/sfu/videolayerselector/simulcast.go +++ b/pkg/sfu/videolayerselector/simulcast.go @@ -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 { diff --git a/pkg/sfu/videolayerselector/simulcast_test.go b/pkg/sfu/videolayerselector/simulcast_test.go new file mode 100644 index 000000000..878db5eec --- /dev/null +++ b/pkg/sfu/videolayerselector/simulcast_test.go @@ -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) +} diff --git a/pkg/sfu/videolayerselector/videolayerselector.go b/pkg/sfu/videolayerselector/videolayerselector.go index 883e46aea..72a6e2c81 100644 --- a/pkg/sfu/videolayerselector/videolayerselector.go +++ b/pkg/sfu/videolayerselector/videolayerselector.go @@ -37,6 +37,7 @@ type VideoLayerSelector interface { IsOvershootOkay() bool SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerSelector) + SetEnableStartAtDesiredQuality(enable bool) SetMax(maxLayer buffer.VideoLayer) SetMaxSpatial(layer int32) diff --git a/pkg/telemetry/prometheus/node.go b/pkg/telemetry/prometheus/node.go index c3a3fc3ca..0214d8e8e 100644 --- a/pkg/telemetry/prometheus/node.go +++ b/pkg/telemetry/prometheus/node.go @@ -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 { diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index fbb1b34a8..80ec5d693 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -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)) } } diff --git a/pkg/telemetry/prometheus/rooms.go b/pkg/telemetry/prometheus/rooms.go index 4001c24d9..d67a2c59d 100644 --- a/pkg/telemetry/prometheus/rooms.go +++ b/pkg/telemetry/prometheus/rooms.go @@ -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())) } diff --git a/test/integration_helpers.go b/test/integration_helpers.go index 3ccde58d9..367294e46 100644 --- a/test/integration_helpers.go +++ b/test/integration_helpers.go @@ -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 { diff --git a/test/multinode_test.go b/test/multinode_test.go index 9b4d5acea..21f4294c6 100644 --- a/test/multinode_test.go +++ b/test/multinode_test.go @@ -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 "" + }) + }) + } +} diff --git a/test/singlenode_test.go b/test/singlenode_test.go index cf09473f3..6d88f4d8a 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -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 "" + }) + }) + } +} diff --git a/version/version.go b/version/version.go index fbc9dc1e2..2ee36a37b 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.1" +const Version = "1.13.3"