diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index b11478cd9..408602152 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -28,8 +28,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: shogo82148/actions-setup-redis@2f3253b148c73d7a0682eae73e862b777a4fa74e # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shogo82148/actions-setup-redis@88a1fb308aae95aa485bf3da667c9d6e61be4c53 # v1.58.0 with: redis-version: "6.x" auto-start: true @@ -65,9 +65,9 @@ jobs: run: go run github.com/magefile/mage build - name: Lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.11.4 + version: v2.13.2 - name: Test run: | @@ -76,7 +76,7 @@ jobs: # Upload the original go test log as an artifact for later review. - name: Upload test log - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: test-log diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 72a510135..43f95922f 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -29,10 +29,10 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Docker meta id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: # list of Docker images to use as base name for tags images: | @@ -67,18 +67,18 @@ jobs: run: go run github.com/magefile/mage generate - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push id: docker_build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c19e14113..ab3adf275 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Fetch all tags run: git fetch --force --tags @@ -47,7 +47,7 @@ jobs: go-version: ${{ steps.go-version.outputs.version }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser version: '~> v2' diff --git a/.github/workflows/slack-notifier.yaml b/.github/workflows/slack-notifier.yaml index 7770345bf..e550807d4 100644 --- a/.github/workflows/slack-notifier.yaml +++ b/.github/workflows/slack-notifier.yaml @@ -19,7 +19,7 @@ jobs: notify-devs: runs-on: ubuntu-latest steps: - - uses: livekit/slack-notifier-action@main + - uses: livekit/slack-notifier-action@34d9e973391fe5e4afacba015c4d144eb912e7ea # main with: config_json: ${{ secrets.SLACK_NOTIFY_CONFIG_JSON }} slack_token: ${{ secrets.SLACK_PR_NOTIFIER_TOKEN }} diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml index 184f0de04..bb7a4b5ca 100644 --- a/.github/workflows/test-server-docker.yaml +++ b/.github/workflows/test-server-docker.yaml @@ -30,19 +30,19 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to DockerHub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: cmd/test-server/Dockerfile diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile new file mode 100644 index 000000000..d62476856 --- /dev/null +++ b/.oss-scanner/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. + +# Build image for Anthropic's OSS Scanner. Keep the Go version at or above go.mod's: GOTOOLCHAIN=local won't fetch one. +FROM golang:1.26-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + GOTOOLCHAIN=local + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential ca-certificates git redis-server \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . /src + +RUN go mod download && go mod verify +RUN go build ./... && go build -o /usr/local/bin/livekit-server ./cmd/server + +# Tests use a local redis on :6379; pkg/service also wants a docker daemon unless -docker=false. +RUN redis-server --daemonize yes \ + && go test -count=1 -timeout 20m $(go list ./... | grep -v '/pkg/service$') \ + && go test -count=1 -timeout 10m ./pkg/service -docker=false \ + || echo 'WARNING: baseline tests failed' + +ENV GOPROXY=off \ + GOSUMDB=off + +CMD ["bash"] diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md new file mode 100644 index 000000000..0302c7337 --- /dev/null +++ b/.oss-scanner/threat_model.md @@ -0,0 +1,84 @@ +# LiveKit server threat model + +LiveKit server is a WebRTC SFU written in Go. Clients join rooms with a signed +access token, publish and subscribe to audio, video, and data tracks, and exchange +signalling over WebSocket or HTTP. Operators manage rooms, participants, egress, +ingress, SIP, and agent dispatch through a Twirp API authenticated by API +key/secret. The server also embeds a TURN server. + +## Scope and trust boundaries + +Audit the server on `master`: signalling (`pkg/service`, `pkg/rtc/signalling`), +room and participant state (`pkg/rtc`), media forwarding (`pkg/sfu`), routing +(`pkg/routing`), the agent endpoint (`pkg/agent`), WHIP, WebTransport, and TURN. + +Untrusted parties: + +- Unauthenticated network clients, against every HTTP, WebSocket, WebTransport, + TURN, and UDP/TCP media port. +- Participants holding a valid token for one room. Their signalling messages, + SDP, ICE candidates, RTP/RTCP, data-channel packets, metadata, and attributes + are untrusted. +- Holders of tokens with restricted grants (subscribe-only, no data publish, + hidden, limited `canPublishSources`, and similar). + +Trusted: the operator, the server config file, the API secret, and redis. +Treat a compromised redis or a leaked API secret as out of scope. + +`pion/dtls` is enrolled separately. Report DTLS bugs there, unless LiveKit +misuses the library. + +## Security properties + +- **Token and grant enforcement:** a token for one room must not reach another + room. Grants must be enforced on every signalling path, including permission + updates pushed after join. A participant must not impersonate another identity + or act on another participant's tracks. +- **Media and data isolation:** a participant must not receive tracks or data + packets they are not subscribed to or permitted to see. Data packets with + destination identities must reach only those identities. +- **TURN:** relay allocations must not reach arbitrary hosts or ports beyond the + configured relay policy. +- **Parsing:** SDP, RTP header extensions, RTCP, codec payload munging, and + protobuf signalling must not panic, corrupt state, or read out of bounds on + malformed input. +- **Resource use:** one participant or unauthenticated client must not exhaust + memory, goroutines, or CPU for the whole process. + +## Severity guidance + +- **Critical:** unauthenticated remote code execution, or an authentication or + room-isolation bypass that needs no valid token. +- **High:** cross-room or cross-participant access to media or data, privilege + escalation beyond the token's grants, or a remote crash of the whole process + that needs at most an ordinary participant token. +- **Medium:** denial of service that takes significant traffic, or affects only + the attacker's own room. Information leaks of metadata. +- **Low / not a finding:** a participant degrading their own session; behavior + needing the API secret, operator config, or redis access; bandwidth estimation + or quality tuning; packet loss and reordering handled as designed. + +## Testing + +The checkout is at `/src`. The server binary is at `/usr/local/bin/livekit-server`. +Go dependencies are cached. `GOPROXY=off` and `GOSUMDB=off` prevent downloads. +Tests expect redis on `localhost:6379`. Start it first. No docker daemon is +available, so pass `-docker=false` to `pkg/service`. + +```sh +cd /src +redis-server --daemonize yes +go test -count=1 $(go list ./... | grep -v '/pkg/service$') +go test -count=1 ./pkg/service -docker=false +go test -race -count=1 ./pkg/rtc/... ./pkg/sfu/... +``` + +`test/` holds end-to-end tests that run real server instances against local +clients. Use them for signalling or permission reproducers. Use unit tests in +the affected package for parsing bugs. + +## Reports + +State the attacker's required access (none, any valid token, or a specific +grant), the entry point, and the demonstrated impact. Provide a minimal +reproducer, preferably a Go test. diff --git a/CHANGELOG.md b/CHANGELOG.md index 864055f60..b0d90cf18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,162 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.10] - 2026-10-14 + +### Changed + +- sfu: forward the dependency descriptor for VP8 simulcast (#4987) +- sfu: keep VP8 descriptor frame numbers across migration (#4989) +- sfu: filter VP9 and AV1 simulcast temporal layers with the dependency descriptor (#4991) +- sfu: filter VP8 temporal layers by frame when the dependency descriptor is sent (#4976) +- sfu: switch VP8 temporal layers up with the dependency descriptor (#4974) +- sfu: pad the slot of a filtered packet that follows a sequence number gap (#4983) +- sfu: send padding in the slot of a dropped out-of-order packet (#4979) +- sfu: parse VP8 and VP9 payload descriptors without an allocation (#4981) +- DownTrack: arm key frame requester timer only while a layer lock is pending (#4980) +- turn: refuse TCP relays and limit relays to this host to media ports (#4992) +- agent endpoints: address an unnamed deployment as "_" (#4997) +- Add OSS Scanner build image and threat model (#4995) +- Update golang Docker tag to v1.27.1 (#4985) +- Update alpine Docker tag to v3.24.2 (#4984) +- Update module github.com/maxbrunsfeld/counterfeiter/v6 to v6.13.0 (#4915) + +### Fixed + +- Send NACKs when processing RTX packets (#5000) +- sfu: send padding-only packets with no payload (#4993) +- rtc: release timed out pending tracks at the pending track limit (#4998) +- Fix nil pointer dereference in IOInfoService.UpdateIngressState (#4806) +- rtc: keep update cache eviction order when listing recent disconnects (#4975) +- fix(cli): return non-zero exit status on command errors (#4888) +- Disable AV1 for Firefox clients before 157 (#4962) + +## [1.13.9] - 2026-10-07 + +### Changed + +- bump protocol and psrpc deps (#4958) +- sfu/buffer: parse the dependency descriptor without allocating (#4953) +- sfu/buffer: stop allocating per ingested packet (#4951) + +### Fixed + +- v1 path: check for subscriber_allow_pause presence before using it (#4957) +- Cap forward-jump loops in SelectorDecisionCache (#4952) + +## [1.13.8] - 2026-10-06 + +### Added + +- agent: HTTP endpoints data plane (#4779) +- use sender report pass through setting to forward abs-capture-time verbatim (#4812) +- Add sdk lable to session start time and participant join (#4885) +- Add a local participant method to get recently disconnected (#4911) +- service: start room composite egress configured in a room preset (#4926) + +### Changed + +- Update github workflows (#4446) +- Reduce descriptor marshal allocations on Go 1.26.4/linux-amd64 (#4681) +- config: reject unknown send_side_bwe_pacer, warn on unwired fallback (#4804) +- Update github.com/livekit/protocol digest to a879e94 (#4864) +- Update module github.com/gotesttools/gotestfmt/v2 to v2.5.0 (#4865) +- Update module github.com/urfave/cli/v3 to v3.11.0 (#4867) +- Update module golang.org/x/mod to v0.41.0 (#4868) +- sfu: return VP8 munged header by value to avoid per-packet allocation (#4871) +- sfu: patch pacer header extensions without per-packet allocation (#4872) +- Update pion/sdp to get lesser SDP retention (#4873) +- sfu: skip pacer allocation assertion under the race detector (#4874) +- sfu: broadcast RTP to down tracks without per-packet allocations (#4875) +- Track action versions and stop quarantining our own actions (#4877) +- test: fix two data races in the test suites (#4878) +- Bump go.opentelemetry.io/otel/sdk (#4879) +- deps: update psrpc to v0.8.0 (#4882) +- Update module github.com/moby/moby/client to v0.6.0 (#4883) +- Update module golang.org/x/sync to v0.23.0 (#4884) +- Propagate SIP_TRUNK_FAILURE (#4890) +- ccutils: use millisecond resolution for probe interval backoff (#4891) +- Increase receiver loadbalance threshold when batch io enabled (#4899) +- rtc: parse each remote offer once per negotiation (#4904) +- Bump protocol (#4909) +- rtc: split EndSession out of MoveToRoom (#4917) +- telemetry: drop the stats worker re-key path (#4918) +- sfu: report forwarding latency as p90 instead of the mean (#4920) +- rtc: accept ICE Completed in ICE-restart connect assertions (#4921) +- rtc: measure data blob keys by their content, not their text form (#4923) +- Remove deprecatred way of setting prometheus from the config file (#4928) +- test: give mock participants a connection quality, close test rooms (#4933) +- update warp dep (#4943) + +### Fixed + +- Do not add down tracks after receiver close (#4857) +- Do not request media sections for transceivers that cannot send (#4892) +- Deregister agent job when the server terminates it (#4893) +- sfu: don't hold bindLock across the blank-frame flush in CloseWithFlush (#4895) +- dynacast: re-send subscribed qualities after every publisher answer (#4907) +- agent: release JobTerminate handler when job ends before registration (#4908) +- Report disconnected for older disconnected participants. (#4910) +- rtc: record a room's departure when the participant leaves it (#4913) +- update deps to fix warp issue (#4916) +- sfu: fix data stats active window bitrate and duration units (#4922) +- fix: reject instead of drop for no dispatch rule (#4927) +- fix: skip RTX/FEC when picking a fallback video codec (#4930) +- fix: do not read invalid delta (#4935) +- fix: use the fast debounce for the first negotiation (#4936) +- fix: skip DTLS HelloVerifyRequest (#4937) +- config: parse generated duration flags as durations (#4939) +- fix: use the most common recent packet rate in connection quality (#4940) +- fix: copy RED history packets instead of keeping pointers (#4941) +- fix: keep one subscription when first subscribes race (#4944) +- bump protocol to get unlock on no key fix (#4945) +- fix: count leftover upstream loss after RTX repairs (#4946) +- fix: switch VP8 temporal layer up only at a layer sync frame (#4947) +- fix: reconcile everything when the reconcile queue overflows (#4948) +- fix: enable H.264 publish for desktop Linux Firefox (#4949) + +## [1.13.7] - 2026-09-14 + +### Added + +- Participant kind agent details (#4809) +- report a reason on room-ended telemetry (#4815) +- telemetry: support roomID change for a participant (#4816) +- log telemetry guard details (#4817) +- ingress: add opt-in support for udp:// URL pull ingress (#4810) +- Support VP9/AV1 simulcast (#4830) +- add test-server support for compressed join requests (#4834) +- wire psrpc bus compression into the message bus constructor (#4844) +- turn: accept PROXY protocol on the TCP listener (#4852) +- telemetry: add visibility into stats worker reference underflow (#4856) +- rtc: add GetSubscribedDataTracks to LocalParticipant (#4862) + +### Changed + +- Skip the docker-backed service tests when there is no docker (#4799) +- Wait for the callbacks these tests assert on. (#4803) +- utils: make Median generic, overflow-safe, and add tests (#4553) +- warp log (#4811) +- Remove duplication within cpuload and sysload (#4818) +- Update github.com/livekit/protocol digest to a469dd4 (#4820) +- update ice fork for warp (#4824) +- Move data track packet serialization to protocol (#4829) +- Update README to include snippet about Agents (#4808) +- protocol update for SDP unmarshal hardening (#4836) +- Update golang Docker tag to v1.26.7 (#4846) +- README rewrite (#4837) + +### Fixed + +- Set up track info properly for dummy receiver. (#4807) +- Do not escape the ICE server URI in the WHIP Link header (#4795) +- fix: hold signal messages until the ReconnectResponse goes out (#4827) +- Fix/reconcile data track subscription deadlock (#4843) +- telemetry: drop unused getCPUStats, unblocking darwin builds without cgo (#4841) +- update psrpc for subscription close goroutine leak fix (#4859) +- telemetry: do not recreate a stats worker for a released guard (#4860) +- Get participant by authed identity in WHIP participant service. (#4861) + ## [1.13.6] - 2026-08-26 ### Added diff --git a/Dockerfile b/Dockerfile index c71b191d3..4d9920a2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ # This image is also the single source of truth for the Go toolchain: CI reads the # version out of this line (see .github/scripts/go-version.sh) so tests and images # always run the same runtime. -FROM golang:1.26.6-alpine3.24@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS builder +FROM golang:1.27.1-alpine3.24@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS builder ARG TARGETPLATFORM ARG TARGETARCH @@ -44,7 +44,7 @@ COPY version/ version/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-server ./cmd/server -FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 # Pull the latest security patches for base packages within the pinned Alpine # release. The digest above is only refreshed by a Renovate PR, so without this diff --git a/README.md b/README.md index c49da91db..7d74cd3bf 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ -# LiveKit: Real-time video, audio and data for developers +# LiveKit: Realtime infrastructure for voice, video, and AI agents -[LiveKit](https://livekit.io) is an open source project that provides scalable, multi-user conferencing based on WebRTC. -It's designed to provide everything you need to build real-time video audio data capabilities in your applications. +[LiveKit](https://livekit.com) is an open source platform for building voice, video, and physical AI agents. +This repository is the LiveKit server: a scalable, distributed WebRTC SFU that moves realtime audio, video, and +data between people, devices, and AI models. The SDKs, agents frameworks, and companion services are linked in +the table at the bottom of this page. LiveKit's server is written in Go, using the awesome [Pion WebRTC](https://github.com/pion/webrtc) implementation. @@ -23,182 +25,86 @@ LiveKit's server is written in Go, using the awesome [Pion WebRTC](https://githu [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/livekit/livekit/buildtest.yaml?branch=master)](https://github.com/livekit/livekit/actions/workflows/buildtest.yaml) [![License](https://img.shields.io/github/license/livekit/livekit)](https://github.com/livekit/livekit/blob/master/LICENSE) + +> [!IMPORTANT] +> If you're building Voice AI, [LiveKit Agents](https://github.com/livekit/agents) is the SDK for code-first realtime voice agents. STT, LLM, TTS, turn detection, [expressive speech](https://docs.livekit.io/agents/models/tts/expressive/), [keyterm accuracy](https://docs.livekit.io/agents/models/stt/keyterms/), tool usage, and telephony all come bundled in the framework. It's available in both [Python](https://github.com/livekit/agents) and [Node.js](https://github.com/livekit/agents-js). +> +> ```python +> # agent.py +> from livekit import agents +> from livekit.agents import Agent, AgentServer, AgentSession, STTContextOptions, TurnHandlingOptions, inference +> +> server = AgentServer() +> +> +> @server.rtc_session(agent_name="my-agent") +> async def my_agent(ctx: agents.JobContext): +> session = AgentSession( +> stt=inference.STT(model="deepgram/nova-3", language="multi"), +> llm=inference.LLM(model="google/gemma-4-31b-it"), +> tts=inference.TTS(model="inworld/inworld-tts-2", voice="Ashley"), +> turn_handling=TurnHandlingOptions(turn_detection=inference.TurnDetector()), +> stt_context_options=STTContextOptions(keyterms=["LiveKit", "Acme Corp"]), +> expressive=True, +> ) +> await session.start(room=ctx.room, agent=Agent(instructions="You are a helpful voice AI assistant.")) +> await session.generate_reply(instructions="Greet the user and offer your assistance.") +> +> +> if __name__ == "__main__": +> agents.cli.run_app(server) +> ``` +> +> Models come from [LiveKit Inference](https://docs.livekit.io/agents/models/) with no per-provider API keys, and LiveKit Cloud handles [deployment](https://docs.livekit.io/deploy/agents/) and [observability](https://docs.livekit.io/deploy/observability/). Visit the docs for more info at [docs.livekit.io/agents](https://docs.livekit.io/agents/). + + +## Used in production by + +LiveKit carries billions of calls a year for companies including Salesforce, Nvidia, Oracle, SAP, +Deutsche Telekom, Spotify, Tinder, Coursera, Headspace, Skydio, Retell, Decagon, Cresta, and HeyGen. Read how +[Assort Health](https://livekit.com/customers/assort-health), [Playback](https://livekit.com/customers/playback), and +[Polymath Robotics](https://livekit.com/customers/polymath) use it, or see [more customers](https://livekit.com/customers). + ## Features - Scalable, distributed WebRTC SFU (Selective Forwarding Unit) -- Modern, full-featured client SDKs +- People, devices, and AI agents join the same room as participants, with + [agent dispatch](https://docs.livekit.io/agents/server/agent-dispatch/) to route agents in automatically or on demand +- Modern, full-featured SDKs for web, mobile, desktop, embedded, and server - Built for production, supports JWT authentication - Robust networking and connectivity, UDP/TCP/TURN - Easy to deploy: single binary, Docker or Kubernetes - Advanced features including: - - [speaker detection](https://docs.livekit.io/home/client/tracks/subscribe/#speaker-detection) - - [simulcast](https://docs.livekit.io/home/client/tracks/publish/#video-simulcast) - - [end-to-end optimizations](https://blog.livekit.io/livekit-one-dot-zero/) - - [selective subscription](https://docs.livekit.io/home/client/tracks/subscribe/#selective-subscription) - - [moderation APIs](https://docs.livekit.io/home/server/managing-participants/) - - end-to-end encryption + - [speaker detection](https://docs.livekit.io/transport/media/subscribe/) + - [simulcast](https://docs.livekit.io/transport/media/publish/) + - [selective subscription](https://docs.livekit.io/transport/media/subscribe/) + - [moderation APIs](https://docs.livekit.io/intro/basics/rooms-participants-tracks/participants/) + - [end-to-end encryption](https://docs.livekit.io/transport/media/encryption/) - SVC codecs (VP9, AV1) - - [webhooks](https://docs.livekit.io/home/server/webhooks/) - - [distributed and multi-region](https://docs.livekit.io/home/self-hosting/distributed/) + - [data tracks](https://docs.livekit.io/transport/data/data-tracks/) for low-latency telemetry and teleoperation + - [telephony](https://docs.livekit.io/telephony/) over SIP + - [webhooks](https://docs.livekit.io/intro/basics/rooms-participants-tracks/webhooks-events/) + - [distributed and multi-region](https://docs.livekit.io/transport/self-hosting/distributed/) ## Documentation & Guides https://docs.livekit.io +Working with a coding agent? Give it the [LiveKit Docs MCP server](https://docs.livekit.io/mcp/), or start with the +[coding agents guide](https://docs.livekit.io/intro/coding-agents/). + ## Live Demos +- [Talk to a voice agent](https://livekit.com) built with LiveKit Agents - [LiveKit Meet](https://meet.livekit.io) ([source](https://github.com/livekit-examples/meet)) - [Spatial Audio](https://spatial-audio-demo.livekit.io/) ([source](https://github.com/livekit-examples/spatial-audio)) - Livestreaming from OBS Studio ([source](https://github.com/livekit-examples/livestream)) -- [AI voice assistant using ChatGPT](https://livekit.io/kitt) ([source](https://github.com/livekit-examples/kitt)) - -## Ecosystem - -- [Agents](https://github.com/livekit/agents): build real-time multimodal AI applications with programmable backend participants -- [Egress](https://github.com/livekit/egress): record or multi-stream rooms and export individual tracks -- [Ingress](https://github.com/livekit/ingress): ingest streams from external sources like RTMP, WHIP, HLS, or OBS Studio - -## SDKs & Tools - -### Client SDKs - -Client SDKs enable your frontend to include interactive, multi-user experiences. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LanguageRepo - Declarative UI - Links
JavaScript (TypeScript) - client-sdk-js - - React - - docs - | - JS example - | - React example -
Swift (iOS / MacOS) - client-sdk-swift - Swift UI - docs - | - example -
Kotlin (Android) - client-sdk-android - Compose - docs - | - example - | - Compose example -
Flutter (all platforms) - client-sdk-flutter - native - docs - | - example -
Unity WebGL - client-sdk-unity-web - - docs -
React Native (beta) - client-sdk-react-native - native
Rust - client-sdk-rust -
- -### Server SDKs - -Server SDKs enable your backend to generate [access tokens](https://docs.livekit.io/home/get-started/authentication/), -call [server APIs](https://docs.livekit.io/reference/server/server-apis/), and -receive [webhooks](https://docs.livekit.io/home/server/webhooks/). In addition, the Go SDK includes client capabilities, -enabling you to build automations that behave like end-users. - -| Language | Repo | Docs | -| :---------------------- | :-------------------------------------------------------------------------------------- | :---------------------------------------------------------- | -| Go | [server-sdk-go](https://github.com/livekit/server-sdk-go) | [docs](https://pkg.go.dev/github.com/livekit/server-sdk-go) | -| JavaScript (TypeScript) | [server-sdk-js](https://github.com/livekit/server-sdk-js) | [docs](https://docs.livekit.io/server-sdk-js/) | -| Ruby | [server-sdk-ruby](https://github.com/livekit/server-sdk-ruby) | | -| Java (Kotlin) | [server-sdk-kotlin](https://github.com/livekit/server-sdk-kotlin) | | -| Python (community) | [python-sdks](https://github.com/livekit/python-sdks) | | -| PHP (community) | [agence104/livekit-server-sdk-php](https://github.com/agence104/livekit-server-sdk-php) | | - -### Tools - -- [CLI](https://github.com/livekit/livekit-cli) - command line interface & load tester -- [Docker image](https://hub.docker.com/r/livekit/livekit-server) -- [Helm charts](https://github.com/livekit/livekit-helm) ## Install > [!TIP] > We recommend installing [LiveKit CLI](https://github.com/livekit/livekit-cli) along with the server. It lets you access -> server APIs, create tokens, and generate test traffic. +> server APIs, create tokens, generate test traffic, and scaffold and deploy agents. The following will install LiveKit's media server: @@ -229,11 +135,11 @@ API Key: devkey API Secret: secret ``` -To customize your setup for production, refer to our [deployment docs](https://docs.livekit.io/deploy/) +To customize your setup for production, refer to our [deployment docs](https://docs.livekit.io/transport/self-hosting/deployment/) ### Creating access token -A user connecting to a LiveKit room requires an [access token](https://docs.livekit.io/home/get-started/authentication/#creating-a-token). Access +A user connecting to a LiveKit room requires an [access token](https://docs.livekit.io/frontends/build/authentication/). Access tokens (JWT) encode the user's identity and the room permissions they've been granted. You can generate a token with our CLI: @@ -247,7 +153,7 @@ lk token create \ ### Test with example app Head over to our [example app](https://example.livekit.io) and enter a generated token to connect to your LiveKit -server. This app is built with our [React SDK](https://github.com/livekit/livekit-react). +server. Once connected, your video and audio are now being published to your new LiveKit instance! @@ -266,24 +172,34 @@ This command publishes a looped demo video to a room. Due to how the video clip there's a slight delay before the browser has sufficient data to begin rendering frames. This is an artifact of the simulation. +### Adding an agent + +Agents join rooms as participants, the same way a browser or a phone does. Follow the +[Voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/) to build one. An agent connects to a self-hosted +server the same way it connects to LiveKit Cloud; when running without Cloud, use +[model plugins](https://docs.livekit.io/agents/models/#plugins) in place of LiveKit Inference. + ## Deployment ### Use LiveKit Cloud -LiveKit Cloud is the fastest and most reliable way to run LiveKit. Every project gets free monthly bandwidth and -transcoding credits. +LiveKit Cloud is the fastest and most reliable way to run LiveKit. It runs in 19+ regions with 99.99% uptime and adds +agent hosting, model inference, telephony, and observability on top of the server. The Build plan is free, with no +credit card required. Sign up for [LiveKit Cloud](https://cloud.livekit.io/). ### Self-host -Read our [deployment docs](https://docs.livekit.io/transport/self-hosting/) for more information. +Read our [deployment docs](https://docs.livekit.io/transport/self-hosting/) for more information. Official +[Docker images](https://hub.docker.com/r/livekit/livekit-server) and [Helm charts](https://github.com/livekit/livekit-helm) +are available. ## Building from source Pre-requisites: -- Go 1.23+ is installed +- Go 1.26+ is installed - GOPATH/bin is in your PATH Then run @@ -298,7 +214,8 @@ mage ## Contributing We welcome your contributions toward improving LiveKit! Please join us -[on Slack](http://livekit.io/join-slack) to discuss your ideas and/or PRs. +[on Slack](http://livekit.io/join-slack) or in the [Developer Community](https://community.livekit.io) to discuss your +ideas and/or PRs. ## License diff --git a/cmd/server/main.go b/cmd/server/main.go index dde5b0adc..b86503065 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -189,6 +189,7 @@ func main() { if err := cmd.Run(context.Background(), os.Args); err != nil { fmt.Println(err) + os.Exit(1) } } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index dc82bf086..b1c3669dd 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -16,11 +16,80 @@ package main import ( "os" + "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/version" ) +func TestMain(m *testing.M) { + if os.Getenv("LIVEKIT_TEST_RUN_MAIN") == "1" { + main() + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestMainExitCode(t *testing.T) { + testBinary, err := os.Executable() + require.NoError(t, err) + missingConfig := filepath.Join(t.TempDir(), "missing.yaml") + + for _, tt := range []struct { + name string + args []string + exitCode int + output string + }{ + { + name: "missing config", + args: []string{"--config", missingConfig, "ports"}, + exitCode: 1, + output: missingConfig, + }, + { + name: "unknown flag", + args: []string{"--unknown-flag"}, + exitCode: 1, + output: "flag provided but not defined", + }, + { + name: "invalid flag value", + args: []string{"--dev=invalid"}, + exitCode: 1, + output: "invalid value", + }, + { + name: "help", + args: []string{"--help"}, + output: "High performance WebRTC server", + }, + { + name: "version", + args: []string{"--version"}, + output: version.Version, + }, + } { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.CommandContext(t.Context(), testBinary, tt.args...) + // Run the real entry point without inheriting server configuration. + cmd.Env = []string{"LIVEKIT_TEST_RUN_MAIN=1"} + output, err := cmd.CombinedOutput() + if tt.exitCode == 0 { + require.NoError(t, err, string(output)) + } else { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, string(output)) + require.Equal(t, tt.exitCode, exitErr.ExitCode(), string(output)) + } + require.Contains(t, string(output), tt.output) + }) + } +} + type testStruct struct { configFileName string configBody string diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile index e0dc826db..bbac501ec 100644 --- a/cmd/test-server/Dockerfile +++ b/cmd/test-server/Dockerfile @@ -17,7 +17,7 @@ # # Pinned by digest so the build is reproducible even if the tag is republished. # The tag is kept alongside it for readability; Renovate updates both together. -FROM golang:1.26.6-alpine3.24@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS builder +FROM golang:1.27.1-alpine3.24@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS builder ARG TARGETARCH @@ -37,7 +37,7 @@ COPY version/ version/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server -FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 # Pull the latest security patches for base packages within the pinned Alpine # release; the digest above is only refreshed by a Renovate PR. See the note in diff --git a/cmd/test-server/signal.go b/cmd/test-server/signal.go index 376359ab4..22d52651b 100644 --- a/cmd/test-server/signal.go +++ b/cmd/test-server/signal.go @@ -15,7 +15,11 @@ package main import ( + "bytes" + "compress/gzip" + "encoding/base64" "encoding/json" + "io" "net/http" "strconv" "strings" @@ -216,7 +220,7 @@ func (h *mockHandler) handleSignal(w http.ResponseWriter, r *http.Request) { return } - reconnect := r.URL.Query().Get("reconnect") == "1" + reconnect := r.URL.Query().Get("reconnect") == "1" || joinRequestSaysReconnect(r.URL.Query().Get("join_request")) conn, err := signalUpgrader.Upgrade(w, r, nil) if err != nil { @@ -416,6 +420,45 @@ func reconnectResponse(regionIndex int) *livekit.SignalResponse { } } +// joinRequestSaysReconnect unpacks the v1 `join_request` param and reports its +// Reconnect flag. Any decode error is ignored (treated as non-reconnect); this +// is a test mock, not a validator. +func joinRequestSaysReconnect(param string) bool { + if param == "" { + return false + } + wrappedBytes, err := base64.URLEncoding.DecodeString(param) + if err != nil { + return false + } + wrapped := &livekit.WrappedJoinRequest{} + if err := proto.Unmarshal(wrappedBytes, wrapped); err != nil { + return false + } + var joinBytes []byte + switch wrapped.Compression { + case livekit.WrappedJoinRequest_NONE: + joinBytes = wrapped.JoinRequest + case livekit.WrappedJoinRequest_GZIP: + gz, err := gzip.NewReader(bytes.NewReader(wrapped.JoinRequest)) + if err != nil { + return false + } + defer func() { _ = gz.Close() }() + joinBytes, err = io.ReadAll(gz) + if err != nil { + return false + } + default: + return false + } + join := &livekit.JoinRequest{} + if err := proto.Unmarshal(joinBytes, join); err != nil { + return false + } + return join.Reconnect +} + func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse { return &livekit.SignalResponse{ Message: &livekit.SignalResponse_Leave{ diff --git a/config-sample.yaml b/config-sample.yaml index ddb938353..6ab5ee0f4 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -117,6 +117,16 @@ rtc: # # in the unlikely event of highly congested networks, SFU may choose to pause some tracks # # in order to allow others to stream smoothly. You can disable this behavior here # allow_pause: true + # # use send-side bandwidth estimation instead of relying on receiver reports, default false + # use_send_side_bwe: false + # # how egress is paced when use_send_side_bwe is enabled. one of: + # # no-queue - hand packets to a send worker that drains them without rate + # # limiting, so the sender never blocks on a write (default) + # # pass-through - send each packet inline on the calling goroutine + # # leaky-bucket - accepted, but not currently wired to the bandwidth estimate; it + # # logs a warning and falls back to no-queue. see livekit/livekit#4724 + # # an unrecognized value is rejected at startup. + # send_side_bwe_pacer: no-queue # # FlexFEC-03 forward error correction, disabled by default. # # Accepts FEC from publishers and uses it to repair lost packets before # # forwarding. @@ -182,7 +192,8 @@ rtc: # datachannel_data_track_target_latency: 100ms # when enabled, LiveKit will expose prometheus metrics on :6789/metrics -# prometheus_port: 6789 +# prometheus: +# port: 6789 # expose /debug/pprof (and /debug/goroutine, /debug/rooms) on a dedicated port, # separate from the public signalling port. only enabled when port is set. @@ -273,6 +284,15 @@ keys: # backoff: 500ms # # number of messages to buffer before dropping # buffer_size: 1000 +# # optional gzip compression of bus payloads +# compression: +# # gzip level 1-9; 0 disables. every node on the bus must support +# # compression before enabling it +# quality: 0 +# # payload bytes below which compression is skipped +# threshold: 1024 +# # cap on an inbound payload after decompression, 0 for unlimited +# max_decompressed_size: 0 # customize audio level sensitivity # audio: @@ -313,6 +333,19 @@ keys: # # set external_tls to true if using a L4 load balancer to terminate TLS. when enabled, # # LiveKit expects unencrypted traffic on tls_port, and still advertise tls_port as a TURN/TLS candidate. # external_tls: true +# # set proxy_protocol to true if the proxy or load balancer in front of tls_port does not preserve +# # the client address (it terminates TLS, or dials LiveKit from its own IP) and can send a +# # PROXY protocol v1/v2 header instead. Without it TURN reports the proxy's address to the +# # client as XOR-MAPPED-ADDRESS, which Firefox rejects when it is loopback or wildcard. +# # every connection on tls_port must then carry the header; connections without it are rejected. +# # prefer PROXY protocol v2 on the proxy side: the v1 text header must arrive in a single read. +# proxy_protocol: false +# # proxies whose PROXY header is trusted. connections from any other address are closed, so a +# # client that reaches tls_port directly cannot claim an arbitrary source address. +# # defaults to loopback, for a proxy running on the same host. +# proxy_protocol_trusted_cidrs: +# - 127.0.0.0/8 +# - ::1/128 # # needs to match tls cert domain # domain: turn.myhost.com # # optional (set only if not using external TLS termination) @@ -342,6 +375,15 @@ keys: # rtmp_base_url: "rtmp://my.domain.com/live" # # Prefix used to generate WHIP URLs for WHIP ingress. # whip_base_url: "http://my.domain.com/whip" +# # Allow URL pull ingress from udp:// source URLs. Disabled by default. +# # Only enable this if you trust both the callers allowed to create ingresses and the network the +# # ingress handlers run on. Unlike an http or srt source url, a udp source url doesn't make the +# # handler connect out to the url host: the handler binds a local socket on the address and port +# # taken from the url, and joins the multicast group if one is given. This lets the caller choose +# # which local port the handler binds, feed the session unauthenticated and easily spoofed traffic, +# # and make the handler join arbitrary multicast groups and republish whatever it receives into a +# # room, using the ingress as a relay for streams on the handler's local network. +# enable_udp_url_pull: false # Region of the current node. Required if using regionaware node selector # region: us-west-2 diff --git a/go.mod b/go.mod index 2ed6f1985..5e5d3d78c 100644 --- a/go.mod +++ b/go.mod @@ -20,28 +20,31 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.1 github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 - github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 - github.com/livekit/protocol v1.52.0 - github.com/livekit/psrpc v0.7.7 + github.com/livekit/mediatransportutil v0.0.0-20261014192331-dd8e31a0adc0 + github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819 + github.com/livekit/psrpc v0.8.2 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 - github.com/moby/moby/client v0.5.1 + github.com/moby/moby/client v0.6.0 github.com/olekukonko/tablewriter v1.1.4 github.com/ory/dockertest/v4 v4.0.0 - github.com/pion/datachannel v1.6.2 - github.com/pion/dtls/v3 v3.1.5 + github.com/pion/datachannel v1.6.3 + github.com/pion/dtls/v3 v3.1.9 github.com/pion/ice/v4 v4.4.0 - github.com/pion/interceptor v0.1.47 - github.com/pion/rtcp v1.2.17 + github.com/pion/interceptor v0.1.49 + github.com/pion/rtcp v1.2.19 github.com/pion/rtp v1.10.5 - github.com/pion/sctp v1.11.1 - github.com/pion/sdp/v3 v3.0.19 - github.com/pion/transport/v4 v4.1.0 + github.com/pion/sctp v1.12.0 + github.com/pion/sdp/v3 v3.0.20 + github.com/pion/transport/v4 v4.1.1 github.com/pion/turn/v5 v5.0.13 github.com/pion/webrtc/v4 v4.2.18 + github.com/pires/go-proxyproto v0.15.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.24.1 + github.com/quic-go/quic-go v0.61.0 + github.com/quic-go/webtransport-go v0.12.0 github.com/redis/go-redis/v9 v9.22.0 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.12.1 @@ -50,16 +53,17 @@ require ( github.com/twitchtv/twirp v8.1.3+incompatible github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c github.com/urfave/negroni/v3 v3.1.1 - go.uber.org/atomic v1.11.0 + go.uber.org/atomic v1.12.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 - golang.org/x/mod v0.40.0 - golang.org/x/sync v0.22.0 + golang.org/x/mod v0.41.0 + golang.org/x/sync v0.23.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) require ( + cel.dev/cel-go v0.32.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cilium/ebpf v0.22.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect @@ -67,41 +71,45 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect github.com/fatih/color v1.19.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/gotesttools/gotestfmt/v2 v2.4.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 // indirect - github.com/moby/moby/api v1.55.0 // indirect + github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0 // indirect + github.com/moby/moby/api v1.56.0 // indirect github.com/nyaruka/phonenumbers v1.8.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.3.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect - github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect + github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect + github.com/pion/transport/v5 v5.1.1 // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.46.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect - golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 // indirect + golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 // indirect + golang.org/x/time v0.16.0 // indirect ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 // indirect - buf.build/go/protovalidate v1.3.0 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/go/protovalidate v1.4.0 // indirect buf.build/go/protoyaml v0.7.0 // indirect cel.dev/expr v0.25.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -113,14 +121,13 @@ 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.4 // indirect - github.com/google/cel-go v0.31.0 // 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.19.2 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/lithammer/shortuuid/v4 v4.3.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect @@ -128,32 +135,29 @@ require ( github.com/mdlayher/socket v0.6.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/nats.go v1.53.1 // indirect - github.com/nats-io/nkeys v0.4.16 // indirect - github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pion/logging v0.2.4 - github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/mdns/v2 v2.2.2 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/srtp/v3 v3.0.13 // indirect github.com/pion/stun/v3 v3.1.7 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect - github.com/urfave/cli/v3 v3.10.1 + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/urfave/cli/v3 v3.11.0 github.com/wlynxg/anet v0.0.5 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.49.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/grpc v1.83.2 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.51.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/grpc v1.84.0 // indirect ) tool ( @@ -163,8 +167,8 @@ tool ( golang.org/x/tools/cmd/goimports ) -replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 +replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6 -replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1 +replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.3 -replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.1 +replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.3 diff --git a/go.sum b/go.sum index 326e9a504..33f0341ad 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 h1:Slv0uGxx219srASyiaI5C9cDlyG8kNDcXpTSYcuAeE4= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -buf.build/go/protovalidate v1.3.0 h1:8ITcnZGkAHx6TyhZvro+iET/AyqU8gEWQJK2WsT62ms= -buf.build/go/protovalidate v1.3.0/go.mod h1:82s5g+rFRj1CZPiLv6OTA31jBu2fpq7mLXHwa9mZfEs= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -52,14 +54,16 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.1 h1:eV7lfZ5fVL8d36b8Wogqi/eqm7R/kZcftA9Yiyj+63M= github.com/elliotchance/orderedmap/v3 v3.1.1/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/florianl/go-tc v0.4.8 h1:hgmakUX1Nm0Ba1I0ZkbUl9CH6HbRwqSiwipnpmYp3Es= github.com/florianl/go-tc v0.4.8/go.mod h1:B8GeOEnmrbOnxZtaCvsYJcgIzzmM8c/AIhtfCZsDj3Q= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -83,8 +87,6 @@ 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.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= -github.com/google/cel-go v0.31.0/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= @@ -105,10 +107,10 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gotesttools/gotestfmt/v2 v2.4.1 h1:Ml+KPqPocp/KckpizL+tgsy/dlddI4/z2w6lgS7YIFE= -github.com/gotesttools/gotestfmt/v2 v2.4.1/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4= +github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -139,8 +141,8 @@ 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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -156,20 +158,20 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2AvpXGdWA= github.com/lithammer/shortuuid/v4 v4.3.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= -github.com/livekit/dtls/v3 v3.1.5-warp.1 h1:5jo2qQreDnUrgw6Al1F+9xVyXHvWQ2IaQkjF53FHEyU= -github.com/livekit/dtls/v3 v3.1.5-warp.1/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= -github.com/livekit/ice/v4 v4.4.0-warp.1 h1:P9Y1f02VVx6kkRJxY3jzoS7HnblFbYkgMwiu6iOqCOU= -github.com/livekit/ice/v4 v4.4.0-warp.1/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= +github.com/livekit/dtls/v3 v3.1.5-warp.3 h1:oDUvjCb2ky8KXggOv4k4zTQKlAX7YwlGjBAva2zfgmk= +github.com/livekit/dtls/v3 v3.1.5-warp.3/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/livekit/ice/v4 v4.4.0-warp.3 h1:iSAtEiLeCi/ZKEKOsRrDlBg/BRcyUKpV/NuAXX+dS3o= +github.com/livekit/ice/v4 v4.4.0-warp.3/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= -github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= -github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.0 h1:K7XWd1cJOekMCigjkyBnpmM841J8TX/YWXYWBMK9wYc= -github.com/livekit/protocol v1.52.0/go.mod h1:YUI8xd1bvdEWtUWwgvsIkUFb4Ll98ngsi7ifLdT5HV0= -github.com/livekit/psrpc v0.7.7 h1:eZ/jYlayQ3Y+C/3+NPtwTJBXZo/aN4vJf2MmBK6AfT4= -github.com/livekit/psrpc v0.7.7/go.mod h1:Twno03W8gpTNRpmc2cQwSoxYEo/nJxf+65iMq+9IuHo= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc= +github.com/livekit/mediatransportutil v0.0.0-20261014192331-dd8e31a0adc0 h1:BSLyHr3Lsl71bHGHsfiYNmbohugR2yoaf9HjAZeFJnc= +github.com/livekit/mediatransportutil v0.0.0-20261014192331-dd8e31a0adc0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= +github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819 h1:oZ/dI5RG22t0GalRDU1BJWCMtNAEQVPU+odrRgycKDE= +github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= +github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6 h1:UaGYlQ/Ma73LN12xPDlH3OBYNfOEDTVOtHdRZbHg2dY= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= @@ -180,8 +182,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 h1:V23nK2R2B63g2GhygF9zVGpnigmhvoZoH8d0hrZwMGY= -github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2/go.mod h1:Mr897yU9FmyKaQDPtRlVKibrjz40XXyOHUfyZBPSyZU= +github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0 h1:ybB78iZ/Hkh76F6t+MbN+DP9WFNkLX+tB5JWOctzPyY= +github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0/go.mod h1:pLuSACD770jzP/sacZMoGDxz34d5uiUpo+xdj/pYF/Y= github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43/go.mod h1:+t7E0lkKfbBsebllff1xdTmyJt8lH37niI6kwFk9OTo= github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc= github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= @@ -205,10 +207,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= -github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo= @@ -227,44 +229,46 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= -github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= -github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= -github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= -github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo= -github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pion/datachannel v1.6.3 h1:HZAYLpunVI0xi057ZiANl558d0uSpomcWw4UEPz3qXs= +github.com/pion/datachannel v1.6.3/go.mod h1:oRmkXVVXxkUbqa8MatgdNHW29kvtQY3HUdjJQsfv2GE= +github.com/pion/interceptor v0.1.49 h1:iyBsNHRoLNNXZiJA/DdGvJgdyfS8YWDCzxIAlVjt5nY= +github.com/pion/interceptor v0.1.49/go.mod h1:MZ6PJkja/TCo350HAnBrs/rUIyad9mWjpcvytrf3ViQ= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/mdns/v2 v2.2.2 h1:qq9Kj3PnjF0mfSwd8Icu0CXy5aoYdQKnNUIsGPeUpgk= +github.com/pion/mdns/v2 v2.2.2/go.mod h1:ZX5f0AAH1D6TOjdjvcBORZZHaZuG0t9+br78lHEwiJ4= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= -github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtcp v1.2.19 h1:T79lR2Tfd/7zj+41nsUrftrUEee+Z03NQr9GSPSQ/WU= +github.com/pion/rtcp v1.2.19/go.mod h1:vX7Es6skWYm4oVvOSC5+4WHLYUTZHcbGNmk5wjzMGH4= github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= -github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= -github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= -github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/sctp v1.12.0 h1:AzUdVkVBNyGY1W4crWO1/aOWzMaW3zsVHGrNuAfFQiI= +github.com/pion/sctp v1.12.0/go.mod h1:6k1zrpkrxSwbisitn/AlzDQaa15DR49MFsrMochG/yc= +github.com/pion/sdp/v3 v3.0.20 h1:TS6DViqcmp+49f0+mjw9anbr9xY3vJtsZewxAvlMCRQ= +github.com/pion/sdp/v3 v3.0.20/go.mod h1:slIMXDK5OKj0nhISwjfeN18AzTBCt2LYZq9uPw0cU5Q= github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q= github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY= github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= -github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= -github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo= +github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= +github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8= +github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4= github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -272,14 +276,22 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -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/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/quic-go/webtransport-go v0.12.0 h1:CpnKNwZvdV0LD73xoHO8QaR0NI3llqpWRwnazdZS0sE= +github.com/quic-go/webtransport-go v0.12.0/go.mod h1:GHne8aRFJ24h73pAMrcywXtuaz/ShBXCLXLvG/NPFdU= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= @@ -305,8 +317,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.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= -github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.11.0 h1:P/euJp99kb9p0tlVY+iYTLYYTAQlfl0hR2gUO1Img1Q= +github.com/urfave/cli/v3 v3.11.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= @@ -317,28 +329,32 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= @@ -351,12 +367,12 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 h1:qUp3i6M39kccSm3OMMIE3tvs7YQWO9rFeCKdk1sSaFk= +golang.org/x/exp v0.0.0-20261005173118-76772065c9b0/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= 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 +387,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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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,33 +416,33 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 h1:DftxASHKl6JolFPqoN+2WrFO53JvkHwRXvFqf8gsivA= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= 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-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= -google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 94be44aa7..fab7656df 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -2,11 +2,13 @@ package agent_test import ( "context" + "errors" "fmt" "sync" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/atomic" @@ -40,8 +42,11 @@ func TestAgent(t *testing.T) { Room: &livekit.Room{}, AgentName: testAgentName, } - _, err := client.JobRequest(context.Background(), testAgentName, agent.RoomAgentTopic, job) - require.NoError(t, err) + // registration is async and the job topic is subscribed after the register response, so retry until a worker is there + require.EventuallyWithT(t, func(c *assert.CollectT) { + _, err := client.JobRequest(context.Background(), testAgentName, agent.RoomAgentTopic, job) + assert.NoError(c, err) + }, 5*time.Second, 50*time.Millisecond) select { case a := <-jobAssignments.Events(): @@ -189,6 +194,91 @@ func TestAgentLoadBalancing(t *testing.T) { }) } +func TestJobTerminateReleasesJob(t *testing.T) { + const agentName = "test_agent" + + // starts a server with a worker whose job request topic is registered + newWorker := func(t *testing.T) (rpc.AgentInternalClient, *testutils.AgentWorker) { + bus := psrpc.NewLocalMessageBus() + client := must.Get(rpc.NewAgentInternalClient(bus)) + server := testutils.NewTestServer(bus) + t.Cleanup(server.Close) + + registered := must.Get(client.SubscribeWorkerRegistered(context.Background(), agent.DefaultHandlerNamespace)) + defer registered.Close() + + worker := server.SimulateAgentWorker() + worker.Register(agentName, livekit.JobType_JT_ROOM) + select { + case <-registered.Channel(): + case <-time.After(time.Second): + require.Fail(t, "registration timeout") + } + return client, worker + } + + // assigns a job, then terminates it as Room.RemoveParticipant does when + // the agent participant leaves + runJob := func(t *testing.T, client rpc.AgentInternalClient) *livekit.Job { + job := &livekit.Job{ + Id: guid.New(guid.AgentJobPrefix), + DispatchId: guid.New(guid.AgentDispatchPrefix), + Type: livekit.JobType_JT_ROOM, + Room: &livekit.Room{}, + AgentName: agentName, + } + _, err := client.JobRequest(context.Background(), agentName, agent.RoomAgentTopic, job) + require.NoError(t, err) + + res, err := client.JobTerminate(context.Background(), job.Id, &rpc.JobTerminateRequest{ + JobId: job.Id, + Reason: rpc.JobTerminateReason_AGENT_LEFT_ROOM, + }) + require.NoError(t, err) + require.Equal(t, livekit.JobStatus_JS_FAILED, res.State.Status) + return job + } + + // no server should still be handling JobTerminate for the job + requireReleased := func(t *testing.T, client rpc.AgentInternalClient, job *livekit.Job) { + _, err := client.JobTerminate(context.Background(), job.Id, &rpc.JobTerminateRequest{JobId: job.Id}, psrpc.WithRequestTimeout(500*time.Millisecond)) + require.ErrorIs(t, err, psrpc.ErrNoResponse) + } + + t.Run("worker does not report job status", func(t *testing.T) { + // workers are not required to report job status after a termination + // (e.g. agents-js does not currently send UpdateJobStatus), and a + // worker disconnect does not release jobs that are no longer running + client, worker := newWorker(t) + job := runJob(t, client) + require.NoError(t, worker.Close()) + requireReleased(t, client, job) + }) + + t.Run("worker reports ended status after termination", func(t *testing.T) { + client, worker := newWorker(t) + job := runJob(t, client) + worker.SendUpdateJob(&livekit.UpdateJobStatus{ + JobId: job.Id, + Status: livekit.JobStatus_JS_SUCCESS, + }) + + // worker messages are handled in order, so the pong confirms the + // update was handled + pongs := worker.WorkerPongs.Observe() + defer pongs.Stop() + worker.SendPing(&livekit.WorkerPing{}) + select { + case <-pongs.Events(): + case <-time.After(time.Second): + require.Fail(t, "pong timeout") + } + + require.NoError(t, worker.Close()) + requireReleased(t, client, job) + }) +} + func TestConnectionClosedOnDispatchError(t *testing.T) { t.Run("connection closed when unknown message type received", func(t *testing.T) { bus := psrpc.NewLocalMessageBus() @@ -256,3 +346,112 @@ func TestDrainConnectionsDoesNotDeadlock(t *testing.T) { }) } } + +func TestJobTerminateHandlerReleased(t *testing.T) { + const jobCount = 50 + + requestJobs := func(t *testing.T, client rpc.AgentInternalClient, agentName func(i int) string) []string { + jobIDs := make([]string, jobCount) + errs := make([]error, jobCount) + var wg sync.WaitGroup + for i := range jobCount { + job := &livekit.Job{ + Id: guid.New(guid.AgentJobPrefix), + DispatchId: guid.New(guid.AgentDispatchPrefix), + Type: livekit.JobType_JT_ROOM, + Room: &livekit.Room{}, + AgentName: agentName(i), + } + jobIDs[i] = job.Id + wg.Add(1) + go func() { + defer wg.Done() + _, errs[i] = client.JobRequest(context.Background(), job.AgentName, agent.RoomAgentTopic, job) + }() + } + wg.Wait() + for _, err := range errs { + require.NoError(t, err) + } + return jobIDs + } + + // the server reads the ping only after registering the worker's job request topic. + waitRegistered := func(t *testing.T, w *testutils.AgentWorker, agentName string) { + pongs := w.WorkerPongs.Observe() + defer pongs.Stop() + w.Register(agentName, livekit.JobType_JT_ROOM) + w.SendPing(&livekit.WorkerPing{Timestamp: time.Now().UnixMilli()}) + select { + case <-pongs.Events(): + case <-time.After(5 * time.Second): + require.Fail(t, "registration timeout") + } + } + + // any response means a JobTerminate handler is still registered. + countAnswered := func(client rpc.AgentInternalClient, jobIDs []string) int32 { + var answered atomic.Int32 + var wg sync.WaitGroup + for _, id := range jobIDs { + wg.Add(1) + go func() { + defer wg.Done() + _, err := client.JobTerminate(context.Background(), id, &rpc.JobTerminateRequest{JobId: id}, psrpc.WithRequestTimeout(200*time.Millisecond)) + if !errors.Is(err, psrpc.ErrRequestTimedOut) && !errors.Is(err, psrpc.ErrNoResponse) { + answered.Inc() + } + }() + } + wg.Wait() + return answered.Load() + } + + // handlers are released as the server processes the worker's messages; a leaked one never is. + requireHandlersReleased := func(t *testing.T, client rpc.AgentInternalClient, jobIDs []string) { + var answered int32 + require.Eventually(t, func() bool { + answered = countAnswered(client, jobIDs) + return answered == 0 + }, 10*time.Second, 100*time.Millisecond, "jobs with leaked JobTerminate handlers: %d", answered) + } + + t.Run("job fails on assignment", func(t *testing.T) { + bus := psrpc.NewLocalMessageBus() + client := must.Get(rpc.NewAgentInternalClient(bus)) + server := testutils.NewTestServer(bus) + t.Cleanup(server.Close) + + var worker *testutils.AgentWorker + worker = server.SimulateAgentWorker(testutils.WithJobAssignmentHandler(func(j *livekit.Job) testutils.JobLoad { + worker.SendUpdateJob(&livekit.UpdateJobStatus{JobId: j.Id, Status: livekit.JobStatus_JS_FAILED}) + return testutils.NewStableJobLoad(0) + })) + waitRegistered(t, worker, "fail_agent") + + jobIDs := requestJobs(t, client, func(int) string { return "fail_agent" }) + + requireHandlersReleased(t, client, jobIDs) + }) + + t.Run("worker disconnects on assignment", func(t *testing.T) { + bus := psrpc.NewLocalMessageBus() + client := must.Get(rpc.NewAgentInternalClient(bus)) + server := testutils.NewTestServer(bus) + t.Cleanup(server.Close) + + agentName := func(i int) string { return fmt.Sprintf("disconnect_agent_%d", i) } + for i := range jobCount { + var worker *testutils.AgentWorker + worker = server.SimulateAgentWorker(testutils.WithJobAssignmentHandler(func(j *livekit.Job) testutils.JobLoad { + worker.Close() + return nil + })) + waitRegistered(t, worker, agentName(i)) + } + + jobIDs := requestJobs(t, client, agentName) + + requireHandlersReleased(t, client, jobIDs) + }) +} diff --git a/pkg/agent/client.go b/pkg/agent/client.go index 788da9846..383729f29 100644 --- a/pkg/agent/client.go +++ b/pkg/agent/client.go @@ -62,9 +62,9 @@ type JobRequest struct { // only set for participant jobs Participant *livekit.ParticipantInfo Metadata string - AgentName string - Deployment string - Attributes map[string]string + AgentName string + Deployment string + Attributes map[string]string } type agentClient struct { diff --git a/pkg/agent/config.go b/pkg/agent/config.go index ee529884e..1bbcfbc25 100644 --- a/pkg/agent/config.go +++ b/pkg/agent/config.go @@ -1,9 +1,33 @@ package agent +import "time" + const DefaultTargetLoad = 0.7 type Config struct { EnableUserDataRecording bool `yaml:"enable_user_data_recording"` EnableUserDataRedaction bool `yaml:"enable_user_data_redaction"` TargetLoad float32 `yaml:"target_load,omitempty"` + + // agent HTTP endpoints data plane; zero values take the endpoint package + // defaults + Endpoints EndpointsConfig `yaml:"endpoints,omitempty"` +} + +type EndpointsConfig struct { + // Disabled turns off the /agents/{agent_name}/{deployment}/... front and + // rejects registrations that declare endpoints. + Disabled bool `yaml:"disabled,omitempty"` + + // MaxStreams is the soft per-session concurrent-stream cap used for capacity + // weighting; 0 takes the endpoint package default. + MaxStreams uint32 `yaml:"max_streams,omitempty"` + + // MaxTunnels caps one worker's concurrent WebSocket tunnels, reserving the + // rest of its streams for HTTP routes; 0 takes half of MaxStreams. + MaxTunnels uint32 `yaml:"max_tunnels,omitempty"` + + // TunnelDrainTimeout is how long a WebSocket tunnel outlives its worker or + // this node starting to drain; 0 takes the endpoint package default. + TunnelDrainTimeout time.Duration `yaml:"tunnel_drain_timeout,omitempty"` } diff --git a/pkg/agent/endpoint/attempt.go b/pkg/agent/endpoint/attempt.go new file mode 100644 index 000000000..6c3c64cef --- /dev/null +++ b/pkg/agent/endpoint/attempt.go @@ -0,0 +1,418 @@ +// 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 endpoint + +import ( + "bufio" + "errors" + "fmt" + "io" + "maps" + "net" + "net/http" + "net/url" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/guid" +) + +// attempt is the per-request state shared across worker attempts. +type attempt struct { + req *http.Request + // escPath is the percent-encoded path; a decoded %0A would forge a log line + escPath string + // target is the origin-form request target, still percent-encoded + target string + requestID string + granted bool + body io.Reader + pools *bridgePools + preamble *livekit.AgentHttp_StreamPreamble + // the serialized canonical request head, built once and replayed verbatim + head []byte + // client body bytes consumed when this attempt began + before int64 + // a status line reached the client, so nothing may be retried past here + committed bool + // on a 101 the stream becomes a tunnel + webSocket bool +} + +// replayable reports whether the request body can be sent again from the start. +// writeRequest never reads the body when ContentLength is 0, so an attempt that +// passes here leaves the counter where it found it and the next one replays too. +func (a *attempt) replayable() bool { + return a.before == 0 && a.req.ContentLength == 0 +} + +// retryable implements the retry table: nothing once a status line has reached +// the client; before that, proven non-dispatch is safe for any method, and an +// outcome where the application ran is never safe. +func (a *attempt) retryable(err error) bool { + if a.committed || !a.replayable() { + return false + } + var ce *wire.CompletionError + if errors.As(err, &ce) { + switch ce.Completion { + case wire.CompletionRefused: + return true + case wire.CompletionTimeout, wire.CompletionProtocol, wire.CompletionUnknown: + return idempotentMethod(a.req.Method) + default: + return false // the application ran + } + } + // dispatch is unknown + return idempotentMethod(a.req.Method) +} + +// newPreamble carries what the worker cannot derive from the HTTP bytes and +// must not infer from them - authenticated above all. +func (a *attempt) newPreamble() *livekit.AgentHttp_StreamPreamble { + scheme := "http" + if a.req.TLS != nil { + scheme = "https" + } + clientAddr, _, _ := net.SplitHostPort(a.req.RemoteAddr) + + p := &livekit.AgentHttp_StreamPreamble{ + Kind: livekit.AgentHttp_AEK_HTTP, + RequestId: a.requestID, + Authorized: a.granted, + ClientAddr: clientAddr, + Scheme: scheme, + } + return p +} + +// requestHeader builds the header set the worker sees. +func (a *attempt) requestHeader() http.Header { + r := a.req + h := r.Header.Clone() + if h == nil { + h = http.Header{} + } + removeHopByHopHeaders(h) + if a.webSocket { + h.Set("Connection", "Upgrade") + h.Set("Upgrade", "websocket") + } + // identity travels as preamble.authorized; the token must never reach the + // worker + if isLiveKitBearer(h.Get("Authorization")) { + h.Del("Authorization") + } + // the signalling namespace is the node's to write: a client able to set + // x-lk-completion could tell the worker a body it cut short was whole + wire.StripReservedHeaders(h) + h.Del("Expect") // the front owns 100-continue semantics client-side + + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + if prior := h.Get("X-Forwarded-For"); prior != "" { + h.Set("X-Forwarded-For", prior+", "+host) + } else { + h.Set("X-Forwarded-For", host) + } + } + return h +} + +// buildHead serializes the canonical request head. It is byte-identical across +// attempts, so it is built once; only the preamble's shrinking deadline varies. +func (a *attempt) buildHead() error { + head, err := wire.BuildRequestHead(a.req.Method, a.target, a.req.Host, a.requestHeader(), bodyLength(a.req)) + if err != nil { + return err + } + if len(head) > wire.MaxRequestHeadSize { + return errRequestHeadTooLarge + } + a.head = head + return nil +} + +// refreshTimeout restates the remaining budget: the preamble is built once, but +// a retry must not hand the worker time already spent. +func (a *attempt) refreshTimeout() { + deadline, ok := a.req.Context().Deadline() + if !ok { + return + } + ms := time.Until(deadline).Milliseconds() + if ms < 1 { + ms = 1 // 0 means "no deadline", which an expired budget is not + } + a.preamble.TimeoutMs = uint32(ms) +} + +// writeRequest writes the preamble, then the canonical head and body as opaque +// HTTP/1.1. A failure reading the client's body still ends the body explicitly +// where the framing allows it. +func (a *attempt) writeRequest(w io.Writer) error { + if err := wire.WritePreamble(w, a.preamble); err != nil { + return err + } + if _, err := w.Write(a.head); err != nil { + return err + } + cl := bodyLength(a.req) + if a.body == nil || cl == 0 { + return nil + } + + // a declared length is framed as declared; only an unknown length needs + // chunking, the one framing that can carry a reason for a short body. + var bw wire.BodyWriter + if cl > 0 { + bw = wire.NewIdentityBody(w) + } else { + bw = wire.NewChunkedBody(w) + } + + bufp := a.pools.getBuf() + defer a.pools.putBuf(bufp) + + n, err := wire.CopyBody(bw, a.body, *bufp) + var srcErr *wire.SourceError + switch { + case errors.As(err, &srcErr): + return bw.Close(wire.CompletionPeerGone, srcErr.Err.Error()) + case err != nil: + return err + case cl > 0 && n < cl: + return bw.Close(wire.CompletionTruncated, fmt.Sprintf("declared %d bytes, read %d", cl, n)) + } + return bw.Close(wire.CompletionUnknown, "") +} + +// readResponse reads the worker's response head, relaying any informational +// heads that precede it. 1xx carries no body, so each returns as soon as it +// lands. +func (a *attempt) readResponse( + w http.ResponseWriter, + br *bufio.Reader, + lim *headLimiter, + stream Stream, +) (*http.Response, error) { + // A deadline only bounds waiting, so failing to set or clear one must never + // discard a response the peer has already delivered: a transport reports an + // error here as soon as the stream is closed, which a worker that answered + // in full and hung up has done. + _ = stream.SetReadDeadline(time.Now().Add(responseHeadTimeout)) + // headers middleware set before the front ran; no 1xx head may consume them + var base http.Header + for i := 0; ; i++ { + if i > maxInformationalHeads { + return nil, errTooManyInformational + } + resp, err := http.ReadResponse(br, a.req) + if err != nil { + return nil, err + } + if resp.StatusCode < 100 || resp.StatusCode > 599 { + return nil, errBadStatus + } + if resp.StatusCode == http.StatusSwitchingProtocols { + if !a.webSocket || !strings.EqualFold(resp.Header.Get("Upgrade"), "websocket") { + return nil, errProtocolSwitch + } + } + if resp.StatusCode >= 200 || resp.StatusCode == http.StatusSwitchingProtocols { + lim.release() + // bodies (SSE, long streams) are unbounded; only the head is deadlined + _ = stream.SetReadDeadline(time.Time{}) + return resp, nil + } + h := w.Header() + if base == nil { + base = h.Clone() + } + copyResponseHeaders(h, resp.Header) + w.WriteHeader(resp.StatusCode) + clear(h) + maps.Copy(h, base) + a.committed = true + } +} + +// requestID resolves the request's idempotence token. It is client-chosen, so it +// is never rewritten - only accepted, or refused so the caller can retry under a +// token the front will honor. Being client-chosen it is neither unique nor +// trusted: two callers may present the same value, so log queries scope by +// worker and deployment. +func requestID(r *http.Request) (string, bool) { + v := r.Header.Values("X-Request-Id") + switch { + case len(v) == 0: + return guid.New("AER_"), true + case len(v) > 1: + return "", false // a token naming two requests dedups neither + } + if !validRequestID(v[0]) { + return "", false + } + return v[0], true +} + +// validRequestID accepts visible ASCII within the length bound, so UUID, ULID, +// base64 and base64url pass unchanged while control characters do not. +func validRequestID(id string) bool { + if len(id) == 0 || len(id) > maxRequestIDLen { + return false + } + for i := 0; i < len(id); i++ { + if id[i] < 0x21 || id[i] > 0x7e { + return false + } + } + return true +} + +func idempotentMethod(m string) bool { + switch m { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + return true + } + return false +} + +// bodyLength is the framing the head declares: a known length, 0 for no body, +// or -1 when only the client knows where it ends. +func bodyLength(r *http.Request) int64 { + if r.Body == nil || r.Body == http.NoBody { + return 0 + } + return r.ContentLength +} + +func requestTarget(escPath, rawQuery string) string { + rawQuery = stripAccessToken(rawQuery) + if rawQuery == "" { + return escPath + } + return escPath + "?" + rawQuery +} + +func stripAccessToken(rawQuery string) string { + if rawQuery == "" { + return "" + } + // the kept pairs must keep the client's encoding byte for byte, so the query + // is never decoded and re-encoded + pairs := strings.Split(rawQuery, "&") + kept := pairs[:0] + for _, p := range pairs { + k, _, _ := strings.Cut(p, "=") + if uk, err := url.QueryUnescape(k); err == nil && uk == "access_token" { + continue + } + kept = append(kept, p) + } + return strings.Join(kept, "&") +} + +// isWebSocketUpgrade admits only an HTTP/1.x websocket handshake; every other +// protocol switch, h2c included, must stay blocked. +func isWebSocketUpgrade(r *http.Request) bool { + return r.ProtoMajor == 1 && r.Method == http.MethodGet && bodyLength(r) == 0 && + connectionHasToken(r.Header, "upgrade") && + strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") +} + +func connectionHasToken(h http.Header, token string) bool { + for _, v := range h.Values("Connection") { + for _, t := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(t), token) { + return true + } + } + } + return false +} + +func isLiveKitBearer(v string) bool { + tok, ok := strings.CutPrefix(v, "Bearer ") + if !ok || tok == "" { + return false + } + t, err := auth.ParseAPIToken(tok) + return err == nil && t.APIKey() != "" +} + +// hop-by-hop headers per RFC 9110; Connection-nominated headers are dropped too. +func removeHopByHopHeaders(h http.Header) { + for _, f := range h.Values("Connection") { + for _, sf := range strings.Split(f, ",") { + if sf = strings.TrimSpace(sf); sf != "" { + h.Del(sf) + } + } + } + for _, k := range []string{ + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "Te", "Trailer", "Transfer-Encoding", "Upgrade", + } { + h.Del(k) + } +} + +// copyResponseHeaders forwards what the worker sent, minus hop-by-hop fields (a +// forwarded Transfer-Encoding makes the client de-chunk an already-decoded body) +// and minus the reserved namespace, which is not the client's to see. +func copyResponseHeaders(dst, src http.Header) { + MergeWorkerHeaders(dst, src) + removeHopByHopHeaders(dst) + wire.StripReservedHeaders(dst) +} + +// MergeWorkerHeaders adds a worker's response head to dst, which may hold +// headers set by serving middleware. A worker's Access-Control-* and Vary +// values replace the middleware's. Preflights never reach the worker: they +// carry no credential to admit them to a private route. +func MergeWorkerHeaders(dst, worker http.Header) { + for k, vs := range worker { + if k == "Vary" || strings.HasPrefix(k, "Access-Control-") { + // dst's slices may be shared by middleware and must not be written through + dst[k] = slices.Clone(vs) + continue + } + for _, v := range vs { + dst.Add(k, v) + } + } +} + +// countingReader tracks how much of the client body has been consumed. The +// counter is atomic because it is read while the request writer goroutine holds +// the reader. +type countingReader struct { + r io.Reader + n *atomic.Int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if n > 0 { + c.n.Add(int64(n)) + } + return n, err +} diff --git a/pkg/agent/endpoint/completion_test.go b/pkg/agent/endpoint/completion_test.go new file mode 100644 index 000000000..83a52e82f --- /dev/null +++ b/pkg/agent/endpoint/completion_test.go @@ -0,0 +1,387 @@ +// 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 endpoint + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// scriptedSession drives the front with a scripted worker writing raw HTTP/1.1. +type scriptedSession struct { + script func(r io.Reader, w *workerSide) +} + +func (s *scriptedSession) OpenStreams() int { return 0 } +func (s *scriptedSession) MaxStreams() int { return DefaultMaxStreams } +func (s *scriptedSession) Close(string) {} + +func (s *scriptedSession) OpenStream(context.Context) (Stream, error) { + toWorker, fromFront := net.Pipe() + toFront, fromWorker := net.Pipe() + st := &pipeStream{w: fromFront, r: toFront} + + go func() { + s.script(toWorker, &workerSide{WriteCloser: fromWorker, st: st}) + }() + return st, nil +} + +// pipeStream is a Stream over a pair of net.Pipes, carrying a reset code the way +// a QUIC stream does. +type pipeStream struct { + w io.WriteCloser + r io.ReadCloser + + mu sync.Mutex + closed bool + peerReset *livekit.AgentHttp_HttpStreamResetCode +} + +func (p *pipeStream) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if err != nil { + p.mu.Lock() + code := p.peerReset + p.mu.Unlock() + if code != nil { + return n, &StreamResetError{Code: *code} + } + } + return n, err +} + +func (p *pipeStream) Write(b []byte) (int, error) { return p.w.Write(b) } +func (p *pipeStream) CloseWrite() error { return p.w.Close() } +func (p *pipeStream) SetReadDeadline(t time.Time) error { + type deadliner interface{ SetReadDeadline(time.Time) error } + if d, ok := p.r.(deadliner); ok { + return d.SetReadDeadline(t) + } + return nil +} + +func (p *pipeStream) Reset(livekit.AgentHttp_HttpStreamResetCode, string) { _ = p.Close() } + +func (p *pipeStream) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return nil + } + p.closed = true + _ = p.w.Close() + _ = p.r.Close() + return nil +} + +// workerSide is the scripted worker's end of the exchange. +type workerSide struct { + io.WriteCloser + st *pipeStream +} + +// Reset ends the exchange the way a worker's RESET_STREAM does: the front's next +// read reports the code. The code is recorded before the close so the front +// cannot observe the EOF without it. +func (ws *workerSide) Reset(code livekit.AgentHttp_HttpStreamResetCode) { + ws.st.mu.Lock() + ws.st.peerReset = &code + ws.st.mu.Unlock() + _ = ws.WriteCloser.Close() +} + +func (ws *workerSide) WriteString(s string) { _, _ = io.WriteString(ws.WriteCloser, s) } + +// rawScriptedFront serves a front backed by one scripted worker whose side of +// the exchange is raw bytes. +func rawScriptedFront(t *testing.T, script func(r io.Reader, w *workerSide)) *httptest.Server { + t.Helper() + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/stream", Methods: []string{"GET", "POST"}, Public: true}, + }) + require.NoError(t, err) + + g, s := NewRegistry(), NewScope(logger.GetLogger()) + r := NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &scriptedSession{script: script}}) + g.Register(s, r) + + ts := httptest.NewServer(NewFront(FrontParams{ResolveAccess: grantedTo(s), Logger: logger.GetLogger()})) + t.Cleanup(ts.Close) + return ts +} + +// drainRequest reads the preamble and the whole request so the worker script can +// respond. net.Pipe is unbuffered, so a script that skips this deadlocks the +// front's writer. +func drainRequest(r io.Reader) *livekit.AgentHttp_StreamPreamble { + pre, err := wire.ReadPreamble(r) + if err != nil { + return nil + } + req, err := http.ReadRequest(bufio.NewReader(r)) + if err != nil { + return pre + } + _, _ = io.Copy(io.Discard, req.Body) + return pre +} + +func chunk(p []byte) string { return fmt.Sprintf("%x\r\n%s\r\n", len(p), p) } + +const chunkedHead = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + +// A worker reports an incomplete body that the HTTP framing does not reveal: the +// chunked body terminates cleanly, so only the trailer carries the outcome. +func TestTrailersReportTruncationWithoutContentLength(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString(chunk(make([]byte, 1000))) + w.WriteString("0\r\nx-lk-completion: truncated\r\nx-lk-error: generator raised mid-stream\r\n\r\n") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body the worker reported as truncated must not read as complete") +} + +// A chunked body that never reaches its terminator is incomplete, whatever the +// stream does next. +func TestMissingTerminatorAbortsTheClient(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString(chunk(make([]byte, 1000))) + // no terminal 0-chunk + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "an unterminated body must not read as a clean EOF") +} + +// A chunk header that overstates its payload must not reach the client as a +// complete body. +func TestTruncatedMidChunkAbortsTheClient(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString("3e8\r\n") // announces 1000 bytes + w.WriteString(strings.Repeat("x", 10)) // delivers 10 + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body cut short mid-chunk must not read as complete") +} + +// A body that terminates with no completion trailer is exactly success. +func TestUnlengthedCompleteResponseSucceeds(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString(chunk([]byte("streamed"))) + w.WriteString("0\r\n\r\n") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "streamed", string(got)) +} + +// A refusal before any byte flowed is retryable and exhausts into a 503. +func TestRefusalBeforeHeadIsRetryable(t *testing.T) { + var attempts int + var mu sync.Mutex + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + mu.Lock() + attempts++ + mu.Unlock() + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_REFUSED) + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, 1, attempts, "one registration means one attempt, then the 503") +} + +// A post-dispatch failure before the head is a 502 and is never retried. +func TestInternalBeforeHeadIsBadGateway(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_INTERNAL) + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// A deadline overrun maps to 504. POST is not retryable, so the mapping reaches +// the client. +func TestTimeoutBeforeHeadIsGatewayTimeout(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_TIMEOUT) + }) + + resp, err := http.Post(ts.URL+PathPrefix+"a/d/stream", "text/plain", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusGatewayTimeout, resp.StatusCode) +} + +// 101 must not reach the client as a switch the stream never performs. +func TestSwitchingProtocolsIsRejected(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n") + }) + + resp, err := http.Post(ts.URL+PathPrefix+"a/d/stream", "text/plain", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// A retryable state with no other worker to try exhausts into a 503. +func TestRetryableStateExhaustsTo503(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_TIMEOUT) + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, "1", resp.Header.Get("Retry-After")) +} + +// A reset with no code carries no claim that nothing was applied, so it is +// retried only for an idempotent method. +func TestAbortWithoutCodeIsIdempotentOnly(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_ABORT) + }) + + resp, err := http.Post(ts.URL+PathPrefix+"a/d/stream", "text/plain", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode, + "a POST must not be retried on an unexplained reset") +} + +// The signalling namespace is the node's: a client that could set +// x-lk-completion could tell the worker a body it cut short was whole. +func TestClientSuppliedReservedHeadersAreStripped(t *testing.T) { + seen := make(chan string, 1) + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + _, _ = wire.ReadPreamble(r) + req, err := http.ReadRequest(bufio.NewReader(r)) + if err != nil { + seen <- "read error" + return + } + seen <- req.Header.Get("X-Lk-Completion") + w.WriteString("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + }) + + req, err := http.NewRequest(http.MethodGet, ts.URL+PathPrefix+"a/d/stream", nil) + require.NoError(t, err) + req.Header.Set("X-Lk-Completion", "internal") + req.Header.Set("X-Lk-Error", "forged") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Empty(t, <-seen, "a client must not be able to forge a completion") +} + +// Signalling between node and worker is not the client's to see. +func TestReservedResponseHeadersDoNotReachTheClient(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nx-lk-internal: leaked\r\n\r\nok") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "ok", string(body)) + require.Empty(t, resp.Header.Get("X-Lk-Internal")) +} + +// Header values are octets: a latin-1 filename must survive the hop. +func TestNonUTF8HeaderValueSurvives(t *testing.T) { + const v = "attachment; filename=\"caf\xe9.txt\"" + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nContent-Disposition: " + v + "\r\n\r\n") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, v, resp.Header.Get("Content-Disposition")) +} diff --git a/pkg/agent/endpoint/conformance/worker.go b/pkg/agent/endpoint/conformance/worker.go new file mode 100644 index 000000000..9d0b7641e --- /dev/null +++ b/pkg/agent/endpoint/conformance/worker.go @@ -0,0 +1,302 @@ +// 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 conformance + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/utils/guid" +) + +type Config struct { + // ServerURL is the https:// URL of the /agent endpoint (WebTransport). + ServerURL string + APIKey string + APISecret string + + AgentName string + Deployment string + Endpoints []*livekit.AgentHttp_AgentEndpoint + + // TargetAddr is the host:port of the local HTTP server streams bridge into. + TargetAddr string + + // Insecure skips TLS verification (self-signed certs in tests). + Insecure bool + + Logger logger.Logger +} + +// Worker is one registration epoch: a single WebTransport session carrying the +// control stream and every HTTP-exchange stream. +type Worker struct { + cfg Config + instanceID string + + mu sync.Mutex + sess *webtransport.Session + workerID string + protocol uint32 + closed bool + registered chan struct{} +} + +func New(cfg Config) *Worker { + if cfg.Logger == nil { + cfg.Logger = logger.GetLogger() + } + return &Worker{ + cfg: cfg, + instanceID: guid.New("AEI_"), + registered: make(chan struct{}), + } +} + +// Start dials the WebTransport session, registers on the control stream, and +// begins serving node-opened streams. It returns once registration completes. +func (w *Worker) Start(ctx context.Context) error { + token, err := w.mintToken() + if err != nil { + return err + } + + tr := &webtransport.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: w.cfg.Insecure, //nolint:gosec // test/self-signed only + NextProtos: []string{http3.NextProtoH3}, + }, + } + hdr := http.Header{} + hdr.Set("Authorization", "Bearer "+token) + _, sess, err := tr.Dial(ctx, w.cfg.ServerURL, hdr) + if err != nil { + return fmt.Errorf("dial webtransport: %w", err) + } + w.mu.Lock() + w.sess = sess + w.mu.Unlock() + + // control stream: the worker opens it first, then registers + control, err := sess.OpenStreamSync(ctx) + if err != nil { + return fmt.Errorf("open control stream: %w", err) + } + if err := wire.WriteControlMessage(control, &livekit.WorkerMessage{ + Message: &livekit.WorkerMessage_Register{ + Register: &livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: w.cfg.AgentName, + Version: "endpoint-conformance-client", + PingInterval: 30, + Deployment: w.cfg.Deployment, + Endpoints: w.cfg.Endpoints, + InstanceId: w.instanceID, + EndpointProtocol: wire.CurrentProtocol, + }, + }, + }); err != nil { + return fmt.Errorf("write register: %w", err) + } + + var resp livekit.ServerMessage + if err := wire.ReadControlMessage(control, &resp); err != nil { + return fmt.Errorf("read register response: %w", err) + } + reg := resp.GetRegister() + if reg == nil { + return fmt.Errorf("expected register response, got %T", resp.GetMessage()) + } + settings := reg.GetEndpointSettings() + if settings == nil { + return fmt.Errorf("server accepted the registration without endpoint settings") + } + if p := settings.GetProtocol(); p < wire.MinProtocol || p > wire.CurrentProtocol { + return fmt.Errorf("server negotiated endpoint protocol %d, this worker speaks %d..%d", + p, wire.MinProtocol, wire.CurrentProtocol) + } + + w.mu.Lock() + w.workerID = reg.GetWorkerId() + w.protocol = settings.GetProtocol() + close(w.registered) + w.mu.Unlock() + + go w.controlLoop(control) + // the serve loop lives as long as the session, not the dial context (which + // the caller may cancel as soon as Start returns) + go w.serveLoop(sess) + return nil +} + +// WaitRegistered blocks until registration completes or ctx is done. +func (w *Worker) WaitRegistered(ctx context.Context) error { + select { + case <-w.registered: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Second): + return fmt.Errorf("timed out waiting for registration") + } +} + +// WorkerID returns the id assigned by the server (valid after registration). +func (w *Worker) WorkerID() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.workerID +} + +// Close tears down the session. +func (w *Worker) Close() { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return + } + w.closed = true + sess := w.sess + w.mu.Unlock() + if sess != nil { + _ = sess.CloseWithError(wire.SessionCloseOK, "worker closed") + } +} + +func (w *Worker) mintToken() (string, error) { + at := auth.NewAccessToken(w.cfg.APIKey, w.cfg.APISecret). + SetVideoGrant(&auth.VideoGrant{Agent: true}). + SetValidFor(24 * time.Hour) + return at.ToJWT() +} + +// controlLoop drains further control messages (availability requests are +// declined; the conformance worker takes no jobs). +func (w *Worker) controlLoop(control *webtransport.Stream) { + for { + var msg livekit.ServerMessage + if err := wire.ReadControlMessage(control, &msg); err != nil { + return + } + if a := msg.GetAvailability(); a != nil { + _ = wire.WriteControlMessage(control, &livekit.WorkerMessage{ + Message: &livekit.WorkerMessage_Availability{ + Availability: &livekit.AvailabilityResponse{ + JobId: a.GetJob().GetId(), + Available: false, + }, + }, + }) + } + } +} + +// serveLoop accepts node-opened streams (one HTTP exchange each) and bridges +// them to the local target. It runs until the session ends. +func (w *Worker) serveLoop(sess *webtransport.Session) { + ctx := sess.Context() + for { + stream, err := sess.AcceptStream(ctx) + if err != nil { + return + } + go w.serve(ctx, stream) + } +} + +// serve bridges one HTTP exchange to the local target. After the preamble the +// stream is opaque HTTP/1.1 in both directions. +func (w *Worker) serve(ctx context.Context, stream *webtransport.Stream) { + defer stream.Close() + + pre, err := wire.ReadPreamble(stream) + if err != nil { + resetStream(stream, livekit.AgentHttp_HSR_PROTOCOL) + return + } + if pre.GetKind() != livekit.AgentHttp_AEK_HTTP { + w.cfg.Logger.Infow("agent endpoint stream kind not served", + "kind", pre.GetKind().String(), "requestID", pre.GetRequestId()) + resetStream(stream, livekit.AgentHttp_HSR_PROTOCOL) + return + } + + if ms := pre.GetTimeoutMs(); ms > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(ms)*time.Millisecond) + defer cancel() + } + + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", w.cfg.TargetAddr) + if err != nil { + // the application never observed the request, so refused is sound here. + // The code carries no detail, so the reason is logged against the + // request ID instead. + w.cfg.Logger.Infow("agent endpoint target dial failed", + "error", err, "target", w.cfg.TargetAddr, "requestID", pre.GetRequestId()) + resetStream(stream, livekit.AgentHttp_HSR_REFUSED) + return + } + defer conn.Close() + + // the deadline and the session ending both have to reach a blocked copy + stop := context.AfterFunc(ctx, func() { + _ = conn.Close() + stream.CancelRead(wire.StreamCode(livekit.AgentHttp_HSR_ABORT)) + }) + defer stop() + + pipe(stream, conn) +} + +// pipe copies the exchange in both directions until the target has finished +// answering. Neither direction is parsed. +func pipe(stream *webtransport.Stream, conn net.Conn) { + reqDone := make(chan struct{}) + go func() { + defer close(reqDone) + _, _ = io.Copy(conn, stream) + // the request ended; the target needs the EOF to answer a body it read + // to completion + if tc, ok := conn.(*net.TCPConn); ok { + _ = tc.CloseWrite() + } + }() + + _, _ = io.Copy(stream, conn) + // the target is done answering, so nothing more of the request is wanted + stream.CancelRead(wire.StreamCode(livekit.AgentHttp_HSR_ABORT)) + <-reqDone +} + +// resetStream reports an outcome that happened before any HTTP bytes flowed, +// the only point at which a reset can carry one without racing them. +func resetStream(stream *webtransport.Stream, c livekit.AgentHttp_HttpStreamResetCode) { + stream.CancelWrite(wire.StreamCode(c)) + stream.CancelRead(wire.StreamCode(c)) +} diff --git a/pkg/agent/endpoint/front.go b/pkg/agent/endpoint/front.go new file mode 100644 index 000000000..7a2741dfa --- /dev/null +++ b/pkg/agent/endpoint/front.go @@ -0,0 +1,679 @@ +// 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 endpoint + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +const ( + // PathPrefix is the public route namespace: /agents/{agent_name}/{deployment}/{path...} + PathPrefix = "/agents/" + + // MaxPathLength caps the escaped route path. Matching runs before the + // request head is sized, so this is the only bound on it. + MaxPathLength = 8 << 10 + + // responseHeadTimeout bounds the wait for the worker's response head. Bodies + // (SSE, long streams) are unbounded; the head never legitimately takes this + // long. + responseHeadTimeout = 90 * time.Second + + // maxAttempts bounds worker retries per request + maxAttempts = 3 + + // maxRequestIDLen bounds the client's idempotence token: it reaches this + // node's logs and the worker's, so it cannot be unbounded. + maxRequestIDLen = 128 + + // maxInformationalHeads bounds 1xx responses before the final head, so a + // worker cannot hold a client open by trickling them forever. + maxInformationalHeads = 8 + + maxResponseHeadSize = 1 << 20 + responseBufSize = 8 << 10 +) + +var ( + errNotEndpointPath = errors.New("endpoint: not an agent endpoint path") + errMalformedPath = errors.New("endpoint: malformed agent endpoint path") + errPathTooLong = errors.New("endpoint: agent endpoint path too long") + errRequestHeadTooLarge = errors.New("endpoint: request head too large") + errProtocolSwitch = errors.New("endpoint: worker switched protocols on an HTTP exchange") + errTooManyInformational = errors.New("endpoint: too many informational responses") + errBadStatus = errors.New("endpoint: response status out of range") + errHeadTooLarge = errors.New("endpoint: response head too large") +) + +// AccessLevel is how far a request's caller is trusted. Callers compare against +// it, so a new level must be inserted at its correct rank. +type AccessLevel int + +const ( + // AccessNone presented no credential. + AccessNone AccessLevel = iota + // AccessCredentialed presented a valid token carrying no agent-endpoint + // grant for the addressed agent and deployment. + AccessCredentialed + // AccessGranted presented a token whose agent-endpoint grant covers the + // addressed agent and deployment. + AccessGranted +) + +func (a AccessLevel) String() string { + switch a { + case AccessNone: + return "none" + case AccessCredentialed: + return "credentialed" + case AccessGranted: + return "granted" + default: + return fmt.Sprintf("%d", int(a)) + } +} + +// Access is what the front knows about a request's caller, together with the +// serving state it resolved to. The front never keys anything itself: whatever +// scopes a request - a tenant, an api key, a project - is resolved by the +// embedder and arrives here already looked up. +type Access struct { + // Scope is the deployment's serving state this request is placed against. + // nil means no worker here holds it; Fallback may still place it elsewhere. + Scope *Scope + // Fallback serves the request elsewhere (e.g. a multi-node relay), already + // curried on the deployment it resolved. nil means local misses are final. + Fallback Fallback + Level AccessLevel +} + +// AccessResolver maps an inbound request, plus the agent and deployment its URL +// addresses, to the caller's access. ok is false when the request cannot be +// placed at all - no credential, or an unknown tenant - and the front +// challenges. +type AccessResolver func(r *http.Request, agentName, deployment string) (access Access, ok bool) + +type Front struct { + params FrontParams + pools *bridgePools + tunnels *Tunnels +} + +// Identity resolves the agent and deployment a request addresses. Reporting +// false leaves them to the URL. +type Identity func(r *http.Request) (agentName, deployment string, ok bool) + +// FrontParams configures a Front. Fields are read on every request once the +// Front is serving, so none may change after construction. +type FrontParams struct { + ResolveAccess AccessResolver + Logger logger.Logger + Identity Identity + + // MaxTunnelsPerWorker caps one worker's concurrent WebSocket tunnels. 0 + // means half its session's MaxStreams. + MaxTunnelsPerWorker int + // TunnelDrainTimeout is how long a tunnel outlives its worker starting to + // drain. 0 means DefaultTunnelDrainTimeout. + TunnelDrainTimeout time.Duration +} + +func NewFront(params FrontParams) *Front { + params.Logger = params.Logger.WithComponent("agents.endpoint") + return &Front{params: params, pools: newBridgePools(), tunnels: NewTunnels()} +} + +// Fallback serves a request elsewhere (e.g. a multi-node relay); it reports +// whether a response was written. Returning false falls back to the local +// status mapping. It is curried on the deployment it was resolved for, so it +// carries no scope arguments. +type Fallback func(w http.ResponseWriter, r *http.Request, level AccessLevel) bool + +// writeUnavailable writes a 503 with a Retry-After hint: no local worker can +// serve the request and no fallback placed it elsewhere. +func (f *Front) writeUnavailable(w http.ResponseWriter, msg string) { + w.Header().Set("Retry-After", "1") + http.Error(w, msg, http.StatusServiceUnavailable) +} + +func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ep, err := splitEndpointPath(r.URL) + if err != nil { + if errors.Is(err, errMalformedPath) { + http.Error(w, "bad request path", http.StatusBadRequest) + return + } + if errors.Is(err, errPathTooLong) { + http.Error(w, "uri too long", http.StatusRequestURITooLong) + return + } + http.NotFound(w, r) + return + } + agentName, deployment, path, escPath := ep.agentName, ep.deployment, ep.path, ep.escPath + if f.params.Identity != nil { + // must precede resolveAccess, which may consume its source headers + if name, dep, ok := f.params.Identity(r); ok { + agentName, deployment = name, dep + } + } + + reqID, ok := requestID(r) + if !ok { + http.Error(w, "invalid X-Request-Id", http.StatusBadRequest) + return + } + + access, ok := f.params.ResolveAccess(r, agentName, deployment) + if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + + tbl := access.Scope.routeTable() + if tbl == nil && access.Fallback == nil { + f.writeUnavailable(w, "no workers available for deployment") + return + } + + mask := methodMask(r.Method) + granted := access.Level >= AccessGranted + matched, partial, denied := f.matchDeployment(access.Scope, tbl, path, mask, granted) + // no exact match: if only the trailing-slash alternate matches a registered + // route, normalize the path to that form and serve it directly (no client + // redirect). The exact form is tried first, so a route registered with a + // trailing slash is served as-is; this only rewrites a slash mismatch toward + // the registered form. When the request must be relayed, the serving node + // runs this same normalization, so no redirect is ever emitted. + if len(matched) == 0 && !partial && !denied { + if alt, altEsc, ok := slashAlternatePaths(tbl, path, escPath, mask); ok { + path, escPath = alt, altEsc + matched, partial, denied = f.matchDeployment(access.Scope, tbl, path, mask, granted) + } + } + if len(matched) == 0 && access.Fallback != nil { + // nothing local matched: hand off to the multi-node fallback (relay to a + // node holding the deployment) before the local status mapping. The + // serving node's relay listener installs no fallback of its own, so a + // relayed request is served or errored there and never re-relays. + if access.Fallback(w, r, access.Level) { + return + } + if tbl == nil && !denied && !partial { + f.writeUnavailable(w, "no workers available for deployment") + return + } + } + if len(matched) == 0 { + switch { + case denied: + // access does not vary across candidates, so one verdict covers them all + if access.Level >= AccessCredentialed { + http.Error(w, "forbidden", http.StatusForbidden) + } else { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "authentication required", http.StatusUnauthorized) + } + case partial: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + default: + http.Error(w, "not found", http.StatusNotFound) + } + return + } + + var bodyConsumed atomic.Int64 + a := &attempt{ + req: r, + escPath: escPath, + target: requestTarget(escPath, r.URL.RawQuery), + requestID: reqID, + granted: granted, + webSocket: isWebSocketUpgrade(r), + pools: f.pools, + } + a.body = &countingReader{r: r.Body, n: &bodyConsumed} + a.preamble = a.newPreamble() + a.refreshTimeout() + if err := a.buildHead(); err != nil { + if errors.Is(err, errRequestHeadTooLarge) { + http.Error(w, "request header fields too large", http.StatusRequestHeaderFieldsTooLarge) + return + } + f.params.Logger.Debugw("agent endpoint rejected a request head", "error", err, "requestID", reqID) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // must precede the first write, so the request body keeps streaming once the + // response has started + if err := http.NewResponseController(w).EnableFullDuplex(); err != nil && !errors.Is(err, http.ErrNotSupported) { + f.params.Logger.Debugw("agent endpoint cannot enable full duplex", "error", err) + } + + attempted := make(map[*Registration]bool) + if a.webSocket { + f.excludeTunnelsAtLimit(matched, attempted) + } + for range maxAttempts { + picked := pickWorker(matched, attempted) + if picked == nil { + break + } + attempted[picked.reg] = true + + a.before = bodyConsumed.Load() + a.refreshTimeout() + // the preamble is re-serialized per attempt, so this reaches only the + // worker it was set for + a.preamble.Route = picked.raw + switch f.bridge(w, a, picked.reg) { + case bridgeDone: + return + case bridgeAbort: + // the head is on the wire already, so nothing can report the failure in + // band. This must reach net/http to abort the response; anything that + // recovers it completes the body. + panic(http.ErrAbortHandler) + } + } + + // the route matched locally but nothing served it (matches draining or + // conn-less, or every attempt failed before writing): the fallback may hold + // capacity elsewhere. Safe exactly while no request bytes were consumed - + // reaching this point implies it, since consuming attempts are never + // retryable. + if bodyConsumed.Load() == 0 && access.Fallback != nil { + if access.Fallback(w, r, access.Level) { + return + } + } + + f.writeUnavailable(w, "no worker could serve the request") +} + +// matchDeployment resolves a path against the deployment's merged route table. +// matched is the dispatch test: a route can be decided while nothing is left to +// serve it, and an undecidable table dispatches with no route at all. +func (f *Front) matchDeployment(scope *Scope, tbl *routeTable, path string, mask router.Mask, granted bool) (matched []routeWorker, partial, denied bool) { + if tbl == nil { + return nil, false, false + } + var res router.Result + matched, _, res, denied = tbl.match(path, mask, granted) + switch res { + case router.ResultPartial: + partial = true + case router.ResultOverBudget: + // no route was decided, so its Public flag is unknown and only a grant + // can clear the request + if !granted { + denied = true + break + } + for _, reg := range scope.Candidates() { + matched = append(matched, routeWorker{reg: reg}) + } + } + return +} + +// slashAlternatePaths looks for the trailing-slash alternate of path in the +// deployment's table, returning the decoded and escaped forms to retry. Both +// forms come out of the same transform. +func slashAlternatePaths(tbl *routeTable, path, escPath string, mask router.Mask) (string, string, bool) { + if tbl == nil || path == "/" { + return path, escPath, false + } + var alt, altEsc string + if strings.HasSuffix(path, "/") { + // %2F decodes to a slash without being a separator, so the alternate + // applies only while the slash is literal in escPath too + if !strings.HasSuffix(escPath, "/") { + return path, escPath, false + } + alt, altEsc = strings.TrimSuffix(path, "/"), strings.TrimSuffix(escPath, "/") + } else { + alt, altEsc = path+"/", escPath+"/" + } + if !tbl.serves(alt, mask) { + return path, escPath, false + } + return alt, altEsc, true +} + +// endpointPath is a request split into its routing components. +type endpointPath struct { + agentName string + deployment string + // path is decoded, for manifest matching + path string + // escPath keeps the client's encoding, for the request line the worker gets + escPath string +} + +// splitEndpointPath splits /agents/{agent_name}/{deployment}/{path...} from the +// ESCAPED path. The worker is handed a re-serialized request line, so the target +// must keep the client's encoding: a decoded %3F or %2F re-emits as a real '?' +// or '/' and changes which resource the worker routes to. +func splitEndpointPath(u *url.URL) (endpointPath, error) { + rest, ok := strings.CutPrefix(u.EscapedPath(), PathPrefix) + if !ok { + return endpointPath{}, errNotEndpointPath + } + rawAgentName, rest, found := strings.Cut(rest, "/") + if !found || rawAgentName == "" { + return endpointPath{}, errNotEndpointPath + } + rawDeployment, escPath, found := strings.Cut(rest, "/") + if !found { + escPath = "" + } + if rawDeployment == "" { + return endpointPath{}, errNotEndpointPath + } + escPath = "/" + escPath + + if len(escPath) > MaxPathLength { + return endpointPath{}, errPathTooLong + } + + agentName, err1 := url.PathUnescape(rawAgentName) + deployment, err2 := url.PathUnescape(rawDeployment) + path, err3 := url.PathUnescape(escPath) + if err1 != nil || err2 != nil || err3 != nil { + return endpointPath{}, errMalformedPath + } + // "_" and "%5F" both land here; neither is a registrable name + // (IsReservedAgentName, ValidateDeployment) + if agentName == UnnamedSegment { + agentName = "" + } + if deployment == UnnamedSegment { + deployment = "" + } + return endpointPath{agentName: agentName, deployment: deployment, path: path, escPath: escPath}, nil +} + +// pickWorker chooses a worker by the power of two choices: sample two eligible +// declarations at random and take the one with fewer in-flight streams. +// Eligible = not already attempted, has a live session, not draining. +func pickWorker(workers []routeWorker, ignore map[*Registration]bool) *routeWorker { + var eligible []routeWorker + for _, w := range workers { + if ignore[w.reg] || !w.reg.HasSession() || w.reg.IsDraining() { + continue + } + eligible = append(eligible, w) + } + if len(eligible) == 0 { + return nil + } + return &eligible[p2c(eligible, func(w routeWorker) int { return w.reg.InflightStreams() })] +} + +// p2c returns the index of the less-loaded of two distinct random draws from +// items, which must be non-empty. +func p2c[T any](items []T, load func(T) int) int { + n := len(items) + if n == 1 { + return 0 + } + i := rand.IntN(n) + j := rand.IntN(n - 1) + if j >= i { // fold to a distinct second draw + j++ + } + if load(items[i]) <= load(items[j]) { + return i + } + return j +} + +// bridgeOutcome is what one attempt against one worker concluded. +type bridgeOutcome int + +const ( + // nothing reached the client; another worker may still serve it + bridgeRetry bridgeOutcome = iota + // a response, or an error standing in for one, reached the client + bridgeDone + // the response was committed and cannot be completed + bridgeAbort +) + +// bridge runs one attempt against one worker. +func (f *Front) bridge(w http.ResponseWriter, a *attempt, reg *Registration) bridgeOutcome { + if a.webSocket { + if !reg.tryAcquireTunnel(f.tunnelLimit(reg)) { + return bridgeRetry + } + defer reg.releaseTunnel() + } + + ctx := a.req.Context() + stream, err := reg.OpenStream(ctx) + if err != nil { + return bridgeRetry // no session/capacity here; try another worker + } + defer stream.Close() + + stop := context.AfterFunc(ctx, func() { + stream.Reset(livekit.AgentHttp_HSR_ABORT, "client disconnected") + }) + defer stop() + + // serialize the request into the stream concurrently with response reading: + // directions are independent (full duplex within the stream) + writeErrCh := make(chan error, 1) + go func() { + err := a.writeRequest(stream) + switch { + case err == nil && a.webSocket: + // the send side stays open: on a 101 it carries the client's frames + case err == nil: + err = stream.CloseWrite() + default: + // fail fast: the worker is waiting for bytes that will never come + stream.Reset(livekit.AgentHttp_HSR_ABORT, "request write failed") + } + writeErrCh <- err + }() + + lim := &headLimiter{r: stream, n: maxResponseHeadSize} + br := f.pools.getReader(lim) + defer f.pools.putReader(br) + + resp, err := a.readResponse(w, br, lim, stream) + if err != nil { + err = completionError(err) + if !a.retryable(err) { + f.params.Logger.Warnw("agent endpoint request failed", err, + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID) + writeGatewayError(w, err) + return bridgeDone + } + // join the request writer before another attempt touches the shared + // body reader (retries are bodyless per the table, so this is prompt) + stream.Reset(livekit.AgentHttp_HSR_ABORT, "retrying elsewhere") + <-writeErrCh + return bridgeRetry + } + // resp.Body must not be Closed: net/http's Close drains whatever the head + // declared and the body has not delivered, blocking on a stream that is + // about to be reset. stream.Close owns the underlying resource. + + if resp.StatusCode == http.StatusSwitchingProtocols { + f.spliceWebSocket(w, a, reg, stream, br, resp, writeErrCh) + return bridgeDone + } + + // a response head arrived: from here every failure is surfaced + copyResponseHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + a.committed = true + + rc := http.NewResponseController(w) + bufp := f.pools.getBuf() + buf := *bufp + defer f.pools.putBuf(bufp) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + stream.Reset(livekit.AgentHttp_HSR_ABORT, "client write failed") + return bridgeDone + } + _ = rc.Flush() + } + if rerr == io.EOF { + break + } + if rerr != nil { + // never expose a clean-looking short body + return f.aborted(rerr, reg, a, writeErrCh) + } + } + // framing complete; the sender may still report a short body in trailers + if ce := wire.CompletionFromTrailers(resp.Trailer); ce != nil { + return f.aborted(ce, reg, a, writeErrCh) + } + if a.webSocket { + // the request writer left the send side open; a declined upgrade ends + // with a FIN + if err := <-writeErrCh; err == nil { + _ = stream.CloseWrite() + } + } + return bridgeDone +} + +// aborted logs why a committed response cannot be completed. +func (f *Front) aborted(err error, reg *Registration, a *attempt, writeErrCh <-chan error) bridgeOutcome { + f.logAborted(err, reg, a) + select { + case werr := <-writeErrCh: + f.params.Logger.Debugw("request write result after response failure", "error", werr) + default: + } + return bridgeAbort +} + +func (f *Front) logAborted(err error, reg *Registration, a *attempt) { + var ce *wire.CompletionError + if errors.As(err, &ce) { + f.params.Logger.Infow("agent endpoint response aborted", + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID, + "completion", string(ce.Completion), "reason", ce.Reason) + return + } + f.params.Logger.Infow("agent endpoint response aborted", + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID, "error", err) +} + +// completionError normalizes a failure into the protocol's outcome vocabulary. +// A peer reset code becomes the outcome; anything else leaves dispatch unknown. +func completionError(err error) error { + var sre *StreamResetError + if errors.As(err, &sre) { + return &wire.CompletionError{Completion: wire.CompletionFromResetCode(sre.Code)} + } + return err +} + +// writeGatewayError maps a terminal state to a status for a request whose +// response never reached the client. +func writeGatewayError(w http.ResponseWriter, err error) { + var ce *wire.CompletionError + if errors.As(err, &ce) && ce.Completion == wire.CompletionTimeout { + http.Error(w, "gateway timeout", http.StatusGatewayTimeout) + return + } + if errors.Is(err, os.ErrDeadlineExceeded) { + http.Error(w, "gateway timeout", http.StatusGatewayTimeout) + return + } + http.Error(w, "bad gateway", http.StatusBadGateway) +} + +// bridgePools holds the per-request scratch one Front reuses: a body buffer and +// the buffered reader http.ReadResponse parses through. +type bridgePools struct { + bodyBuf sync.Pool + readers sync.Pool +} + +func newBridgePools() *bridgePools { + return &bridgePools{ + bodyBuf: sync.Pool{New: func() any { b := make([]byte, wire.BodyChunkSize); return &b }}, + readers: sync.Pool{New: func() any { return bufio.NewReaderSize(nil, responseBufSize) }}, + } +} + +func (p *bridgePools) getBuf() *[]byte { return p.bodyBuf.Get().(*[]byte) } +func (p *bridgePools) putBuf(b *[]byte) { p.bodyBuf.Put(b) } + +func (p *bridgePools) getReader(r io.Reader) *bufio.Reader { + br := p.readers.Get().(*bufio.Reader) + br.Reset(r) + return br +} + +// putReader drops the stream reference so a pooled reader never pins a dead one. +func (p *bridgePools) putReader(br *bufio.Reader) { + br.Reset(nil) + p.readers.Put(br) +} + +// headLimiter caps the bytes a response head may make this node buffer. release +// lifts the cap once the head is parsed; the body behind it is unbounded. +type headLimiter struct { + r io.Reader + n int64 // remaining head budget; negative once the head has been parsed +} + +func (h *headLimiter) Read(p []byte) (int, error) { + if h.n == 0 { + return 0, errHeadTooLarge + } + if h.n > 0 && int64(len(p)) > h.n { + p = p[:h.n] + } + n, err := h.r.Read(p) + if h.n > 0 { + h.n -= int64(n) + } + return n, err +} + +func (h *headLimiter) release() { h.n = -1 } diff --git a/pkg/agent/endpoint/front_test.go b/pkg/agent/endpoint/front_test.go new file mode 100644 index 000000000..0a98e65fb --- /dev/null +++ b/pkg/agent/endpoint/front_test.go @@ -0,0 +1,413 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// grantedTo resolves every request to scope with full access. +func grantedTo(scope *Scope) AccessResolver { + return func(*http.Request, string, string) (Access, bool) { + return Access{Scope: scope, Level: AccessGranted}, true + } +} + +// resolveTo resolves every request to a fixed access. +func resolveTo(a Access) AccessResolver { + return func(*http.Request, string, string) (Access, bool) { return a, true } +} + +func fallbackFront(t *testing.T, fb Fallback, withWorker bool) *Front { + g, s := NewRegistry(), NewScope(logger.GetLogger()) + if withWorker { + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/known", Methods: []string{"GET"}, Public: true}, + }) + require.NoError(t, err) + g.Register(s, NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &fakeSession{}})) + } + return NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Scope: s, Fallback: fb, Level: AccessGranted}), + Logger: logger.GetLogger(), + }) +} + +func serveFront(f *Front, path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + f.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathPrefix+"a/d"+path, nil)) + return w +} + +// a path no local worker matches hands off to the fallback the resolver curried, +// which is given the caller's access level; when the fallback serves, the front +// writes nothing itself. +func TestFrontFallbackFires(t *testing.T) { + got := AccessLevel(-1) + f := fallbackFront(t, func(w http.ResponseWriter, _ *http.Request, level AccessLevel) bool { + got = level + w.WriteHeader(http.StatusTeapot) // stands in for a relayed response + return true + }, true) + + w := serveFront(f, "/unknown") + require.Equal(t, http.StatusTeapot, w.Code) + require.Equal(t, AccessGranted, got) +} + +// a declined fallback with a local worker present falls through to the front's +// own 404 for the unmatched path. +func TestFrontFallbackDeclinedMapsStatus(t *testing.T) { + f := fallbackFront(t, func(http.ResponseWriter, *http.Request, AccessLevel) bool { return false }, true) + require.Equal(t, http.StatusNotFound, serveFront(f, "/unknown").Code) +} + +// a declined fallback with no local worker for the deployment falls through to +// 503. +func TestFrontFallbackDeclinedNoCandidates(t *testing.T) { + f := fallbackFront(t, func(http.ResponseWriter, *http.Request, AccessLevel) bool { return false }, false) + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, "/unknown").Code) +} + +// a resolver that cannot place the request at all challenges, and never reaches +// a scope. This is the path that used to ride an empty api key. +func TestFrontUnresolvedAccessIsChallenged(t *testing.T) { + f := NewFront(FrontParams{ + ResolveAccess: func(*http.Request, string, string) (Access, bool) { return Access{}, false }, + Logger: logger.GetLogger(), + }) + w := serveFront(f, "/anything") + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, "Bearer", w.Header().Get("WWW-Authenticate")) +} + +// a resolved caller whose deployment no worker holds here, with no fallback, is +// 503 - distinct from the 401 above, and no longer distinguished by a string. +func TestFrontResolvedButNoScopeIsUnavailable(t *testing.T) { + f := NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Level: AccessGranted}), + Logger: logger.GetLogger(), + }) + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, "/anything").Code) +} + +func TestRequestIDAcceptsOrRefuses(t *testing.T) { + atBound := strings.Repeat("x", maxRequestIDLen) + cases := []struct { + name string + values []string + want string + ok bool + }{ + {name: "absent mints one", ok: true}, + {name: "uuid", values: []string{"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"}, want: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", ok: true}, + {name: "base64url", values: []string{"a-B_c9=="}, want: "a-B_c9==", ok: true}, + {name: "at the length bound", values: []string{atBound}, want: atBound, ok: true}, + {name: "past the length bound", values: []string{atBound + "x"}}, + {name: "empty value", values: []string{""}}, + {name: "newline", values: []string{"ab\ncd"}}, + {name: "space", values: []string{"ab cd"}}, + {name: "non ascii", values: []string{"abc\u00e9"}}, + {name: "two values", values: []string{"a", "b"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/x", nil) + for _, v := range tc.values { + r.Header.Add("X-Request-Id", v) + } + got, ok := requestID(r) + require.Equal(t, tc.ok, ok) + switch { + case !tc.ok: + require.Empty(t, got, "a refused token must not reach the worker or the logs") + case tc.want != "": + require.Equal(t, tc.want, got, "a client token is never rewritten") + default: + require.True(t, strings.HasPrefix(got, "AER_")) + } + }) + } +} + +// a token the front cannot honor is refused before any routing or dispatch, so +// the caller learns to retry under one it will honor instead of silently losing +// idempotence to a substitute. +func TestFrontRefusesInvalidRequestID(t *testing.T) { + f := fallbackFront(t, nil, true) + + serve := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, PathPrefix+"a/d/known", nil) + r.Header.Set("X-Request-Id", token) + w := httptest.NewRecorder() + f.ServeHTTP(w, r) + return w + } + + require.Equal(t, http.StatusBadRequest, serve(strings.Repeat("x", maxRequestIDLen+1)).Code) + // same route, acceptable token: the request reaches dispatch (and fails + // there for want of a stream), so the 400 above came from the token alone + require.Equal(t, http.StatusServiceUnavailable, serve("f81d4fae-7dec").Code) +} + +// the preamble is built once, so a retry must not hand the worker a budget +// already spent. +func TestRefreshTimeoutTracksRemainingBudget(t *testing.T) { + newAttempt := func(ctx context.Context) *attempt { + a := &attempt{req: httptest.NewRequest(http.MethodGet, "/x", nil).WithContext(ctx)} + a.preamble = a.newPreamble() + return a + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + a := newAttempt(ctx) + a.refreshTimeout() + first := a.preamble.GetTimeoutMs() + require.NotZero(t, first) + + time.Sleep(25 * time.Millisecond) + a.refreshTimeout() + require.Less(t, a.preamble.GetTimeoutMs(), first) + + // no deadline: 0 is the proto's "no deadline" + none := newAttempt(context.Background()) + none.refreshTimeout() + require.Zero(t, none.preamble.GetTimeoutMs()) + + // an expired budget is not "no deadline" + expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancelExpired() + spent := newAttempt(expired) + spent.refreshTimeout() + require.EqualValues(t, 1, spent.preamble.GetTimeoutMs()) +} + +// accessFront registers one worker serving a public and a non-public route, and +// resolves every request to the given access. +func accessFront(t *testing.T, level AccessLevel, fb Fallback) *Front { + g, s := NewRegistry(), NewScope(logger.GetLogger()) + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/pub", Methods: []string{"GET"}, Public: true}, + {Path: "/private", Methods: []string{"GET"}, Public: false}, + }) + require.NoError(t, err) + g.Register(s, NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &fakeSession{}})) + + return NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Scope: s, Fallback: fb, Level: level}), + Logger: logger.GetLogger(), + }) +} + +// fakeSession opens no stream, so a request that clears authorization reaches 503. +func TestFrontPrivateRouteAccessMapping(t *testing.T) { + anonymous, credentialed, granted := AccessNone, AccessCredentialed, AccessGranted + + t.Run("anonymous is challenged", func(t *testing.T) { + w := serveFront(accessFront(t, anonymous, nil), "/private") + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, "Bearer", w.Header().Get("WWW-Authenticate")) + }) + t.Run("credential without the grant is refused, not challenged", func(t *testing.T) { + w := serveFront(accessFront(t, credentialed, nil), "/private") + require.Equal(t, http.StatusForbidden, w.Code) + require.Empty(t, w.Header().Get("WWW-Authenticate")) + }) + t.Run("granted passes authorization", func(t *testing.T) { + require.Equal(t, http.StatusServiceUnavailable, serveFront(accessFront(t, granted, nil), "/private").Code) + }) + t.Run("a credential without the grant keeps public access", func(t *testing.T) { + require.Equal(t, http.StatusServiceUnavailable, serveFront(accessFront(t, credentialed, nil), "/pub").Code) + }) + t.Run("anonymous keeps public access", func(t *testing.T) { + require.Equal(t, http.StatusServiceUnavailable, serveFront(accessFront(t, anonymous, nil), "/pub").Code) + }) +} + +// the slash-normalized form of a private route is still private. +func TestFrontDeniedAppliesToNormalizedPath(t *testing.T) { + f := accessFront(t, AccessCredentialed, nil) + require.Equal(t, http.StatusForbidden, serveFront(f, "/private/").Code) +} + +// another node's worker may declare the same path public. +func TestFrontDeniedStillRelays(t *testing.T) { + got := AccessLevel(-1) + f := accessFront(t, AccessCredentialed, func(w http.ResponseWriter, _ *http.Request, level AccessLevel) bool { + got = level + w.WriteHeader(http.StatusTeapot) + return true + }) + + require.Equal(t, http.StatusTeapot, serveFront(f, "/private").Code) + require.Equal(t, AccessCredentialed, got) +} + +// the split runs before decoding, so a name or route param may carry any byte +// a percent-encoded segment can hold. +func TestSplitEndpointPath(t *testing.T) { + cases := []struct { + name string + target string + agentName string + deployment string + path string + escPath string + err error + }{ + {name: "plain", target: "/agents/a/d/x", agentName: "a", deployment: "d", path: "/x", escPath: "/x"}, + {name: "no tail", target: "/agents/a/d", agentName: "a", deployment: "d", path: "/", escPath: "/"}, + {name: "spaces", target: "/agents/LODHA%20Vayam%20Agent/d/x", agentName: "LODHA Vayam Agent", deployment: "d", path: "/x", escPath: "/x"}, + {name: "colon and star", target: "/agents/prod%3A%2A/d/x", agentName: "prod:*", deployment: "d", path: "/x", escPath: "/x"}, + {name: "at sign", target: "/agents/charlie%40v1.42.0/d/x", agentName: "charlie@v1.42.0", deployment: "d", path: "/x", escPath: "/x"}, + {name: "brackets", target: "/agents/Nathan%20%5BElara%5D/d/x", agentName: "Nathan [Elara]", deployment: "d", path: "/x", escPath: "/x"}, + {name: "slash in name", target: "/agents/a%2Fb/d/x", agentName: "a/b", deployment: "d", path: "/x", escPath: "/x"}, + {name: "non ascii raw", target: "/agents/agent-ü/d/x", agentName: "agent-ü", deployment: "d", path: "/x", escPath: "/x"}, + {name: "non ascii encoded", target: "/agents/agent-%C3%BC/d/x", agentName: "agent-ü", deployment: "d", path: "/x", escPath: "/x"}, + {name: "past the old 64 byte cap", target: "/agents/" + strings.Repeat("n", 77) + "/d/x", agentName: strings.Repeat("n", 77), deployment: "d", path: "/x", escPath: "/x"}, + {name: "deployment encoded", target: "/agents/a/prod%20us/x", agentName: "a", deployment: "prod us", path: "/x", escPath: "/x"}, + + // escPath keeps the client's encoding; path is what the manifest matches + {name: "encoded tail", target: "/agents/a/d/files/a%2Fb", agentName: "a", deployment: "d", path: "/files/a/b", escPath: "/files/a%2Fb"}, + {name: "escaped percent in tail", target: "/agents/a/d/%2541", agentName: "a", deployment: "d", path: "/%41", escPath: "/%2541"}, + + // "_" addresses the unnamed agent in either form + {name: "bare underscore is unnamed", target: "/agents/_/d/x", agentName: "", deployment: "d", path: "/x", escPath: "/x"}, + {name: "encoded underscore is unnamed", target: "/agents/%5F/d/x", agentName: "", deployment: "d", path: "/x", escPath: "/x"}, + {name: "double encoded underscore is a name", target: "/agents/%255F/d/x", agentName: "%5F", deployment: "d", path: "/x", escPath: "/x"}, + {name: "bare underscore deployment is unnamed", target: "/agents/a/_/x", agentName: "a", deployment: "", path: "/x", escPath: "/x"}, + {name: "encoded underscore deployment is unnamed", target: "/agents/a/%5F/x", agentName: "a", deployment: "", path: "/x", escPath: "/x"}, + {name: "unnamed agent and deployment", target: "/agents/_/_/x", agentName: "", deployment: "", path: "/x", escPath: "/x"}, + {name: "default is a deployment name", target: "/agents/a/default/x", agentName: "a", deployment: "default", path: "/x", escPath: "/x"}, + + {name: "not an endpoint path", target: "/other/x", err: errNotEndpointPath}, + {name: "no deployment segment", target: "/agents/a", err: errNotEndpointPath}, + {name: "empty name", target: "/agents//d/x", err: errNotEndpointPath}, + {name: "empty deployment", target: "/agents/a//x", err: errNotEndpointPath}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u, err := url.ParseRequestURI(c.target) + require.NoError(t, err) + + ep, err := splitEndpointPath(u) + if c.err != nil { + require.ErrorIs(t, err, c.err) + return + } + require.NoError(t, err) + require.Equal(t, c.agentName, ep.agentName) + require.Equal(t, c.deployment, ep.deployment) + require.Equal(t, c.path, ep.path) + require.Equal(t, c.escPath, ep.escPath) + }) + } +} + +// a trailing %2F decodes to a slash without being a separator, so the +// trailing-slash alternate does not apply to it. +func TestFrontSlashAlternateIgnoresEncodedSlash(t *testing.T) { + f := fallbackFront(t, nil, true) + + // 503 is dispatch reached: the fake session opens no stream + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, "/known/").Code) + require.Equal(t, http.StatusNotFound, serveFront(f, "/known%2F").Code) +} + +func TestIsReservedAgentName(t *testing.T) { + for _, n := range []string{"_", ".", ".."} { + require.True(t, IsReservedAgentName(n), n) + } + for _, n := range []string{"", "a", "_x", "x_", "...", "LODHA Vayam Agent", "%5F"} { + require.False(t, IsReservedAgentName(n), n) + } +} + +// matching runs before anything sizes the request head, so the split is where +// an over-long path is refused. +func TestSplitEndpointPathLength(t *testing.T) { + long := PathPrefix + "a/d/" + strings.Repeat("x", MaxPathLength) + _, err := splitEndpointPath(&url.URL{Path: long, RawPath: long}) + require.ErrorIs(t, err, errPathTooLong) + + f := fallbackFront(t, nil, true) + require.Equal(t, http.StatusRequestURITooLong, serveFront(f, "/"+strings.Repeat("x", MaxPathLength)).Code) + + ok := PathPrefix + "a/d/" + strings.Repeat("x", MaxPathLength-4) + _, err = splitEndpointPath(&url.URL{Path: ok, RawPath: ok}) + require.NoError(t, err) +} + +// budgetFront registers a worker whose routes are ambiguous enough that the +// matcher gives up deciding. +func budgetFront(t *testing.T, level AccessLevel) *Front { + g, s := NewRegistry(), NewScope(logger.GetLogger()) + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/{a}{b}{c}{d}x", Methods: []string{"GET"}, Public: true}, + }) + require.NoError(t, err) + require.Len(t, m.Ambiguous(), 1) + g.Register(s, NewRegistration(RegistrationParams{ + WorkerID: "w1", Manifest: m, Session: &fakeSession{}, + })) + return NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Scope: s, Level: level}), + Logger: logger.GetLogger(), + }) +} + +// A table too ambiguous to decide forwards to the worker. No route is decided, +// so its Public flag is unknown and only a grant clears the request. +func TestFrontOverBudgetForwardsWithAGrant(t *testing.T) { + long := "/" + strings.Repeat("a", 512) + + f := budgetFront(t, AccessGranted) + // 503 is dispatch reached: the fake session opens no stream + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, long).Code) + + f = budgetFront(t, AccessCredentialed) + require.Equal(t, http.StatusForbidden, serveFront(f, long).Code) + + f = budgetFront(t, AccessNone) + require.Equal(t, http.StatusUnauthorized, serveFront(f, long).Code) + + // a path the same table decides normally is unaffected + f = budgetFront(t, AccessNone) + require.Equal(t, http.StatusNotFound, serveFront(f, "/ab").Code) +} + +func TestMergeWorkerHeaders(t *testing.T) { + sharedVary := []string{"Origin"} + dst := http.Header{ + "Access-Control-Allow-Origin": {"https://server.example"}, + "Access-Control-Expose-Headers": {"*"}, + "Vary": sharedVary, + "Set-Cookie": {"a=1"}, + } + MergeWorkerHeaders(dst, http.Header{ + "Access-Control-Allow-Origin": {"https://app.example"}, + "Vary": {"Accept-Encoding"}, + "Set-Cookie": {"b=2"}, + "Link": {"; rel=preload", "; rel=preload"}, + }) + + require.Equal(t, []string{"https://app.example"}, dst.Values("Access-Control-Allow-Origin")) + require.Equal(t, []string{"*"}, dst.Values("Access-Control-Expose-Headers"), "a key the worker left alone keeps the server's value") + require.Equal(t, []string{"Accept-Encoding"}, dst.Values("Vary")) + require.Equal(t, []string{"a=1", "b=2"}, dst.Values("Set-Cookie")) + require.Equal(t, []string{"; rel=preload", "; rel=preload"}, dst.Values("Link")) + require.Equal(t, []string{"Origin"}, sharedVary, "a slice the middleware shares must not be written through") +} diff --git a/pkg/agent/endpoint/headerbound_test.go b/pkg/agent/endpoint/headerbound_test.go new file mode 100644 index 000000000..7cff6ca4f --- /dev/null +++ b/pkg/agent/endpoint/headerbound_test.go @@ -0,0 +1,81 @@ +// 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 endpoint_test + +import ( + "fmt" + "io" + "net" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +func padHeaders(t *testing.T, url string, bytes int) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + const per = 8 << 10 + for i := 0; i*per < bytes; i++ { + req.Header.Set(fmt.Sprintf("X-Pad-%d", i), strings.Repeat("p", per)) + } + return req +} + +// A head too large to serialize is answered directly and reaches no worker: no +// worker could accept it, so spending an attempt on it only turns a permanent +// failure into a retryable-looking one. +func TestOversizedHeadIsRejectedWithoutReachingAWorker(t *testing.T) { + var hits atomic.Int32 + addr := rawTarget(t, func(c net.Conn) { + hits.Add(1) + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/h", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.DefaultClient.Do(padHeaders(t, base+"/h", wire.MaxRequestHeadSize+(64<<10))) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusRequestHeaderFieldsTooLarge, resp.StatusCode, + "an unservable head must not come back as a retryable 503") + require.Empty(t, resp.Header.Get("Retry-After"), "the request can never succeed, so it must not invite a retry") + require.Zero(t, hits.Load(), "no worker attempt may be spent on a head no worker could accept") +} + +// A head comfortably inside the bound is served normally. +func TestLargeHeadWithinBoundIsServed(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/h", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.DefaultClient.Do(padHeaders(t, base+"/h", 512<<10)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, "ok", string(body)) +} diff --git a/pkg/agent/endpoint/largebody_test.go b/pkg/agent/endpoint/largebody_test.go new file mode 100644 index 000000000..c00136bc7 --- /dev/null +++ b/pkg/agent/endpoint/largebody_test.go @@ -0,0 +1,151 @@ +// 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 endpoint_test + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +// filler produces an endless recognizable stream. The pattern is keyed to the +// absolute offset so it stays continuous across reads. +type filler struct{ off int } + +func (f *filler) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte('a' + f.off%26) + f.off++ + } + return len(p), nil +} + +// verify checks the pattern without retaining the body. +type verify struct { + n int64 + off int +} + +func (v *verify) Write(p []byte) (int, error) { + for _, b := range p { + if b != byte('a'+v.off%26) { + return 0, fmt.Errorf("corrupt byte at offset %d", v.n) + } + v.off++ + v.n++ + } + return len(p), nil +} + +func heapInUse() uint64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.HeapInuse +} + +// A response far larger than any buffer in the path streams to the client +// without the node's heap growing with the body. +func TestLargeResponseStreamsInConstantMemory(t *testing.T) { + if testing.Short() { + t.Skip("moves 512MiB") + } + const bodySize = 512 << 20 + + addr := rawTarget(t, func(c net.Conn) { + _, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", bodySize) + _, _ = io.CopyN(c, &filler{}, bodySize) + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/big", Methods: []string{"GET"}, Public: true}, + }) + + before := heapInUse() + + resp, err := http.Get(base + "/big") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + v := &verify{} + n, err := io.Copy(v, resp.Body) + require.NoError(t, err) + require.EqualValues(t, bodySize, n) + + growth := int64(heapInUse()) - int64(before) + t.Logf("streamed %d MiB; node heap growth %d KiB", bodySize>>20, growth>>10) + require.Less(t, growth, int64(32<<20), + "the body must not be buffered: heap grew %d MiB streaming %d MiB", growth>>20, bodySize>>20) +} + +// The same in the request direction. +func TestLargeRequestStreamsInConstantMemory(t *testing.T) { + if testing.Short() { + t.Skip("moves 512MiB") + } + const bodySize = 512 << 20 + + received := make(chan int64, 1) + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + req, err := http.ReadRequest(newReqReader(c)) + if err != nil { + return + } + v := &verify{} + n, _ := io.Copy(v, req.Body) + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + received <- n + }() + + base := startFramedWorker(t, ln.Addr().String(), []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/upload", Methods: []string{"POST"}, Public: true}, + }) + + before := heapInUse() + + req, err := http.NewRequest(http.MethodPost, base+"/upload", io.LimitReader(&filler{}, bodySize)) + require.NoError(t, err) + req.ContentLength = bodySize + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.EqualValues(t, bodySize, <-received) + + growth := int64(heapInUse()) - int64(before) + t.Logf("uploaded %d MiB; node heap growth %d KiB", bodySize>>20, growth>>10) + require.Less(t, growth, int64(32<<20), + "the body must not be buffered: heap grew %d MiB uploading %d MiB", growth>>20, bodySize>>20) +} + +func newReqReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) } diff --git a/pkg/agent/endpoint/manifest.go b/pkg/agent/endpoint/manifest.go new file mode 100644 index 000000000..2946f986e --- /dev/null +++ b/pkg/agent/endpoint/manifest.go @@ -0,0 +1,156 @@ +// 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 endpoint + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "go.uber.org/zap/zapcore" + + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" +) + +const MaxManifestRoutes = 256 + +// Manifest is one worker's validated route table, in declaration order. +type Manifest struct { + Endpoints []Endpoint + ambiguous []string +} + +// Endpoint is one validated manifest entry. Mask carries the methods exactly as +// declared, so it may name several. +type Endpoint struct { + Template *router.Template + Mask router.Mask + Public bool +} + +// Ambiguous returns the declared templates whose shape forces the matcher to +// backtrack. The result is read-only. +func (m *Manifest) Ambiguous() []string { + if m == nil { + return nil + } + return m.ambiguous +} + +// MarshalLogObject describes the manifest's shape. +func (m *Manifest) MarshalLogObject(e zapcore.ObjectEncoder) error { + if m == nil { + return nil + } + e.AddInt("routes", len(m.Endpoints)) + if len(m.ambiguous) == 0 { + return nil + } + return e.AddArray("ambiguous", zapcore.ArrayMarshalerFunc(func(a zapcore.ArrayEncoder) error { + for _, t := range m.ambiguous { + a.AppendString(t) + } + return nil + })) +} + +// methodMask tags a route with the methods it serves. The set is exactly the +// verbs FastAPI can route; anything else masks to 0 and matches nothing. +func methodMask(method string) router.Mask { + switch method { + case http.MethodGet: + return 1 << 0 + case http.MethodHead: + return 1 << 1 + case http.MethodPost: + return 1 << 2 + case http.MethodPut: + return 1 << 3 + case http.MethodPatch: + return 1 << 4 + case http.MethodDelete: + return 1 << 5 + case http.MethodOptions: + return 1 << 6 + case http.MethodTrace: + return 1 << 7 + } + return 0 +} + +// NegotiateSettings validates a registration's endpoint manifest and negotiates +// the data-plane protocol version. The data plane is a WebTransport session (no +// attach token, no fixed connection pool), so the version is all there is to +// agree on. +func NegotiateSettings(req *livekit.RegisterWorkerRequest) (*livekit.AgentHttp_AgentEndpointSettings, error) { + if _, err := ParseManifest(req.GetEndpoints()); err != nil { + return nil, err + } + if req.GetInstanceId() == "" { + return nil, errors.New("registrations with endpoints require an instance_id") + } + if req.GetEndpointProtocol() == 0 { + return nil, errors.New("worker declared endpoints but no endpoint protocol; upgrade the agent SDK to one that speaks the endpoint data plane") + } + // the worker frames to the version returned here, so it must be the + // negotiated one and not the server's own constant + negotiated := min(req.GetEndpointProtocol(), wire.CurrentProtocol) + if negotiated < wire.MinProtocol { + return nil, fmt.Errorf("unsupported agent endpoint protocol %d (this server serves %d..%d)", + req.GetEndpointProtocol(), wire.MinProtocol, wire.CurrentProtocol) + } + return &livekit.AgentHttp_AgentEndpointSettings{Protocol: negotiated}, nil +} + +// ParseManifest validates a registration's endpoint list. +func ParseManifest(endpoints []*livekit.AgentHttp_AgentEndpoint) (*Manifest, error) { + if len(endpoints) > MaxManifestRoutes { + return nil, fmt.Errorf("manifest exceeds %d routes", MaxManifestRoutes) + } + m := &Manifest{Endpoints: make([]Endpoint, 0, len(endpoints))} + for _, ep := range endpoints { + tpl, err := router.ParseTemplate(ep.GetPath()) + if err != nil { + return nil, err + } + if ep.GetKind() != livekit.AgentHttp_AEK_HTTP { + return nil, fmt.Errorf("endpoint %q has unsupported kind %s", ep.GetPath(), ep.GetKind()) + } + if len(ep.GetMethods()) == 0 { + return nil, fmt.Errorf("endpoint %q declares no methods", ep.GetPath()) + } + var mask router.Mask + for _, method := range ep.GetMethods() { + u := strings.ToUpper(method) + if u != method { + return nil, fmt.Errorf("endpoint %q method %q must be uppercase", ep.GetPath(), method) + } + b := methodMask(u) + if b == 0 { + return nil, fmt.Errorf("endpoint %q declares unsupported method %q", ep.GetPath(), method) + } + mask |= b + } + if tpl.Ambiguous() { + m.ambiguous = append(m.ambiguous, tpl.String()) + } + m.Endpoints = append(m.Endpoints, Endpoint{Template: tpl, Mask: mask, Public: ep.GetPublic()}) + } + return m, nil +} diff --git a/pkg/agent/endpoint/manifest_test.go b/pkg/agent/endpoint/manifest_test.go new file mode 100644 index 000000000..d81922437 --- /dev/null +++ b/pkg/agent/endpoint/manifest_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" +) + +func ep(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint { + return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public} +} + +func TestManifestFullPartialSemantics(t *testing.T) { + // POST /x registered after GET /x must still serve POSTs (starlette scans + // for a FULL match before settling for the PARTIAL 405) + tbl := tableOf(t, mustManifest(t, + ep("/x", []string{"GET"}, true), + ep("/x", []string{"POST"}, true), + )) + + _, r, res, _ := tbl.match("/x", methodMask(http.MethodPost), true) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/x", r.Template.String()) + + // the manifest carries the app's methods verbatim: FastAPI does not imply + // HEAD from GET, so neither does the matcher + _, _, res, _ = tbl.match("/x", methodMask(http.MethodHead), true) + require.Equal(t, router.ResultPartial, res) + + // PARTIAL only when no route serves the method + _, _, res, _ = tbl.match("/x", methodMask(http.MethodDelete), true) + require.Equal(t, router.ResultPartial, res) + + // an unroutable method masks to 0 + _, _, res, _ = tbl.match("/x", methodMask("BREW"), true) + require.Equal(t, router.ResultPartial, res) + + _, _, res, _ = tbl.match("/nope", methodMask(http.MethodGet), true) + require.Equal(t, router.ResultNone, res) +} + +func TestSlashAlternatePaths(t *testing.T) { + tbl := tableOf(t, mustManifest(t, + ep("/hook", []string{"POST"}, true), + ep("/", []string{"POST"}, true), + )) + + cases := []struct { + path, escPath string + alt, altEsc string + ok bool + }{ + // a slash-mismatched request normalizes to the registered form + {"/hook/", "/hook/", "/hook", "/hook", true}, + // no route matches either slash form + {"/other/", "/other/", "/other/", "/other/", false}, + // %2F decodes to a slash without being a separator + {"/hook/", "/hook%2F", "/hook/", "/hook%2F", false}, + // "//" trims to "/" in both forms + {"//", "//", "/", "/", true}, + // "/" has no alternate to try + {"/", "/", "/", "/", false}, + } + for _, c := range cases { + alt, altEsc, ok := slashAlternatePaths(tbl, c.path, c.escPath, methodMask(http.MethodPost)) + require.Equal(t, c.ok, ok, "%q %q", c.path, c.escPath) + require.Equal(t, c.alt, alt, "%q %q", c.path, c.escPath) + require.Equal(t, c.altEsc, altEsc, "%q %q", c.path, c.escPath) + } +} + +// Templates anchor as `\n?$`: path refuses '\n' where str accepts it, so a +// decoded %0A would otherwise change which route wins. +func TestManifestTrailingNewlineRouteIdentity(t *testing.T) { + tbl := tableOf(t, mustManifest(t, + ep("/files/{p:path}", []string{"GET"}, false), + ep("/files/{p}", []string{"GET"}, true), + )) + + matched, r, res, denied := tbl.match("/files/x\n", methodMask(http.MethodGet), false) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/files/{p:path}", r.Template.String()) + require.Empty(t, matched) + require.True(t, denied, "the private route must win, as it does on the worker") +} + +func TestManifestValidation(t *testing.T) { + _, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", nil, false)}) + require.Error(t, err, "http endpoint without methods") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"get"}, false)}) + require.Error(t, err, "lowercase method") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"GTE"}, false)}) + require.Error(t, err, "unsupported method rejected") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("no-slash", []string{"GET"}, false)}) + require.Error(t, err, "template without a leading slash") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x/{a:slug}", []string{"GET"}, false)}) + require.Error(t, err, "custom convertor") + + // the full FastAPI/starlette verb set is accepted + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + ep("/x", []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"}, false), + }) + require.NoError(t, err, "all FastAPI verbs accepted") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/ws", Kind: livekit.AgentHttp_AEK_TEXT, Methods: []string{"GET"}}, + }) + require.Error(t, err, "unsupported endpoint kind") +} diff --git a/pkg/agent/endpoint/pick_test.go b/pkg/agent/endpoint/pick_test.go new file mode 100644 index 000000000..ed7a54be8 --- /dev/null +++ b/pkg/agent/endpoint/pick_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestP2CChoice(t *testing.T) { + // a single candidate is always chosen + require.Equal(t, 0, p2c([]int{42}, func(int) int { return 42 })) + + // with two candidates both are always drawn, so the lower-load one wins + // deterministically regardless of the random draw + for i := 0; i < 100; i++ { + require.Equal(t, 1, p2c([]int{5, 2}, func(v int) int { return v })) + require.Equal(t, 0, p2c([]int{2, 5}, func(v int) int { return v })) + } + + // larger n: the pick is always in range + for i := 0; i < 500; i++ { + idx := p2c(make([]int, 5), func(int) int { return 0 }) + require.GreaterOrEqual(t, idx, 0) + require.Less(t, idx, 5) + } + + // one clearly-idle candidate among four busy ones: whenever it is one of the + // two draws it wins, so it is picked far more than its 1/n share and the busy + // ones are relieved (this is the whole point of power-of-two-choices) + loads := []int{0, 100, 100, 100, 100} + low := 0 + for i := 0; i < 4000; i++ { + if p2c(loads, func(v int) int { return v }) == 0 { + low++ + } + } + // P(idle drawn) = 1 - (4/5)(3/4) = 0.4, and it always wins when drawn + require.Greater(t, low, 1000) + require.Less(t, low, 2400) +} diff --git a/pkg/agent/endpoint/registry.go b/pkg/agent/endpoint/registry.go new file mode 100644 index 000000000..7b61d680d --- /dev/null +++ b/pkg/agent/endpoint/registry.go @@ -0,0 +1,249 @@ +// 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 endpoint + +import ( + "context" + "errors" + "sync" + "sync/atomic" +) + +// UnnamedSegment is the URL segment that addresses workers registered with an +// empty agent name or deployment. +const UnnamedSegment = "_" + +// IsReservedAgentName reports whether a name cannot address a worker. These are +// unreserved characters (RFC 3986 §2.3), so percent-encoding them yields no +// distinct form to address them by. +func IsReservedAgentName(agentName string) bool { + return agentName == UnnamedSegment || agentName == "." || agentName == ".." +} + +// DefaultMaxStreams is the soft per-session concurrency cap used only for +// capacity weighting; QUIC's own stream limit is the hard bound. +const DefaultMaxStreams = 256 + +// ErrNoSession means the registration has no live data-plane session, so no +// stream can be opened toward the worker. +var ErrNoSession = errors.New("registration has no data-plane session") + +// Registration is one worker's data-plane state: its manifest and the single +// WebTransport session that carries both its control stream and the HTTP +// exchanges the node opens toward it. It lives exactly as long as that session +// (epoch fencing: a reconnecting worker forms a new registration, and the old +// session dies with it). +// +// It carries no agent name, deployment or tenant identity: those name the Scope +// it was registered into, which is the embedder's to key. +type Registration struct { + WorkerID string + Manifest *Manifest + + draining func() bool + // tunnels counts WebSocket handshakes and tunnels holding a stream here + tunnels atomic.Int32 + + lock sync.RWMutex + session Session + closed bool +} + +// RegistrationParams is fixed for the life of the registration, which lasts +// exactly as long as the session. +type RegistrationParams struct { + WorkerID string + Manifest *Manifest + + // Session is the worker's live data-plane session: the WebTransport session + // that also carries its control stream. One session per worker. + Session Session + // Draining reports that the worker is shedding; a shedding worker takes no + // new streams. + Draining func() bool +} + +func NewRegistration(params RegistrationParams) *Registration { + return &Registration{ + WorkerID: params.WorkerID, + Manifest: params.Manifest, + draining: params.Draining, + session: params.Session, + } +} + +// IsDraining is false when no drain signal was supplied. +func (r *Registration) IsDraining() bool { + return r.draining != nil && r.draining() +} + +func (r *Registration) getSession() Session { + r.lock.RLock() + defer r.lock.RUnlock() + if r.closed { + return nil + } + return r.session +} + +// OpenStream opens a data-plane stream toward the worker for one HTTP exchange. +func (r *Registration) OpenStream(ctx context.Context) (Stream, error) { + s := r.getSession() + if s == nil { + return nil, ErrNoSession + } + return s.OpenStream(ctx) +} + +// HasSession reports whether the worker has a live data-plane session, i.e. can +// serve HTTP requests right now. +func (r *Registration) HasSession() bool { + return r.getSession() != nil +} + +// InflightStreams reports the open streams on this worker's session - the +// least-outstanding-requests signal for worker selection. +func (r *Registration) InflightStreams() int { + s := r.getSession() + if s == nil { + return 0 + } + return s.OpenStreams() +} + +// SpareStreams reports remaining stream capacity - the node's live serving +// headroom for the worker, used to weight node selection. +func (r *Registration) SpareStreams() int { + s := r.getSession() + if s == nil { + return 0 + } + if spare := s.MaxStreams() - s.OpenStreams(); spare > 0 { + return spare + } + return 0 +} + +// maxStreams is the session's soft concurrency cap, or 0 without a session. +func (r *Registration) maxStreams() int { + s := r.getSession() + if s == nil { + return 0 + } + return s.MaxStreams() +} + +// Tunnels reports the WebSocket tunnels (and handshakes) holding a stream on +// this worker. +func (r *Registration) Tunnels() int { + return int(r.tunnels.Load()) +} + +func (r *Registration) tryAcquireTunnel(limit int) bool { + for { + n := r.tunnels.Load() + if int(n) >= limit { + return false + } + if r.tunnels.CompareAndSwap(n, n+1) { + return true + } + } +} + +func (r *Registration) releaseTunnel() { + r.tunnels.Add(-1) +} + +func (r *Registration) close() { + r.lock.Lock() + if r.closed { + r.lock.Unlock() + return + } + r.closed = true + s := r.session + r.session = nil + r.lock.Unlock() + if s != nil { + s.Close("registration closed") + } +} + +// Registry fences worker epochs on this node. Worker ids are server-issued and +// unique across the node, so this index is tenancy-blind: it exists only so a +// reconnecting worker supersedes its own stale epoch, wherever that epoch was +// scoped. +// +// It holds the outermost lock in this package: a Scope's lock may be taken while +// holding it, never the reverse. +type Registry struct { + lock sync.RWMutex + regs map[string]*regEntry // by worker id +} + +// regEntry remembers which scope an epoch was registered into, so a supersede +// can retract it from there even when the worker came back under a different +// agent name or deployment. +type regEntry struct { + reg *Registration + scope *Scope +} + +func NewRegistry() *Registry { + return &Registry{regs: make(map[string]*regEntry)} +} + +// Register records a registration in a scope. A worker id already present is +// superseded: worker ids are stable across reconnects, and the retiring session +// must not be able to strand the new epoch (its own Deregister is a no-op once +// replaced). The superseded epoch's session is closed. +func (g *Registry) Register(scope *Scope, r *Registration) { + g.lock.Lock() + e := g.regs[r.WorkerID] + var superseded, old *Registration + if e != nil { + superseded = e.reg + if e.scope == scope { + old = e.reg + } else { + // nothing pins agent name or deployment across epochs, so the + // retiring epoch's routes may live in another scope + e.scope.remove(e.reg) + } + } + // one transaction, so an unchanged manifest keeps its Route pointers + scope.replace(old, r) + g.regs[r.WorkerID] = ®Entry{reg: r, scope: scope} + g.lock.Unlock() + if superseded != nil { + superseded.close() + } +} + +// Deregister removes exactly this registration; it is a no-op when a newer epoch +// has already superseded it. +func (g *Registry) Deregister(r *Registration) { + g.lock.Lock() + e := g.regs[r.WorkerID] + if e == nil || e.reg != r { + g.lock.Unlock() + return + } + delete(g.regs, r.WorkerID) + e.scope.remove(r) + g.lock.Unlock() + r.close() +} diff --git a/pkg/agent/endpoint/registry_test.go b/pkg/agent/endpoint/registry_test.go new file mode 100644 index 000000000..ab9587ea3 --- /dev/null +++ b/pkg/agent/endpoint/registry_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +// fakeSession is a no-op Session for registry tests: it records whether it was +// closed so supersede/deregister can be asserted. +type fakeSession struct { + closed bool +} + +func (s *fakeSession) OpenStream(context.Context) (Stream, error) { return nil, ErrNoSession } +func (s *fakeSession) OpenStreams() int { return 0 } +func (s *fakeSession) MaxStreams() int { return DefaultMaxStreams } +func (s *fakeSession) Close(string) { s.closed = true } + +// worker ids are stable across reconnects: a re-registration must supersede the +// old epoch and close its session, and the retiring session's Deregister must +// not strand the new one. +func TestRegistrySupersede(t *testing.T) { + g, s := NewRegistry(), testScope() + manifest, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{{ + Path: "/x", Methods: []string{"GET"}, Public: true, + }}) + require.NoError(t, err) + + mk := func() (*Registration, *fakeSession) { + sess := &fakeSession{} + return NewRegistration(RegistrationParams{ + WorkerID: "AW_1", Manifest: manifest, Session: sess, + }), sess + } + + oldReg, oldSess := mk() + g.Register(s, oldReg) + newReg, newSess := mk() + g.Register(s, newReg) + + require.Equal(t, []*Registration{newReg}, s.Candidates()) + require.True(t, oldSess.closed, "superseded epoch's session must be closed") + require.False(t, newSess.closed) + + // the old control connection tears down after the new one registered + g.Deregister(oldReg) + require.Equal(t, []*Registration{newReg}, s.Candidates(), + "the retiring epoch must not deregister its successor") + + g.Deregister(newReg) + require.Empty(t, s.Candidates()) + require.True(t, s.Empty()) + require.True(t, newSess.closed, "deregistered session must be closed") +} + +// The fence is node-wide and scope-blind: worker ids are server-issued, so an +// epoch is superseded wherever it was scoped, and scopes are otherwise +// independent. +func TestRegistryScopesAreIndependent(t *testing.T) { + g := NewRegistry() + alpha, beta := testScope(), testScope() + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{{ + Path: "/x", Methods: []string{"GET"}, Public: true, + }}) + require.NoError(t, err) + + a := NewRegistration(RegistrationParams{WorkerID: "AW_a", Manifest: m, Session: &fakeSession{}}) + b := NewRegistration(RegistrationParams{WorkerID: "AW_b", Manifest: m, Session: &fakeSession{}}) + g.Register(alpha, a) + g.Register(beta, b) + + require.Equal(t, []*Registration{a}, alpha.Candidates()) + require.Equal(t, []*Registration{b}, beta.Candidates()) + + // a scope nothing holds is empty, and holds no route table at all + g.Deregister(a) + require.True(t, alpha.Empty()) + require.Nil(t, alpha.routeTable()) + require.Equal(t, []*Registration{b}, beta.Candidates(), "one scope draining leaves the other") +} + +// A nil scope is the "no worker here holds this deployment" answer, and must be +// safe for the front to interrogate without a separate test. +func TestNilScopeIsEmpty(t *testing.T) { + var s *Scope + require.Nil(t, s.Candidates()) + require.Nil(t, s.routeTable()) + require.True(t, s.Empty()) +} diff --git a/pkg/agent/endpoint/router/alloc_test.go b/pkg/agent/endpoint/router/alloc_test.go new file mode 100644 index 000000000..43d6c1d2d --- /dev/null +++ b/pkg/agent/endpoint/router/alloc_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 LiveKit, Inc. + +//go:build !race + +package router + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The race runtime allocates, so this cannot run under -race. +func TestMatchZeroAlloc(t *testing.T) { + specs := benchSpecs(256) + r, _ := build(t, specs) + for _, c := range benchCases(specs) { + n := testing.AllocsPerRun(200, func() { r.Match(c.path, c.mask) }) + require.Zero(t, n, c.name) + } + + // an ambiguous table searches, and must not allocate while it does + amb, _ := build(t, get("/f/{name}.{ext}", "/{p:path}/end")) + n := testing.AllocsPerRun(200, func() { amb.Match("/f/a.b.c", mGET) }) + require.Zero(t, n, "ambiguous") +} diff --git a/pkg/agent/endpoint/router/bench_test.go b/pkg/agent/endpoint/router/bench_test.go new file mode 100644 index 000000000..40a391db1 --- /dev/null +++ b/pkg/agent/endpoint/router/bench_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "testing" +) + +// benchSpecs is shaped like a FastAPI app: a few static routes, then resource +// collections with an id param, then a catch-all. +func benchSpecs(n int) []spec { + specs := []spec{ + {"/health", mGET}, + {"/ready", mGET}, + {"/metrics", mGET}, + } + for i := len(specs); i < n-1; i += 2 { + res := fmt.Sprintf("/api/v1/res%d", i) + specs = append(specs, spec{res, mGET | mPOST}) + specs = append(specs, spec{res + "/{id:int}", mGET | mPUT}) + } + specs = append(specs, spec{"/static/{p:path}", mGET}) + return specs[:min(n, len(specs))] +} + +type benchCase struct { + name, path string + mask Mask +} + +func benchCases(specs []spec) []benchCase { + last := specs[len(specs)-2].tpl + return []benchCase{ + {"hit_first", "/health", mGET}, + {"hit_last", last[:len(last)-len("/{id:int}")] + "/42", mGET}, + {"method_not_allowed", "/health", mPOST}, + {"miss", "/api/v1/nope/42", mGET}, + } +} + +func BenchmarkMatch(b *testing.B) { + for _, n := range []int{8, 64, 256} { + specs := benchSpecs(n) + r, _ := build(b, specs) + for _, c := range benchCases(specs) { + b.Run(fmt.Sprintf("routes=%d/%s", n, c.name), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + r.Match(c.path, c.mask) + } + }) + } + } +} + +// No shared prefixes, so the root's edge scan cannot be narrowed. +func BenchmarkMatchWideFanout(b *testing.B) { + specs := make([]spec, 0, 256) + for i := range 256 { + specs = append(specs, spec{fmt.Sprintf("/%c%c/x", 'a'+i/16, 'a'+i%16), mGET}) + } + r, _ := build(b, specs) + b.ReportAllocs() + for b.Loop() { + r.Match("/pp/x", mGET) + } +} + +func BenchmarkBuild(b *testing.B) { + specs := benchSpecs(256) + tpls := make([]*Template, len(specs)) + for i, s := range specs { + t, err := ParseTemplate(s.tpl) + if err != nil { + b.Fatal(err) + } + tpls[i] = t + } + b.ReportAllocs() + for b.Loop() { + bld := NewBuilder[string]() + for i, t := range tpls { + _ = bld.Add(t, specs[i].mask, specs[i].tpl) + } + bld.Build() + } +} diff --git a/pkg/agent/endpoint/router/build.go b/pkg/agent/endpoint/router/build.go new file mode 100644 index 000000000..f250eecf6 --- /dev/null +++ b/pkg/agent/endpoint/router/build.go @@ -0,0 +1,177 @@ +// 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 router + +import "errors" + +const MaxRoutes = 1 << 16 + +var errTooManyRoutes = errors.New("router: too many routes") + +type buildNode[T any] struct { + kids []buildEdge[T] + leaves []leaf[T] + minIdx uint32 +} + +type buildEdge[T any] struct { + lit string + to *buildNode[T] + minIdx uint32 + kind kind +} + +// Builder accumulates a route table. A route's index is its position in the +// order added. +type Builder[T any] struct { + root *buildNode[T] + n uint32 + ambiguous []string +} + +func NewBuilder[T any]() *Builder[T] { + return &Builder[T]{root: &buildNode[T]{minIdx: noIdx}} +} + +// Add appends a route. Routes must be added in declaration order: the first +// one added that matches a path wins. +func (b *Builder[T]) Add(t *Template, m Mask, v T) error { + if b.n >= MaxRoutes { + return errTooManyRoutes + } + idx := b.n + b.n++ + + if t.Ambiguous() { + b.ambiguous = append(b.ambiguous, t.raw) + } + + n := b.root + if n.minIdx == noIdx { + n.minIdx = idx + } + for _, e := range t.elements { + if e.kind == kindLiteral { + n = insertLiteral(n, e.lit, idx) + } else { + n = insertParam(n, e.kind, idx) + } + } + n.leaves = append(n.leaves, leaf[T]{idx: idx, mask: m, val: v}) + return nil +} + +// Build freezes the table, compacting its nodes into one arena. +func (b *Builder[T]) Build() *Router[T] { + r := &Router[T]{ambiguous: b.ambiguous, routes: int(b.n)} + r.nodes = make([]node[T], 0, countNodes(b.root)) + r.compact(b.root) + return r +} + +func countNodes[T any](n *buildNode[T]) int { + total := 1 + for i := range n.kids { + total += countNodes(n.kids[i].to) + } + return total +} + +func (r *Router[T]) compact(bn *buildNode[T]) uint32 { + ni := uint32(len(r.nodes)) + r.nodes = append(r.nodes, node[T]{leaves: bn.leaves, minIdx: bn.minIdx}) + + kids := make([]edge, len(bn.kids)) + for i := range bn.kids { + be := &bn.kids[i] + kids[i] = edge{lit: be.lit, minIdx: be.minIdx, kind: be.kind, single: singleRun(be)} + if be.kind == kindLiteral { + kids[i].first = be.lit[0] + } + } + // recurse before wiring: appending children may move the arena + for i := range bn.kids { + kids[i].to = r.compact(bn.kids[i].to) + } + r.nodes[ni].kids = kids + return ni +} + +// singleRun reports that only the convertor's greedy run can lead anywhere: a +// shorter run leaves at the head a byte the convertor could have consumed, and +// no child below can start with one. A terminal param qualifies vacuously. +func singleRun[T any](e *buildEdge[T]) bool { + if e.kind == kindLiteral || e.kind == kindUUID { + return true + } + for i := range e.to.kids { + k := &e.to.kids[i] + if k.kind != kindLiteral || e.kind.charset(k.lit[0]) { + return false + } + } + return true +} + +func insertLiteral[T any](n *buildNode[T], lit string, idx uint32) *buildNode[T] { + for len(lit) > 0 { + e := literalEdge(n, lit[0]) + if e == nil { + child := &buildNode[T]{minIdx: idx} + n.kids = append(n.kids, buildEdge[T]{kind: kindLiteral, lit: lit, to: child, minIdx: idx}) + return child + } + cp := commonPrefix(e.lit, lit) + if cp < len(e.lit) { + // the edge keeps its slot and its minIdx, so kids stay ordered + mid := &buildNode[T]{minIdx: e.minIdx} + mid.kids = append(mid.kids, buildEdge[T]{kind: kindLiteral, lit: e.lit[cp:], to: e.to, minIdx: e.minIdx}) + e.lit, e.to = e.lit[:cp], mid + } + n, lit = e.to, lit[cp:] + } + return n +} + +func insertParam[T any](n *buildNode[T], k kind, idx uint32) *buildNode[T] { + for i := range n.kids { + if n.kids[i].kind == k { + return n.kids[i].to + } + } + child := &buildNode[T]{minIdx: idx} + n.kids = append(n.kids, buildEdge[T]{kind: k, to: child, minIdx: idx}) + return child +} + +// literalEdge finds the one literal edge that can consume c. Splitting keeps +// the first bytes of a node's literal edges distinct, so there is at most one. +func literalEdge[T any](n *buildNode[T], c byte) *buildEdge[T] { + for i := range n.kids { + if e := &n.kids[i]; e.kind == kindLiteral && e.lit[0] == c { + return e + } + } + return nil +} + +func commonPrefix(a, b string) int { + n := min(len(a), len(b)) + i := 0 + for i < n && a[i] == b[i] { + i++ + } + return i +} diff --git a/pkg/agent/endpoint/router/fuzz_test.go b/pkg/agent/endpoint/router/fuzz_test.go new file mode 100644 index 000000000..cec93d1dd --- /dev/null +++ b/pkg/agent/endpoint/router/fuzz_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fragments compose into templates. %d takes a per-template counter so +// generated param names never collide, which ParseTemplate rejects. +var fragments = []string{ + "/a", "/b", "/ab", "/abc", "/", + "/{p%d}", "/{n%d:int}", "/{f%d:float}", "/{u%d:uuid}", "/{r%d:path}", + "{q%d}", ".json", ".{e%d}", "-{s%d}", "0", "/end", "//", "\n", "{", "x", +} + +func genSpecs(script []byte) []spec { + var specs []spec + var b strings.Builder + n := 0 + flush := func() { + if b.Len() > 0 { + specs = append(specs, spec{"/" + b.String(), Mask(n%7) + 1}) + b.Reset() + } + } + for _, c := range script { + if int(c)%(len(fragments)+2) >= len(fragments) { + flush() + continue + } + fmt.Fprintf(&b, fragments[int(c)%len(fragments)], n) + n++ + } + flush() + if len(specs) > 64 { + specs = specs[:64] + } + return specs +} + +// The trie answers what a linear scan of compiled starlette patterns answers: +// the same winning template and the same result, for every table and path. +func FuzzMatchAgainstOracle(f *testing.F) { + f.Add([]byte{5, 200, 0, 5}, "/x/y", uint8(1)) + f.Add([]byte{5, 1, 200, 1, 5}, "/a/b", uint8(1)) + f.Add([]byte{9, 200, 15}, "/a/b/end", uint8(1)) + f.Add([]byte{5, 12, 200, 5}, "/f/a.b.json", uint8(1)) + f.Add([]byte{7, 11, 200, 7}, "/1.25.json", uint8(3)) + f.Add([]byte{8, 200, 8, 15}, "/123e4567e89b12d3a456426614174000", uint8(1)) + f.Add([]byte{0, 1, 2, 3, 200, 0, 200, 2}, "/ab", uint8(1)) + f.Add([]byte{5, 200, 5, 200, 5}, "/x\n", uint8(7)) + f.Add([]byte{13, 5, 200, 5}, "/a-b", uint8(1)) + + f.Fuzz(func(t *testing.T, script []byte, path string, q uint8) { + specs := genSpecs(script) + if len(specs) == 0 { + return + } + + b := NewBuilder[string]() + o := &oracle{} + added := 0 + for _, s := range specs { + tpl, err := ParseTemplate(s.tpl) + if err != nil { + continue + } + require.NoError(t, o.add(s.tpl, s.mask), "oracle rejected an accepted template %q", s.tpl) + require.NoError(t, b.Add(tpl, s.mask, s.tpl)) + added++ + } + if added == 0 { + return + } + r := b.Build() + + mask := Mask(q) + got, res := r.Match(path, mask) + if res == ResultOverBudget { + return + } + wantTpl, wantRes := o.match(path, mask) + require.Equal(t, wantRes, res, "path %q mask %d over %v", path, mask, specs) + require.Equal(t, wantTpl, got, "path %q mask %d over %v", path, mask, specs) + }) +} diff --git a/pkg/agent/endpoint/router/match.go b/pkg/agent/endpoint/router/match.go new file mode 100644 index 000000000..3617d7e2a --- /dev/null +++ b/pkg/agent/endpoint/router/match.go @@ -0,0 +1,129 @@ +// 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 router + +import "strings" + +// state lives in Match's frame and is threaded by pointer; nothing reachable +// from it may escape. +type state[T any] struct { + path string + q Mask + val T + // best is the lowest index of a route matching both path and mask. Only a + // full match may assign it, since it gates pruning. + best uint32 + // acc is the second accepting position, len(path)-1 when the path ends in a + // newline and -1 otherwise: a template anchors as `\n?$`. + acc int + steps int + partial bool +} + +// Match returns the value of the first route in declaration order whose +// template matches path and whose mask intersects q. It returns ResultPartial +// when a template matched but no such route carried the mask, and +// ResultOverBudget when the search exceeded its step budget. +// +// Match allocates nothing. +func (r *Router[T]) Match(path string, q Mask) (T, Result) { + st := state[T]{path: path, q: q, best: noIdx, acc: -1} + if n := len(path); n > 0 && path[n-1] == '\n' { + st.acc = n - 1 + } + + if !r.walk(0, 0, &st) { + // a cut-short search may have missed a lower-indexed route, so anything + // found is not necessarily what declaration order selects + var zero T + return zero, ResultOverBudget + } + switch { + case st.best != noIdx: + return st.val, ResultFull + case st.partial: + var zero T + return zero, ResultPartial + default: + var zero T + return zero, ResultNone + } +} + +// walk explores the subtree at ni with pos bytes of the path consumed, +// reporting false when the step budget ran out. Declaration order is +// uncorrelated with depth, so a match is not a stopping condition; only the +// minIdx prune cuts the scan short. +func (r *Router[T]) walk(ni uint32, pos int, st *state[T]) bool { + n := &r.nodes[ni] + + if pos == len(st.path) || pos == st.acc { + for i := range n.leaves { + lf := &n.leaves[i] + if lf.idx >= st.best { + break + } + if lf.mask&st.q != 0 { + st.best, st.val = lf.idx, lf.val + break + } + st.partial = true + } + // a path convertor matches the empty string, so a route may still + // terminate below this node at this same position + } + + rest := st.path[pos:] + for i := range n.kids { + e := &n.kids[i] + if e.minIdx >= st.best { + break // kids ascend by minIdx, so no later one can improve on best + } + + if e.kind == kindLiteral { + if len(e.lit) > len(rest) || rest[0] != e.first || !strings.HasPrefix(rest, e.lit) { + continue + } + if !r.walk(e.to, pos+len(e.lit), st) { + return false + } + continue + } + + k := e.kind.scan(rest) + if k < 0 { + continue + } + if e.single { + if !r.walk(e.to, pos+k, st) { + return false + } + continue + } + for ; k >= 0; k = e.kind.next(rest, k) { + st.steps++ + if st.steps > maxSteps { + return false + } + if !r.walk(e.to, pos+k, st) { + return false + } + if e.minIdx >= st.best { + break + } + } + } + return true +} diff --git a/pkg/agent/endpoint/router/oracle_test.go b/pkg/agent/endpoint/router/oracle_test.go new file mode 100644 index 000000000..abec73be2 --- /dev/null +++ b/pkg/agent/endpoint/router/oracle_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "regexp" + "strings" +) + +// One compiled regex per route, scanned linearly. Transcribed from starlette's +// compile_path and carrying its own parser, so it references the hand-rolled +// scanner as well as the trie. + +var oracleConvertors = map[string]string{ + "str": `[^/]+`, + "path": `.*`, + "int": `[0-9]+`, + "float": `[0-9]+(?:\.[0-9]+)?`, + "uuid": `[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}`, +} + +// starlette's PARAM_REGEX +var oracleParamRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?\}`) + +func oracleCompile(path string) (*regexp.Regexp, error) { + var pattern strings.Builder + pattern.WriteString("^") + + idx := 0 + for _, m := range oracleParamRegex.FindAllStringSubmatchIndex(path, -1) { + start, end := m[0], m[1] + convertor := "str" + if m[4] != -1 { + convertor = path[m[4]+1 : m[5]] + } + p, ok := oracleConvertors[convertor] + if !ok { + return nil, fmt.Errorf("unknown convertor %q", convertor) + } + pattern.WriteString(regexp.QuoteMeta(path[idx:start])) + pattern.WriteString("(?:") + pattern.WriteString(p) + pattern.WriteString(")") + idx = end + } + pattern.WriteString(regexp.QuoteMeta(path[idx:])) + // python's '$' is Go's `\n?\z` + pattern.WriteString("\n?$") + + return regexp.Compile(pattern.String()) +} + +type oracleRoute struct { + re *regexp.Regexp + raw string + mask Mask +} + +type oracle struct{ routes []oracleRoute } + +func (o *oracle) add(raw string, mask Mask) error { + re, err := oracleCompile(raw) + if err != nil { + return err + } + o.routes = append(o.routes, oracleRoute{re: re, raw: raw, mask: mask}) + return nil +} + +func (o *oracle) match(path string, q Mask) (string, Result) { + partial := false + for _, r := range o.routes { + if !r.re.MatchString(path) { + continue + } + if r.mask&q != 0 { + return r.raw, ResultFull + } + partial = true + } + if partial { + return "", ResultPartial + } + return "", ResultNone +} diff --git a/pkg/agent/endpoint/router/router.go b/pkg/agent/endpoint/router/router.go new file mode 100644 index 000000000..5511d54cd --- /dev/null +++ b/pkg/agent/endpoint/router/router.go @@ -0,0 +1,118 @@ +// 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 router matches request paths against an ordered table of +// starlette-style path templates. The first route declared that matches wins: +// declaration order is the entire priority rule. +package router + +import ( + "go.uber.org/zap/zapcore" +) + +// maxSteps bounds the backtracking a single Match may do. Only a non-single +// edge spends a step, so a table of unambiguous templates never reaches it. +const maxSteps = 100_000 + +// Mask is an opaque per-route tag. A route fully matches when its mask +// intersects the query mask, and partially matches when only its template does. +type Mask uint32 + +type Result uint8 + +const ( + ResultNone Result = iota + // ResultPartial means a template matched but no route carrying the queried + // mask did. + ResultPartial + ResultFull + // ResultOverBudget means the search exceeded maxSteps and was abandoned, so + // no route was decided. + ResultOverBudget +) + +func (r Result) String() string { + switch r { + case ResultNone: + return "none" + case ResultPartial: + return "partial" + case ResultFull: + return "full" + case ResultOverBudget: + return "over-budget" + } + return "unknown" +} + +// noIdx is above every route index, so an unset best never prunes. +const noIdx = ^uint32(0) + +type edge struct { + lit string // kindLiteral only + to uint32 // index into Router.nodes + minIdx uint32 // mirrors nodes[to].minIdx, so a prune dereferences no child + kind kind + first byte // kindLiteral only: lit[0] + single bool // the convertor admits one viable run at any position +} + +type leaf[T any] struct { + idx uint32 + mask Mask + val T +} + +type node[T any] struct { + kids []edge + // several routes can terminate at one node: /u/{id} and /u/{name} compile + // alike, and a path may be declared once per method + leaves []leaf[T] + minIdx uint32 +} + +// Router matches paths against a route table. It is built once and never +// mutated, so readers need no lock and a new table is published by a single +// atomic.Pointer store, which synchronises everything a reader reaches. +type Router[T any] struct { + nodes []node[T] + ambiguous []string + routes int +} + +// MarshalLogObject describes the table's shape. A Router holds no per-match +// state. +func (r *Router[T]) MarshalLogObject(e zapcore.ObjectEncoder) error { + if r == nil { + return nil + } + e.AddInt("routes", r.routes) + if len(r.ambiguous) > 0 { + err := e.AddArray("ambiguous", zapcore.ArrayMarshalerFunc(func(a zapcore.ArrayEncoder) error { + for _, t := range r.ambiguous { + a.AppendString(t) + } + return nil + })) + if err != nil { + return err + } + } + return nil +} + +// Ambiguous returns the templates whose shape forces the matcher to backtrack - +// a param that a following literal can extend, adjacent params, or a non-final +// path convertor. The result is read-only. +func (r *Router[T]) Ambiguous() []string { return r.ambiguous } diff --git a/pkg/agent/endpoint/router/router_test.go b/pkg/agent/endpoint/router/router_test.go new file mode 100644 index 000000000..c9537401f --- /dev/null +++ b/pkg/agent/endpoint/router/router_test.go @@ -0,0 +1,254 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +const ( + mGET Mask = 1 << iota + mPOST + mPUT +) + +type spec struct { + tpl string + mask Mask +} + +func get(tpls ...string) []spec { + s := make([]spec, len(tpls)) + for i, t := range tpls { + s[i] = spec{t, mGET} + } + return s +} + +func build(t testing.TB, specs []spec) (*Router[string], *oracle) { + t.Helper() + b := NewBuilder[string]() + o := &oracle{} + for _, s := range specs { + tpl, err := ParseTemplate(s.tpl) + require.NoError(t, err, s.tpl) + require.NoError(t, b.Add(tpl, s.mask, s.tpl)) + require.NoError(t, o.add(s.tpl, s.mask), s.tpl) + } + return b.Build(), o +} + +// Every case asserts the expected answer and that the oracle agrees. +func TestMatchAdversarial(t *testing.T) { + cases := []struct { + name string + routes []spec + path string + mask Mask + want string + res Result + }{ + // declaration order decides + {"order beats specificity", get("/u/{id}", "/u/me"), "/u/me", mGET, "/u/{id}", ResultFull}, + {"order the other way", get("/u/me", "/u/{id}"), "/u/me", mGET, "/u/me", ResultFull}, + {"order with int first", get("/x/{a:int}", "/x/{b}"), "/x/42", mGET, "/x/{a:int}", ResultFull}, + {"order falls through to str", get("/x/{a:int}", "/x/{b}"), "/x/4x", mGET, "/x/{b}", ResultFull}, + {"literal buried deep", get("/a/{x}/c", "/a/b/c"), "/a/b/c", mGET, "/a/{x}/c", ResultFull}, + + // the method dimension must not let a partial prune a later full match + {"later method wins", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull}, + {"wildcard partial then literal full", []spec{{"/{a}", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull}, + {"partial at a lower index", []spec{{"/a/{x}", mGET}, {"/a/b", mPOST}}, "/a/b", mPOST, "/a/b", ResultFull}, + {"no method matches", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPUT, "", ResultPartial}, + {"nothing matches", get("/x"), "/nope", mGET, "", ResultNone}, + + // mid-segment params, which the walk must search for + {"name dot ext", get("/f/{name}.{ext}"), "/f/a.b.c", mGET, "/f/{name}.{ext}", ResultFull}, + {"name dot ext needs a dot", get("/f/{name}.{ext}"), "/f/abc", mGET, "", ResultNone}, + {"suffix literal", get("/f/{name}.json"), "/f/a.b.json", mGET, "/f/{name}.json", ResultFull}, + {"prefix and suffix", get("/v{major}.{minor}"), "/v1.2", mGET, "/v{major}.{minor}", ResultFull}, + {"adjacent params", get("/{a}{b}"), "/xy", mGET, "/{a}{b}", ResultFull}, + + // greedy path, which crosses '/' + {"path backtracks to a literal", get("/{p:path}/end"), "/a/b/end/end", mGET, "/{p:path}/end", ResultFull}, + {"path matches empty", get("/files/{p:path}"), "/files/", mGET, "/files/{p:path}", ResultFull}, + {"path spans slashes", get("/files/{p:path}"), "/files/a/b/c.txt", mGET, "/files/{p:path}", ResultFull}, + {"path refuses newline", get("/files/{p:path}"), "/files/a\nb", mGET, "", ResultNone}, + {"str accepts newline", get("/files/{p}"), "/files/a\nb", mGET, "/files/{p}", ResultFull}, + + // a template anchors as `\n?$` + {"trailing newline accepted", get("/x"), "/x\n", mGET, "/x", ResultFull}, + {"two trailing newlines rejected", get("/x"), "/x\n\n", mGET, "", ResultNone}, + {"path convertor eats to the newline", get("/files/{p:path}"), "/files/a\n", mGET, "/files/{p:path}", ResultFull}, + + // float's optional fraction backtracks + {"float with fraction", get("/p/{v:float}.json"), "/p/1.25.json", mGET, "/p/{v:float}.json", ResultFull}, + {"float without fraction", get("/p/{v:float}.json"), "/p/1.json", mGET, "/p/{v:float}.json", ResultFull}, + {"float rejects a bare dot", get("/p/{v:float}"), "/p/1.", mGET, "", ResultNone}, + {"float takes the fraction", get("/p/{v:float}"), "/p/1.25", mGET, "/p/{v:float}", ResultFull}, + + // int followed by a digit needs a shorter run + {"int backtracks off a digit", get("/{n:int}0/x"), "/120/x", mGET, "/{n:int}0/x", ResultFull}, + + // uuid admits exactly one length + {"uuid hyphenated", get("/o/{u:uuid}"), "/o/123e4567-e89b-12d3-a456-426614174000", mGET, "/o/{u:uuid}", ResultFull}, + {"uuid bare", get("/o/{u:uuid}"), "/o/123e4567e89b12d3a456426614174000", mGET, "/o/{u:uuid}", ResultFull}, + {"uuid then literal", get("/o/{u:uuid}/z"), "/o/123e4567e89b12d3a456426614174000/z", mGET, "/o/{u:uuid}/z", ResultFull}, + {"uuid too short", get("/o/{u:uuid}"), "/o/123e4567", mGET, "", ResultNone}, + + // radix splits + {"split abc", get("/abc", "/abd", "/ab", "/a"), "/ab", mGET, "/ab", ResultFull}, + {"split abd", get("/abc", "/abd", "/ab", "/a"), "/abd", mGET, "/abd", ResultFull}, + {"split a", get("/abc", "/abd", "/ab", "/a"), "/a", mGET, "/a", ResultFull}, + {"split reversed", get("/a", "/ab", "/abd", "/abc"), "/abc", mGET, "/abc", ResultFull}, + {"split miss", get("/abc", "/abd"), "/abe", mGET, "", ResultNone}, + + // literals are byte-exact + {"case sensitive", get("/token"), "/Token", mGET, "", ResultNone}, + {"trailing slash is not implied", get("/token"), "/token/", mGET, "", ResultNone}, + {"registered with a slash", get("/token/"), "/token/", mGET, "/token/", ResultFull}, + + // degenerate paths + {"root", get("/"), "/", mGET, "/", ResultFull}, + {"double slash", get("/", "//"), "//", mGET, "//", ResultFull}, + {"empty segment", get("/a//b"), "/a//b", mGET, "/a//b", ResultFull}, + {"str will not cross a slash", get("/a/{x}"), "/a/b/c", mGET, "", ResultNone}, + {"str needs a byte", get("/a/{x}"), "/a/", mGET, "", ResultNone}, + {"a decoded %2F is just a slash", get("/files/{p}"), "/files/a/b", mGET, "", ResultNone}, + + // a brace that opens nothing well-formed is a literal + {"stray brace", get("/a{b"), "/a{b", mGET, "/a{b", ResultFull}, + {"empty convertor is literal", get("/x/{a:}"), "/x/{a:}", mGET, "/x/{a:}", ResultFull}, + {"digit-leading name is literal", get("/x/{1bad}"), "/x/{1bad}", mGET, "/x/{1bad}", ResultFull}, + {"doubled brace", get("/{{a}"), "/{x", mGET, "/{{a}", ResultFull}, + } + + for _, c := range cases { + r, o := build(t, c.routes) + + got, res := r.Match(c.path, c.mask) + require.Equal(t, c.res, res, "%s: %q", c.name, c.path) + require.Equal(t, c.want, got, "%s: %q", c.name, c.path) + + oGot, oRes := o.match(c.path, c.mask) + require.Equal(t, c.res, oRes, "oracle disagrees on %s: %q", c.name, c.path) + require.Equal(t, c.want, oGot, "oracle disagrees on %s: %q", c.name, c.path) + } +} + +// Whether a template can force a search is fixed at build time. +func TestAmbiguousClassification(t *testing.T) { + cases := []struct { + tpl string + ambiguous bool + }{ + {"/static", false}, + {"/u/{id}", false}, + {"/u/{id}/posts", false}, + {"/u/{id:int}/posts", false}, + {"/files/{p:path}", false}, + {"/o/{u:uuid}/z", false}, + {"/o/{u:uuid}{rest}", false}, + {"/f/{name}.json", true}, + {"/f/{name}.{ext}", true}, + {"/{p:path}/end", true}, + {"/p/{v:float}.json", true}, + {"/{n:int}0/x", true}, + {"/{a}{b}", true}, + {"/{n:int}/x", false}, + {"/{v:float}/x", false}, + } + for _, c := range cases { + r, _ := build(t, get(c.tpl)) + require.Equal(t, c.ambiguous, len(r.Ambiguous()) == 1, "%s", c.tpl) + } +} + +// An unambiguous table holds no edge that can search, so no path length reaches +// the budget; an ambiguous one gives up at it. +func TestStepBudget(t *testing.T) { + r, _ := build(t, get("/api/{v:int}/res/{id}/sub/{name}")) + for i := range r.nodes { + for _, e := range r.nodes[i].kids { + require.True(t, e.single, "%q", e.lit) + } + } + _, res := r.Match("/api/1/res/"+strings.Repeat("x", 4096)+"/sub/y", mGET) + require.Equal(t, ResultFull, res) + + r, _ = build(t, get("/{a}{b}{c}{d}x")) + _, res = r.Match("/"+strings.Repeat("a", 4096), mGET) + require.Equal(t, ResultOverBudget, res) +} + +func TestEmptyRouter(t *testing.T) { + r := NewBuilder[string]().Build() + got, res := r.Match("/x", mGET) + require.Equal(t, ResultNone, res) + require.Empty(t, got) +} + +// Readers match against a published table while writers swap in new ones. +func TestConcurrentSwapUnderLoad(t *testing.T) { + tables := make([]*Router[string], 4) + for i := range tables { + tables[i], _ = build(t, get( + fmt.Sprintf("/v%d/{id:int}", i), + "/f/{name}.json", + "/static/{p:path}", + "/health", + )) + } + + var h atomic.Pointer[Router[string]] + h.Store(tables[0]) + + var stop atomic.Bool + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + for i := 0; !stop.Load(); i++ { + h.Store(tables[i%len(tables)]) + } + }) + } + for range 8 { + wg.Go(func() { + for !stop.Load() { + r := h.Load() + r.Match("/v2/42", mGET) + r.Match("/f/a.b.json", mGET) + r.Match("/static/a/b/c", mGET) + r.Match("/health", mPOST) + } + }) + } + time.Sleep(200 * time.Millisecond) + stop.Store(true) + wg.Wait() +} + +func TestMarshalLogObject(t *testing.T) { + r, _ := build(t, get("/health", "/f/{name}.json")) + + enc := zapcore.NewMapObjectEncoder() + require.NoError(t, r.MarshalLogObject(enc)) + require.Equal(t, 2, enc.Fields["routes"]) + require.Equal(t, []any{"/f/{name}.json"}, enc.Fields["ambiguous"]) + + plain, _ := build(t, get("/health")) + enc = zapcore.NewMapObjectEncoder() + require.NoError(t, plain.MarshalLogObject(enc)) + require.NotContains(t, enc.Fields, "ambiguous") + + var nilRouter *Router[string] + require.NoError(t, nilRouter.MarshalLogObject(zapcore.NewMapObjectEncoder())) +} diff --git a/pkg/agent/endpoint/router/scan.go b/pkg/agent/endpoint/router/scan.go new file mode 100644 index 000000000..f5ab748d6 --- /dev/null +++ b/pkg/agent/endpoint/router/scan.go @@ -0,0 +1,130 @@ +// 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 router + +import "strings" + +// Scanning is byte-wise: every byte a convertor distinguishes ('/', '\n', and +// [0-9a-fA-F-]) is ASCII, and UTF-8 is self-synchronising, so no multi-byte +// sequence can contain one. + +// scan returns the longest run the convertor consumes at the head of s, or -1 +// when it cannot match at all. +func (k kind) scan(s string) int { + switch k { + case kindStr: + if n := runTo(s, '/'); n > 0 { + return n + } + case kindPath: + return runTo(s, '\n') + case kindInt: + if n := digitRun(s); n > 0 { + return n + } + case kindFloat: + d := digitRun(s) + if d == 0 { + break + } + if d < len(s) && s[d] == '.' { + if f := digitRun(s[d+1:]); f > 0 { + return d + 1 + f + } + } + return d + case kindUUID: + return uuidRun(s) + } + return -1 +} + +// next returns the next shorter viable run after prev, or -1 when prev was the +// last candidate. Candidates descend, so runs are enumerated greedily. +func (k kind) next(s string, prev int) int { + switch k { + case kindStr, kindInt: + if prev > 1 { + return prev - 1 + } + case kindPath: + if prev > 0 { + return prev - 1 + } + case kindFloat: + // the integer part is a digit run from the head, so a '.' can only sit + // at its end: viable runs are 1..d and d+1+1..d+1+f, never d+1 + d := digitRun(s) + switch { + case prev > d+2: + return prev - 1 + case prev == d+2: + return d + case prev > 1: + return prev - 1 + } + case kindUUID: + // every '-?' is forced by the input: skipping a present hyphen demands a + // hex digit where the hyphen is, so the shape admits one length + } + return -1 +} + +// charset reports whether a shorter run of this convertor could be followed by +// c, which is what makes an edge ambiguous. +func (k kind) charset(c byte) bool { + switch k { + case kindStr: + return c != '/' + case kindPath: + return c != '\n' + case kindInt: + return isDigit(c) + case kindFloat: + return isDigit(c) || c == '.' + } + return false +} + +func runTo(s string, c byte) int { + if i := strings.IndexByte(s, c); i >= 0 { + return i + } + return len(s) +} + +func digitRun(s string) int { + i := 0 + for i < len(s) && isDigit(s[i]) { + i++ + } + return i +} + +func uuidRun(s string) int { + i := 0 + for g, n := range [...]int{8, 4, 4, 4, 12} { + if g > 0 && i < len(s) && s[i] == '-' { + i++ + } + for range n { + if i >= len(s) || !isHex(s[i]) { + return -1 + } + i++ + } + } + return i +} diff --git a/pkg/agent/endpoint/router/template.go b/pkg/agent/endpoint/router/template.go new file mode 100644 index 000000000..d329ec4be --- /dev/null +++ b/pkg/agent/endpoint/router/template.go @@ -0,0 +1,201 @@ +// 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 router + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +// kind is what one element of a template consumes. The convertor set is part +// of the wire contract and is closed. +type kind uint8 + +const ( + kindLiteral kind = iota + kindStr // [^/]+ + kindPath // .*, which excludes '\n' + kindInt // [0-9]+ + kindFloat // [0-9]+(?:\.[0-9]+)? + kindUUID // 8-4-4-4-12 hex, every hyphen optional +) + +func convertorKind(name string) (kind, bool) { + switch name { + case "str": + return kindStr, true + case "path": + return kindPath, true + case "int": + return kindInt, true + case "float": + return kindFloat, true + case "uuid": + return kindUUID, true + } + return 0, false +} + +// element is one step of a parsed template: literal bytes to match exactly, or +// a convertor to consume. +type element struct { + kind kind + lit string // kindLiteral only +} + +// Template is a parsed starlette-style path template. Parsing mirrors +// starlette's compile_path exactly. +type Template struct { + // the template as declared + raw string + elements []element +} + +// String returns the template as declared. +func (t *Template) String() string { return t.raw } + +// Canonical returns a key identifying what the template matches: two templates +// with the same canonical form accept exactly the same paths and differ only in +// param names. Literals are length-prefixed so that a literal brace cannot forge +// a convertor - "/x/{:str}" is literal text, and must not collide with "/x/{a}". +func (t *Template) Canonical() string { + var b strings.Builder + for _, e := range t.elements { + if e.kind == kindLiteral { + b.WriteByte('L') + b.WriteString(strconv.Itoa(len(e.lit))) + b.WriteByte(':') + b.WriteString(e.lit) + continue + } + b.WriteByte('K') + b.WriteString(strconv.Itoa(int(e.kind))) + } + return b.String() +} + +// Ambiguous reports whether the template's own shape can force the matcher to +// backtrack - a param a following literal can extend, adjacent params, or a +// non-final path convertor - independent of what other templates put in the +// tree. +func (t *Template) Ambiguous() bool { + for i, e := range t.elements { + if e.kind == kindLiteral || e.kind == kindUUID { + continue + } + if i == len(t.elements)-1 { + continue // terminal: only the greedy run can reach the end + } + next := t.elements[i+1] + if next.kind != kindLiteral || e.kind.charset(next.lit[0]) { + return true + } + } + return false +} + +// ParseTemplate parses a starlette path template. Custom convertors are +// rejected: only the five built-ins may travel over the wire. +func ParseTemplate(path string) (*Template, error) { + if !strings.HasPrefix(path, "/") { + return nil, fmt.Errorf("path template must start with '/': %q", path) + } + // literals are compared byte-wise, which agrees with rune-wise semantics + // only for valid UTF-8 + if !utf8.ValidString(path) { + return nil, fmt.Errorf("path template is not valid UTF-8: %q", path) + } + + t := &Template{raw: path} + seen := map[string]struct{}{} + lit := 0 + for i := 0; i < len(path); { + name, convertor, end, ok := scanParam(path, i) + if !ok { + i++ + continue + } + k, ok := convertorKind(convertor) + if !ok { + return nil, fmt.Errorf("unknown path convertor %q in template %q", convertor, path) + } + if _, dup := seen[name]; dup { + return nil, fmt.Errorf("duplicated param name %q in template %q", name, path) + } + seen[name] = struct{}{} + + if i > lit { + t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:i]}) + } + t.elements = append(t.elements, element{kind: k}) + i, lit = end, end + } + if lit < len(path) { + t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:]}) + } + return t, nil +} + +// scanParam matches starlette's PARAM_REGEX at i: +// +// {([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?} +// +// Everything between matches is a literal, so a brace that does not open a +// well-formed param is an ordinary literal character. +func scanParam(s string, i int) (name, convertor string, end int, ok bool) { + if s[i] != '{' { + return "", "", 0, false + } + j := i + 1 + n := scanIdent(s, j) + if n == j { + return "", "", 0, false + } + name, j = s[j:n], n + + convertor = "str" + if j < len(s) && s[j] == ':' { + c := scanIdent(s, j+1) + if c == j+1 { + return "", "", 0, false + } + convertor, j = s[j+1:c], c + } + if j >= len(s) || s[j] != '}' { + return "", "", 0, false + } + return name, convertor, j + 1, true +} + +func scanIdent(s string, i int) int { + if i >= len(s) || !isIdentStart(s[i]) { + return i + } + j := i + 1 + for j < len(s) && (isIdentStart(s[j]) || isDigit(s[j])) { + j++ + } + return j +} + +func isAlpha(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isIdentStart(c byte) bool { return isAlpha(c) || c == '_' } + +func isHex(c byte) bool { + return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} diff --git a/pkg/agent/endpoint/router/template_test.go b/pkg/agent/endpoint/router/template_test.go new file mode 100644 index 000000000..e03f5942f --- /dev/null +++ b/pkg/agent/endpoint/router/template_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func matches(t *testing.T, tpl, path string) bool { + t.Helper() + r, o := build(t, get(tpl)) + got, res := r.Match(path, mGET) + oGot, oRes := o.match(path, mGET) + require.Equal(t, oRes, res, "oracle disagrees: %q vs %q", tpl, path) + require.Equal(t, oGot, got, "oracle disagrees: %q vs %q", tpl, path) + return res == ResultFull +} + +func TestTemplateStarletteSemantics(t *testing.T) { + cases := []struct { + template string + path string + match bool + }{ + {"/token", "/token", true}, + {"/token", "/token/", false}, + {"/token", "/Token", false}, + {"/users/{id}", "/users/42", true}, + {"/users/{id}", "/users/42/posts", false}, + {"/users/{id}", "/users/", false}, + {"/users/{id:int}", "/users/42", true}, + {"/users/{id:int}", "/users/4x2", false}, + {"/files/{p:path}", "/files/a/b/c.txt", true}, + {"/files/{p:path}", "/files/", true}, + {"/price/{v:float}", "/price/1.25", true}, + {"/price/{v:float}", "/price/1.", false}, + {"/obj/{u:uuid}", "/obj/123e4567-e89b-12d3-a456-426614174000", true}, + // starlette's uuid convertor makes every hyphen optional + {"/obj/{u:uuid}", "/obj/123e4567e89b12d3a456426614174000", true}, + {"/obj/{u:uuid}", "/obj/123e4567", false}, + {"/a/{x}/b/{y}", "/a/1/b/2", true}, + {"/a/{x}/b/{y}", "/a/1/c/2", false}, + } + for _, c := range cases { + require.Equal(t, c.match, matches(t, c.template, c.path), "%s vs %s", c.template, c.path) + } +} + +// Vectors generated from CPython against a transcription of compile_path. +func TestTemplateTrailingNewline(t *testing.T) { + cases := []struct { + template string + path string + match bool + }{ + {"/x", "/x\n", true}, + {"/x", "/x\n\n", false}, + {"/x", "/x\nz", false}, + {"/files/{p:path}", "/files/a\n", true}, + {"/files/{p:path}", "/files/a\nb", false}, + {"/files/{p}", "/files/a\n", true}, + {"/files/{p}", "/files/a\nb", true}, + {"/n/{i:int}", "/n/42\n", true}, + {"/n/{i:int}", "/n/4\n2", false}, + {"/", "/\n", true}, + {"/a/", "/a/\n", true}, + } + for _, c := range cases { + require.Equal(t, c.match, matches(t, c.template, c.path), "%q vs %q", c.template, c.path) + } +} + +func TestParseTemplateRejects(t *testing.T) { + for _, tpl := range []string{ + "/x/{id:slug}", + "/x/{a}/{a}", + "no-slash", + "/x/\xff", + } { + _, err := ParseTemplate(tpl) + require.Error(t, err, tpl) + } +} + +// A brace that opens nothing well-formed is an ordinary literal. +func TestParseTemplateLenientBraces(t *testing.T) { + for _, tpl := range []string{"/a{b", "/x/{a:}", "/x/{1bad}", "/{{a}", "/}", "/{}"} { + tt, err := ParseTemplate(tpl) + require.NoError(t, err, tpl) + require.Equal(t, tpl, tt.String()) + } +} diff --git a/pkg/agent/endpoint/routes.go b/pkg/agent/endpoint/routes.go new file mode 100644 index 000000000..759af4315 --- /dev/null +++ b/pkg/agent/endpoint/routes.go @@ -0,0 +1,257 @@ +// 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 endpoint + +import ( + "cmp" + "slices" + "sync" + "sync/atomic" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" +) + +// routeKey identifies one route: the template shape it matches and the single +// method it serves. Templates differing only in param names share a shape, so +// /x/{a} and /x/{b} are one route. +type routeKey struct { + canonical string + method router.Mask +} + +// Route is one endpoint of a deployment and every worker that declared it. The +// compiled table holds these by pointer: a route outlives the tables built +// around it, and its worker set changes without a rebuild. +type Route struct { + Template *router.Template + Mask router.Mask + + key routeKey + // ord mirrors the merge position the published table was built from + ord uint32 + + lock sync.RWMutex + workers []routeWorker +} + +// routeWorker is one worker's declaration of a route. Declarers of one route can +// disagree about visibility, position and spelling. +type routeWorker struct { + reg *Registration + raw string // the template as this worker declared it + public bool + idx uint32 // its position in this worker's manifest +} + +// declare records one worker's claim. A registration appears at most once, and +// callers walk endpoints in declaration order, so a path and method named twice +// keeps its first position. +func (r *Route) declare(w routeWorker) { + r.lock.Lock() + defer r.lock.Unlock() + for i := range r.workers { + if r.workers[i].reg == w.reg { + return + } + } + r.workers = append(r.workers, w) +} + +// retract drops a registration's claim. Matching is by pointer: a supersede has +// both epochs live at once under one worker id. +func (r *Route) retract(reg *Registration) { + r.lock.Lock() + defer r.lock.Unlock() + r.workers = slices.DeleteFunc(r.workers, func(w routeWorker) bool { return w.reg == reg }) +} + +// position reports the route's merge position - the lowest index it holds in any +// declaring manifest - and whether anything still declares it. +func (r *Route) position() (ord uint32, live bool) { + r.lock.RLock() + defer r.lock.RUnlock() + ord = noOrder + for _, w := range r.workers { + ord = min(ord, w.idx) + } + return ord, len(r.workers) > 0 +} + +const noOrder = ^uint32(0) + +// eligible copies out the workers that may serve the request. Nothing here may +// reach into a Registration: this lock must not be held across the transport. +func (r *Route) eligible(granted bool) (workers []routeWorker, denied bool) { + r.lock.RLock() + defer r.lock.RUnlock() + for _, w := range r.workers { + if !granted && !w.public { + denied = true + continue + } + workers = append(workers, w) + } + return +} + +// routeTable is a deployment's merged route table. The tree is rebuilt only when +// the route set or its order changes, and is published by a single atomic store +// under lock. +type routeTable struct { + logger logger.Logger + + lock sync.Mutex + routes map[routeKey]*Route + tree atomic.Pointer[router.Router[*Route]] +} + +// newRouteTable takes the scope's logger as-is: whatever identifies the scope is +// curried in by the embedder, so the table holds no key of its own. +func newRouteTable(l logger.Logger) *routeTable { + return &routeTable{ + logger: l, + routes: make(map[routeKey]*Route), + } +} + +// mutate applies a removal and an addition as one transaction, republishing the +// tree if the merged table moved. Routes the removal empties are deleted only +// once the addition has run, so a registration replaced by an equivalent one +// keeps its Route pointers and the published tree stays live. +func (t *routeTable) mutate(remove, add *Registration) { + t.lock.Lock() + defer t.lock.Unlock() + + touched := make(map[*Route]struct{}) + if remove != nil { + t.retractLocked(remove, touched) + } + if add != nil { + t.declareLocked(add, touched) + } + + dirty := false + for r := range touched { + ord, live := r.position() + if !live { + delete(t.routes, r.key) + dirty = true + continue + } + // a departure can raise a route's position and an arrival lower it; + // either reorders the table + if ord != r.ord { + dirty = true + } + } + if dirty { + t.publishLocked() + } +} + +func (t *routeTable) declareLocked(reg *Registration, touched map[*Route]struct{}) { + if reg.Manifest == nil { + return + } + for i, ep := range reg.Manifest.Endpoints { + for mask := ep.Mask; mask != 0; mask &= mask - 1 { + key := routeKey{canonical: ep.Template.Canonical(), method: mask & -mask} + r := t.routes[key] + if r == nil { + r = &Route{Template: ep.Template, Mask: key.method, key: key, ord: noOrder} + t.routes[key] = r + } + r.declare(routeWorker{reg: reg, raw: ep.Template.String(), public: ep.Public, idx: uint32(i)}) + touched[r] = struct{}{} + } + } +} + +func (t *routeTable) retractLocked(reg *Registration, touched map[*Route]struct{}) { + for _, r := range t.routes { + r.retract(reg) + touched[r] = struct{}{} + } +} + +// publishLocked compiles the merged table and swaps it in. The builder assigns +// each route an index from its position in the order added, so routes must be +// added in merge order. +func (t *routeTable) publishLocked() { + ordered := make([]*Route, 0, len(t.routes)) + for _, r := range t.routes { + ordered = append(ordered, r) + } + for _, r := range ordered { + r.ord, _ = r.position() + } + // routes sharing a position are separated by the tie-break alone, so the + // comparison must be total and reach no further than the key + slices.SortFunc(ordered, func(a, b *Route) int { + if c := cmp.Compare(a.ord, b.ord); c != 0 { + return c + } + if c := cmp.Compare(a.key.canonical, b.key.canonical); c != 0 { + return c + } + return cmp.Compare(a.key.method, b.key.method) + }) + + b := router.NewBuilder[*Route]() + for i, r := range ordered { + if err := b.Add(r.Template, r.Mask, r); err != nil { + // MaxManifestRoutes caps a worker, not the merged table; what + // survives is the lowest stretch of the merge order + t.logger.Warnw("agent endpoint route table truncated", err, + "routes", len(ordered), "installed", i) + break + } + } + t.tree.Store(b.Build()) +} + +func (t *routeTable) empty() bool { + t.lock.Lock() + defer t.lock.Unlock() + return len(t.routes) == 0 +} + +// match resolves a path against the merged table. matched is the dispatch test: +// a route reached through a tree loaded before its last worker left resolves to +// an empty set. +func (t *routeTable) match(path string, mask router.Mask, granted bool) (matched []routeWorker, route *Route, res router.Result, denied bool) { + tree := t.tree.Load() + if tree == nil { + return nil, nil, router.ResultNone, false + } + rt, res := tree.Match(path, mask) + if res != router.ResultFull { + return nil, nil, res, false + } + matched, denied = rt.eligible(granted) + return matched, rt, res, denied +} + +// serves reports whether the table has a route for this exact path and method. +func (t *routeTable) serves(path string, mask router.Mask) bool { + tree := t.tree.Load() + if tree == nil { + return false + } + _, res := tree.Match(path, mask) + return res == router.ResultFull +} diff --git a/pkg/agent/endpoint/routes_test.go b/pkg/agent/endpoint/routes_test.go new file mode 100644 index 000000000..58f2aae74 --- /dev/null +++ b/pkg/agent/endpoint/routes_test.go @@ -0,0 +1,365 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "fmt" + "net/http" + "slices" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" +) + +func mustManifest(t *testing.T, eps ...*livekit.AgentHttp_AgentEndpoint) *Manifest { + t.Helper() + m, err := ParseManifest(eps) + require.NoError(t, err) + return m +} + +func regOf(workerID string, m *Manifest) *Registration { + return NewRegistration(RegistrationParams{ + WorkerID: workerID, Manifest: m, Session: &fakeSession{}, + }) +} + +func testScope() *Scope { return NewScope(logger.GetLogger()) } + +// tableOf registers one worker per manifest into a single scope and returns the +// deployment's merged table. +func tableOf(t *testing.T, manifests ...*Manifest) *routeTable { + t.Helper() + g, s := NewRegistry(), testScope() + for i, m := range manifests { + g.Register(s, regOf(fmt.Sprintf("w%d", i), m)) + } + tbl := s.routeTable() + require.NotNil(t, tbl) + return tbl +} + +// serving names the workers a request would be dispatched to, sorted. +func serving(tbl *routeTable, path, method string, granted bool) []string { + matched, _, _, _ := tbl.match(path, methodMask(method), granted) + ids := make([]string, 0, len(matched)) + for _, w := range matched { + ids = append(ids, w.reg.WorkerID) + } + slices.Sort(ids) + return ids +} + +// A mixed fleet serves the union of its routes, and a route only ever +// dispatches to the workers that declared it. +func TestRouteTableUnion(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v1", []string{"GET"}, true)), + mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v2", []string{"GET"}, true)), + ) + + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/health", http.MethodGet, true)) + require.Equal(t, []string{"w0"}, serving(tbl, "/v1", http.MethodGet, true)) + require.Equal(t, []string{"w1"}, serving(tbl, "/v2", http.MethodGet, true)) + require.Empty(t, serving(tbl, "/v3", http.MethodGet, true)) +} + +// A worker leaving takes only the routes nothing else declares. +func TestRouteTableDeparture(t *testing.T) { + g, s := NewRegistry(), testScope() + old := regOf("w0", mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v1", []string{"GET"}, true))) + g.Register(s, old) + g.Register(s, regOf("w1", mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v2", []string{"GET"}, true)))) + tbl := s.routeTable() + + g.Deregister(old) + + require.Equal(t, []string{"w1"}, serving(tbl, "/health", http.MethodGet, true)) + require.Empty(t, serving(tbl, "/v1", http.MethodGet, true), "the departing worker's own route is gone") + require.Equal(t, []string{"w1"}, serving(tbl, "/v2", http.MethodGet, true)) + + // the last worker out drops the table with it + g.Deregister(s.Candidates()[0]) + require.Nil(t, s.routeTable(), "the last worker out drops the table, so the front reports 503 not 404") + require.True(t, s.Empty()) +} + +// Declaration order is the whole priority rule, and a homogeneous fleet keeps +// the order its manifest declared: a literal registered ahead of a param that +// would also match it must still win. +func TestRouteTableMergeOrder(t *testing.T) { + m := mustManifest(t, ep("/users/me", []string{"GET"}, true), ep("/users/{id}", []string{"GET"}, true)) + tbl := tableOf(t, m, m) + + _, r, res, _ := tbl.match("/users/me", methodMask(http.MethodGet), true) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/users/me", r.Template.String()) + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/users/me", http.MethodGet, true)) + + _, r, _, _ = tbl.match("/users/7", methodMask(http.MethodGet), true) + require.Equal(t, "/users/{id}", r.Template.String()) +} + +// Workers may disagree about a route's visibility mid-rollout. An ungranted +// caller is narrowed to the workers that declared it public. +func TestRouteTablePerWorkerPublic(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/x", []string{"GET"}, true)), + mustManifest(t, ep("/x", []string{"GET"}, false)), + ) + + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/x", http.MethodGet, true)) + require.Equal(t, []string{"w0"}, serving(tbl, "/x", http.MethodGet, false)) + + matched, _, _, denied := tbl.match("/x", methodMask(http.MethodGet), false) + require.Len(t, matched, 1) + require.True(t, denied, "the private declaration is reported, not silently dropped") + + // no public declarer at all denies outright + private := tableOf(t, mustManifest(t, ep("/y", []string{"GET"}, false))) + matched, _, _, denied = private.match("/y", methodMask(http.MethodGet), false) + require.Empty(t, matched) + require.True(t, denied) +} + +// One route per method: workers declaring different verbs on one path each +// serve their own, and a verb nobody declared is partial. +func TestRouteTableMethodSplit(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/thing", []string{"GET"}, true)), + mustManifest(t, ep("/thing", []string{"POST", "DELETE"}, true)), + ) + + require.Equal(t, []string{"w0"}, serving(tbl, "/thing", http.MethodGet, true)) + require.Equal(t, []string{"w1"}, serving(tbl, "/thing", http.MethodPost, true)) + require.Equal(t, []string{"w1"}, serving(tbl, "/thing", http.MethodDelete, true)) + + _, _, res, _ := tbl.match("/thing", methodMask(http.MethodPut), true) + require.Equal(t, router.ResultPartial, res) +} + +// Param names are invisible to the matcher, so two spellings of one shape are +// one route serving both workers, each carrying its own spelling. +func TestRouteTableCanonicalMerge(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/items/{id}", []string{"GET"}, true)), + mustManifest(t, ep("/items/{key}", []string{"GET"}, true)), + ) + + require.Len(t, tbl.routes, 1, "one shape is one route") + matched, _, res, _ := tbl.match("/items/7", methodMask(http.MethodGet), true) + require.Equal(t, router.ResultFull, res) + require.Len(t, matched, 2) + + spelling := map[string]string{} + for _, w := range matched { + spelling[w.reg.WorkerID] = w.raw + } + require.Equal(t, map[string]string{"w0": "/items/{id}", "w1": "/items/{key}"}, spelling) + + // a different shape stays a different route + require.Len(t, tableOf(t, + mustManifest(t, ep("/items/{id}", []string{"GET"}, true)), + mustManifest(t, ep("/items/{id:int}", []string{"GET"}, true)), + ).routes, 2) +} + +// A literal brace is not a param, and must not collide with one. +func TestRouteTableLiteralBraceDistinctFromParam(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/x/{a}", []string{"GET"}, true)), + mustManifest(t, ep("/x/{:str}", []string{"GET"}, true)), + ) + require.Len(t, tbl.routes, 2) +} + +// A manifest may name one path and method twice; the worker joins the route +// once. +func TestRouteTableDuplicateDeclaration(t *testing.T) { + tbl := tableOf(t, mustManifest(t, + ep("/x", []string{"GET", "POST"}, true), + ep("/x", []string{"GET"}, true), + )) + require.Equal(t, []string{"w0"}, serving(tbl, "/x", http.MethodGet, true)) +} + +// The published tree holds routes by pointer, so a reconnect that rebuilds the +// same route set keeps those pointers and stays routable. +func TestRouteTableSupersedeStaysRoutable(t *testing.T) { + g, s := NewRegistry(), testScope() + m := mustManifest(t, ep("/x", []string{"GET"}, true)) + g.Register(s, regOf("w0", m)) + tbl := s.routeTable() + before := tbl.tree.Load() + + g.Register(s, regOf("w0", m)) + + require.Equal(t, []string{"w0"}, serving(tbl, "/x", http.MethodGet, true), + "a reconnect with an unchanged manifest stays routable") + require.Same(t, before, tbl.tree.Load(), "an unchanged route set needs no rebuild") +} + +// A reconnect may land on a different agent name or deployment, i.e. a different +// scope. The retiring epoch's routes live in the old scope and must be retracted +// there. +func TestRouteTableSupersedeAcrossScopes(t *testing.T) { + g := NewRegistry() + from, to := testScope(), testScope() + m := mustManifest(t, ep("/x", []string{"GET"}, true)) + firstSess := &fakeSession{} + first := NewRegistration(RegistrationParams{WorkerID: "w0", Manifest: m, Session: firstSess}) + g.Register(from, first) + + g.Register(to, regOf("w0", m)) + + require.Nil(t, from.routeTable(), "the old scope's table is gone") + require.True(t, from.Empty()) + require.Equal(t, []string{"w0"}, serving(to.routeTable(), "/x", http.MethodGet, true)) + require.True(t, firstSess.closed, "the superseded epoch's session is closed") + + // the retiring control connection tears down afterwards and must not strand + // the new epoch + g.Deregister(first) + require.Equal(t, []string{"w0"}, serving(to.routeTable(), "/x", http.MethodGet, true)) +} + +// The tree is rebuilt exactly when the merged route set or its order moves. +func TestRouteTableRebuildTrigger(t *testing.T) { + g, s := NewRegistry(), testScope() + m := mustManifest(t, ep("/x", []string{"GET"}, true)) + g.Register(s, regOf("w0", m)) + tbl := s.routeTable() + + before := tbl.tree.Load() + g.Register(s, regOf("w1", m)) + require.Same(t, before, tbl.tree.Load(), "a worker joining an existing route changes no route") + + g.Register(s, regOf("w2", mustManifest(t, ep("/x", []string{"GET"}, true), ep("/y", []string{"GET"}, true)))) + require.NotSame(t, before, tbl.tree.Load(), "a new route rebuilds") +} + +// A departure can raise a route's merge position and an arrival lower it; +// either reorders the table. +func TestRouteTableOrderFollowsDeparture(t *testing.T) { + g, s := NewRegistry(), testScope() + first := regOf("w0", mustManifest(t, + ep("/a/{x}", []string{"GET"}, true), + ep("/a/b", []string{"GET"}, true), + )) + g.Register(s, first) + g.Register(s, regOf("w1", mustManifest(t, + ep("/a/b", []string{"GET"}, true), + ep("/a/{x}", []string{"GET"}, true), + ))) + tbl := s.routeTable() + + // both routes hold position 0 - w0 declares the param first, w1 the literal + // - so the tie-break decides, and the param shadows the literal + _, r, _, _ := tbl.match("/a/b", methodMask(http.MethodGet), true) + require.Equal(t, "/a/{x}", r.Template.String()) + + // w0 leaving raises the param to w1's position 1, putting the literal ahead + // of it + g.Deregister(first) + _, r, _, _ = tbl.match("/a/b", methodMask(http.MethodGet), true) + require.Equal(t, "/a/b", r.Template.String()) +} + +// Ambiguity is a property of the merged tree: one worker's shape can carry the +// whole deployment's matcher over its step budget, and an undecided route has no +// Public flag left to clear an ungranted request. +func TestRouteTableAmbiguityIsContagious(t *testing.T) { + long := "/" + strings.Repeat("a", 512) + + open := tableOf(t, mustManifest(t, ep("/{p:path}", []string{"GET"}, true))) + _, _, res, _ := open.match(long, methodMask(http.MethodGet), false) + require.Equal(t, router.ResultFull, res, "alone, the catch-all decides") + + merged := tableOf(t, + mustManifest(t, ep("/{a}{b}{c}{d}x", []string{"GET"}, true)), + mustManifest(t, ep("/{p:path}", []string{"GET"}, true)), + ) + _, _, res, _ = merged.match(long, methodMask(http.MethodGet), false) + require.Equal(t, router.ResultOverBudget, res) +} + +// KNOWN LIMITATION: the merge takes each route's lowest position across the +// fleet, which does not preserve any one worker's internal order. A worker can +// be handed a request the merged table resolved to a public route while its own +// table routes that path to a private one. An order-preserving merge of the +// declaration chains would hold the property. +func TestRouteTableMergeOrderInversion(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, + ep("/x/{z:int}", []string{"GET"}, false), + ep("/x/{b}", []string{"GET"}, true), + ), + mustManifest(t, ep("/x/{b}", []string{"GET"}, true)), + ) + + _, r, res, _ := tbl.match("/x/7", methodMask(http.MethodGet), false) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/x/{b}", r.Template.String()) + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/x/7", http.MethodGet, false), + "w0 is reachable anonymously while its own table routes /x/7 to a private route") +} + +// Registrations churn while requests resolve; run under -race. +func TestRouteTableConcurrentChurn(t *testing.T) { + g, sc := NewRegistry(), testScope() + stable := regOf("stable", mustManifest(t, ep("/x", []string{"GET"}, true))) + g.Register(sc, stable) + + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + m := mustManifest(t, ep("/x", []string{"GET"}, true), ep(fmt.Sprintf("/w%d", i), []string{"GET"}, true)) + for n := 0; ; n++ { + select { + case <-stop: + return + default: + } + r := regOf(fmt.Sprintf("w%d", i), m) + g.Register(sc, r) + g.Deregister(r) + } + }() + } + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + if tbl := sc.routeTable(); tbl != nil { + tbl.match("/x", methodMask(http.MethodGet), true) + tbl.serves("/x/", methodMask(http.MethodGet)) + } + } + }() + } + for range 2000 { + if tbl := sc.routeTable(); tbl != nil { + tbl.match("/x", methodMask(http.MethodGet), false) + } + } + close(stop) + wg.Wait() + + require.Contains(t, serving(sc.routeTable(), "/x", http.MethodGet, true), "stable") +} diff --git a/pkg/agent/endpoint/scope.go b/pkg/agent/endpoint/scope.go new file mode 100644 index 000000000..a04f8da82 --- /dev/null +++ b/pkg/agent/endpoint/scope.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 endpoint + +import ( + "slices" + "sync" + + "github.com/livekit/protocol/logger" +) + +// Scope is one deployment's serving state on this node: the registrations that +// hold it and their merged route table. Who a scope belongs to is the embedder's +// business - nothing here stores a tenancy identity, and the embedder owns the +// map that scopes it. +// +// Registrations and routes move together under lock, so a worker present in the +// candidate set always has its routes installed. +type Scope struct { + logger logger.Logger + + lock sync.RWMutex + regs []*Registration + table *routeTable // nil while nothing declares a route +} + +// NewScope takes a logger the embedder has already curried with whatever +// identifies the scope; the package adds no identity of its own. +func NewScope(l logger.Logger) *Scope { + return &Scope{logger: l} +} + +// Candidates returns the registrations holding this scope. A nil scope holds +// none, so a caller that could not resolve one needs no separate test. +func (s *Scope) Candidates() []*Registration { + if s == nil { + return nil + } + s.lock.RLock() + defer s.lock.RUnlock() + return slices.Clone(s.regs) +} + +// Empty reports that no registration holds this scope, so the embedder may drop +// it. +func (s *Scope) Empty() bool { + if s == nil { + return true + } + s.lock.RLock() + defer s.lock.RUnlock() + return len(s.regs) == 0 +} + +// routeTable returns the merged table, or nil when nothing declares a route. A +// scope whose last worker left must report nil rather than an empty tree: the +// front maps a missing table to "no worker for this deployment" (503) and an +// empty one to "no such path" (404). +func (s *Scope) routeTable() *routeTable { + if s == nil { + return nil + } + s.lock.RLock() + defer s.lock.RUnlock() + return s.table +} + +// replace swaps one registration for another as a single transaction. Routes the +// removal empties are dropped only once the addition has run, so a registration +// replaced by an equivalent one keeps its Route pointers and the published tree +// stays live. Either side may be nil. +func (s *Scope) replace(remove, add *Registration) { + s.lock.Lock() + defer s.lock.Unlock() + if remove != nil { + s.unlinkLocked(remove) + } + if add != nil { + s.regs = append(s.regs, add) + } + s.mutateLocked(remove, add) +} + +// remove drops a registration and its routes. +func (s *Scope) remove(r *Registration) { + s.lock.Lock() + defer s.lock.Unlock() + s.unlinkLocked(r) + s.mutateLocked(r, nil) +} + +func (s *Scope) unlinkLocked(r *Registration) { + if i := slices.Index(s.regs, r); i != -1 { + s.regs = slices.Delete(s.regs, i, i+1) + } +} + +// mutateLocked applies the route change, creating the table on first use and +// dropping it once it empties. +func (s *Scope) mutateLocked(remove, add *Registration) { + if s.table == nil { + if add == nil { + return + } + s.table = newRouteTable(s.logger) + } + s.table.mutate(remove, add) + if s.table.empty() { + s.table = nil + } +} diff --git a/pkg/agent/endpoint/session.go b/pkg/agent/endpoint/session.go new file mode 100644 index 000000000..e3ccbfc85 --- /dev/null +++ b/pkg/agent/endpoint/session.go @@ -0,0 +1,72 @@ +// 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 endpoint + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/livekit/protocol/livekit" +) + +// Session is the node's handle to one worker's data plane: a single +// WebTransport (QUIC) session that also carries the worker's control stream. +// The node opens a fresh Stream per HTTP exchange; QUIC provides the +// multiplexing and per-stream flow control, so there is no capsule layer. +type Session interface { + // OpenStream opens a bidirectional stream toward the worker for one HTTP + // exchange. It blocks until a stream can be opened (QUIC stream-limit + // backpressure) or ctx is done. + OpenStream(ctx context.Context) (Stream, error) + // OpenStreams reports the streams currently open on this session - the + // least-outstanding-requests signal for worker selection. + OpenStreams() int + // MaxStreams is the soft cap on concurrent streams, used to derive spare + // capacity for node-selection weighting. + MaxStreams() int + // Close tears the session down. + Close(reason string) +} + +// Stream is one HTTP exchange over a Session: a preamble, then opaque HTTP/1.1 +// bytes in each direction. +type Stream interface { + io.Reader + io.Writer + // CloseWrite half-closes the send side (QUIC stream FIN). A FIN arriving + // before the body framing says the body is complete is truncation. + CloseWrite() error + // Reset aborts the stream in both directions (QUIC RESET_STREAM / + // STOP_SENDING). The code reaches the peer and is the only outcome signal + // available once no bytes can flow; the reason is local only. + Reset(code livekit.AgentHttp_HttpStreamResetCode, reason string) + // Close releases the stream after a completed exchange. + Close() error + // SetReadDeadline bounds the wait for more bytes. + SetReadDeadline(t time.Time) error +} + +// StreamResetError reports that the peer reset the stream, carrying the code it +// sent. Transports translate their own reset errors into this at the Stream +// boundary. +type StreamResetError struct { + Code livekit.AgentHttp_HttpStreamResetCode +} + +func (e *StreamResetError) Error() string { + return fmt.Sprintf("endpoint: stream reset by peer (%s)", e.Code) +} diff --git a/pkg/agent/endpoint/truncation_test.go b/pkg/agent/endpoint/truncation_test.go new file mode 100644 index 000000000..fc9742f21 --- /dev/null +++ b/pkg/agent/endpoint/truncation_test.go @@ -0,0 +1,261 @@ +// 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 endpoint_test + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// rawTarget speaks HTTP/1.1 by hand so a response can end in ways net/http will +// not produce. +func rawTarget(t *testing.T, respond func(net.Conn)) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + br := bufio.NewReader(c) + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + if line == "\r\n" { + break + } + } + respond(c) + }() + } + }() + return ln.Addr().String() +} + +func startFramedWorker(t *testing.T, targetAddr string, eps []*livekit.AgentHttp_AgentEndpoint) string { + t.Helper() + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + front := endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope}, true + }, + Logger: logger.GetLogger(), + }) + ts := httptest.NewUnstartedServer(front) + // raised past net/http's 1 MiB default so the front's own head bound is what + // rejects an oversized head + ts.Config.MaxHeaderBytes = 4 << 20 + ts.Start() + t.Cleanup(ts.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: targetAddr, + Insecure: true, + Endpoints: eps, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + return ts.URL + "/agents/myagent/production" +} + +// A chunked response cut short must not reach the client as complete. +func TestTruncatedChunkedResponseIsDetected(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n") + _, _ = fmt.Fprintf(c, "%x\r\n", 1000) + _, _ = c.Write(make([]byte, 1000)) + _, _ = io.WriteString(c, "\r\n") + // closes without the terminating 0-length chunk + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/partial", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/partial") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a truncated chunked body must not read as a clean EOF") +} + +// A response short of its declared length must not reach the client as complete. +func TestShortContentLengthResponseIsDetected(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\n\r\n") + _, _ = c.Write(make([]byte, 1000)) + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/short", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/short") + require.NoError(t, err) + defer resp.Body.Close() + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body short of its declared length must not read as complete") +} + +// An unreachable application never dispatched the request, so it is retryable; +// with no healthy worker the attempts exhaust into a 503. +func TestRefusedExhaustsTo503(t *testing.T) { + base := startFramedWorker(t, "127.0.0.1:1", []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/json", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/json") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, + "a refused request is retryable, so it must exhaust into 503 rather than 502") +} + +// A complete response reads as complete. +func TestCompleteResponseStillCompletes(t *testing.T) { + body := strings.Repeat("y", 40<<10) + addr := rawTarget(t, func(c net.Conn) { + _, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(body)) + _, _ = io.WriteString(c, body) + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/ok", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/ok") + require.NoError(t, err) + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, body, string(got)) +} + +// A bodiless response declares a length it never delivers and is not truncated. +func TestHeadAndNoContentAreNotTruncated(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 204 No Content\r\n\r\n") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/empty", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/empty") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) +} + +// Duplicate response headers must survive the proto hop. +func TestDuplicateHeadersSurvive(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nContent-Length: 2\r\n\r\nhi") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/cookies", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/cookies") + require.NoError(t, err) + defer resp.Body.Close() + require.ElementsMatch(t, []string{"a=1", "b=2"}, resp.Header.Values("Set-Cookie")) +} + +// The request head reaches the application with path, query and forwarding +// headers intact. +func TestRequestMetadataReachesWorker(t *testing.T) { + seen := make(chan string, 1) + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + br := bufio.NewReader(c) + var head strings.Builder + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + head.WriteString(line) + if line == "\r\n" { + break + } + } + seen <- head.String() + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }() + + base := startFramedWorker(t, ln.Addr().String(), []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/items/{id}", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/items/42?q=1") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + select { + case h := <-seen: + require.Contains(t, h, "GET /items/42?q=1 HTTP/1.1") + require.Contains(t, strings.ToLower(h), "x-forwarded-for:") + case <-time.After(5 * time.Second): + t.Fatal("worker never reached the application") + } +} diff --git a/pkg/agent/endpoint/tunnel.go b/pkg/agent/endpoint/tunnel.go new file mode 100644 index 000000000..f73a427ce --- /dev/null +++ b/pkg/agent/endpoint/tunnel.go @@ -0,0 +1,305 @@ +// 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 endpoint + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "io" + "net" + "net/http" + "sync" + "time" + + "github.com/livekit/protocol/livekit" +) + +const ( + // DefaultTunnelDrainTimeout is how long a WebSocket tunnel outlives its + // worker starting to drain. + DefaultTunnelDrainTimeout = 30 * time.Second + + // bounds how late a worker's drain is noticed; draining is polled + workerDrainPollInterval = time.Second + + // a client that stopped reading must not hold a drain open + closeFrameWriteTimeout = time.Second + + // bounds a client that keeps its side open after the worker has closed + tunnelLinger = 5 * time.Second +) + +// a server-to-client close frame carrying 1001 (going away) +var wsGoingAwayCloseFrame = []byte{0x88, 0x02, 0x03, 0xe9} + +func (f *Front) tunnelLimit(reg *Registration) int { + if f.params.MaxTunnelsPerWorker > 0 { + return f.params.MaxTunnelsPerWorker + } + return max(reg.maxStreams()/2, 1) +} + +// excludeTunnelsAtLimit marks full workers as attempted, which sends an upgrade +// with no admissible worker to the fallback and then 503. A draining front +// admits none. +func (f *Front) excludeTunnelsAtLimit(workers []routeWorker, attempted map[*Registration]bool) { + draining := f.tunnels.IsDraining() + for _, w := range workers { + if draining || w.reg.Tunnels() >= f.tunnelLimit(w.reg) { + attempted[w.reg] = true + } + } +} + +// Drain refuses new WebSocket upgrades, lets open tunnels finish until ctx is +// done, then closes the rest with 1001 (going away). It returns once no tunnel +// remains and is safe to call repeatedly. +func (f *Front) Drain(ctx context.Context) { + f.tunnels.Drain(ctx) +} + +// OpenTunnels reports the WebSocket tunnels this front is splicing. +func (f *Front) OpenTunnels() int { + return f.tunnels.Len() +} + +// spliceWebSocket blocks until the tunnel is done: bridge's deferred stream and +// reader cleanup must not run under a live tunnel. +func (f *Front) spliceWebSocket( + w http.ResponseWriter, + a *attempt, + reg *Registration, + stream Stream, + br *bufio.Reader, + resp *http.Response, + writeErrCh <-chan error, +) { + // the request writer must be done before the client's bytes follow its head + if err := <-writeErrCh; err != nil { + f.params.Logger.Warnw("agent endpoint upgrade request write failed", err, + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID) + stream.Reset(livekit.AgentHttp_HSR_ABORT, "request write failed") + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + conn, crw, err := http.NewResponseController(w).Hijack() + if err != nil { + f.params.Logger.Warnw("agent endpoint cannot hijack the client connection", err, + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID) + stream.Reset(livekit.AgentHttp_HSR_ABORT, "hijack failed") + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + defer conn.Close() + // server deadlines must not bound the tunnel + _ = conn.SetDeadline(time.Time{}) + + t := &tunnel{conn: conn, stream: stream, done: make(chan struct{})} + admitted := f.tunnels.Add(t) + defer f.tunnels.Remove(t) + defer close(t.done) + + if _, err := conn.Write(webSocketSwitchHead(resp.Header)); err != nil { + t.abort("client write failed") + return + } + if !admitted { + _ = t.Close() + } else { + go t.watch(reg, f.tunnels.Draining(), f.params.TunnelDrainTimeout) + } + + bufp := f.pools.getBuf() + defer f.pools.putBuf(bufp) + t.pipe(br, crw.Reader, *bufp) +} + +func webSocketSwitchHead(workerHeader http.Header) []byte { + h := http.Header{} + copyResponseHeaders(h, workerHeader) + h.Set("Connection", "Upgrade") + h.Set("Upgrade", "websocket") + var b bytes.Buffer + b.WriteString("HTTP/1.1 101 Switching Protocols\r\n") + _ = h.Write(&b) + b.WriteString("\r\n") + return b.Bytes() +} + +// watch covers worker drain only; once the front drains, Front.Drain owns the +// deadline. +func (t *tunnel) watch(reg *Registration, frontDraining <-chan struct{}, timeout time.Duration) { + tick := time.NewTicker(workerDrainPollInterval) + defer tick.Stop() + for !reg.IsDraining() { + select { + case <-t.done: + return + case <-frontDraining: + return + case <-tick.C: + } + } + if timeout <= 0 { + timeout = DefaultTunnelDrainTimeout + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-t.done: + case <-timer.C: + _ = t.Close() + } +} + +type tunnel struct { + conn net.Conn + stream Stream + done chan struct{} + + // mu serializes client-bound writes, so a close frame never lands mid-frame + mu sync.Mutex + closed bool + frames wsFrameTracker +} + +// pipe returns once both directions are done. EOF on one side half-closes the +// other; an error on either side tears down both. +func (t *tunnel) pipe(fromWorker, fromClient io.Reader, buf []byte) { + upDone := make(chan struct{}) + go func() { + defer close(upDone) + if _, err := io.Copy(t.stream, fromClient); err != nil { + t.abort("client read failed") + return + } + _ = t.stream.CloseWrite() + }() + + if err := t.toClient(fromWorker, buf); err != io.EOF { + t.abort("worker read failed") + } else if cw, ok := t.conn.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + _ = t.conn.SetReadDeadline(time.Now().Add(tunnelLinger)) + } else { + _ = t.conn.Close() + } + <-upDone +} + +func (t *tunnel) toClient(src io.Reader, buf []byte) error { + for { + n, rerr := src.Read(buf) + if n > 0 { + t.mu.Lock() + if t.closed { + t.mu.Unlock() + return net.ErrClosed + } + nw, werr := t.conn.Write(buf[:n]) + t.frames.advance(buf[:nw]) + t.mu.Unlock() + if werr != nil { + return werr + } + } + if rerr != nil { + return rerr + } + } +} + +// Close sends the 1001 close frame only at a frame boundary, since a frame +// injected mid-frame corrupts the stream. +func (t *tunnel) Close() error { + _ = t.conn.SetWriteDeadline(time.Now().Add(closeFrameWriteTimeout)) + t.mu.Lock() + if !t.closed { + t.closed = true + if t.frames.atBoundary() { + _, _ = t.conn.Write(wsGoingAwayCloseFrame) + } + } + t.mu.Unlock() + t.abort("tunnel drained") + return nil +} + +func (t *tunnel) abort(reason string) { + t.stream.Reset(livekit.AgentHttp_HSR_ABORT, reason) + _ = t.conn.Close() +} + +// wsFrameTracker follows the worker's framing (RFC 6455 §5.2) only far enough +// to find frame boundaries. +type wsFrameTracker struct { + hdr [14]byte + hlen int + remaining uint64 +} + +func (f *wsFrameTracker) atBoundary() bool { + return f.hlen == 0 && f.remaining == 0 +} + +func (f *wsFrameTracker) advance(p []byte) { + for len(p) > 0 { + if f.remaining > 0 { + n := min(uint64(len(p)), f.remaining) + f.remaining -= n + p = p[n:] + continue + } + f.hdr[f.hlen] = p[0] + f.hlen++ + p = p[1:] + if f.hlen < wsFrameHeaderLen(f.hdr[:f.hlen]) { + continue + } + f.remaining = wsFramePayloadLen(f.hdr[:f.hlen]) + f.hlen = 0 + } +} + +// wsFrameHeaderLen can grow as more of the header arrives. +func wsFrameHeaderLen(h []byte) int { + if len(h) < 2 { + return 2 + } + n := 2 + switch h[1] & 0x7f { + case 126: + n += 2 + case 127: + n += 8 + } + if h[1]&0x80 != 0 { + n += 4 + } + return n +} + +func wsFramePayloadLen(h []byte) uint64 { + switch l := h[1] & 0x7f; l { + case 126: + return uint64(binary.BigEndian.Uint16(h[2:4])) + case 127: + return binary.BigEndian.Uint64(h[2:10]) + default: + return uint64(l) + } +} diff --git a/pkg/agent/endpoint/tunnel_test.go b/pkg/agent/endpoint/tunnel_test.go new file mode 100644 index 000000000..fdbe944fa --- /dev/null +++ b/pkg/agent/endpoint/tunnel_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "bufio" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +const wsSwitchingHead = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + +// scriptedTunnelFront serves one scripted worker on a GET route, with a drain +// signal the test controls. +func scriptedTunnelFront(t *testing.T, draining *atomic.Bool, params FrontParams, script func(r io.Reader, w *workerSide)) *httptest.Server { + t.Helper() + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/ws", Methods: []string{"GET"}, Public: true}, + }) + require.NoError(t, err) + g, s := NewRegistry(), NewScope(logger.GetLogger()) + g.Register(s, NewRegistration(RegistrationParams{ + WorkerID: "w1", + Manifest: m, + Session: &scriptedSession{script: script}, + Draining: draining.Load, + })) + params.ResolveAccess = grantedTo(s) + params.Logger = logger.GetLogger() + ts := httptest.NewServer(NewFront(params)) + t.Cleanup(ts.Close) + return ts +} + +// readWorkerRequest parses the request the worker was handed. +func readWorkerRequest(r io.Reader) (*http.Request, error) { + if _, err := wire.ReadPreamble(r); err != nil { + return nil, err + } + return http.ReadRequest(bufio.NewReader(r)) +} + +// rawWebSocketHandshake sends a handshake with the given Upgrade token and returns the +// connection positioned after the response head. +func rawWebSocketHandshake(t *testing.T, ts *httptest.Server, upgrade string) (net.Conn, *bufio.Reader, *http.Response) { + t.Helper() + c, err := net.Dial("tcp", strings.TrimPrefix(ts.URL, "http://")) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + _, err = io.WriteString(c, "GET "+PathPrefix+"a/d/ws HTTP/1.1\r\nHost: x\r\n"+ + "Connection: Upgrade\r\nUpgrade: "+upgrade+"\r\n"+ + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n") + require.NoError(t, err) + _ = c.SetReadDeadline(time.Now().Add(10 * time.Second)) + br := bufio.NewReader(c) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err) + return c, br, resp +} + +func TestUpgradeHeadersReachTheWorker(t *testing.T) { + seen := make(chan http.Header, 1) + ts := scriptedTunnelFront(t, &atomic.Bool{}, FrontParams{}, func(r io.Reader, w *workerSide) { + defer w.Close() + req, err := readWorkerRequest(r) + if err != nil { + return + } + seen <- req.Header + w.WriteString(wsSwitchingHead) + }) + _, _, resp := rawWebSocketHandshake(t, ts, "WebSocket") + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + h := <-seen + require.Equal(t, "Upgrade", h.Get("Connection")) + require.Equal(t, "websocket", h.Get("Upgrade")) + require.Equal(t, "dGhlIHNhbXBsZSBub25jZQ==", h.Get("Sec-WebSocket-Key")) + require.Equal(t, "13", h.Get("Sec-WebSocket-Version")) +} + +// only websocket is tunneled: any other token is stripped like any hop-by-hop +// header. +func TestNonWebSocketUpgradeIsStripped(t *testing.T) { + seen := make(chan http.Header, 1) + ts := scriptedTunnelFront(t, &atomic.Bool{}, FrontParams{}, func(r io.Reader, w *workerSide) { + defer w.Close() + req, err := readWorkerRequest(r) + if err != nil { + return + } + seen <- req.Header + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + }) + _, _, resp := rawWebSocketHandshake(t, ts, "h2c") + require.Equal(t, http.StatusOK, resp.StatusCode) + h := <-seen + require.Empty(t, h.Values("Connection")) + require.Empty(t, h.Values("Upgrade")) +} + +// HTTP/2 has no Upgrade: the same headers on an h2 request are a plain GET. +func TestHTTP2UpgradeHeadersArePlainGET(t *testing.T) { + seen := make(chan http.Header, 1) + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{{Path: "/ws", Methods: []string{"GET"}, Public: true}}) + require.NoError(t, err) + g, s := NewRegistry(), NewScope(logger.GetLogger()) + g.Register(s, NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &scriptedSession{ + script: func(r io.Reader, w *workerSide) { + defer w.Close() + req, err := readWorkerRequest(r) + if err != nil { + return + } + seen <- req.Header + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }, + }})) + f := NewFront(FrontParams{ResolveAccess: grantedTo(s), Logger: logger.GetLogger()}) + + r := httptest.NewRequest(http.MethodGet, PathPrefix+"a/d/ws", nil) + r.Proto, r.ProtoMajor, r.ProtoMinor = "HTTP/2.0", 2, 0 + r.Header.Set("Connection", "Upgrade") + r.Header.Set("Upgrade", "websocket") + rec := httptest.NewRecorder() + f.ServeHTTP(rec, r) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "ok", rec.Body.String()) + h := <-seen + require.Empty(t, h.Values("Connection")) + require.Empty(t, h.Values("Upgrade")) +} + +// a switch the front did not ask for is a protocol error; GET is idempotent, so +// with one worker it exhausts its retries into a 503. +func TestUnrequestedSwitchIsProtocolError(t *testing.T) { + cases := map[string]struct { + upgrade bool + resp string + }{ + "101 to a plain GET": {resp: wsSwitchingHead}, + "101 to another protocol": {upgrade: true, resp: "HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\n\r\n"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ts := scriptedTunnelFront(t, &atomic.Bool{}, FrontParams{}, func(r io.Reader, w *workerSide) { + defer w.Close() + if _, err := readWorkerRequest(r); err != nil { + return + } + w.WriteString(tc.resp) + }) + if tc.upgrade { + _, _, resp := rawWebSocketHandshake(t, ts, "websocket") + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + return + } + resp, err := http.Get(ts.URL + PathPrefix + "a/d/ws") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + }) + } +} + +// a tunnel outlives its worker starting to drain by the drain timeout, then +// closes with 1001. +func TestWorkerDrainClosesTunnelWithGoingAway(t *testing.T) { + var draining atomic.Bool + ts := scriptedTunnelFront(t, &draining, FrontParams{TunnelDrainTimeout: 10 * time.Millisecond}, + func(r io.Reader, w *workerSide) { + defer w.Close() + if _, err := readWorkerRequest(r); err != nil { + return + } + // one complete text frame "hi", then the tunnel idles + w.WriteString(wsSwitchingHead + "\x81\x02hi") + _, _ = io.Copy(io.Discard, r) + }) + _, br, resp := rawWebSocketHandshake(t, ts, "websocket") + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + frame := make([]byte, 4) + _, err := io.ReadFull(br, frame) + require.NoError(t, err) + require.Equal(t, "\x81\x02hi", string(frame)) + + draining.Store(true) + _, err = io.ReadFull(br, frame) + require.NoError(t, err) + require.Equal(t, wsGoingAwayCloseFrame, frame) + _, err = br.ReadByte() + require.Error(t, err) +} + +func TestWSFrameTrackerBoundaries(t *testing.T) { + var f wsFrameTracker + require.True(t, f.atBoundary()) + + small := []byte{0x81, 0x03, 'a', 'b', 'c'} + for i := range small { + f.advance(small[i : i+1]) + require.Equal(t, i == len(small)-1, f.atBoundary(), "byte %d", i) + } + + // 16-bit length, split mid-header and mid-payload + medium := append([]byte{0x82, 126, 0x01, 0x00}, make([]byte, 256)...) + f.advance(medium[:3]) + require.False(t, f.atBoundary()) + f.advance(medium[3:100]) + require.False(t, f.atBoundary()) + f.advance(medium[100:]) + require.True(t, f.atBoundary()) + + // 64-bit length with the mask bit, back to back with a small frame + large := append([]byte{0x82, 0xff, 0, 0, 0, 0, 0, 0, 0x01, 0x00, 1, 2, 3, 4}, make([]byte, 65536)...) + f.advance(append(large, small...)) + require.True(t, f.atBoundary()) + + f.advance([]byte{0x88, 0x00}) + require.True(t, f.atBoundary()) +} diff --git a/pkg/agent/endpoint/tunnels.go b/pkg/agent/endpoint/tunnels.go new file mode 100644 index 000000000..43f0ec953 --- /dev/null +++ b/pkg/agent/endpoint/tunnels.go @@ -0,0 +1,108 @@ +// 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 endpoint + +import ( + "context" + "io" + "sync" +) + +// Tunnels tracks hijacked connections so a drain can close them. +type Tunnels struct { + mu sync.Mutex + live map[io.Closer]struct{} + idle chan struct{} // closed when live empties; nil while nobody waits + draining chan struct{} + once sync.Once +} + +func NewTunnels() *Tunnels { + return &Tunnels{live: make(map[io.Closer]struct{}), draining: make(chan struct{})} +} + +// Add tracks c. It reports false once a drain has begun: c is tracked +// regardless, so the drain still waits for it, but the caller must close it. +func (s *Tunnels) Add(c io.Closer) bool { + s.mu.Lock() + defer s.mu.Unlock() + s.live[c] = struct{}{} + return !s.IsDraining() +} + +func (s *Tunnels) Remove(c io.Closer) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.live, c) + if len(s.live) == 0 && s.idle != nil { + close(s.idle) + s.idle = nil + } +} + +func (s *Tunnels) Len() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.live) +} + +// Draining is closed once a drain has begun. +func (s *Tunnels) Draining() <-chan struct{} { + return s.draining +} + +func (s *Tunnels) IsDraining() bool { + select { + case <-s.draining: + return true + default: + return false + } +} + +// Drain lets the tracked connections end until ctx is done, then closes the +// rest. It returns once none remain and is safe to call repeatedly. +func (s *Tunnels) Drain(ctx context.Context) { + s.once.Do(func() { close(s.draining) }) + select { + case <-s.idleCh(): + return + case <-ctx.Done(): + } + s.mu.Lock() + open := make([]io.Closer, 0, len(s.live)) + for c := range s.live { + open = append(open, c) + } + s.mu.Unlock() + for _, c := range open { + _ = c.Close() + } + <-s.idleCh() +} + +func (s *Tunnels) idleCh() <-chan struct{} { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.live) == 0 { + c := make(chan struct{}) + close(c) + return c + } + if s.idle == nil { + s.idle = make(chan struct{}) + } + return s.idle +} diff --git a/pkg/agent/endpoint/websocket_test.go b/pkg/agent/endpoint/websocket_test.go new file mode 100644 index 000000000..10428157e --- /dev/null +++ b/pkg/agent/endpoint/websocket_test.go @@ -0,0 +1,299 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint_test + +import ( + "bufio" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// startWSFront serves app behind a front and a conformance worker over a real +// WebTransport session. Every request is granted. +func startWSFront( + t *testing.T, + app http.Handler, + eps []*livekit.AgentHttp_AgentEndpoint, + tune func(*endpoint.FrontParams), +) (*endpoint.Front, string) { + t.Helper() + target := httptest.NewServer(app) + t.Cleanup(target.Close) + + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + params := endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope, Level: endpoint.AccessGranted}, true + }, + Logger: logger.GetLogger(), + } + if tune != nil { + tune(¶ms) + } + front := endpoint.NewFront(params) + ts := httptest.NewServer(front) + t.Cleanup(ts.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: strings.TrimPrefix(target.URL, "http://"), + Insecure: true, + Endpoints: eps, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + return front, ts.URL + "/agents/myagent/production" +} + +func wsURL(httpURL string) string { return "ws" + strings.TrimPrefix(httpURL, "http") } + +var wsRoute = []*livekit.AgentHttp_AgentEndpoint{{Path: "/ws", Methods: []string{"GET"}, Public: true}} + +// wsEchoApp echoes every message on /ws and reports the close code the client sent. +func wsEchoApp(closed chan<- int) http.Handler { + up := websocket.Upgrader{} + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + for { + mt, msg, err := c.ReadMessage() + if err != nil { + if closed != nil { + var ce *websocket.CloseError + if errors.As(err, &ce) { + closed <- ce.Code + } else { + closed <- -1 + } + } + return + } + if err := c.WriteMessage(mt, msg); err != nil { + return + } + } + }) +} + +func TestWebSocketEchoThroughFront(t *testing.T) { + closed := make(chan int, 1) + front, base := startWSFront(t, wsEchoApp(closed), wsRoute, nil) + + c, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + require.Equal(t, 1, front.OpenTunnels()) + + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte("hello"))) + mt, msg, err := c.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.TextMessage, mt) + require.Equal(t, "hello", string(msg)) + + big := []byte(strings.Repeat("b", 200<<10)) + require.NoError(t, c.WriteMessage(websocket.BinaryMessage, big)) + mt, msg, err = c.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.BinaryMessage, mt) + require.Equal(t, big, msg) + + require.NoError(t, c.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "bye"))) + select { + case code := <-closed: + require.Equal(t, websocket.CloseNormalClosure, code) + case <-time.After(5 * time.Second): + t.Fatal("the client's close never reached the app") + } + _ = c.Close() + require.Eventually(t, func() bool { return front.OpenTunnels() == 0 }, 5*time.Second, 10*time.Millisecond) +} + +// an app refusing the handshake answers like any route. +func TestWebSocketRefusedUpgradeIsOrdinaryResponse(t *testing.T) { + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + }) + front, base := startWSFront(t, app, wsRoute, nil) + + _, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.ErrorIs(t, err, websocket.ErrBadHandshake) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, "nope\n", string(body)) + require.Equal(t, 0, front.OpenTunnels()) +} + +func TestWebSocketDrainSendsGoingAway(t *testing.T) { + front, base := startWSFront(t, wsEchoApp(nil), wsRoute, nil) + + c, _, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.NoError(t, err) + defer c.Close() + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte("x"))) + _, _, err = c.ReadMessage() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + done := make(chan struct{}) + go func() { + front.Drain(ctx) + close(done) + }() + + _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, _, err = c.ReadMessage() + require.True(t, websocket.IsCloseError(err, websocket.CloseGoingAway), "got %v", err) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("drain did not return") + } + require.Equal(t, 0, front.OpenTunnels()) + + // a draining front admits no new tunnel + _, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.Error(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +func TestWebSocketTunnelLimit(t *testing.T) { + _, base := startWSFront(t, wsEchoApp(nil), wsRoute, func(p *endpoint.FrontParams) { + p.MaxTunnelsPerWorker = 1 + }) + + c, _, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.NoError(t, err) + defer c.Close() + + _, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.Error(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// the worker gets identity from the preamble; the caller's LiveKit token must +// not reach application code. +func TestLiveKitCredentialsDoNotReachTheWorker(t *testing.T) { + type seen struct { + auth string + query string + } + got := make(chan seen, 2) + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- seen{auth: r.Header.Get("Authorization"), query: r.URL.RawQuery} + }) + _, base := startWSFront(t, app, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/who", Methods: []string{"GET"}, Public: true}, + }, nil) + + jwt, err := auth.NewAccessToken("APIkey", "secret-that-is-long-enough-to-sign"). + SetIdentity("caller").SetValidFor(time.Minute).ToJWT() + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, base+"/who?access_token="+jwt+"&x=1&b=%2F", nil) + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + s := <-got + require.Empty(t, s.auth) + require.Equal(t, "x=1&b=%2F", s.query) + + req, _ = http.NewRequest(http.MethodGet, base+"/who", nil) + req.Header.Set("Authorization", "Bearer app-owned") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, "Bearer app-owned", (<-got).auth) +} + +// an HTTP/1 client keeps streaming its body after the response has started. +func TestFullDuplexOverHTTP1(t *testing.T) { + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = http.NewResponseController(w).EnableFullDuplex() + _, _ = io.WriteString(w, "ready\n") + _ = http.NewResponseController(w).Flush() + br := bufio.NewReader(r.Body) + for { + line, err := br.ReadString('\n') + if line != "" { + _, _ = io.WriteString(w, "echo "+line) + _ = http.NewResponseController(w).Flush() + } + if err != nil { + return + } + } + }) + _, base := startWSFront(t, app, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/duplex", Methods: []string{"POST"}, Public: true}, + }, nil) + + pr, pw := io.Pipe() + req, _ := http.NewRequest(http.MethodPost, base+"/duplex", pr) + var ( + resp *http.Response + err error + wg sync.WaitGroup + ) + wg.Add(1) + go func() { + defer wg.Done() + // bounds the test if the second echo never comes + resp, err = (&http.Client{Timeout: 10 * time.Second}).Do(req) + }() + _, _ = io.WriteString(pw, "one\n") + wg.Wait() + require.NoError(t, err) + defer resp.Body.Close() + rr := bufio.NewReader(resp.Body) + + line, err := rr.ReadString('\n') + require.NoError(t, err) + require.Equal(t, "ready\n", line) + line, err = rr.ReadString('\n') + require.NoError(t, err) + require.Equal(t, "echo one\n", line) + + // the response is streaming; the request body is still open + _, err = io.WriteString(pw, "two\n") + require.NoError(t, err) + line, err = rr.ReadString('\n') + require.NoError(t, err) + require.Equal(t, "echo two\n", line) + + require.NoError(t, pw.Close()) + rest, err := io.ReadAll(rr) + require.NoError(t, err) + require.Empty(t, string(rest)) +} diff --git a/pkg/agent/endpoint/webtransport.go b/pkg/agent/endpoint/webtransport.go new file mode 100644 index 000000000..b61ae9dd5 --- /dev/null +++ b/pkg/agent/endpoint/webtransport.go @@ -0,0 +1,138 @@ +// 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 endpoint + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/quic-go/webtransport-go" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +// wtSession adapts a WebTransport session to Session: the node opens one stream +// per HTTP exchange and QUIC multiplexes them. +type wtSession struct { + sess *webtransport.Session + maxStreams int + open atomic.Int64 +} + +// NewWebTransportSession wraps a live WebTransport session as a data-plane +// Session. maxStreams is the soft concurrency cap used only for capacity +// weighting; QUIC's own stream limit is the hard bound. +func NewWebTransportSession(sess *webtransport.Session, maxStreams int) Session { + return &wtSession{sess: sess, maxStreams: maxStreams} +} + +func (s *wtSession) OpenStream(ctx context.Context) (Stream, error) { + qs, err := s.sess.OpenStreamSync(ctx) + if err != nil { + return nil, err + } + s.open.Add(1) + return &wtStream{sess: s, qs: qs}, nil +} + +func (s *wtSession) OpenStreams() int { return int(s.open.Load()) } +func (s *wtSession) MaxStreams() int { return s.maxStreams } +func (s *wtSession) Close(reason string) { _ = s.sess.CloseWithError(wire.SessionCloseOK, reason) } + +// wtStream is one HTTP exchange over a WebTransport bidi stream. +type wtStream struct { + sess *wtSession + qs *webtransport.Stream + + mu sync.Mutex + done bool + sendClosed bool // send side FIN'd (CloseWrite) or reset +} + +func (s *wtStream) Read(p []byte) (int, error) { + n, err := s.qs.Read(p) + return n, translateStreamError(err) +} + +func (s *wtStream) SetReadDeadline(t time.Time) error { return s.qs.SetReadDeadline(t) } + +func (s *wtStream) Write(p []byte) (int, error) { + n, err := s.qs.Write(p) + return n, translateStreamError(err) +} + +// CloseWrite sends STREAM FIN on the send side. +func (s *wtStream) CloseWrite() error { + s.mu.Lock() + s.sendClosed = true + s.mu.Unlock() + return s.qs.Close() +} + +func (s *wtStream) Reset(code livekit.AgentHttp_HttpStreamResetCode, _ string) { + s.mu.Lock() + s.sendClosed = true + s.mu.Unlock() + c := wire.StreamCode(code) + s.qs.CancelWrite(c) + s.qs.CancelRead(c) + s.release() +} + +func (s *wtStream) Close() error { + // If the send side was never cleanly FIN'd (CloseWrite) or reset, a writer + // goroutine may still be blocked in Write because the peer stopped reading: + // cancel the send side to unblock it (else the goroutine and QUIC stream leak). + // After a clean FIN we must NOT reset: the body's own terminator followed by + // FIN is the completion signal, and a reset destroys it. + s.mu.Lock() + cancelWrite := !s.sendClosed + s.sendClosed = true + s.mu.Unlock() + abort := wire.StreamCode(livekit.AgentHttp_HSR_ABORT) + if cancelWrite { + s.qs.CancelWrite(abort) + } + s.qs.CancelRead(abort) + s.release() + return nil +} + +// release decrements the session's open-stream count exactly once. +func (s *wtStream) release() { + s.mu.Lock() + if s.done { + s.mu.Unlock() + return + } + s.done = true + s.mu.Unlock() + s.sess.open.Add(-1) +} + +// translateStreamError turns a peer reset into the protocol's own error. Only a +// remote reset carries meaning: cancelling this side says nothing about what the +// worker did with the request. +func translateStreamError(err error) error { + var se *webtransport.StreamError + if errors.As(err, &se) && se.Remote { + return &StreamResetError{Code: livekit.AgentHttp_HttpStreamResetCode(se.ErrorCode)} + } + return err +} diff --git a/pkg/agent/endpoint/webtransport_test.go b/pkg/agent/endpoint/webtransport_test.go new file mode 100644 index 000000000..f6cc8082f --- /dev/null +++ b/pkg/agent/endpoint/webtransport_test.go @@ -0,0 +1,254 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +func selfSignedTLS(t *testing.T) *tls.Config { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{http3.NextProtoH3}, + } +} + +// startWTServer runs a WebTransport /agent server that registers each session's +// worker (read off the control stream) into reg and keeps the session alive. +func startWTServer(t *testing.T, reg *endpoint.Registry, scope *endpoint.Scope) string { + udp, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + require.NoError(t, err) + + mux := http.NewServeMux() + srv := &webtransport.Server{H3: &http3.Server{TLSConfig: selfSignedTLS(t), Handler: mux}} + mux.HandleFunc("/agent", func(w http.ResponseWriter, r *http.Request) { + sess, err := srv.Upgrade(w, r) + if err != nil { + return + } + go handleSession(reg, scope, sess) + }) + go func() { _ = srv.Serve(udp) }() + t.Cleanup(func() { _ = srv.Close(); _ = udp.Close() }) + return "https://" + udp.LocalAddr().String() + "/agent" +} + +func handleSession(reg *endpoint.Registry, scope *endpoint.Scope, sess *webtransport.Session) { + ctx := sess.Context() + control, err := sess.AcceptStream(ctx) + if err != nil { + return + } + var msg livekit.WorkerMessage + if err := wire.ReadControlMessage(control, &msg); err != nil { + return + } + rw := msg.GetRegister() + if rw == nil { + return + } + manifest, err := endpoint.ParseManifest(rw.GetEndpoints()) + if err != nil { + return + } + registration := endpoint.NewRegistration(endpoint.RegistrationParams{ + WorkerID: rw.GetInstanceId(), + Manifest: manifest, + Session: endpoint.NewWebTransportSession(sess, endpoint.DefaultMaxStreams), + }) + reg.Register(scope, registration) + _ = wire.WriteControlMessage(control, &livekit.ServerMessage{ + Message: &livekit.ServerMessage_Register{ + Register: &livekit.RegisterWorkerResponse{ + WorkerId: registration.WorkerID, + EndpointSettings: &livekit.AgentHttp_AgentEndpointSettings{ + Protocol: wire.CurrentProtocol, + }, + }, + }, + }) + // keep the session alive; drain further control messages until it dies + for { + var m livekit.WorkerMessage + if err := wire.ReadControlMessage(control, &m); err != nil { + return + } + } +} + +func TestWebTransportEndpointRoundTrip(t *testing.T) { + // the worker's local app + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/hello": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + case "/hello-private": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"private":true}`) + case "/echo": + body, _ := io.ReadAll(r.Body) + _, _ = w.Write(body) + default: + http.NotFound(w, r) + } + })) + defer target.Close() + + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + front := endpoint.NewFront(endpoint.FrontParams{ + // anonymous: the scope resolves, but no grant, so only public routes serve + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope}, true + }, + Logger: logger.GetLogger(), + }) + ts := httptest.NewServer(front) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: strings.TrimPrefix(target.URL, "http://"), + Insecure: true, + Endpoints: []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/hello", Methods: []string{"GET"}, Public: true}, + {Path: "/echo", Methods: []string{"POST"}, Public: true}, + {Path: "/hello-private", Methods: []string{"GET"}, Public: false}, + }, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + agentBase := ts.URL + "/agents/myagent/production" + + t.Run("GET json", func(t *testing.T) { + resp, err := http.Get(agentBase + "/hello") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.JSONEq(t, `{"ok":true}`, string(body)) + }) + + t.Run("POST echo", func(t *testing.T) { + payload := strings.Repeat("x", 64<<10) + resp, err := http.Post(agentBase+"/echo", "application/octet-stream", strings.NewReader(payload)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, payload, string(body)) + }) + + t.Run("unknown path 404", func(t *testing.T) { + resp, err := http.Get(agentBase + "/nope") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// the grant is enforced across a real WebTransport session. +func TestWebTransportPrivateEndpointRequiresGrant(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"private":true}`) + })) + defer target.Close() + + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + anonymous := httptest.NewServer(endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope}, true + }, + Logger: logger.GetLogger(), + })) + defer anonymous.Close() + + granted := httptest.NewServer(endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope, Level: endpoint.AccessGranted}, true + }, + Logger: logger.GetLogger(), + })) + defer granted.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: strings.TrimPrefix(target.URL, "http://"), + Insecure: true, + Endpoints: []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/secret", Methods: []string{"GET"}, Public: false}, + }, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + const path = "/agents/myagent/production/secret" + + resp, err := http.Get(anonymous.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + resp2, err := http.Get(granted.URL + path) + require.NoError(t, err) + defer resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) + body, _ := io.ReadAll(resp2.Body) + require.JSONEq(t, `{"private":true}`, string(body)) +} diff --git a/pkg/agent/endpoint/wire/control.go b/pkg/agent/endpoint/wire/control.go new file mode 100644 index 000000000..3a77d98a1 --- /dev/null +++ b/pkg/agent/endpoint/wire/control.go @@ -0,0 +1,73 @@ +// 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 wire + +import ( + "encoding/binary" + "fmt" + "io" + + "google.golang.org/protobuf/proto" +) + +// MaxControlMessageSize bounds one framed control message so a malformed length +// prefix cannot make the reader allocate without bound. +const MaxControlMessageSize = 1 << 20 + +// WriteControlMessage writes a length-delimited protobuf on the control stream: +// a 4-byte big-endian length followed by the marshaled message. The stream +// carries the WorkerMessage/ServerMessage exchange. +func WriteControlMessage(w io.Writer, m proto.Message) error { + return writeLenPrefixed(w, m) +} + +// ReadControlMessage reads one length-delimited protobuf written by +// WriteControlMessage into m. +func ReadControlMessage(r io.Reader, m proto.Message) error { + return readLenPrefixed(r, m, MaxControlMessageSize) +} + +// writeLenPrefixed is the package's one length-delimited protobuf encoding: +// 4-byte big-endian length, then the marshaled message. The control stream and +// the per-exchange preamble share it. +func writeLenPrefixed(w io.Writer, m proto.Message) error { + b, err := proto.Marshal(m) + if err != nil { + return err + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(b))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err = w.Write(b) + return err +} + +func readLenPrefixed(r io.Reader, m proto.Message, max uint32) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return err + } + n := binary.BigEndian.Uint32(hdr[:]) + if n > max { + return fmt.Errorf("message too large: %d bytes", n) + } + b := make([]byte, n) + if _, err := io.ReadFull(r, b); err != nil { + return err + } + return proto.Unmarshal(b, m) +} diff --git a/pkg/agent/endpoint/wire/exchange.go b/pkg/agent/endpoint/wire/exchange.go new file mode 100644 index 000000000..b6299fe5d --- /dev/null +++ b/pkg/agent/endpoint/wire/exchange.go @@ -0,0 +1,342 @@ +// 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 wire + +import ( + "fmt" + "io" + "net/http" + "slices" + "strconv" + "strings" + + "golang.org/x/net/http/httpguts" + + "github.com/livekit/protocol/livekit" +) + +const ( + // MaxPreambleSize bounds the one allocation a peer can size before any HTTP + // bytes are parsed. + MaxPreambleSize = 64 << 10 + + MaxRequestHeadSize = 1 << 20 + BodyChunkSize = 32 << 10 + + // ReservedHeaderPrefix namespaces the in-band signalling below. + ReservedHeaderPrefix = "x-lk-" + + completionHeader = "x-lk-completion" + errorHeader = "x-lk-error" +) + +// Completion is how one direction of an exchange ended, spelled as the literal +// x-lk-completion trailer token. The empty value carries no information, so a +// sender that omits the field cannot thereby claim success; success is the +// body's own framing reaching its end and is never announced. +type Completion string + +const ( + CompletionUnknown Completion = "" + CompletionTruncated Completion = "truncated" + CompletionPeerGone Completion = "peer_gone" + CompletionRefused Completion = "refused" + CompletionInternal Completion = "internal" + CompletionTimeout Completion = "timeout" + CompletionCanceled Completion = "canceled" + CompletionProtocol Completion = "protocol" +) + +// CompletionError is a terminal state other than success. +type CompletionError struct { + Completion Completion + Reason string +} + +func (e *CompletionError) Error() string { + c := string(e.Completion) + if c == "" { + c = "unknown" + } + if e.Reason == "" { + return fmt.Sprintf("endpoint: exchange ended %s", c) + } + return fmt.Sprintf("endpoint: exchange ended %s: %s", c, e.Reason) +} + +// ResetCode is the QUIC reset code carrying this completion when it happened +// before any HTTP bytes flowed. Outcomes that can only arise mid-body have no +// code of their own and collapse to HSR_ABORT. +func (c Completion) ResetCode() livekit.AgentHttp_HttpStreamResetCode { + switch c { + case CompletionRefused: + return livekit.AgentHttp_HSR_REFUSED + case CompletionInternal: + return livekit.AgentHttp_HSR_INTERNAL + case CompletionTimeout: + return livekit.AgentHttp_HSR_TIMEOUT + case CompletionProtocol: + return livekit.AgentHttp_HSR_PROTOCOL + default: + return livekit.AgentHttp_HSR_ABORT + } +} + +// CompletionFromResetCode is the inverse. HSR_ABORT carries no claim about +// whether the request was applied. +func CompletionFromResetCode(code livekit.AgentHttp_HttpStreamResetCode) Completion { + switch code { + case livekit.AgentHttp_HSR_REFUSED: + return CompletionRefused + case livekit.AgentHttp_HSR_INTERNAL: + return CompletionInternal + case livekit.AgentHttp_HSR_TIMEOUT: + return CompletionTimeout + case livekit.AgentHttp_HSR_PROTOCOL: + return CompletionProtocol + default: + return CompletionUnknown + } +} + +// CompletionFromTrailers reports the outcome a peer announced, or nil when it +// announced none. No announcement, the body framing having reached its end, is +// success. +func CompletionFromTrailers(t http.Header) *CompletionError { + if len(t) == 0 { + return nil + } + c := Completion(strings.ToLower(strings.TrimSpace(t.Get(completionHeader)))) + if c == CompletionUnknown { + return nil + } + return &CompletionError{Completion: c, Reason: t.Get(errorHeader)} +} + +// WritePreamble writes the stream preamble. Everything after it is opaque +// HTTP/1.1. +func WritePreamble(w io.Writer, p *livekit.AgentHttp_StreamPreamble) error { + return writeLenPrefixed(w, p) +} + +func ReadPreamble(r io.Reader) (*livekit.AgentHttp_StreamPreamble, error) { + p := &livekit.AgentHttp_StreamPreamble{} + if err := readLenPrefixed(r, p, MaxPreambleSize); err != nil { + return nil, err + } + return p, nil +} + +// StripReservedHeaders removes the in-band signalling fields. Every header set +// crossing between client and worker must pass through here: a client-set +// x-lk-completion would otherwise forge an outcome, and a worker-set one would +// reach the end client. +func StripReservedHeaders(h http.Header) { + for k := range h { + if strings.HasPrefix(strings.ToLower(k), ReservedHeaderPrefix) { + delete(h, k) + } + } +} + +// BuildRequestHead serializes the canonical origin-form request head. +// +// Its inputs must come from an already-parsed request, never from the client's +// own bytes: emitting a head this side has parsed is what keeps request +// smuggling out of the worker. +// +// contentLength >= 0 is framed with Content-Length; a negative length is framed +// chunked, the only framing that can carry completion trailers. +func BuildRequestHead(method, target, host string, h http.Header, contentLength int64) ([]byte, error) { + if !httpguts.ValidHeaderFieldName(method) { + return nil, fmt.Errorf("endpoint: invalid method %q", method) + } + if !strings.HasPrefix(target, "/") { + return nil, fmt.Errorf("endpoint: request target must be origin-form, got %q", target) + } + if strings.ContainsAny(target, " \r\n") { + return nil, fmt.Errorf("endpoint: invalid request target %q", target) + } + if !httpguts.ValidHostHeader(host) { + return nil, fmt.Errorf("endpoint: invalid host %q", host) + } + + var b strings.Builder + b.WriteString(method) + b.WriteByte(' ') + b.WriteString(target) + b.WriteString(" HTTP/1.1\r\nHost: ") + b.WriteString(host) + b.WriteString("\r\n") + + // the only framing header on the request; isFramingHeader drops any the + // caller supplied + if contentLength >= 0 { + b.WriteString("Content-Length: ") + b.WriteString(strconv.FormatInt(contentLength, 10)) + b.WriteString("\r\n") + } else { + b.WriteString("Transfer-Encoding: chunked\r\n") + } + + keys := make([]string, 0, len(h)) + for k := range h { + if isFramingHeader(k) { + continue + } + keys = append(keys, k) + } + slices.Sort(keys) // deterministic across attempts and across nodes + for _, k := range keys { + if !httpguts.ValidHeaderFieldName(k) { + return nil, fmt.Errorf("endpoint: invalid header name %q", k) + } + for _, v := range h[k] { + if !httpguts.ValidHeaderFieldValue(v) { + return nil, fmt.Errorf("endpoint: invalid value for header %q", k) + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(v) + b.WriteString("\r\n") + } + } + b.WriteString("\r\n") + return []byte(b.String()), nil +} + +// isFramingHeader reports the fields whose presence would let the head declare a +// second, conflicting body framing. +func isFramingHeader(k string) bool { + switch http.CanonicalHeaderKey(k) { + case "Host", "Content-Length", "Transfer-Encoding", "Trailer": + return true + } + return false +} + +// BodyWriter frames one body onto a stream. Close ends the body; a completion +// other than success is reported in trailers where the framing allows it. +type BodyWriter interface { + io.Writer + Close(c Completion, reason string) error +} + +// NewChunkedBody frames a body with chunked transfer coding, the only framing +// that can carry a completion trailer. +func NewChunkedBody(w io.Writer) BodyWriter { return &chunkedBody{w: w} } + +// NewIdentityBody writes a body whose length the head already declared. It +// cannot carry trailers: a short body is surfaced by the FIN arriving before +// the declared length is met. +func NewIdentityBody(w io.Writer) BodyWriter { return &identityBody{w: w} } + +type identityBody struct{ w io.Writer } + +func (b *identityBody) Write(p []byte) (int, error) { return b.w.Write(p) } +func (b *identityBody) Close(Completion, string) error { return nil } + +type chunkedBody struct { + w io.Writer + buf []byte +} + +func (b *chunkedBody) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil // a zero-length chunk would terminate the body + } + b.buf = strconv.AppendUint(b.buf[:0], uint64(len(p)), 16) + b.buf = append(b.buf, '\r', '\n') + if _, err := b.w.Write(b.buf); err != nil { + return 0, err + } + if _, err := b.w.Write(p); err != nil { + return 0, err + } + if _, err := b.w.Write([]byte("\r\n")); err != nil { + return 0, err + } + return len(p), nil +} + +// Close writes the terminal 0-chunk and, for a non-success outcome, the +// completion trailers. Success is the absence of them. +func (b *chunkedBody) Close(c Completion, reason string) error { + var sb strings.Builder + sb.WriteString("0\r\n") + if c != CompletionUnknown { + sb.WriteString(completionHeader) + sb.WriteString(": ") + sb.WriteString(string(c)) + sb.WriteString("\r\n") + if reason != "" { + sb.WriteString(errorHeader) + sb.WriteString(": ") + sb.WriteString(sanitizeReason(reason)) + sb.WriteString("\r\n") + } + } + sb.WriteString("\r\n") + _, err := b.w.Write([]byte(sb.String())) + return err +} + +// sanitizeReason bounds an operator-supplied string and strips the control +// characters that would break trailer framing. +func sanitizeReason(s string) string { + const max = 256 + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, s) + if len(s) > max { + s = s[:max] + } + return s +} + +// SourceError reports that the body's source failed mid-copy. The stream is +// still intact, so the caller can close the body with a completion. +type SourceError struct{ Err error } + +func (e *SourceError) Error() string { + return fmt.Sprintf("endpoint: body source failed: %s", e.Err) +} + +func (e *SourceError) Unwrap() error { return e.Err } + +// CopyBody pumps src into dst one write per read, so a streaming body stays +// incremental. A src failure is wrapped in *SourceError; any other error is +// from dst. +func CopyBody(dst BodyWriter, src io.Reader, buf []byte) (int64, error) { + var n int64 + for { + nr, rerr := src.Read(buf) + if nr > 0 { + if _, werr := dst.Write(buf[:nr]); werr != nil { + return n, werr + } + n += int64(nr) + } + if rerr == io.EOF { + return n, nil + } + if rerr != nil { + return n, &SourceError{Err: rerr} + } + } +} diff --git a/pkg/agent/endpoint/wire/exchange_test.go b/pkg/agent/endpoint/wire/exchange_test.go new file mode 100644 index 000000000..818f2f57c --- /dev/null +++ b/pkg/agent/endpoint/wire/exchange_test.go @@ -0,0 +1,272 @@ +// 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 wire + +import ( + "bufio" + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +func TestPreambleRoundTrip(t *testing.T) { + var buf bytes.Buffer + in := &livekit.AgentHttp_StreamPreamble{ + Kind: livekit.AgentHttp_AEK_HTTP, + RequestId: "AER_abc", + Authorized: true, + Route: "/items/{id}", + TimeoutMs: 1500, + ClientAddr: "203.0.113.7", + Scheme: "https", + } + require.NoError(t, WritePreamble(&buf, in)) + + // the exchange bytes follow immediately; the reader must stop at the boundary + buf.WriteString("GET / HTTP/1.1\r\n") + + out, err := ReadPreamble(&buf) + require.NoError(t, err) + require.Equal(t, in.RequestId, out.RequestId) + require.True(t, out.Authorized) + require.Equal(t, "/items/{id}", out.Route) + require.EqualValues(t, 1500, out.TimeoutMs) + require.Equal(t, "https", out.Scheme) + require.Equal(t, "GET / HTTP/1.1\r\n", buf.String(), "the preamble must not over-read") +} + +func TestPreambleRejectsOversizedLength(t *testing.T) { + var buf bytes.Buffer + buf.Write([]byte{0xff, 0xff, 0xff, 0xff}) + _, err := ReadPreamble(&buf) + require.Error(t, err, "a malformed length must not make the reader allocate") +} + +// The head is what keeps request smuggling out of the worker, so it declares +// exactly one framing and nothing the caller supplied can add a second. +func TestBuildRequestHeadEmitsExactlyOneFraming(t *testing.T) { + h := http.Header{} + h.Set("Content-Length", "999") + h.Set("Transfer-Encoding", "chunked") + h.Set("Trailer", "x-whatever") + h.Set("Host", "evil.example") + h.Set("Accept", "text/plain") + + b, err := BuildRequestHead("POST", "/items/42?q=1", "good.example", h, 7) + require.NoError(t, err) + head := string(b) + + require.True(t, strings.HasPrefix(head, "POST /items/42?q=1 HTTP/1.1\r\n")) + require.Equal(t, 1, strings.Count(head, "Content-Length: ")) + require.Contains(t, head, "Content-Length: 7\r\n") + require.NotContains(t, head, "chunked") + require.NotContains(t, head, "evil.example") + require.NotContains(t, head, "Trailer:") + require.Contains(t, head, "Host: good.example\r\n") + require.Contains(t, head, "Accept: text/plain\r\n") + require.True(t, strings.HasSuffix(head, "\r\n\r\n")) + + // net/http parses back what we emitted + req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(head + "1234567"))) + require.NoError(t, err) + require.Equal(t, "POST", req.Method) + require.Equal(t, "/items/42", req.URL.Path) + require.Equal(t, "q=1", req.URL.RawQuery) + require.Equal(t, "good.example", req.Host) + require.EqualValues(t, 7, req.ContentLength) +} + +func TestBuildRequestHeadChunkedWhenLengthUnknown(t *testing.T) { + b, err := BuildRequestHead("POST", "/x", "h", http.Header{}, -1) + require.NoError(t, err) + require.Contains(t, string(b), "Transfer-Encoding: chunked\r\n") + require.NotContains(t, string(b), "Content-Length:") +} + +func TestBuildRequestHeadRejectsInjection(t *testing.T) { + bad := http.Header{} + bad.Set("X-Evil", "a\r\nX-Injected: yes") + _, err := BuildRequestHead("GET", "/x", "h", bad, 0) + require.Error(t, err, "a header value carrying CRLF must never be serialized") + + _, err = BuildRequestHead("GET", "/x y", "h", http.Header{}, 0) + require.Error(t, err, "a target with a space would split the request line") + + _, err = BuildRequestHead("GET", "x", "h", http.Header{}, 0) + require.Error(t, err, "the target must be origin-form") + + _, err = BuildRequestHead("GET", "/x", "bad host\r\n", http.Header{}, 0) + require.Error(t, err) +} + +// Go parses what we emit, including trailers never announced in Trailer:. +func TestChunkedBodyAndTrailersParseWithNetHTTP(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + _, err := bw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, bw.Close(CompletionInternal, "boom at chunk 12")) + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + + ce := CompletionFromTrailers(resp.Trailer) + require.NotNil(t, ce, "an undeclared trailer must still be read") + require.Equal(t, CompletionInternal, ce.Completion) + require.Equal(t, "boom at chunk 12", ce.Reason) +} + +// Success is the absence of a completion trailer, never a value you send. +func TestCleanChunkedBodyReportsNoCompletion(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + _, _ = bw.Write([]byte("hello")) + require.NoError(t, bw.Close(CompletionUnknown, "")) + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + require.Nil(t, CompletionFromTrailers(resp.Trailer)) +} + +// The same bytes cut before the terminator must not read as a clean body. +func TestTruncatedChunkedBodyIsDistinguishable(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + _, _ = bw.Write([]byte("hello")) + // no Close: the terminal 0-chunk never arrives + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body with no terminator must not read clean") +} + +func TestIdentityBodyWritesNothingExtra(t *testing.T) { + var buf bytes.Buffer + bw := NewIdentityBody(&buf) + _, err := bw.Write([]byte("hello")) + require.NoError(t, err) + // an identity body cannot carry trailers; Close must not invent framing + require.NoError(t, bw.Close(CompletionTruncated, "ignored")) + require.Equal(t, "hello", buf.String()) +} + +func TestResetCodeRoundTrip(t *testing.T) { + for _, c := range []Completion{ + CompletionRefused, CompletionInternal, CompletionTimeout, CompletionProtocol, + } { + require.Equal(t, c, CompletionFromResetCode(c.ResetCode()), string(c)) + } + // outcomes that can only arise mid-body have no code of their own + require.Equal(t, livekit.AgentHttp_HSR_ABORT, CompletionTruncated.ResetCode()) + require.Equal(t, livekit.AgentHttp_HSR_ABORT, CompletionPeerGone.ResetCode()) + // and a bare abort must not decode as a claim that nothing was applied + require.Equal(t, CompletionUnknown, CompletionFromResetCode(livekit.AgentHttp_HSR_ABORT)) + require.NotEqual(t, CompletionRefused, CompletionFromResetCode(livekit.AgentHttp_HSR_ABORT)) +} + +func TestStripReservedHeaders(t *testing.T) { + h := http.Header{} + h.Set("X-Lk-Completion", "internal") + h.Set("x-lk-error", "forged") + h.Set("X-LK-Anything", "nope") + h.Set("X-Keep", "yes") + StripReservedHeaders(h) + require.Empty(t, h.Get("X-Lk-Completion")) + require.Empty(t, h.Get("X-Lk-Error")) + require.Empty(t, h.Get("X-Lk-Anything")) + require.Equal(t, "yes", h.Get("X-Keep")) +} + +// A reason describing worker internals must not be able to break framing. +func TestCompletionReasonCannotBreakFraming(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + require.NoError(t, bw.Close(CompletionInternal, "a\r\nx-injected: yes\r\n")) + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Empty(t, resp.Trailer.Get("X-Injected")) +} + +// oneByteReader forces reassembly across read boundaries. +type oneByteReader struct{ r io.Reader } + +func (o oneByteReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + return o.r.Read(p[:1]) +} + +func TestCopyBodySplitReads(t *testing.T) { + src := bytes.Repeat([]byte("abcdefgh"), 1024) + var buf bytes.Buffer + bw := NewIdentityBody(&buf) + n, err := CopyBody(bw, oneByteReader{bytes.NewReader(src)}, make([]byte, 512)) + require.NoError(t, err) + require.EqualValues(t, len(src), n) + require.Equal(t, src, buf.Bytes()) +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +// Only a source failure leaves the stream able to report the outcome. +func TestCopyBodySeparatesSourceAndSinkFailures(t *testing.T) { + // a destination failure is returned unwrapped + _, err := CopyBody(NewIdentityBody(failingWriter{}), strings.NewReader("xxxx"), make([]byte, 2)) + require.Error(t, err) + var srcErr *SourceError + require.False(t, errors.As(err, &srcErr)) + + // a source failure is wrapped, so the caller can still close the body + _, err = CopyBody(NewIdentityBody(io.Discard), iotestErrReader{}, make([]byte, 2)) + require.ErrorAs(t, err, &srcErr) +} + +type iotestErrReader struct{} + +func (iotestErrReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +// The body path is the hot path: chunk framing must not allocate per write. +func TestChunkedWriteDoesNotAllocatePerChunk(t *testing.T) { + bw := NewChunkedBody(io.Discard) + p := make([]byte, 4096) + avg := testing.AllocsPerRun(200, func() { + _, _ = bw.Write(p) + }) + require.LessOrEqual(t, avg, 1.0, "chunk framing should reuse its scratch buffer") +} diff --git a/pkg/agent/endpoint/wire/protocol.go b/pkg/agent/endpoint/wire/protocol.go new file mode 100644 index 000000000..dc5cd7fd3 --- /dev/null +++ b/pkg/agent/endpoint/wire/protocol.go @@ -0,0 +1,26 @@ +// 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 wire + +// CurrentProtocol is the data-plane protocol version this implementation speaks, +// negotiated in RegisterWorkerResponse. +const CurrentProtocol uint32 = 1 + +// MinProtocol is the oldest data-plane protocol version still served. +const MinProtocol uint32 = 1 + +// SessionCloseOK is the WebTransport application close code for a normal session +// teardown; the human-readable reason travels in the close message. +const SessionCloseOK = 0 diff --git a/pkg/agent/endpoint/wire/streamcode.go b/pkg/agent/endpoint/wire/streamcode.go new file mode 100644 index 000000000..a2fef054d --- /dev/null +++ b/pkg/agent/endpoint/wire/streamcode.go @@ -0,0 +1,28 @@ +// 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 wire + +import ( + "github.com/quic-go/webtransport-go" + + "github.com/livekit/protocol/livekit" +) + +// StreamCode converts a protocol reset code to the WebTransport code that +// carries it. HSR_ABORT is zero, so a teardown with nothing to say sends the +// plain cancel code. +func StreamCode(c livekit.AgentHttp_HttpStreamResetCode) webtransport.StreamErrorCode { + return webtransport.StreamErrorCode(c) +} diff --git a/pkg/agent/testutils/server.go b/pkg/agent/testutils/server.go index 3f490918c..30793d069 100644 --- a/pkg/agent/testutils/server.go +++ b/pkg/agent/testutils/server.go @@ -15,6 +15,7 @@ import ( "github.com/gammazero/deque" "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/service" @@ -40,7 +41,7 @@ type TestServer struct { func NewTestServer(bus psrpc.MessageBus) *TestServer { localNode, _ := routing.NewLocalNode(nil) - return NewTestServerWithService(must.Get(service.NewAgentService( + return NewTestServerWithService(must.Get(service.NewAgentHandler( &config.Config{ Region: "test", Agents: agent.Config{ @@ -50,6 +51,8 @@ func NewTestServer(bus psrpc.MessageBus) *TestServer { localNode, bus, auth.NewSimpleKeyProvider("test", "verysecretsecret"), + endpoint.NewRegistry(), + service.NewEndpointScopes(), ))) } diff --git a/pkg/agent/worker.go b/pkg/agent/worker.go index d8d14b94a..bdd5f5c40 100644 --- a/pkg/agent/worker.go +++ b/pkg/agent/worker.go @@ -25,11 +25,11 @@ import ( "go.uber.org/multierr" "google.golang.org/protobuf/proto" + "github.com/livekit/livekit-server/pkg/agent/endpoint" protoagent "github.com/livekit/protocol/agent" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" "github.com/livekit/psrpc" ) @@ -155,6 +155,12 @@ type WorkerRegistration struct { Permissions *livekit.ParticipantPermission ClientIP string Deployment string + + // agent HTTP endpoints data plane + Endpoints []*livekit.AgentHttp_AgentEndpoint + InstanceID string + EndpointSettings *livekit.AgentHttp_AgentEndpointSettings + // KindDetails, when set by the server, are stamped onto the participant // join token minted for every job assigned to this worker (e.g. marking // hosted/cloud agents so they can be distinguished from self-hosted ones in @@ -172,21 +178,52 @@ func MakeWorkerRegistration() WorkerRegistration { var _ WorkerSignalHandler = (*WorkerRegisterer)(nil) +// WorkerRegisterHandler takes a turn during a worker's registration: it reads the +// request, validates the part it owns, and fills in its part of the response and +// the registration. Returning an error fails the registration. +type WorkerRegisterHandler func(req *livekit.RegisterWorkerRequest, res *livekit.RegisterWorkerResponse, reg *WorkerRegistration) error + +// EndpointRegisterHandler owns a worker's endpoint declaration: it validates the +// declaration and negotiates the data-plane settings. A registration declaring no +// endpoints passes through untouched. +func EndpointRegisterHandler(req *livekit.RegisterWorkerRequest, res *livekit.RegisterWorkerResponse, reg *WorkerRegistration) error { + if len(req.GetEndpoints()) == 0 { + return nil + } + // endpoints are addressed at /agents/{agent_name}/{deployment}/..., with the + // name percent-encoded into that segment + if req.GetAgentName() == "" { + return errors.New("agent HTTP endpoints require an agent name") + } + if endpoint.IsReservedAgentName(req.GetAgentName()) { + return fmt.Errorf("agent name %q is reserved and cannot serve HTTP endpoints", req.GetAgentName()) + } + settings, err := endpoint.NegotiateSettings(req) + if err != nil { + return err + } + res.EndpointSettings = settings + reg.EndpointSettings = settings + return nil +} + type WorkerRegisterer struct { WorkerPingHandler serverInfo *livekit.ServerInfo deadline time.Time + handlers []WorkerRegisterHandler registration WorkerRegistration registered bool } -func NewWorkerRegisterer(conn SignalConn, serverInfo *livekit.ServerInfo, base WorkerRegistration) *WorkerRegisterer { +func NewWorkerRegisterer(conn SignalConn, serverInfo *livekit.ServerInfo, base WorkerRegistration, handlers ...WorkerRegisterHandler) *WorkerRegisterer { return &WorkerRegisterer{ WorkerPingHandler: WorkerPingHandler{conn: conn}, serverInfo: serverInfo, registration: base, deadline: time.Now().Add(RegisterTimeout), + handlers: handlers, } } @@ -227,15 +264,22 @@ func (h *WorkerRegisterer) HandleRegister(req *livekit.RegisterWorkerRequest) er h.registration.JobType = req.GetType() h.registration.Permissions = permissions h.registration.Deployment = req.GetDeployment() + h.registration.Endpoints = req.GetEndpoints() + h.registration.InstanceID = req.GetInstanceId() + + res := &livekit.RegisterWorkerResponse{ + WorkerId: h.registration.ID, + ServerInfo: h.serverInfo, + } + for _, handle := range h.handlers { + if err := handle(req, res, &h.registration); err != nil { + return err + } + } h.registered = true _, err := h.conn.WriteServerMessage(&livekit.ServerMessage{ - Message: &livekit.ServerMessage_Register{ - Register: &livekit.RegisterWorkerResponse{ - WorkerId: h.registration.ID, - ServerInfo: h.serverInfo, - }, - }, + Message: &livekit.ServerMessage_Register{Register: res}, }) return err } @@ -254,9 +298,11 @@ type Worker struct { cancel context.CancelFunc closed chan struct{} - mu sync.Mutex - load float32 - status livekit.WorkerStatus + mu sync.Mutex + load float32 + status livekit.WorkerStatus + statusSeq uint64 + draining bool runningJobs map[livekit.JobID]*livekit.Job availability map[livekit.JobID]chan *livekit.AvailabilityResponse @@ -333,12 +379,12 @@ func (w *Worker) GetJobState(jobID livekit.JobID) (*livekit.JobState, error) { if !ok { return nil, ErrJobNotFound } - return utils.CloneProto(j.State), nil + return proto.CloneOf(j.State), nil } func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook AssignmentHook) (*livekit.JobState, error) { availCh := make(chan *livekit.AvailabilityResponse, 1) - job = utils.CloneProto(job) + job = proto.CloneOf(job) jobID := livekit.JobID(job.Id) w.mu.Lock() @@ -420,16 +466,20 @@ func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook Assignmen if hook != nil { send = hook(send) } + state := proto.CloneOf(job.State) + + // must be in runningJobs before the assignment is sent; stored as a copy because send reads job without w.mu. + w.mu.Lock() + w.runningJobs[jobID] = proto.CloneOf(job) + w.mu.Unlock() + if err := send(&livekit.JobAssignment{Job: job, Token: token}); err != nil { + w.mu.Lock() + delete(w.runningJobs, jobID) + w.mu.Unlock() return nil, err } - state := utils.CloneProto(job.State) - - w.mu.Lock() - w.runningJobs[jobID] = job - w.mu.Unlock() - // TODO sweep jobs that are never started. We can't do this until all SDKs actually update the the JOB state return state, nil @@ -588,15 +638,30 @@ func (w *Worker) HandleUpdateWorker(update *livekit.UpdateWorkerStatus) error { w.mu.Lock() defer w.mu.Unlock() + // status updates may interleave across connections; never regress newer state + if seq := update.GetSeq(); seq != 0 { + if seq <= w.statusSeq { + return nil + } + w.statusSeq = seq + } + if status := update.Status; status != nil && w.status != *status { w.status = *status w.Logger().Debugw("worker status changed", "status", w.status) } w.load = update.GetLoad() + w.draining = update.GetDraining() return nil } +func (w *Worker) Draining() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.draining +} + func (w *Worker) HandleMigrateJob(req *livekit.MigrateJobRequest) error { // TODO(theomonnom): On OSS this is not implemented // We could maybe just move a specific job to another worker diff --git a/pkg/agent/worker_test.go b/pkg/agent/worker_test.go new file mode 100644 index 000000000..5b9947892 --- /dev/null +++ b/pkg/agent/worker_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 LiveKit, Inc. + +package agent_test + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +type nopSignalConn struct{} + +func (nopSignalConn) WriteServerMessage(*livekit.ServerMessage) (int, error) { return 0, nil } +func (nopSignalConn) ReadWorkerMessage() (*livekit.WorkerMessage, int, error) { return nil, 0, nil } +func (nopSignalConn) SetReadDeadline(time.Time) error { return nil } +func (nopSignalConn) Close() error { return nil } +func (nopSignalConn) CloseWithReason(string) error { return nil } + +// the name is percent-encoded into a URL path segment. +func TestHandleRegisterEndpointAgentNames(t *testing.T) { + cases := []struct { + name string + agentName string + ok bool + }{ + {"plain", "test-agent", true}, + {"underscore", "my_agent", true}, + {"spaces", "LODHA Vayam Agent", true}, + {"colon and star", "prod:*", true}, + {"at sign", "charlie@v1.42.0", true}, + {"slash", "models/gemma/v2", true}, + {"brackets", "Nathan [Elara Agent]", true}, + {"non ascii", "Sehhaty الصحة", true}, + {"past the old 64 byte cap", strings.Repeat("n", 77), true}, + + {"empty", "", false}, + {"unnamed sentinel", "_", false}, + {"dot", ".", false}, + {"dot dot", "..", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := agent.NewWorkerRegisterer(nopSignalConn{}, &livekit.ServerInfo{}, agent.WorkerRegistration{}, + agent.EndpointRegisterHandler) + + err := h.HandleRegister(&livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: c.agentName, + Endpoints: []*livekit.AgentHttp_AgentEndpoint{{Path: "/hook", Methods: []string{"GET"}}}, + InstanceId: "AEI_test", + EndpointProtocol: wire.CurrentProtocol, + }) + if c.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// the reserved names constrain only workers that declare endpoints. +func TestHandleRegisterWithoutEndpointsIgnoresReservedNames(t *testing.T) { + for _, name := range []string{"", "_", ".", ".."} { + h := agent.NewWorkerRegisterer(nopSignalConn{}, &livekit.ServerInfo{}, agent.WorkerRegistration{}, agent.EndpointRegisterHandler) + require.NoError(t, h.HandleRegister(&livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: name, + }), name) + } +} diff --git a/pkg/clientconfiguration/conf.go b/pkg/clientconfiguration/conf.go index 7fb5ae8d5..9614a0602 100644 --- a/pkg/clientconfiguration/conf.go +++ b/pkg/clientconfiguration/conf.go @@ -49,9 +49,25 @@ var StaticConfigurations = []ConfigurationItem{ }, Merge: true, }, + // Firefox 155 enabled AV1 for WebRTC by default, but 155/156 decode only the base spatial + // layer of an AV1 SVC stream. SVC is not visible in SDP, so such a subscriber negotiating AV1 + // would be forwarded the SVC stream in place of the publisher's backup codec and render + // higher layers black. Firefox 157 decodes the higher layers, so it keeps AV1. + // Firefox does not publish AV1 either (no SVC encoding), so disable it in both directions. + { + Match: must.Get(NewScriptMatch(`(c.browser == "firefox" || c.browser == "firefox mobile") && c.browser_version < "157"`)), + Configuration: &livekit.ClientConfiguration{ + DisabledCodecs: &livekit.DisabledCodecs{ + Codecs: []*livekit.Codec{ + {Mime: mime.MimeTypeAV1.String()}, + }, + }, + }, + Merge: true, + }, { Match: must.Get(NewScriptMatch(`(c.device_model == "xiaomi 2201117ti" && c.os == "android") || - ((c.browser == "firefox" || c.browser == "firefox mobile") && (c.os == "linux" || c.os == "android"))`)), + ((c.browser == "firefox" || c.browser == "firefox mobile") && c.os == "android")`)), Configuration: &livekit.ClientConfiguration{ DisabledCodecs: &livekit.DisabledCodecs{ Publish: []*livekit.Codec{ @@ -59,6 +75,7 @@ var StaticConfigurations = []ConfigurationItem{ }, }, }, - Merge: false, + // merged so Firefox for Android keeps the AV1 rule above + Merge: true, }, } diff --git a/pkg/clientconfiguration/conf_test.go b/pkg/clientconfiguration/conf_test.go index 453d57cb8..415abbbf3 100644 --- a/pkg/clientconfiguration/conf_test.go +++ b/pkg/clientconfiguration/conf_test.go @@ -19,10 +19,67 @@ import ( "github.com/stretchr/testify/require" + "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils/must" ) +func TestStaticConfigurationH264(t *testing.T) { + cm := NewStaticClientConfigurationManager(StaticConfigurations) + + h264Disabled := func(info *livekit.ClientInfo) bool { + conf := cm.GetConfiguration(info) + if conf == nil || conf.DisabledCodecs == nil { + return false + } + for _, c := range conf.DisabledCodecs.Publish { + if mime.IsMimeTypeStringH264(c.Mime) { + return true + } + } + return false + } + + // desktop Linux Firefox publishes H.264 through OpenH264, so it must not be disabled + require.False(t, h264Disabled(&livekit.ClientInfo{Browser: "firefox", Os: "linux"})) + // Firefox for Android is still worked around + require.True(t, h264Disabled(&livekit.ClientInfo{Browser: "firefox mobile", Os: "android"})) +} + +func TestStaticConfigurationAV1(t *testing.T) { + cm := NewStaticClientConfigurationManager(StaticConfigurations) + + av1Disabled := func(info *livekit.ClientInfo) bool { + conf := cm.GetConfiguration(info) + if conf == nil || conf.DisabledCodecs == nil { + return false + } + for _, c := range conf.DisabledCodecs.Codecs { + if mime.IsMimeTypeStringEqual(c.Mime, mime.MimeTypeAV1.String()) { + return true + } + } + return false + } + + // Firefox 155/156 decode only the base spatial layer of AV1 SVC + require.True(t, av1Disabled(&livekit.ClientInfo{Browser: "Firefox", BrowserVersion: "155.0", Os: "macOS"})) + require.True(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", BrowserVersion: "156.0.1", Os: "windows"})) + // an unknown version is treated as affected + require.True(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", Os: "linux"})) + // Firefox 157 decodes every layer, so it keeps AV1 + require.False(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", BrowserVersion: "157.0", Os: "windows"})) + require.False(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", BrowserVersion: "158.0.2", Os: "windows"})) + require.False(t, av1Disabled(&livekit.ClientInfo{Browser: "chrome", Os: "linux"})) + + // Firefox for Android keeps the AV1 rule alongside its H.264 publish rule + android := &livekit.ClientInfo{Browser: "firefox mobile", BrowserVersion: "156.0", Os: "android"} + require.True(t, av1Disabled(android)) + conf := cm.GetConfiguration(android) + require.Len(t, conf.DisabledCodecs.Publish, 1) + require.True(t, mime.IsMimeTypeStringH264(conf.DisabledCodecs.Publish[0].Mime)) +} + func TestScriptMatchConfiguration(t *testing.T) { t.Run("no merge", func(t *testing.T) { confs := []ConfigurationItem{ diff --git a/pkg/clientconfiguration/staticconfiguration.go b/pkg/clientconfiguration/staticconfiguration.go index d8145c260..198b81dc9 100644 --- a/pkg/clientconfiguration/staticconfiguration.go +++ b/pkg/clientconfiguration/staticconfiguration.go @@ -20,7 +20,6 @@ import ( "github.com/livekit/livekit-server/pkg/utils" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - protoutils "github.com/livekit/protocol/utils" ) type ConfigurationItem struct { @@ -59,7 +58,7 @@ func (s *StaticClientConfigurationManager) GetConfiguration(clientInfo *livekit. var conf *livekit.ClientConfiguration for k, v := range matchedConf { if k == 0 { - conf = protoutils.CloneProto(matchedConf[0]) + conf = proto.CloneOf(matchedConf[0]) } else { // TODO : there is a problem use protobuf merge, we don't have flag to indicate 'no value', // don't override default behavior or other configuration's field. So a bool value = false or diff --git a/pkg/config/config.go b/pkg/config/config.go index 575fa5345..faa86935e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -82,6 +82,7 @@ type Config struct { PrometheusPort uint32 `yaml:"prometheus_port,omitempty"` Prometheus PrometheusConfig `yaml:"prometheus,omitempty"` DebugHandler DebugHandlerConfig `yaml:"debug_handler,omitempty"` + WebTransport WebTransportConfig `yaml:"webtransport,omitempty"` RTC RTCConfig `yaml:"rtc,omitempty"` Redis redisLiveKit.RedisConfig `yaml:"redis,omitempty"` Audio sfu.AudioConfig `yaml:"audio,omitempty"` @@ -117,6 +118,15 @@ type Config struct { API APIConfig `yaml:"api,omitempty"` } +// WebTransportConfig is the node's HTTP/3 listener. QUIC has no plaintext mode, +// so a certificate is required; development mode generates a self-signed one. +type WebTransportConfig struct { + // Port is the UDP port to listen on. 0 starts no listener. + Port uint32 `yaml:"port,omitempty"` + TLSCertFile string `yaml:"tls_cert_file,omitempty"` + TLSKeyFile string `yaml:"tls_key_file,omitempty"` +} + type RTCConfig struct { rtcconfig.RTCConfig `yaml:",inline"` @@ -222,6 +232,22 @@ type CongestionControlConfig struct { SendSideBWE sendsidebwe.SendSideBWEConfig `yaml:"send_side_bwe,omitempty"` } +// Validate reports configuration that would otherwise be ignored at runtime. +func (c CongestionControlConfig) Validate() error { + switch pacer.PacerBehavior(c.SendSideBWEPacer) { + case "", pacer.PacerBehaviorPassThrough, pacer.PacerBehaviorNoQueue, pacer.PacerBehaviorLeakybucket: + return nil + default: + return fmt.Errorf( + "invalid send_side_bwe_pacer %q, must be one of %q, %q, %q", + c.SendSideBWEPacer, + pacer.PacerBehaviorPassThrough, + pacer.PacerBehaviorNoQueue, + pacer.PacerBehaviorLeakybucket, + ) + } +} + // FlexFECConfig controls FlexFEC-03 recovery on the publisher -> SFU leg. type FlexFECConfig struct { // negotiate flexfec-03 with publishers and use it to recover lost upstream packets @@ -287,6 +313,19 @@ type TURNConfig struct { RelayPortRangeEnd uint16 `yaml:"relay_range_end,omitempty"` ExternalTLS bool `yaml:"external_tls,omitempty"` BindAddresses []string `yaml:"bind_addresses,omitempty"` + // ProxyProtocol makes the TURN TCP listener require a PROXY protocol (v1 or v2) + // header on every connection and use the client address it carries. Needed when + // a proxy in front of TURN dials from its own address (a TLS-terminating L4 + // proxy, a TCP reverse proxy): TURN echoes the address it sees back to the + // client as XOR-MAPPED-ADDRESS, and browsers such as Firefox reject a loopback + // or wildcard address there and abandon the allocation. Connections without + // the header are rejected. + ProxyProtocol bool `yaml:"proxy_protocol,omitempty"` + // ProxyProtocolTrustedCIDRs lists the proxies whose PROXY header is believed. + // Connections from any other address are closed, so a client that reaches + // tls_port directly cannot claim an arbitrary source address. Defaults to + // loopback only, for a proxy on the same host. + ProxyProtocolTrustedCIDRs []string `yaml:"proxy_protocol_trusted_cidrs,omitempty"` // PerUserRelayAllocationLimit caps the number of concurrent relay allocations // a single participant credential may hold, keyed by the participant ID. This // stops one authenticated participant from consuming the shared relay-port @@ -394,8 +433,20 @@ 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 +// DataBlobKeyLength returns the length of the identifying content of a data blob key. +// The text form of the key message is not used, as it adds field names and quotes. +func DataBlobKeyLength(key *livekit.DataBlobKey) int { + switch k := key.GetKey().(type) { + case *livekit.DataBlobKey_Generic: + return len(k.Generic) + case *livekit.DataBlobKey_SchemaId: + return len(k.SchemaId.GetName()) + len(k.SchemaId.GetEncoding().GetCustom()) + } + return 0 +} + +func (l LimitConfig) CheckDataBlobKeyLength(key *livekit.DataBlobKey) bool { + return l.MaxDataBlobKeyLength == 0 || DataBlobKeyLength(key) <= l.MaxDataBlobKeyLength } func (l LimitConfig) CheckDataTrackCustomEncodingLength(identifier string) bool { @@ -425,7 +476,7 @@ func (l LimitConfig) CheckDataBlobsSize(dataBlobs []*livekit.DataBlob) bool { total := 0 for _, dataBlob := range dataBlobs { - total += len(dataBlob.GetKey().String()) + len(dataBlob.Contents) + total += DataBlobKeyLength(dataBlob.GetKey()) + len(dataBlob.Contents) } return uint32(total) <= l.MaxDataBlobSize } @@ -437,9 +488,9 @@ func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *liveki total := 0 for _, dataBlob := range dataBlobs { - total += len(dataBlob.Key.String()) + len(dataBlob.Contents) + total += DataBlobKeyLength(dataBlob.GetKey()) + len(dataBlob.Contents) } - return uint32(total+len(toAdd.GetKey().String())+len(toAdd.Contents)) <= l.MaxDataBlobSize + return uint32(total+DataBlobKeyLength(toAdd.GetKey())+len(toAdd.Contents)) <= l.MaxDataBlobSize } // --------------------------------- @@ -447,6 +498,19 @@ func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *liveki type IngressConfig struct { RTMPBaseURL string `yaml:"rtmp_base_url,omitempty"` WHIPBaseURL string `yaml:"whip_base_url,omitempty"` + // Allow URL pull ingresses with a udp:// source URL. Disabled by default, and should only be + // enabled if both the callers allowed to create ingresses and the network the ingress handlers + // run on are trusted. Unlike an http or srt source url, a udp source url doesn't make the ingress + // handler connect out to the url host: the handler binds a local socket on the address and port + // taken from the url, and joins the multicast group if one is given. This lets the caller: + // - Choose which local port the handler binds, potentially colliding with other services on + // the host. + // - Feed the session unauthenticated traffic. UDP is connectionless, so any host able to reach + // that port can inject media, or spoof the sender address to disrupt a legitimate feed. + // - Make the handler join arbitrary multicast groups and republish whatever it receives into a + // room, using the ingress as a relay for streams on the handler's local network the caller has + // no direct access to. + EnableUDPURLPull bool `yaml:"enable_udp_url_pull,omitempty"` } type SIPConfig struct{} @@ -580,6 +644,7 @@ var DefaultConfig = Config{ TURN: TURNConfig{ Enabled: false, BindAddresses: []string{"0.0.0.0"}, + ProxyProtocolTrustedCIDRs: []string{"127.0.0.0/8", "::1/128"}, TTLSeconds: DefaultTURNTTLSeconds, PerUserRelayAllocationLimit: DefaultTURNPerUserRelayAllocationLimit, }, @@ -640,6 +705,10 @@ func NewConfig(confString string, strictMode bool, c *cli.Command, baseFlags []c return nil, fmt.Errorf("could not validate RTC config: %v", err) } + if err := conf.RTC.CongestionControl.Validate(); err != nil { + return nil, fmt.Errorf("could not validate congestion control config: %v", err) + } + conf.NormalizeTURNTTLs() // expand env vars in filenames @@ -883,10 +952,11 @@ func GenerateCLIFlags(existingFlags []cli.Flag, hidden bool) ([]cli.Flag, error) defaultConfig := &DefaultConfig flags := make([]cli.Flag, 0) for name, value := range (defaultConfig).ToCLIFlagNames(existingFlags) { - kind := value.Kind() - if kind == reflect.Ptr { - kind = value.Type().Elem().Kind() + valueType := value.Type() + if valueType.Kind() == reflect.Ptr { + valueType = valueType.Elem() } + kind := valueType.Kind() var flag cli.Flag envVar := fmt.Sprintf("LIVEKIT_%s", strings.ToUpper(strings.ReplaceAll(name, ".", "_"))) @@ -918,12 +988,23 @@ func GenerateCLIFlags(existingFlags []cli.Flag, hidden bool) ([]cli.Flag, error) Hidden: hidden, } case reflect.Int64: - flag = &cli.Int64Flag{ - Name: name, - Sources: cli.EnvVars(envVar), - Usage: generatedCLIFlagUsage, - DefaultText: defaultText, - Hidden: hidden, + if valueType == reflect.TypeOf(time.Duration(0)) { + // parse values like "30s", as the YAML config does + flag = &cli.DurationFlag{ + Name: name, + Sources: cli.EnvVars(envVar), + Usage: generatedCLIFlagUsage, + DefaultText: defaultText, + Hidden: hidden, + } + } else { + flag = &cli.Int64Flag{ + Name: name, + Sources: cli.EnvVars(envVar), + Usage: generatedCLIFlagUsage, + DefaultText: defaultText, + Hidden: hidden, + } } case reflect.Uint8, reflect.Uint16, reflect.Uint32: flag = &cli.UintFlag{ diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3e9cfbf48..244bf84a7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -15,14 +15,17 @@ package config import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "github.com/livekit/livekit-server/pkg/config/configtest" + "github.com/livekit/livekit-server/pkg/sfu/pacer" ) func TestConfig_UnmarshalKeys(t *testing.T) { @@ -61,6 +64,27 @@ func TestConfig_SignalMessageSizeLimitOverride(t *testing.T) { require.Equal(t, int64(0), conf.Limit.AgentSignalMessageSizeLimit) } +func TestConfig_PSRPCCompressionDefaults(t *testing.T) { + conf, err := NewConfig("", true, nil, nil) + require.NoError(t, err) + require.Equal(t, 0, conf.PSRPC.Compression.Quality) + require.Equal(t, 1024, conf.PSRPC.Compression.Threshold) + require.Equal(t, 0, conf.PSRPC.Compression.MaxDecompressedSize) +} + +func TestConfig_PSRPCCompressionOverride(t *testing.T) { + const content = `psrpc: + compression: + quality: 6 + max_decompressed_size: 4096` + conf, err := NewConfig(content, true, nil, nil) + require.NoError(t, err) + require.Equal(t, 6, conf.PSRPC.Compression.Quality) + require.Equal(t, 4096, conf.PSRPC.Compression.MaxDecompressedSize) + require.Equal(t, 1024, conf.PSRPC.Compression.Threshold) + require.Equal(t, 3, conf.PSRPC.MaxAttempts) +} + func TestConfig_UnknownKeys(t *testing.T) { const content = `unknown: 10 room: @@ -101,6 +125,47 @@ func TestGeneratedFlags(t *testing.T) { require.False(t, *conf.RTC.ReconnectOnSubscriptionError) } +func TestGeneratedDurationFlags(t *testing.T) { + // run a command with the generated flags, as cmd/server does, so that flag and env var values are parsed + run := func(t *testing.T, args ...string) (*Config, error) { + generatedFlags, err := GenerateCLIFlags(nil, true) + require.NoError(t, err) + + var conf *Config + c := &cli.Command{ + Name: "test", + Flags: generatedFlags, + Action: func(_ context.Context, c *cli.Command) error { + var err error + conf, err = NewConfig("", true, c, nil) + return err + }, + } + err = c.Run(context.Background(), append([]string{"test"}, args...)) + return conf, err + } + + t.Run("flag", func(t *testing.T) { + conf, err := run(t, "--api.execution_timeout=5s") + require.NoError(t, err) + require.Equal(t, 5*time.Second, conf.API.ExecutionTimeout) + }) + + t.Run("env var", func(t *testing.T) { + t.Setenv("LIVEKIT_ROOM_CREATE_ROOM_TIMEOUT", "30s") + conf, err := run(t) + require.NoError(t, err) + require.Equal(t, 30*time.Second, conf.Room.CreateRoomTimeout) + }) + + t.Run("value without a unit", func(t *testing.T) { + // rejected, as in the YAML config, instead of being read as nanoseconds + t.Setenv("LIVEKIT_ROOM_CREATE_ROOM_TIMEOUT", "30") + _, err := run(t) + require.ErrorContains(t, err, "LIVEKIT_ROOM_CREATE_ROOM_TIMEOUT") + }) +} + func TestYAMLTag(t *testing.T) { require.NoError(t, configtest.CheckYAMLTags(Config{})) } @@ -161,6 +226,40 @@ func TestNewConfigNormalizesTURNTTL(t *testing.T) { require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds) } +func TestNewConfigValidatesSendSideBWEPacer(t *testing.T) { + cases := []struct { + name string + pacer string + wantErr bool + }{ + {name: "unset keeps the default", pacer: "", wantErr: false}, + {name: "pass-through", pacer: string(pacer.PacerBehaviorPassThrough), wantErr: false}, + {name: "no-queue", pacer: string(pacer.PacerBehaviorNoQueue), wantErr: false}, + {name: "leaky-bucket", pacer: string(pacer.PacerBehaviorLeakybucket), wantErr: false}, + {name: "typo is rejected", pacer: "leakybucket", wantErr: true}, + {name: "unknown value is rejected", pacer: "not-a-pacer", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + content := "rtc:\n congestion_control:\n send_side_bwe_pacer: " + tc.pacer + conf, err := NewConfig(content, true, nil, nil) + if tc.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tc.pacer) + return + } + + require.NoError(t, err) + if tc.pacer == "" { + require.Equal(t, string(pacer.PacerBehaviorNoQueue), conf.RTC.CongestionControl.SendSideBWEPacer) + return + } + require.Equal(t, tc.pacer, conf.RTC.CongestionControl.SendSideBWEPacer) + }) + } +} + func writeSecretFile(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "secret") diff --git a/pkg/routing/node.go b/pkg/routing/node.go index df47226e9..dceddb92b 100644 --- a/pkg/routing/node.go +++ b/pkg/routing/node.go @@ -19,6 +19,8 @@ import ( "sync" "time" + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" @@ -76,14 +78,14 @@ func NewLocalNode(conf *config.Config) (*LocalNodeImpl, error) { } func NewLocalNodeFromNodeProto(node *livekit.Node) (*LocalNodeImpl, error) { - return &LocalNodeImpl{node: utils.CloneProto(node)}, nil + return &LocalNodeImpl{node: proto.CloneOf(node)}, nil } func (l *LocalNodeImpl) Clone() *livekit.Node { l.lock.RLock() defer l.lock.RUnlock() - return utils.CloneProto(l.node) + return proto.CloneOf(l.node) } // for testing only @@ -134,7 +136,7 @@ func (l *LocalNodeImpl) SetStats(stats *livekit.NodeStats) { l.lock.Lock() defer l.lock.Unlock() - l.node.Stats = utils.CloneProto(stats) + l.node.Stats = proto.CloneOf(stats) } func (l *LocalNodeImpl) UpdateNodeStats() bool { diff --git a/pkg/routing/nodestats.go b/pkg/routing/nodestats.go index ac16bb0ef..1a49a01af 100644 --- a/pkg/routing/nodestats.go +++ b/pkg/routing/nodestats.go @@ -18,9 +18,10 @@ import ( "sync" "time" + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" @@ -87,5 +88,5 @@ func (n *NodeStats) GetLatestNodeStats() *livekit.NodeStats { defer n.lock.RUnlock() latestPtr := (n.statsHistoryWritePtr - 1 + len(n.statsHistory)) % len(n.statsHistory) - return utils.CloneProto(n.statsHistory[latestPtr]) + return proto.CloneOf(n.statsHistory[latestPtr]) } diff --git a/pkg/routing/selector/cpuload.go b/pkg/routing/selector/cpuload.go index f84b4a3ce..3c31236f1 100644 --- a/pkg/routing/selector/cpuload.go +++ b/pkg/routing/selector/cpuload.go @@ -27,21 +27,13 @@ type CPULoadSelector struct { } func (s *CPULoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) { - nodes = GetAvailableNodes(nodes) - if len(nodes) == 0 { - return nil, ErrNoAvailableNodes + nodes, err := FilterNodesByCriteria(nodes, s.CPULoadLimit, func(node *livekit.Node) float32 { + return node.Stats.CpuLoad + }) + if err != nil { + return nil, err } - nodesLowLoad := make([]*livekit.Node, 0) - for _, node := range nodes { - stats := node.Stats - if stats.CpuLoad < s.CPULoadLimit { - nodesLowLoad = append(nodesLowLoad, node) - } - } - if len(nodesLowLoad) > 0 { - nodes = nodesLowLoad - } return nodes, nil } diff --git a/pkg/routing/selector/nodesfilter.go b/pkg/routing/selector/nodesfilter.go new file mode 100644 index 000000000..3dd49d3b5 --- /dev/null +++ b/pkg/routing/selector/nodesfilter.go @@ -0,0 +1,21 @@ +package selector + +import "github.com/livekit/protocol/livekit" + +func FilterNodesByCriteria(nodes []*livekit.Node, criteriaThreshold float32, calculateCriteriaFunc func(*livekit.Node) float32) ([]*livekit.Node, error) { + nodes = GetAvailableNodes(nodes) + if len(nodes) == 0 { + return nil, ErrNoAvailableNodes + } + + filteredNodes := make([]*livekit.Node, 0) + for _, node := range nodes { + if calculateCriteriaFunc(node) < criteriaThreshold { + filteredNodes = append(filteredNodes, node) + } + } + if len(filteredNodes) > 0 { + nodes = filteredNodes + } + return nodes, nil +} diff --git a/pkg/routing/selector/sysload.go b/pkg/routing/selector/sysload.go index 285fca493..88af4a19c 100644 --- a/pkg/routing/selector/sysload.go +++ b/pkg/routing/selector/sysload.go @@ -27,20 +27,11 @@ type SystemLoadSelector struct { } func (s *SystemLoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) { - nodes = GetAvailableNodes(nodes) - if len(nodes) == 0 { - return nil, ErrNoAvailableNodes + nodes, err := FilterNodesByCriteria(nodes, s.SysloadLimit, GetNodeSysload) + if err != nil { + return nil, err } - nodesLowLoad := make([]*livekit.Node, 0) - for _, node := range nodes { - if GetNodeSysload(node) < s.SysloadLimit { - nodesLowLoad = append(nodesLowLoad, node) - } - } - if len(nodesLowLoad) > 0 { - nodes = nodesLowLoad - } return nodes, nil } diff --git a/pkg/rtc/datadowntrack.go b/pkg/rtc/datadowntrack.go index 45f5e19a8..a1b0fdc35 100644 --- a/pkg/rtc/datadowntrack.go +++ b/pkg/rtc/datadowntrack.go @@ -19,8 +19,8 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) diff --git a/pkg/rtc/datatrack.go b/pkg/rtc/datatrack.go index 2f3629b47..e5fd98a7c 100644 --- a/pkg/rtc/datatrack.go +++ b/pkg/rtc/datatrack.go @@ -20,13 +20,13 @@ import ( "sync" "github.com/frostbyte73/core" + "google.golang.org/protobuf/proto" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" sfuutils "github.com/livekit/livekit-server/pkg/sfu/utils" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" ) var ( @@ -98,7 +98,7 @@ func (d *DataTrack) PublisherIdentity() livekit.ParticipantIdentity { } func (d *DataTrack) ToProto() *livekit.DataTrackInfo { - return utils.CloneProto(d.dti) + return proto.CloneOf(d.dti) } func (d *DataTrack) PubHandle() uint16 { diff --git a/pkg/rtc/datatrack/extension_participant_sid.go b/pkg/rtc/datatrack/extension_participant_sid.go deleted file mode 100644 index 35ada18f1..000000000 --- a/pkg/rtc/datatrack/extension_participant_sid.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "errors" - - "github.com/livekit/protocol/livekit" -) - -type ExtensionParticipantSid struct { - participantID livekit.ParticipantID -} - -func NewExtensionParticipantSid(participantID livekit.ParticipantID) (*ExtensionParticipantSid, error) { - if len(participantID) >= 256 { - return nil, errors.New("participantID too long") - } - - return &ExtensionParticipantSid{participantID}, nil -} - -func (e *ExtensionParticipantSid) ParticipantID() livekit.ParticipantID { - return e.participantID -} - -func (e *ExtensionParticipantSid) Marshal() (Extension, error) { - data := make([]byte, len(e.participantID)) - copy(data, e.participantID) - return Extension{ - id: uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID), - data: data, - }, nil -} - -func (e *ExtensionParticipantSid) Unmarshal(ext Extension) error { - if ext.id != uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID) { - return errors.New("invalid extension ID") - } - - if len(ext.data) == 0 { - return errors.New("empty extension data") - } - - e.participantID = livekit.ParticipantID(ext.data) - return nil -} diff --git a/pkg/rtc/datatrack/extension_participant_sid_test.go b/pkg/rtc/datatrack/extension_participant_sid_test.go deleted file mode 100644 index 7128e8bdc..000000000 --- a/pkg/rtc/datatrack/extension_participant_sid_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "testing" - - "github.com/livekit/protocol/livekit" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExtensionParticipantSid(t *testing.T) { - longTestParticipantID := livekit.ParticipantID(make([]byte, 256)) - _, err := NewExtensionParticipantSid(longTestParticipantID) - require.Error(t, err) - - testParticipantID := livekit.ParticipantID("test") - extParticipantSid, err := NewExtensionParticipantSid(testParticipantID) - require.NoError(t, err) - - expectedExt := Extension{ - id: uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID), - data: []byte{'t', 'e', 's', 't'}, - } - ext, err := extParticipantSid.Marshal() - require.NoError(t, err) - require.Equal(t, expectedExt, ext) - - var unmarshaled ExtensionParticipantSid - err = unmarshaled.Unmarshal(ext) - require.NoError(t, err) - assert.Equal(t, testParticipantID, unmarshaled.ParticipantID()) -} diff --git a/pkg/rtc/datatrack/packet.go b/pkg/rtc/datatrack/packet.go deleted file mode 100644 index 42fe1eef2..000000000 --- a/pkg/rtc/datatrack/packet.go +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "encoding/binary" - "errors" - "fmt" -) - -var ( - errHeaderSizeInsufficient = errors.New("data track packet header size insufficient") - errBufferSizeInsufficient = errors.New("data track packet buffer size insufficient") - errExtensionSizeInsufficient = errors.New("data track packet extension size insufficient") - errExtensionNotFound = errors.New("data track packet extension not found") - errExtensionSizeTooBig = errors.New("extension size is too big") -) - -const ( - headerLength = 12 - - versionShift = 5 - versionMask = (1 << 3) - 1 - - startOfFrameShift = 4 - startOfFrameMask = (1 << 1) - 1 - - finalOfFrameShift = 3 - finalOfFrameMask = (1 << 1) - 1 - - extensionsShift = 2 - extensionsMask = (1 << 1) - 1 - - handleOffset = 2 - handleLength = 2 - - seqNumOffset = 4 - seqNumLength = 2 - - frameNumOffset = 6 - frameNumLength = 2 - - timestampOffset = 8 - timestampLength = 4 - - extensionsSizeOffset = headerLength - extensionsSizeLength = 2 - - extensionIDLength = 1 - extensionSizeLength = 1 -) - -type Extension struct { - id uint8 - data []byte -} - -type Header struct { - Version uint8 - IsStartOfFrame bool - IsFinalOfFrame bool - HasExtensions bool - Handle uint16 - SequenceNumber uint16 - FrameNumber uint16 - Timestamp uint32 - ExtensionsSize uint16 - Extensions []Extension -} - -/* - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* 0 1 2 3 - ┆* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* |V |S|F|X| reserved | handle | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* | sequence number | frame number | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* | timestamp | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |* Extensions Size if X=1 | Extensions... | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - - Each extension - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* 0 1 2 3 - ┆* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* | Extension ID | Extension size| Extension payload | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - - End of all extensions - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |* padded to 4 byte boundary if aggregate of `Extensions Size` | - |* field and all extensions do not end on a 4 byte boundary | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -*/ - -func (h *Header) Unmarshal(buf []byte) (int, error) { - if len(buf) < headerLength { - return 0, fmt.Errorf("%w: %d < %d", errHeaderSizeInsufficient, len(buf), headerLength) - } - - hdrSize := headerLength - h.Version = buf[0] >> versionShift & versionMask - h.IsStartOfFrame = (buf[0] >> startOfFrameShift & startOfFrameMask) > 0 - h.IsFinalOfFrame = (buf[0] >> finalOfFrameShift & finalOfFrameMask) > 0 - h.HasExtensions = (buf[0] >> extensionsShift & extensionsMask) > 0 - - h.Handle = binary.BigEndian.Uint16(buf[handleOffset : handleOffset+handleLength]) - h.SequenceNumber = binary.BigEndian.Uint16(buf[seqNumOffset : seqNumOffset+seqNumLength]) - h.FrameNumber = binary.BigEndian.Uint16(buf[frameNumOffset : frameNumOffset+frameNumLength]) - h.Timestamp = binary.BigEndian.Uint32(buf[timestampOffset : timestampOffset+timestampLength]) - - if h.HasExtensions { - if len(buf) < extensionsSizeOffset+extensionsSizeLength { - return 0, fmt.Errorf("%w: %d < %d", errHeaderSizeInsufficient, len(buf), extensionsSizeOffset+extensionsSizeLength) - } - extensionsSize := (int(binary.BigEndian.Uint16(buf[extensionsSizeOffset:extensionsSizeOffset+extensionsSizeLength]))+1)*4 - extensionsSizeLength - hdrSize += extensionsSizeLength - - extensionHeaderSize := extensionIDLength + extensionSizeLength - remainingSize := extensionsSize - idx := extensionsSizeOffset + extensionsSizeLength - for remainingSize != 0 { - // read extension header - if len(buf[idx:]) < extensionIDLength || remainingSize < extensionIDLength { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), extensionIDLength) - } - id := buf[idx] - if id == 0 { - // end of extensions, padding has started - if len(buf[idx:]) < remainingSize { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), remainingSize) - } - hdrSize += remainingSize - break - } - - if len(buf[idx+1:]) < extensionSizeLength || remainingSize < extensionSizeLength { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), extensionSizeLength) - } - size := int(buf[idx+1]) - - remainingSize -= extensionHeaderSize - idx += extensionHeaderSize - hdrSize += extensionHeaderSize - - // read extension data - if len(buf[idx:]) < size || remainingSize < size { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), size) - } - h.Extensions = append(h.Extensions, Extension{id: id, data: buf[idx : idx+size]}) - - remainingSize -= size - idx += size - hdrSize += size - } - h.ExtensionsSize = uint16(extensionsSize - remainingSize) - } - - return hdrSize, nil -} - -func (h *Header) MarshalSize() int { - extensionsSize := 0 - if h.HasExtensions { - extensionsSize += extensionsSizeLength - for _, ext := range h.Extensions { - extensionsSize += len(ext.data) + extensionIDLength + extensionSizeLength - } - } - - return headerLength + (extensionsSize+3)/4*4 -} - -func (h *Header) MarshalTo(buf []byte) (int, error) { - if len(buf) < headerLength { - return 0, fmt.Errorf("%w: %d < %d", errHeaderSizeInsufficient, len(buf), headerLength) - } - - hdrSize := headerLength - buf[0] = h.Version << versionShift - if h.IsStartOfFrame { - buf[0] |= (1 << startOfFrameShift) - } - if h.IsFinalOfFrame { - buf[0] |= (1 << finalOfFrameShift) - } - if h.HasExtensions { - buf[0] |= (1 << extensionsShift) - } - - binary.BigEndian.PutUint16(buf[handleOffset:handleOffset+handleLength], h.Handle) - binary.BigEndian.PutUint16(buf[seqNumOffset:seqNumOffset+seqNumLength], h.SequenceNumber) - binary.BigEndian.PutUint16(buf[frameNumOffset:frameNumOffset+frameNumLength], h.FrameNumber) - binary.BigEndian.PutUint32(buf[timestampOffset:timestampOffset+timestampLength], h.Timestamp) - - if h.HasExtensions { - extensionsSize := (extensionsSizeLength + h.ExtensionsSize + 3) / 4 * 4 - binary.BigEndian.PutUint16(buf[extensionsSizeOffset:extensionsSizeOffset+extensionsSizeLength], (extensionsSize/4)-1) - hdrSize += extensionsSizeLength - - addedSize := 0 - idx := extensionsSizeOffset + extensionsSizeLength - for _, ext := range h.Extensions { - buf[idx] = ext.id - if len(ext.data) > 255 { - return 0, fmt.Errorf("%w: %d > 255", errExtensionSizeTooBig, len(ext.data)) - } - buf[idx+extensionIDLength] = byte(len(ext.data)) - copy(buf[idx+extensionIDLength+extensionSizeLength:], ext.data) - - extSize := len(ext.data) + extensionIDLength + extensionSizeLength - idx += extSize - hdrSize += extSize - addedSize += extSize - } - - paddingSize := extensionsSize - extensionsSizeLength - uint16(addedSize) - for i := range paddingSize { - buf[idx+int(i)] = 0 - } - idx += int(paddingSize) - hdrSize += int(paddingSize) - } - - return hdrSize, nil -} - -func (h *Header) AddExtension(ext Extension) { - for i, existingExt := range h.Extensions { - if existingExt.id == ext.id { - h.ExtensionsSize -= uint16(len(existingExt.data) + extensionIDLength + extensionSizeLength) - h.Extensions[i].data = ext.data - h.ExtensionsSize += uint16(len(h.Extensions[i].data) + extensionIDLength + extensionSizeLength) - return - } - } - - h.Extensions = append(h.Extensions, ext) - h.ExtensionsSize += uint16(len(ext.data) + extensionIDLength + extensionSizeLength) - h.HasExtensions = true -} - -func (h *Header) GetExtension(id uint8) (Extension, error) { - for _, ext := range h.Extensions { - if ext.id == id { - return ext, nil - } - } - return Extension{}, fmt.Errorf("%w, id: %d", errExtensionNotFound, id) -} - -// ---------------------------------------------------- - -type Packet struct { - Header - Payload []byte -} - -func (p *Packet) Unmarshal(buf []byte) error { - hdrSize, err := p.Header.Unmarshal(buf) - if err != nil { - return err - } - if hdrSize > len(buf) { - return fmt.Errorf("%w: %d < %d", errBufferSizeInsufficient, len(buf), hdrSize) - } - - p.Payload = buf[hdrSize:] - return nil -} - -func (p *Packet) Marshal() ([]byte, error) { - buf := make([]byte, p.Header.MarshalSize()+len(p.Payload)) - if err := p.MarshalTo(buf); err != nil { - return nil, err - } - - return buf, nil -} - -func (p *Packet) MarshalTo(buf []byte) error { - size := p.Header.MarshalSize() + len(p.Payload) - if len(buf) < size { - return fmt.Errorf("%w: %d < %d", errBufferSizeInsufficient, len(buf), size) - } - - hdrSize, err := p.Header.MarshalTo(buf) - if err != nil { - return err - } - - copy(buf[hdrSize:], p.Payload) - return nil -} diff --git a/pkg/rtc/datatrack/packet_test.go b/pkg/rtc/datatrack/packet_test.go deleted file mode 100644 index 8f1bc672b..000000000 --- a/pkg/rtc/datatrack/packet_test.go +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "testing" - - "github.com/livekit/protocol/livekit" - "github.com/stretchr/testify/require" -) - -func TestPacket(t *testing.T) { - t.Run("without extension", func(t *testing.T) { - payload := make([]byte, 6) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: true, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x18, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0xff, 0xfe, 0xfd, 0xfc, - 0xfb, 0xfa, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - }) - - t.Run("with extension", func(t *testing.T) { - payload := make([]byte, 4) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: false, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("test_participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x04, 0x01, 0x10, - 0x74, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x72, - 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, - 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - - ext, err := unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.NoError(t, err) - - var extParticipantSid ExtensionParticipantSid - require.NoError(t, extParticipantSid.Unmarshal(ext)) - require.Equal(t, livekit.ParticipantID("test_participant"), extParticipantSid.ParticipantID()) - }) - - t.Run("with extension padding", func(t *testing.T) { - payload := make([]byte, 4) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: false, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - - ext, err := unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.NoError(t, err) - - var extParticipantSid ExtensionParticipantSid - require.NoError(t, extParticipantSid.Unmarshal(ext)) - require.Equal(t, livekit.ParticipantID("participant"), extParticipantSid.ParticipantID()) - }) - - t.Run("replace extension", func(t *testing.T) { - payload := make([]byte, 4) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: false, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - // replace existing extension ID and ensure that marshalled packet is updated - if extParticipantSid, err := NewExtensionParticipantSid("test_participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err = packet.Marshal() - require.NoError(t, err) - - expectedRawPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x04, 0x01, 0x10, - 0x74, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x72, - 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, - 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - - ext, err := unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.NoError(t, err) - - var extParticipantSid ExtensionParticipantSid - require.NoError(t, extParticipantSid.Unmarshal(ext)) - require.Equal(t, livekit.ParticipantID("test_participant"), extParticipantSid.ParticipantID()) - }) - - t.Run("bad packet", func(t *testing.T) { - var unmarshaled Packet - // extensions size too small - badPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x02, 0x01, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - - // get an invalid extension id - badPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x02, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err = unmarshaled.Unmarshal(badPacket) - require.NoError(t, err) - _, err = unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.Error(t, err) - - // extension payload size bigger than payload - badPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x0d, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err = unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - - // extension payload size smaller than payload - badPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x07, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err = unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) - - t.Run("oversized extension padding does not panic", func(t *testing.T) { - var unmarshaled Packet - // HasExtensions set, extensionsSize describes more bytes than present, - // terminated by a 0x00 padding id -> hdrSize would exceed len(buf) - badPacket := []byte{ - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) - - t.Run("extensions size wraparound does not panic", func(t *testing.T) { - var unmarshaled Packet - // 0xFFFF extensions-size field wraps (raw+1)*4 uint16 arithmetic to a huge - // remainingSize; the 0x00 padding id must not push hdrSize past len(buf) - badPacket := []byte{ - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) - - t.Run("truncated extensions size field does not panic", func(t *testing.T) { - var unmarshaled Packet - // HasExtensions set but buffer too short to hold the extensionsSize field - badPacket := []byte{ - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) -} diff --git a/pkg/rtc/datatrack/testutils.go b/pkg/rtc/datatrack/testutils.go deleted file mode 100644 index e7df0bbd9..000000000 --- a/pkg/rtc/datatrack/testutils.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "math/rand" - "time" -) - -func GenerateRawDataPackets(handle uint16, seqNum uint16, frameNum uint16, numFrames int, frameSize int, frameDuration time.Duration) [][]byte { - if seqNum == 0 { - seqNum = uint16(rand.Intn(256) + 1) - } - if frameNum == 0 { - frameNum = uint16(rand.Intn(256) + 1) - } - timestamp := uint32(rand.Intn(1024)) - - packetsPerFrame := (frameSize + 255) / 256 // using 256 bytes of payload per packet - if packetsPerFrame == 0 { - return nil - } - numPackets := packetsPerFrame * numFrames - rawPackets := make([][]byte, 0, numPackets) - for range numFrames { - remainingSize := frameSize - for packetIdx := range packetsPerFrame { - payloadSize := min(remainingSize, 256) - payload := make([]byte, payloadSize) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: packetIdx == 0, - IsFinalOfFrame: packetIdx == packetsPerFrame-1, - Handle: handle, - SequenceNumber: seqNum, - FrameNumber: frameNum, - Timestamp: timestamp, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("test_participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - if err == nil { - rawPackets = append(rawPackets, rawPacket) - } - seqNum++ - remainingSize -= payloadSize - } - frameNum++ - timestamp += uint32(90000 * frameDuration.Seconds()) - } - - return rawPackets -} diff --git a/pkg/rtc/datatrack_stats.go b/pkg/rtc/datatrack_stats.go index 5bde6f57f..37ae15640 100644 --- a/pkg/rtc/datatrack_stats.go +++ b/pkg/rtc/datatrack_stats.go @@ -18,7 +18,7 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils/mono" ) diff --git a/pkg/rtc/dynacast/dynacastmanager_test.go b/pkg/rtc/dynacast/dynacastmanager_test.go index 6d48e67ea..43d939546 100644 --- a/pkg/rtc/dynacast/dynacastmanager_test.go +++ b/pkg/rtc/dynacast/dynacastmanager_test.go @@ -498,6 +498,53 @@ func TestCodecRegression(t *testing.T) { }) } +func TestResendCommittedQuality(t *testing.T) { + var lock sync.Mutex + var notified []string + dm := NewDynacastManagerVideo(DynacastManagerVideoParams{ + // keeps the downgrade below pending for the whole test + DynacastPauseDelay: time.Minute, + Listener: &testDynacastManagerListener{ + onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) { + lock.Lock() + notified = append(notified, subscribedCodecsAsString(subscribedQualities)) + lock.Unlock() + }, + }, + }) + defer dm.Close() + notifications := func() []string { + lock.Lock() + defer lock.Unlock() + return slices.Clone(notified) + } + + // nothing committed yet, nothing to resend + dm.ResendCommittedQuality() + time.Sleep(100 * time.Millisecond) + require.Empty(t, notifications()) + + dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_LOW) + require.Eventually(t, func() bool { return len(notifications()) == 1 }, 5*time.Second, 10*time.Millisecond) + + // the downgrade waits for the debounce, LOW is still the committed quality + dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_OFF) + time.Sleep(100 * time.Millisecond) + require.Len(t, notifications(), 1) + + // the resend notifies LOW again, without committing the pending OFF + dm.ResendCommittedQuality() + require.Eventually(t, func() bool { return len(notifications()) == 2 }, 5*time.Second, 10*time.Millisecond) + require.Equal(t, notifications()[0], notifications()[1]) + + // no layer paused, nothing to resend + dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_HIGH) + require.Eventually(t, func() bool { return len(notifications()) == 3 }, 5*time.Second, 10*time.Millisecond) + dm.ResendCommittedQuality() + time.Sleep(100 * time.Millisecond) + require.Len(t, notifications(), 3) +} + func subscribedCodecsAsString(c1 []*livekit.SubscribedCodec) string { slices.SortFunc(c1, func(a, b *livekit.SubscribedCodec) int { return strings.Compare(a.Codec, b.Codec) diff --git a/pkg/rtc/dynacast/dynacastmanagervideo.go b/pkg/rtc/dynacast/dynacastmanagervideo.go index 8178b4627..86c04e818 100644 --- a/pkg/rtc/dynacast/dynacastmanagervideo.go +++ b/pkg/rtc/dynacast/dynacastmanagervideo.go @@ -104,6 +104,21 @@ func (d *dynacastManagerVideo) ForceQuality(quality livekit.VideoQuality) { d.enqueueSubscribedQualityChange() } +// ResendCommittedQuality notifies the listener again of the last committed subscribed qualities, +// for a publisher that may have lost them. Nothing new is committed: a debounced downgrade stays pending. +// Only needed if some layer is paused, i.e. some committed quality is not HIGH. +func (d *dynacastManagerVideo) ResendCommittedQuality() { + d.lock.Lock() + defer d.lock.Unlock() + + for _, quality := range d.committedMaxSubscribedQuality { + if quality != livekit.VideoQuality_HIGH { + d.enqueueSubscribedQualityChange() + return + } + } +} + func (d *dynacastManagerVideo) NotifySubscriberMaxQuality( subscriberID livekit.ParticipantID, mime mime.MimeType, diff --git a/pkg/rtc/dynacast/interfaces.go b/pkg/rtc/dynacast/interfaces.go index c544ff54a..a64cfc962 100644 --- a/pkg/rtc/dynacast/interfaces.go +++ b/pkg/rtc/dynacast/interfaces.go @@ -53,6 +53,7 @@ type DynacastManager interface { Restart() Close() ForceUpdate() + ResendCommittedQuality() ForceQuality(quality livekit.VideoQuality) ForceEnable(enabled bool) @@ -88,6 +89,7 @@ func (d *dynacastManagerNull) HandleCodecRegression(fromMime, toMime mime.MimeTy func (d *dynacastManagerNull) Restart() {} func (d *dynacastManagerNull) Close() {} func (d *dynacastManagerNull) ForceUpdate() {} +func (d *dynacastManagerNull) ResendCommittedQuality() {} func (d *dynacastManagerNull) ForceQuality(quality livekit.VideoQuality) {} func (d *dynacastManagerNull) ForceEnable(enabled bool) {} func (d *dynacastManagerNull) NotifySubscriberMaxQuality( diff --git a/pkg/rtc/mediaengine.go b/pkg/rtc/mediaengine.go index 0b56305d1..45df171bd 100644 --- a/pkg/rtc/mediaengine.go +++ b/pkg/rtc/mediaengine.go @@ -171,6 +171,10 @@ func isCodecEnabledWithFmtp(codecs []*livekit.Codec, cap webrtc.RTPCodecCapabili func selectAlternativeVideoCodec(enabledCodecs []*livekit.Codec) string { for _, c := range enabledCodecs { + // RTX and FEC are not real codecs, skip them + if mime.IsMimeTypeStringRTX(c.Mime) || mime.IsMimeTypeStringFlexFEC03(c.Mime) { + continue + } if mime.IsMimeTypeStringVideo(c.Mime) { return c.Mime } diff --git a/pkg/rtc/mediaengine_test.go b/pkg/rtc/mediaengine_test.go index 0b7424394..b4648112b 100644 --- a/pkg/rtc/mediaengine_test.go +++ b/pkg/rtc/mediaengine_test.go @@ -73,6 +73,23 @@ func TestIsCodecEnabled(t *testing.T) { }) } +func TestSelectAlternativeVideoCodec(t *testing.T) { + // RTX and FEC have the video/ prefix but are not real codecs. The fallback must skip them. + t.Run("skips rtx and fec, picks real codec", func(t *testing.T) { + enabled := []*livekit.Codec{ + {Mime: mime.MimeTypeRTX.String()}, + {Mime: mime.MimeTypeFlexFEC03.String()}, + {Mime: mime.MimeTypeH264.String()}, + } + require.Equal(t, mime.MimeTypeH264.String(), selectAlternativeVideoCodec(enabled)) + }) + + t.Run("falls back to vp8 when only rtx is enabled", func(t *testing.T) { + enabled := []*livekit.Codec{{Mime: mime.MimeTypeRTX.String()}} + require.Equal(t, mime.MimeTypeVP8.String(), selectAlternativeVideoCodec(enabled)) + }) +} + type offeredCodec struct { payloadType webrtc.PayloadType name string diff --git a/pkg/rtc/mediatrack.go b/pkg/rtc/mediatrack.go index 5bc6cce9c..895216f41 100644 --- a/pkg/rtc/mediatrack.go +++ b/pkg/rtc/mediatrack.go @@ -96,6 +96,7 @@ type MediaTrackParams struct { EnableRTPStreamRestartDetection bool UpdateTrackInfoByVideoSizeChange bool ForceBackupCodecPolicySimulcast bool + MediaBatchIOEnabled bool OnSubscribedMaxQualityChange func( trackID livekit.TrackID, trackInfo *livekit.TrackInfo, @@ -273,6 +274,18 @@ func (t *MediaTrack) ToProto() *livekit.TrackInfo { return t.MediaTrackReceiver.TrackInfoClone() } +func (t *MediaTrack) getReceiverLBThreshold() int { + // With batch IO the send syscall moves to the BatchConn flush goroutine, so WriteRTP only + // translates the packet and enqueues it: ~3.3us per downtrack, against ~26us when the send runs + // inline. The last subscriber of 300 subscribers at a serial forwarder is enqueued ~1ms + // after the first. + if t.params.MediaBatchIOEnabled { + return 300 + } + + return 20 +} + // AddReceiver adds a new RTP receiver to the track, returns true when receiver represents a new codec // and if a receiver was added successfully func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRemote, mid string) (bool, bool) { @@ -402,7 +415,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe t.params.VideoConfig.StreamTrackerManager, sfu.WithPliThrottleConfig(t.params.PLIThrottleConfig), sfu.WithAudioConfig(t.params.AudioConfig), - sfu.WithLoadBalanceThreshold(20), + sfu.WithLoadBalanceThreshold(t.getReceiverLBThreshold()), sfu.WithForwardStats(t.params.ForwardStats), sfu.WithEnableRTPStreamRestartDetection(t.params.EnableRTPStreamRestartDetection), ) @@ -636,6 +649,13 @@ func (t *MediaTrack) SetMuted(muted bool) { t.MediaTrackReceiver.SetMuted(muted) } +// ResendSubscribedQuality sends the publisher the subscribed qualities of the track again. +func (t *MediaTrack) ResendSubscribedQuality() { + if t.dynacastManager != nil { + t.dynacastManager.ResendCommittedQuality() + } +} + // OnTrackSubscribed is called when the track is subscribed by a non-hidden subscriber // this allows the publisher to know when they should start sending data func (t *MediaTrack) OnTrackSubscribed() { diff --git a/pkg/rtc/mediatrackreceiver.go b/pkg/rtc/mediatrackreceiver.go index c6130e352..e372207f9 100644 --- a/pkg/rtc/mediatrackreceiver.go +++ b/pkg/rtc/mediatrackreceiver.go @@ -30,7 +30,6 @@ import ( "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu" @@ -154,7 +153,7 @@ func NewMediaTrackReceiver(params MediaTrackReceiverParams, ti *livekit.TrackInf params: params, state: mediaTrackReceiverStateOpen, } - t.trackInfo.Store(utils.CloneProto(ti)) + t.trackInfo.Store(proto.CloneOf(ti)) t.MediaTrackSubscriptions = NewMediaTrackSubscriptions(MediaTrackSubscriptionsParams{ MediaTrack: params.MediaTrack, @@ -812,7 +811,7 @@ func (t *MediaTrackReceiver) UpdateCodecInfo(codecs []*livekit.SimulcastCodec) { if len(c.Layers) != 0 { clonedLayers := make([]*livekit.VideoLayer, 0, len(c.Layers)) for _, l := range c.Layers { - clonedLayers = append(clonedLayers, utils.CloneProto(l)) + clonedLayers = append(clonedLayers, proto.CloneOf(l)) } origin.Layers = clonedLayers @@ -871,7 +870,7 @@ func (t *MediaTrackReceiver) UpdateCodecRids(mimeType mime.MimeType, rids buffer } func (t *MediaTrackReceiver) UpdateTrackInfo(ti *livekit.TrackInfo) { - clonedInfo := utils.CloneProto(ti) + clonedInfo := proto.CloneOf(ti) t.lock.Lock() trackInfo := t.TrackInfo() @@ -924,7 +923,7 @@ func (t *MediaTrackReceiver) UpdateAudioTrack(update *livekit.UpdateLocalAudioTr t.lock.Lock() trackInfo := t.TrackInfo() - clonedInfo := utils.CloneProto(trackInfo) + clonedInfo := proto.CloneOf(trackInfo) clonedInfo.AudioFeatures = sutils.DedupeSlice(update.Features) @@ -960,7 +959,7 @@ func (t *MediaTrackReceiver) UpdateVideoTrack(update *livekit.UpdateLocalVideoTr t.lock.Lock() trackInfo := t.TrackInfo() - clonedInfo := utils.CloneProto(trackInfo) + clonedInfo := proto.CloneOf(trackInfo) clonedInfo.Width = update.Width clonedInfo.Height = update.Height if proto.Equal(trackInfo, clonedInfo) { @@ -981,7 +980,7 @@ func (t *MediaTrackReceiver) UpdateVideoSize(mimeType mime.MimeType, sizes []cod var changed bool t.lock.Lock() trackInfo := t.TrackInfo() - clonedInfo := utils.CloneProto(trackInfo) + clonedInfo := proto.CloneOf(trackInfo) var maxWidth, maxHeight uint32 for _, size := range sizes { if size.Width > maxWidth { @@ -1028,7 +1027,7 @@ func (t *MediaTrackReceiver) TrackInfo() *livekit.TrackInfo { } func (t *MediaTrackReceiver) TrackInfoClone() *livekit.TrackInfo { - return utils.CloneProto(t.TrackInfo()) + return proto.CloneOf(t.TrackInfo()) } func (t *MediaTrackReceiver) NotifyMaxLayerChange(mimeType mime.MimeType, maxLayer int32) { diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index cdf7168de..0ded2aba1 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -74,12 +74,16 @@ const ( sdBatchSize = 30 rttUpdateInterval = 5 * time.Second + publisherAnswerDynacastResendDelay = 2 * time.Second + publisherAnswerDynacastResendMaxDelay = 8 * time.Second + disconnectCleanupDuration = 5 * time.Second migrationWaitDuration = 3 * time.Second migrationWaitContinuousMsgDuration = 2 * time.Second cMaxPendingTracks = 20 cMaxPendingQueuedTracks = 3 + cMaxReleasedTracks = 32 // unsequenced reliable data (server API sends) cannot be recovered from the // data message cache, so it is held here until the reliable data channel is writable @@ -241,6 +245,7 @@ type ParticipantParams struct { MigrationWaitDuration time.Duration ExcludeIPv6LocalCandidates bool EnableWarp bool + MediaBatchIOEnabled bool } type ParticipantImpl struct { @@ -261,6 +266,10 @@ type ParticipantImpl struct { state atomic.Value // livekit.ParticipantInfo_State disconnected chan struct{} + // a migrating in participant resumes on a reconnect response, the client takes it + // only as the first message on the resumed signal connection + reconnectResponseSent atomic.Bool + grants atomic.Pointer[auth.ClaimGrants] isPublisher atomic.Bool @@ -272,6 +281,9 @@ type ParticipantImpl struct { // timer that's set when disconnect is detected on primary PC disconnectTimer *time.Timer migrationTimer *time.Timer + // re-send of the subscribed qualities pending after publisher answers + dynacastResendTimer *time.Timer + dynacastResendDeadline time.Time migratedInAt atomic.Pointer[time.Time] @@ -285,6 +297,10 @@ type ParticipantImpl struct { pendingTracks map[string]*pendingTrackInfo pendingPublishingTracks map[livekit.TrackID]*pendingTrackInfo pendingRemoteTracks []*pendingRemoteTrack + // client ids (signal, codec and SDP) of each pending track released without media, so that + // media or an offer for them is dropped instead of being matched by type to another pending + // track, bounded to cMaxReleasedTracks, oldest out first + releasedTrackCids [][]string // supported codecs enabledPublishCodecs []*livekit.Codec @@ -612,9 +628,10 @@ func (p *ParticipantImpl) IsReady() bool { state := p.State() // when migrating, there is no JoinResponse, state transitions from JOINING -> ACTIVE -> DISCONNECTED - // so JOINING is considered ready. + // so JOINING is considered ready. The ReconnectResponse takes the place of the JoinResponse + // as the message the resumed signal connection opens with, so readiness waits for it. if p.params.Migration { - return state != livekit.ParticipantInfo_DISCONNECTED + return state != livekit.ParticipantInfo_DISCONNECTED && p.reconnectResponseSent.Load() } // when not migrating, there is a JoinResponse, state transitions from JOINING -> JOINED -> ACTIVE -> DISCONNECTED @@ -661,7 +678,7 @@ func (p *ParticipantImpl) GetClientInfo() *livekit.ClientInfo { func (p *ParticipantImpl) GetClientConfiguration() *livekit.ClientConfiguration { p.lock.RLock() defer p.lock.RUnlock() - return utils.CloneProto(p.params.ClientConf) + return proto.CloneOf(p.params.ClientConf) } func (p *ParticipantImpl) GetBufferFactory() *buffer.Factory { @@ -703,7 +720,7 @@ func (p *ParticipantImpl) UpdateMetadata(update *livekit.UpdateParticipantMetada sendRequestResponse := func() error { if !fromAdmin || (update.RequestId != 0 || err != nil) { requestResponse.Request = &livekit.RequestResponse_UpdateMetadata{ - UpdateMetadata: utils.CloneProto(update), + UpdateMetadata: proto.CloneOf(update), } p.sendRequestResponse(requestResponse) } @@ -884,6 +901,9 @@ func (p *ParticipantImpl) SetPermission(permission *livekit.ParticipantPermissio for _, st := range p.SubscriptionManager.GetSubscribedTracks() { st.MediaTrack().RemoveSubscriber(p.ID(), false) } + for _, ddt := range p.SubscriptionManager.GetSubscribedDataTracks() { + ddt.PublishDataTrack().RemoveSubscriber(p.ID()) + } } if !grants.Video.GetCanPublishData() { @@ -979,7 +999,7 @@ func (p *ParticipantImpl) ToProtoWithVersion() (*livekit.ParticipantInfo, utils. } if !found { - pi.Tracks = append(pi.Tracks, utils.CloneProto(pti.trackInfos[0])) + pi.Tracks = append(pi.Tracks, proto.CloneOf(pti.trackInfos[0])) } } @@ -1189,7 +1209,7 @@ func (p *ParticipantImpl) HandleICETrickle(trickleRequest *livekit.TrickleReques Reason: livekit.RequestResponse_UNCLASSIFIED_ERROR, Message: err.Error(), Request: &livekit.RequestResponse_Trickle{ - Trickle: utils.CloneProto(trickleRequest), + Trickle: proto.CloneOf(trickleRequest), }, }) return @@ -1209,20 +1229,19 @@ func (p *ParticipantImpl) HandleOffer(sd *livekit.SessionDescription) error { lgr.Debugw("received offer") - parsedOffer, err := offer.Unmarshal() - if err != nil { - lgr.Warnw("could not parse offer", err) - return err - } - if p.params.UseOneShotSignallingMode { + parsedOffer, err := offer.Unmarshal() + if err != nil { + lgr.Warnw("could not parse offer", err) + return err + } if err := p.synthesizeAddTrackRequests(parsedOffer); err != nil { lgr.Warnw("could not synthesize add track requests", err) return err } } - err = p.TransportManager.HandleOffer(offer, offerId, p.MigrateState() == types.MigrateStateInit) + err := p.TransportManager.HandleOffer(offer, offerId, p.MigrateState() == types.MigrateStateInit) if err != nil { lgr.Warnw("could not handle offer", err, "mungedOffer", offer) return err @@ -1237,13 +1256,8 @@ func (p *ParticipantImpl) HandleOffer(sd *livekit.SessionDescription) error { } func (p *ParticipantImpl) onPublisherSetRemoteDescription() { - offer := p.TransportManager.LastPublisherOfferPending() - if offer == nil { - return - } - parsedOffer, err := offer.Unmarshal() - if err != nil { - p.pubLogger.Warnw("could not parse offer", err) + parsedOffer := p.TransportManager.LastPublisherOfferParsed() + if parsedOffer == nil { return } @@ -1268,7 +1282,38 @@ func (p *ParticipantImpl) onPublisherAnswer(answer webrtc.SessionDescription, an "midToTrackID", midToTrackID, ) - return p.sendSdpAnswer(answer, answerId, midToTrackID) + if err := p.sendSdpAnswer(answer, answerId, midToTrackID); err != nil { + return err + } + + // The answer does not carry the pause state of the simulcast layers (SetIgnoreRidPauseForRecv), + // so applying it re-enables in the publisher the layers paused by dynacast. Send the subscribed + // qualities again with a delay to ensure the answer has been applied, once for a burst of answers: + // the delay restarts with each answer, up to a maximum from the first one + p.lock.Lock() + if p.dynacastResendTimer == nil { + p.dynacastResendDeadline = time.Now().Add(publisherAnswerDynacastResendMaxDelay) + p.dynacastResendTimer = time.AfterFunc(publisherAnswerDynacastResendDelay, p.resendSubscribedQualities) + } else { + p.dynacastResendTimer.Reset(min(publisherAnswerDynacastResendDelay, time.Until(p.dynacastResendDeadline))) + } + p.lock.Unlock() + return nil +} + +func (p *ParticipantImpl) resendSubscribedQualities() { + p.lock.Lock() + p.dynacastResendTimer = nil + p.lock.Unlock() + + if p.IsClosed() || p.IsDisconnected() { + return + } + for _, track := range p.GetPublishedTracks() { + if mt, ok := track.(*MediaTrack); ok { + mt.ResendSubscribedQuality() + } + } } func (p *ParticipantImpl) GetAnswer() (webrtc.SessionDescription, uint32, error) { @@ -1361,7 +1406,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) { p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_ALLOWED, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) return @@ -1372,7 +1417,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) { p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_UNSUPPORTED_TYPE, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) return @@ -1475,9 +1520,9 @@ func (p *ParticipantImpl) recordRTCState(closeReason types.ParticipantCloseReaso } if p.IsConnectionCanceled(closeReason) { - prometheus.IncrementParticipantRtcCanceled(1, p.params.EnableWarp) + prometheus.IncrementParticipantRtcCanceled(1, p.params.EnableWarp, p.GetClientInfo().GetSdk()) } else { - prometheus.IncrementParticipantRtcFailure(1, p.params.EnableWarp) + prometheus.IncrementParticipantRtcFailure(1, p.params.EnableWarp, p.GetClientInfo().GetSdk()) } } @@ -2071,8 +2116,9 @@ func (p *ParticipantImpl) setupSignalling() { Participant: p, }) p.signaller = signalling.NewSignallerAsync(signalling.SignallerAsyncParams{ - Logger: p.params.Logger, - Participant: p, + Logger: p.params.Logger, + Participant: p, + OnHandshakeOpened: p.flushQueuedUpdates, }) } @@ -2198,6 +2244,9 @@ func (p *ParticipantImpl) setupSubscriptionManager() { SubscriptionLimitAudio: p.params.SubscriptionLimitAudio, UseOneShotSignallingMode: p.params.UseOneShotSignallingMode, }) + p.SubscriptionManager.OnSubscribeStatusChanged(func(publisherID livekit.ParticipantID, subscribed bool) { + p.listener().OnSubscribeStatusChanged(p, publisherID, subscribed) + }) } func (p *ParticipantImpl) MetricsCollectorTimeToCollectMetrics() { @@ -2709,8 +2758,8 @@ func (p *ParticipantImpl) onPrimaryTransportInitialConnected() { } if !p.sessionStartRecorded.Swap(true) { - prometheus.RecordSessionStartTime(int(p.ProtocolVersion()), p.params.EnableWarp, time.Since(p.params.SessionStartTime)) - prometheus.IncrementParticipantRtcSuccess(1, p.params.EnableWarp) + prometheus.RecordSessionStartTime(int(p.ProtocolVersion()), p.params.EnableWarp, p.GetClientInfo().GetSdk(), time.Since(p.params.SessionStartTime)) + prometheus.IncrementParticipantRtcSuccess(1, p.params.EnableWarp, p.GetClientInfo().GetSdk()) } p.updateState(livekit.ParticipantInfo_ACTIVE) } @@ -2935,6 +2984,94 @@ func (p *ParticipantImpl) onSubscribedAudioCodecChange( return p.sendSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate) } +// releasePendingTrackLocked drops every pending entry of a track whose media never arrived, +// so it stops counting against cMaxPendingTracks. Must be called with pendingTracksLock held. +func (p *ParticipantImpl) releasePendingTrackLocked(trackID livekit.TrackID) { + var ( + released *livekit.TrackInfo + releasedCids []string + ) + for cid, pti := range p.pendingTracks { + remaining := pti.trackInfos[:0] + for _, ti := range pti.trackInfos { + if livekit.TrackID(ti.Sid) == trackID { + released = ti + releasedCids = append(releasedCids, cid) + for _, c := range ti.Codecs { + releasedCids = append(releasedCids, c.Cid, c.SdpCid) + } + continue + } + remaining = append(remaining, ti) + } + pti.trackInfos = remaining + if len(pti.trackInfos) == 0 { + delete(p.pendingTracks, cid) + } + } + if released == nil { + return + } + p.addReleasedTrackCidsLocked(releasedCids) + + p.pubLogger.Infow( + "releasing pending track that never received media", + "trackID", trackID, + "trackInfo", logger.Proto(released), + "numPendingTracks", len(p.pendingTracks), + ) + if p.supervisor != nil { + p.supervisor.RemovePublication(trackID) + } + if p.ProtocolVersion().SupportsUnpublish() { + p.sendTrackUnpublished(trackID) + } else { + p.sendTrackMuted(trackID, true) + } +} + +// must be called with pendingTracksLock held +func (p *ParticipantImpl) addReleasedTrackCidsLocked(cids []string) { + cids = slices.DeleteFunc(cids, func(cid string) bool { return cid == "" }) + slices.Sort(cids) + cids = slices.Compact(cids) + if len(cids) == 0 { + return + } + p.releasedTrackCids = append(p.releasedTrackCids, cids) + if len(p.releasedTrackCids) > cMaxReleasedTracks { + p.releasedTrackCids = p.releasedTrackCids[1:] + } +} + +// must be called with pendingTracksLock held +func (p *ParticipantImpl) isReleasedTrackCidLocked(cid string) bool { + for _, cids := range p.releasedTrackCids { + if slices.Contains(cids, cid) { + return true + } + } + return false +} + +// clearReleasedTrackCidsLocked drops every released track that shares a client id with +// this request. A client re-adding a released track intends to publish on it again, so all +// of its ids are cleared, including an SDP id a reused transceiver keeps. +// Must be called with pendingTracksLock held. +func (p *ParticipantImpl) clearReleasedTrackCidsLocked(req *livekit.AddTrackRequest) { + p.releasedTrackCids = slices.DeleteFunc(p.releasedTrackCids, func(cids []string) bool { + if slices.Contains(cids, req.Cid) { + return true + } + for _, sc := range req.SimulcastCodecs { + if sc.Cid != "" && slices.Contains(cids, sc.Cid) { + return true + } + } + return false + }) +} + func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit.TrackInfo { p.pendingTracksLock.Lock() if req.Sid != "" { @@ -2944,7 +3081,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) p.pendingTracksLock.Unlock() @@ -2957,19 +3094,29 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit return ti } + if len(p.pendingTracks) >= cMaxPendingTracks && p.supervisor != nil { + // a client can abandon a publish without any signal the server acts on, + // so before rejecting, release pending tracks the supervisor reports as timed out + for _, trackID := range p.supervisor.TimedOutPublications() { + p.releasePendingTrackLocked(trackID) + } + } + if len(p.pendingTracks) >= cMaxPendingTracks { p.pubLogger.Infow("too many pending tracks, rejecting new track", "numPendingTracks", len(p.pendingTracks)) p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_LIMIT_EXCEEDED, Message: fmt.Sprintf("too many pending tracks, rejecting new track, numPendingTracks: %d", len(p.pendingTracks)), Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) p.pendingTracksLock.Unlock() return nil } + p.clearReleasedTrackCidsLocked(req) + backupCodecPolicy := req.BackupCodecPolicy // enable simulcast codec for audio by default @@ -2985,7 +3132,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit clonedLayers := make([]*livekit.VideoLayer, 0, len(layers)) for _, l := range layers { - clonedLayers = append(clonedLayers, utils.CloneProto(l)) + clonedLayers = append(clonedLayers, proto.CloneOf(l)) } slices.SortFunc(clonedLayers, func(i, j *livekit.VideoLayer) int { return int(i.Quality) - int(j.Quality) @@ -3153,7 +3300,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit Reason: livekit.RequestResponse_LIMIT_EXCEEDED, Message: fmt.Sprintf("too many pending queued tracks, rejecting new track, numPendingTracksQueued: %d", len(p.pendingTracks[req.Cid].trackInfos)), Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) p.pendingTracksLock.Unlock() @@ -3171,7 +3318,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_QUEUED, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) @@ -3181,7 +3328,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit } p.pendingTracksLock.Unlock() - p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true) + p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), proto.CloneOf(ti), true) return nil } @@ -3206,7 +3353,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit } p.pendingTracksLock.Unlock() - p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true) + p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), proto.CloneOf(ti), true) return ti } @@ -3258,7 +3405,7 @@ func (p *ParticipantImpl) setTrackMuted(mute *livekit.MuteTrackRequest, fromAdmi for _, pti := range p.pendingTracks { for i, ti := range pti.trackInfos { if livekit.TrackID(ti.Sid) == trackID { - ti = utils.CloneProto(ti) + ti = proto.CloneOf(ti) changed = changed || ti.Muted != mute.Muted ti.Muted = mute.Muted pti.trackInfos[i] = ti @@ -3282,7 +3429,7 @@ func (p *ParticipantImpl) setTrackMuted(mute *livekit.MuteTrackRequest, fromAdmi p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_Mute{ - Mute: utils.CloneProto(mute), + Mute: proto.CloneOf(mute), }, }) } @@ -3327,6 +3474,16 @@ func (p *ParticipantImpl) mediaTrackReceived( ) mt, ok := p.getPublishedTrackBySdpCid(track.ID()).(*MediaTrack) if !ok { + if p.isReleasedTrackCidLocked(track.ID()) { + p.pendingTracksLock.Unlock() + p.pubLogger.Infow( + "dropping media for a released pending track", + "trackID", track.ID(), + "mid", mid, + ) + return nil, false, false, buffer.VideoLayersRid{} + } + var ( signalCid string ti *livekit.TrackInfo @@ -3564,6 +3721,7 @@ func (p *ParticipantImpl) addMediaTrack(signalCid string, ti *livekit.TrackInfo) EnableRTPStreamRestartDetection: p.params.EnableRTPStreamRestartDetection, UpdateTrackInfoByVideoSizeChange: p.params.UseOneShotSignallingMode, ForceBackupCodecPolicySimulcast: p.params.ForceBackupCodecPolicySimulcast, + MediaBatchIOEnabled: p.params.MediaBatchIOEnabled, OnSubscribedMaxQualityChange: p.onSubscribedMaxQualityChange, OnSubscribedAudioCodecChange: p.onSubscribedAudioCodecChange, }, ti) @@ -3673,6 +3831,11 @@ func (p *ParticipantImpl) onUpTrackManagerClose() { func (p *ParticipantImpl) getPendingTrack(clientId string, kind livekit.TrackType, skipQueued bool) (string, *livekit.TrackInfo, buffer.VideoLayersRid, bool, time.Time) { signalCid := clientId + if p.isReleasedTrackCidLocked(clientId) { + // do not fall back to matching by type, it would hand this to another pending track + return signalCid, nil, buffer.VideoLayersRid{}, false, time.Time{} + } + pendingInfo := p.pendingTracks[clientId] if pendingInfo == nil { track_loop: @@ -3709,7 +3872,7 @@ func (p *ParticipantImpl) getPendingTrack(clientId string, kind livekit.TrackTyp return signalCid, nil, buffer.VideoLayersRid{}, false, time.Time{} } - return signalCid, utils.CloneProto(pendingInfo.trackInfos[0]), pendingInfo.sdpRids, pendingInfo.migrated, pendingInfo.createdAt + return signalCid, proto.CloneOf(pendingInfo.trackInfos[0]), pendingInfo.sdpRids, pendingInfo.migrated, pendingInfo.createdAt } func (p *ParticipantImpl) getPendingTrackPrimaryBySdpCid(sdpCid string) *pendingTrackInfo { @@ -4226,7 +4389,7 @@ func (p *ParticipantImpl) UpdateAudioTrack(update *livekit.UpdateLocalAudioTrack p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_UpdateAudioTrack{ - UpdateAudioTrack: utils.CloneProto(update), + UpdateAudioTrack: proto.CloneOf(update), }, }) return errors.New("could not find track") @@ -4258,7 +4421,7 @@ func (p *ParticipantImpl) UpdateVideoTrack(update *livekit.UpdateLocalVideoTrack p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_UpdateVideoTrack{ - UpdateVideoTrack: utils.CloneProto(update), + UpdateVideoTrack: proto.CloneOf(update), }, }) return errors.New("could not find track") @@ -4302,6 +4465,55 @@ func (p *ParticipantImpl) SupportsMoving() error { return nil } +// LeaveSession takes the participant out of its session in its current room without +// closing the participant, for it to join another session (see JoinSession): its +// published tracks are reported unpublished, the session end is reported, and the +// telemetry guard and deferred resolvers are reset for the next session. `leave` runs in +// between, while the left session's guard is still in place, for the caller to report the +// leave. +func (p *ParticipantImpl) LeaveSession(leave func()) { + for _, track := range p.GetPublishedTracks() { + p.GetTelemetryListener().OnTrackUnpublished( + p.ID(), + p.Identity(), + track.ToProto(), + track.(types.LocalMediaTrack).Published(), + true, + ) + } + + p.params.Reporter.ReportEndTime(time.Now()) + + if leave != nil { + leave() + } + + p.lock.Lock() + p.telemetryGuard = &telemetry.ReferenceGuard{} + p.lock.Unlock() + + p.params.LoggerResolver.Reset() + p.params.ReporterResolver.Reset() +} + +// JoinSession takes the participant, after LeaveSession, into the session its current +// room now serves: `join` runs first, for the caller to report the join, then the +// published tracks are reported published again, in that order as on a fresh join. +func (p *ParticipantImpl) JoinSession(join func()) { + if join != nil { + join() + } + + for _, track := range p.GetPublishedTracks() { + p.GetTelemetryListener().OnTrackPublished( + p.ID(), + p.Identity(), + track.ToProto(), + true, + ) + } +} + func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) { for _, track := range p.GetPublishedTracks() { for _, sub := range track.GetAllSubscribers() { @@ -4311,38 +4523,23 @@ func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) { // clear the subscriber node max quality/audio codecs as the remote quality notify // from source room would not reach the moving out participant. track.(types.LocalMediaTrack).ClearSubscriberNodes() - - trackInfo := track.ToProto() - p.GetTelemetryListener().OnTrackUnpublished( - p.ID(), - p.Identity(), - trackInfo, - track.(types.LocalMediaTrack).Published(), - true, - ) } - p.params.Reporter.ReportEndTime(time.Now()) - p.SubscriptionManager.ClearAllSubscriptions() + p.LeaveSession(func() { + p.SubscriptionManager.ClearAllSubscriptions() - // fire onClose callback for original room - p.lock.Lock() - onClose := p.onClose - p.onClose = make(map[string]func(types.LocalParticipant)) - p.lock.Unlock() - for _, cb := range onClose { - cb(p) - } + // fire onClose callback for original room + p.lock.Lock() + onClose := p.onClose + p.onClose = make(map[string]func(types.LocalParticipant)) + p.lock.Unlock() + for _, cb := range onClose { + cb(p) + } + }) p.params.Logger.Infow("move participant to new room", "newRoomName", params.RoomName, "newID", params.ParticipantID) - p.lock.Lock() - p.telemetryGuard = &telemetry.ReferenceGuard{} - p.lock.Unlock() - - p.params.LoggerResolver.Reset() - p.params.ReporterResolver.Reset() - p.setListener(params.Listener) p.setTelemetryListener(params.TelemetryListener) p.participantHelper.Store(params.Helper) diff --git a/pkg/rtc/participant_data_blob_handler.go b/pkg/rtc/participant_data_blob_handler.go index 8c19ee765..b9d131ade 100644 --- a/pkg/rtc/participant_data_blob_handler.go +++ b/pkg/rtc/participant_data_blob_handler.go @@ -15,6 +15,7 @@ package rtc import ( + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -31,7 +32,7 @@ func (p *ParticipantImpl) HandleStoreDataBlobRequest(req *livekit.StoreDataBlobR return } - if req.Blob == nil || req.Blob.Key == nil || len(req.Blob.Key.String()) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key.String()) { + if req.Blob == nil || req.Blob.Key == nil || config.DataBlobKeyLength(req.Blob.Key) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key) { p.pubLogger.Warnw("data blob is invalid", nil, "req", logger.Proto(req)) p.sendRequestResponse(&livekit.RequestResponse{ RequestId: req.RequestId, diff --git a/pkg/rtc/participant_data_blob_handler_test.go b/pkg/rtc/participant_data_blob_handler_test.go index 411a6a203..409cc1ddf 100644 --- a/pkg/rtc/participant_data_blob_handler_test.go +++ b/pkg/rtc/participant_data_blob_handler_test.go @@ -124,6 +124,61 @@ func TestHandleStoreDataBlobRequest(t *testing.T) { require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) }) + t.Run("returns INVALID_REQUEST when generic key 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(""), + Contents: []byte("def"), + }, + }) + + 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("accepts a key exactly at the length limit", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 5, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + key := genericKey("abcde") + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: key, + Contents: []byte("def"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + _, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse) + require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message) + require.NotNil(t, p.dataBlob.Get(key)) + }) + + t.Run("counts only key and contents bytes against the size limit", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 9) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + key := genericKey("abc") + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: key, + Contents: []byte("defghi"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + _, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse) + require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message) + require.NotNil(t, p.dataBlob.Get(key)) + }) + 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) diff --git a/pkg/rtc/participant_data_track.go b/pkg/rtc/participant_data_track.go index eff47b82b..f025c6452 100644 --- a/pkg/rtc/participant_data_track.go +++ b/pkg/rtc/participant_data_track.go @@ -15,8 +15,10 @@ package rtc import ( - "github.com/livekit/livekit-server/pkg/rtc/datatrack" + "google.golang.org/protobuf/proto" + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" @@ -30,7 +32,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_NOT_ALLOWED, Message: "does not have permission to publish data", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -42,7 +44,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_INVALID_HANDLE, Message: "handle should be > 0 AND < 65536", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -54,7 +56,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_INVALID_NAME, Message: "name should not be empty and should not exceed 256 characters", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -67,7 +69,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_INVALID_REQUEST, Message: "encoding identifier is empty or exceeds the maximum length", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -95,7 +97,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: reason, Message: message, Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -108,8 +110,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()) + dti.FrameEncoding = proto.CloneOf(req.GetFrameEncoding()) + dti.Schema = proto.CloneOf(req.GetSchema()) dt := NewDataTrack( DataTrackParams{ Logger: p.params.Logger.WithValues("trackID", dti.Sid), @@ -143,7 +145,7 @@ func (p *ParticipantImpl) HandleUnpublishDataTrackRequest(req *livekit.Unpublish p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_UnpublishDataTrack{ - UnpublishDataTrack: utils.CloneProto(req), + UnpublishDataTrack: proto.CloneOf(req), }, }) return diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index 2cde9181c..c3ac21065 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -32,6 +32,7 @@ import ( "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/logger/zaputil" "github.com/livekit/protocol/observability/roomobs" lksdp "github.com/livekit/protocol/sdp" "github.com/livekit/protocol/signalling" @@ -78,6 +79,38 @@ func TestIsReady(t *testing.T) { } } +// Disabling a codec can leave the publish list with only RTX and FEC. The +// fallback must still pick a real codec, or the track registers as RTX and +// subscribers see a blank tile. +func TestPublishCodecFallbackSkipsRTX(t *testing.T) { + h264 := &livekit.Codec{Mime: mime.MimeTypeH264.String()} + rtx := &livekit.Codec{Mime: mime.MimeTypeRTX.String()} + + newParticipant := func() *ParticipantImpl { + return &ParticipantImpl{params: ParticipantParams{Logger: logger.GetLogger()}} + } + + t.Run("h264 enabled, rtx is last and not chosen", func(t *testing.T) { + p := newParticipant() + p.setupEnabledCodecs([]*livekit.Codec{h264, rtx}, nil, nil) + + require.Equal(t, mime.MimeTypeH264.String(), selectAlternativeVideoCodec(p.enabledPublishCodecs)) + }) + + t.Run("h264 disabled leaves rtx only, fallback avoids rtx", func(t *testing.T) { + p := newParticipant() + disabled := &livekit.DisabledCodecs{Publish: []*livekit.Codec{{Mime: mime.MimeTypeH264.String()}}} + p.setupEnabledCodecs([]*livekit.Codec{h264, rtx}, nil, disabled) + + require.Len(t, p.enabledPublishCodecs, 1) + require.True(t, mime.IsMimeTypeStringRTX(p.enabledPublishCodecs[0].Mime)) + + alt := selectAlternativeVideoCodec(p.enabledPublishCodecs) + require.False(t, mime.IsMimeTypeStringRTX(alt), "fallback must not be rtx, got %q", alt) + require.Equal(t, mime.MimeTypeVP8.String(), alt) + }) +} + func TestSupportsMoving(t *testing.T) { t.Run("current protocol version", func(t *testing.T) { p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: types.CurrentProtocol}) @@ -223,6 +256,122 @@ func TestTrackPublishing(t *testing.T) { require.Equal(t, p.pendingTracks["cid"].trackInfos[0].Sid, p.pendingTracks["cid"].trackInfos[1].Sid) }) + t.Run("should release timed out pending tracks when at the pending track limit", func(t *testing.T) { + p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 7}) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + // one slot holds two queued entries that share a track id, both before any media + queued := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidq", Name: "q0", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, queued) + p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidq", Name: "q1", Type: livekit.TrackType_VIDEO}) // queued, returns nil + require.Len(t, p.pendingTracks["cidq"].trackInfos, 2) + + var sids []livekit.TrackID + for i := 0; i < cMaxPendingTracks-1; i++ { + ti := p.addPendingTrack(&livekit.AddTrackRequest{ + Cid: fmt.Sprintf("cid%d", i), + Name: fmt.Sprintf("track%d", i), + Type: livekit.TrackType_VIDEO, + }) + require.NotNil(t, ti) + sids = append(sids, livekit.TrackID(ti.Sid)) + } + require.Len(t, p.pendingTracks, cMaxPendingTracks) + + // nothing timed out, so a new publish is rejected + require.Nil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh", Name: "fresh", Type: livekit.TrackType_VIDEO})) + + // one lone track times out, a new publish takes its slot + p.supervisor.SetPublicationTimedOutForTest(sids[0]) + numMessages := sink.WriteMessageCallCount() + require.NotNil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh", Name: "fresh", Type: livekit.TrackType_VIDEO})) + require.Nil(t, p.pendingTracks["cid0"]) + require.NotNil(t, p.pendingTracks["fresh"]) + require.Len(t, p.pendingTracks, cMaxPendingTracks) + require.Empty(t, p.supervisor.TimedOutPublications()) + + require.Equal(t, numMessages+1, sink.WriteMessageCallCount()) + res := sink.WriteMessageArgsForCall(numMessages).(*livekit.SignalResponse) + require.IsType(t, &livekit.SignalResponse_TrackUnpublished{}, res.Message) + require.Equal(t, string(sids[0]), res.Message.(*livekit.SignalResponse_TrackUnpublished).TrackUnpublished.TrackSid) + + // the queued track id times out, both entries are released together + p.supervisor.SetPublicationTimedOutForTest(livekit.TrackID(queued.Sid)) + require.NotNil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh2", Name: "fresh2", Type: livekit.TrackType_VIDEO})) + require.Nil(t, p.pendingTracks["cidq"]) + require.NotNil(t, p.pendingTracks["fresh2"]) + require.Len(t, p.pendingTracks, cMaxPendingTracks) + }) + + t.Run("should not match late media of a released pending track to another pending track", func(t *testing.T) { + p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 7}) + + released := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidA", Name: "a", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, released) + // SDP client id that an offer would set, as Firefox does + p.pendingTracks["cidA"].trackInfos[0].Codecs[0].SdpCid = "sdpA" + + p.pendingTracksLock.Lock() + p.releasePendingTrackLocked(livekit.TrackID(released.Sid)) + p.pendingTracksLock.Unlock() + + other := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidB", Name: "b", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, other) + + // media for the released track, by signal or SDP client id, does not fall back to the other track by type + for _, cid := range []string{"cidA", "sdpA"} { + _, ti, _, _, _ := p.getPendingTrack(cid, livekit.TrackType_VIDEO, true) + require.Nil(t, ti, cid) + } + // an unknown client id still falls back by type, as before + _, ti, _, _, _ := p.getPendingTrack("unknown", livekit.TrackType_VIDEO, true) + require.NotNil(t, ti) + require.Equal(t, other.Sid, ti.Sid) + + // reusing the released client id publishes on it again + reused := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidA", Name: "a2", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, reused) + _, ti, _, _, _ = p.getPendingTrack("cidA", livekit.TrackType_VIDEO, true) + require.NotNil(t, ti) + require.Equal(t, reused.Sid, ti.Sid) + // the old SDP id is cleared too, a reused transceiver keeps it + p.pendingTracksLock.Lock() + require.False(t, p.isReleasedTrackCidLocked("sdpA")) + p.pendingTracksLock.Unlock() + }) + + t.Run("should clear a released track when a new request reuses one of its codec client ids", func(t *testing.T) { + p := newParticipantForTest("test") + + p.pendingTracksLock.Lock() + p.addReleasedTrackCidsLocked([]string{"cidA", "codecA", "sdpA"}) + p.addReleasedTrackCidsLocked([]string{"cidB"}) + p.clearReleasedTrackCidsLocked(&livekit.AddTrackRequest{ + Cid: "cidC", + SimulcastCodecs: []*livekit.SimulcastCodec{{Codec: "vp8", Cid: "codecA"}}, + }) + for _, cid := range []string{"cidA", "codecA", "sdpA"} { + require.False(t, p.isReleasedTrackCidLocked(cid), cid) + } + require.True(t, p.isReleasedTrackCidLocked("cidB")) + p.pendingTracksLock.Unlock() + }) + + t.Run("should bound released track client ids", func(t *testing.T) { + p := newParticipantForTest("test") + + p.pendingTracksLock.Lock() + for i := 0; i <= cMaxReleasedTracks; i++ { + p.addReleasedTrackCidsLocked([]string{fmt.Sprintf("cid%d", i), fmt.Sprintf("sdp%d", i)}) + } + require.Len(t, p.releasedTrackCids, cMaxReleasedTracks) + // the oldest track ages out with all of its ids + require.False(t, p.isReleasedTrackCidLocked("cid0")) + require.False(t, p.isReleasedTrackCidLocked("sdp0")) + require.True(t, p.isReleasedTrackCidLocked(fmt.Sprintf("sdp%d", cMaxReleasedTracks))) + p.pendingTracksLock.Unlock() + }) + t.Run("should not allow adding disallowed sources", func(t *testing.T) { p := newParticipantForTest("test") p.SetPermission(&livekit.ParticipantPermission{ @@ -251,6 +400,50 @@ func TestTrackPublishing(t *testing.T) { }) } +func TestSetPermissionDataTrackSubscriptions(t *testing.T) { + p := newParticipantForTestWithOpts("sub", &participantOpts{ + permissions: &livekit.ParticipantPermission{ + CanSubscribe: true, + }, + }) + defer p.Close(false, types.ParticipantCloseReasonNone, false) + + pubID := livekit.ParticipantID("PA_pub") + dt := NewDataTrack( + DataTrackParams{ + Logger: logger.GetLogger(), + ParticipantID: func() livekit.ParticipantID { return pubID }, + ParticipantIdentity: "pub", + }, + &livekit.DataTrackInfo{PubHandle: 1, Sid: "DTR_test", Name: "test"}, + ) + defer dt.Close() + + helper := p.params.ParticipantHelper.(*typesfakes.FakeLocalParticipantHelper) + helper.ResolveDataTrackReturns(types.DataResolverResult{ + DataTrack: dt, + HasPermission: true, + PublisherID: pubID, + PublisherIdentity: "pub", + }) + + p.SubscribeToDataTrack(dt.ID()) + require.Eventually(t, func() bool { + return dt.IsSubscriber(p.ID()) + }, 5*time.Second, 10*time.Millisecond, "data track subscription should be established") + + // revoking canSubscribe should drop data track subscriptions, like media track subscriptions + require.True(t, p.SetPermission(&livekit.ParticipantPermission{CanSubscribe: false})) + require.False(t, dt.IsSubscriber(p.ID())) + require.Empty(t, p.GetSubscribedDataTracks()) + + // granting canSubscribe again should restore the subscription + require.True(t, p.SetPermission(&livekit.ParticipantPermission{CanSubscribe: true})) + require.Eventually(t, func() bool { + return dt.IsSubscriber(p.ID()) + }, 5*time.Second, 10*time.Millisecond, "data track subscription should be restored") +} + func TestOutOfOrderUpdates(t *testing.T) { p := newParticipantForTest("test") p.updateState(livekit.ParticipantInfo_JOINED) @@ -272,6 +465,42 @@ func TestOutOfOrderUpdates(t *testing.T) { require.Equal(t, "second update", sent.GetUpdate().Participants[0].Metadata) } +func TestRecentlyDisconnectedKeepsUpdateOrder(t *testing.T) { + p := newParticipantForTest("test") + + // fill the update cache (128 entries), the last update is a disconnect + const cacheSize = 128 + for i := range cacheSize { + state := livekit.ParticipantInfo_ACTIVE + if i == cacheSize-1 { + state = livekit.ParticipantInfo_DISCONNECTED + } + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: fmt.Sprintf("PA_%d", i), + Identity: fmt.Sprintf("p%d", i), + Version: 1, + State: state, + }})) + } + + disconnected := p.GetRecentlyDisconnectedParticipants() + require.Len(t, disconnected, 1) + require.Equal(t, "PA_127", disconnected[0].Sid) + + // an update for a new participant evicts the least recently updated entry, + // not the disconnect that was just reported + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_new", + Identity: "new", + Version: 1, + State: livekit.ParticipantInfo_ACTIVE, + }})) + + disconnected = p.GetRecentlyDisconnectedParticipants() + require.Len(t, disconnected, 1) + require.Equal(t, "PA_127", disconnected[0].Sid) +} + // after disconnection, things should continue to function and not panic func TestDisconnectTiming(t *testing.T) { t.Run("Negotiate doesn't panic after channel closed", func(t *testing.T) { @@ -560,7 +789,7 @@ func TestPreferMediaCodecForPublisher(t *testing.T) { for i := 0; i < 2; i++ { // publish preferred track without client using setCodecPreferences() trackCid := fmt.Sprintf("%s-%d", tc.trackBaseCid, i) - req := utils.CloneProto(tc.addTrack) + req := proto.CloneOf(tc.addTrack) req.SimulcastCodecs = []*livekit.SimulcastCodec{ { Codec: tc.preferredCodec, @@ -639,6 +868,89 @@ func TestPreferMediaCodecForPublisher(t *testing.T) { } } +func TestOneShotSimulcastOfferAppliesRids(t *testing.T) { + participant := newParticipantForTestWithOpts("whip", &participantOpts{ + publisher: true, + oneShot: true, + }) + participant.SetMigrateState(types.MigrateStateComplete) + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer pc.Close() + + codec := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()} + medium, err := webrtc.NewTrackLocalStaticRTP(codec, "video", "stream", webrtc.WithRTPStreamID("h")) + require.NoError(t, err) + low, err := webrtc.NewTrackLocalStaticRTP(codec, "video", "stream", webrtc.WithRTPStreamID("q")) + require.NoError(t, err) + sender, err := pc.AddTrack(medium) + require.NoError(t, err) + require.NoError(t, sender.AddEncoding(low)) + + offer, err := pc.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, pc.SetLocalDescription(offer)) + require.Contains(t, offer.SDP, "a=simulcast:send h;q") + + require.NoError(t, participant.HandleOffer(&livekit.SessionDescription{ + Type: webrtc.SDPTypeOffer.String(), + Sdp: offer.SDP, + })) + + participant.pendingTracksLock.RLock() + pti := participant.pendingTracks["video"] + participant.pendingTracksLock.RUnlock() + require.NotNil(t, pti) + + layers := pti.trackInfos[0].Codecs[0].Layers + require.Len(t, layers, 2) + require.Equal(t, livekit.VideoQuality_LOW, layers[0].Quality) + require.Equal(t, "q", layers[0].Rid) + require.EqualValues(t, 0, layers[0].SpatialLayer) + require.Equal(t, livekit.VideoQuality_MEDIUM, layers[1].Quality) + require.Equal(t, "h", layers[1].Rid) + require.EqualValues(t, 1, layers[1].SpatialLayer) +} + +func TestOneShotSingleLayerOfferUnchanged(t *testing.T) { + participant := newParticipantForTestWithOpts("whip", &participantOpts{ + publisher: true, + oneShot: true, + }) + participant.SetMigrateState(types.MigrateStateComplete) + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer pc.Close() + + track, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()}, "video", "stream") + require.NoError(t, err) + _, err = pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) + require.NoError(t, err) + + offer, err := pc.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, pc.SetLocalDescription(offer)) + require.NotContains(t, offer.SDP, "a=simulcast") + require.NotContains(t, offer.SDP, "a=rid") + + require.NoError(t, participant.HandleOffer(&livekit.SessionDescription{ + Type: webrtc.SDPTypeOffer.String(), + Sdp: offer.SDP, + })) + + participant.pendingTracksLock.RLock() + pti := participant.pendingTracks["video"] + participant.pendingTracksLock.RUnlock() + require.NotNil(t, pti) + + layers := pti.trackInfos[0].Codecs[0].Layers + require.Len(t, layers, 1) + require.Equal(t, "", layers[0].Rid) + require.EqualValues(t, 0, layers[0].SpatialLayer) +} + func TestPreferAudioCodecForRed(t *testing.T) { participant := newParticipantForTestWithOpts("123", &participantOpts{ publisher: true, @@ -783,6 +1095,8 @@ type participantOpts struct { publisher bool clientConf *livekit.ClientConfiguration clientInfo *livekit.ClientInfo + migration bool + oneShot bool } func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *participantOpts) *ParticipantImpl { @@ -819,24 +1133,26 @@ func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *p } sid := livekit.ParticipantID(guid.New(utils.ParticipantPrefix)) p, _ := NewParticipant(ParticipantParams{ - SID: sid, - Identity: identity, - Config: rtcConf, - Sink: &routingfakes.FakeMessageSink{}, - ProtocolVersion: opts.protocolVersion, - SessionStartTime: time.Now(), - PLIThrottleConfig: conf.RTC.PLIThrottle, - Grants: grants, - PublishEnabledCodecs: enabledCodecs, - SubscribeEnabledCodecs: enabledCodecs, - ClientConf: opts.clientConf, - ClientInfo: ClientInfo{ClientInfo: opts.clientInfo}, - Logger: LoggerWithParticipant(logger.GetLogger(), identity, sid, false), - Reporter: roomobs.NewNoopParticipantSessionReporter(), - TelemetryListener: &typesfakes.FakeParticipantTelemetryListener{}, - VersionGenerator: utils.NewDefaultTimedVersionGenerator(), - ParticipantListener: &typesfakes.FakeLocalParticipantListener{}, - ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{}, + SID: sid, + Identity: identity, + Config: rtcConf, + Sink: &routingfakes.FakeMessageSink{}, + ProtocolVersion: opts.protocolVersion, + SessionStartTime: time.Now(), + PLIThrottleConfig: conf.RTC.PLIThrottle, + Grants: grants, + PublishEnabledCodecs: enabledCodecs, + SubscribeEnabledCodecs: enabledCodecs, + ClientConf: opts.clientConf, + ClientInfo: ClientInfo{ClientInfo: opts.clientInfo}, + Logger: LoggerWithParticipant(logger.GetLogger(), identity, sid, false), + Reporter: roomobs.NewNoopParticipantSessionReporter(), + TelemetryListener: &typesfakes.FakeParticipantTelemetryListener{}, + VersionGenerator: utils.NewDefaultTimedVersionGenerator(), + ParticipantListener: &typesfakes.FakeLocalParticipantListener{}, + ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{}, + Migration: opts.migration, + UseOneShotSignallingMode: opts.oneShot, }) p.isPublisher.Store(opts.publisher) p.updateState(livekit.ParticipantInfo_ACTIVE) @@ -895,3 +1211,204 @@ func TestUnsequencedReliableDataBufferedWhileJoining(t *testing.T) { require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages) }) } + +func TestMigratingInParticipantWaitsForReconnectResponse(t *testing.T) { + // a migrating in participant resumes on a ReconnectResponse, the client takes it only + // as the first message on the resumed signal connection, so everything else waits + newMigratingParticipant := func() (*ParticipantImpl, *routingfakes.FakeMessageSink) { + p := newParticipantForTestWithOpts("test", &participantOpts{ + migration: true, + protocolVersion: 17, + clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: "2.15.2"}, + }) + return p, p.params.Sink.(*routingfakes.FakeMessageSink) + } + + t.Run("not ready until the response is sent", func(t *testing.T) { + p, sink := newMigratingParticipant() + require.False(t, p.IsReady()) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_UNKNOWN, + &livekit.ReconnectResponse{LastMessageSeq: 21}, + nil, + )) + require.True(t, p.IsReady()) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, res.GetReconnect()) + require.EqualValues(t, 21, res.GetReconnect().LastMessageSeq) + }) + + t.Run("the response goes out first", func(t *testing.T) { + p, sink := newMigratingParticipant() + + // a room update while waiting is dropped, it is re-sent after the migration + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + // participant updates while waiting are queued + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_other", + Identity: "other", + Version: 1, + }})) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_UNKNOWN, + &livekit.ReconnectResponse{}, + nil, + )) + + require.Equal(t, 2, sink.WriteMessageCallCount()) + first := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, first.GetReconnect()) + second := sink.WriteMessageArgsForCall(1).(*livekit.SignalResponse) + require.NotNil(t, second.GetUpdate()) + require.Len(t, second.GetUpdate().Participants, 1) + require.EqualValues(t, "PA_other", second.GetUpdate().Participants[0].Sid) + }) + + t.Run("a client that cannot handle the response is ready right away", func(t *testing.T) { + p := newParticipantForTestWithOpts("test", &participantOpts{ + migration: true, + protocolVersion: 17, + clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: "1.6.2"}, + }) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_UNKNOWN, + &livekit.ReconnectResponse{}, + nil, + )) + require.True(t, p.IsReady()) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + require.Equal(t, 1, sink.WriteMessageCallCount()) + }) +} + +func TestResumedParticipantWaitsForReconnectResponse(t *testing.T) { + // a resumed connection has to open with the ReconnectResponse too, and the + // participant is ready throughout, so the signaller holds messages back + newResumedParticipant := func(version string) (*ParticipantImpl, *routingfakes.FakeMessageSink) { + p := newParticipantForTestWithOpts("test", &participantOpts{ + protocolVersion: 17, + clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: version}, + }) + require.True(t, p.IsReady()) + + sink := &routingfakes.FakeMessageSink{} + p.SwapResponseSink(sink, types.SignallingCloseReasonResume) + return p, sink + } + + t.Run("the response goes out first", func(t *testing.T) { + p, sink := newResumedParticipant("2.15.2") + + // a room update in this window is dropped, the resume path re-sends room state + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + // participant updates are queued + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_other", + Identity: "other", + Version: 1, + }})) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED, + &livekit.ReconnectResponse{LastMessageSeq: 7}, + nil, + )) + + require.Equal(t, 2, sink.WriteMessageCallCount()) + first := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, first.GetReconnect()) + require.EqualValues(t, 7, first.GetReconnect().LastMessageSeq) + second := sink.WriteMessageArgsForCall(1).(*livekit.SignalResponse) + require.NotNil(t, second.GetUpdate()) + require.EqualValues(t, "PA_other", second.GetUpdate().Participants[0].Sid) + }) + + t.Run("a connection that opens without a response still delivers the queue", func(t *testing.T) { + p, sink := newResumedParticipant("2.15.2") + + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_other", + Identity: "other", + Version: 1, + }})) + require.Zero(t, sink.WriteMessageCallCount()) + + // no ReconnectResponse written, this is what the handshake window does on expiry + p.signaller.OpenHandshake() + + require.Equal(t, 1, sink.WriteMessageCallCount()) + res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, res.GetUpdate()) + require.EqualValues(t, "PA_other", res.GetUpdate().Participants[0].Sid) + }) + + t.Run("a client that cannot handle the response is not held back", func(t *testing.T) { + p, sink := newResumedParticipant("1.6.2") + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED, + &livekit.ReconnectResponse{}, + nil, + )) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + require.Equal(t, 1, sink.WriteMessageCallCount()) + }) +} + +func TestLeaveJoinSession(t *testing.T) { + p := newParticipantForTest("leave-join-session") + p.params.LoggerResolver = zaputil.NoOpDeferrer{} + _, p.params.ReporterResolver = roomobs.DeferredParticipantReporter(roomobs.NewNoopProjectReporter()) + tl := p.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener) + + track := &typesfakes.FakeLocalMediaTrack{} + track.IDReturns("TR_test") + track.PublishedReturns(true) + p.UpTrackManager.AddPublishedTrack(track) + + // leaving reports the track unpublished, then runs the leave under the left session's + // guard, and the next session gets a fresh one + prevGuard := p.TelemetryGuard() + require.NotNil(t, prevGuard) + left := false + p.LeaveSession(func() { + left = true + require.Same(t, prevGuard, p.TelemetryGuard()) + require.Equal(t, 1, tl.OnTrackUnpublishedCallCount()) + }) + require.True(t, left) + require.NotSame(t, prevGuard, p.TelemetryGuard()) + require.Equal(t, 1, tl.OnTrackUnpublishedCallCount()) + _, _, _, wasPublished, shouldSend := tl.OnTrackUnpublishedArgsForCall(0) + require.True(t, wasPublished) + require.True(t, shouldSend) + + // joining runs the join first, then reports the track published again + joined := false + p.JoinSession(func() { + joined = true + require.Zero(t, tl.OnTrackPublishedCallCount()) + }) + require.True(t, joined) + require.Equal(t, 1, tl.OnTrackPublishedCallCount()) + _, _, _, shouldSend = tl.OnTrackPublishedArgsForCall(0) + require.True(t, shouldSend) + + // nil callbacks are fine + p.LeaveSession(nil) + p.JoinSession(nil) + require.Equal(t, 2, tl.OnTrackUnpublishedCallCount()) + require.Equal(t, 2, tl.OnTrackPublishedCallCount()) +} diff --git a/pkg/rtc/participant_sdp.go b/pkg/rtc/participant_sdp.go index 79cb595cd..26b338777 100644 --- a/pkg/rtc/participant_sdp.go +++ b/pkg/rtc/participant_sdp.go @@ -22,13 +22,13 @@ import ( "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" + "google.golang.org/protobuf/proto" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" lksdp "github.com/livekit/protocol/sdp" - "github.com/livekit/protocol/utils" ) func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([]*sdp.MediaDescription, []*sdp.MediaDescription) { @@ -50,6 +50,11 @@ func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([ } p.pendingTracksLock.Lock() + if p.isReleasedTrackCidLocked(streamID) { + p.pendingTracksLock.Unlock() + continue + } + signalCid, info, _, migrated, _ := p.getPendingTrack(streamID, trackType, false) if migrated { p.pendingTracksLock.Unlock() @@ -143,7 +148,7 @@ func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([ } if updated { - p.pendingTracks[signalCid].trackInfos[0] = utils.CloneProto(info) + p.pendingTracks[signalCid].trackInfos[0] = proto.CloneOf(info) p.pubLogger.Debugw( "pending track SDP cid updated", "signalCid", signalCid, @@ -319,9 +324,8 @@ func (p *ParticipantImpl) setCodecPreferencesOpusRedForPublisher( // configure publisher answer for audio track's dtx and stereo settings func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescription) webrtc.SessionDescription { - offer := p.TransportManager.LastPublisherOffer() - parsedOffer, err := offer.Unmarshal() - if err != nil { + parsedOffer := p.TransportManager.LastPublisherOfferParsed() + if parsedOffer == nil { return answer } @@ -330,6 +334,9 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript return answer } + // payload type lookup scans the whole answer, resolve it once + opusPT, opusPTErr := parsedAnswer.GetPayloadTypeForCodec(sdp.Codec{Name: mime.MimeTypeCodecOpus.String()}) + changed := false for _, m := range parsedAnswer.MediaDescriptions { switch m.MediaName.Media { case "audio": @@ -371,9 +378,8 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript continue } - opusPT, err := parsedAnswer.GetPayloadTypeForCodec(sdp.Codec{Name: mime.MimeTypeCodecOpus.String()}) - if err != nil { - p.pubLogger.Infow("failed to get opus payload type", "error", err, "trackID", ti.Sid) + if opusPTErr != nil { + p.pubLogger.Infow("failed to get opus payload type", "error", opusPTErr, "trackID", ti.Sid) continue } @@ -390,6 +396,7 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript attr.Value = strings.ReplaceAll(attr.Value, ";stereo=1", "") } m.Attributes[i] = attr + changed = true } } @@ -398,6 +405,10 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript } } + if !changed { + return answer + } + bytes, err := parsedAnswer.Marshal() if err != nil { p.pubLogger.Infow("failed to marshal answer", "error", err) diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 8f5c3dd2b..315fbfe99 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -81,7 +81,9 @@ func (p *ParticipantImpl) SendParticipantUpdate(participantsToUpdate []*livekit. return nil } - if !p.IsReady() { + // read under the lock the flush takes, so an update either queues before the flush + // or goes out after the connection has opened + if !p.IsReady() || p.signaller.HandshakePending() { // queue up updates p.queuedUpdates = append(p.queuedUpdates, participantsToUpdate...) p.updateLock.Unlock() @@ -173,34 +175,107 @@ func (p *ParticipantImpl) SendRoomMovedResponse(roomMovedResponse *livekit.RoomM return p.signaller.WriteMessage(p.signalling.SignalRoomMovedResponse(roomMovedResponse)) } -func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse) error { +func (p *ParticipantImpl) HandleReconnectAndSendResponse( + reconnectReason livekit.ReconnectReason, + reconnectResponse *livekit.ReconnectResponse, + recentlyDisconnectedParticipants []*livekit.ParticipantInfo, // when migrating in, these are the participants disconnected received from migrate out node +) error { p.TransportManager.HandleClientReconnect(reconnectReason) if !p.params.ClientInfo.CanHandleReconnectResponse() { - return nil + // no ReconnectResponse opens this connection, so nothing to hold back + return p.reconnectResponseSentAndFlush() } - if err := p.signaller.WriteMessage(p.signalling.SignalReconnectResponse(reconnectResponse)); err != nil { + + // send reconnect response + err := p.signaller.WriteMessage(p.signalling.SignalReconnectResponse(reconnectResponse)) + + // mark sent after sending the message, so that nothing could slip through before + // ReconnectResponse is sent. Marked on a failed write too: the sink is gone in that + // case, and leaving it unmarked would hold back every message after it. + flushErr := p.reconnectResponseSentAndFlush() + if err != nil { return err } + if flushErr != nil { + return flushErr + } if p.params.ProtocolVersion.SupportsDisconnectedUpdate() { + if len(recentlyDisconnectedParticipants) != 0 { + if err := p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(recentlyDisconnectedParticipants)); err != nil { + return err + } + } return p.sendDisconnectUpdatesForReconnect() } return nil } +// reconnectResponseSentAndFlush makes a migrating in participant ready and sends what was +// queued up while the connection was waiting for its ReconnectResponse. +func (p *ParticipantImpl) reconnectResponseSentAndFlush() error { + // a successful write opens the connection by itself, this covers the paths that do + // not write one, i. e. a client that cannot handle it and a failed write + p.signaller.OpenHandshake() + + p.updateLock.Lock() + p.reconnectResponseSent.Store(true) + queuedUpdates := p.queuedUpdates + p.queuedUpdates = nil + p.updateLock.Unlock() + + if len(queuedUpdates) > 0 { + return p.SendParticipantUpdate(queuedUpdates) + } + + return nil +} + +// flushQueuedUpdates sends the updates queued while the connection was closed for the +// handshake. Called when the connection opens, including when it opens without a +// ReconnectResponse having been written. +func (p *ParticipantImpl) flushQueuedUpdates() { + p.updateLock.Lock() + queuedUpdates := p.queuedUpdates + p.queuedUpdates = nil + p.updateLock.Unlock() + + if len(queuedUpdates) == 0 { + return + } + + if err := p.SendParticipantUpdate(queuedUpdates); err != nil { + p.params.Logger.Warnw("could not send queued participant updates", err) + } +} + func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { - lastSignalAt := p.TransportManager.LastSeenSignalAt() - var disconnectedParticipants []*livekit.ParticipantInfo + recentlyDisconnectedParticipants := p.GetRecentlyDisconnectedParticipants() + if len(recentlyDisconnectedParticipants) != 0 { + return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(recentlyDisconnectedParticipants)) + } + return nil +} + +func (p *ParticipantImpl) GetRecentlyDisconnectedParticipants() []*livekit.ParticipantInfo { + // look back a little more than last signal receive time as WebSocket close + // on client side might have flushed messages and client application may have + // lost connectivity earlier. + lastSignalAt := p.TransportManager.LastSeenSignalAt().Add(-3 * time.Second) + var recentlyDisconnectedParticipants []*livekit.ParticipantInfo p.updateLock.Lock() keys := p.updateCache.Keys() for i := len(keys) - 1; i >= 0; i-- { - if info, ok := p.updateCache.Get(keys[i]); ok { + // Peek, as Get would move each entry to the front and reverse the eviction order + if info, ok := p.updateCache.Peek(keys[i]); ok { if info.updatedAt.Before(lastSignalAt) { - break - } else if info.state == livekit.ParticipantInfo_DISCONNECTED { - disconnectedParticipants = append(disconnectedParticipants, &livekit.ParticipantInfo{ + continue + } + + if info.state == livekit.ParticipantInfo_DISCONNECTED { + recentlyDisconnectedParticipants = append(recentlyDisconnectedParticipants, &livekit.ParticipantInfo{ Sid: string(keys[i]), Identity: string(info.identity), Version: info.version, @@ -211,7 +286,7 @@ func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { } p.updateLock.Unlock() - return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(disconnectedParticipants)) + return recentlyDisconnectedParticipants } func (p *ParticipantImpl) sendICECandidate(ic *webrtc.ICECandidate, target livekit.SignalTarget) error { diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 74f7f3059..800d17e40 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -40,7 +40,6 @@ import ( "github.com/livekit/livekit-server/pkg/agent" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" @@ -48,6 +47,7 @@ import ( "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" sutils "github.com/livekit/livekit-server/pkg/utils" + "github.com/livekit/protocol/datatrack" ) const ( @@ -139,7 +139,7 @@ type Room struct { onParticipantChanged func(p types.Participant) onRoomUpdated func() - onClose func() + onClose func(types.RoomCloseReason) simulationLock sync.Mutex disconnectSignalOnResumeParticipants map[livekit.ParticipantIdentity]time.Time @@ -254,7 +254,7 @@ func NewRoom( egressLauncher EgressLauncher, ) *Room { r := &Room{ - protoRoom: utils.CloneProto(room), + protoRoom: proto.CloneOf(room), internal: internal, logger: LoggerWithRoom( logger.GetLogger().WithComponent(sutils.ComponentRoom), @@ -586,7 +586,7 @@ func (r *Room) ResumeParticipant( ClientConfiguration: p.GetClientConfiguration(), ServerInfo: r.serverInfo, LastMessageSeq: p.GetLastReliableSequence(false), - }); err != nil { + }, nil); err != nil { return err } @@ -802,12 +802,12 @@ func (r *Room) CloseIfEmpty() { r.lock.Unlock() if elapsed >= int64(timeout) { - r.Close(types.ParticipantCloseReasonRoomClosed) + r.Close(types.RoomCloseReasonIdleTimeout) r.logger.Infow("closing idle room", "reason", reason) } } -func (r *Room) Close(reason types.ParticipantCloseReason) { +func (r *Room) Close(reason types.RoomCloseReason) { r.lock.Lock() select { case <-r.closed: @@ -819,19 +819,20 @@ func (r *Room) Close(reason types.ParticipantCloseReason) { close(r.closed) r.lock.Unlock() - r.logger.Infow("closing room") + r.logger.Infow("closing room", "reason", reason) + participantCloseReason := reason.ToParticipantCloseReason() for _, p := range r.GetParticipants() { - _ = p.Close(true, reason, false) + _ = p.Close(true, participantCloseReason, false) } r.protoProxy.Stop() if r.onClose != nil { - r.onClose() + r.onClose(reason) } } -func (r *Room) OnClose(f func()) { +func (r *Room) OnClose(f func(types.RoomCloseReason)) { r.onClose = f } @@ -873,7 +874,7 @@ func (r *Room) GetAgentDispatches(dispatchID string) ([]*livekit.AgentDispatch, for _, ad := range r.agentDispatches { if dispatchID == "" || ad.Id == dispatchID { - ret = append(ret, utils.CloneProto(ad.AgentDispatch)) + ret = append(ret, proto.CloneOf(ad.AgentDispatch)) } } @@ -1430,6 +1431,9 @@ func (r *Room) RemoveParticipant( delete(r.hasPublished, identity) delete(r.launchedTrackEgresses, identity) delete(r.agentParticpants, identity) + // recorded while the participant is being removed, not after it is closed: + // the room is already empty to CloseIfEmpty, which runs every second + r.leftAt.Store(time.Now().Unix()) if !p.Hidden() { r.protoRoom.NumParticipants-- } @@ -1490,8 +1494,6 @@ func (r *Room) RemoveParticipant( // close participant as well _ = p.Close(true, reason, false) - r.leftAt.Store(time.Now().Unix()) - if sendUpdates { if r.onParticipantChanged != nil { r.onParticipantChanged(p) @@ -1586,7 +1588,7 @@ func (r *Room) sendSpeakerChanges(speakers []*livekit.SpeakerInfo) { func (r *Room) updateProto() *livekit.Room { r.lock.RLock() - room := utils.CloneProto(r.protoRoom) + room := proto.CloneOf(r.protoRoom) r.lock.RUnlock() room.NumPublishers = 0 @@ -1657,7 +1659,7 @@ func (r *Room) audioUpdateWorker() { // changedSpeakers need to include previous speakers that are no longer speaking for sid, speaker := range lastActiveMap { if nextActiveMap[sid] == nil { - inactiveSpeaker := utils.CloneProto(speaker) + inactiveSpeaker := proto.CloneOf(speaker) inactiveSpeaker.Level = 0 inactiveSpeaker.Active = false changedSpeakers = append(changedSpeakers, inactiveSpeaker) diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 85cdc2cc2..3c828ed6b 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -20,11 +20,11 @@ import ( "time" "github.com/stretchr/testify/require" + "go.uber.org/atomic" "google.golang.org/protobuf/proto" "github.com/livekit/protocol/auth/authfakes" "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/webhook" "github.com/livekit/livekit-server/version" @@ -51,11 +51,11 @@ const ( ) func init() { - conf, err := config.NewConfig("", true, nil, nil) - if err != nil { - panic(err) - } - config.InitLoggerFromConfig(&conf.Logging) + // The logger retains this config and locks it on every component level + // resolution, so it must not be DefaultConfig: NewConfig marshals that + // global from the goroutines these tests spin up. + loggingConf := config.LoggingConfig{PionLevel: config.DefaultConfig.Logging.PionLevel} + config.InitLoggerFromConfig(&loggingConf) roomUpdateInterval = defaultDelay } @@ -291,7 +291,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "last version is enqueued", pi: subscriber1v2, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, // clone the existing value since it can be modified when setting to disconnected + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v1)}, // clone the existing value since it can be modified when setting to disconnected validate: func(t *testing.T, rm *Room, _ []*ParticipantUpdate) { queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)] require.NotNil(t, queued) @@ -301,7 +301,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "latest version when immediate", pi: subscriber1v2, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v1)}, immediate: true, expected: []*ParticipantUpdate{{ParticipantInfo: subscriber1v2}}, validate: func(t *testing.T, rm *Room, _ []*ParticipantUpdate) { @@ -312,7 +312,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "out of order updates are rejected", pi: subscriber1v1, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v2)}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v2)}, validate: func(t *testing.T, rm *Room, updates []*ParticipantUpdate) { queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)] requirePIEquals(t, subscriber1v2, queued.ParticipantInfo) @@ -322,7 +322,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { name: "sid change is broadcasted immediately with synthsized disconnect", pi: publisher2, closeReason: types.ParticipantCloseReasonServiceRequestRemoveParticipant, // just to test if update contain the close reason - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v2), CloseReason: types.ParticipantCloseReasonStale}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v2), CloseReason: types.ParticipantCloseReasonStale}, expected: []*ParticipantUpdate{ { ParticipantInfo: &livekit.ParticipantInfo{ @@ -340,7 +340,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "when switching to publisher, queue is cleared", pi: publisher1v2, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v1)}, expected: []*ParticipantUpdate{{ParticipantInfo: publisher1v2}}, validate: func(t *testing.T, rm *Room, updates []*ParticipantUpdate) { require.Empty(t, rm.batchedUpdates) @@ -381,8 +381,10 @@ func TestRoomClosure(t *testing.T) { t.Run("room closes after participant leaves", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 1}) isClosed := false - rm.OnClose(func() { + var closeReason types.RoomCloseReason + rm.OnClose(func(reason types.RoomCloseReason) { isClosed = true + closeReason = reason }) p := rm.GetParticipants()[0] rm.lock.Lock() @@ -396,26 +398,81 @@ func TestRoomClosure(t *testing.T) { rm.CloseIfEmpty() require.Len(t, rm.GetParticipants(), 0) require.True(t, isClosed) + require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason) require.Equal(t, ErrRoomClosed, rm.Join(p, nil, nil, iceServersForRoom)) }) + // the idle check runs every second and can land while the last participant + // is still closing, when the room is empty but its departure has not been + // recorded yet + closeDuringRemove := func(t *testing.T, priorDeparture bool) bool { + rm := newRoomWithParticipants(t, testRoomOpts{num: 1}) + var isClosed atomic.Bool + rm.OnClose(func(types.RoomCloseReason) { + isClosed.Store(true) + }) + + rm.lock.Lock() + rm.protoRoom.DepartureTimeout = 20 + if priorDeparture { + // someone left earlier in the session, so a departure is recorded + rm.protoRoom.CreationTime = time.Now().Unix() + rm.leftAt.Store(time.Now().Add(-time.Minute).Unix()) + } else { + // older than the empty timeout, as a room in a call for a while is + rm.protoRoom.CreationTime = time.Now().Add(-10 * time.Minute).Unix() + } + rm.lock.Unlock() + + p := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant) + closing := make(chan struct{}) + release := make(chan struct{}) + p.CloseStub = func(bool, types.ParticipantCloseReason, bool) error { + close(closing) + <-release + return nil + } + + go rm.RemoveParticipant(p.Identity(), p.ID(), types.ParticipantCloseReasonClientRequestLeave) + + <-closing + require.Empty(t, rm.GetParticipants()) + rm.CloseIfEmpty() + close(release) + + return isClosed.Load() + } + + t.Run("room does not close while the last participant is closing", func(t *testing.T) { + require.False(t, closeDuringRemove(t, false)) + }) + + t.Run("room does not close while the last participant is closing, after an earlier departure", func(t *testing.T) { + require.False(t, closeDuringRemove(t, true)) + }) + t.Run("room does not close before empty timeout", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 0}) isClosed := false - rm.OnClose(func() { + var closeReason types.RoomCloseReason + rm.OnClose(func(reason types.RoomCloseReason) { isClosed = true + closeReason = reason }) require.NotZero(t, rm.protoRoom.EmptyTimeout) rm.CloseIfEmpty() require.False(t, isClosed) + require.Equal(t, types.RoomCloseReasonUnknown, closeReason) }) t.Run("room closes after empty timeout", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 0}) isClosed := false - rm.OnClose(func() { + var closeReason types.RoomCloseReason + rm.OnClose(func(reason types.RoomCloseReason) { isClosed = true + closeReason = reason }) rm.lock.Lock() rm.protoRoom.EmptyTimeout = 1 @@ -424,6 +481,7 @@ func TestRoomClosure(t *testing.T) { time.Sleep(1010 * time.Millisecond) rm.CloseIfEmpty() require.True(t, isClosed) + require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason) }) } @@ -465,7 +523,7 @@ func TestActiveSpeakers(t *testing.T) { audioUpdateDuration := (audioUpdateInterval + 10) * time.Millisecond t.Run("participant should not be getting audio updates (protocol 2)", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 1, protocol: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) p := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant) require.Empty(t, rm.GetActiveSpeakers()) @@ -477,7 +535,7 @@ func TestActiveSpeakers(t *testing.T) { t.Run("speakers should be sorted by loudness", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) p2 := participants[1].(*typesfakes.FakeLocalParticipant) @@ -492,7 +550,7 @@ func TestActiveSpeakers(t *testing.T) { t.Run("participants are getting audio updates (protocol 3+)", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2, protocol: 3}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) time.Sleep(time.Millisecond) // let the first update cycle run @@ -531,7 +589,7 @@ func TestActiveSpeakers(t *testing.T) { t.Run("audio level is smoothed", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2, protocol: 3, audioSmoothIntervals: 3}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) @@ -625,7 +683,7 @@ func TestDataChannel(t *testing.T) { mode := mode t.Run(modeNames[mode], func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 3}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) lpl := rm.LocalParticipantListener() @@ -642,7 +700,7 @@ func TestDataChannel(t *testing.T) { } setSource(mode, packet, p) - packetExp := utils.CloneProto(packet) + packetExp := proto.CloneOf(packet) if mode != legacySID { packetExp.ParticipantIdentity = string(p.Identity()) packetExp.GetUser().ParticipantIdentity = string(p.Identity()) @@ -671,7 +729,7 @@ func TestDataChannel(t *testing.T) { mode := mode t.Run(modeNames[mode], func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 4}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) lpl := rm.LocalParticipantListener() @@ -690,7 +748,7 @@ func TestDataChannel(t *testing.T) { setSource(mode, packet, p) setDest(mode, packet, p1) - packetExp := utils.CloneProto(packet) + packetExp := proto.CloneOf(packet) if mode != legacySID { packetExp.ParticipantIdentity = string(p.Identity()) packetExp.GetUser().ParticipantIdentity = string(p.Identity()) @@ -717,7 +775,7 @@ func TestDataChannel(t *testing.T) { t.Run("publishing disallowed", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) @@ -747,7 +805,7 @@ func TestDataChannel(t *testing.T) { func TestHiddenParticipants(t *testing.T) { t.Run("other participants don't receive hidden updates", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2, numHidden: 1}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) pNew := NewMockParticipant("new", types.CurrentProtocol, false, false, rm.LocalParticipantListener()) rm.Join(pNew, nil, nil, iceServersForRoom) @@ -779,7 +837,7 @@ func TestHiddenParticipants(t *testing.T) { func TestRoomUpdate(t *testing.T) { t.Run("updates are sent when participant joined", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 1}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) p1 := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant) require.Equal(t, 0, p1.SendRoomUpdateCallCount()) @@ -795,7 +853,7 @@ func TestRoomUpdate(t *testing.T) { t.Run("participants should receive metadata update", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) rm.SetMetadata("test metadata...") @@ -848,6 +906,7 @@ func newRoomWithParticipants(t *testing.T, opts testRoomOpts) *Room { telemetry.NewTelemetryService(n, &telemetryfakes.FakeAnalyticsService{}), nil, nil, nil, ) + t.Cleanup(func() { rm.Close(types.RoomCloseReasonUnknown) }) for i := 0; i < opts.num+opts.numHidden; i++ { identity := livekit.ParticipantIdentity(fmt.Sprintf("p%d", i)) participant := NewMockParticipant(identity, opts.protocol, i >= opts.num, true, rm.LocalParticipantListener()) diff --git a/pkg/rtc/signalling/interfaces.go b/pkg/rtc/signalling/interfaces.go index 99c3a0acc..2f8f59138 100644 --- a/pkg/rtc/signalling/interfaces.go +++ b/pkg/rtc/signalling/interfaces.go @@ -32,6 +32,13 @@ type ParticipantSignaller interface { GetResponseSink() routing.MessageSink CloseSignalConnection(reason types.SignallingCloseReason) + // HandshakePending reports whether a resumed connection is still waiting for the + // ReconnectResponse it has to open with + HandshakePending() bool + // OpenHandshake lets messages flow on a resumed connection that does not open with + // a ReconnectResponse + OpenHandshake() + WriteMessage(msg proto.Message) error } diff --git a/pkg/rtc/signalling/signalhandler.go b/pkg/rtc/signalling/signalhandler.go index 740e65899..fff79c8e6 100644 --- a/pkg/rtc/signalling/signalhandler.go +++ b/pkg/rtc/signalling/signalhandler.go @@ -94,6 +94,8 @@ func (s *signalhandler) HandleMessage(msg proto.Message) error { reason = types.ParticipantCloseReasonUserRejected case livekit.DisconnectReason_AGENT_ERROR: reason = types.ParticipantCloseReasonAgentError + case livekit.DisconnectReason_SIP_TRUNK_FAILURE: + reason = types.ParticipantCloseReasonSIPTrunkFailure } s.params.Logger.Debugw("client leaving room", "reason", reason) s.params.Participant.HandleLeaveRequest(reason) diff --git a/pkg/rtc/signalling/signallerasync.go b/pkg/rtc/signalling/signallerasync.go index 0dc0853ee..c82dbc582 100644 --- a/pkg/rtc/signalling/signallerasync.go +++ b/pkg/rtc/signalling/signallerasync.go @@ -31,8 +31,9 @@ import ( var _ ParticipantSignaller = (*signallerAsync)(nil) type SignallerAsyncParams struct { - Logger logger.Logger - Participant types.LocalParticipant + Logger logger.Logger + Participant types.LocalParticipant + OnHandshakeOpened func() } type signallerAsync struct { @@ -43,8 +44,11 @@ type signallerAsync struct { func NewSignallerAsync(params SignallerAsyncParams) ParticipantSignaller { return &signallerAsync{ - params: params, - signallerAsyncBase: newSignallerAsyncBase(signallerAsyncBaseParams{Logger: params.Logger}), + params: params, + signallerAsyncBase: newSignallerAsyncBase(signallerAsyncBaseParams{ + Logger: params.Logger, + OnHandshakeOpened: params.OnHandshakeOpened, + }), } } @@ -69,17 +73,35 @@ func (s *signallerAsync) WriteMessage(msg proto.Message) error { return nil } - if !s.params.Participant.IsReady() { - if typed, ok := msg.(*livekit.SignalResponse); !ok { - s.params.Logger.Warnw( - "unknown message type", nil, - "messageType", fmt.Sprintf("%T", msg), - ) - } else { - if typed.GetJoin() == nil { - return nil + // a signal connection opens with a join response, or with a reconnect response when + // it is resumed or migrated in. The client reads that first message as the handshake, + // so nothing may go out ahead of it. + isHandshake := false + isSdp := false + if typed, ok := msg.(*livekit.SignalResponse); !ok { + s.params.Logger.Warnw( + "unknown message type", nil, + "messageType", fmt.Sprintf("%T", msg), + ) + } else { + isHandshake = typed.GetJoin() != nil || typed.GetReconnect() != nil + isSdp = typed.GetOffer() != nil || typed.GetAnswer() != nil + } + + if !isHandshake && (!s.params.Participant.IsReady() || s.HandshakePending()) { + logFunc := s.params.Logger.Debugw + if isSdp { + // a dropped SDP leaves the negotiation waiting for the peer until the + // negotiation state machine recovers it, so make it visible + logFunc = func(msg string, keysAndValues ...any) { + s.params.Logger.Infow(msg, keysAndValues...) } } + logFunc( + "dropping message, connection has not opened yet", + "messageType", getMessageType(msg), + ) + return nil } sink := s.GetResponseSink() @@ -108,6 +130,10 @@ func (s *signallerAsync) WriteMessage(msg proto.Message) error { return err } } else { + if isHandshake { + // the connection is open, hold nothing back any more + s.OpenHandshake() + } s.params.Logger.Debugw("sent signal response", "response", logger.Proto(msg)) } return nil diff --git a/pkg/rtc/signalling/signallerasyncbase.go b/pkg/rtc/signalling/signallerasyncbase.go index 0b4d2b665..28bfc968d 100644 --- a/pkg/rtc/signalling/signallerasyncbase.go +++ b/pkg/rtc/signalling/signallerasyncbase.go @@ -16,6 +16,7 @@ package signalling import ( "sync" + "time" "github.com/livekit/protocol/logger" @@ -25,8 +26,16 @@ import ( type signallerAsyncBaseParams struct { Logger logger.Logger + // called when the connection opens, i. e. when messages held back for the + // handshake may be sent + OnHandshakeOpened func() } +// how long a resumed connection holds messages back waiting for its ReconnectResponse. +// A path that resumes without sending one lets messages flow after this instead of +// holding them back for the rest of the session. +const handshakeWindow = 5 * time.Second + type signallerAsyncBase struct { signallerUnimplemented @@ -34,6 +43,13 @@ type signallerAsyncBase struct { resSinkMu sync.Mutex resSink routing.MessageSink + // set while a resumed connection holds messages back waiting for its + // ReconnectResponse, the timer opens the connection if none is written. + // handshakeGeneration tells a timeout whether it belongs to the current wait, a + // Stop cannot cancel a callback that has already started running. + handshakePending bool + handshakeGeneration uint32 + handshakeTimer *time.Timer } func newSignallerAsyncBase(params signallerAsyncBaseParams) *signallerAsyncBase { @@ -46,8 +62,24 @@ func (s *signallerAsyncBase) SwapResponseSink(sink routing.MessageSink, reason t s.resSinkMu.Lock() oldSink := s.resSink s.resSink = sink + // a resumed connection has to open with the ReconnectResponse, the client takes it + // only as the first message it reads + opened := false + switch { + case sink == nil: + // the connection is gone, keep anything queued for the next one + s.disarmHandshakeLocked() + case reason == types.SignallingCloseReasonResume: + s.armHandshakeLocked() + default: + opened = s.disarmHandshakeLocked() + } s.resSinkMu.Unlock() + if opened { + s.notifyHandshakeOpened() + } + if oldSink != nil { if sink != nil { s.params.Logger.Debugw( @@ -67,6 +99,80 @@ func (s *signallerAsyncBase) SwapResponseSink(sink routing.MessageSink, reason t } } +// HandshakePending is a plain read, so a caller can decide to hold a message back +// while holding its own lock +func (s *signallerAsyncBase) HandshakePending() bool { + s.resSinkMu.Lock() + defer s.resSinkMu.Unlock() + + return s.handshakePending +} + +func (s *signallerAsyncBase) OpenHandshake() { + s.resSinkMu.Lock() + opened := s.disarmHandshakeLocked() + s.resSinkMu.Unlock() + + if opened { + s.notifyHandshakeOpened() + } +} + +func (s *signallerAsyncBase) armHandshakeLocked() { + s.stopHandshakeTimerLocked() + + s.handshakePending = true + s.handshakeGeneration++ + generation := s.handshakeGeneration + // the connection opens on its own if nothing writes a ReconnectResponse, a path + // that resumes without one should not hold messages back for the whole session + s.handshakeTimer = time.AfterFunc(handshakeWindow, func() { s.onHandshakeTimeout(generation) }) +} + +// disarmHandshakeLocked reports whether it opened a connection that was holding +// messages back +func (s *signallerAsyncBase) disarmHandshakeLocked() bool { + s.stopHandshakeTimerLocked() + + wasPending := s.handshakePending + s.handshakePending = false + // a timeout of the wait that just ended is stale + s.handshakeGeneration++ + return wasPending +} + +func (s *signallerAsyncBase) stopHandshakeTimerLocked() { + if s.handshakeTimer != nil { + s.handshakeTimer.Stop() + s.handshakeTimer = nil + } +} + +func (s *signallerAsyncBase) onHandshakeTimeout(generation uint32) { + s.resSinkMu.Lock() + if generation != s.handshakeGeneration { + // the wait this timeout was armed for has ended, a later one may be in progress + s.resSinkMu.Unlock() + return + } + opened := s.disarmHandshakeLocked() + s.resSinkMu.Unlock() + + if !opened { + return + } + + s.params.Logger.Warnw("resumed connection did not open with a ReconnectResponse", nil) + s.notifyHandshakeOpened() +} + +// notifyHandshakeOpened runs without resSinkMu held, the callback sends messages +func (s *signallerAsyncBase) notifyHandshakeOpened() { + if s.params.OnHandshakeOpened != nil { + s.params.OnHandshakeOpened() + } +} + func (s *signallerAsyncBase) GetResponseSink() routing.MessageSink { s.resSinkMu.Lock() defer s.resSinkMu.Unlock() diff --git a/pkg/rtc/signalling/signallerasyncbase_test.go b/pkg/rtc/signalling/signallerasyncbase_test.go new file mode 100644 index 000000000..1d91a4c7e --- /dev/null +++ b/pkg/rtc/signalling/signallerasyncbase_test.go @@ -0,0 +1,157 @@ +package signalling + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/routing/routingfakes" + "github.com/livekit/livekit-server/pkg/rtc/types" +) + +func newTestSignallerBase(onHandshakeOpened func()) *signallerAsyncBase { + return newSignallerAsyncBase(signallerAsyncBaseParams{ + Logger: logger.GetLogger(), + OnHandshakeOpened: onHandshakeOpened, + }) +} + +// currentHandshakeGeneration is what a timeout armed right now would be tagged with +func currentHandshakeGeneration(s *signallerAsyncBase) uint32 { + s.resSinkMu.Lock() + defer s.resSinkMu.Unlock() + + return s.handshakeGeneration +} + +func TestHandshakeGate(t *testing.T) { + t.Run("a resumed connection holds messages back", func(t *testing.T) { + s := newTestSignallerBase(nil) + require.False(t, s.HandshakePending()) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.OpenHandshake() + require.False(t, s.HandshakePending()) + }) + + t.Run("other sink swaps do not hold messages back", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonUnknown) + require.False(t, s.HandshakePending()) + }) + + t.Run("closing the connection clears the gate", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.CloseSignalConnection(types.SignallingCloseReasonParticipantClose) + require.False(t, s.HandshakePending()) + }) + + t.Run("the handshake window opens the gate", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.onHandshakeTimeout(currentHandshakeGeneration(s)) + require.False(t, s.HandshakePending()) + }) + + t.Run("a swap to a fresh connection opens the gate", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonUnknown) + require.False(t, s.HandshakePending()) + }) + + t.Run("a timeout of a wait that has ended does nothing", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + stale := currentHandshakeGeneration(s) + + // the client resumes again while the first timeout is running, Stop cannot + // cancel a callback that has already started + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + s.onHandshakeTimeout(stale) + + require.True(t, s.HandshakePending()) + require.Zero(t, opened.Load()) + + // and the wait in progress still opens on its own timeout + s.onHandshakeTimeout(currentHandshakeGeneration(s)) + require.False(t, s.HandshakePending()) + require.EqualValues(t, 1, opened.Load()) + }) +} + +func TestHandshakeGateNotifiesOnOpen(t *testing.T) { + t.Run("on an explicit open", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.Zero(t, opened.Load()) + + s.OpenHandshake() + require.EqualValues(t, 1, opened.Load()) + + // only the transition notifies + s.OpenHandshake() + require.EqualValues(t, 1, opened.Load()) + }) + + t.Run("on the handshake window expiring", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + s.onHandshakeTimeout(currentHandshakeGeneration(s)) + + require.False(t, s.HandshakePending()) + require.EqualValues(t, 1, opened.Load()) + }) + + t.Run("the window fires without anything else touching the gate", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.Eventually(t, func() bool { + return !s.HandshakePending() && opened.Load() == 1 + }, 2*handshakeWindow, 100*time.Millisecond) + }) + + t.Run("not on a connection close", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + s.CloseSignalConnection(types.SignallingCloseReasonParticipantClose) + + require.False(t, s.HandshakePending()) + require.Zero(t, opened.Load()) + }) + + t.Run("not when the gate was never armed", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.OpenHandshake() + require.Zero(t, opened.Load()) + }) +} diff --git a/pkg/rtc/signalling/signallerunimplemented.go b/pkg/rtc/signalling/signallerunimplemented.go index 3592fd857..ae160a74f 100644 --- a/pkg/rtc/signalling/signallerunimplemented.go +++ b/pkg/rtc/signalling/signallerunimplemented.go @@ -34,6 +34,12 @@ func (u *signallerUnimplemented) GetResponseSink() routing.MessageSink { func (u *signallerUnimplemented) CloseSignalConnection(reason types.SignallingCloseReason) {} +func (u *signallerUnimplemented) HandshakePending() bool { + return false +} + +func (u *signallerUnimplemented) OpenHandshake() {} + func (u *signallerUnimplemented) WriteMessage(msg proto.Message) error { return nil } diff --git a/pkg/rtc/subscribedtrack.go b/pkg/rtc/subscribedtrack.go index 27d18c382..1771767d6 100644 --- a/pkg/rtc/subscribedtrack.go +++ b/pkg/rtc/subscribedtrack.go @@ -323,7 +323,7 @@ func (t *SubscribedTrack) UpdateSubscriberSettings(settings *livekit.UpdateTrack } isImmediate = isImmediate || (!settings.Disabled && settings.Disabled != t.isMutedLocked()) - t.settings = utils.CloneProto(settings) + t.settings = proto.CloneOf(settings) t.logger.Debugw("saving subscriber track settings", "settings", logger.Proto(t.settings)) t.settingsLock.Unlock() diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index 3b5ddd849..52f52a1d0 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -80,6 +80,10 @@ type SubscriptionManager struct { closeCh chan struct{} doneCh chan struct{} + // set when a queued reconcile is dropped, the worker then reconciles everything + reconcileOverflow atomic.Bool + reconcileDataTrackOverflow atomic.Bool + onSubscribeStatusChanged func(publisherID livekit.ParticipantID, subscribed bool) dataTrackSubscriptions map[livekit.TrackID]*dataTrackSubscription @@ -189,19 +193,19 @@ func (m *SubscriptionManager) SubscribeToTrack(trackID livekit.TrackID, isSync b return } - sub, desireChanged := m.setDesired(trackID, true) - if sub == nil { + // find or create and set desired under one lock, so that a concurrent subscribe, + // settings update or cleanup cannot replace or remove the subscription in between + m.lock.Lock() + sub, ok := m.subscriptions[trackID] + if !ok { sLogger := m.params.Logger.WithValues( "trackID", trackID, ) sub = newMediaTrackSubscription(m.params.Participant.ID(), trackID, sLogger) - - m.lock.Lock() m.subscriptions[trackID] = sub - m.lock.Unlock() - - sub, desireChanged = m.setDesired(trackID, true) } + desireChanged := sub.setDesired(true) + m.lock.Unlock() if desireChanged { sub.logger.Debugw("subscribing to track") } @@ -234,19 +238,18 @@ func (m *SubscriptionManager) SubscribeToDataTrack(trackID livekit.TrackID) { return } - sub, desireChanged := m.setDataTrackDesired(trackID, true) - if sub == nil { + // same as SubscribeToTrack, find or create and set desired under one lock + m.lock.Lock() + sub, ok := m.dataTrackSubscriptions[trackID] + if !ok { sLogger := m.params.Logger.WithValues( "trackID", trackID, ) sub = newDataTrackSubscription(m.params.Participant.ID(), trackID, sLogger) - - m.lock.Lock() m.dataTrackSubscriptions[trackID] = sub - m.lock.Unlock() - - sub, desireChanged = m.setDataTrackDesired(trackID, true) } + desireChanged := sub.setDesired(true) + m.lock.Unlock() if desireChanged { sub.logger.Debugw("subscribing to data track") } @@ -307,6 +310,19 @@ func (m *SubscriptionManager) GetSubscribedTracks() []types.SubscribedTrack { return tracks } +func (m *SubscriptionManager) GetSubscribedDataTracks() []types.DataDownTrack { + m.lock.RLock() + defer m.lock.RUnlock() + + tracks := make([]types.DataDownTrack, 0, len(m.dataTrackSubscriptions)) + for _, s := range m.dataTrackSubscriptions { + if dt := s.getDataDownTrack(); dt != nil { + tracks = append(tracks, dt) + } + } + return tracks +} + func (m *SubscriptionManager) IsTrackNameSubscribed(publisherIdentity livekit.ParticipantIdentity, trackName string) bool { m.lock.RLock() defer m.lock.RUnlock() @@ -376,6 +392,7 @@ func (m *SubscriptionManager) UpdateSubscribedTrackSettings(trackID livekit.Trac sub = newMediaTrackSubscription(m.params.Participant.ID(), trackID, sLogger) m.subscriptions[trackID] = sub } + sub.keepForSubscribe() m.lock.Unlock() sub.setSettings(settings) @@ -391,6 +408,7 @@ func (m *SubscriptionManager) UpdateDataTrackSubscriptionOptions(trackID livekit sub = newDataTrackSubscription(m.params.Participant.ID(), trackID, sLogger) m.dataTrackSubscriptions[trackID] = sub } + sub.keepForSubscribe() m.lock.Unlock() sub.setSubscriptionOptions(subscriptionOptions) @@ -588,7 +606,7 @@ func (m *SubscriptionManager) reconcileSubscription(s *mediaTrackSubscription) { } m.lock.Lock() - if s.needsCleanup() { + if m.subscriptions[s.trackID] == s && s.needsCleanup() { s.logger.Debugw("cleanup removing subscription") delete(m.subscriptions, s.trackID) } @@ -599,7 +617,7 @@ func (m *SubscriptionManager) reconcileDataTrackSubscriptions() { var needsToReconcile []*dataTrackSubscription m.lock.RLock() for _, sub := range m.dataTrackSubscriptions { - if sub.needsSubscribe() || sub.needsUnsubscribe() { + if sub.needsSubscribe() || sub.needsUnsubscribe() || sub.needsCleanup() { needsToReconcile = append(needsToReconcile, sub) } } @@ -630,7 +648,7 @@ func (m *SubscriptionManager) reconcileDataTrackSubscription(s *dataTrackSubscri if s.durationSinceStart() > notFoundTimeout { s.logger.Infow("unsubscribing from data track after notFoundTimeout", "error", err) s.setDesired(false) - m.queueReconcile(s.trackID) + m.queueReconcileDataTrack(s.trackID) } default: // all other errors @@ -660,20 +678,31 @@ func (m *SubscriptionManager) reconcileDataTrackSubscription(s *dataTrackSubscri s.logger.Warnw("failed to unsubscribe", err) } + // a subscribe during the removal sets the entry as desired again, keep it then m.lock.Lock() - delete(m.dataTrackSubscriptions, s.trackID) + removed := m.dataTrackSubscriptions[s.trackID] == s && !s.isDesired() + if removed { + delete(m.dataTrackSubscriptions, s.trackID) + } m.lock.Unlock() m.notifyDataTrackSubscriberHandles() + if !removed { + m.queueReconcileDataTrack(s.trackID) + } return } m.lock.Lock() - if s.needsCleanup() { + cleanedUp := m.dataTrackSubscriptions[s.trackID] == s && s.needsCleanup() + if cleanedUp { s.logger.Debugw("cleanup removing data track subscription") delete(m.dataTrackSubscriptions, s.trackID) - m.notifyDataTrackSubscriberHandles() } m.lock.Unlock() + + if cleanedUp { + m.notifyDataTrackSubscriberHandles() + } } // trigger an immediate reconciliation, when trackID is empty, will reconcile all subscriptions @@ -681,7 +710,8 @@ func (m *SubscriptionManager) queueReconcile(trackID livekit.TrackID) { select { case m.reconcileCh <- trackID: default: - // queue is full, will reconcile based on timer + // queue is full, the worker reconciles everything after its next queued item + m.reconcileOverflow.Store(true) } } @@ -689,7 +719,7 @@ func (m *SubscriptionManager) queueReconcileDataTrack(trackID livekit.TrackID) { select { case m.reconcileDataTrackCh <- trackID: default: - // queue is full, will reconcile based on timer + m.reconcileDataTrackOverflow.Store(true) } } @@ -717,6 +747,9 @@ func (m *SubscriptionManager) reconcileWorker() { } else { m.reconcileSubscriptions() } + if m.reconcileOverflow.Swap(false) { + m.reconcileSubscriptions() + } case trackID := <-m.reconcileDataTrackCh: m.lock.Lock() @@ -727,6 +760,9 @@ func (m *SubscriptionManager) reconcileWorker() { } else { m.reconcileDataTrackSubscriptions() } + if m.reconcileDataTrackOverflow.Swap(false) { + m.reconcileDataTrackSubscriptions() + } } } } @@ -1222,7 +1258,7 @@ func (m *SubscriptionManager) unmarkSubscribedTo(publisherID livekit.Participant } m.lock.Unlock() if changedCB != nil && lastSubscription { - go changedCB(publisherID, false) + changedCB(publisherID, false) } } @@ -1263,6 +1299,10 @@ type trackSubscription struct { // the timestamp when the subscription was started, will be reset when downtrack is closed with expected resume subscribeAt atomic.Pointer[time.Time] + + // an entry that is not desired is not cleaned up before this time, + // so that settings sent before a subscribe are there when the subscribe comes + keepUntil time.Time } // set permission and return true if it has changed @@ -1314,6 +1354,7 @@ func (s *trackSubscription) setDesired(desired bool) bool { t := time.Now() s.subStartedAt.Store(&t) s.subscribeAt.Store(&t) + s.keepUntil = time.Time{} } if s.desired == desired { @@ -1355,6 +1396,18 @@ func (s *trackSubscription) getNumAttempts() int32 { return s.numAttempts.Load() } +func (s *trackSubscription) keepForSubscribe() { + s.lock.Lock() + defer s.lock.Unlock() + if !s.desired { + s.keepUntil = time.Now().Add(notFoundTimeout) + } +} + +func (s *trackSubscription) isKeptLocked() bool { + return time.Now().Before(s.keepUntil) +} + func (s *trackSubscription) durationSinceStart() time.Duration { t := s.subStartedAt.Load() if t == nil { @@ -1587,7 +1640,7 @@ func (s *mediaTrackSubscription) needsBind() bool { func (s *mediaTrackSubscription) needsCleanup() bool { s.lock.RLock() defer s.lock.RUnlock() - return !s.desired && s.subscribedTrack == nil + return !s.desired && s.subscribedTrack == nil && !s.isKeptLocked() } // ----------------------------------------------------------------- @@ -1628,7 +1681,7 @@ func (s *dataTrackSubscription) needsUnsubscribe() bool { func (s *dataTrackSubscription) needsCleanup() bool { s.lock.RLock() defer s.lock.RUnlock() - return !s.desired && s.dataDownTrack == nil + return !s.desired && s.dataDownTrack == nil && !s.isKeptLocked() } func (s *dataTrackSubscription) setDataDownTrack(dataDownTrack types.DataDownTrack) { diff --git a/pkg/rtc/subscriptionmanager_test.go b/pkg/rtc/subscriptionmanager_test.go index 28b1a4864..e184b7fae 100644 --- a/pkg/rtc/subscriptionmanager_test.go +++ b/pkg/rtc/subscriptionmanager_test.go @@ -17,6 +17,7 @@ package rtc import ( + "fmt" "sync" "testing" "time" @@ -113,7 +114,6 @@ func TestSubscribe(t *testing.T) { require.Eventually(t, func() bool { return numParticipantSubscribed.Load() == 2 }, subSettleTimeout, subCheckInterval, "participant subscribe status was not updated twice") - // the unsubscribed callback is delivered on a goroutine of its own require.Eventually(t, func() bool { return numParticipantUnsubscribed.Load() == 1 }, subSettleTimeout, subCheckInterval, "participant unsubscribe status was not updated") @@ -354,6 +354,226 @@ func TestUpdateSettingsBeforeSubscription(t *testing.T) { require.Equal(t, settings.Height, applied.Height) } +func TestConcurrentFirstSubscribe(t *testing.T) { + settings := &livekit.UpdateTrackSettings{Width: 100, Height: 100} + for range 200 { + sm := newTestSubscriptionManager() + + var lock sync.Mutex + var subscribed bool + mt := &typesfakes.FakeMediaTrack{} + mt.IDReturns("track") + mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) { + lock.Lock() + defer lock.Unlock() + if subscribed { + return nil, errAlreadySubscribed + } + subscribed = true + st := &typesfakes.FakeSubscribedTrack{} + st.IDReturns("track") + st.MediaTrackReturns(mt) + return st, nil + }) + sm.params.TrackResolver = func(types.LocalParticipant, livekit.TrackID) types.MediaResolverResult { + return types.MediaResolverResult{ + Track: mt, + HasPermission: true, + PublisherID: "pubID", + PublisherIdentity: "pub", + TrackChangedNotifier: utils.NewChangeNotifier(), + TrackRemovedNotifier: utils.NewChangeNotifier(), + } + } + + // two first subscribes and a settings update race to create the subscription + var start, done sync.WaitGroup + start.Add(1) + for _, f := range []func(){ + func() { sm.SubscribeToTrack("track", false) }, + func() { sm.SubscribeToTrack("track", false) }, + func() { sm.UpdateSubscribedTrackSettings("track", settings) }, + } { + done.Add(1) + go func() { + defer done.Done() + start.Wait() + f() + }() + } + start.Done() + done.Wait() + + sm.lock.RLock() + s := sm.subscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return !s.needsSubscribe() + }, subSettleTimeout, subCheckInterval, "the subscription that is kept should own the down track") + st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack) + require.Eventually(t, func() bool { + n := st.UpdateSubscriberSettingsCallCount() + if n == 0 { + return false + } + applied, _ := st.UpdateSubscriberSettingsArgsForCall(n - 1) + return applied == settings + }, subSettleTimeout, subCheckInterval, "the down track should get the settings") + + sm.Close(false) + } +} + +func TestSettingsKeptForSubscribe(t *testing.T) { + t.Run("media", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestResolver(true, true, "pub", "pubID") + sm.params.TrackResolver = resolver.Resolve + + settings := &livekit.UpdateTrackSettings{Disabled: true} + sm.UpdateSubscribedTrackSettings("track", settings) + // a cleanup pass between the settings and the subscribe must not drop the settings + sm.reconcileSubscriptions() + sm.SubscribeToTrack("track", false) + + sm.lock.RLock() + s := sm.subscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return !s.needsSubscribe() + }, subSettleTimeout, subCheckInterval, "track should be subscribed") + st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack) + require.Eventually(t, func() bool { + n := st.UpdateSubscriberSettingsCallCount() + if n == 0 { + return false + } + applied, _ := st.UpdateSubscriberSettingsArgsForCall(n - 1) + return applied == settings + }, subSettleTimeout, subCheckInterval, "the down track should get the settings") + + // settings for a track that is never subscribed are cleaned up after notFoundTimeout + sm.UpdateSubscribedTrackSettings("other", settings) + require.Eventually(t, func() bool { + sm.lock.RLock() + defer sm.lock.RUnlock() + _, ok := sm.subscriptions["other"] + return !ok + }, subSettleTimeout, subCheckInterval, "unused settings should be cleaned up") + }) + + t.Run("data", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestDataTrackResolver(true, true, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + fps := uint32(5) + options := &livekit.DataTrackSubscriptionOptions{TargetFps: &fps} + sm.UpdateDataTrackSubscriptionOptions("track", options) + // a cleanup pass between the options and the subscribe must not drop the options + sm.reconcileDataTrackSubscriptions() + sm.SubscribeToDataTrack("track") + + sm.lock.RLock() + s := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return s.getDataDownTrack() != nil + }, subSettleTimeout, subCheckInterval, "data track should be subscribed") + ddt := s.getDataDownTrack().(*typesfakes.FakeDataDownTrack) + n := ddt.UpdateSubscriptionOptionsCallCount() + require.NotZero(t, n) + require.Equal(t, options, ddt.UpdateSubscriptionOptionsArgsForCall(n-1)) + }) +} + +func TestSubscribeBurstOverflowsQueue(t *testing.T) { + // a dropped reconcile must not wait for the ticker + saved := reconcileInterval + reconcileInterval = time.Hour + defer func() { reconcileInterval = saved }() + + t.Run("media", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + + // the first subscribe holds the worker until all are queued, so the queue overflows + release := make(chan struct{}) + var holdOnce sync.Once + sm.params.TrackResolver = func(_ types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult { + mt := &typesfakes.FakeMediaTrack{} + mt.IDReturns(trackID) + mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) { + holdOnce.Do(func() { <-release }) + st := &typesfakes.FakeSubscribedTrack{} + st.IDReturns(trackID) + st.MediaTrackReturns(mt) + return st, nil + }) + return types.MediaResolverResult{ + Track: mt, + HasPermission: true, + PublisherID: "pubID", + PublisherIdentity: "pub", + TrackChangedNotifier: utils.NewChangeNotifier(), + TrackRemovedNotifier: utils.NewChangeNotifier(), + } + } + + numTracks := cap(sm.reconcileCh) + 10 + for i := range numTracks { + sm.SubscribeToTrack(livekit.TrackID(fmt.Sprintf("TR_%d", i)), false) + } + close(release) + + require.Eventually(t, func() bool { + sm.lock.RLock() + defer sm.lock.RUnlock() + for _, s := range sm.subscriptions { + if s.needsSubscribe() { + return false + } + } + return len(sm.subscriptions) == numTracks + }, subSettleTimeout, subCheckInterval, "every track should be subscribed") + }) + + t.Run("data", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestDataTrackResolver(true, true, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + release := make(chan struct{}) + var holdOnce sync.Once + resolver.dataTrack.AddSubscriberCalls(func(types.LocalParticipant) (types.DataDownTrack, error) { + holdOnce.Do(func() { <-release }) + ddt := &typesfakes.FakeDataDownTrack{} + ddt.PublishDataTrackReturns(resolver.dataTrack) + return ddt, nil + }) + + numTracks := cap(sm.reconcileDataTrackCh) + 10 + for i := range numTracks { + sm.SubscribeToDataTrack(livekit.TrackID(fmt.Sprintf("DTR_%d", i))) + } + close(release) + + require.Eventually(t, func() bool { + sm.lock.RLock() + defer sm.lock.RUnlock() + for _, s := range sm.dataTrackSubscriptions { + if s.needsSubscribe() { + return false + } + } + return len(sm.dataTrackSubscriptions) == numTracks + }, subSettleTimeout, subCheckInterval, "every data track should be subscribed") + }) +} + func TestSubscriptionLimits(t *testing.T) { sm := newTestSubscriptionManagerWithParams(testSubscriptionParams{ SubscriptionLimitAudio: 1, @@ -527,6 +747,64 @@ func TestSubscribeDataTrack(t *testing.T) { }, subSettleTimeout, subCheckInterval, "should be resubscribed") require.Equal(t, 2, resolver.dataTrack.AddSubscriberCallCount()) }) + + t.Run("subscribe again during unsubscribe", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestDataTrackResolver(true, true, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + sm.SubscribeToDataTrack("track") + sm.lock.RLock() + s := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return !s.needsSubscribe() + }, subSettleTimeout, subCheckInterval, "should be subscribed") + + // the client subscribes again while the removal runs, + // then the down track closes, as DataTrack.RemoveSubscriber does + ddt := s.getDataDownTrack().(*typesfakes.FakeDataDownTrack) + resolver.dataTrack.RemoveSubscriberCalls(func(livekit.ParticipantID) { + sm.SubscribeToDataTrack("track") + ddt.OnCloseArgsForCall(0)() + }) + sm.UnsubscribeFromDataTrack("track") + + require.Eventually(t, func() bool { + return resolver.dataTrack.AddSubscriberCallCount() == 2 && s.getDataDownTrack() != nil + }, subSettleTimeout, subCheckInterval, "should be subscribed again") + sm.lock.RLock() + require.Same(t, s, sm.dataTrackSubscriptions["track"]) + sm.lock.RUnlock() + }) + + t.Run("unsubscribe before data track resolves", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + // no track available, subscribe attempts fail with ErrTrackNotFound + resolver := newTestDataTrackResolver(true, false, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + sm.SubscribeToDataTrack("track") + sm.lock.RLock() + s := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + require.NotNil(t, s) + + // let the worker attempt (and fail) the subscribe + require.Eventually(t, func() bool { + return s.getNumAttempts() > 0 + }, subSettleTimeout, subCheckInterval, "no subscribe attempt was made") + + sm.UnsubscribeFromDataTrack("track") + require.Eventually(t, func() bool { + sm.lock.RLock() + _, ok := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + return !ok + }, subSettleTimeout, subCheckInterval, "data track subscription was not cleaned up") + }) } type testSubscriptionParams struct { diff --git a/pkg/rtc/supervisor/participant_supervisor.go b/pkg/rtc/supervisor/participant_supervisor.go index 1dc6b9d32..cb960aadf 100644 --- a/pkg/rtc/supervisor/participant_supervisor.go +++ b/pkg/rtc/supervisor/participant_supervisor.go @@ -108,6 +108,56 @@ func (p *ParticipantSupervisor) AddPublication(trackID livekit.TrackID) { p.lock.Unlock() } +// RemovePublication drops the monitor of a pending publication. A monitor that still +// holds a published track is kept, so a live track never loses its monitor. +func (p *ParticipantSupervisor) RemovePublication(trackID livekit.TrackID) { + p.lock.Lock() + defer p.lock.Unlock() + + pm, ok := p.publications[trackID] + if !ok { + return + } + if pub, ok := pm.opMon.(*PublicationMonitor); ok && pub.hasPublishedTrack() { + p.params.Logger.Warnw( + "supervisor: not removing publication with a published track", nil, + "trackID", trackID, + ) + return + } + delete(p.publications, trackID) +} + +// TimedOutPublications returns the track IDs whose pending publish timed out. +func (p *ParticipantSupervisor) TimedOutPublications() []livekit.TrackID { + p.lock.RLock() + defer p.lock.RUnlock() + + var trackIDs []livekit.TrackID + for trackID, pm := range p.publications { + // pm.err is refreshed once per tick, check again so a track that published since is skipped + if pm.err != nil && pm.opMon.Check() != nil { + trackIDs = append(trackIDs, trackID) + } + } + return trackIDs +} + +// SetPublicationTimedOutForTest puts a publication in the state checkPublications reports +// once the publish wait passes. Only for tests outside this package. +func (p *ParticipantSupervisor) SetPublicationTimedOutForTest(trackID livekit.TrackID) { + p.lock.Lock() + if pm, ok := p.publications[trackID]; ok { + if pub, ok := pm.opMon.(*PublicationMonitor); ok { + pub.lock.Lock() + pub.unmutedAt = time.Now().Add(-2 * publishWaitDuration) + pub.lock.Unlock() + } + pm.err = errPublishTimeout + } + p.lock.Unlock() +} + func (p *ParticipantSupervisor) SetPublicationMute(trackID livekit.TrackID, isMuted bool) { p.lock.Lock() pm, ok := p.publications[trackID] @@ -149,7 +199,7 @@ func (p *ParticipantSupervisor) checkState() { func (p *ParticipantSupervisor) checkPublications() { var erroredPublications []livekit.TrackID var removablePublications []livekit.TrackID - p.lock.RLock() + p.lock.Lock() for trackID, pm := range p.publications { if err := pm.opMon.Check(); err != nil { if pm.err == nil { @@ -167,9 +217,6 @@ func (p *ParticipantSupervisor) checkPublications() { } } } - p.lock.RUnlock() - - p.lock.Lock() for _, trackID := range removablePublications { delete(p.publications, trackID) } diff --git a/pkg/rtc/supervisor/participant_supervisor_test.go b/pkg/rtc/supervisor/participant_supervisor_test.go new file mode 100644 index 000000000..c07cbda41 --- /dev/null +++ b/pkg/rtc/supervisor/participant_supervisor_test.go @@ -0,0 +1,112 @@ +// 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 supervisor + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// newSupervisorForTest skips the ticker goroutine, tests call checkPublications directly +func newSupervisorForTest() *ParticipantSupervisor { + s := &ParticipantSupervisor{ + params: ParticipantSupervisorParams{Logger: logger.GetLogger()}, + publications: make(map[livekit.TrackID]*trackMonitor), + } + s.SetPublisherPeerConnectionConnected(true) + return s +} + +func addUnmutedPublication(s *ParticipantSupervisor, trackID livekit.TrackID) { + s.AddPublication(trackID) + s.SetPublicationMute(trackID, false) +} + +// agePublication moves the publish window start back past the publish wait +func agePublication(s *ParticipantSupervisor, trackID livekit.TrackID) { + pm := s.publications[trackID].opMon.(*PublicationMonitor) + pm.lock.Lock() + pm.unmutedAt = pm.unmutedAt.Add(-2 * publishWaitDuration) + pm.lock.Unlock() +} + +func TestTimedOutPublications(t *testing.T) { + const trackID = livekit.TrackID("TR_test") + + t.Run("abandoned publication is reported and can be removed", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + + s.checkPublications() + require.Empty(t, s.TimedOutPublications()) + + agePublication(s, trackID) + s.checkPublications() + require.Equal(t, []livekit.TrackID{trackID}, s.TimedOutPublications()) + + s.RemovePublication(trackID) + require.Empty(t, s.TimedOutPublications()) + require.Empty(t, s.publications) + }) + + t.Run("muted publication is not reported", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + s.SetPublicationMute(trackID, true) + + agePublication(s, trackID) + s.checkPublications() + require.Empty(t, s.TimedOutPublications()) + }) + + t.Run("stale timeout is not reported once the track publishes", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + addUnmutedPublication(s, trackID) + + agePublication(s, trackID) + s.checkPublications() + require.Equal(t, []livekit.TrackID{trackID}, s.TimedOutPublications()) + + // media arrives late, before the next tick clears the cached error + s.SetPublishedTrack(trackID, &typesfakes.FakeLocalMediaTrack{}) + require.Empty(t, s.TimedOutPublications()) + }) + + t.Run("publication with a published track is not removed", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + s.SetPublishedTrack(trackID, &typesfakes.FakeLocalMediaTrack{}) + + s.RemovePublication(trackID) + require.Contains(t, s.publications, trackID) + }) + + t.Run("live publication with a queued successor is not reported", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + addUnmutedPublication(s, trackID) // queued successor shares the track id + s.SetPublishedTrack(trackID, &typesfakes.FakeLocalMediaTrack{}) + + agePublication(s, trackID) + s.checkPublications() + require.Empty(t, s.TimedOutPublications()) + }) +} diff --git a/pkg/rtc/supervisor/publication_monitor.go b/pkg/rtc/supervisor/publication_monitor.go index 199d2188f..572c2e2a7 100644 --- a/pkg/rtc/supervisor/publication_monitor.go +++ b/pkg/rtc/supervisor/publication_monitor.go @@ -163,6 +163,13 @@ func (p *PublicationMonitor) Check() error { return nil } +func (p *PublicationMonitor) hasPublishedTrack() bool { + p.lock.RLock() + defer p.lock.RUnlock() + + return p.publishedTrack != nil +} + func (p *PublicationMonitor) IsIdle() bool { p.lock.RLock() defer p.lock.RUnlock() @@ -170,7 +177,10 @@ func (p *PublicationMonitor) IsIdle() bool { return p.desiredPublishes.Len() == 0 && p.publishedTrack == nil } +// update is the only place the front of desiredPublishes moves. +// Any other pop must also re-arm the publish timer. func (p *PublicationMonitor) update() { + popped := false for { var pub *publish if p.desiredPublishes.Len() > 0 { @@ -184,7 +194,14 @@ func (p *PublicationMonitor) update() { if (pub.isStart && p.publishedTrack == nil) || (!pub.isStart && p.publishedTrack != nil) { // put it back as the condition is not satisfied p.desiredPublishes.PushFront(pub) + + // a queued publish reached the front, start its publish window from now + if popped && pub.isStart { + p.unmutedAt = time.Time{} + p.maybeStartMonitor() + } return } + popped = true } } diff --git a/pkg/rtc/supervisor/publication_monitor_test.go b/pkg/rtc/supervisor/publication_monitor_test.go new file mode 100644 index 000000000..721c2da69 --- /dev/null +++ b/pkg/rtc/supervisor/publication_monitor_test.go @@ -0,0 +1,142 @@ +// 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 supervisor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" + "github.com/livekit/protocol/logger" +) + +func newMonitorForTest() *PublicationMonitor { + return NewPublicationMonitor(PublicationMonitorParams{ + TrackID: "TR_test", + IsPeerConnectionConnected: true, + Logger: logger.GetLogger(), + }) +} + +// addUnmuted adds a pending publish the way the participant does: add, then set mute +func addUnmuted(m *PublicationMonitor) { + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + m.PostEvent(types.OperationMonitorEventSetPublicationMute, false) +} + +// age moves the publish window start back past the publish wait and returns it +func age(m *PublicationMonitor) time.Time { + m.lock.Lock() + defer m.lock.Unlock() + m.unmutedAt = m.unmutedAt.Add(-2 * publishWaitDuration) + return m.unmutedAt +} + +func getUnmutedAt(m *PublicationMonitor) time.Time { + m.lock.RLock() + defer m.lock.RUnlock() + return m.unmutedAt +} + +// Each subtest is one row of the re-arm case table: the event, and whether the +// publish timer restarts because a queued start reached the front. +func TestPublicationMonitorRearm(t *testing.T) { + t.Run("first add: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + require.True(t, getUnmutedAt(m).IsZero()) + }) + + t.Run("successor queued before the track publishes: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + require.Equal(t, aged, getUnmutedAt(m)) + require.ErrorIs(t, m.Check(), errPublishTimeout) + }) + + t.Run("track publishes: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + addUnmuted(m) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, &typesfakes.FakeLocalMediaTrack{}) + require.Equal(t, aged, getUnmutedAt(m)) + require.NoError(t, m.Check()) // front is the live track's end + }) + + t.Run("successor queued while the track is live: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, &typesfakes.FakeLocalMediaTrack{}) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + require.Equal(t, aged, getUnmutedAt(m)) + require.NoError(t, m.Check()) + }) + + t.Run("track closes with a successor queued: re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + addUnmuted(m) + track := &typesfakes.FakeLocalMediaTrack{} + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, track) + age(m) + + m.PostEvent(types.OperationMonitorEventClearPublishedTrack, track) + require.Less(t, time.Since(getUnmutedAt(m)), publishWaitDuration) + require.NoError(t, m.Check()) + + // the successor still times out once its own window passes without media + age(m) + require.ErrorIs(t, m.Check(), errPublishTimeout) + }) + + t.Run("track closes with a muted successor queued: clock cleared", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + addUnmuted(m) + track := &typesfakes.FakeLocalMediaTrack{} + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, track) + m.PostEvent(types.OperationMonitorEventSetPublicationMute, true) + age(m) + + m.PostEvent(types.OperationMonitorEventClearPublishedTrack, track) + require.True(t, getUnmutedAt(m).IsZero()) + + // the clock starts on unmute + m.PostEvent(types.OperationMonitorEventSetPublicationMute, false) + require.Less(t, time.Since(getUnmutedAt(m)), publishWaitDuration) + }) + + t.Run("track closes with nothing queued: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + track := &typesfakes.FakeLocalMediaTrack{} + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, track) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventClearPublishedTrack, track) + require.Equal(t, aged, getUnmutedAt(m)) + require.True(t, m.IsIdle()) + }) +} diff --git a/pkg/rtc/testutils.go b/pkg/rtc/testutils.go index 085582113..253707c9c 100644 --- a/pkg/rtc/testutils.go +++ b/pkg/rtc/testutils.go @@ -23,6 +23,7 @@ import ( "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" + "github.com/livekit/livekit-server/pkg/sfu/connectionquality" ) func NewMockParticipant( @@ -54,6 +55,11 @@ func NewMockParticipant( State: livekit.ParticipantInfo_JOINED, IsPublisher: publisher, }, utils.TimedVersion(0)) + p.GetConnectionQualityReturns(&livekit.ConnectionQualityInfo{ + ParticipantSid: sid, + Quality: livekit.ConnectionQuality_EXCELLENT, + Score: connectionquality.MaxMOS, + }) p.SetMetadataCalls(func(m string) { participantListener.OnParticipantUpdate(p) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index 670b6af72..9b46c6885 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -300,6 +300,9 @@ type PCTransport struct { signalStateCheckTimer *time.Timer currentOfferIceCredential string // ice user:pwd, for publish side ice restart checking pendingRestartIceOffer *webrtc.SessionDescription + // parsed forms kept so that a remote description is parsed once per negotiation + pendingRestartIceOfferParsed *sdp.SessionDescription + remoteOfferParsed *sdp.SessionDescription } type TransportParams struct { @@ -418,6 +421,9 @@ func newPeerConnection( // As Firefox does not support migration, ICE Lite can be disabled. se.SetLite(false) } + // ICE checks already prove the remote address, so the HelloVerifyRequest cookie only adds a round trip. + // pion applies this only when this side ends up as the DTLS server. + se.SetDTLSInsecureSkipHelloVerify(true) se.SetDTLSRetransmissionInterval(dtlsRetransmissionInterval) se.SetDTLSConnectContextMaker(func() (context.Context, func()) { return context.WithTimeout(context.Background(), dtlsHandshakeTimeout) @@ -563,7 +569,6 @@ func NewPCTransport(params TransportParams) (*PCTransport, error) { previousTrackDescription: make(map[string]*trackDescription), canReuseTransceiver: true, connectionDetails: types.NewICEConnectionDetails(params.Transport, params.Logger), - lastNegotiate: time.Now(), } t.localOfferId.Store(uint32(rand.Intn(1<<8) + 1)) @@ -579,12 +584,25 @@ func NewPCTransport(params TransportParams) (*PCTransport, error) { Config: params.CongestionControlConfig.SendSideBWE, Logger: params.Logger, }) - switch pacer.PacerBehavior(params.CongestionControlConfig.SendSideBWEPacer) { + switch behavior := pacer.PacerBehavior(params.CongestionControlConfig.SendSideBWEPacer); behavior { case pacer.PacerBehaviorPassThrough: t.pacer = pacer.NewPassThrough(params.Logger, t.bwe) case pacer.PacerBehaviorNoQueue: t.pacer = pacer.NewNoQueue(params.Logger, t.bwe) default: + // leaky-bucket lands here: it is a recognized value, but LeakyBucket paces + // to a bitrate set through Pacer.SetBitrate and nothing drives that from + // the bandwidth estimate, so it would pace at a fixed rate and ignore + // congestion. Unknown values are rejected by CongestionControlConfig. + // Validate, so they only reach here from callers building TransportParams + // directly. + if behavior != "" { + params.Logger.Warnw( + "send side BWE pacer unavailable, falling back", nil, + "pacerBehavior", behavior, + "fallback", pacer.PacerBehaviorNoQueue, + ) + } t.pacer = pacer.NewNoQueue(params.Logger, t.bwe) } } else { @@ -1234,6 +1252,12 @@ func (t *PCTransport) getNumUnmatchedTransceivers() (uint32, uint32) { continue } + switch tr.Direction() { + case webrtc.RTPTransceiverDirectionSendonly, webrtc.RTPTransceiverDirectionSendrecv: + default: + continue + } + switch tr.Kind() { case webrtc.RTPCodecTypeAudio: numAudios++ @@ -1641,6 +1665,7 @@ func (t *PCTransport) clearConnTimer() { } func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remoteId uint32) error { + parsed, parseErr := sd.Unmarshal() if t.params.UseOneShotSignallingMode { if sd.Type == webrtc.SDPTypeOffer { remoteOfferId := t.remoteOfferId.Load() @@ -1661,8 +1686,7 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo } // add remote candidates to ICE connection details - parsed, err := sd.Unmarshal() - if err == nil { + if parseErr == nil { addRemoteICECandidates := func(attrs []sdp.Attribute) { for _, a := range attrs { if a.IsICECandidate() { @@ -1681,11 +1705,14 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo } } - err = t.pc.SetRemoteDescription(sd) - if err != nil { + if err := t.pc.SetRemoteDescription(sd); err != nil { t.params.Logger.Errorw("could not set remote description on synchronous mode peer connection", err) return err } + t.setRemoteOfferParsed(sd.Type, parsed) + if sd.Type == webrtc.SDPTypeOffer { + t.params.Handler.OnSetRemoteDescriptionOffer() + } rtxRepairs := nonSimulcastRTXRepairsFromSDP(parsed, t.params.Logger) if len(rtxRepairs) > 0 { @@ -1710,6 +1737,8 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo signal: signalRemoteDescriptionReceived, data: remoteDescriptionData{ sessionDescription: &sd, + parsed: parsed, + parseErr: parseErr, remoteId: remoteId, }, }) @@ -1911,6 +1940,7 @@ func (t *PCTransport) HandleICERestartSDPFragment(sdpFragment string) (string, e t.params.Logger.Warnw("could not set remote description", err) return "", err } + t.setRemoteOfferParsed(sd.Type, parsedRemote) // clear out connection details on ICE restart and re-populate t.connectionDetails.Clear() @@ -2293,6 +2323,23 @@ func (t *PCTransport) SetPreviousSdp(localDescription, remoteDescription *webrtc t.lock.Unlock() } +func (t *PCTransport) setRemoteOfferParsed(sdType webrtc.SDPType, parsed *sdp.SessionDescription) { + if sdType != webrtc.SDPTypeOffer { + return + } + t.lock.Lock() + t.remoteOfferParsed = parsed + t.lock.Unlock() +} + +// RemoteOfferParsed returns the parsed form of the last remote offer set on the peer connection, +// candidates filtered the same way pion saw them +func (t *PCTransport) RemoteOfferParsed() *sdp.SessionDescription { + t.lock.RLock() + defer t.lock.RUnlock() + return t.remoteOfferParsed +} + func (t *PCTransport) parseTrackMid(sd webrtc.SessionDescription, senders map[string]*webrtc.RTPSender) error { parsed, err := sd.Unmarshal() if err != nil { @@ -2373,22 +2420,17 @@ func (t *PCTransport) handleICEGatheringCompleteAnswerer() error { } offer := *t.pendingRestartIceOffer + parsed := t.pendingRestartIceOfferParsed t.pendingRestartIceOffer = nil + t.pendingRestartIceOfferParsed = 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 { + if err := t.setRemoteDescription(offer, parsed); err != nil { return err } t.params.Handler.OnSetRemoteDescriptionOffer() - t.processSendersPendingConfig(offerAudioPT) + t.processSendersPendingConfig(offerAudioPayloadTypes(parsed)) return t.createAndSendAnswer() } @@ -2502,10 +2544,23 @@ func (t *PCTransport) isCandidateFilterActive(preferTCP bool) bool { } func (t *PCTransport) filterCandidates(sd webrtc.SessionDescription, preferTCP, isLocal bool) webrtc.SessionDescription { - parsed, err := sd.Unmarshal() - if err != nil { - t.params.Logger.Warnw("could not unmarshal SDP to filter candidates", err) - return sd + return t.filterCandidatesParsed(sd, nil, preferTCP, isLocal) +} + +// filterCandidatesParsed filters using an already parsed description when available, +// the parsed description is modified in place to match the returned SDP +func (t *PCTransport) filterCandidatesParsed( + sd webrtc.SessionDescription, + parsed *sdp.SessionDescription, + preferTCP, isLocal bool, +) webrtc.SessionDescription { + if parsed == nil { + var err error + parsed, err = sd.Unmarshal() + if err != nil { + t.params.Logger.Warnw("could not unmarshal SDP to filter candidates", err) + return sd + } } filterAttributes := func(attrs []sdp.Attribute) []sdp.Attribute { @@ -2731,15 +2786,20 @@ func (t *PCTransport) handleSendOffer(_ event) error { type remoteDescriptionData struct { sessionDescription *webrtc.SessionDescription + parsed *sdp.SessionDescription + parseErr error // set when parsed is nil remoteId uint32 } func (t *PCTransport) handleRemoteDescriptionReceived(e event) error { rdd := e.data.(remoteDescriptionData) + if rdd.parsed == nil { + return rdd.parseErr + } if rdd.sessionDescription.Type == webrtc.SDPTypeOffer { - return t.handleRemoteOfferReceived(rdd.sessionDescription, rdd.remoteId) + return t.handleRemoteOfferReceived(rdd.sessionDescription, rdd.parsed, rdd.remoteId) } else { - return t.handleRemoteAnswerReceived(rdd.sessionDescription, rdd.remoteId) + return t.handleRemoteAnswerReceived(rdd.sessionDescription, rdd.parsed, rdd.remoteId) } } @@ -2755,13 +2815,13 @@ func (t *PCTransport) isRemoteOfferRestartICE(parsed *sdp.SessionDescription) (s return credential, restartICE, nil } -func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription) error { +func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription, parsed *sdp.SessionDescription) error { // filter before setting remote description so that pion does not see filtered remote candidates preferTCP := t.preferTCP.Load() if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("remote description (unfiltered)", "type", sd.Type, "sdp", sd.SDP) } - sd = t.filterCandidates(sd, preferTCP, false) + sd = t.filterCandidatesParsed(sd, parsed, preferTCP, false) if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("remote description (filtered)", "type", sd.Type, "sdp", sd.SDP) } @@ -2778,7 +2838,9 @@ func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription) error { } prometheus.RecordServiceOperationError(sdpType, "remote_description") return errors.Wrap(err, "setting remote description failed") - } else if sd.Type == webrtc.SDPTypeAnswer { + } + t.setRemoteOfferParsed(sd.Type, parsed) + if sd.Type == webrtc.SDPTypeAnswer { t.lock.Lock() if !t.canReuseTransceiver { t.canReuseTransceiver = true @@ -2875,7 +2937,7 @@ func (t *PCTransport) createAndSendAnswer() error { return t.localDescriptionSent() } -func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, offerId uint32) error { +func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, parsed *sdp.SessionDescription, offerId uint32) error { t.params.Logger.Debugw("processing offer", "offerId", offerId) remoteOfferId := t.remoteOfferId.Load() if remoteOfferId != 0 && remoteOfferId != t.localAnswerId.Load() { @@ -2888,11 +2950,6 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o } t.remoteOfferId.Store(offerId) - parsed, err := sd.Unmarshal() - if err != nil { - return err - } - t.lock.Lock() if !t.firstOfferReceived { t.firstOfferReceived = true @@ -2919,6 +2976,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o if offerRestartICE && t.pc.ICEGatheringState() == webrtc.ICEGatheringStateGathering { t.params.Logger.Debugw("remote offer restart ice while ice gathering") t.pendingRestartIceOffer = sd + t.pendingRestartIceOfferParsed = parsed return nil } @@ -2932,7 +2990,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o isStartOfConnectionSequence := t.pc.RemoteDescription() == nil - if err := t.setRemoteDescription(*sd); err != nil { + if err := t.setRemoteDescription(*sd, parsed); err != nil { return err } @@ -2967,7 +3025,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o return t.createAndSendAnswer() } -func (t *PCTransport) handleRemoteAnswerReceived(sd *webrtc.SessionDescription, answerId uint32) error { +func (t *PCTransport) handleRemoteAnswerReceived(sd *webrtc.SessionDescription, parsed *sdp.SessionDescription, answerId uint32) error { t.params.Logger.Debugw("processing answer", "answerId", answerId) if answerId != 0 && answerId != t.localOfferId.Load() { t.params.Logger.Warnw( @@ -2980,7 +3038,7 @@ func (t *PCTransport) handleRemoteAnswerReceived(sd *webrtc.SessionDescription, t.clearSignalStateCheckTimer() - if err := t.setRemoteDescription(*sd); err != nil { + if err := t.setRemoteDescription(*sd, parsed); err != nil { // Pion will call RTPSender.Send method for each new added Downtrack, and return error if the DownTrack.Bind // returns error. In case of Downtrack.Bind returns ErrUnsupportedCodec, the signal state will be stable as negotiation is already completed // before startRTPSenders, and the peerconnection state can be recovered by next negotiation which will be triggered diff --git a/pkg/rtc/transport_media_sections_test.go b/pkg/rtc/transport_media_sections_test.go new file mode 100644 index 000000000..899896427 --- /dev/null +++ b/pkg/rtc/transport_media_sections_test.go @@ -0,0 +1,236 @@ +// 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" + "testing" + "time" + + "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes" + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/codecs/mime" + "github.com/livekit/protocol/livekit" +) + +type mediaSectionsHarness struct { + t *testing.T + server *PCTransport + handler *transportfakes.FakeHandler + client *webrtc.PeerConnection + + mu sync.Mutex + answer *webrtc.SessionDescription + answers int + requirements int + requestedAudios uint32 + requestedVideos uint32 + pendingAudios uint32 + pendingVideos uint32 + offerID uint32 +} + +func newMediaSectionsHarness(t *testing.T) *mediaSectionsHarness { + codecs := []*livekit.Codec{ + {Mime: mime.MimeTypeOpus.String()}, + {Mime: mime.MimeTypeVP8.String()}, + } + + h := &mediaSectionsHarness{t: t, handler: &transportfakes.FakeHandler{}} + + server, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{}, + EnabledPublishCodecs: codecs, + EnabledSubscribeCodecs: codecs, + IsSendSide: true, + Handler: h.handler, + }) + require.NoError(t, err) + h.server = server + + var clientME webrtc.MediaEngine + require.NoError(t, registerCodecs(&clientME, codecs, RTCPFeedbackConfig{}, false)) + client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + h.client = client + + h.handler.OnAnswerCalls(func(sd webrtc.SessionDescription, _ uint32, _ map[string]string) error { + h.mu.Lock() + defer h.mu.Unlock() + h.answer = &sd + h.answers++ + return nil + }) + h.handler.OnUnmatchedMediaCalls(func(numAudios uint32, numVideos uint32) error { + h.mu.Lock() + defer h.mu.Unlock() + h.requirements++ + h.requestedAudios += numAudios + h.requestedVideos += numVideos + h.pendingAudios += numAudios + h.pendingVideos += numVideos + return nil + }) + + t.Cleanup(func() { + server.Close() + client.Close() + }) + return h +} + +func (h *mediaSectionsHarness) negotiate(options *webrtc.OfferOptions) { + h.mu.Lock() + for i := uint32(0); i < h.pendingAudios; i++ { + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }) + require.NoError(h.t, err) + } + for i := uint32(0); i < h.pendingVideos; i++ { + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }) + require.NoError(h.t, err) + } + h.pendingAudios, h.pendingVideos = 0, 0 + answersBefore := h.answers + h.offerID++ + offerID := h.offerID + h.mu.Unlock() + + offer, err := h.client.CreateOffer(options) + require.NoError(h.t, err) + require.NoError(h.t, h.client.SetLocalDescription(offer)) + require.NoError(h.t, h.server.HandleRemoteDescription(*h.client.LocalDescription(), offerID)) + + require.Eventually(h.t, func() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.answers > answersBefore + }, 5*time.Second, 10*time.Millisecond, "server did not produce answer") + + h.mu.Lock() + answer := *h.answer + h.mu.Unlock() + require.NoError(h.t, h.client.SetRemoteDescription(answer)) +} + +func (h *mediaSectionsHarness) addVideoTrack(id string) (*webrtc.RTPSender, *webrtc.RTPTransceiver) { + track, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String(), ClockRate: 90000}, + id, id, + ) + require.NoError(h.t, err) + + sender, transceiver, err := h.server.AddTrack( + track, + types.AddTrackParams{}, + []*livekit.Codec{{Mime: mime.MimeTypeVP8.String()}}, + RTCPFeedbackConfig{}, + ) + require.NoError(h.t, err) + return sender, transceiver +} + +func (h *mediaSectionsHarness) requested() (int, uint32, uint32) { + h.mu.Lock() + defer h.mu.Unlock() + return h.requirements, h.requestedAudios, h.requestedVideos +} + +func (h *mediaSectionsHarness) clientVideoTransceivers() int { + n := 0 + for _, tr := range h.client.GetTransceivers() { + if tr.Kind() == webrtc.RTPCodecTypeVideo { + n++ + } + } + return n +} + +func TestMediaSectionsRequirementNotRepeatedForUnmatchableTransceiver(t *testing.T) { + h := newMediaSectionsHarness(t) + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + require.NoError(t, err) + h.negotiate(nil) + sender, transceiver := h.addVideoTrack("v1") + require.Empty(t, transceiver.Mid()) + require.NoError(t, h.server.RemoveTrack(sender)) + + const rounds = 5 + for i := 0; i < rounds; i++ { + h.negotiate(nil) + } + h.negotiate(nil) + + _, _, requestedVideos := h.requested() + require.LessOrEqual(t, requestedVideos, uint32(1), + "unmatchable transceiver re-requested on every answer: %d video sections requested over %d rounds", requestedVideos, rounds) + require.LessOrEqual(t, h.clientVideoTransceivers(), 1) +} + +func TestMediaSectionsRequirementAfterTransceiverReuse(t *testing.T) { + h := newMediaSectionsHarness(t) + + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + require.NoError(t, err) + h.negotiate(nil) + + sender, released := h.addVideoTrack("v1") + require.NoError(t, h.server.RemoveTrack(sender)) + h.negotiate(nil) + h.negotiate(nil) + require.Empty(t, released.Mid()) + + _, transceiver := h.addVideoTrack("v2") + require.Same(t, released, transceiver, "released transceiver should be reused") + require.Empty(t, transceiver.Mid()) + h.negotiate(nil) + h.server.Negotiate(true) + h.negotiate(nil) + h.negotiate(nil) + + require.NotEmpty(t, transceiver.Mid(), "subscribed track never got a media section") +} + +func TestMediaSectionsRequirementReissuedWhenLost(t *testing.T) { + h := newMediaSectionsHarness(t) + + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + require.NoError(t, err) + h.negotiate(nil) + + _, transceiver := h.addVideoTrack("v1") + h.negotiate(nil) + h.mu.Lock() + h.pendingVideos = 0 + h.mu.Unlock() + + h.negotiate(&webrtc.OfferOptions{ICERestart: true}) + h.negotiate(nil) + h.negotiate(nil) + + require.NotEmpty(t, transceiver.Mid(), "lost requirement was never re-issued") +} diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index 3c0ea425e..a2654ac09 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/pion/dtls/v3/pkg/protocol/handshake" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" @@ -28,9 +29,11 @@ import ( "github.com/livekit/livekit-server/pkg/rtc/transport" "github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes" + "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/testutils" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" + lksdp "github.com/livekit/protocol/sdp" ) func TestMissingAnswerDuringICERestart(t *testing.T) { @@ -58,8 +61,8 @@ func TestMissingAnswerDuringICERestart(t *testing.T) { handleICEExchange(t, transportA, transportB, handlerA, handlerB) connectTransports(t, transportA, transportB, handlerA, handlerB, false, 1, 1) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportA.pc.ICEConnectionState()) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportB.pc.ICEConnectionState()) + require.True(t, iceConnected(transportA.pc)) + require.True(t, iceConnected(transportB.pc)) var negotiationState atomic.Value transportA.OnNegotiationStateChanged(func(state transport.NegotiationState) { @@ -80,8 +83,8 @@ func TestMissingAnswerDuringICERestart(t *testing.T) { }, 10*time.Second, time.Millisecond*10, "transportA offer not received") connectTransports(t, transportA, transportB, handlerA, handlerB, true, 1, 1) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportA.pc.ICEConnectionState()) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportB.pc.ICEConnectionState()) + require.True(t, iceConnected(transportA.pc)) + require.True(t, iceConnected(transportB.pc)) transportA.Close() transportB.Close() @@ -205,6 +208,122 @@ func TestNegotiationTiming(t *testing.T) { transportB.Close() } +func TestFirstNegotiationUsesFastDebounce(t *testing.T) { + offered := make(chan string, 2) + newOfferer := func(name string) *PCTransport { + handler := &transportfakes.FakeHandler{} + handler.OnOfferCalls(func(webrtc.SessionDescription, uint32, map[string]string) error { + select { + case offered <- name: + default: + } + return nil + }) + transport, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{}, + IsOfferer: true, + Handler: handler, + }) + require.NoError(t, err) + t.Cleanup(transport.Close) + _, err = transport.pc.CreateDataChannel(ReliableDataChannel, nil) + require.NoError(t, err) + return transport + } + + // a transport that negotiated just now waits for the full debounce + recent := newOfferer("recent") + recent.lock.Lock() + recent.updateLastNegotiateLocked() + recent.lock.Unlock() + recent.Negotiate(false) + + // a new transport starts later, but its first offer uses the fast debounce and goes out first + time.Sleep(20 * time.Millisecond) + newOfferer("fresh").Negotiate(false) + + for _, want := range []string{"fresh", "recent"} { + select { + case got := <-offered: + require.Equal(t, want, got) + case <-time.After(5 * time.Second): + t.Fatalf("no offer from %s", want) + } + } +} + +func TestDTLSHandshakeSkipsHelloVerify(t *testing.T) { + // the SFU offers as on the subscriber connection, and a plain pion peer answers active like a browser, + // so the SFU is the DTLS server and only the peer sends ClientHello + handler := &transportfakes.FakeHandler{} + sfu, err := NewPCTransport(TransportParams{Config: &WebRTCConfig{}, IsOfferer: true, Handler: handler}) + require.NoError(t, err) + defer sfu.Close() + _, err = sfu.pc.CreateDataChannel(ReliableDataChannel, nil) + require.NoError(t, err) + + var clientHellos atomic.Int32 + se := webrtc.SettingEngine{} + require.NoError(t, se.SetAnsweringDTLSRole(webrtc.DTLSRoleClient)) + se.SetDTLSClientHelloMessageHook(func(m handshake.MessageClientHello) handshake.Message { + clientHellos.Inc() + return &m + }) + client, err := webrtc.NewAPI(webrtc.WithSettingEngine(se)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer client.Close() + + var lock sync.Mutex + var pending []webrtc.ICECandidateInit + hasOffer := false + handler.OnICECandidateCalls(func(c *webrtc.ICECandidate, _ livekit.SignalTarget) error { + if c == nil { + return nil + } + lock.Lock() + defer lock.Unlock() + if !hasOffer { + pending = append(pending, c.ToJSON()) + return nil + } + return client.AddICECandidate(c.ToJSON()) + }) + client.OnICECandidate(func(c *webrtc.ICECandidate) { + if c != nil { + sfu.AddICECandidate(c.ToJSON()) + } + }) + handler.OnOfferCalls(func(offer webrtc.SessionDescription, offerId uint32, _ map[string]string) error { + lock.Lock() + if err := client.SetRemoteDescription(offer); err != nil { + lock.Unlock() + return err + } + hasOffer = true + for _, c := range pending { + _ = client.AddICECandidate(c) + } + lock.Unlock() + + answer, err := client.CreateAnswer(nil) + if err != nil { + return err + } + if err := client.SetLocalDescription(answer); err != nil { + return err + } + return sfu.HandleRemoteDescription(answer, offerId) + }) + sfu.Negotiate(true) + + require.Eventually(t, func() bool { + return sfu.IsEstablished() && client.ConnectionState() == webrtc.PeerConnectionStateConnected + }, 10*time.Second, 10*time.Millisecond, "transports did not connect") + + // a HelloVerifyRequest from the SFU makes the peer send its ClientHello a second time, with the cookie + require.Equal(t, int32(1), clientHellos.Load()) +} + func TestFirstOfferMissedDuringICERestart(t *testing.T) { params := TransportParams{ Config: &WebRTCConfig{}, @@ -268,8 +387,8 @@ func TestFirstOfferMissedDuringICERestart(t *testing.T) { // ensure we are connected require.Eventually(t, func() bool { - return transportA.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && - transportB.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && + return iceConnected(transportA.pc) && + iceConnected(transportB.pc) && offerCount.Load() == 2 }, testutils.ConnectTimeout, 10*time.Millisecond, "transport did not connect") @@ -345,8 +464,8 @@ func TestFirstAnswerMissedDuringICERestart(t *testing.T) { // ensure we are connected require.Eventually(t, func() bool { - return transportA.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && - transportB.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && + return iceConnected(transportA.pc) && + iceConnected(transportB.pc) && offerCount.Load() == 2 }, testutils.ConnectTimeout, 10*time.Millisecond, "transport did not connect") @@ -516,6 +635,15 @@ func TestFilteringCandidates(t *testing.T) { transport.Close() } +// iceConnected reports whether a peer connection's ICE has established. The +// controlling agent (the offerer) advances Connected -> Completed once its +// connectivity checks finish, so asserting exactly Connected races the poll +// against that transition; both states mean the transport is up. +func iceConnected(pc *webrtc.PeerConnection) bool { + s := pc.ICEConnectionState() + return s == webrtc.ICEConnectionStateConnected || s == webrtc.ICEConnectionStateCompleted +} + func handleICEExchange(t *testing.T, a, b *PCTransport, ah, bh *transportfakes.FakeHandler) { ah.OnICECandidateCalls(func(candidate *webrtc.ICECandidate, target livekit.SignalTarget) error { if candidate == nil { @@ -561,7 +689,7 @@ func connectTransports(t *testing.T, offerer, answerer *PCTransport, offererHand }, 10*time.Second, time.Millisecond*10, fmt.Sprintf("offer count mismatch, expected: %d, actual: %d", expectedOfferCount, offerCount.Load())) require.Eventually(t, func() bool { - return offerer.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected + return iceConnected(offerer.pc) }, 10*time.Second, time.Millisecond*10, "offerer did not become connected") require.Eventually(t, func() bool { @@ -569,7 +697,7 @@ func connectTransports(t *testing.T, offerer, answerer *PCTransport, offererHand }, 10*time.Second, time.Millisecond*10, fmt.Sprintf("answer count mismatch, expected: %d, actual: %d", expectedAnswerCount, answerCount.Load())) require.Eventually(t, func() bool { - return answerer.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected + return iceConnected(answerer.pc) }, 10*time.Second, time.Millisecond*10, "answerer did not become connected") transportsConnected := untilTransportsConnected(offererHandler, answererHandler) @@ -803,3 +931,145 @@ func TestSinglePCAnswerStripsSubscribeOnlyCodecsFromRecvSide(t *testing.T) { "answer must not advertise H.264 in recv-side m-section: %s", a.Value) } } + +// the parsed remote offer kept by the transport must follow every accepted offer +// and survive an offer that does not parse +func TestRemoteOfferParsedTracksOffers(t *testing.T) { + codecs := []*livekit.Codec{{Mime: mime.MimeTypeVP8.String()}} + handler := &transportfakes.FakeHandler{} + tr, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{ + // offers from a real client carry RTX pairs that the transport registers here + BufferFactory: buffer.NewFactoryOfBufferFactory(500, 200).CreateBufferFactory(), + }, + EnabledPublishCodecs: codecs, + IsOfferer: false, + Handler: handler, + }) + require.NoError(t, err) + defer tr.Close() + + var clientME webrtc.MediaEngine + require.NoError(t, registerCodecs(&clientME, codecs, RTCPFeedbackConfig{}, false)) + client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer client.Close() + + handler.OnAnswerCalls(func(sd webrtc.SessionDescription, _ uint32, _ map[string]string) error { + return client.SetRemoteDescription(sd) + }) + + mids := func(parsed *sdp.SessionDescription) []string { + var out []string + for _, m := range parsed.MediaDescriptions { + mid, _ := m.Attribute(sdp.AttrKeyMID) + out = append(out, mid) + } + return out + } + + var offerId uint32 + sendOffer := func(numSections int) { + _, err := client.AddTransceiverFromKind( + webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}, + ) + require.NoError(t, err) + offer, err := client.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, client.SetLocalDescription(offer)) + + offerId++ + setCalls := handler.OnSetRemoteDescriptionOfferCallCount() + require.NoError(t, tr.HandleRemoteDescription(offer, offerId)) + require.Eventually(t, func() bool { + return handler.OnSetRemoteDescriptionOfferCallCount() > setCalls + }, 5*time.Second, 10*time.Millisecond) + + parsed := tr.RemoteOfferParsed() + require.NotNil(t, parsed) + require.Len(t, parsed.MediaDescriptions, numSections) + var want []string + for _, tc := range client.GetTransceivers() { + want = append(want, tc.Mid()) + } + require.Equal(t, want, mids(parsed)) + + // wait for the answer so that the client can offer again + require.Eventually(t, func() bool { + return client.SignalingState() == webrtc.SignalingStateStable + }, 5*time.Second, 10*time.Millisecond) + } + + sendOffer(1) + sendOffer(2) + + // an offer that does not parse fails the negotiation and leaves the last parse alone + before := tr.RemoteOfferParsed() + failures := handler.OnNegotiationFailedCallCount() + require.NoError(t, tr.HandleRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: "not an sdp"}, offerId+1)) + require.Eventually(t, func() bool { + return handler.OnNegotiationFailedCallCount() > failures + }, 5*time.Second, 10*time.Millisecond) + require.Same(t, before, tr.RemoteOfferParsed()) +} + +// in one-shot signalling mode an ICE restart arrives as an SDP fragment that is patched into +// the remote offer and set on pion directly, the stored parse must follow it +func TestRemoteOfferParsedFollowsICERestartFragment(t *testing.T) { + codecs := []*livekit.Codec{{Mime: mime.MimeTypeVP8.String()}} + handler := &transportfakes.FakeHandler{} + tr, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{ + BufferFactory: buffer.NewFactoryOfBufferFactory(500, 200).CreateBufferFactory(), + }, + EnabledPublishCodecs: codecs, + IsOfferer: false, + UseOneShotSignallingMode: true, + Handler: handler, + }) + require.NoError(t, err) + defer tr.Close() + + var clientME webrtc.MediaEngine + require.NoError(t, registerCodecs(&clientME, codecs, RTCPFeedbackConfig{}, false)) + client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer client.Close() + + _, err = client.AddTransceiverFromKind( + webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}, + ) + require.NoError(t, err) + offer, err := client.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, client.SetLocalDescription(offer)) + + require.NoError(t, tr.HandleRemoteDescription(offer, 1)) + before := tr.RemoteOfferParsed() + require.NotNil(t, before) + ufrag, _, err := lksdp.ExtractICECredential(before) + require.NoError(t, err) + require.NotEqual(t, "restartUfrag", ufrag) + + _, _, err = tr.GetAnswer() + require.NoError(t, err) + + mid := client.GetTransceivers()[0].Mid() + fragment := "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + + "a=mid:" + mid + "\r\n" + + "a=ice-ufrag:restartUfrag\r\n" + + "a=ice-pwd:restartPwd0123456789abcdef\r\n" + + "a=candidate:1 1 udp 2122260223 192.0.2.1 61764 typ host generation 0\r\n" + answer, err := tr.HandleICERestartSDPFragment(fragment) + require.NoError(t, err) + require.NotEmpty(t, answer) + + after := tr.RemoteOfferParsed() + require.NotSame(t, before, after) + ufrag, pwd, err := lksdp.ExtractICECredential(after) + require.NoError(t, err) + require.Equal(t, "restartUfrag", ufrag) + require.Equal(t, "restartPwd0123456789abcdef", pwd) +} diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 108651f21..e6d983a43 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -523,12 +523,9 @@ func (t *TransportManager) GetUnmatchMediaForOffer(parsedOffer *sdp.SessionDescr return } -func (t *TransportManager) LastPublisherOffer() *webrtc.SessionDescription { - return t.publisher.CurrentRemoteDescription() -} - -func (t *TransportManager) LastPublisherOfferPending() *webrtc.SessionDescription { - return t.publisher.PendingRemoteDescription() +// LastPublisherOfferParsed returns the parsed form of the last offer set on the publisher peer connection +func (t *TransportManager) LastPublisherOfferParsed() *sdp.SessionDescription { + return t.publisher.RemoteOfferParsed() } func (t *TransportManager) HandleOffer(offer webrtc.SessionDescription, offerId uint32, shouldPend bool) error { @@ -678,7 +675,7 @@ func (t *TransportManager) GetICEConfig() *livekit.ICEConfig { if t.iceConfig == nil { return nil } - return utils.CloneProto(t.iceConfig) + return proto.CloneOf(t.iceConfig) } func (t *TransportManager) resetTransportConfigureLocked(reconfigured bool) { diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index a67b19ffd..1d7734f1d 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -30,11 +30,11 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/pacer" "github.com/livekit/livekit-server/pkg/telemetry" + "github.com/livekit/protocol/datatrack" "google.golang.org/protobuf/proto" ) @@ -120,6 +120,7 @@ const ( ParticipantCloseReasonUserRejected ParticipantCloseReasonMoveFailed ParticipantCloseReasonAgentError + ParticipantCloseReasonSIPTrunkFailure ) func (p ParticipantCloseReason) String() string { @@ -182,6 +183,8 @@ func (p ParticipantCloseReason) String() string { return "MOVE_FAILED" case ParticipantCloseReasonAgentError: return "AGENT_ERROR" + case ParticipantCloseReasonSIPTrunkFailure: + return "SIP_TRUNK_FAILURE" default: return fmt.Sprintf("%d", int(p)) } @@ -221,12 +224,16 @@ func (p ParticipantCloseReason) ToDisconnectReason() livekit.DisconnectReason { return livekit.DisconnectReason_USER_REJECTED case ParticipantCloseReasonAgentError: return livekit.DisconnectReason_AGENT_ERROR + case ParticipantCloseReasonSIPTrunkFailure: + return livekit.DisconnectReason_SIP_TRUNK_FAILURE default: // the other types will map to unknown reason return livekit.DisconnectReason_UNKNOWN_REASON } } +// --------------------------------------------- + // IsIntentionalDisconnect reports whether a disconnect reason represents an // intentional/expected closure (client leaving, admin action, room teardown, // migration, etc.) as opposed to a connection failure. @@ -238,7 +245,8 @@ func IsIntentionalDisconnect(reason livekit.DisconnectReason) bool { livekit.DisconnectReason_MIGRATION, livekit.DisconnectReason_PARTICIPANT_REMOVED, livekit.DisconnectReason_ROOM_DELETED, - livekit.DisconnectReason_ROOM_CLOSED: + livekit.DisconnectReason_ROOM_CLOSED, + livekit.DisconnectReason_SIP_TRUNK_FAILURE: return true } return false @@ -292,6 +300,7 @@ func (s SignallingCloseReason) String() string { } // --------------------------------------------- + const ( ParticipantCloseKeyNormal = "normal" ParticipantCloseKeyWHIP = "whip" @@ -299,6 +308,70 @@ const ( // --------------------------------------------- +type RoomCloseReason int + +const ( + RoomCloseReasonUnknown RoomCloseReason = iota + RoomCloseReasonAPIDelete + RoomCloseReasonIdleTimeout + RoomCloseReasonServerShutdown + RoomCloseReasonSuperseded + RoomCloseReasonOpenFailed +) + +func (r RoomCloseReason) String() string { + switch r { + case RoomCloseReasonUnknown: + return "UNKNOWN" + case RoomCloseReasonAPIDelete: + return "API_DELETE" + case RoomCloseReasonIdleTimeout: + return "IDLE_TIMEOUT" + case RoomCloseReasonServerShutdown: + return "SERVER_SHUTDOWN" + case RoomCloseReasonSuperseded: + return "SUPERSEDED" + case RoomCloseReasonOpenFailed: + return "OPEN_FAILED" + default: + return fmt.Sprintf("%d", int(r)) + } +} + +func (r RoomCloseReason) ToProto() livekit.RoomEndReason { + switch r { + case RoomCloseReasonAPIDelete: + return livekit.RoomEndReason_ROOM_END_API_DELETE + case RoomCloseReasonIdleTimeout: + return livekit.RoomEndReason_ROOM_END_IDLE_TIMEOUT + case RoomCloseReasonServerShutdown: + return livekit.RoomEndReason_ROOM_END_SERVER_SHUTDOWN + case RoomCloseReasonSuperseded: + return livekit.RoomEndReason_ROOM_END_SUPERSEDED + case RoomCloseReasonOpenFailed: + return livekit.RoomEndReason_ROOM_END_OPEN_FAILED + default: + return livekit.RoomEndReason_ROOM_END_UNKNOWN + } +} + +// ToParticipantCloseReason gives the reason participants are closed with when the +// room closes for this reason, so the two can never disagree. +func (r RoomCloseReason) ToParticipantCloseReason() ParticipantCloseReason { + switch r { + case RoomCloseReasonAPIDelete: + return ParticipantCloseReasonServiceRequestDeleteRoom + case RoomCloseReasonIdleTimeout, RoomCloseReasonSuperseded: + return ParticipantCloseReasonRoomClosed + case RoomCloseReasonServerShutdown: + return ParticipantCloseReasonRoomManagerStop + default: + return ParticipantCloseReasonNone + } +} + +// --------------------------------------------- + //counterfeiter:generate . Participant type Participant interface { ID() livekit.ParticipantID @@ -481,6 +554,7 @@ type LocalParticipant interface { UnsubscribeFromTrack(trackID livekit.TrackID) UpdateSubscribedTrackSettings(trackID livekit.TrackID, settings *livekit.UpdateTrackSettings) GetSubscribedTracks() []SubscribedTrack + GetSubscribedDataTracks() []DataDownTrack IsTrackNameSubscribed(publisherIdentity livekit.ParticipantIdentity, trackName string) bool SubscribeToDataTrack(trackID livekit.TrackID) UnsubscribeFromDataTrack(trackID livekit.TrackID) @@ -509,10 +583,11 @@ type LocalParticipant interface { SendConnectionQualityUpdate(update *livekit.ConnectionQualityUpdate) error SendSubscriptionPermissionUpdate(publisherID livekit.ParticipantID, trackID livekit.TrackID, allowed bool) error SendRefreshToken(token string) error - HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse) error + HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse, recentlyDisconnectedParticipants []*livekit.ParticipantInfo) error IssueFullReconnect(reason ParticipantCloseReason) SendRoomMovedResponse(moved *livekit.RoomMovedResponse) error SendDataTrackSubscriberHandles(handles map[uint32]*livekit.DataTrackSubscriberHandles_PublishedDataTrack) error + GetRecentlyDisconnectedParticipants() []*livekit.ParticipantInfo AddOnClose(key string, callback func(LocalParticipant)) OnClaimsChanged(callback func(LocalParticipant)) @@ -533,6 +608,8 @@ type LocalParticipant interface { ) IsReconnect() bool IsMigration() bool + LeaveSession(leave func()) + JoinSession(join func()) MoveToRoom(params MoveToRoomParams) UpdateMediaRTT(rtt uint32) diff --git a/pkg/rtc/types/typesfakes/fake_data_track.go b/pkg/rtc/types/typesfakes/fake_data_track.go index 07a49f9b1..ddd512d92 100644 --- a/pkg/rtc/types/typesfakes/fake_data_track.go +++ b/pkg/rtc/types/typesfakes/fake_data_track.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/types/typesfakes/fake_data_track_sender.go b/pkg/rtc/types/typesfakes/fake_data_track_sender.go index a9a69486a..f45c28e82 100644 --- a/pkg/rtc/types/typesfakes/fake_data_track_sender.go +++ b/pkg/rtc/types/typesfakes/fake_data_track_sender.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index e0900c54f..6c11e6bf6 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -6,13 +6,13 @@ import ( "time" "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/pacer" "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/observability/roomobs" @@ -527,6 +527,16 @@ type FakeLocalParticipant struct { result1 string result2 error } + GetRecentlyDisconnectedParticipantsStub func() []*livekit.ParticipantInfo + getRecentlyDisconnectedParticipantsMutex sync.RWMutex + getRecentlyDisconnectedParticipantsArgsForCall []struct { + } + getRecentlyDisconnectedParticipantsReturns struct { + result1 []*livekit.ParticipantInfo + } + getRecentlyDisconnectedParticipantsReturnsOnCall map[int]struct { + result1 []*livekit.ParticipantInfo + } GetReporterStub func() roomobs.ParticipantSessionReporter getReporterMutex sync.RWMutex getReporterArgsForCall []struct { @@ -557,6 +567,16 @@ type FakeLocalParticipant struct { getResponseSinkReturnsOnCall map[int]struct { result1 routing.MessageSink } + GetSubscribedDataTracksStub func() []types.DataDownTrack + getSubscribedDataTracksMutex sync.RWMutex + getSubscribedDataTracksArgsForCall []struct { + } + getSubscribedDataTracksReturns struct { + result1 []types.DataDownTrack + } + getSubscribedDataTracksReturnsOnCall map[int]struct { + result1 []types.DataDownTrack + } GetSubscribedParticipantsStub func() []livekit.ParticipantID getSubscribedParticipantsMutex sync.RWMutex getSubscribedParticipantsArgsForCall []struct { @@ -682,11 +702,12 @@ type FakeLocalParticipant struct { arg1 *sfu.DownTrack arg2 *rtcp.ReceiverReport } - HandleReconnectAndSendResponseStub func(livekit.ReconnectReason, *livekit.ReconnectResponse) error + HandleReconnectAndSendResponseStub func(livekit.ReconnectReason, *livekit.ReconnectResponse, []*livekit.ParticipantInfo) error handleReconnectAndSendResponseMutex sync.RWMutex handleReconnectAndSendResponseArgsForCall []struct { arg1 livekit.ReconnectReason arg2 *livekit.ReconnectResponse + arg3 []*livekit.ParticipantInfo } handleReconnectAndSendResponseReturns struct { result1 error @@ -990,6 +1011,11 @@ type FakeLocalParticipant struct { issueFullReconnectArgsForCall []struct { arg1 types.ParticipantCloseReason } + JoinSessionStub func(func()) + joinSessionMutex sync.RWMutex + joinSessionArgsForCall []struct { + arg1 func() + } KindStub func() livekit.ParticipantInfo_Kind kindMutex sync.RWMutex kindArgsForCall []struct { @@ -1010,6 +1036,11 @@ type FakeLocalParticipant struct { kindDetailsReturnsOnCall map[int]struct { result1 []livekit.ParticipantInfo_KindDetail } + LeaveSessionStub func(func()) + leaveSessionMutex sync.RWMutex + leaveSessionArgsForCall []struct { + arg1 func() + } MaybeStartMigrationStub func(bool, func()) bool maybeStartMigrationMutex sync.RWMutex maybeStartMigrationArgsForCall []struct { @@ -4254,6 +4285,59 @@ func (fake *FakeLocalParticipant) GetPublisherICESessionUfragReturnsOnCall(i int }{result1, result2} } +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipants() []*livekit.ParticipantInfo { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + ret, specificReturn := fake.getRecentlyDisconnectedParticipantsReturnsOnCall[len(fake.getRecentlyDisconnectedParticipantsArgsForCall)] + fake.getRecentlyDisconnectedParticipantsArgsForCall = append(fake.getRecentlyDisconnectedParticipantsArgsForCall, struct { + }{}) + stub := fake.GetRecentlyDisconnectedParticipantsStub + fakeReturns := fake.getRecentlyDisconnectedParticipantsReturns + fake.recordInvocation("GetRecentlyDisconnectedParticipants", []interface{}{}) + fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsCallCount() int { + fake.getRecentlyDisconnectedParticipantsMutex.RLock() + defer fake.getRecentlyDisconnectedParticipantsMutex.RUnlock() + return len(fake.getRecentlyDisconnectedParticipantsArgsForCall) +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsCalls(stub func() []*livekit.ParticipantInfo) { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + defer fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + fake.GetRecentlyDisconnectedParticipantsStub = stub +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsReturns(result1 []*livekit.ParticipantInfo) { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + defer fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + fake.GetRecentlyDisconnectedParticipantsStub = nil + fake.getRecentlyDisconnectedParticipantsReturns = struct { + result1 []*livekit.ParticipantInfo + }{result1} +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsReturnsOnCall(i int, result1 []*livekit.ParticipantInfo) { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + defer fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + fake.GetRecentlyDisconnectedParticipantsStub = nil + if fake.getRecentlyDisconnectedParticipantsReturnsOnCall == nil { + fake.getRecentlyDisconnectedParticipantsReturnsOnCall = make(map[int]struct { + result1 []*livekit.ParticipantInfo + }) + } + fake.getRecentlyDisconnectedParticipantsReturnsOnCall[i] = struct { + result1 []*livekit.ParticipantInfo + }{result1} +} + func (fake *FakeLocalParticipant) GetReporter() roomobs.ParticipantSessionReporter { fake.getReporterMutex.Lock() ret, specificReturn := fake.getReporterReturnsOnCall[len(fake.getReporterArgsForCall)] @@ -4413,6 +4497,59 @@ func (fake *FakeLocalParticipant) GetResponseSinkReturnsOnCall(i int, result1 ro }{result1} } +func (fake *FakeLocalParticipant) GetSubscribedDataTracks() []types.DataDownTrack { + fake.getSubscribedDataTracksMutex.Lock() + ret, specificReturn := fake.getSubscribedDataTracksReturnsOnCall[len(fake.getSubscribedDataTracksArgsForCall)] + fake.getSubscribedDataTracksArgsForCall = append(fake.getSubscribedDataTracksArgsForCall, struct { + }{}) + stub := fake.GetSubscribedDataTracksStub + fakeReturns := fake.getSubscribedDataTracksReturns + fake.recordInvocation("GetSubscribedDataTracks", []interface{}{}) + fake.getSubscribedDataTracksMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksCallCount() int { + fake.getSubscribedDataTracksMutex.RLock() + defer fake.getSubscribedDataTracksMutex.RUnlock() + return len(fake.getSubscribedDataTracksArgsForCall) +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksCalls(stub func() []types.DataDownTrack) { + fake.getSubscribedDataTracksMutex.Lock() + defer fake.getSubscribedDataTracksMutex.Unlock() + fake.GetSubscribedDataTracksStub = stub +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksReturns(result1 []types.DataDownTrack) { + fake.getSubscribedDataTracksMutex.Lock() + defer fake.getSubscribedDataTracksMutex.Unlock() + fake.GetSubscribedDataTracksStub = nil + fake.getSubscribedDataTracksReturns = struct { + result1 []types.DataDownTrack + }{result1} +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksReturnsOnCall(i int, result1 []types.DataDownTrack) { + fake.getSubscribedDataTracksMutex.Lock() + defer fake.getSubscribedDataTracksMutex.Unlock() + fake.GetSubscribedDataTracksStub = nil + if fake.getSubscribedDataTracksReturnsOnCall == nil { + fake.getSubscribedDataTracksReturnsOnCall = make(map[int]struct { + result1 []types.DataDownTrack + }) + } + fake.getSubscribedDataTracksReturnsOnCall[i] = struct { + result1 []types.DataDownTrack + }{result1} +} + func (fake *FakeLocalParticipant) GetSubscribedParticipants() []livekit.ParticipantID { fake.getSubscribedParticipantsMutex.Lock() ret, specificReturn := fake.getSubscribedParticipantsReturnsOnCall[len(fake.getSubscribedParticipantsArgsForCall)] @@ -5105,19 +5242,25 @@ func (fake *FakeLocalParticipant) HandleReceiverReportArgsForCall(i int) (*sfu.D return argsForCall.arg1, argsForCall.arg2 } -func (fake *FakeLocalParticipant) HandleReconnectAndSendResponse(arg1 livekit.ReconnectReason, arg2 *livekit.ReconnectResponse) error { +func (fake *FakeLocalParticipant) HandleReconnectAndSendResponse(arg1 livekit.ReconnectReason, arg2 *livekit.ReconnectResponse, arg3 []*livekit.ParticipantInfo) error { + var arg3Copy []*livekit.ParticipantInfo + if arg3 != nil { + arg3Copy = make([]*livekit.ParticipantInfo, len(arg3)) + copy(arg3Copy, arg3) + } fake.handleReconnectAndSendResponseMutex.Lock() ret, specificReturn := fake.handleReconnectAndSendResponseReturnsOnCall[len(fake.handleReconnectAndSendResponseArgsForCall)] fake.handleReconnectAndSendResponseArgsForCall = append(fake.handleReconnectAndSendResponseArgsForCall, struct { arg1 livekit.ReconnectReason arg2 *livekit.ReconnectResponse - }{arg1, arg2}) + arg3 []*livekit.ParticipantInfo + }{arg1, arg2, arg3Copy}) stub := fake.HandleReconnectAndSendResponseStub fakeReturns := fake.handleReconnectAndSendResponseReturns - fake.recordInvocation("HandleReconnectAndSendResponse", []interface{}{arg1, arg2}) + fake.recordInvocation("HandleReconnectAndSendResponse", []interface{}{arg1, arg2, arg3Copy}) fake.handleReconnectAndSendResponseMutex.Unlock() if stub != nil { - return stub(arg1, arg2) + return stub(arg1, arg2, arg3) } if specificReturn { return ret.result1 @@ -5131,17 +5274,17 @@ func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseCallCount() int return len(fake.handleReconnectAndSendResponseArgsForCall) } -func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseCalls(stub func(livekit.ReconnectReason, *livekit.ReconnectResponse) error) { +func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseCalls(stub func(livekit.ReconnectReason, *livekit.ReconnectResponse, []*livekit.ParticipantInfo) error) { fake.handleReconnectAndSendResponseMutex.Lock() defer fake.handleReconnectAndSendResponseMutex.Unlock() fake.HandleReconnectAndSendResponseStub = stub } -func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseArgsForCall(i int) (livekit.ReconnectReason, *livekit.ReconnectResponse) { +func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseArgsForCall(i int) (livekit.ReconnectReason, *livekit.ReconnectResponse, []*livekit.ParticipantInfo) { fake.handleReconnectAndSendResponseMutex.RLock() defer fake.handleReconnectAndSendResponseMutex.RUnlock() argsForCall := fake.handleReconnectAndSendResponseArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseReturns(result1 error) { @@ -6786,6 +6929,38 @@ func (fake *FakeLocalParticipant) IssueFullReconnectArgsForCall(i int) types.Par return argsForCall.arg1 } +func (fake *FakeLocalParticipant) JoinSession(arg1 func()) { + fake.joinSessionMutex.Lock() + fake.joinSessionArgsForCall = append(fake.joinSessionArgsForCall, struct { + arg1 func() + }{arg1}) + stub := fake.JoinSessionStub + fake.recordInvocation("JoinSession", []interface{}{arg1}) + fake.joinSessionMutex.Unlock() + if stub != nil { + fake.JoinSessionStub(arg1) + } +} + +func (fake *FakeLocalParticipant) JoinSessionCallCount() int { + fake.joinSessionMutex.RLock() + defer fake.joinSessionMutex.RUnlock() + return len(fake.joinSessionArgsForCall) +} + +func (fake *FakeLocalParticipant) JoinSessionCalls(stub func(func())) { + fake.joinSessionMutex.Lock() + defer fake.joinSessionMutex.Unlock() + fake.JoinSessionStub = stub +} + +func (fake *FakeLocalParticipant) JoinSessionArgsForCall(i int) func() { + fake.joinSessionMutex.RLock() + defer fake.joinSessionMutex.RUnlock() + argsForCall := fake.joinSessionArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) Kind() livekit.ParticipantInfo_Kind { fake.kindMutex.Lock() ret, specificReturn := fake.kindReturnsOnCall[len(fake.kindArgsForCall)] @@ -6892,6 +7067,38 @@ func (fake *FakeLocalParticipant) KindDetailsReturnsOnCall(i int, result1 []live }{result1} } +func (fake *FakeLocalParticipant) LeaveSession(arg1 func()) { + fake.leaveSessionMutex.Lock() + fake.leaveSessionArgsForCall = append(fake.leaveSessionArgsForCall, struct { + arg1 func() + }{arg1}) + stub := fake.LeaveSessionStub + fake.recordInvocation("LeaveSession", []interface{}{arg1}) + fake.leaveSessionMutex.Unlock() + if stub != nil { + fake.LeaveSessionStub(arg1) + } +} + +func (fake *FakeLocalParticipant) LeaveSessionCallCount() int { + fake.leaveSessionMutex.RLock() + defer fake.leaveSessionMutex.RUnlock() + return len(fake.leaveSessionArgsForCall) +} + +func (fake *FakeLocalParticipant) LeaveSessionCalls(stub func(func())) { + fake.leaveSessionMutex.Lock() + defer fake.leaveSessionMutex.Unlock() + fake.LeaveSessionStub = stub +} + +func (fake *FakeLocalParticipant) LeaveSessionArgsForCall(i int) func() { + fake.leaveSessionMutex.RLock() + defer fake.leaveSessionMutex.RUnlock() + argsForCall := fake.leaveSessionArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) MaybeStartMigration(arg1 bool, arg2 func()) bool { fake.maybeStartMigrationMutex.Lock() ret, specificReturn := fake.maybeStartMigrationReturnsOnCall[len(fake.maybeStartMigrationArgsForCall)] diff --git a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go index 4c0a3a07a..7f6912626 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/types/typesfakes/fake_participant.go b/pkg/rtc/types/typesfakes/fake_participant.go index 652c8bf14..df9f2255d 100644 --- a/pkg/rtc/types/typesfakes/fake_participant.go +++ b/pkg/rtc/types/typesfakes/fake_participant.go @@ -5,8 +5,8 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" diff --git a/pkg/rtc/types/typesfakes/fake_participant_listener.go b/pkg/rtc/types/typesfakes/fake_participant_listener.go index a05a3e0f5..ac1b05bea 100644 --- a/pkg/rtc/types/typesfakes/fake_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_participant_listener.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/updatatrackmanager.go b/pkg/rtc/updatatrackmanager.go index c3af8dc50..dbf6c5a32 100644 --- a/pkg/rtc/updatatrackmanager.go +++ b/pkg/rtc/updatatrackmanager.go @@ -19,8 +19,8 @@ import ( "slices" "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) diff --git a/pkg/service/agent_dispatch_service.go b/pkg/service/agent_dispatch_service.go index f2dc581f5..bab17b268 100644 --- a/pkg/service/agent_dispatch_service.go +++ b/pkg/service/agent_dispatch_service.go @@ -18,13 +18,14 @@ import ( "context" "fmt" + "google.golang.org/protobuf/proto" + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/protocol/agent" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" "github.com/livekit/psrpc" ) @@ -120,7 +121,7 @@ func redactCreateAgentDispatchRequest(req *livekit.CreateAgentDispatchRequest) * return req } - clone := utils.CloneProto(req) + clone := proto.CloneOf(req) // replace with size of metadata to provide visibility on request size if clone.Metadata != "" { diff --git a/pkg/service/agentendpoint.go b/pkg/service/agentendpoint.go new file mode 100644 index 000000000..ec6761a0b --- /dev/null +++ b/pkg/service/agentendpoint.go @@ -0,0 +1,65 @@ +// 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 service + +import ( + "net/http" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" +) + +// AgentEndpointService is the /agents/{agent_name}/{deployment}/{path...} front +// backed by this node's attached workers. The api key comes from validated grants +// when a token is present; a non-public route additionally requires an +// agent-endpoint grant scoped to this agent and deployment. +type AgentEndpointService struct { + *endpoint.Front +} + +func NewAgentEndpointService(h *AgentHandler, scopes *EndpointScopes) *AgentEndpointService { + return &AgentEndpointService{ + Front: endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(r *http.Request, agentName, deployment string) (endpoint.Access, bool) { + if claims := GetGrants(r.Context()); claims != nil { + level := endpoint.AccessCredentialed + if claims.AgentEndpoint.Allows(agentName, deployment) { + level = endpoint.AccessGranted + } + return endpoint.Access{ + Scope: scopes.Scope(GetAPIKey(r.Context()), agentName, deployment), + Level: level, + }, true + } + // unauthenticated: one configured key makes the api key + // unambiguous, and failing that a single attached tenant does. + // A guessed api key confers no access, so such a request still + // reaches only routes marked public. + apiKey := h.singleAPIKey + if apiKey == "" { + var ok bool + if apiKey, ok = scopes.SingleKey(); !ok { + return endpoint.Access{}, false + } + } + return endpoint.Access{ + Scope: scopes.Scope(apiKey, agentName, deployment), + }, true + }, + Logger: h.logger, + MaxTunnelsPerWorker: int(h.endpointsConfig.MaxTunnels), + TunnelDrainTimeout: h.endpointsConfig.TunnelDrainTimeout, + }), + } +} diff --git a/pkg/service/agentendpoint_protocol_test.go b/pkg/service/agentendpoint_protocol_test.go new file mode 100644 index 000000000..40ac8439b --- /dev/null +++ b/pkg/service/agentendpoint_protocol_test.go @@ -0,0 +1,80 @@ +// 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 service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +func TestEndpointSettingsNegotiation(t *testing.T) { + req := func(protocol uint32) *livekit.RegisterWorkerRequest { + return &livekit.RegisterWorkerRequest{ + AgentName: "test-agent", + Deployment: "production", + InstanceId: "AEI_test", + Endpoints: []*livekit.AgentHttp_AgentEndpoint{{Path: "/json", Methods: []string{"GET"}}}, + EndpointProtocol: protocol, + } + } + h := &AgentHandler{} + negotiate := func(h *AgentHandler, r *livekit.RegisterWorkerRequest) (*livekit.AgentHttp_AgentEndpointSettings, error) { + res := &livekit.RegisterWorkerResponse{} + reg := &agent.WorkerRegistration{} + if err := h.endpointRegisterHandler(r, res, reg); err != nil { + return nil, err + } + require.Equal(t, res.GetEndpointSettings(), reg.EndpointSettings, + "the registration must carry what the worker was told") + return res.GetEndpointSettings(), nil + } + + // a worker predating the data plane declares endpoints with protocol 0: it + // cannot frame at all, and the error must name that rather than read as a + // version out of range + _, err := negotiate(h, req(0)) + require.ErrorContains(t, err, "no endpoint protocol") + + settings, err := negotiate(h, req(wire.CurrentProtocol)) + require.NoError(t, err) + require.Equal(t, wire.CurrentProtocol, settings.GetProtocol()) + + // a worker speaking a newer version is negotiated down to what this server + // serves; the worker frames to the version returned here + settings, err = negotiate(h, req(wire.CurrentProtocol+42)) + require.NoError(t, err) + require.Equal(t, wire.CurrentProtocol, settings.GetProtocol()) + + noInstance := req(wire.CurrentProtocol) + noInstance.InstanceId = "" + _, err = negotiate(h, noInstance) + require.ErrorContains(t, err, "instance_id") + + disabled := &AgentHandler{endpointsConfig: agent.EndpointsConfig{Disabled: true}} + _, err = negotiate(disabled, req(wire.CurrentProtocol)) + require.ErrorContains(t, err, "disabled") + + // a registration declaring no endpoints is left alone, even with endpoints off + bare := req(wire.CurrentProtocol) + bare.Endpoints = nil + settings, err = negotiate(disabled, bare) + require.NoError(t, err) + require.Nil(t, settings) +} diff --git a/pkg/service/agentendpoint_test.go b/pkg/service/agentendpoint_test.go new file mode 100644 index 000000000..a4ae1af7b --- /dev/null +++ b/pkg/service/agentendpoint_test.go @@ -0,0 +1,711 @@ +// 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 service_test + +import ( + "bufio" + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/quic-go/quic-go/http3" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/livekit-server/pkg/agent/testutils" + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/psrpc" +) + +const ( + testKey = "test" + testSecret = "verysecretsecret" +) + +// small enough that a test body can exceed it +const testMaxAPIBodySize = 64 << 10 + +type endpointStack struct { + t *testing.T + ts *httptest.Server + handler *service.AgentHandler + scopes *service.EndpointScopes + wtURL string // https://host:port/agent (WebTransport control+data) +} + +// selfSignedTLS mints an in-memory cert for 127.0.0.1 with the h3 ALPN, for the +// node's WebTransport listener. +func selfSignedTLS(t *testing.T) *tls.Config { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{http3.NextProtoH3}, + } +} + +func newEndpointStack(t *testing.T, endpointsCfg agent.EndpointsConfig) *endpointStack { + localNode, err := routing.NewLocalNode(nil) + require.NoError(t, err) + keyProvider := auth.NewSimpleKeyProvider(testKey, testSecret) + + conf := &config.Config{ + Region: "test", + Keys: map[string]string{testKey: testSecret}, + Agents: agent.Config{TargetLoad: agent.DefaultTargetLoad, Endpoints: endpointsCfg}, + } + conf.Limit.MaxAPIRequestBodySize = testMaxAPIBodySize + + scopes := service.NewEndpointScopes() + h, err := service.NewAgentHandler(conf, localNode, psrpc.NewLocalMessageBus(), keyProvider, endpoint.NewRegistry(), scopes) + require.NoError(t, err) + + // the production handler, so these tests run on the node's real middleware chain + apiMux := http.NewServeMux() + apiMux.HandleFunc("POST /api-sink", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusNoContent) + }) + apiMux.HandleFunc("/", http.NotFound) + + var agentFront http.Handler + if !endpointsCfg.Disabled { + agentFront = service.NewAgentEndpointService(h, scopes) + } + ts := httptest.NewServer(service.NewHTTPHandler(conf, keyProvider, apiMux, agentFront)) + t.Cleanup(ts.Close) + t.Cleanup(func() { h.DrainConnections(time.Millisecond, true) }) + + // workers connect over WebTransport (control + data on one session) + wtMux := http.NewServeMux() + wtMux.Handle("/agent", service.NewAgentWTService(h)) + wt := service.NewWebTransportServer(selfSignedTLS(t)) + wt.H3.Handler = service.NewWebTransportHandler(keyProvider, wt, wtMux) + bound, err := wt.Listen([]string{"127.0.0.1"}, 0) + require.NoError(t, err) + t.Cleanup(func() { + // t.Context() is already cancelled by the time cleanups run + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = wt.Shutdown(ctx) + }) + wtURL := "https://" + bound[0].String() + "/agent" + + return &endpointStack{t: t, ts: ts, handler: h, scopes: scopes, wtURL: wtURL} +} + +func (s *endpointStack) startWorker(target string, deployment string, endpoints []*livekit.AgentHttp_AgentEndpoint) *conformance.Worker { + return s.startNamedWorker("test-agent", target, deployment, endpoints) +} + +func (s *endpointStack) startNamedWorker(agentName, target, deployment string, endpoints []*livekit.AgentHttp_AgentEndpoint) *conformance.Worker { + w := conformance.New(conformance.Config{ + ServerURL: s.wtURL, + APIKey: testKey, + APISecret: testSecret, + AgentName: agentName, + Deployment: deployment, + Endpoints: endpoints, + TargetAddr: strings.TrimPrefix(target, "http://"), + Insecure: true, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(s.t, w.Start(ctx)) + require.NoError(s.t, w.WaitRegistered(ctx)) + s.t.Cleanup(w.Close) + s.waitRoutable(w, agentName, deployment) + return w +} + +// waitRoutable blocks until the node has installed the worker's routes. The +// register response the worker waits on is written before that, so a request +// sent the instant a worker calls itself registered can still miss it. +func (s *endpointStack) waitRoutable(w *conformance.Worker, agentName, deployment string) { + s.t.Helper() + require.Eventually(s.t, func() bool { + return slices.ContainsFunc( + s.scopes.Scope(testKey, agentName, deployment).Candidates(), + func(r *endpoint.Registration) bool { return r.WorkerID == w.WorkerID() }, + ) + }, 10*time.Second, time.Millisecond, "worker %s never reached the endpoint registry", w.WorkerID()) +} + +func (s *endpointStack) clientToken(t *testing.T) string { + at := auth.NewAccessToken(testKey, testSecret).SetVideoGrant(&auth.VideoGrant{RoomJoin: true, Room: "x"}) + tok, err := at.ToJWT() + require.NoError(t, err) + return tok +} + +func (s *endpointStack) endpointToken(t *testing.T, g *auth.AgentEndpointGrant) string { + at := auth.NewAccessToken(testKey, testSecret).SetAgentEndpointGrant(g) + tok, err := at.ToJWT() + require.NoError(t, err) + return tok +} + +func httpEP(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint { + return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public} +} + +// newTargetApp is the local app the worker bridges into; it never listens on a +// port reachable through the stack, only via the tunnel. +func newTargetApp(t *testing.T, mux *http.ServeMux) *httptest.Server { + app := httptest.NewServer(mux) + t.Cleanup(app.Close) + return app +} + +func TestAgentEndpointsCorrectnessGate(t *testing.T) { + bigDown := make([]byte, 4<<20) + _, _ = rand.Read(bigDown) + + mux := http.NewServeMux() + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) { + sum := sha256.New() + n, err := io.Copy(sum, r.Body) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + fmt.Fprintf(w, "%d:%x", n, sum.Sum(nil)) + }) + mux.HandleFunc("GET /big", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(bigDown) + }) + mux.HandleFunc("GET /sse", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + f := w.(http.Flusher) + for i := 0; i < 5; i++ { + fmt.Fprintf(w, "data: event-%d\n\n", i) + f.Flush() + time.Sleep(150 * time.Millisecond) + } + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/json", []string{"GET"}, true), + httpEP("/upload", []string{"POST"}, true), + httpEP("/big", []string{"GET"}, true), + httpEP("/sse", []string{"GET"}, true), + }) + + base := stack.ts.URL + "/agents/test-agent/production" + + t.Run("json round trip", func(t *testing.T) { + resp, err := http.Get(base + "/json") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.JSONEq(t, `{"ok":true}`, string(body)) + }) + + t.Run("upload byte exact", func(t *testing.T) { + up := make([]byte, 8<<20) + _, _ = rand.Read(up) + resp, err := http.Post(base+"/upload", "application/octet-stream", bytes.NewReader(up)) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, fmt.Sprintf("%d:%x", len(up), sha256.Sum256(up)), string(body)) + }) + + t.Run("download byte exact", func(t *testing.T) { + resp, err := http.Get(base + "/big") + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + require.True(t, bytes.Equal(bigDown, body)) + }) + + t.Run("sse events arrive one at a time", func(t *testing.T) { + start := time.Now() + resp, err := http.Get(base + "/sse") + require.NoError(t, err) + defer resp.Body.Close() + br := bufio.NewReader(resp.Body) + var arrivals []time.Duration + for { + line, err := br.ReadString('\n') + if err != nil { + break + } + if strings.HasPrefix(line, "data:") { + arrivals = append(arrivals, time.Since(start)) + } + } + require.Len(t, arrivals, 5) + // incremental delivery: the first event arrives well before the last is + // even written (5 x 150ms); buffering the whole body would collapse gaps + require.Less(t, arrivals[0], 450*time.Millisecond) + require.Greater(t, arrivals[4]-arrivals[0], 300*time.Millisecond) + }) + + t.Run("32 concurrent requests", func(t *testing.T) { + errCh := make(chan error, 32) + for i := 0; i < 32; i++ { + go func() { + resp, err := http.Get(base + "/json") + if err == nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + err = fmt.Errorf("status %d", resp.StatusCode) + } + } + errCh <- err + }() + } + for i := 0; i < 32; i++ { + require.NoError(t, <-errCh) + } + }) +} + +func TestAgentEndpointsStatusMapping(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/hook", []string{"POST"}, true), + httpEP("/private", []string{"GET"}, false), + }) + + base := stack.ts.URL + "/agents/test-agent/production" + + t.Run("404 unknown path", func(t *testing.T) { + resp, _ := http.Get(base + "/nope") + resp.Body.Close() + require.Equal(t, 404, resp.StatusCode) + }) + t.Run("405 wrong method", func(t *testing.T) { + resp, _ := http.Get(base + "/hook") + resp.Body.Close() + require.Equal(t, 405, resp.StatusCode) + }) + t.Run("401 non-public without token", func(t *testing.T) { + resp, _ := http.Get(base + "/private") + resp.Body.Close() + require.Equal(t, 401, resp.StatusCode) + require.Equal(t, "Bearer", resp.Header.Get("WWW-Authenticate")) + }) + t.Run("403 non-public with token lacking the grant", func(t *testing.T) { + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.clientToken(t)) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 403, resp.StatusCode) + // no challenge: the caller already presented a credential + require.Empty(t, resp.Header.Get("WWW-Authenticate")) + }) + t.Run("200 non-public with agent-endpoint grant", func(t *testing.T) { + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, &auth.AgentEndpointGrant{Call: true})) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + }) + t.Run("403 grant scoped to another deployment", func(t *testing.T) { + // candidates exist for this deployment; the grant scope is what denies it + g := &auth.AgentEndpointGrant{Call: true, Deployment: "staging"} + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, g)) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 403, resp.StatusCode) + }) + t.Run("200 non-public with grant scoped to this agent and deployment", func(t *testing.T) { + g := &auth.AgentEndpointGrant{Call: true, AgentName: "test-agent", Deployment: "production"} + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, g)) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + }) + t.Run("503 unknown deployment", func(t *testing.T) { + resp, _ := http.Get(stack.ts.URL + "/agents/test-agent/staging/hook") + resp.Body.Close() + require.Equal(t, 503, resp.StatusCode) + }) + t.Run("trailing slash normalized and served, not redirected", func(t *testing.T) { + // the route is registered as /hook (no slash); a request to /hook/ is + // normalized to /hook and served directly, never 307'd back to the client + c := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := c.Post(base+"/hook/", "text/plain", nil) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + }) +} + +// two workers of the same agent+deployment serve the SAME path with DIFFERENT +// methods; each request must route to the worker whose manifest serves its +// method (method-aware candidate selection: FULL = path+method). +func TestAgentEndpointsMethodAcrossWorkers(t *testing.T) { + postMux := http.NewServeMux() + postMux.HandleFunc("POST /thing", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "post-worker") + }) + postApp := newTargetApp(t, postMux) + + getMux := http.NewServeMux() + getMux.HandleFunc("GET /thing", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "get-worker") + }) + getApp := newTargetApp(t, getMux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(postApp.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/thing", []string{"POST"}, true), + }) + stack.startWorker(getApp.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/thing", []string{"GET"}, true), + }) + base := stack.ts.URL + "/agents/test-agent/production" + + t.Run("GET routes to the GET worker", func(t *testing.T) { + resp, err := http.Get(base + "/thing") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, "get-worker", string(body)) + }) + t.Run("POST routes to the POST worker", func(t *testing.T) { + resp, err := http.Post(base+"/thing", "text/plain", nil) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, "post-worker", string(body)) + }) + t.Run("unserved method is 405", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, base+"/thing", nil) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 405, resp.StatusCode) + }) +} + +func TestAgentEndpointsHOL(t *testing.T) { + // every exchange rides its own QUIC stream on the one session: QUIC's + // per-stream flow control is what keeps a stalled reader from blocking its + // siblings (there is no shared socket, no credit window, no write scheduler). + mux := http.NewServeMux() + mux.HandleFunc("GET /drip", func(w http.ResponseWriter, r *http.Request) { + f := w.(http.Flusher) + buf := make([]byte, 64<<10) + for { + if _, err := w.Write(buf); err != nil { + return + } + f.Flush() + select { + case <-r.Context().Done(): + return + default: + } + } + }) + mux.HandleFunc("GET /quick", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/drip", []string{"GET"}, true), + httpEP("/quick", []string{"GET"}, true), + }) + base := stack.ts.URL + "/agents/test-agent/production" + + // a stalled client: open /drip, read a little, then stop reading entirely + resp, err := http.Get(base + "/drip") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + small := make([]byte, 1024) + _, err = io.ReadFull(resp.Body, small) + require.NoError(t, err) + // do not read further; the drip stream's QUIC flow-control window fills + time.Sleep(500 * time.Millisecond) + + // sibling streams on the SAME connection must proceed at full speed + for i := 0; i < 5; i++ { + start := time.Now() + q, err := http.Get(base + "/quick") + require.NoError(t, err) + body, _ := io.ReadAll(q.Body) + q.Body.Close() + require.Equal(t, "ok", string(body)) + require.Less(t, time.Since(start), 2*time.Second, + "sibling stream stalled behind a blocked heavy stream") + } +} + +func TestAgentEndpointsRetrySafety(t *testing.T) { + var hits atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + // a worker whose local app is unreachable REFUSES streams; the front must + // retry on the healthy worker exactly once + stack := newEndpointStack(t, agent.EndpointsConfig{}) + deadTarget := "127.0.0.1:1" // nothing listens + eps := []*livekit.AgentHttp_AgentEndpoint{httpEP("/json", []string{"GET"}, true)} + + broken := conformance.New(conformance.Config{ + ServerURL: stack.wtURL, APIKey: testKey, APISecret: testSecret, + AgentName: "test-agent", Deployment: "production", + Endpoints: eps, TargetAddr: deadTarget, Insecure: true, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, broken.Start(ctx)) + require.NoError(t, broken.WaitRegistered(ctx)) + t.Cleanup(broken.Close) + stack.waitRoutable(broken, "test-agent", "production") + + stack.startWorker(app.URL, "production", eps) + + // run enough requests that both workers get picked first sometimes + okCount := 0 + for i := 0; i < 12; i++ { + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/json") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == 200 && string(body) == "ok" { + okCount++ + } + } + require.Equal(t, 12, okCount, "REFUSED streams must fall through to the healthy worker") + require.EqualValues(t, 12, hits.Load()) +} + +func TestAgentEndpointsTruncationAborts(t *testing.T) { + // a worker connection dying mid-response must abort the client connection, + // never expose a clean-looking short body + mux := http.NewServeMux() + release := make(chan struct{}) + mux.HandleFunc("GET /partial", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1000000") + _, _ = w.Write(make([]byte, 1000)) + w.(http.Flusher).Flush() + <-release + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + w := stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/partial", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/partial") + require.NoError(t, err) + defer resp.Body.Close() + + head := make([]byte, 1000) + _, err = io.ReadFull(resp.Body, head) + require.NoError(t, err) + + w.Close() // kill the worker mid-stream + close(release) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "truncated response must not read as clean EOF") +} + +func TestAgentEndpointsNoLocalListenerContract(t *testing.T) { + // the front never routes undeclared paths: the worker-local health/info + // routes are unreachable by construction + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("local")) }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/declared", []string{"GET"}, true), + }) + + for _, path := range []string{"/", "/worker", "/undeclared"} { + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production" + path) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 404, resp.StatusCode, path) + } +} + +func TestAgentEndpointsNonUTF8HeaderSurvives(t *testing.T) { + // header values are octets: a latin-1 filename must reach the client + // byte-for-byte + const disposition = "attachment; filename=\"caf\xe9.txt\"" + + mux := http.NewServeMux() + mux.HandleFunc("GET /download", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", disposition) + _, _ = w.Write([]byte("data")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/download", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/download") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "data", string(body)) + require.Equal(t, disposition, resp.Header.Get("Content-Disposition")) +} + +// the agent name and the request tail are percent-encoded path segments, and +// the worker's request line carries the client's bytes. +func TestAgentEndpointsEncodedNameAndPath(t *testing.T) { + const agentName = "LODHA Vayam/Agent" + + targets := make(chan string, 4) + mux := http.NewServeMux() + mux.HandleFunc("GET /echo/{rest...}", func(w http.ResponseWriter, r *http.Request) { + targets <- r.RequestURI + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startNamedWorker(agentName, app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/echo/{rest:path}", []string{"GET"}, true), + }) + + base := stack.ts.URL + "/agents/" + url.PathEscape(agentName) + "/production" + + get := func(t *testing.T, path string) (int, string) { + t.Helper() + resp, err := http.Get(base + path) + require.NoError(t, err) + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + select { + case target := <-targets: + return resp.StatusCode, target + case <-time.After(5 * time.Second): + return resp.StatusCode, "" + } + } + + t.Run("a name with a space and a slash addresses its worker", func(t *testing.T) { + code, target := get(t, "/echo/plain") + require.Equal(t, 200, code) + require.Equal(t, "/echo/plain", target) + }) + + t.Run("an encoded slash in the tail stays encoded", func(t *testing.T) { + code, target := get(t, "/echo/a%2Fb") + require.Equal(t, 200, code) + require.Equal(t, "/echo/a%2Fb", target, "a decoded %2F would re-emit as a separator and change the resource") + }) + + t.Run("the query is passed through verbatim", func(t *testing.T) { + code, target := get(t, "/echo/q?a=1&b=%2F%20x") + require.Equal(t, 200, code) + require.Equal(t, "/echo/q?a=1&b=%2F%20x", target) + }) +} + +func TestAgentEndpointsWithheldOverWebSocket(t *testing.T) { + server := testutils.NewTestServer(psrpc.NewLocalMessageBus()) + t.Cleanup(server.Close) + + worker := server.SimulateAgentWorker() + responses := worker.RegisterWorkerResponses.Observe() + defer responses.Stop() + worker.SendRegister(&livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: "ws-agent", + Endpoints: []*livekit.AgentHttp_AgentEndpoint{{Path: "/json", Methods: []string{"GET"}}}, + InstanceId: "ws-instance", + EndpointProtocol: wire.CurrentProtocol, + }) + + select { + case res := <-responses.Events(): + require.NotEmpty(t, res.GetWorkerId()) + require.Nil(t, res.GetEndpointSettings()) + case <-time.After(time.Second): + require.Fail(t, "registration timeout") + } +} diff --git a/pkg/service/agentendpointchain_test.go b/pkg/service/agentendpointchain_test.go new file mode 100644 index 000000000..8d028d03c --- /dev/null +++ b/pkg/service/agentendpointchain_test.go @@ -0,0 +1,358 @@ +// 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 service_test + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/http" + "net/http/httptrace" + "net/textproto" + "strings" + "sync/atomic" + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" +) + +// rawHTTPTarget speaks HTTP/1.1 by hand, so it can end a response in ways +// net/http will not produce. +func rawHTTPTarget(t *testing.T, respond func(net.Conn)) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + br := bufio.NewReader(c) + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + if line == "\r\n" { + break + } + } + respond(c) + }() + } + }() + return ln.Addr().String() +} + +// A chunked body cut short must not reach the client as complete, and nothing in +// the serving chain may recover the abort that prevents it. +func TestAgentChainDeliversTruncatedChunkedResponse(t *testing.T) { + addr := rawHTTPTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n") + _, _ = fmt.Fprintf(c, "%x\r\n", 1000) + _, _ = c.Write(bytes.Repeat([]byte("y"), 1000)) + _, _ = io.WriteString(c, "\r\n") + // closes without the terminating 0-length chunk + }) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(addr, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/partial", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/partial") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.Error(t, err, "a truncated chunked body must not read as a clean EOF through the serving chain") + require.NotContains(t, string(body), "PANIC:", "recovery must not write panic text into a committed body") + require.NotContains(t, string(body), "goroutine ", "a stack trace must never reach the client") +} + +// The front streams request bodies, so its chain does not bound them. +func TestAgentChainExemptsAPIBodyLimiter(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) { + n, err := io.Copy(io.Discard, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "%d", n) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/upload", []string{"POST"}, true), + }) + + big := bytes.Repeat([]byte("z"), testMaxAPIBodySize*2) + + resp, err := http.Post(stack.ts.URL+"/agents/test-agent/production/upload", "application/octet-stream", bytes.NewReader(big)) + require.NoError(t, err) + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, fmt.Sprintf("%d", len(big)), string(got), "the whole body must reach the application") + + // the same body on an API route is still rejected + apiResp, err := http.Post(stack.ts.URL+"/api-sink", "application/octet-stream", bytes.NewReader(big)) + require.NoError(t, err) + defer apiResp.Body.Close() + require.Equal(t, http.StatusRequestEntityTooLarge, apiResp.StatusCode) +} + +// A manifest may declare PUT, so the agent chain's preflight must allow it. +func TestAgentChainCORSAllowsPUT(t *testing.T) { + stack := newEndpointStack(t, agent.EndpointsConfig{}) + + preflight := func(t *testing.T, path string) string { + t.Helper() + req, err := http.NewRequest(http.MethodOptions, stack.ts.URL+path, nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodPut) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + return resp.Header.Get("Access-Control-Allow-Methods") + } + + require.Contains(t, preflight(t, "/agents/test-agent/production/thing"), http.MethodPut) + require.NotContains(t, preflight(t, "/api-sink"), http.MethodPut) +} + +// A worker's CORS headers replace the chain's. +func TestAgentChainWorkerCORSReplacesServer(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /cors", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "https://app.example") + w.Header().Set("Vary", "Accept-Encoding") + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/cors", []string{"GET"}, true), + }) + + req, err := http.NewRequest(http.MethodGet, stack.ts.URL+"/agents/test-agent/production/cors", nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://example.com") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, []string{"https://app.example"}, resp.Header.Values("Access-Control-Allow-Origin")) + require.Equal(t, []string{"Accept-Encoding"}, resp.Header.Values("Vary")) +} + +// The chain's CORS headers survive an informational head. +func TestAgentChainKeepsCORSAfterEarlyHints(t *testing.T) { + addr := rawHTTPTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n") + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(addr, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/hints", []string{"GET"}, true), + }) + + var hints atomic.Int32 + trace := &httptrace.ClientTrace{ + Got1xxResponse: func(code int, _ textproto.MIMEHeader) error { + if code == http.StatusEarlyHints { + hints.Add(1) + } + return nil + }, + } + req, err := http.NewRequest(http.MethodGet, stack.ts.URL+"/agents/test-agent/production/hints", nil) + require.NoError(t, err) + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + req.Header.Set("Origin", "https://example.com") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.EqualValues(t, 1, hints.Load()) + require.Equal(t, []string{"https://example.com"}, resp.Header.Values("Access-Control-Allow-Origin")) + require.Equal(t, []string{"Origin"}, resp.Header.Values("Vary")) +} + +// The chain answers preflights; a plain OPTIONS reaches a declared route. +func TestAgentChainAnswersPreflights(t *testing.T) { + var hits atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("OPTIONS /opts", func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/opts", []string{"OPTIONS"}, true), + }) + url := stack.ts.URL + "/agents/test-agent/production/opts" + + req, err := http.NewRequest(http.MethodOptions, url, nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + preflight, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer preflight.Body.Close() + require.Equal(t, http.StatusNoContent, preflight.StatusCode) + require.Equal(t, "https://example.com", preflight.Header.Get("Access-Control-Allow-Origin")) + require.Contains(t, preflight.Header.Get("Access-Control-Allow-Methods"), http.MethodPost) + require.Zero(t, hits.Load(), "a preflight must not reach the worker") + + req, err = http.NewRequest(http.MethodOptions, url, nil) + require.NoError(t, err) + plain, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer plain.Body.Close() + require.Equal(t, http.StatusOK, plain.StatusCode) + require.EqualValues(t, 1, hits.Load()) +} + +// Disabled leaves the prefix unserved. +func TestAgentEndpointsDisabledIsNotServed(t *testing.T) { + stack := newEndpointStack(t, agent.EndpointsConfig{Disabled: true}) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/json") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// A leading "//" is folded above the split, so it still routes to the agent chain. +func TestAgentChainRoutesDoubleSlashedPath(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/json", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "//agents/test-agent/production/json") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "ok", string(body)) +} + +// The front resolves access from the grants the api-key auth middleware installs, +// so the agent chain has to carry it. +func TestAgentChainStillResolvesGrants(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /private", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/private", []string{"GET"}, false), + }) + url := stack.ts.URL + "/agents/test-agent/production/private" + + resp, err := http.Get(url) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "a non-public route needs a grant") + + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, &auth.AgentEndpointGrant{ + Call: true, AgentName: "test-agent", Deployment: "production", + })) + granted, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer granted.Body.Close() + require.Equal(t, http.StatusOK, granted.StatusCode, "a scoped grant must reach the application") +} + +// A private WebSocket route takes its grant as access_token; the tunnel must +// survive every wrapper in the chain, and the token must not reach the +// application. +func TestAgentChainWebSocketWithQueryToken(t *testing.T) { + seenQuery := make(chan string, 1) + up := websocket.Upgrader{} + mux := http.NewServeMux() + mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) { + seenQuery <- r.URL.RawQuery + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + mt, msg, err := c.ReadMessage() + if err != nil { + return + } + _ = c.WriteMessage(mt, msg) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/ws", []string{"GET"}, false), + }) + url := "ws" + strings.TrimPrefix(stack.ts.URL, "http") + "/agents/test-agent/production/ws" + + _, resp, err := websocket.DefaultDialer.Dial(url, nil) + require.Error(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "a non-public route needs a grant") + + token := stack.endpointToken(t, &auth.AgentEndpointGrant{ + Call: true, AgentName: "test-agent", Deployment: "production", + }) + c, resp, err := websocket.DefaultDialer.Dial(url+"?room=r1&access_token="+token, nil) + require.NoError(t, err) + defer c.Close() + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + require.Equal(t, "room=r1", <-seenQuery) + + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte("ping"))) + _, msg, err := c.ReadMessage() + require.NoError(t, err) + require.Equal(t, "ping", string(msg)) +} diff --git a/pkg/service/agentendpointmiddleware.go b/pkg/service/agentendpointmiddleware.go new file mode 100644 index 000000000..fb153eebe --- /dev/null +++ b/pkg/service/agentendpointmiddleware.go @@ -0,0 +1,57 @@ +// 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 service + +import ( + "fmt" + "net/http" + "strings" + + "github.com/urfave/negroni/v3" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" +) + +// IsAgentEndpointPath reports whether an escaped request path targets the agent +// endpoint front. The path must already be normalized; see WithPathNormalization. +func IsAgentEndpointPath(escapedPath string) bool { + return strings.HasPrefix(escapedPath, endpoint.PathPrefix) +} + +// AgentRecovery recovers panics, re-panicking http.ErrAbortHandler so it reaches +// net/http and aborts the response. +func AgentRecovery(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + defer func() { + rec := recover() + if rec == nil { + return + } + if rec == http.ErrAbortHandler { + panic(rec) + } + err, ok := rec.(error) + if !ok { + err = fmt.Errorf("%v", rec) + } + logger.Errorw("panic serving agent endpoint", err, "path", r.URL.Path) + // a committed response already has its status on the wire + if nrw, ok := w.(negroni.ResponseWriter); !ok || !nrw.Written() { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next(w, r) +} diff --git a/pkg/service/agentendpointscope.go b/pkg/service/agentendpointscope.go new file mode 100644 index 000000000..1a6ab251f --- /dev/null +++ b/pkg/service/agentendpointscope.go @@ -0,0 +1,117 @@ +// 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 service + +import ( + "sync" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" +) + +// endpointScopeKey is this server's tenancy for agent HTTP endpoints: the api +// key is the project identity. The endpoint package holds no tenancy identity of +// its own, so this is the one place that rule is written. +type endpointScopeKey struct { + apiKey string + agentName string + deployment string +} + +func newEndpointScopeKey(apiKey, agentName, deployment string) endpointScopeKey { + return endpointScopeKey{apiKey, agentName, deployment} +} + +// EndpointScopes maps that tenancy onto the endpoint package's scopes, one per +// key, held for as long as a worker holds it. +type EndpointScopes struct { + logger logger.Logger + + mu sync.Mutex + scopes map[endpointScopeKey]*endpointScopeEntry +} + +// endpointScopeEntry refcounts a scope by the registrations that hold it: a +// scope must outlive a worker that has acquired it but not yet registered, or a +// concurrent release would strand the new registration in an orphaned scope. +type endpointScopeEntry struct { + scope *endpoint.Scope + n int +} + +func NewEndpointScopes() *EndpointScopes { + return &EndpointScopes{ + logger: logger.GetLogger().WithComponent("agents.endpoint"), + scopes: make(map[endpointScopeKey]*endpointScopeEntry), + } +} + +// acquire returns the key's scope, creating it on first use. Every acquire must +// be matched by a release. +func (s *EndpointScopes) acquire(k endpointScopeKey) *endpoint.Scope { + s.mu.Lock() + defer s.mu.Unlock() + e := s.scopes[k] + if e == nil { + e = &endpointScopeEntry{scope: endpoint.NewScope(s.logger.WithValues( + "apiKey", k.apiKey, "agentName", k.agentName, "deployment", k.deployment))} + s.scopes[k] = e + } + e.n++ + return e.scope +} + +func (s *EndpointScopes) release(k endpointScopeKey) { + s.mu.Lock() + defer s.mu.Unlock() + e := s.scopes[k] + if e == nil { + return + } + if e.n--; e.n <= 0 { + delete(s.scopes, k) + } +} + +// Scope returns the deployment's serving state, or nil when no worker holds it - +// which the front reads as "no worker for this deployment". +func (s *EndpointScopes) Scope(apiKey, agentName, deployment string) *endpoint.Scope { + s.mu.Lock() + defer s.mu.Unlock() + if e := s.scopes[newEndpointScopeKey(apiKey, agentName, deployment)]; e != nil { + return e.scope + } + return nil +} + +// SingleKey returns the api key when every scope on this node belongs to one - +// the resolution for unauthenticated requests to public endpoints. It is sound +// only because a guessed api key confers no access: the front still serves such +// a request only against routes marked public. ok is false when zero or multiple +// keys are present. +func (s *EndpointScopes) SingleKey() (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + var key string + for k := range s.scopes { + if key == "" { + key = k.apiKey + } else if key != k.apiKey { + return "", false + } + } + return key, key != "" +} diff --git a/pkg/service/agentservice.go b/pkg/service/agenthandler.go similarity index 69% rename from pkg/service/agentservice.go rename to pkg/service/agenthandler.go index b5128cb9e..62e3b03fe 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agenthandler.go @@ -18,81 +18,28 @@ import ( "context" "errors" "math/rand" - "net/http" "slices" "sort" - "strconv" "sync" "time" - "github.com/gorilla/websocket" "google.golang.org/protobuf/types/known/emptypb" - "github.com/livekit/livekit-server/pkg/agent" - "github.com/livekit/livekit-server/pkg/config" - "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc" - "github.com/livekit/livekit-server/pkg/rtc/types" - "github.com/livekit/livekit-server/version" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" "github.com/livekit/protocol/utils" "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/livekit-server/version" ) -type AgentSocketUpgrader struct { - websocket.Upgrader -} - -func (u AgentSocketUpgrader) Upgrade( - w http.ResponseWriter, - r *http.Request, - responseHeader http.Header, -) ( - conn *websocket.Conn, - registration agent.WorkerRegistration, - ok bool, -) { - if u.CheckOrigin == nil { - // allow connections from any origin, since script may be hosted anywhere - // security is enforced by access tokens - u.CheckOrigin = func(r *http.Request) bool { - return true - } - } - - // reject non websocket requests - if !websocket.IsWebSocketUpgrade(r) { - w.WriteHeader(404) - return - } - - // require a claim - claims := GetGrants(r.Context()) - if claims == nil || claims.Video == nil || !claims.Video.Agent { - HandleError(w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied) - return - } - - registration = agent.MakeWorkerRegistration() - registration.ClientIP = GetClientIP(r) - - // upgrade - conn, err := u.Upgrader.Upgrade(w, r, responseHeader) - if err != nil { - HandleError(w, r, http.StatusInternalServerError, err) - return - } - - if pv, err := strconv.Atoi(r.FormValue("protocol")); err == nil { - registration.Protocol = agent.WorkerProtocolVersion(pv) - } - - return conn, registration, true -} - func DispatchAgentWorkerSignal(c agent.SignalConn, h agent.WorkerSignalHandler, l logger.Logger) bool { req, _, err := c.ReadWorkerMessage() if err != nil { @@ -112,8 +59,8 @@ func DispatchAgentWorkerSignal(c agent.SignalConn, h agent.WorkerSignalHandler, return true } -func HandshakeAgentWorker(c agent.SignalConn, serverInfo *livekit.ServerInfo, registration agent.WorkerRegistration, l logger.Logger) (r agent.WorkerRegistration, ok bool) { - wr := agent.NewWorkerRegisterer(c, serverInfo, registration) +func HandshakeAgentWorker(c agent.SignalConn, serverInfo *livekit.ServerInfo, registration agent.WorkerRegistration, l logger.Logger, handlers ...agent.WorkerRegisterHandler) (r agent.WorkerRegistration, ok bool) { + wr := agent.NewWorkerRegisterer(c, serverInfo, registration, handlers...) if err := c.SetReadDeadline(wr.Deadline()); err != nil { return } @@ -128,18 +75,14 @@ func HandshakeAgentWorker(c agent.SignalConn, serverInfo *livekit.ServerInfo, re return wr.Registration(), true } -type AgentService struct { - upgrader AgentSocketUpgrader - - signalMessageSizeLimit int64 - - *AgentHandler -} - type AgentHandler struct { agentServer rpc.AgentInternalServer - mu sync.Mutex - logger logger.Logger + // the server's only configured api key, when exactly one exists: the + // unauthenticated identity for public endpoints regardless of which node + // holds the workers + singleAPIKey string + mu sync.Mutex + logger logger.Logger serverInfo *livekit.ServerInfo workers map[string]*agent.Worker @@ -147,6 +90,10 @@ type AgentHandler struct { keyProvider auth.KeyProvider targetLoad float32 + endpointRegistry *endpoint.Registry + endpointScopes *EndpointScopes + endpointsConfig agent.EndpointsConfig + namespaceWorkers map[workerKey][]*agent.Worker roomKeyCount int publisherKeyCount int @@ -166,85 +113,80 @@ type workerKey struct { deployment string } -func NewAgentService( +// NewAgentHandler builds the node's agent worker handler, shared by the +// transport services. +func NewAgentHandler( conf *config.Config, currentNode routing.LocalNode, bus psrpc.MessageBus, keyProvider auth.KeyProvider, -) (*AgentService, error) { - s := &AgentService{ - signalMessageSizeLimit: conf.Limit.AgentSignalMessageSizeLimit, - } - - serverInfo := &livekit.ServerInfo{ - Edition: livekit.ServerInfo_Standard, - Version: version.Version, - Protocol: types.CurrentProtocol, - AgentProtocol: agent.CurrentProtocol, - Region: conf.Region, - NodeId: string(currentNode.NodeID()), - } - - agentServer, err := rpc.NewAgentInternalServer(s, bus) - if err != nil { - return nil, err - } - s.AgentHandler = NewAgentHandler( - agentServer, - keyProvider, - logger.GetLogger(), - serverInfo, - conf.Agents.TargetLoad, - agent.RoomAgentTopic, - agent.PublisherAgentTopic, - agent.ParticipantAgentTopic, - ) - return s, nil -} - -func (s *AgentService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if conn, registration, ok := s.upgrader.Upgrade(w, r, nil); ok { - // bound the size of a single signalling frame so an oversized message is - // rejected by the transport before being fully buffered in memory. This - // limits the compressed bytes read off the wire; the decompressed size is - // bounded separately in WSSignalConnection. - if s.signalMessageSizeLimit > 0 { - conn.SetReadLimit(s.signalMessageSizeLimit) - } - sigConn := NewWSSignalConnection(conn, s.signalMessageSizeLimit) - defer sigConn.Close() - - s.HandleConnection(r.Context(), sigConn, registration) - } -} - -func NewAgentHandler( - agentServer rpc.AgentInternalServer, - keyProvider auth.KeyProvider, - logger logger.Logger, - serverInfo *livekit.ServerInfo, - targetLoad float32, - roomTopic string, - publisherTopic string, - participantTopic string, -) *AgentHandler { - return &AgentHandler{ - agentServer: agentServer, - logger: logger.WithComponent("agents"), + registry *endpoint.Registry, + scopes *EndpointScopes, +) (*AgentHandler, error) { + h := &AgentHandler{ + logger: logger.GetLogger().WithComponent("agents"), workers: make(map[string]*agent.Worker), jobToWorker: make(map[livekit.JobID]*agent.Worker), namespaceWorkers: make(map[workerKey][]*agent.Worker), - serverInfo: serverInfo, + serverInfo: &livekit.ServerInfo{ + Edition: livekit.ServerInfo_Standard, + Version: version.Version, + Protocol: types.CurrentProtocol, + AgentProtocol: agent.CurrentProtocol, + Region: conf.Region, + NodeId: string(currentNode.NodeID()), + }, keyProvider: keyProvider, - targetLoad: targetLoad, - roomTopic: roomTopic, - publisherTopic: publisherTopic, - participantTopic: participantTopic, + targetLoad: conf.Agents.TargetLoad, + roomTopic: agent.RoomAgentTopic, + publisherTopic: agent.PublisherAgentTopic, + participantTopic: agent.ParticipantAgentTopic, + endpointRegistry: registry, + endpointScopes: scopes, + endpointsConfig: conf.Agents.Endpoints, } + if len(conf.Keys) == 1 { + for key := range conf.Keys { + h.singleAPIKey = key + } + } + + agentServer, err := rpc.NewAgentInternalServer(h, bus) + if err != nil { + return nil, err + } + h.agentServer = agentServer + + return h, nil } +// endpointRegisterHandler negotiates a worker's endpoint settings, refusing any +// declaration when endpoints are turned off. +func (h *AgentHandler) endpointRegisterHandler(req *livekit.RegisterWorkerRequest, res *livekit.RegisterWorkerResponse, reg *agent.WorkerRegistration) error { + if len(req.GetEndpoints()) > 0 && h.endpointsConfig.Disabled { + return errors.New("agent HTTP endpoints are disabled on this server") + } + return agent.EndpointRegisterHandler(req, res, reg) +} + +// HandleConnection serves a worker's control connection with no data-plane +// session (the WebSocket path: control and job dispatch only). func (h *AgentHandler) HandleConnection(ctx context.Context, conn agent.SignalConn, registration agent.WorkerRegistration) { - registration, ok := HandshakeAgentWorker(conn, h.serverInfo, registration, h.logger) + h.handleConnection(ctx, conn, registration, nil) +} + +// handleConnection serves a worker's control connection. sess is the worker's +// data-plane session when it registered over WebTransport (control and HTTP +// exchanges share it); nil for a WebSocket control connection, which serves no +// endpoints. +func (h *AgentHandler) handleConnection(ctx context.Context, conn agent.SignalConn, registration agent.WorkerRegistration, sess endpoint.Session) { + // without a session there is no data plane, so endpoint settings are withheld + // rather than promising routes nothing will serve + var handlers []agent.WorkerRegisterHandler + if sess != nil { + handlers = append(handlers, h.endpointRegisterHandler) + } + registration, ok := HandshakeAgentWorker(conn, h.serverInfo, registration, h.logger, handlers...) if !ok { return } @@ -255,15 +197,65 @@ func (h *AgentHandler) HandleConnection(ctx context.Context, conn agent.SignalCo worker := agent.NewWorker(registration, apiKey, apiSecret, conn, h.logger) h.registerWorker(worker) + if sess == nil && len(registration.Endpoints) > 0 { + worker.Logger().Infow("endpoints ignored, connection has no data plane", + "agentName", registration.AgentName, + "deployment", registration.Deployment, + ) + } + + endpointTeardown := h.registerEndpoints(worker, sess) + handlerWorker := &agentHandlerWorker{h, worker} for ok := true; ok; { ok = DispatchAgentWorkerSignal(conn, handlerWorker, worker.Logger()) } + if endpointTeardown != nil { + endpointTeardown() + } h.deregisterWorker(worker) worker.Close() } +// registerEndpoints registers a worker's endpoint manifest into the scope its +// api key, agent name and deployment address, binding it to the worker's +// data-plane session. The registration lives exactly as long as the control +// connection, so the returned teardown must run when that connection ends; it +// is nil when the worker serves no endpoints. A nil session (WebSocket control +// path) registers nothing, since HTTP endpoints require a WebTransport session +// to serve them. +func (h *AgentHandler) registerEndpoints(w *agent.Worker, sess endpoint.Session) func() { + if sess == nil || w.EndpointSettings == nil { + return nil + } + manifest, err := endpoint.ParseManifest(w.Endpoints) + if err != nil { + // validated during the handshake; a failure here is a programming error + w.Logger().Errorw("endpoint manifest failed to re-parse", err) + return nil + } + reg := endpoint.NewRegistration(endpoint.RegistrationParams{ + WorkerID: w.ID, + Manifest: manifest, + Session: sess, + Draining: w.Draining, + }) + key := newEndpointScopeKey(w.APIKey(), w.AgentName, w.Deployment) + h.endpointRegistry.Register(h.endpointScopes.acquire(key), reg) + w.Logger().Infow("endpoints registered", + "namespace", w.Namespace, + "agentName", w.AgentName, + "deployment", w.Deployment, + "workerID", w.ID, + "manifest", manifest, + ) + return func() { + h.endpointRegistry.Deregister(reg) + h.endpointScopes.release(key) + } +} + func (h *AgentHandler) registerWorker(w *agent.Worker) { h.mu.Lock() @@ -317,6 +309,7 @@ func (h *AgentHandler) registerWorker(w *agent.Worker) { "namespace", w.Namespace, "jobType", w.JobType, "agentName", w.AgentName, + "deployment", w.Deployment, "workerID", w.ID, ) if created { @@ -352,6 +345,7 @@ func (h *AgentHandler) deregisterWorker(w *agent.Worker) { "namespace", w.Namespace, "jobType", w.JobType, "agentName", w.AgentName, + "deployment", w.Deployment, "workerID", w.ID, ) delete(h.namespaceWorkers, key) @@ -398,6 +392,7 @@ func (h *AgentHandler) JobRequest(ctx context.Context, job *livekit.Job) (*rpc.J "jobID", job.Id, "namespace", job.Namespace, "agentName", job.AgentName, + "deployment", job.Deployment, "jobType", job.Type.String(), ) if job.Room != nil { @@ -423,14 +418,19 @@ func (h *AgentHandler) JobRequest(ctx context.Context, job *livekit.Job) (*rpc.J switch state.GetStatus() { case livekit.JobStatus_JS_RUNNING: logger.Infow("assigned job to worker", "apiKey", selected.APIKey()) - h.mu.Lock() - h.jobToWorker[livekit.JobID(job.Id)] = selected - h.mu.Unlock() - - err = h.agentServer.RegisterJobTerminateTopic(job.Id) - if err != nil { + jobID := livekit.JobID(job.Id) + if err := h.agentServer.RegisterJobTerminateTopic(job.Id); err != nil { logger.Errorw("failed to register JobTerminate handler", err) } + + // checked under h.mu after registering so that either this or the job/worker cleanup, whichever runs second, releases the handler. + h.mu.Lock() + if _, err := selected.GetJobState(jobID); err == nil && h.workers[selected.ID] == selected { + h.jobToWorker[jobID] = selected + } else { + h.deregisterJob(jobID) + } + h.mu.Unlock() fallthrough case livekit.JobStatus_JS_SUCCESS: return &rpc.JobRequestResponse{ @@ -474,6 +474,15 @@ func (h *AgentHandler) JobTerminate(ctx context.Context, req *rpc.JobTerminateRe } state, err := w.TerminateJob(livekit.JobID(req.JobId), req.Reason) + + // the job is no longer tracked as running, and the worker may never send an + // ended status update for it, so deregister it here. deregistering from + // inside this handler is safe because psrpc force closes the handler + // without waiting for in-flight requests. + h.mu.Lock() + h.deregisterJob(livekit.JobID(req.JobId)) + h.mu.Unlock() + if err != nil { return nil, err } diff --git a/pkg/service/agentws.go b/pkg/service/agentws.go new file mode 100644 index 000000000..c9f0c538a --- /dev/null +++ b/pkg/service/agentws.go @@ -0,0 +1,102 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "net/http" + "strconv" + + "github.com/gorilla/websocket" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/rtc" +) + +type AgentSocketUpgrader struct { + websocket.Upgrader +} + +func (u AgentSocketUpgrader) Upgrade( + w http.ResponseWriter, + r *http.Request, + responseHeader http.Header, +) ( + conn *websocket.Conn, + registration agent.WorkerRegistration, + ok bool, +) { + if u.CheckOrigin == nil { + // allow connections from any origin, since script may be hosted anywhere + // security is enforced by access tokens + u.CheckOrigin = func(r *http.Request) bool { + return true + } + } + + if !websocket.IsWebSocketUpgrade(r) { + w.WriteHeader(404) + return + } + + claims := GetGrants(r.Context()) + if claims == nil || claims.Video == nil || !claims.Video.Agent { + HandleError(w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied) + return + } + + registration = agent.MakeWorkerRegistration() + registration.ClientIP = GetClientIP(r) + + conn, err := u.Upgrader.Upgrade(w, r, responseHeader) + if err != nil { + HandleError(w, r, http.StatusInternalServerError, err) + return + } + + if pv, err := strconv.Atoi(r.FormValue("protocol")); err == nil { + registration.Protocol = agent.WorkerProtocolVersion(pv) + } + + return conn, registration, true +} + +// AgentWSService serves a worker's WebSocket control connection on /agent: +// control and job dispatch only, with no data-plane session for HTTP endpoints. +type AgentWSService struct { + *AgentHandler + + upgrader AgentSocketUpgrader + signalMessageSizeLimit int64 +} + +func NewAgentWSService(conf *config.Config, h *AgentHandler) *AgentWSService { + return &AgentWSService{ + AgentHandler: h, + signalMessageSizeLimit: conf.Limit.AgentSignalMessageSizeLimit, + } +} + +func (s *AgentWSService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if conn, registration, ok := s.upgrader.Upgrade(w, r, nil); ok { + // bound a single signalling frame before it is buffered; 0 disables + if s.signalMessageSizeLimit > 0 { + conn.SetReadLimit(s.signalMessageSizeLimit) + } + sigConn := NewWSSignalConnection(conn, s.signalMessageSizeLimit) + defer sigConn.Close() + s.HandleConnection(r.Context(), sigConn, registration) + } +} diff --git a/pkg/service/agentwt.go b/pkg/service/agentwt.go new file mode 100644 index 000000000..d442eb4db --- /dev/null +++ b/pkg/service/agentwt.go @@ -0,0 +1,143 @@ +// 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 service + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" + + "github.com/quic-go/webtransport-go" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/livekit-server/pkg/rtc" +) + +// AgentWTService serves a worker's WebTransport session on /agent: one control +// stream, plus a node-opened QUIC stream per HTTP exchange. +type AgentWTService struct { + *AgentHandler +} + +func NewAgentWTService(h *AgentHandler) *AgentWTService { + return &AgentWTService{AgentHandler: h} +} + +func (s *AgentWTService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + sess, registration, ok := UpgradeAgentWebTransport(w, r, s.logger) + if !ok { + return + } + + sigConn, epSession, ok := AcceptAgentControlStream(sess, s.endpointsConfig.MaxStreams) + if !ok { + return + } + defer sigConn.Close() + + s.handleConnection(r.Context(), sigConn, registration, epSession) +} + +// UpgradeAgentWebTransport verifies the agent grant, builds the worker's +// registration from the request and upgrades to a WebTransport session. An outer +// auth middleware must already have placed the grant in the request context. +func UpgradeAgentWebTransport(w http.ResponseWriter, r *http.Request, l logger.Logger) ( + sess *webtransport.Session, + registration agent.WorkerRegistration, + ok bool, +) { + claims := GetGrants(r.Context()) + if claims == nil || claims.Video == nil || !claims.Video.Agent { + HandleError(w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied) + return + } + + registration = agent.MakeWorkerRegistration() + registration.ClientIP = GetClientIP(r) + if pv, err := strconv.Atoi(r.FormValue("protocol")); err == nil { + registration.Protocol = agent.WorkerProtocolVersion(pv) + } + + sess, err := UpgradeWebTransport(w, r) + if err != nil { + l.Warnw("agent webtransport upgrade failed", err) + return nil, registration, false + } + + return sess, registration, true +} + +// AcceptAgentControlStream waits for the worker's control stream and wraps the +// session for the data plane. The wait is bounded so a session that upgrades but +// never opens a control stream can't hold a goroutine until the QUIC idle timeout. +func AcceptAgentControlStream(sess *webtransport.Session, maxStreams uint32) (agent.SignalConn, endpoint.Session, bool) { + acceptCtx, cancel := context.WithTimeout(sess.Context(), agent.RegisterTimeout) + defer cancel() + control, err := sess.AcceptStream(acceptCtx) + if err != nil { + _ = sess.CloseWithError(wire.SessionCloseOK, "no control stream") + return nil, nil, false + } + if maxStreams == 0 { + maxStreams = endpoint.DefaultMaxStreams + } + return NewWTSignalConn(sess, control), endpoint.NewWebTransportSession(sess, int(maxStreams)), true +} + +// wtSignalConn adapts a WebTransport control stream to agent.SignalConn: a +// length-delimited WorkerMessage/ServerMessage exchange on one QUIC bidirectional +// stream. +type wtSignalConn struct { + sess *webtransport.Session + control *webtransport.Stream + writeMu sync.Mutex +} + +func NewWTSignalConn(sess *webtransport.Session, control *webtransport.Stream) agent.SignalConn { + return &wtSignalConn{sess: sess, control: control} +} + +func (c *wtSignalConn) WriteServerMessage(msg *livekit.ServerMessage) (int, error) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return 0, wire.WriteControlMessage(c.control, msg) +} + +func (c *wtSignalConn) ReadWorkerMessage() (*livekit.WorkerMessage, int, error) { + var msg livekit.WorkerMessage + if err := wire.ReadControlMessage(c.control, &msg); err != nil { + return nil, 0, err + } + return &msg, 0, nil +} + +func (c *wtSignalConn) SetReadDeadline(t time.Time) error { + return c.control.SetReadDeadline(t) +} + +func (c *wtSignalConn) Close() error { + return c.sess.CloseWithError(wire.SessionCloseOK, "") +} + +func (c *wtSignalConn) CloseWithReason(reason string) error { + return c.sess.CloseWithError(wire.SessionCloseOK, reason) +} diff --git a/pkg/service/auth.go b/pkg/service/auth.go index 9d8c1e921..d6122dde7 100644 --- a/pkg/service/auth.go +++ b/pkg/service/auth.go @@ -76,8 +76,11 @@ func (m *APIKeyAuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, authToken = authHeader[len(bearerPrefix):] } else { - // attempt to find from request header - authToken = r.FormValue(accessTokenParam) + // fall back to the query string rather than a form body, which must + // survive intact for requests proxied further (agent HTTP endpoints). + if r.URL != nil { + authToken = r.URL.Query().Get(accessTokenParam) + } } if authToken != "" { diff --git a/pkg/service/ingress.go b/pkg/service/ingress.go index 6441756fb..528309c68 100644 --- a/pkg/service/ingress.go +++ b/pkg/service/ingress.go @@ -19,8 +19,6 @@ import ( "fmt" "net/url" - "github.com/livekit/livekit-server/pkg/config" - "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/protocol/ingress" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -28,8 +26,12 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/telemetry" ) +//counterfeiter:generate . IngressLauncher type IngressLauncher interface { LaunchPullIngress(ctx context.Context, info *livekit.IngressInfo) (*livekit.IngressInfo, error) } @@ -133,7 +135,13 @@ func (s *IngressService) CreateIngressWithUrl(ctx context.Context, urlStr string if err != nil { return nil, psrpc.NewError(psrpc.InvalidArgument, err) } - if urlObj.Scheme != "http" && urlObj.Scheme != "https" && urlObj.Scheme != "srt" { + switch urlObj.Scheme { + case "http", "https", "srt": + case "udp": + if !s.conf.EnableUDPURLPull { + return nil, ingress.ErrInvalidIngress("udp url pull is not enabled") + } + default: return nil, ingress.ErrInvalidIngress(fmt.Sprintf("invalid url scheme %s", urlObj.Scheme)) } // Marshall the URL again for sanitization diff --git a/pkg/service/ingress_test.go b/pkg/service/ingress_test.go new file mode 100644 index 000000000..86d29d2e2 --- /dev/null +++ b/pkg/service/ingress_test.go @@ -0,0 +1,96 @@ +// 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 service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/livekit-server/pkg/service/servicefakes" +) + +func TestCreateURLPullIngressScheme(t *testing.T) { + newService := func(enableUDP bool) (*service.IngressService, *servicefakes.FakeIngressLauncher) { + launcher := &servicefakes.FakeIngressLauncher{} + launcher.LaunchPullIngressCalls(func(_ context.Context, info *livekit.IngressInfo) (*livekit.IngressInfo, error) { + return info, nil + }) + + svc := service.NewIngressServiceWithIngressLauncher( + &config.IngressConfig{EnableUDPURLPull: enableUDP}, + "nodeID", + nil, + nil, + &servicefakes.FakeIngressStore{}, + nil, + nil, + launcher, + ) + return svc, launcher + } + + adminCtx := func() context.Context { + return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{IngressAdmin: true}}, "") + } + + createReq := func(url string) *livekit.CreateIngressRequest { + return &livekit.CreateIngressRequest{ + InputType: livekit.IngressInput_URL_INPUT, + Url: url, + RoomName: "testroom", + ParticipantIdentity: "ingress", + } + } + + t.Run("udp rejected when disabled", func(t *testing.T) { + svc, launcher := newService(false) + + _, err := svc.CreateIngress(adminCtx(), createReq("udp://1.2.3.4:1234")) + require.Error(t, err) + require.Contains(t, err.Error(), "udp url pull is not enabled") + require.Zero(t, launcher.LaunchPullIngressCallCount()) + }) + + t.Run("udp accepted when enabled", func(t *testing.T) { + svc, launcher := newService(true) + + info, err := svc.CreateIngress(adminCtx(), createReq("udp://1.2.3.4:1234")) + require.NoError(t, err) + require.Equal(t, "udp://1.2.3.4:1234", info.Url) + require.Equal(t, 1, launcher.LaunchPullIngressCallCount()) + }) + + t.Run("other schemes unaffected by the udp option", func(t *testing.T) { + for _, url := range []string{"http://example.com/live", "https://example.com/live", "srt://1.2.3.4:1234"} { + svc, _ := newService(false) + + info, err := svc.CreateIngress(adminCtx(), createReq(url)) + require.NoError(t, err, url) + require.Equal(t, url, info.Url) + } + + svc, _ := newService(true) + _, err := svc.CreateIngress(adminCtx(), createReq("rtsp://1.2.3.4/live")) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid url scheme rtsp") + }) +} diff --git a/pkg/service/interfaces.go b/pkg/service/interfaces.go index 22a8fddb4..c86b47d86 100644 --- a/pkg/service/interfaces.go +++ b/pkg/service/interfaces.go @@ -81,7 +81,9 @@ type IngressStore interface { type RoomAllocator interface { AutoCreateEnabled(ctx context.Context) bool SelectRoomNode(ctx context.Context, roomName livekit.RoomName, nodeID livekit.NodeID) error - CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, bool, error) + // CreateRoom also returns the request with its room preset applied, + // or req unchanged when the implementation does not resolve presets + CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) ValidateCreateRoom(ctx context.Context, roomName livekit.RoomName) error } diff --git a/pkg/service/ioservice_ingress.go b/pkg/service/ioservice_ingress.go index c1fe59555..39e882447 100644 --- a/pkg/service/ioservice_ingress.go +++ b/pkg/service/ioservice_ingress.go @@ -73,6 +73,13 @@ func (s *IOInfoService) UpdateIngressState(ctx context.Context, req *rpc.UpdateI if err != nil { return nil, err } + if info.State == nil { + // LoadIngress reads the ingress info and its state non-atomically: a concurrent + // DeleteIngress can remove the state key between the two reads, in which case + // LoadIngress returns the info with a nil State and no error. Treat it as an + // empty state instead of panicking on the status comparison below. + info.State = &livekit.IngressState{} + } if err = s.is.UpdateIngressState(ctx, req.IngressId, req.State); err != nil { logger.Errorw("could not update ingress", err) diff --git a/pkg/service/ioservice_ingress_test.go b/pkg/service/ioservice_ingress_test.go new file mode 100644 index 000000000..acd778d68 --- /dev/null +++ b/pkg/service/ioservice_ingress_test.go @@ -0,0 +1,46 @@ +// 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 service_test + +import ( + "context" + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/livekit-server/pkg/service/servicefakes" + "github.com/livekit/livekit-server/pkg/telemetry/telemetryfakes" +) + +// A concurrent DeleteIngress can remove the ingress state key between the two +// non-atomic reads LoadIngress performs, making it return an IngressInfo with a +// nil State and no error. UpdateIngressState used to panic dereferencing it, +// bringing down the whole node. +func TestUpdateIngressStateWithNilState(t *testing.T) { + is := &servicefakes.FakeIngressStore{} + is.LoadIngressReturns(&livekit.IngressInfo{IngressId: "IN_test"}, nil) + + io, err := service.NewIOInfoService(nil, nil, is, nil, &telemetryfakes.FakeTelemetryService{}) + require.NoError(t, err) + + _, err = io.UpdateIngressState(context.Background(), &rpc.UpdateIngressStateRequest{ + IngressId: "IN_test", + State: &livekit.IngressState{Status: livekit.IngressState_ENDPOINT_INACTIVE}, + }) + require.NoError(t, err) +} diff --git a/pkg/service/ioservice_sip.go b/pkg/service/ioservice_sip.go index 386baef4f..43124d8da 100644 --- a/pkg/service/ioservice_sip.go +++ b/pkg/service/ioservice_sip.go @@ -113,7 +113,7 @@ func (s *IOInfoService) EvaluateSIPDispatchRules(ctx context.Context, req *rpc.E if e := (*sip.ErrNoDispatchMatched)(nil); errors.As(err, &e) { return &rpc.EvaluateSIPDispatchRulesResponse{ SipTrunkId: trunkID, - Result: rpc.SIPDispatchResult_DROP, + Result: rpc.SIPDispatchResult_REJECT, }, nil } return nil, err diff --git a/pkg/service/ioservice_sip_test.go b/pkg/service/ioservice_sip_test.go index 2a8b75288..0cb585a56 100644 --- a/pkg/service/ioservice_sip_test.go +++ b/pkg/service/ioservice_sip_test.go @@ -16,19 +16,21 @@ package service_test import ( "context" - "github.com/dennwc/iters" - "github.com/livekit/livekit-server/pkg/service" - "github.com/livekit/psrpc" "slices" "testing" - "github.com/livekit/protocol/livekit" + "github.com/dennwc/iters" "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/livekit/psrpc/pkg/bus/redisbus" ) func ioStoreDocker(t testing.TB) (*service.IOInfoService, *service.RedisStore) { r := redisClientDocker(t) - bus := psrpc.NewRedisMessageBus(r) + bus := redisbus.New(r) rs := service.NewRedisStore(r) io, err := service.NewIOInfoService(bus, rs, rs, rs, nil) require.NoError(t, err) @@ -120,3 +122,18 @@ func TestSIPRuleSelect(t *testing.T) { }) } } + +func TestSIPDispatchNoRuleRejects(t *testing.T) { + ctx := context.Background() + s, rs := ioStoreDocker(t) + require.NoError(t, rs.StoreSIPInboundTrunk(ctx, &livekit.SIPInboundTrunkInfo{SipTrunkId: "A", Numbers: []string{"+1000"}})) + + for number, trunk := range map[string]string{"+1000": "A", "+2000": ""} { + resp, err := s.EvaluateSIPDispatchRules(ctx, &rpc.EvaluateSIPDispatchRulesRequest{ + Call: &rpc.SIPCall{SourceIp: "1.1.1.1", From: &livekit.SIPUri{User: "+3000"}, To: &livekit.SIPUri{User: number}}, + }) + require.NoError(t, err) + require.Equal(t, rpc.SIPDispatchResult_REJECT, resp.Result, number) + require.Equal(t, trunk, resp.SipTrunkId) + } +} diff --git a/pkg/service/localstore.go b/pkg/service/localstore.go index 1cf747f94..863a90ae0 100644 --- a/pkg/service/localstore.go +++ b/pkg/service/localstore.go @@ -20,6 +20,7 @@ import ( "time" "github.com/thoas/go-funk" + "google.golang.org/protobuf/proto" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils" @@ -203,7 +204,7 @@ func (s *LocalStore) StoreAgentDispatch(ctx context.Context, dispatch *livekit.A s.lock.Lock() defer s.lock.Unlock() - clone := utils.CloneProto(dispatch) + clone := proto.CloneOf(dispatch) if clone.State != nil { clone.State.Jobs = nil } @@ -242,13 +243,13 @@ func (s *LocalStore) ListAgentDispatches(ctx context.Context, roomName livekit.R var js []*livekit.Job for _, j := range agentJobs { - js = append(js, utils.CloneProto(j)) + js = append(js, proto.CloneOf(j)) } var ds []*livekit.AgentDispatch m := make(map[string]*livekit.AgentDispatch) for _, d := range agentDispatches { - clone := utils.CloneProto(d) + clone := proto.CloneOf(d) m[d.Id] = clone ds = append(ds, clone) } @@ -256,7 +257,7 @@ func (s *LocalStore) ListAgentDispatches(ctx context.Context, roomName livekit.R for _, j := range js { d := m[j.DispatchId] if d != nil { - d.State.Jobs = append(d.State.Jobs, utils.CloneProto(j)) + d.State.Jobs = append(d.State.Jobs, proto.CloneOf(j)) } } @@ -267,7 +268,7 @@ func (s *LocalStore) StoreAgentJob(ctx context.Context, job *livekit.Job) error s.lock.Lock() defer s.lock.Unlock() - clone := utils.CloneProto(job) + clone := proto.CloneOf(job) clone.Room = nil if clone.Participant != nil { clone.Participant = &livekit.ParticipantInfo{ diff --git a/pkg/service/redisstore.go b/pkg/service/redisstore.go index 292ed9524..936f403a1 100644 --- a/pkg/service/redisstore.go +++ b/pkg/service/redisstore.go @@ -548,7 +548,7 @@ func (s *RedisStore) storeIngress(_ context.Context, info *livekit.IngressInfo) } // ignore state - infoCopy := utils.CloneProto(info) + infoCopy := proto.CloneOf(info) infoCopy.State = nil data, err := proto.Marshal(infoCopy) @@ -851,7 +851,7 @@ func (s *RedisStore) DeleteIngress(_ context.Context, info *livekit.IngressInfo) } func (s *RedisStore) StoreAgentDispatch(_ context.Context, dispatch *livekit.AgentDispatch) error { - di := utils.CloneProto(dispatch) + di := proto.CloneOf(dispatch) // Do not store jobs with the dispatch if di.State != nil { @@ -914,7 +914,7 @@ func (s *RedisStore) StoreAgentJob(_ context.Context, job *livekit.Job) error { key := AgentJobPrefix + string(job.Room.Name) - jb := utils.CloneProto(job) + jb := proto.CloneOf(job) // Do not store room with the job jb.Room = nil diff --git a/pkg/service/redisstore_test.go b/pkg/service/redisstore_test.go index e84a6c41f..419694537 100644 --- a/pkg/service/redisstore_test.go +++ b/pkg/service/redisstore_test.go @@ -347,7 +347,7 @@ func TestAgentStore(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(rd)) - expected := utils.CloneProto(ad) + expected := proto.CloneOf(ad) expected.State.Jobs = nil require.True(t, proto.Equal(expected, rd[0])) @@ -358,7 +358,7 @@ func TestAgentStore(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(rd)) - expected = utils.CloneProto(ad) + expected = proto.CloneOf(ad) expected.State.Jobs[0].Room = nil expected.State.Jobs[0].Participant = &livekit.ParticipantInfo{ Identity: "identity", @@ -372,7 +372,7 @@ func TestAgentStore(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(rd)) - expected = utils.CloneProto(ad) + expected = proto.CloneOf(ad) expected.State.Jobs = nil require.True(t, proto.Equal(expected, rd[0])) diff --git a/pkg/service/roomallocator.go b/pkg/service/roomallocator.go index 436a433c0..6beb00c09 100644 --- a/pkg/service/roomallocator.go +++ b/pkg/service/roomallocator.go @@ -19,6 +19,8 @@ import ( "errors" "time" + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" @@ -57,10 +59,10 @@ func (r *StandardRoomAllocator) AutoCreateEnabled(context.Context) bool { // CreateRoom creates a new room from a request and allocates it to a node to handle // it'll also monitor its state, and cleans it up when appropriate -func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, bool, error) { +func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) { token, err := r.roomStore.LockRoom(ctx, livekit.RoomName(req.Name), 5*time.Second) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } defer func() { _ = r.roomStore.UnlockRoom(ctx, livekit.RoomName(req.Name), token) @@ -82,12 +84,12 @@ func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.Cre internal = &livekit.RoomInternal{} applyDefaultRoomConfig(rm, internal, &r.config.Room) } else if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } req, err = r.applyNamedRoomConfiguration(req) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } if req.EmptyTimeout > 0 { @@ -125,10 +127,10 @@ func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.Cre } if err = r.roomStore.StoreRoom(ctx, rm, internal); err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } - return rm, internal, created, nil + return rm, internal, req, created, nil } func (r *StandardRoomAllocator) SelectRoomNode(ctx context.Context, roomName livekit.RoomName, nodeID livekit.NodeID) error { @@ -211,7 +213,7 @@ func (r *StandardRoomAllocator) applyNamedRoomConfiguration(req *livekit.CreateR return req, psrpc.NewErrorf(psrpc.InvalidArgument, "unknown room configuration in create room request") } - clone := utils.CloneProto(req) + clone := proto.CloneOf(req) if clone.EmptyTimeout == 0 { clone.EmptyTimeout = conf.EmptyTimeout @@ -223,12 +225,12 @@ func (r *StandardRoomAllocator) applyNamedRoomConfiguration(req *livekit.CreateR clone.MaxParticipants = conf.MaxParticipants } if clone.Egress == nil { - clone.Egress = utils.CloneProto(conf.Egress) + clone.Egress = proto.CloneOf(conf.Egress) } if clone.Agents == nil { clone.Agents = make([]*livekit.RoomAgentDispatch, 0, len(conf.Agents)) for _, agent := range conf.Agents { - clone.Agents = append(clone.Agents, utils.CloneProto(agent)) + clone.Agents = append(clone.Agents, proto.CloneOf(agent)) } } if clone.MinPlayoutDelay == 0 { diff --git a/pkg/service/roomallocator_test.go b/pkg/service/roomallocator_test.go index 3d3dd29db..4e9a2ae9a 100644 --- a/pkg/service/roomallocator_test.go +++ b/pkg/service/roomallocator_test.go @@ -39,12 +39,44 @@ func TestCreateRoom(t *testing.T) { ra, conf := newTestRoomAllocator(t, conf, node.Clone()) - room, _, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{Name: "myroom"}, true) + room, _, _, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{Name: "myroom"}, true) require.NoError(t, err) require.Equal(t, conf.Room.EmptyTimeout, room.EmptyTimeout) require.Equal(t, conf.Room.DepartureTimeout, room.DepartureTimeout) require.NotEmpty(t, room.EnabledCodecs) }) + + t.Run("return the request with the room preset applied", func(t *testing.T) { + conf, err := config.NewConfig(` +room: + room_configurations: + record: + egress: + room: + layout: grid +`, true, nil, nil) + require.NoError(t, err) + + node, err := routing.NewLocalNode(conf) + require.NoError(t, err) + + ra, conf := newTestRoomAllocator(t, conf, node.Clone()) + + // without a preset, the request itself is returned + req := &livekit.CreateRoomRequest{Name: "myroom"} + _, _, applied, _, err := ra.CreateRoom(context.Background(), req, true) + require.NoError(t, err) + require.Same(t, req, applied) + + // with a preset, a copy with the preset applied is returned, + // leaving both the request and the preset unchanged + req = &livekit.CreateRoomRequest{Name: "myroom", RoomPreset: "record"} + _, _, applied, _, err = ra.CreateRoom(context.Background(), req, true) + require.NoError(t, err) + require.Nil(t, req.Egress) + require.Equal(t, "grid", applied.GetEgress().GetRoom().GetLayout()) + require.NotSame(t, conf.Room.RoomConfigurations["record"].Egress.Room, applied.Egress.Room) + }) } func SelectRoomNode(t *testing.T) { diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 57282b3c3..7b38c82c6 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -246,7 +246,7 @@ func (r *RoomManager) Stop() { r.lock.RUnlock() for _, room := range rooms { - room.Close(types.ParticipantCloseReasonRoomManagerStop) + room.Close(types.RoomCloseReasonServerShutdown) } r.roomManagerServer.Kill() @@ -306,7 +306,7 @@ func (r *RoomManager) StartSession( room, err := r.getOrCreateRoom(ctx, createRoom) if err != nil { if pi.Identity != "" { - prometheus.IncrementParticipantRtcCanceled(1, false) + prometheus.IncrementParticipantRtcCanceled(1, false, pi.Client.GetSdk()) } return err } @@ -359,7 +359,7 @@ func (r *RoomManager) StartSession( Leave: leave, }, }) - prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled()) + prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled(), participant.GetClientInfo().GetSdk()) return errors.New("could not restart closed participant") } @@ -383,7 +383,7 @@ func (r *RoomManager) StartSession( pi.ReconnectReason, ); err != nil { participant.GetLogger().Warnw("could not resume participant", err) - prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled()) + prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled(), participant.GetClientInfo().GetSdk()) return err } r.telemetry.ParticipantResumed(ctx, room.ToProto(), participant.ToProto(), r.currentNode.NodeID(), pi.ReconnectReason) @@ -419,7 +419,7 @@ func (r *RoomManager) StartSession( Leave: leave, }, }) - prometheus.IncrementParticipantRtcCanceled(1, false) + prometheus.IncrementParticipantRtcCanceled(1, false, pi.Client.GetSdk()) return errors.New("could not restart participant") } @@ -543,9 +543,12 @@ func (r *RoomManager) StartSession( EnableParticipantDataBlob: r.config.EnableParticipantDataBlob, EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection, EnableWarp: enableWarp, + MediaBatchIOEnabled: r.config.RTC.BatchIO.BatchSize > 0 && + (r.config.RTC.ICEPortRangeStart == 0 || r.config.RTC.ICEPortRangeEnd == 0) && + r.config.RTC.UDPPort.Valid(), }) if err != nil { - prometheus.IncrementParticipantRtcCanceled(1, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } iceConfig := r.setIceConfig(room.Name(), participant) @@ -561,7 +564,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, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } @@ -573,7 +576,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, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } @@ -584,7 +587,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, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } } @@ -653,7 +656,9 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C } // create new room, get details first - ri, internal, created, err := r.roomAllocator.CreateRoom(ctx, createRoom, true) + // createRoom is reassigned to the request with the room preset applied, so that a room + // composite egress from the preset, which RoomInternal has no field for, is started + ri, internal, createRoom, created, err := r.roomAllocator.CreateRoom(ctx, createRoom, true) if err != nil { return nil, err } @@ -692,12 +697,12 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C return nil, err } - newRoom.OnClose(func() { + newRoom.OnClose(func(reason types.RoomCloseReason) { killRoomServer() killDispServer() roomInfo := newRoom.ToProto() - r.telemetry.RoomEnded(ctx, roomInfo) + r.telemetry.RoomEnded(ctx, roomInfo, reason.ToProto()) prometheus.RoomEnded(time.Unix(roomInfo.CreationTime, 0)) if err := r.deleteRoom(ctx, roomName); err != nil { newRoom.Logger().Errorw("could not delete room", err) @@ -730,6 +735,11 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C prometheus.RoomStarted() if created && createRoom.GetEgress().GetRoom() != nil { + if r.egressLauncher == nil { + newRoom.Release() + return nil, ErrEgressNotConnected + } + // ensure room name matches createRoom.Egress.Room.RoomName = createRoom.Name _, err = r.egressLauncher.StartEgress(ctx, &rpc.StartEgressRequest{ @@ -943,7 +953,7 @@ func (r *RoomManager) DeleteRoom(ctx context.Context, req *livekit.DeleteRoomReq } } else { room.Logger().Infow("deleting room") - room.Close(types.ParticipantCloseReasonServiceRequestDeleteRoom) + room.Close(types.RoomCloseReasonAPIDelete) } return &livekit.DeleteRoomResponse{}, nil } diff --git a/pkg/service/roommanager_service.go b/pkg/service/roommanager_service.go index d8ea05433..d6ead74ad 100644 --- a/pkg/service/roommanager_service.go +++ b/pkg/service/roommanager_service.go @@ -11,6 +11,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/rtc" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu/rtpstats" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" @@ -281,13 +282,25 @@ type whipParticipantService struct { *RoomManager } +func getWHIPParticipant( + room *rtc.Room, + identity livekit.ParticipantIdentity, + participantID livekit.ParticipantID, +) types.LocalParticipant { + lp := room.GetParticipant(identity) + if lp == nil || lp.ID() != participantID { + return nil + } + return lp +} + func (r whipParticipantService) ICETrickle(ctx context.Context, req *rpc.WHIPParticipantICETrickleRequest) (*emptypb.Empty, error) { room := r.RoomManager.GetRoom(ctx, livekit.RoomName(req.Room)) if room == nil { return nil, ErrRoomNotFound } - lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) + lp := getWHIPParticipant(room, livekit.ParticipantIdentity(req.GetParticipantIdentity()), livekit.ParticipantID(req.GetParticipantId())) if lp == nil { return nil, ErrParticipantNotFound } @@ -318,7 +331,7 @@ func (r whipParticipantService) ICERestart(ctx context.Context, req *rpc.WHIPPar return nil, ErrRoomNotFound } - lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) + lp := getWHIPParticipant(room, livekit.ParticipantIdentity(req.GetParticipantIdentity()), livekit.ParticipantID(req.GetParticipantId())) if lp == nil { return nil, ErrParticipantNotFound } @@ -346,7 +359,7 @@ func (r whipParticipantService) DeleteSession(ctx context.Context, req *rpc.WHIP return nil, ErrRoomNotFound } - lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) + lp := getWHIPParticipant(room, livekit.ParticipantIdentity(req.GetParticipantIdentity()), livekit.ParticipantID(req.GetParticipantId())) if lp != nil { room.RemoveParticipant( lp.Identity(), diff --git a/pkg/service/roommanager_test.go b/pkg/service/roommanager_test.go new file mode 100644 index 000000000..a8208ff2c --- /dev/null +++ b/pkg/service/roommanager_test.go @@ -0,0 +1,181 @@ +// 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 service_test + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/utils" + "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/routing/routingfakes" + "github.com/livekit/livekit-server/pkg/rtc" + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/livekit-server/pkg/telemetry/telemetryfakes" +) + +// fakeEgressLauncher records the egress start requests it receives. +type fakeEgressLauncher struct { + mu sync.Mutex + started []*rpc.StartEgressRequest +} + +func (f *fakeEgressLauncher) StartEgress(_ context.Context, req *rpc.StartEgressRequest) (*livekit.EgressInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.started = append(f.started, req) + return &livekit.EgressInfo{}, nil +} + +func (f *fakeEgressLauncher) StopEgress(context.Context, *livekit.StopEgressRequest) (*livekit.EgressInfo, error) { + return &livekit.EgressInfo{}, nil +} + +func (f *fakeEgressLauncher) roomCompositeRoomNames() []string { + f.mu.Lock() + defer f.mu.Unlock() + + var names []string + for _, req := range f.started { + if rc := req.GetRoomComposite(); rc != nil { + names = append(names, rc.RoomName) + } + } + return names +} + +func newTestRoomManager(t *testing.T, conf *config.Config, egressLauncher rtc.EgressLauncher) *service.RoomManager { + // no fixed RTC ports, so that the test does not bind the defaults + conf.RTC.TCPPort = 0 + conf.RTC.UDPPort = rtcconfig.PortRange{} + + node, err := routing.NewLocalNode(conf) + require.NoError(t, err) + router := &routingfakes.FakeRouter{} + router.GetNodeForRoomReturns(node.Clone(), nil) + + store := service.NewLocalStore() + ra, err := service.NewRoomAllocator(conf, router, store) + require.NoError(t, err) + + rm, err := service.NewLocalRoomManager( + conf, + store, + node, + router, + ra, + &telemetryfakes.FakeTelemetryService{}, + nil, + store, + egressLauncher, + utils.NewDefaultTimedVersionGenerator(), + nil, + psrpc.NewLocalMessageBus(), + nil, + ) + require.NoError(t, err) + t.Cleanup(rm.Stop) + return rm +} + +func TestRoomCompositeEgressOnCreateRoom(t *testing.T) { + t.Run("from request", func(t *testing.T) { + conf, err := config.NewConfig("", true, nil, nil) + require.NoError(t, err) + launcher := &fakeEgressLauncher{} + rm := newTestRoomManager(t, conf, launcher) + + _, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ + Name: "room-a", + Egress: &livekit.RoomEgress{ + Room: &livekit.RoomCompositeEgressRequest{ + Layout: "grid", + FileOutputs: []*livekit.EncodedFileOutput{{Filepath: "recordings/{room_name}.mp4"}}, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"room-a"}, launcher.roomCompositeRoomNames()) + }) + + t.Run("from room preset", func(t *testing.T) { + conf, err := config.NewConfig(roomPresetConfig, true, nil, nil) + require.NoError(t, err) + launcher := &fakeEgressLauncher{} + rm := newTestRoomManager(t, conf, launcher) + + for _, name := range []string{"room-a", "room-b"} { + _, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ + Name: name, + RoomPreset: "record", + }) + require.NoError(t, err) + } + + // each room records itself, and the preset is left as configured + require.Equal(t, []string{"room-a", "room-b"}, launcher.roomCompositeRoomNames()) + require.Empty(t, conf.Room.RoomConfigurations["record"].GetEgress().GetRoom().GetRoomName()) + }) + + t.Run("from room preset when a session starts", func(t *testing.T) { + conf, err := config.NewConfig(roomPresetConfig, true, nil, nil) + require.NoError(t, err) + launcher := &fakeEgressLauncher{} + rm := newTestRoomManager(t, conf, launcher) + + // the join and WHIP paths create the room through StartSession, with the + // token's room preset; without an identity only the room is created + err = rm.StartSession(context.Background(), routing.ParticipantInit{ + CreateRoom: &livekit.CreateRoomRequest{ + Name: "room-a", + RoomPreset: "record", + }, + }, nil, nil, false) + require.NoError(t, err) + require.Equal(t, []string{"room-a"}, launcher.roomCompositeRoomNames()) + }) + + t.Run("from room preset without an egress launcher", func(t *testing.T) { + conf, err := config.NewConfig(roomPresetConfig, true, nil, nil) + require.NoError(t, err) + rm := newTestRoomManager(t, conf, nil) + + _, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ + Name: "room-a", + RoomPreset: "record", + }) + require.ErrorIs(t, err, service.ErrEgressNotConnected) + }) +} + +const roomPresetConfig = ` +room: + room_configurations: + record: + egress: + room: + layout: grid + file_outputs: + - filepath: recordings/{room_name}.mp4 +` diff --git a/pkg/service/rtcservice.go b/pkg/service/rtcservice.go index b111e4108..bff1151d7 100644 --- a/pkg/service/rtcservice.go +++ b/pkg/service/rtcservice.go @@ -280,8 +280,10 @@ func (s *RTCService) validateInternal( pi.AdaptiveStream = joinRequest.GetConnectionSettings().GetAdaptiveStream() pi.DisableICELite = joinRequest.GetConnectionSettings().GetDisableIceLite() - subscriberAllowPause := joinRequest.GetConnectionSettings().GetSubscriberAllowPause() - pi.SubscriberAllowPause = &subscriberAllowPause + if cs := joinRequest.GetConnectionSettings(); cs != nil && cs.SubscriberAllowPause != nil { + v := cs.GetSubscriberAllowPause() + pi.SubscriberAllowPause = &v + } pi.AddTrackRequests = joinRequest.AddTrackRequests pi.PublisherOffer = joinRequest.PublisherOffer diff --git a/pkg/service/server.go b/pkg/service/server.go index e26569804..421ed5872 100644 --- a/pkg/service/server.go +++ b/pkg/service/server.go @@ -16,6 +16,7 @@ package service import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -40,28 +41,30 @@ import ( "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils/xtwirp" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/version" ) type LivekitServer struct { - config *config.Config - ioService *IOInfoService - rtcService *RTCService - whipService *WHIPService - agentService *AgentService - httpServer *http.Server - promServer *http.Server - debugServer *http.Server - router routing.Router - roomManager *RoomManager - signalServer *SignalServer - turnServer *turn.Server - currentNode routing.LocalNode - running atomic.Bool - doneChan chan struct{} - closedChan chan struct{} + config *config.Config + ioService *IOInfoService + rtcService *RTCService + whipService *WHIPService + httpServer *http.Server + promServer *http.Server + debugServer *http.Server + webtransportServer *WebTransportServer + agentEndpoint *AgentEndpointService + router routing.Router + roomManager *RoomManager + signalServer *SignalServer + turnServer *turn.Server + currentNode routing.LocalNode + running atomic.Bool + doneChan chan struct{} + closedChan chan struct{} } func NewLivekitServer(conf *config.Config, @@ -73,7 +76,9 @@ func NewLivekitServer(conf *config.Config, ioService *IOInfoService, rtcService *RTCService, whipService *WHIPService, - agentService *AgentService, + agentWSService *AgentWSService, + agentWTService *AgentWTService, + agentEndpointService *AgentEndpointService, keyProvider auth.KeyProvider, router routing.Router, roomManager *RoomManager, @@ -86,7 +91,6 @@ func NewLivekitServer(conf *config.Config, ioService: ioService, rtcService: rtcService, whipService: whipService, - agentService: agentService, router: router, roomManager: roomManager, signalServer: signalServer, @@ -96,28 +100,6 @@ func NewLivekitServer(conf *config.Config, closedChan: make(chan struct{}), } - middlewares := []negroni.Handler{ - // always first - negroni.NewRecovery(), - // CORS is allowed, we rely on token authentication to prevent improper use - cors.New(cors.Options{ - AllowOriginFunc: func(origin string) bool { - return true - }, - AllowedMethods: []string{"OPTIONS", "HEAD", "GET", "POST", "PATCH", "DELETE"}, - AllowedHeaders: []string{"*"}, - ExposedHeaders: []string{"*"}, - // allow preflight to be cached for a day - MaxAge: 86400, - }), - negroni.HandlerFunc(RemoveDoubleSlashes), - // limit request body size so large messages cannot exhaust memory - NewRequestBodyLimiter(conf.Limit.MaxAPIRequestBodySize), - } - if keyProvider != nil { - middlewares = append(middlewares, NewAPIKeyAuthMiddleware(keyProvider)) - } - serverOptions := []any{ twirp.WithServerHooks(twirp.ChainHooks( TwirpLogger(), @@ -149,11 +131,18 @@ func NewLivekitServer(conf *config.Config, xtwirp.RegisterServer(mux, sipServer) rtcService.SetupRoutes(mux) whipService.SetupRoutes(mux) - mux.Handle("/agent", agentService) + mux.Handle("/agent", agentWSService) mux.HandleFunc("/", s.defaultHandler) + // NewHTTPHandler branches on a nil handler, which a typed-nil would defeat + var agentFront http.Handler + if !conf.Agents.Endpoints.Disabled { + agentFront = agentEndpointService + s.agentEndpoint = agentEndpointService + } + s.httpServer = &http.Server{ - Handler: configureMiddlewares(mux, middlewares...), + Handler: NewHTTPHandler(conf, keyProvider, mux, agentFront), } if conf.PrometheusPort > 0 { @@ -192,6 +181,19 @@ func NewLivekitServer(conf *config.Config, } } + if conf.WebTransport.Port > 0 { + var tlsConf *tls.Config + tlsConf, err = WebTransportTLS(conf.WebTransport.TLSCertFile, conf.WebTransport.TLSKeyFile, conf.Development) + if err != nil { + return + } + wtMux := http.NewServeMux() + wtMux.Handle("/agent", agentWTService) + + s.webtransportServer = NewWebTransportServer(tlsConf) + s.webtransportServer.H3.Handler = NewWebTransportHandler(keyProvider, s.webtransportServer, wtMux) + } + if err = router.RemoveDeadNodes(); err != nil { return } @@ -267,6 +269,12 @@ func (s *LivekitServer) Start() error { } } + if s.webtransportServer != nil { + if _, err := s.webtransportServer.Listen(s.config.BindAddresses, s.config.WebTransport.Port); err != nil { + return err + } + } + values := []any{ "portHttp", s.config.Port, "nodeID", s.currentNode.NodeID(), @@ -292,6 +300,9 @@ func (s *LivekitServer) Start() error { if s.config.DebugHandler.Port != 0 { values = append(values, "portDebugHandler", s.config.DebugHandler.Port) } + if s.config.WebTransport.Port != 0 { + values = append(values, "portWebTransport", s.config.WebTransport.Port) + } if s.config.Region != "" { values = append(values, "region", s.config.Region) } @@ -335,6 +346,9 @@ func (s *LivekitServer) Start() error { <-s.doneChan + // hijacked tunnels need their own drain before Shutdown + s.drainAgentTunnels(false) + // wait for shutdown ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() @@ -342,6 +356,9 @@ func (s *LivekitServer) Start() error { if s.debugServer != nil { _ = s.debugServer.Shutdown(ctx) } + if s.webtransportServer != nil { + _ = s.webtransportServer.Shutdown(ctx) + } if s.turnServer != nil { _ = s.turnServer.Close() @@ -356,6 +373,9 @@ func (s *LivekitServer) Start() error { } func (s *LivekitServer) Stop(force bool) { + // the tunnels' grace runs alongside the participant wait + go s.drainAgentTunnels(force) + // wait for all participants to exit s.router.Drain() partTicker := time.NewTicker(5 * time.Second) @@ -378,6 +398,23 @@ func (s *LivekitServer) Stop(force bool) { <-s.closedChan } +// drainAgentTunnels returns once no agent endpoint WebSocket tunnel remains. +func (s *LivekitServer) drainAgentTunnels(force bool) { + if s.agentEndpoint == nil { + return + } + grace := s.config.Agents.Endpoints.TunnelDrainTimeout + if grace <= 0 { + grace = endpoint.DefaultTunnelDrainTimeout + } + if force { + grace = 0 + } + ctx, cancel := context.WithTimeout(context.Background(), grace) + defer cancel() + s.agentEndpoint.Drain(ctx) +} + func (s *LivekitServer) RoomManager() *RoomManager { return s.roomManager } @@ -440,6 +477,64 @@ func (s *LivekitServer) backgroundWorker() { } } +// NewHTTPHandler builds the node's public HTTP handler: the agent endpoint prefix +// on its own middleware chain, everything else on the API chain. A nil agentFront +// leaves the prefix unserved, and those paths fall through to apiHandler. +func NewHTTPHandler(conf *config.Config, keyProvider auth.KeyProvider, apiHandler, agentFront http.Handler) http.Handler { + apiMiddlewares := []negroni.Handler{ + // always first + negroni.NewRecovery(), + // CORS is allowed, we rely on token authentication to prevent improper use + cors.New(corsOptions([]string{"OPTIONS", "HEAD", "GET", "POST", "PATCH", "DELETE"})), + // limit request body size so large messages cannot exhaust memory + NewRequestBodyLimiter(conf.Limit.MaxAPIRequestBodySize), + } + // this chain must not swallow http.ErrAbortHandler or bound the request body + agentMiddlewares := []negroni.Handler{ + negroni.HandlerFunc(AgentRecovery), + NewAgentEndpointCORS(), + } + if keyProvider != nil { + authMiddleware := NewAPIKeyAuthMiddleware(keyProvider) + apiMiddlewares = append(apiMiddlewares, authMiddleware) + agentMiddlewares = append(agentMiddlewares, authMiddleware) + } + + api := configureMiddlewares(apiHandler, apiMiddlewares...) + var dispatch http.Handler = api + if agentFront != nil { + agents := configureMiddlewares(agentFront, agentMiddlewares...) + dispatch = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if IsAgentEndpointPath(r.URL.EscapedPath()) { + agents.ServeHTTP(w, r) + return + } + api.ServeHTTP(w, r) + }) + } + return WithPathNormalization(dispatch) +} + +// NewAgentEndpointCORS is the agent endpoint chain's CORS middleware and answers +// every preflight. Worker headers override it per endpoint.MergeWorkerHeaders. +func NewAgentEndpointCORS() *cors.Cors { + // the methods a manifest may declare, less TRACE, which browsers forbid in CORS + return cors.New(corsOptions([]string{"OPTIONS", "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"})) +} + +func corsOptions(methods []string) cors.Options { + return cors.Options{ + AllowOriginFunc: func(origin string) bool { + return true + }, + AllowedMethods: methods, + AllowedHeaders: []string{"*"}, + ExposedHeaders: []string{"*"}, + // allow preflight to be cached for a day + MaxAge: 86400, + } +} + func configureMiddlewares(handler http.Handler, middlewares ...negroni.Handler) *negroni.Negroni { n := negroni.New() for _, m := range middlewares { diff --git a/pkg/service/servicefakes/fake_ingress_launcher.go b/pkg/service/servicefakes/fake_ingress_launcher.go new file mode 100644 index 000000000..06ed69e6b --- /dev/null +++ b/pkg/service/servicefakes/fake_ingress_launcher.go @@ -0,0 +1,118 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package servicefakes + +import ( + "context" + "sync" + + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/protocol/livekit" +) + +type FakeIngressLauncher struct { + LaunchPullIngressStub func(context.Context, *livekit.IngressInfo) (*livekit.IngressInfo, error) + launchPullIngressMutex sync.RWMutex + launchPullIngressArgsForCall []struct { + arg1 context.Context + arg2 *livekit.IngressInfo + } + launchPullIngressReturns struct { + result1 *livekit.IngressInfo + result2 error + } + launchPullIngressReturnsOnCall map[int]struct { + result1 *livekit.IngressInfo + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeIngressLauncher) LaunchPullIngress(arg1 context.Context, arg2 *livekit.IngressInfo) (*livekit.IngressInfo, error) { + fake.launchPullIngressMutex.Lock() + ret, specificReturn := fake.launchPullIngressReturnsOnCall[len(fake.launchPullIngressArgsForCall)] + fake.launchPullIngressArgsForCall = append(fake.launchPullIngressArgsForCall, struct { + arg1 context.Context + arg2 *livekit.IngressInfo + }{arg1, arg2}) + stub := fake.LaunchPullIngressStub + fakeReturns := fake.launchPullIngressReturns + fake.recordInvocation("LaunchPullIngress", []interface{}{arg1, arg2}) + fake.launchPullIngressMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeIngressLauncher) LaunchPullIngressCallCount() int { + fake.launchPullIngressMutex.RLock() + defer fake.launchPullIngressMutex.RUnlock() + return len(fake.launchPullIngressArgsForCall) +} + +func (fake *FakeIngressLauncher) LaunchPullIngressCalls(stub func(context.Context, *livekit.IngressInfo) (*livekit.IngressInfo, error)) { + fake.launchPullIngressMutex.Lock() + defer fake.launchPullIngressMutex.Unlock() + fake.LaunchPullIngressStub = stub +} + +func (fake *FakeIngressLauncher) LaunchPullIngressArgsForCall(i int) (context.Context, *livekit.IngressInfo) { + fake.launchPullIngressMutex.RLock() + defer fake.launchPullIngressMutex.RUnlock() + argsForCall := fake.launchPullIngressArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeIngressLauncher) LaunchPullIngressReturns(result1 *livekit.IngressInfo, result2 error) { + fake.launchPullIngressMutex.Lock() + defer fake.launchPullIngressMutex.Unlock() + fake.LaunchPullIngressStub = nil + fake.launchPullIngressReturns = struct { + result1 *livekit.IngressInfo + result2 error + }{result1, result2} +} + +func (fake *FakeIngressLauncher) LaunchPullIngressReturnsOnCall(i int, result1 *livekit.IngressInfo, result2 error) { + fake.launchPullIngressMutex.Lock() + defer fake.launchPullIngressMutex.Unlock() + fake.LaunchPullIngressStub = nil + if fake.launchPullIngressReturnsOnCall == nil { + fake.launchPullIngressReturnsOnCall = make(map[int]struct { + result1 *livekit.IngressInfo + result2 error + }) + } + fake.launchPullIngressReturnsOnCall[i] = struct { + result1 *livekit.IngressInfo + result2 error + }{result1, result2} +} + +func (fake *FakeIngressLauncher) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeIngressLauncher) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ service.IngressLauncher = new(FakeIngressLauncher) diff --git a/pkg/service/servicefakes/fake_room_allocator.go b/pkg/service/servicefakes/fake_room_allocator.go index b15ae5648..631ef1faf 100644 --- a/pkg/service/servicefakes/fake_room_allocator.go +++ b/pkg/service/servicefakes/fake_room_allocator.go @@ -21,7 +21,7 @@ type FakeRoomAllocator struct { autoCreateEnabledReturnsOnCall map[int]struct { result1 bool } - CreateRoomStub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, bool, error) + CreateRoomStub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) createRoomMutex sync.RWMutex createRoomArgsForCall []struct { arg1 context.Context @@ -31,14 +31,16 @@ type FakeRoomAllocator struct { createRoomReturns struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error + result3 *livekit.CreateRoomRequest + result4 bool + result5 error } createRoomReturnsOnCall map[int]struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error + result3 *livekit.CreateRoomRequest + result4 bool + result5 error } SelectRoomNodeStub func(context.Context, livekit.RoomName, livekit.NodeID) error selectRoomNodeMutex sync.RWMutex @@ -130,7 +132,7 @@ func (fake *FakeRoomAllocator) AutoCreateEnabledReturnsOnCall(i int, result1 boo }{result1} } -func (fake *FakeRoomAllocator) CreateRoom(arg1 context.Context, arg2 *livekit.CreateRoomRequest, arg3 bool) (*livekit.Room, *livekit.RoomInternal, bool, error) { +func (fake *FakeRoomAllocator) CreateRoom(arg1 context.Context, arg2 *livekit.CreateRoomRequest, arg3 bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) { fake.createRoomMutex.Lock() ret, specificReturn := fake.createRoomReturnsOnCall[len(fake.createRoomArgsForCall)] fake.createRoomArgsForCall = append(fake.createRoomArgsForCall, struct { @@ -146,9 +148,9 @@ func (fake *FakeRoomAllocator) CreateRoom(arg1 context.Context, arg2 *livekit.Cr return stub(arg1, arg2, arg3) } if specificReturn { - return ret.result1, ret.result2, ret.result3, ret.result4 + return ret.result1, ret.result2, ret.result3, ret.result4, ret.result5 } - return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4 + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4, fakeReturns.result5 } func (fake *FakeRoomAllocator) CreateRoomCallCount() int { @@ -157,7 +159,7 @@ func (fake *FakeRoomAllocator) CreateRoomCallCount() int { return len(fake.createRoomArgsForCall) } -func (fake *FakeRoomAllocator) CreateRoomCalls(stub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, bool, error)) { +func (fake *FakeRoomAllocator) CreateRoomCalls(stub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error)) { fake.createRoomMutex.Lock() defer fake.createRoomMutex.Unlock() fake.CreateRoomStub = stub @@ -170,19 +172,20 @@ func (fake *FakeRoomAllocator) CreateRoomArgsForCall(i int) (context.Context, *l return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } -func (fake *FakeRoomAllocator) CreateRoomReturns(result1 *livekit.Room, result2 *livekit.RoomInternal, result3 bool, result4 error) { +func (fake *FakeRoomAllocator) CreateRoomReturns(result1 *livekit.Room, result2 *livekit.RoomInternal, result3 *livekit.CreateRoomRequest, result4 bool, result5 error) { fake.createRoomMutex.Lock() defer fake.createRoomMutex.Unlock() fake.CreateRoomStub = nil fake.createRoomReturns = struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error - }{result1, result2, result3, result4} + result3 *livekit.CreateRoomRequest + result4 bool + result5 error + }{result1, result2, result3, result4, result5} } -func (fake *FakeRoomAllocator) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 *livekit.RoomInternal, result3 bool, result4 error) { +func (fake *FakeRoomAllocator) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 *livekit.RoomInternal, result3 *livekit.CreateRoomRequest, result4 bool, result5 error) { fake.createRoomMutex.Lock() defer fake.createRoomMutex.Unlock() fake.CreateRoomStub = nil @@ -190,16 +193,18 @@ func (fake *FakeRoomAllocator) CreateRoomReturnsOnCall(i int, result1 *livekit.R fake.createRoomReturnsOnCall = make(map[int]struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error + result3 *livekit.CreateRoomRequest + result4 bool + result5 error }) } fake.createRoomReturnsOnCall[i] = struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error - }{result1, result2, result3, result4} + result3 *livekit.CreateRoomRequest + result4 bool + result5 error + }{result1, result2, result3, result4, result5} } func (fake *FakeRoomAllocator) SelectRoomNode(arg1 context.Context, arg2 livekit.RoomName, arg3 livekit.NodeID) error { diff --git a/pkg/service/turn.go b/pkg/service/turn.go index 0b3834c91..99c0e41ad 100644 --- a/pkg/service/turn.go +++ b/pkg/service/turn.go @@ -19,6 +19,7 @@ import ( "crypto/tls" "fmt" "net" + "net/netip" "strconv" "strings" "time" @@ -26,6 +27,7 @@ import ( "github.com/jxskiss/base62" "github.com/pion/stun/v3" "github.com/pion/turn/v5" + "github.com/pires/go-proxyproto" "github.com/pkg/errors" "github.com/livekit/protocol/auth" @@ -108,6 +110,17 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone logValues = append(logValues, "turn.relay_range_end", turnConf.RelayPortRangeEnd) logValues = append(logValues, "turn.per_user_relay_allocation_limit", turnConf.PerUserRelayAllocationLimit) + // embedded TURN shares the host with the SFU, limit relays to this host to its media ports + var localIPs map[netip.Addr]struct{} + var mediaPorts []portRange + if !standalone { + localIPs, err = getTURNLocalIPs(&conf.RTC) + if err != nil { + return nil, errors.Wrap(err, "could not get local IPs for TURN relay policy") + } + mediaPorts = getTURNMediaPorts(&conf.RTC) + } + for _, addr := range turnConf.BindAddresses { var nodeIP string if net.ParseIP(addr).To4() != nil { @@ -126,18 +139,10 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone MaxPort: turnConf.RelayPortRangeEnd, MaxRetries: allocateRetries, } - if standalone { - relayAddrGen = telemetry.NewRelayAddressGenerator(relayAddrGen) - } permissionHandler := func(_clientAddr net.Addr, peerIP net.IP) bool { // restricted peer IP is denied by default, unless allowed by the allow list, - if peerIP.IsLoopback() || - peerIP.IsLinkLocalUnicast() || - peerIP.IsLinkLocalMulticast() || - peerIP.IsMulticast() || - peerIP.IsPrivate() || - peerIP.IsUnspecified() { + if isRestrictedPeerIP(peerIP) { allowed := false for _, ipnet := range allowRestrictedPeerCIDRs { if ipnet.Contains(peerIP) { @@ -161,27 +166,17 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone return true } + relayAddrGen = newRejectTCPRelayAddressGenerator(relayAddrGen) + if standalone { + relayAddrGen = telemetry.NewRelayAddressGenerator(relayAddrGen) + } else { + relayAddrGen = newTURNRelayPolicy(relayAddrGen, localIPs, mediaPorts) + } + if turnConf.TLSPort > 0 { - var listener net.Listener - var listenerErr error - - if turnConf.ExternalTLS { - listener, listenerErr = net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort))) - } else { - cert, err := tls.LoadX509KeyPair(turnConf.CertFile, turnConf.KeyFile) - if err != nil { - return nil, errors.Wrap(err, "TURN tls cert required") - } - - listener, listenerErr = tls.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort)), - &tls.Config{ - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - } - - if listenerErr != nil { - return nil, errors.Wrap(listenerErr, "could not listen on TURN TCP port") + listener, err := newTURNTCPListener(turnConf, net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort))) + if err != nil { + return nil, err } if standalone { listener = telemetry.NewListener(listener) @@ -194,7 +189,7 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone } serverConfig.ListenerConfigs = append(serverConfig.ListenerConfigs, listenerConfig) - logValues = append(logValues, "turn.portTLS", turnConf.TLSPort, "turn.externalTLS", turnConf.ExternalTLS) + logValues = append(logValues, "turn.portTLS", turnConf.TLSPort, "turn.externalTLS", turnConf.ExternalTLS, "turn.proxyProtocol", turnConf.ProxyProtocol) } if turnConf.UDPPort > 0 { @@ -221,6 +216,65 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone return turn.NewServer(serverConfig) } +// newTURNTCPListener returns the TCP listener for TURN/TLS. The PROXY protocol +// header, when enabled, is read before TLS so the client address is known to +// the TLS layer and to TURN regardless of who terminates TLS. +func newTURNTCPListener(turnConf config.TURNConfig, address string) (net.Listener, error) { + var tlsConfig *tls.Config + if !turnConf.ExternalTLS { + cert, err := tls.LoadX509KeyPair(turnConf.CertFile, turnConf.KeyFile) + if err != nil { + return nil, errors.Wrap(err, "TURN tls cert required") + } + tlsConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + } + } + + var proxyPolicy proxyproto.ConnPolicyFunc + if turnConf.ProxyProtocol { + trusted, err := parsePeerCIDRs("turn.proxy_protocol_trusted_cidrs", turnConf.ProxyProtocolTrustedCIDRs) + if err != nil { + return nil, err + } + if len(trusted) == 0 { + return nil, errors.New("turn.proxy_protocol requires at least one entry in turn.proxy_protocol_trusted_cidrs") + } + proxyPolicy = proxyProtocolPolicy(trusted) + } + + listener, err := net.Listen("tcp", address) + if err != nil { + return nil, errors.Wrap(err, "could not listen on TURN TCP port") + } + if proxyPolicy != nil { + listener = &proxyproto.Listener{Listener: listener, ConnPolicy: proxyPolicy} + } + if tlsConfig != nil { + listener = tls.NewListener(listener, tlsConfig) + } + return listener, nil +} + +// proxyProtocolPolicy requires the PROXY header from trusted proxies and closes +// every other connection, so the header cannot be forged by a direct client. +func proxyProtocolPolicy(trusted []*net.IPNet) proxyproto.ConnPolicyFunc { + return func(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + tcpAddr, ok := opts.Upstream.(*net.TCPAddr) + if !ok { + return proxyproto.REJECT, fmt.Errorf("%w: unexpected address %v", proxyproto.ErrInvalidUpstream, opts.Upstream) + } + for _, ipnet := range trusted { + if ipnet.Contains(tcpAddr.IP) { + return proxyproto.REQUIRE, nil + } + } + // wrapping ErrInvalidUpstream closes this connection and keeps the listener accepting + return proxyproto.REJECT, fmt.Errorf("%w: %s is not a trusted proxy", proxyproto.ErrInvalidUpstream, tcpAddr.IP) + } +} + func getTURNAuthHandlerFunc(handler *TURNAuthHandler) turn.AuthHandler { return handler.HandleAuth } diff --git a/pkg/service/turn_test.go b/pkg/service/turn_test.go index ab6b2d9ad..c310df2b4 100644 --- a/pkg/service/turn_test.go +++ b/pkg/service/turn_test.go @@ -278,3 +278,139 @@ func TestTURNAuthHandler_CreateUsername_TTLClamped(t *testing.T) { _, negativeExpiry := h.CreateUsername(turnTestAPIKey, pID, -1<<40) require.InDelta(t, time.Now().Unix()+int64(config.DefaultTURNTTLSeconds), negativeExpiry, 2) } + +func proxyProtocolTURNConfig(trustedCIDRs ...string) config.TURNConfig { + return config.TURNConfig{ExternalTLS: true, ProxyProtocol: true, ProxyProtocolTrustedCIDRs: trustedCIDRs} +} + +func TestNewTURNTCPListener_ProxyProtocol(t *testing.T) { + listener, err := newTURNTCPListener(proxyProtocolTURNConfig("127.0.0.0/8"), "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + accepted := make(chan net.Addr, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + accepted <- nil + return + } + defer conn.Close() + // the PROXY header is consumed lazily, on the first read + buf := make([]byte, 1) + _, _ = conn.Read(buf) + accepted <- conn.RemoteAddr() + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("PROXY TCP4 203.0.113.9 127.0.0.1 40123 443\r\nx")) + require.NoError(t, err) + + select { + case addr := <-accepted: + require.NotNil(t, addr) + require.Equal(t, "203.0.113.9:40123", addr.String()) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the accepted connection") + } +} + +func TestNewTURNTCPListener_ProxyProtocolRejectsBareConnection(t *testing.T) { + listener, err := newTURNTCPListener(proxyProtocolTURNConfig("127.0.0.0/8"), "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + result := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer conn.Close() + buf := make([]byte, 1) + _, err = conn.Read(buf) + result <- err + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("not a proxy header\r\n")) + require.NoError(t, err) + + select { + case err := <-result: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the rejected connection") + } +} + +func TestNewTURNTCPListener_WithoutProxyProtocol(t *testing.T) { + listener, err := newTURNTCPListener(config.TURNConfig{ExternalTLS: true}, "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + accepted := make(chan net.Addr, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + accepted <- nil + return + } + defer conn.Close() + accepted <- conn.RemoteAddr() + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + select { + case addr := <-accepted: + require.NotNil(t, addr) + require.Equal(t, conn.LocalAddr().String(), addr.String()) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the accepted connection") + } +} + +func TestNewTURNTCPListener_ProxyProtocolClosesUntrustedProxy(t *testing.T) { + listener, err := newTURNTCPListener(proxyProtocolTURNConfig("203.0.113.0/24"), "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + accepted := make(chan struct{}, 1) + go func() { + if conn, err := listener.Accept(); err == nil { + conn.Close() + accepted <- struct{}{} + } + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, _ = conn.Write([]byte("PROXY TCP4 203.0.113.9 127.0.0.1 40123 443\r\nx")) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + _, err = conn.Read(make([]byte, 1)) + require.Error(t, err, "the listener should have closed the connection") + + select { + case <-accepted: + t.Fatal("connection from an untrusted proxy must not be accepted") + case <-time.After(200 * time.Millisecond): + } +} + +func TestNewTURNTCPListener_ProxyProtocolRequiresTrustedCIDRs(t *testing.T) { + _, err := newTURNTCPListener(proxyProtocolTURNConfig(), "127.0.0.1:0") + require.Error(t, err) + + _, err = newTURNTCPListener(proxyProtocolTURNConfig("not-a-cidr"), "127.0.0.1:0") + require.Error(t, err) +} diff --git a/pkg/service/turnrelay.go b/pkg/service/turnrelay.go new file mode 100644 index 000000000..618e66db0 --- /dev/null +++ b/pkg/service/turnrelay.go @@ -0,0 +1,189 @@ +// 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 service + +import ( + "net" + "net/netip" + + "github.com/pion/turn/v5" + "github.com/pkg/errors" + + "github.com/livekit/livekit-server/pkg/config" +) + +var ErrTURNTCPRelayDisabled = errors.New("TURN TCP relay allocations are disabled") + +// restricted ranges not covered by the net.IP predicates +var extraRestrictedPeerPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "this" network + netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking + netip.MustParsePrefix("240.0.0.0/4"), // reserved, includes broadcast + netip.MustParsePrefix("64:ff9b::/96"), // NAT64, can map to restricted IPv4 + netip.MustParsePrefix("64:ff9b:1::/48"), // local-use NAT64 +} + +func isRestrictedPeerIP(ip net.IP) bool { + if ip.IsLoopback() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsMulticast() || + ip.IsPrivate() || + ip.IsUnspecified() { + return true + } + + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return true + } + addr = addr.Unmap() + for _, prefix := range extraRestrictedPeerPrefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +type portRange struct { + start uint16 + end uint16 +} + +// rejectTCPRelayAddressGenerator refuses RFC 6062 TCP relays, which would let +// a client open TCP connections to any permitted address. +type rejectTCPRelayAddressGenerator struct { + turn.RelayAddressGenerator +} + +func newRejectTCPRelayAddressGenerator(g turn.RelayAddressGenerator) *rejectTCPRelayAddressGenerator { + return &rejectTCPRelayAddressGenerator{RelayAddressGenerator: g} +} + +func (g *rejectTCPRelayAddressGenerator) AllocateListener(turn.AllocateListenerConfig) (net.Listener, net.Addr, error) { + return nil, nil, ErrTURNTCPRelayDisabled +} + +func (g *rejectTCPRelayAddressGenerator) AllocateConn(turn.AllocateConnConfig) (net.Conn, error) { + return nil, ErrTURNTCPRelayDisabled +} + +// turnRelayPolicy enforces what TURN permissions cannot, since they are per IP +// only: relays to this host's own addresses may reach only the RTC media +// ports, because such packets are delivered locally and bypass any firewall +// in front of the host. Used for embedded TURN, which shares the host with +// the SFU. +type turnRelayPolicy struct { + turn.RelayAddressGenerator + + localIPs map[netip.Addr]struct{} + mediaPorts []portRange +} + +func newTURNRelayPolicy(g turn.RelayAddressGenerator, localIPs map[netip.Addr]struct{}, mediaPorts []portRange) *turnRelayPolicy { + return &turnRelayPolicy{ + RelayAddressGenerator: g, + localIPs: localIPs, + mediaPorts: mediaPorts, + } +} + +func (g *turnRelayPolicy) allows(addr net.Addr) bool { + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { + return false + } + ap := udpAddr.AddrPort() + if _, ok := g.localIPs[ap.Addr().Unmap().WithZone("")]; !ok { + return true + } + for _, r := range g.mediaPorts { + if ap.Port() >= r.start && ap.Port() <= r.end { + return true + } + } + return false +} + +func (g *turnRelayPolicy) AllocatePacketConn(c turn.AllocateListenerConfig) (net.PacketConn, net.Addr, error) { + conn, addr, err := g.RelayAddressGenerator.AllocatePacketConn(c) + if err != nil { + return conn, addr, err + } + return &turnRelayPacketConn{PacketConn: conn, policy: g}, addr, nil +} + +// turnRelayPacketConn silently drops relayed packets to peers the policy does +// not allow. +type turnRelayPacketConn struct { + net.PacketConn + policy *turnRelayPolicy +} + +func (c *turnRelayPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + if !c.policy.allows(addr) { + return len(p), nil + } + return c.PacketConn.WriteTo(p, addr) +} + +// getTURNLocalIPs returns the addresses that are delivered to this host: its +// interface addresses and the node IPs, which may be 1:1 NAT addresses. +func getTURNLocalIPs(rtcConf *config.RTCConfig) (map[netip.Addr]struct{}, error) { + ips := make(map[netip.Addr]struct{}) + + ifAddrs, err := net.InterfaceAddrs() + if err != nil { + return nil, err + } + for _, ifAddr := range ifAddrs { + ipNet, ok := ifAddr.(*net.IPNet) + if !ok { + continue + } + if addr, ok := netip.AddrFromSlice(ipNet.IP); ok { + ips[addr.Unmap()] = struct{}{} + } + } + + for _, ip := range rtcConf.NodeIP.ToStringSlice() { + addr, err := netip.ParseAddr(ip) + if err != nil { + return nil, err + } + ips[addr.Unmap()] = struct{}{} + } + return ips, nil +} + +func getTURNMediaPorts(rtcConf *config.RTCConfig) []portRange { + if rtcConf.ForceTCP { + return nil + } + + var ports []portRange + if rtcConf.ICEPortRangeStart != 0 && rtcConf.ICEPortRangeEnd != 0 { + ports = append(ports, portRange{start: uint16(rtcConf.ICEPortRangeStart), end: uint16(rtcConf.ICEPortRangeEnd)}) + } else if rtcConf.UDPPort.Valid() { + end := max(rtcConf.UDPPort.End, rtcConf.UDPPort.Start) + ports = append(ports, portRange{start: uint16(rtcConf.UDPPort.Start), end: uint16(end)}) + if rtcConf.UseStunPortAsICE { + ports = append(ports, portRange{start: 3478, end: 3478}) + } + } + return ports +} diff --git a/pkg/service/turnrelay_test.go b/pkg/service/turnrelay_test.go new file mode 100644 index 000000000..ca28890af --- /dev/null +++ b/pkg/service/turnrelay_test.go @@ -0,0 +1,311 @@ +// 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 service + +import ( + "fmt" + "net" + "net/netip" + "testing" + "time" + + "github.com/pion/turn/v5" + "github.com/stretchr/testify/require" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + + "github.com/livekit/livekit-server/pkg/config" +) + +func TestGetTURNMediaPorts(t *testing.T) { + ports := getTURNMediaPorts(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + UDPPort: rtcconfig.PortRange{Start: 7882, End: 7885}, + UseStunPortAsICE: true, + }}) + require.Equal(t, []portRange{{7882, 7885}, {3478, 3478}}, ports) + + ports = getTURNMediaPorts(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + UDPPort: rtcconfig.PortRange{Start: 7882}, + ICEPortRangeStart: 50000, + ICEPortRangeEnd: 60000, + }}) + require.Equal(t, []portRange{{50000, 60000}}, ports) + + require.Empty(t, getTURNMediaPorts(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + UDPPort: rtcconfig.PortRange{Start: 7882}, + ForceTCP: true, + }})) +} + +func TestGetTURNLocalIPs(t *testing.T) { + ips, err := getTURNLocalIPs(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + NodeIP: rtcconfig.NodeIP{V4: "203.0.113.1", V6: "2001:db8::1"}, + }}) + require.NoError(t, err) + require.Contains(t, ips, netip.MustParseAddr("127.0.0.1")) + require.Contains(t, ips, netip.MustParseAddr("203.0.113.1")) + require.Contains(t, ips, netip.MustParseAddr("2001:db8::1")) +} + +func TestTURNRelayPolicy_Allows(t *testing.T) { + policy := newTURNRelayPolicy(nil, + map[netip.Addr]struct{}{netip.MustParseAddr("203.0.113.1"): {}}, + []portRange{{7882, 7882}, {50000, 60000}}, + ) + + for _, tc := range []struct { + addr net.Addr + allowed bool + }{ + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 7882}, true}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1").To16(), Port: 55000}, true}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 22}, false}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 443}, false}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 60001}, false}, + {&net.UDPAddr{IP: net.ParseIP("198.51.100.7"), Port: 7882}, true}, + {&net.UDPAddr{IP: net.ParseIP("198.51.100.7"), Port: 443}, true}, + {&net.TCPAddr{IP: net.ParseIP("198.51.100.7"), Port: 443}, false}, + } { + require.Equal(t, tc.allowed, policy.allows(tc.addr), "%s", tc.addr) + } +} + +type fakeRelayAddressGenerator struct { + conn net.PacketConn +} + +func (g *fakeRelayAddressGenerator) Validate() error { return nil } + +func (g *fakeRelayAddressGenerator) AllocatePacketConn(turn.AllocateListenerConfig) (net.PacketConn, net.Addr, error) { + return g.conn, g.conn.LocalAddr(), nil +} + +func (g *fakeRelayAddressGenerator) AllocateListener(turn.AllocateListenerConfig) (net.Listener, net.Addr, error) { + panic("must not be called") +} + +func (g *fakeRelayAddressGenerator) AllocateConn(turn.AllocateConnConfig) (net.Conn, error) { + panic("must not be called") +} + +func listenUDP(t *testing.T) net.PacketConn { + t.Helper() + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func TestTURNRelayPolicy_PacketConn(t *testing.T) { + relay := listenUDP(t) + mediaPeer := listenUDP(t) + otherPeer := listenUDP(t) + + mediaPort := uint16(mediaPeer.LocalAddr().(*net.UDPAddr).Port) + policy := newTURNRelayPolicy( + &fakeRelayAddressGenerator{conn: relay}, + map[netip.Addr]struct{}{netip.MustParseAddr("127.0.0.1"): {}}, + []portRange{{mediaPort, mediaPort}}, + ) + + conn, _, err := policy.AllocatePacketConn(turn.AllocateListenerConfig{UserID: "PA_x"}) + require.NoError(t, err) + + buf := make([]byte, 64) + + // writes to a non-media port on this host are dropped + n, err := conn.WriteTo([]byte("denied"), otherPeer.LocalAddr()) + require.NoError(t, err) + require.Equal(t, len("denied"), n) + require.NoError(t, otherPeer.SetReadDeadline(time.Now().Add(100*time.Millisecond))) + _, _, err = otherPeer.ReadFrom(buf) + require.Error(t, err) + + _, err = conn.WriteTo([]byte("allowed"), mediaPeer.LocalAddr()) + require.NoError(t, err) + require.NoError(t, mediaPeer.SetReadDeadline(time.Now().Add(2*time.Second))) + n, _, err = mediaPeer.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "allowed", string(buf[:n])) +} + +func TestRejectTCPRelayAddressGenerator(t *testing.T) { + relay := listenUDP(t) + g := newRejectTCPRelayAddressGenerator(&fakeRelayAddressGenerator{conn: relay}) + + _, _, err := g.AllocateListener(turn.AllocateListenerConfig{}) + require.ErrorIs(t, err, ErrTURNTCPRelayDisabled) + _, err = g.AllocateConn(turn.AllocateConnConfig{}) + require.ErrorIs(t, err, ErrTURNTCPRelayDisabled) + + conn, _, err := g.AllocatePacketConn(turn.AllocateListenerConfig{}) + require.NoError(t, err) + require.Same(t, relay, conn) +} + +// allocateTestTurnRelay starts a TURN server on loopback, with the RTC media +// port set to mediaPort, and returns a relay allocated through it. +func allocateTestTurnRelay(t *testing.T, standalone bool, mediaPort int) net.PacketConn { + t.Helper() + + turnPort := listenUDP(t) + turnAddr := turnPort.LocalAddr().String() + require.NoError(t, turnPort.Close()) + + conf := &config.Config{ + RTC: config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + NodeIP: rtcconfig.NodeIP{V4: "127.0.0.1"}, + UDPPort: rtcconfig.PortRange{Start: mediaPort}, + }}, + TURN: config.TURNConfig{ + Enabled: true, + UDPPort: turnPort.LocalAddr().(*net.UDPAddr).Port, + BindAddresses: []string{"127.0.0.1"}, + RelayPortRangeStart: 40000, + RelayPortRangeEnd: 40100, + AllowRestrictedPeerCIDRs: []string{"127.0.0.0/8"}, + }, + } + h := newTestTurnAuthHandler() + server, err := NewTurnServer(conf, h.HandleAuth, standalone) + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + + username, expiry := h.CreateUsername(turnTestAPIKey, "PA_relay", 300) + password, err := h.CreatePassword(turnTestAPIKey, "PA_relay", expiry) + require.NoError(t, err) + + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Username: username, + Password: password, + Realm: LivekitRealm, + Conn: listenUDP(t), + }) + require.NoError(t, err) + t.Cleanup(client.Close) + require.NoError(t, client.Listen()) + + relayConn, err := client.Allocate() + require.NoError(t, err) + t.Cleanup(func() { _ = relayConn.Close() }) + return relayConn +} + +func TestNewTurnServer_RelayPolicy(t *testing.T) { + mediaPeer := listenUDP(t) + otherPeer := listenUDP(t) + relayConn := allocateTestTurnRelay(t, false, mediaPeer.LocalAddr().(*net.UDPAddr).Port) + + buf := make([]byte, 64) + + _, err := relayConn.WriteTo([]byte("denied"), otherPeer.LocalAddr()) + require.NoError(t, err) + _, err = relayConn.WriteTo([]byte("allowed"), mediaPeer.LocalAddr()) + require.NoError(t, err) + + require.NoError(t, mediaPeer.SetReadDeadline(time.Now().Add(2*time.Second))) + n, relayAddr, err := mediaPeer.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "allowed", string(buf[:n])) + + require.NoError(t, otherPeer.SetReadDeadline(time.Now().Add(200*time.Millisecond))) + _, _, err = otherPeer.ReadFrom(buf) + require.Error(t, err, "relay to a non-media port on this host must be dropped") + + // the media peer can reply through the relay + _, err = mediaPeer.WriteTo([]byte("reply"), relayAddr) + require.NoError(t, err) + require.NoError(t, relayConn.SetReadDeadline(time.Now().Add(2*time.Second))) + n, from, err := relayConn.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "reply", string(buf[:n])) + require.Equal(t, mediaPeer.LocalAddr().String(), from.String()) +} + +func TestNewTurnServer_StandaloneRelayPolicy(t *testing.T) { + mediaPeer := listenUDP(t) + otherPeer := listenUDP(t) + relayConn := allocateTestTurnRelay(t, true, mediaPeer.LocalAddr().(*net.UDPAddr).Port) + + // standalone TURN has no SFU on the host, so ports on this host are not limited + _, err := relayConn.WriteTo([]byte("allowed"), otherPeer.LocalAddr()) + require.NoError(t, err) + + buf := make([]byte, 64) + require.NoError(t, otherPeer.SetReadDeadline(time.Now().Add(2*time.Second))) + n, _, err := otherPeer.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "allowed", string(buf[:n])) +} + +func TestNewTurnServer_RejectsTCPRelay(t *testing.T) { + for _, standalone := range []bool{false, true} { + t.Run(fmt.Sprintf("standalone=%v", standalone), func(t *testing.T) { + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + turnAddr := ln.Addr().String() + require.NoError(t, ln.Close()) + + conf := &config.Config{ + RTC: config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + NodeIP: rtcconfig.NodeIP{V4: "127.0.0.1"}, + UDPPort: rtcconfig.PortRange{Start: 7882}, + }}, + TURN: config.TURNConfig{ + Enabled: true, + Domain: "turn.example.com", + TLSPort: ln.Addr().(*net.TCPAddr).Port, + ExternalTLS: true, + BindAddresses: []string{"127.0.0.1"}, + RelayPortRangeStart: 40000, + RelayPortRangeEnd: 40100, + AllowRestrictedPeerCIDRs: []string{"127.0.0.0/8"}, + }, + } + h := newTestTurnAuthHandler() + server, err := NewTurnServer(conf, h.HandleAuth, standalone) + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + + username, expiry := h.CreateUsername(turnTestAPIKey, "PA_tcp", 300) + password, err := h.CreatePassword(turnTestAPIKey, "PA_tcp", expiry) + require.NoError(t, err) + + tcpConn, err := net.Dial("tcp4", turnAddr) + require.NoError(t, err) + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Username: username, + Password: password, + Realm: LivekitRealm, + Conn: turn.NewSTUNConn(tcpConn), + }) + require.NoError(t, err) + t.Cleanup(client.Close) + require.NoError(t, client.Listen()) + + _, err = client.AllocateTCP() + require.Error(t, err, "TCP relay allocation must be refused") + + // a UDP relay over the same TCP connection still works + relayConn, err := client.Allocate() + require.NoError(t, err) + _ = relayConn.Close() + }) + } +} diff --git a/pkg/service/utils.go b/pkg/service/utils.go index cc31be859..0ee1d55bd 100644 --- a/pkg/service/utils.go +++ b/pkg/service/utils.go @@ -101,12 +101,25 @@ func boolValue(s string) bool { } func RemoveDoubleSlashes(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - if strings.HasPrefix(r.URL.Path, "//") { + // Path and RawPath must move together: once they disagree, EscapedPath() + // re-encodes from Path and every escape in the request is lost. + if strings.HasPrefix(r.URL.EscapedPath(), "//") { r.URL.Path = r.URL.Path[1:] + if r.URL.RawPath != "" { + r.URL.RawPath = r.URL.RawPath[1:] + } } next(w, r) } +// WithPathNormalization applies RemoveDoubleSlashes ahead of h, so routing and every +// middleware below it see one path form. +func WithPathNormalization(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + RemoveDoubleSlashes(w, r, h.ServeHTTP) + }) +} + // RequestBodyLimiter bounds the size of an incoming HTTP request body so that // large messages cannot exhaust memory. The Twirp handlers decode the whole // body before any grant check runs, so the limit is applied here, up front. diff --git a/pkg/service/utils_test.go b/pkg/service/utils_test.go index 1f675c6f8..2c1a43060 100644 --- a/pkg/service/utils_test.go +++ b/pkg/service/utils_test.go @@ -19,6 +19,7 @@ import ( "compress/gzip" "context" "net/http" + "net/http/httptest" "testing" "time" @@ -127,3 +128,30 @@ func TestDecompressGzip(t *testing.T) { require.Contains(t, err.Error(), "cannot read decompressed") }) } + +// URL.Path and URL.RawPath must stay in step: once they disagree, +// EscapedPath() re-encodes from Path and every escape is lost. +func TestRemoveDoubleSlashes(t *testing.T) { + cases := []struct { + name string + target string + want string + }{ + {"doubled slash is trimmed", "//agents/a/d/x", "/agents/a/d/x"}, + {"single slash untouched", "/agents/a/d/x", "/agents/a/d/x"}, + {"doubled slash keeps the encoded tail", "//agents/a/d/f%2Fg", "/agents/a/d/f%2Fg"}, + {"encoded name survives", "//agents/LODHA%20Vayam/d/x", "/agents/LODHA%20Vayam/d/x"}, + {"leading encoded slash is not doubled", "/%2Fagents/a/d/x", "/%2Fagents/a/d/x"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, c.target, nil) + var got string + service.RemoveDoubleSlashes(httptest.NewRecorder(), r, func(_ http.ResponseWriter, r *http.Request) { + got = r.URL.EscapedPath() + }) + require.Equal(t, c.want, got) + }) + } +} diff --git a/pkg/service/webtransport.go b/pkg/service/webtransport.go new file mode 100644 index 000000000..42db2c454 --- /dev/null +++ b/pkg/service/webtransport.go @@ -0,0 +1,251 @@ +// 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 service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "net" + "net/http" + "strconv" + "sync" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "github.com/urfave/negroni/v3" + + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/logger" +) + +// WebTransportTLS builds the listener's TLS config from cert files, or a +// generated self-signed cert in dev mode. +func WebTransportTLS(certFile, keyFile string, dev bool) (*tls.Config, error) { + if certFile != "" && keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + return &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{http3.NextProtoH3}}, nil + } + if !dev { + return nil, errors.New("webtransport requires webtransport.tls_cert_file/tls_key_file") + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "livekit-webtransport-dev"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + logger.Warnw("webtransport using a generated self-signed cert (dev mode); clients must skip verification", nil) + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{http3.NextProtoH3}, + }, nil +} + +// WebTransportServer's embedded Close releases sessions but not the sockets; +// Shutdown is the teardown path. +type WebTransportServer struct { + *webtransport.Server + + mu sync.Mutex + conns []*net.UDPConn + lns []*quic.EarlyListener + cancel context.CancelFunc + serving sync.WaitGroup +} + +// NewWebTransportServer wraps an HTTP/3 WebTransport server around tlsConf (the +// h3 ALPN is set here if absent). The caller must assign wt.H3.Handler. +func NewWebTransportServer(tlsConf *tls.Config) *WebTransportServer { + tlsConf = tlsConf.Clone() + if len(tlsConf.NextProtos) == 0 { + tlsConf.NextProtos = []string{http3.NextProtoH3} + } + return &WebTransportServer{Server: &webtransport.Server{H3: &http3.Server{TLSConfig: tlsConf}}} +} + +// NewWebTransportHandler builds the listener's handler: mux behind api-key auth, +// with wt in each request's context. +func NewWebTransportHandler(keyProvider auth.KeyProvider, wt *WebTransportServer, mux http.Handler) http.Handler { + middlewares := []negroni.Handler{negroni.NewRecovery()} + if keyProvider != nil { + middlewares = append(middlewares, NewAPIKeyAuthMiddleware(keyProvider)) + } + return WithWebTransportServer(wt, WithPathNormalization(configureMiddlewares(mux, middlewares...))) +} + +type webTransportServerKey struct{} + +// WithWebTransportServer puts wt in each request's context, where a route that +// upgrades reads it. Apply it outermost, ahead of the listener's middleware chain. +func WithWebTransportServer(wt *WebTransportServer, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), webTransportServerKey{}, wt))) + }) +} + +// GetWebTransportServer returns the server serving this request, or nil when the +// request did not arrive over a WebTransport listener. +func GetWebTransportServer(ctx context.Context) *WebTransportServer { + wt, _ := ctx.Value(webTransportServerKey{}).(*WebTransportServer) + return wt +} + +// UpgradeWebTransport upgrades a request on the server carried in its context. +func UpgradeWebTransport(w http.ResponseWriter, r *http.Request) (*webtransport.Session, error) { + wt := GetWebTransportServer(r.Context()) + if wt == nil { + return nil, errors.New("no webtransport server serving this request") + } + return wt.Upgrade(unwrapResponseWriter(w), r) +} + +// unwrapResponseWriter reaches the ResponseWriter net/http handed in. An upgrade +// type-asserts it to http3.Settingser and http3.HTTPStreamer without checking, so +// it must not be a middleware's wrapper. +func unwrapResponseWriter(w http.ResponseWriter) http.ResponseWriter { + for { + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + return w + } + w = u.Unwrap() + } +} + +// Listen binds one UDP socket per address. Empty addrs binds all interfaces. +func (s *WebTransportServer) Listen(addrs []string, port uint32) ([]net.Addr, error) { + if len(addrs) == 0 { + addrs = []string{""} + } + + // Server.Serve takes a reference on the WaitGroup Server.Close waits on; + // Server.ServeQUICConn does not. + quicConf := &quic.Config{} + if s.H3.QUICConfig != nil { + quicConf = s.H3.QUICConfig.Clone() + } + quicConf.EnableDatagrams = true + quicConf.EnableStreamResetPartialDelivery = true + + var ( + conns []*net.UDPConn + lns []*quic.EarlyListener + bound []net.Addr + ) + closeAll := func() { + for _, ln := range lns { + _ = ln.Close() + } + for _, c := range conns { + _ = c.Close() + } + } + for _, addr := range addrs { + udpAddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(addr, strconv.Itoa(int(port)))) + if err != nil { + closeAll() + return nil, err + } + udp, err := net.ListenUDP("udp", udpAddr) + if err != nil { + closeAll() + return nil, err + } + conns = append(conns, udp) + bound = append(bound, udp.LocalAddr()) + + ln, err := quic.ListenEarly(udp, s.H3.TLSConfig, quicConf) + if err != nil { + closeAll() + return nil, err + } + lns = append(lns, ln) + } + + ctx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.conns, s.lns, s.cancel = conns, lns, cancel + s.mu.Unlock() + + for _, ln := range lns { + s.serving.Go(func() { s.accept(ctx, ln) }) + } + logger.Infow("webtransport listener started", "addresses", bound) + + return bound, nil +} + +// Shutdown is safe on a server that never listened, and safe to call more than +// once. Peers get a GOAWAY, then whatever has not drained by the ctx deadline is +// closed under it. +func (s *WebTransportServer) Shutdown(ctx context.Context) error { + s.mu.Lock() + cancel, conns, lns := s.cancel, s.conns, s.lns + s.cancel, s.conns, s.lns = nil, nil, nil + s.mu.Unlock() + + if cancel != nil { + cancel() + } + err := s.H3.Shutdown(ctx) + _ = s.Server.Close() + s.serving.Wait() + + // the sockets close last, so every CONNECTION_CLOSE frame still reaches its peer + for _, ln := range lns { + _ = ln.Close() + } + for _, c := range conns { + _ = c.Close() + } + return err +} + +func (s *WebTransportServer) accept(ctx context.Context, ln *quic.EarlyListener) { + for { + conn, err := ln.Accept(ctx) + if err != nil { + logger.Infow("webtransport listener stopped", "error", err) + return + } + s.serving.Go(func() { + if err := s.ServeQUICConn(conn); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Infow("webtransport connection stopped", "error", err) + } + }) + } +} diff --git a/pkg/service/webtransport_test.go b/pkg/service/webtransport_test.go new file mode 100644 index 000000000..2af6c6006 --- /dev/null +++ b/pkg/service/webtransport_test.go @@ -0,0 +1,47 @@ +// 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 service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/negroni/v3" +) + +// An upgrade type-asserts the ResponseWriter to http3.Settingser and +// http3.HTTPStreamer without checking, so it must reach the one net/http handed +// in. Every negroni chain wraps it. +func TestUnwrapResponseWriterReachesTheOriginal(t *testing.T) { + original := httptest.NewRecorder() + + var seen http.ResponseWriter + chain := negroni.New() + chain.UseHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen = w + })) + chain.ServeHTTP(original, httptest.NewRequest(http.MethodGet, "/agent", nil)) + + _, wrapped := seen.(interface{ Unwrap() http.ResponseWriter }) + require.True(t, wrapped, "negroni no longer wraps; this test no longer covers anything") + require.Same(t, original, unwrapResponseWriter(seen)) +} + +func TestUnwrapResponseWriterPassesThroughUnwrapped(t *testing.T) { + original := httptest.NewRecorder() + require.Same(t, original, unwrapResponseWriter(original)) +} diff --git a/pkg/service/webtransportlisten_test.go b/pkg/service/webtransportlisten_test.go new file mode 100644 index 000000000..1ed41531e --- /dev/null +++ b/pkg/service/webtransportlisten_test.go @@ -0,0 +1,38 @@ +// 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 service_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" +) + +// a listener stopped before it has served anything must not race its own accept +// loop: under -race this is what catches webtransport.Server.Serve running +// concurrently with Server.Close. +func TestWebTransportStopBeforeFirstConnection(t *testing.T) { + for range 20 { + wt := service.NewWebTransportServer(selfSignedTLS(t)) + wt.H3.Handler = service.NewWebTransportHandler(nil, wt, http.NewServeMux()) + _, err := wt.Listen([]string{"127.0.0.1"}, 0) + require.NoError(t, err) + require.NoError(t, wt.Shutdown(context.Background())) + } +} diff --git a/pkg/service/whipservice.go b/pkg/service/whipservice.go index 7f5f20fd9..b2d5d7f7a 100644 --- a/pkg/service/whipservice.go +++ b/pkg/service/whipservice.go @@ -20,7 +20,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "github.com/pion/webrtc/v4" @@ -269,7 +268,7 @@ func (s *WHIPService) handleCreate(w http.ResponseWriter, r *http.Request) { for _, iceServer := range res.IceServers { for _, iceURL := range iceServer.Urls { iceServerLink := &linkheader.Link{ - URL: url.PathEscape(iceURL), + URL: iceURL, Rel: "ice-server", Params: map[string]string{}, } diff --git a/pkg/service/wire.go b/pkg/service/wire.go index a2d60fd32..098ae773c 100644 --- a/pkg/service/wire.go +++ b/pkg/service/wire.go @@ -35,9 +35,11 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/protocol/webhook" "github.com/livekit/psrpc" + "github.com/livekit/psrpc/pkg/bus/redisbus" "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/sfu" @@ -80,7 +82,12 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live NewRoomService, NewRTCService, NewWHIPService, - NewAgentService, + endpoint.NewRegistry, + NewEndpointScopes, + NewAgentHandler, + NewAgentWSService, + NewAgentWTService, + NewAgentEndpointService, NewAgentDispatchService, getAgentConfig, agent.NewAgentClient, @@ -193,11 +200,12 @@ func createStore(rc redis.UniversalClient) ObjectStore { return NewLocalStore() } -func getMessageBus(rc redis.UniversalClient) psrpc.MessageBus { +func getMessageBus(rc redis.UniversalClient, psrpcConf rpc.PSRPCConfig) psrpc.MessageBus { + opts := psrpcConf.BusOptions() if rc == nil { - return psrpc.NewLocalMessageBus() + return psrpc.NewLocalMessageBus(opts...) } - return psrpc.NewRedisMessageBus(rc) + return redisbus.New(rc, opts...) } func getEgressStore(s ObjectStore) EgressStore { diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 64c181640..ca10d3e35 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -9,6 +9,7 @@ package service import ( "fmt" "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/sfu" @@ -21,6 +22,7 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/protocol/webhook" "github.com/livekit/psrpc" + "github.com/livekit/psrpc/pkg/bus/redisbus" "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" "github.com/pion/turn/v5" "github.com/pkg/errors" @@ -39,13 +41,13 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live return nil, err } nodeID := getNodeID(currentNode) - v := getMessageBus(universalClient) + psrpcConfig := getPSRPCConfig(conf) + v := getMessageBus(universalClient, psrpcConfig) signalRelayConfig := getSignalRelayConfig(conf) signalClient, err := routing.NewSignalClient(nodeID, v, signalRelayConfig) if err != nil { return nil, err } - psrpcConfig := getPSRPCConfig(conf) clientParams := getPSRPCClientParams(psrpcConfig, v) roomConfig := getRoomConfig(conf) roomManagerClient, err := routing.NewRoomManagerClient(clientParams, roomConfig) @@ -125,10 +127,15 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - agentService, err := NewAgentService(conf, currentNode, v, keyProvider) + registry := endpoint.NewRegistry() + endpointScopes := NewEndpointScopes() + agentHandler, err := NewAgentHandler(conf, currentNode, v, keyProvider, registry, endpointScopes) if err != nil { return nil, err } + agentWSService := NewAgentWSService(conf, agentHandler) + agentWTService := NewAgentWTService(agentHandler) + agentEndpointService := NewAgentEndpointService(agentHandler, endpointScopes) agentConfig := getAgentConfig(conf) client, err := agent.NewAgentClient(v, agentConfig) if err != nil { @@ -151,7 +158,7 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - livekitServer, err := NewLivekitServer(conf, roomService, agentDispatchService, egressService, ingressService, sipService, ioInfoService, rtcService, serviceWHIPService, agentService, keyProvider, router, roomManager, signalServer, server, currentNode) + livekitServer, err := NewLivekitServer(conf, roomService, agentDispatchService, egressService, ingressService, sipService, ioInfoService, rtcService, serviceWHIPService, agentWSService, agentWTService, agentEndpointService, keyProvider, router, roomManager, signalServer, server, currentNode) if err != nil { return nil, err } @@ -164,13 +171,13 @@ func InitializeRouter(conf *config.Config, currentNode routing.LocalNode) (routi return nil, err } nodeID := getNodeID(currentNode) - v := getMessageBus(universalClient) + psrpcConfig := getPSRPCConfig(conf) + v := getMessageBus(universalClient, psrpcConfig) signalRelayConfig := getSignalRelayConfig(conf) signalClient, err := routing.NewSignalClient(nodeID, v, signalRelayConfig) if err != nil { return nil, err } - psrpcConfig := getPSRPCConfig(conf) clientParams := getPSRPCClientParams(psrpcConfig, v) roomConfig := getRoomConfig(conf) roomManagerClient, err := routing.NewRoomManagerClient(clientParams, roomConfig) @@ -254,11 +261,12 @@ func createStore(rc redis.UniversalClient) ObjectStore { return NewLocalStore() } -func getMessageBus(rc redis.UniversalClient) psrpc.MessageBus { +func getMessageBus(rc redis.UniversalClient, psrpcConf rpc.PSRPCConfig) psrpc.MessageBus { + opts := psrpcConf.BusOptions() if rc == nil { - return psrpc.NewLocalMessageBus() + return psrpc.NewLocalMessageBus(opts...) } - return psrpc.NewRedisMessageBus(rc) + return redisbus.New(rc, opts...) } func getEgressStore(s ObjectStore) EgressStore { diff --git a/pkg/sfu/buffer/buffer.go b/pkg/sfu/buffer/buffer.go index 4a3368aa5..1dd2ea1be 100644 --- a/pkg/sfu/buffer/buffer.go +++ b/pkg/sfu/buffer/buffer.go @@ -184,7 +184,8 @@ func (b *Buffer) Bind(params webrtc.RTPParameters, codec webrtc.RTPCodecCapabili // Write adds an RTP Packet, ordering is not guaranteed, newer packets may arrive later func (b *Buffer) Write(pkt []byte) (n int, err error) { - var rtpPacket rtp.Packet + rtpPacket := rtpPacketFactory.Get().(*rtp.Packet) + defer rtpPacketFactory.Put(rtpPacket) err = rtpPacket.Unmarshal(pkt) if err != nil { return @@ -212,7 +213,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { } if b.streamInfoProbe != nil { - b.probeStreamInfoLocked(&rtpPacket) + b.probeStreamInfoLocked(rtpPacket) } // handle RTX packet @@ -224,7 +225,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { return } - pb.writeRTX(&rtpPacket, now) + pb.writeRTX(rtpPacket, now) return } @@ -237,7 +238,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { return } - pb.writeFEC(&rtpPacket, now) + pb.writeFEC(rtpPacket, now) return } @@ -279,7 +280,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { return } - b.ingestLocked(pkt, &rtpPacket, now, false, false) + b.ingestLocked(pkt, rtpPacket, now, false, false) var fecDelta fecRecoveryDelta var onFECRecovery func(received int, recovered int, discarded int, bytesReceived int) if b.fecDecoder != nil { @@ -287,7 +288,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { // previously unrecoverable FEC window. Recovered packets update NACK // state before feedback is generated below, so a packet repaired by // FEC is not needlessly NACKed. - fecDelta, onFECRecovery = b.feedFECLocked(&rtpPacket, now) + fecDelta, onFECRecovery = b.feedFECLocked(rtpPacket, now) } b.finishWriteLocked(now, fecDelta, onFECRecovery) return @@ -373,16 +374,25 @@ func (b *Buffer) NotifyRTX(ssrc uint32, repairSSRC uint32, rsid string) { } func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { + rtcpPackets := b.writeRTXLocked(rtxPkt, arrivalTime) + if len(rtcpPackets) != 0 { + if cb := b.getOnRtcpFeedback(); cb != nil { + cb(rtcpPackets) + } + } +} + +func (b *Buffer) writeRTXLocked(rtxPkt *rtp.Packet, arrivalTime int64) []rtcp.Packet { b.Lock() if !b.isBound { b.Unlock() - return + return nil } if rtxPkt.PayloadType != b.rtxPayloadType { b.logger.Debugw("unexpected rtx payload type", "expected", b.rtxPayloadType, "actual", rtxPkt.PayloadType) b.Unlock() - return + return nil } if b.rtxPktBuf == nil { @@ -392,7 +402,7 @@ func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { if len(rtxPkt.Payload) < 2 { b.logger.Warnw("rtx payload too short", nil, "size", len(rtxPkt.Payload)) b.Unlock() - return + return nil } repairedPkt := *rtxPkt @@ -404,7 +414,7 @@ func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { if err != nil { b.logger.Errorw("could not marshal repaired packet", err, "ssrc", b.BufferBase.SSRC(), "sn", repairedPkt.SequenceNumber) b.Unlock() - return + return nil } b.ingestLocked(b.rtxPktBuf[:n], &repairedPkt, arrivalTime, false, true) @@ -414,6 +424,7 @@ func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { fecDelta, onFECRecovery = b.feedFECLocked(&repairedPkt, arrivalTime) } b.finishWriteLocked(arrivalTime, fecDelta, onFECRecovery) + return nil } func (b *Buffer) SetPrimaryBufferForFEC(primaryBuffer *Buffer) { @@ -560,6 +571,7 @@ func (b *Buffer) writeFEC(fecPkt *rtp.Packet, arrivalTime int64) { fecDelta, onFECRecovery := b.feedFECLocked(fecPkt, arrivalTime) b.finishWriteLocked(arrivalTime, fecDelta, onFECRecovery) + return nil } // feedFECLocked runs a media or FEC packet through the FEC decoder and diff --git a/pkg/sfu/buffer/buffer_base.go b/pkg/sfu/buffer/buffer_base.go index 4d6cb0384..8e7b33438 100644 --- a/pkg/sfu/buffer/buffer_base.go +++ b/pkg/sfu/buffer/buffer_base.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "io" + "slices" "strings" "sync" "time" @@ -50,6 +51,13 @@ var ( return &ExtPacket{} }, } + + // unmarshal scratch, a reused packet keeps its Extensions capacity + rtpPacketFactory = &sync.Pool{ + New: func() any { + return &rtp.Packet{} + }, + } ) func ReleaseExtPacket(extPkt *ExtPacket) { @@ -71,13 +79,23 @@ type ExtPacket struct { ExtSequenceNumber uint64 ExtTimestamp uint64 Packet *rtp.Packet - Payload any + VP8 *codec.VP8 // nil when not parsed + VP9 *codecs.VP9Packet // nil when not parsed IsKeyFrame bool RawPacket []byte DependencyDescriptor *ExtDependencyDescriptor AbsCaptureTimeExt *act.AbsCaptureTime IsOutOfOrder bool IsBuffered bool + + // backing storage for Packet when the buffer creates the ext packet, + // extension and CSRC slices point into the write input until ReadExtended re-points them into RawPacket + pkt rtp.Packet + extensions [8]rtp.Extension + + // backing storage for VP8 and VP9, so parsing does not allocate + vp8 codec.VP8 + vp9 codecs.VP9Packet } type BufferProvider interface { @@ -742,7 +760,8 @@ func (b *BufferBase) HandleIncomingPacketLocked( oobSequenceNumber uint16, ) (uint64, error) { if rtpPacket == nil { - rtpPacket = &rtp.Packet{} + rtpPacket = rtpPacketFactory.Get().(*rtp.Packet) + defer rtpPacketFactory.Put(rtpPacket) if err := rtpPacket.Unmarshal(rawPkt); err != nil { b.logger.Errorw("could not unmarshal RTP packet", err) return 0, err @@ -886,10 +905,12 @@ func (b *BufferBase) HandleIncomingPacketLocked( if errors.Is(err, bucket.ErrPacketTooOld) { packetTooOldCount := b.packetTooOldCount.Inc() if (packetTooOldCount-1)%100 == 0 { + // log a copy so flowState stays on the stack + fs := flowState b.logger.Warnw( "could not add packet to bucket", err, "count", packetTooOldCount, - "flowState", &flowState, + "flowState", &fs, "snAdjustment", snAdjustment, "incomingSequenceNumber", flowState.ExtSequenceNumber+snAdjustment, "rtpStats", b.rtpStats, @@ -899,9 +920,10 @@ func (b *BufferBase) HandleIncomingPacketLocked( ) } } else if err != bucket.ErrRTXPacket { + fs := flowState b.logger.Warnw( "could not add packet to bucket", err, - "flowState", &flowState, + "flowState", &fs, "snAdjustment", snAdjustment, "incomingSequenceNumber", flowState.ExtSequenceNumber+snAdjustment, "rtpStats", b.rtpStats, @@ -1034,7 +1056,7 @@ func (b *BufferBase) getExtPacket( Arrival: arrivalTime, ExtSequenceNumber: flowState.ExtSequenceNumber, ExtTimestamp: flowState.ExtTimestamp, - Packet: rtpPacket, + pkt: *rtpPacket, VideoLayer: VideoLayer{ Spatial: InvalidLayerSpatial, Temporal: InvalidLayerTemporal, @@ -1042,6 +1064,10 @@ func (b *BufferBase) getExtPacket( IsOutOfOrder: flowState.IsOutOfOrder, IsBuffered: isBuffered, } + // rtpPacket may be reused scratch, copy what it backs + ep.pkt.Extensions = append(ep.extensions[:0], rtpPacket.Extensions...) + ep.pkt.CSRC = slices.Clone(rtpPacket.CSRC) + ep.Packet = &ep.pkt if len(ep.Packet.Payload) == 0 { // padding only packet, nothing else to do @@ -1054,11 +1080,11 @@ func (b *BufferBase) getExtPacket( } if b.absCaptureTimeExtID != 0 { - extData := rtpPacket.GetExtension(b.absCaptureTimeExtID) - - var actExt act.AbsCaptureTime - if err := actExt.Unmarshal(extData); err == nil { - ep.AbsCaptureTimeExt = &actExt + if extData := rtpPacket.GetExtension(b.absCaptureTimeExtID); len(extData) != 0 { + actExt := &act.AbsCaptureTime{} + if err := actExt.Unmarshal(extData); err == nil { + ep.AbsCaptureTimeExt = actExt + } } } @@ -1094,7 +1120,7 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { switch b.mime { case mime.MimeTypeVP8: - vp8Packet := codec.VP8{} + vp8Packet := &ep.vp8 if err := vp8Packet.Unmarshal(ep.Packet.Payload); err != nil { b.logger.Warnw("could not unmarshal VP8 packet", err) return err @@ -1104,7 +1130,7 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { ep.Temporal = int32(vp8Packet.TID) if ep.IsKeyFrame { - if sz := codec.ExtractVP8VideoSize(&vp8Packet, ep.Packet.Payload); sz.Width > 0 && sz.Height > 0 { + if sz := codec.ExtractVP8VideoSize(vp8Packet, ep.Packet.Payload); sz.Width > 0 && sz.Height > 0 { videoSize = append(videoSize, sz) } } @@ -1112,12 +1138,12 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { // vp8 with DependencyDescriptor enabled, use the TID from the descriptor vp8Packet.TID = uint8(ep.Temporal) } - ep.Payload = vp8Packet + ep.VP8 = vp8Packet ep.Spatial = InvalidLayerSpatial // vp8 don't have spatial scalability, reset to invalid case mime.MimeTypeVP9: if ep.DependencyDescriptor == nil { - var vp9Packet codecs.VP9Packet + vp9Packet := &ep.vp9 _, err := vp9Packet.Unmarshal(ep.Packet.Payload) if err != nil { b.logger.Warnw("could not unmarshal VP9 packet", err) @@ -1127,8 +1153,8 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { Spatial: int32(vp9Packet.SID), Temporal: int32(vp9Packet.TID), } - ep.Payload = vp9Packet - ep.IsKeyFrame = codec.IsVP9KeyFrame(&vp9Packet, ep.Packet.Payload) + ep.VP9 = vp9Packet + ep.IsKeyFrame = codec.IsVP9KeyFrame(vp9Packet, ep.Packet.Payload) if ep.IsKeyFrame { for i := 0; i < len(vp9Packet.Width); i++ { @@ -1210,16 +1236,19 @@ func (b *BufferBase) patchExtPacket(ep *ExtPacket, buf []byte) *ExtPacket { } ep.RawPacket = buf[:n] - // patch RTP packet to point payload to new buffer - pkt := *ep.Packet - payloadStart := ep.Packet.Header.MarshalSize() + // parse the header again from the new buffer so extension and CSRC slices point into it, + // then point the payload there too, ep owns its rtp.Packet + payloadStart, err := ep.Packet.Header.Unmarshal(buf[:n]) + if err != nil { + b.logger.Warnw("could not unmarshal header", err, "sn", ep.Packet.SequenceNumber) + return nil + } payloadEnd := payloadStart + len(ep.Packet.Payload) if payloadEnd > n { b.logger.Warnw("unexpected marshal size", nil, "max", n, "need", payloadEnd) return nil } - pkt.Payload = buf[payloadStart:payloadEnd] - ep.Packet = &pkt + ep.Packet.Payload = buf[payloadStart:payloadEnd] return ep } diff --git a/pkg/sfu/buffer/buffer_test.go b/pkg/sfu/buffer/buffer_test.go index f0f307690..032596dfd 100644 --- a/pkg/sfu/buffer/buffer_test.go +++ b/pkg/sfu/buffer/buffer_test.go @@ -15,6 +15,7 @@ package buffer import ( + "encoding/binary" "fmt" "math" "sync" @@ -23,10 +24,14 @@ import ( "github.com/pion/rtcp" "github.com/pion/rtp" + "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" "github.com/livekit/mediatransportutil/pkg/nack" + + act "github.com/livekit/livekit-server/pkg/sfu/rtpextension/abscapturetime" + "github.com/livekit/livekit-server/pkg/sfu/utils" ) var h265Codec = webrtc.RTPCodecParameters{ @@ -177,6 +182,59 @@ func TestNack(t *testing.T) { }) } +func TestNackOnRTX(t *testing.T) { + buff := NewBuffer(123, 1, 1) + buff.codecType = webrtc.RTPCodecTypeVideo + var mu sync.Mutex + var nacked []uint16 + buff.OnRtcpFeedback(func(fb []rtcp.Packet) { + mu.Lock() + defer mu.Unlock() + for _, pkt := range fb { + if p, ok := pkt.(*rtcp.TransportLayerNack); ok && p.MediaSSRC == 123 { + for _, pair := range p.Nacks { + nacked = append(nacked, pair.PacketList()...) + } + } + } + }) + require.NoError(t, buff.Bind(webrtc.RTPParameters{ + Codecs: []webrtc.RTPCodecParameters{vp8Codec}, + }, vp8Codec.RTPCodecCapability, 0)) + buff.rtxPayloadType = 97 + rtt := uint32(20) + buff.nacker.SetRTT(rtt) + + payload := []byte{0xff, 0xff, 0xff, 0xfd, 0xb4, 0x9f, 0x94, 0x1} + // sequence numbers 1 and 2 are lost + for _, sn := range []uint16{0, 3} { + pkt := rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 96, SequenceNumber: sn, Timestamp: uint32(sn), SSRC: 123}, + Payload: payload, + } + b, err := pkt.Marshal() + require.NoError(t, err) + _, err = buff.Write(b) + require.NoError(t, err) + } + + time.Sleep(time.Duration(2*rtt) * time.Millisecond) + mu.Lock() + nacked = nil + mu.Unlock() + + // RTX repairs sequence number 2, the NACK for 1 is due and must be sent + rtxPkt := rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 97, SequenceNumber: 500, Timestamp: 2, SSRC: 456}, + Payload: append([]byte{0x00, 0x02}, payload...), + } + buff.writeRTX(&rtxPkt, time.Now().UnixNano()) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, []uint16{1}, nacked) +} + func TestNewBuffer(t *testing.T) { tests := []struct { name string @@ -460,6 +518,138 @@ func TestCodecChange(t *testing.T) { } } +// Write unmarshals into reused scratch and callers reuse the input buffer, so each ExtPacket +// must keep its own header and extensions, and Write plus ReadExtended must not allocate per packet +func TestIngestNoAlloc(t *testing.T) { + testCases := []struct { + name string + codec webrtc.RTPCodecParameters + payload []byte + maxAllocs float64 + }{ + {"opus", opusCodec, make([]byte, 100), 0}, + // VP8 boxes the parsed descriptor into ExtPacket.Payload + {"vp8", vp8Codec, []byte{0x90, 0xe0, 0x80, 0x01, 0x05, 0x00, 0x01}, 1}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buff := NewBuffer(123, 100, 100) + require.NoError(t, buff.Bind(webrtc.RTPParameters{ + HeaderExtensions: []webrtc.RTPHeaderExtensionParameter{ + {URI: sdp.AudioLevelURI, ID: 1}, + {URI: act.AbsCaptureTimeURI, ID: 2}, + }, + Codecs: []webrtc.RTPCodecParameters{tc.codec}, + }, tc.codec.RTPCodecCapability, 0)) + defer buff.Close() + + marshal := func(sn uint16, level byte) []byte { + p := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: uint8(tc.codec.PayloadType), + SequenceNumber: sn, + Timestamp: uint32(sn) * 960, + SSRC: 123, + }, + Payload: tc.payload, + } + require.NoError(t, p.SetExtension(1, []byte{level})) + require.NoError(t, p.SetExtension(3, []byte{0, 0})) + raw, err := p.Marshal() + require.NoError(t, err) + return raw + } + + // one input buffer for all writes, like the transport read loop + input := make([]byte, 1500) + write := func(sn uint16, level byte) { + n := copy(input, marshal(sn, level)) + _, err := buff.Write(input[:n]) + require.NoError(t, err) + } + + readBuf := make([]byte, 1500) + write(1, 10) + write(2, 20) + for _, want := range []struct { + sn uint16 + level byte + }{{1, 10}, {2, 20}} { + ep, err := buff.ReadExtended(readBuf) + require.NoError(t, err) + require.Equal(t, want.sn, ep.Packet.SequenceNumber) + require.Equal(t, []byte{want.level}, ep.Packet.GetExtension(1)) + require.Equal(t, tc.payload, ep.Packet.Payload) + require.Nil(t, ep.AbsCaptureTimeExt) + ReleaseExtPacket(ep) + } + + raw := marshal(3, 30) + sn := uint16(3) + ingest := func() { + binary.BigEndian.PutUint16(raw[2:], sn) + binary.BigEndian.PutUint32(raw[4:], uint32(sn)*960) + sn++ + _, err := buff.Write(raw) + require.NoError(t, err) + ep, err := buff.ReadExtended(readBuf) + require.NoError(t, err) + ReleaseExtPacket(ep) + } + allocs := testing.AllocsPerRun(1000, ingest) + if !utils.RaceEnabled { + require.LessOrEqual(t, allocs, tc.maxAllocs, "allocations per Write plus ReadExtended") + } + }) + } +} + +// a dropped padding only packet shifts later sequence numbers, ReadExtended must keep the shifted number +// in the header and in RawPacket +func TestIngestDroppedPaddingKeepsAdjustedSequenceNumber(t *testing.T) { + buff := NewBuffer(123, 100, 100) + require.NoError(t, buff.Bind(webrtc.RTPParameters{ + Codecs: []webrtc.RTPCodecParameters{opusCodec}, + }, opusCodec.RTPCodecCapability, 0)) + defer buff.Close() + + write := func(sn uint16, paddingOnly bool) { + p := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: uint8(opusCodec.PayloadType), + SequenceNumber: sn, + Timestamp: uint32(sn) * 960, + SSRC: 123, + }, + } + if paddingOnly { + p.Header.Padding = true + p.Header.PaddingSize = 20 + } else { + p.Payload = make([]byte, 100) + } + raw, err := p.Marshal() + require.NoError(t, err) + _, err = buff.Write(raw) + require.NoError(t, err) + } + + write(40, false) + write(41, true) + write(42, false) + + readBuf := make([]byte, 1500) + for _, wantSN := range []uint16{40, 41} { + ep, err := buff.ReadExtended(readBuf) + require.NoError(t, err) + require.Equal(t, wantSN, ep.Packet.SequenceNumber) + require.Equal(t, wantSN, binary.BigEndian.Uint16(ep.RawPacket[2:])) + ReleaseExtPacket(ep) + } +} + func BenchmarkMemcpu(b *testing.B) { buf := make([]byte, 1500*1500*10) buf2 := make([]byte, 1500*1500*20) diff --git a/pkg/sfu/buffer/datastats.go b/pkg/sfu/buffer/datastats.go index 795f8a0f2..89255b453 100644 --- a/pkg/sfu/buffer/datastats.go +++ b/pkg/sfu/buffer/datastats.go @@ -68,13 +68,14 @@ func (s *DataStats) ToProtoActive() *livekit.RTPStats { if duration > s.params.WindowDuration.Nanoseconds() { return &livekit.RTPStats{} } + seconds := float64(duration) / 1e9 return &livekit.RTPStats{ StartTime: timestamppb.New(time.Unix(s.windowStart/1e9, s.windowStart%1e9)), EndTime: timestamppb.New(time.Unix(0, now)), - Duration: float64(duration / 1e9), + Duration: seconds, Bytes: uint64(s.windowBytes), - Bitrate: float64(s.windowBytes) * 8 / float64(duration) / 1e9, + Bitrate: float64(s.windowBytes) * 8 / seconds, } } diff --git a/pkg/sfu/buffer/datastats_test.go b/pkg/sfu/buffer/datastats_test.go index 5803fe9d9..bfdc42235 100644 --- a/pkg/sfu/buffer/datastats_test.go +++ b/pkg/sfu/buffer/datastats_test.go @@ -51,3 +51,16 @@ func TestDataStats(t *testing.T) { require.EqualValues(t, 100, r.Bytes) require.NotZero(t, r.Bitrate) } + +func TestDataStatsActiveBitrateAndDuration(t *testing.T) { + stats := NewDataStats(DataStatsParam{WindowDuration: 10 * time.Second}) + + // 1000 bytes over a 1.5 second window is 8000 bits / 1.5 s + stats.windowStart = time.Now().Add(-1500 * time.Millisecond).UnixNano() + stats.windowBytes = 1000 + + r := stats.ToProtoActive() + require.EqualValues(t, 1000, r.Bytes) + require.InDelta(t, 1.5, r.Duration, 0.1) + require.InDelta(t, 8000/1.5, r.Bitrate, 0.1*8000/1.5) +} diff --git a/pkg/sfu/buffer/dependencydescriptorparser.go b/pkg/sfu/buffer/dependencydescriptorparser.go index 618200aab..332c38a28 100644 --- a/pkg/sfu/buffer/dependencydescriptorparser.go +++ b/pkg/sfu/buffer/dependencydescriptorparser.go @@ -60,12 +60,19 @@ type DependencyDescriptorParser struct { onMaxLayerChanged func(int32, int32) decodeTargets []DependencyDescriptorDecodeTarget - seqWrapAround *utils.WrapAround[uint16, uint64] - frameWrapAround *utils.WrapAround[uint16, uint64] - structureExtFrameNum uint64 - activeDecodeTargetsExtSeq uint64 - activeDecodeTargetsMask uint32 - frameChecker *FrameIntegrityChecker + seqWrapAround *utils.WrapAround[uint16, uint64] + frameWrapAround *utils.WrapAround[uint16, uint64] + structureExtFrameNum uint64 + // drop threshold for frames belonging to a previous dependency structure. + // advanced only when the structure id actually changes: structureExtFrameNum + // advances on every structure-bearing key frame, so with frequent key frames + // repeating the same structure (e. g. screen content), valid late/retransmitted + // frames would be dropped as "earlier than current structure". + structureChangeExtFrameNum uint64 + hasActiveDecodeTargets bool // the first mask is taken whatever its sequence number + activeDecodeTargetsExtSeq uint64 + activeDecodeTargetsMask uint32 + frameChecker *FrameIntegrityChecker ddNotFoundCount atomic.Uint32 @@ -93,14 +100,26 @@ type ExtDependencyDescriptor struct { DecodeTargets []DependencyDescriptorDecodeTarget StructureUpdated bool ActiveDecodeTargetsUpdated bool - Integrity bool - ExtFrameNum uint64 + // the stream's current active decode targets, on every packet, also when the mask + // changed on a packet the subscriber does not get + ActiveDecodeTargetsMask uint32 + Integrity bool + ExtFrameNum uint64 // the frame number of the keyframe which the current frame depends on ExtKeyFrameNum uint64 // increase when the stream restarts, clear and reinitialize all dd state includes // attached structure, frame chain, decode target. RestartGeneration int + + // storage for Descriptor, zeroed with the rest of the struct on release, + // a frame with more than 8 frame diffs spills to the heap + descriptor dd.DependencyDescriptor + frameDependencies dd.FrameDependencyTemplate + resolution dd.RenderResolution + dtis [dd.MaxDecodeTargets]dd.DecodeTargetIndication + chainDiffs [dd.MaxDecodeTargets]int + frameDiffs [8]int } func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescriptor, VideoLayer, error) { @@ -114,9 +133,19 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr return nil, videoLayer, ErrDDExtentionNotFound } - var ddVal dd.DependencyDescriptor + extDD := ExtDependencyDescriptorFactory.Get().(*ExtDependencyDescriptor) + extDD.frameDependencies = dd.FrameDependencyTemplate{ + DecodeTargetIndications: extDD.dtis[:0], + FrameDiffs: extDD.frameDiffs[:0], + ChainDiffs: extDD.chainDiffs[:0], + } + ddVal := &extDD.descriptor + *ddVal = dd.DependencyDescriptor{ + FrameDependencies: &extDD.frameDependencies, + Resolution: &extDD.resolution, + } ext := &dd.DependencyDescriptorExtension{ - Descriptor: &ddVal, + Descriptor: ddVal, Structure: r.structure, } _, err := ext.Unmarshal(ddBuf) @@ -124,6 +153,7 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr if err != dd.ErrDDReaderNoStructure && err != dd.ErrDDReaderInvalidTemplateIndex { r.logger.Infow("failed to parse generic dependency descriptor", err, "payload", pkt.PayloadType, "ddbufLen", len(ddBuf)) } + ReleaseExtDependencyDescriptor(extDD) return nil, videoLayer, err } @@ -153,27 +183,25 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr unwrapped := r.frameWrapAround.UpdateWithOrderKnown(ddVal.FrameNumber, restart) extFN := unwrapped.ExtendedVal - if extFN < r.structureExtFrameNum { + if extFN < r.structureChangeExtFrameNum { r.logger.Debugw( "drop frame which is earlier than current structure", "fn", ddVal.FrameNumber, "extFN", extFN, - "structureExtFrameNum", r.structureExtFrameNum, + "structureChangeExtFrameNum", r.structureChangeExtFrameNum, "unwrappedFN", unwrapped, "frameWrapAround", r.frameWrapAround, ) + ReleaseExtDependencyDescriptor(extDD) return nil, videoLayer, ErrFrameEarlierThanKeyFrame } - r.frameChecker.AddPacket(extSeq, extFN, &ddVal) + r.frameChecker.AddPacket(extSeq, extFN, ddVal) - extDD := ExtDependencyDescriptorFactory.Get().(*ExtDependencyDescriptor) - *extDD = ExtDependencyDescriptor{ - Descriptor: &ddVal, - ExtFrameNum: extFN, - Integrity: r.frameChecker.FrameIntegrity(extFN), - RestartGeneration: r.restartGeneration, - } + extDD.Descriptor = ddVal + extDD.ExtFrameNum = extFN + extDD.Integrity = r.frameChecker.FrameIntegrity(extFN) + extDD.RestartGeneration = r.restartGeneration if ddVal.AttachedStructure != nil { if !ddVal.FirstPacketInFrame { @@ -188,7 +216,24 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr return nil, videoLayer, ErrDDStructureAttachedToNonFirstPacket } + if extFN < r.structureExtFrameNum { + // out-of-order key frame repeating the current structure: accepting it + // would regress structureExtFrameNum (ExtKeyFrameNum) and replay a stale + // structure update, confusing the downtrack's dependency descriptor + // selector. + r.logger.Debugw( + "drop out-of-order key frame", + "extFN", extFN, + "structureExtFrameNum", r.structureExtFrameNum, + ) + ReleaseExtDependencyDescriptor(extDD) + return nil, videoLayer, ErrFrameEarlierThanKeyFrame + } + if r.structure == nil || ddVal.AttachedStructure.StructureId != r.structure.StructureId { + // structure actually changed (or first structure): advance the drop threshold + // so that only frames preceding this structure are dropped. + r.structureChangeExtFrameNum = extFN r.logger.Debugw( "structure updated", "structureID", ddVal.AttachedStructure.StructureId, @@ -221,7 +266,8 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr // so don't need to notify max layer change here. } - if mask := ddVal.ActiveDecodeTargetsBitmask; mask != nil && extSeq > r.activeDecodeTargetsExtSeq { + if mask := ddVal.ActiveDecodeTargetsBitmask; mask != nil && (!r.hasActiveDecodeTargets || extSeq > r.activeDecodeTargetsExtSeq) { + r.hasActiveDecodeTargets = true r.activeDecodeTargetsExtSeq = extSeq if *mask != r.activeDecodeTargetsMask { r.activeDecodeTargetsMask = *mask @@ -244,6 +290,7 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr extDD.DecodeTargets = r.decodeTargets extDD.ExtKeyFrameNum = r.structureExtFrameNum + extDD.ActiveDecodeTargetsMask = r.activeDecodeTargetsMask return extDD, videoLayer, nil } @@ -252,6 +299,8 @@ func (r *DependencyDescriptorParser) restart() { r.frameChecker = NewFrameIntegrityChecker(integrityCheckFrame, integrityCheckPkt) r.structure = nil r.structureExtFrameNum = 0 + r.structureChangeExtFrameNum = 0 + r.hasActiveDecodeTargets = false r.activeDecodeTargetsExtSeq = 0 r.activeDecodeTargetsMask = 0 r.decodeTargets = r.decodeTargets[:0] diff --git a/pkg/sfu/buffer/dependencydescriptorparser_test.go b/pkg/sfu/buffer/dependencydescriptorparser_test.go new file mode 100644 index 000000000..370c53023 --- /dev/null +++ b/pkg/sfu/buffer/dependencydescriptorparser_test.go @@ -0,0 +1,290 @@ +// 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 buffer + +import ( + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" +) + +const ddTestExtID = uint8(1) + +// single spatial layer, single temporal layer, one decode target, one chain +func newL1T1Structure(structureID int) *dd.FrameDependencyStructure { + return &dd.FrameDependencyStructure{ + StructureId: structureID, + NumDecodeTargets: 1, + NumChains: 1, + DecodeTargetProtectedByChain: []int{0}, + Templates: []*dd.FrameDependencyTemplate{ + { // key frame + DecodeTargetIndications: []dd.DecodeTargetIndication{dd.DecodeTargetSwitch}, + ChainDiffs: []int{0}, + }, + { // delta frame + DecodeTargetIndications: []dd.DecodeTargetIndication{dd.DecodeTargetRequired}, + FrameDiffs: []int{1}, + ChainDiffs: []int{1}, + }, + }, + } +} + +type ddTestFeeder struct { + t *testing.T + parser *DependencyDescriptorParser + // structure the writer marshals against, i. e. what the publisher last sent + structure *dd.FrameDependencyStructure + seq uint16 +} + +func newDDTestFeeder(t *testing.T) *ddTestFeeder { + return &ddTestFeeder{ + t: t, + parser: NewDependencyDescriptorParser(ddTestExtID, logger.GetLogger(), func(int32, int32) {}, false), + } +} + +// keyFrame sends a key frame carrying `structure`. Passing the same structure id as +// the previous key frame models a publisher that repeats an unchanged structure, +// which is what screen content with frequent key frames does. +func (f *ddTestFeeder) keyFrame(frameNumber uint16, structure *dd.FrameDependencyStructure) (*ExtDependencyDescriptor, error) { + f.structure = structure + return f.feed(frameNumber, structure.Templates[0], structure) +} + +func (f *ddTestFeeder) deltaFrame(frameNumber uint16) (*ExtDependencyDescriptor, error) { + return f.feed(frameNumber, f.structure.Templates[1], nil) +} + +// deltaFrameWithMask sends a delta frame that changes the active decode targets +func (f *ddTestFeeder) deltaFrameWithMask(frameNumber uint16, activeDecodeTargets uint32) (*ExtDependencyDescriptor, error) { + return f.feedWithMask(frameNumber, f.structure.Templates[1], nil, &activeDecodeTargets) +} + +func (f *ddTestFeeder) feed( + frameNumber uint16, + template *dd.FrameDependencyTemplate, + attachedStructure *dd.FrameDependencyStructure, +) (*ExtDependencyDescriptor, error) { + return f.feedWithMask(frameNumber, template, attachedStructure, nil) +} + +func (f *ddTestFeeder) feedWithMask( + frameNumber uint16, + template *dd.FrameDependencyTemplate, + attachedStructure *dd.FrameDependencyStructure, + activeDecodeTargets *uint32, +) (*ExtDependencyDescriptor, error) { + f.t.Helper() + + ddVal := &dd.DependencyDescriptor{ + FirstPacketInFrame: true, + LastPacketInFrame: true, + FrameNumber: frameNumber, + FrameDependencies: template, + AttachedStructure: attachedStructure, + ActiveDecodeTargetsBitmask: activeDecodeTargets, + } + buf, err := (&dd.DependencyDescriptorExtension{Descriptor: ddVal, Structure: f.structure}).Marshal() + require.NoError(f.t, err) + + f.seq++ + pkt := &rtp.Packet{Header: rtp.Header{SequenceNumber: f.seq}} + require.NoError(f.t, pkt.SetExtension(ddTestExtID, buf)) + + extDD, _, err := f.parser.Parse(pkt) + return extDD, err +} + +// A key frame that repeats the current structure must not start dropping frames that +// precede it. Upstream advanced the drop threshold on every structure bearing key +// frame, so with frequent key frames (screen content) a late or retransmitted frame +// arriving after one was discarded as "earlier than current structure". +func TestDependencyDescriptorParserLateFrameAfterRepeatedStructure(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + _, err := f.keyFrame(0, structure) + require.NoError(t, err) + _, err = f.deltaFrame(1) + require.NoError(t, err) + _, err = f.deltaFrame(3) + require.NoError(t, err) + + // key frame repeating the same structure id + _, err = f.keyFrame(4, structure) + require.NoError(t, err) + + // frame 2 finally shows up (reordered or retransmitted). The structure has not + // changed, so it is still decodable and must be forwarded. + extDD, err := f.deltaFrame(2) + require.NoError(t, err) + require.NotNil(t, extDD) + require.EqualValues(t, 2, extDD.ExtFrameNum) +} + +// A key frame carrying a *different* structure does invalidate everything before it: +// earlier frames reference templates that no longer exist. +func TestDependencyDescriptorParserLateFrameAfterStructureChange(t *testing.T) { + f := newDDTestFeeder(t) + + _, err := f.keyFrame(0, newL1T1Structure(0)) + require.NoError(t, err) + _, err = f.deltaFrame(1) + require.NoError(t, err) + _, err = f.deltaFrame(3) + require.NoError(t, err) + + _, err = f.keyFrame(4, newL1T1Structure(1)) + require.NoError(t, err) + + _, err = f.deltaFrame(2) + require.ErrorIs(t, err, ErrFrameEarlierThanKeyFrame) +} + +// An out-of-order key frame must still be dropped: accepting it would regress +// structureExtFrameNum (ExtKeyFrameNum) and replay a stale structure update. +func TestDependencyDescriptorParserOutOfOrderKeyFrame(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + _, err := f.keyFrame(0, structure) + require.NoError(t, err) + _, err = f.deltaFrame(1) + require.NoError(t, err) + _, err = f.keyFrame(4, structure) + require.NoError(t, err) + + _, err = f.keyFrame(2, structure) + require.ErrorIs(t, err, ErrFrameEarlierThanKeyFrame) +} + +// ExtKeyFrameNum keeps tracking every structure bearing key frame, unchanged by the +// split of the drop threshold into its own field. +func TestDependencyDescriptorParserExtKeyFrameNum(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ExtKeyFrameNum) + + extDD, err = f.deltaFrame(1) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ExtKeyFrameNum) + + // repeated structure still advances ExtKeyFrameNum + extDD, err = f.keyFrame(4, structure) + require.NoError(t, err) + require.EqualValues(t, 4, extDD.ExtKeyFrameNum) + + extDD, err = f.deltaFrame(5) + require.NoError(t, err) + require.EqualValues(t, 4, extDD.ExtKeyFrameNum) +} + +// every packet carries the stream's current active decode targets, also after the mask +// changed on a packet the forwarder dropped +func TestDependencyDescriptorParserActiveDecodeTargetsMask(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.deltaFrameWithMask(1, 0) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.deltaFrame(2) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.keyFrame(3, structure) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) +} + +// a first key frame at RTP sequence number 0 still makes its structure's decode targets +// active +func TestDependencyDescriptorParserActiveDecodeTargetsMaskAtSequenceZero(t *testing.T) { + f := newDDTestFeeder(t) + f.seq = 0xffff // the first packet goes out with sequence number 0 + structure := newL1T1Structure(0) + + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.deltaFrame(1) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) +} + +// a pooled ExtDependencyDescriptor carries the storage for its descriptor, so parsing a delta +// frame must not allocate and a reused descriptor must not show the previous packet's values +func TestDependencyDescriptorParserNoAlloc(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + structure.Resolutions = []dd.RenderResolution{{Width: 640, Height: 360}} + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.Equal(t, 0, len(extDD.Descriptor.FrameDependencies.FrameDiffs)) + require.Equal(t, dd.RenderResolution{Width: 640, Height: 360}, *extDD.Descriptor.Resolution) + ReleaseExtDependencyDescriptor(extDD) + + extDD, err = f.deltaFrame(1) + require.NoError(t, err) + require.Equal(t, []int{1}, extDD.Descriptor.FrameDependencies.FrameDiffs) + require.Equal(t, []int{1}, extDD.Descriptor.FrameDependencies.ChainDiffs) + require.Nil(t, extDD.Descriptor.AttachedStructure) + ReleaseExtDependencyDescriptor(extDD) + + // the parsed template is a copy, the structure's template is untouched + require.Equal(t, []int{1}, structure.Templates[1].FrameDiffs) + + // marshal the packets first, only Parse runs under the allocation count + bufs := make([][]byte, 128) + for i := range bufs { + ddVal := &dd.DependencyDescriptor{ + FirstPacketInFrame: true, + LastPacketInFrame: true, + FrameNumber: uint16(i + 2), + FrameDependencies: structure.Templates[1], + } + bufs[i], err = (&dd.DependencyDescriptorExtension{Descriptor: ddVal, Structure: structure}).Marshal() + require.NoError(t, err) + } + pkt := &rtp.Packet{Header: rtp.Header{SequenceNumber: f.seq}} + require.NoError(t, pkt.SetExtension(ddTestExtID, bufs[0])) + i := 0 + allocs := testing.AllocsPerRun(100, func() { + pkt.SequenceNumber++ + require.NoError(t, pkt.SetExtension(ddTestExtID, bufs[i])) + i++ + extDD, _, err := f.parser.Parse(pkt) + require.NoError(t, err) + ReleaseExtDependencyDescriptor(extDD) + }) + require.Zero(t, allocs) +} diff --git a/pkg/sfu/buffer/fps.go b/pkg/sfu/buffer/fps.go index 9ac69cd8f..fa25dc584 100644 --- a/pkg/sfu/buffer/fps.go +++ b/pkg/sfu/buffer/fps.go @@ -17,22 +17,18 @@ package buffer import ( "container/list" - "github.com/pion/rtp/codecs" - - "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/logger" ) var minFramesForCalculation = [...]int{8, 15, 40, 60} type frameInfo struct { - startSeq uint16 - endSeq uint16 - ts uint32 - fn uint16 - spatial int32 - temporal int32 - frameDiff []int + startSeq uint16 + endSeq uint16 + ts uint32 + fn uint16 + spatial int32 + temporal int32 } type FrameRateCalculator interface { @@ -210,8 +206,8 @@ func (f *FrameRateCalculatorVP8) RecvPacket(ep *ExtPacket) bool { return true } - vp8, ok := ep.Payload.(codec.VP8) - if !ok { + vp8 := ep.VP8 + if vp8 == nil { f.logger.Debugw("no vp8 payload", "sn", ep.Packet.SequenceNumber) return false } @@ -261,8 +257,8 @@ func (f *FrameRateCalculatorVP9) RecvPacket(ep *ExtPacket) bool { return true } - vp9, ok := ep.Payload.(codecs.VP9Packet) - if !ok { + vp9 := ep.VP9 + if vp9 == nil { f.logger.Debugw("no vp9 payload", "sn", ep.Packet.SequenceNumber) return false } @@ -425,11 +421,10 @@ func (f *FrameRateCalculatorDD) RecvPacket(ep *ExtPacket) bool { } fi := &frameInfo{ - ts: ep.Packet.Timestamp, - fn: fn, - temporal: temporal, - spatial: spatial, - frameDiff: ep.DependencyDescriptor.Descriptor.FrameDependencies.FrameDiffs, + ts: ep.Packet.Timestamp, + fn: fn, + temporal: temporal, + spatial: spatial, } f.fnReceived[baseDiff] = fi diff --git a/pkg/sfu/buffer/fps_test.go b/pkg/sfu/buffer/fps_test.go index 8bdacb13f..6e7979abe 100644 --- a/pkg/sfu/buffer/fps_test.go +++ b/pkg/sfu/buffer/fps_test.go @@ -36,7 +36,7 @@ type testFrameInfo struct { func (f *testFrameInfo) toVP8() *ExtPacket { return &ExtPacket{ Packet: &rtp.Packet{Header: f.header}, - Payload: codec.VP8{ + VP8: &codec.VP8{ I: true, PictureID: f.framenumber, }, diff --git a/pkg/sfu/buffer/frameintegrity.go b/pkg/sfu/buffer/frameintegrity.go index e2cbe8a43..9e7814a8b 100644 --- a/pkg/sfu/buffer/frameintegrity.go +++ b/pkg/sfu/buffer/frameintegrity.go @@ -19,9 +19,9 @@ import ( ) type FrameEntity struct { - startSeq *uint64 - endSeq *uint64 - integrity bool + startSeq, endSeq uint64 + hasStartSeq, hasEndSeq bool + integrity bool pktHistory *PacketHistory } @@ -32,15 +32,15 @@ func (fe *FrameEntity) AddPacket(extSeq uint64, ddVal *dd.DependencyDescriptor) return } - if fe.startSeq == nil && ddVal.FirstPacketInFrame { - fe.startSeq = &extSeq + if !fe.hasStartSeq && ddVal.FirstPacketInFrame { + fe.startSeq, fe.hasStartSeq = extSeq, true } - if fe.endSeq == nil && ddVal.LastPacketInFrame { - fe.endSeq = &extSeq + if !fe.hasEndSeq && ddVal.LastPacketInFrame { + fe.endSeq, fe.hasEndSeq = extSeq, true } - if fe.startSeq != nil && fe.endSeq != nil { - if fe.pktHistory.PacketsConsecutive(*fe.startSeq, *fe.endSeq) { + if fe.hasStartSeq && fe.hasEndSeq { + if fe.pktHistory.PacketsConsecutive(fe.startSeq, fe.endSeq) { fe.integrity = true } } @@ -48,7 +48,7 @@ func (fe *FrameEntity) AddPacket(extSeq uint64, ddVal *dd.DependencyDescriptor) func (fe *FrameEntity) Reset() { fe.integrity = false - fe.startSeq, fe.endSeq = nil, nil + fe.hasStartSeq, fe.hasEndSeq = false, false } func (fe *FrameEntity) Integrity() bool { diff --git a/pkg/sfu/bwe/sendsidebwe/congestion_detector.go b/pkg/sfu/bwe/sendsidebwe/congestion_detector.go index 720d4adc2..868d63555 100644 --- a/pkg/sfu/bwe/sendsidebwe/congestion_detector.go +++ b/pkg/sfu/bwe/sendsidebwe/congestion_detector.go @@ -689,13 +689,17 @@ func (c *congestionDetector) HandleTWCCFeedback(report *rtcp.TransportLayerCC) { processSymbol := func(symbol uint16) { recvTime := int64(0) isLost := false - if symbol != rtcp.TypeTCCPacketNotReceived { + if symbol == rtcp.TypeTCCPacketNotReceived { + isLost = true + } else if (symbol == rtcp.TypeTCCPacketReceivedSmallDelta || symbol == rtcp.TypeTCCPacketReceivedLargeDelta) && deltaIdx < len(report.RecvDeltas) { recvRefTime += report.RecvDeltas[deltaIdx].Delta deltaIdx++ recvTime = recvRefTime } else { - isLost = true + // the reserved symbol carries no receive delta, nothing to record + sequenceNumber++ + return } pi, sendDelta, recvDelta := c.packetTracker.RecordPacketIndicationFromRemote(sequenceNumber, recvTime) if pi.sendTime != 0 { diff --git a/pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go b/pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go new file mode 100644 index 000000000..1d36a144d --- /dev/null +++ b/pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go @@ -0,0 +1,107 @@ +// 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 sendsidebwe + +import ( + "testing" + + "github.com/pion/rtcp" + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/utils/mono" + + "github.com/livekit/livekit-server/pkg/sfu/ccutils" +) + +// the reserved symbol has no receive delta, so its packet is neither acked nor lost, +// and the next received packet uses the next delta +func TestHandleTWCCFeedbackReservedSymbol(t *testing.T) { + // small delta, reserved, reserved, small delta, not received + for name, chunks := range map[string][]rtcp.PacketStatusChunk{ + "status vector": { + &rtcp.StatusVectorChunk{ + SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit, + SymbolList: []uint16{ + rtcp.TypeTCCPacketReceivedSmallDelta, + rtcp.TypeTCCPacketReceivedWithoutDelta, + rtcp.TypeTCCPacketReceivedWithoutDelta, + rtcp.TypeTCCPacketReceivedSmallDelta, + rtcp.TypeTCCPacketNotReceived, + rtcp.TypeTCCPacketNotReceived, + rtcp.TypeTCCPacketNotReceived, + }, + }, + }, + "run length": { + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta, RunLength: 1}, + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedWithoutDelta, RunLength: 2}, + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta, RunLength: 1}, + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketNotReceived, RunLength: 1}, + }, + } { + t.Run(name, func(t *testing.T) { + c := newCongestionDetector(congestionDetectorParams{ + Config: defaultCongestionDetectorConfig, + Logger: logger.GetLogger(), + }) + // the first packet sent is the send time base and never joins a group, so keep it out of the report + now := mono.UnixMicro() + c.RecordPacketSendAndGetSequenceNumber(now, 1200, false, ccutils.ProbeClusterIdInvalid, false) + sns := make([]uint16, 5) + for i := range sns { + sns[i] = c.RecordPacketSendAndGetSequenceNumber(now+int64(i+1)*1000, 1200, false, ccutils.ProbeClusterIdInvalid, false) + } + + fb := &rtcp.TransportLayerCC{ + Header: rtcp.Header{ + Count: rtcp.FormatTCC, + Type: rtcp.TypeTransportSpecificFeedback, + }, + MediaSSRC: 1, + BaseSequenceNumber: sns[0], + PacketStatusCount: uint16(len(sns)), + ReferenceTime: 100, + FbPktCount: 1, + PacketChunks: chunks, + RecvDeltas: []*rtcp.RecvDelta{ + {Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 1000}, + {Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 2000}, + }, + } + fb.Header.Padding = fb.MarshalSize()%4 != 0 + fb.Header.Length = uint16(fb.MarshalSize()/4 - 1) + + // round trip so the report is exactly what a remote peer can deliver + raw, err := fb.Marshal() + require.NoError(t, err) + pkts, err := rtcp.Unmarshal(raw) + require.NoError(t, err) + + require.NotPanics(t, func() { c.HandleTWCCFeedback(pkts[0].(*rtcp.TransportLayerCC)) }) + + // receive time is relative to the first received packet + require.Equal(t, int64(2000), c.packetTracker.getPacketInfoExisting(sns[3]).recvTime) + + acked, lost := 0, 0 + for _, pg := range c.packetGroups { + acked += pg.acked.numPackets() + lost += pg.lost.numPackets() + } + require.Equal(t, 2, acked) + require.Equal(t, 1, lost) + }) + } +} diff --git a/pkg/sfu/ccutils/probe_regulator.go b/pkg/sfu/ccutils/probe_regulator.go index ed0940bd9..5804404b1 100644 --- a/pkg/sfu/ccutils/probe_regulator.go +++ b/pkg/sfu/ccutils/probe_regulator.go @@ -80,7 +80,7 @@ func (p *ProbeRegulator) ProbeDuration() time.Duration { func (p *ProbeRegulator) ProbeSignal(probeSignal ProbeSignal, baseTime time.Time) { if probeSignal == ProbeSignalCongesting { // wait longer till next probe - p.probeInterval = time.Duration(p.probeInterval.Seconds()*p.params.Config.BackoffFactor) * time.Second + p.probeInterval = time.Duration(float64(p.probeInterval.Milliseconds())*p.params.Config.BackoffFactor) * time.Millisecond if p.probeInterval > p.params.Config.MaxInterval { p.probeInterval = p.params.Config.MaxInterval } diff --git a/pkg/sfu/codecmunger/codecmunger.go b/pkg/sfu/codecmunger/codecmunger.go index 413af9688..add55f333 100644 --- a/pkg/sfu/codecmunger/codecmunger.go +++ b/pkg/sfu/codecmunger/codecmunger.go @@ -23,9 +23,13 @@ import ( var ( ErrNotVP8 = errors.New("not VP8") ErrOutOfOrderVP8PictureIdCacheMiss = errors.New("out-of-order VP8 picture id not found in cache") - ErrFilteredVP8TemporalLayer = errors.New("filtered VP8 temporal layer") + ErrFilteredTemporalLayer = errors.New("filtered temporal layer") + ErrFilteredOutOfOrder = errors.New("filtered out-of-order packet") ) +// MaxHeaderSize is the largest codec header the mungers produce (VP8: 6 bytes). +const MaxHeaderSize = 8 + type CodecMunger interface { GetState() any SeedState(state any) @@ -33,7 +37,9 @@ type CodecMunger interface { SetLast(extPkt *buffer.ExtPacket) UpdateOffsets(extPkt *buffer.ExtPacket) - UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, []byte, error) + // UpdateAndGet returns the incoming codec header size and the munged + // header by value, so the per-packet path does not allocate. + UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, [MaxHeaderSize]byte, int, error) UpdateAndGetPadding(newPicture bool) ([]byte, error) } diff --git a/pkg/sfu/codecmunger/ddtemporal.go b/pkg/sfu/codecmunger/ddtemporal.go new file mode 100644 index 000000000..7fcbd116e --- /dev/null +++ b/pkg/sfu/codecmunger/ddtemporal.go @@ -0,0 +1,138 @@ +// 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 codecmunger + +import ( + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/mediatransportutil/pkg/codec" + "github.com/livekit/protocol/codecs/mime" + "github.com/livekit/protocol/logger" +) + +// frames with a remembered decision, behind the highest frame number +const ddTemporalFrameWindow = 64 + +// DDTemporal filters the temporal layers of a simulcast stream with the dependency +// descriptor, for any codec: it leaves the payload unchanged. Every packet names its frame +// and temporal layer, so the munger decides once per frame and applies that decision to all +// packets of the frame, a late one included. For VP8, libwebrtc sends a one byte payload +// descriptor with the dependency descriptor, so there is no picture ID to munge; the +// forwarder uses the VP8 munger for a stream that keeps picture IDs. +type DDTemporal struct { + logger logger.Logger + isVP8 bool // padding needs a VP8 payload descriptor + + initialized bool + highestFrame uint64 + decidedMask uint64 // bit i: frame highestFrame - i has a decision + forwardedMask uint64 // bit i: frame highestFrame - i is forwarded +} + +func NewDDTemporal(mimeType mime.MimeType, logger logger.Logger) *DDTemporal { + return &DDTemporal{ + logger: logger, + isVP8: mimeType == mime.MimeTypeVP8, + } +} + +func (v *DDTemporal) GetState() any { + return nil +} + +func (v *DDTemporal) SeedState(_state any) { +} + +func (v *DDTemporal) SetLast(_extPkt *buffer.ExtPacket) { + v.initialized = false +} + +func (v *DDTemporal) UpdateOffsets(_extPkt *buffer.ExtPacket) { + // a layer switch starts a stream with its own frame numbers + v.initialized = false +} + +func (v *DDTemporal) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, _snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { + var hdr [MaxHeaderSize]byte + ddwdt := extPkt.DependencyDescriptor + if ddwdt == nil { + return 0, hdr, 0, nil + } + + frame := ddwdt.ExtFrameNum + isForwarded, ok := v.decision(frame) + if !ok { + isForwarded = extPkt.Temporal <= maxTemporalLayer + if !v.record(frame, isForwarded) { + // a late packet of a frame that is too old to have a decision + isForwarded = false + } + } + + switch { + case isForwarded: + return 0, hdr, 0, nil + case snOutOfOrder: + return 0, hdr, 0, ErrFilteredOutOfOrder + default: + return 0, hdr, 0, ErrFilteredTemporalLayer + } +} + +func (v *DDTemporal) UpdateAndGetPadding(_newPicture bool) ([]byte, error) { + if !v.isVP8 { + return nil, nil + } + + // the one byte VP8 payload descriptor of a key frame that starts a partition + vp8Packet := &codec.VP8{ + FirstByte: 0x10, + IsKeyFrame: true, + HeaderSize: 1, + } + return vp8Packet.Marshal() +} + +func (v *DDTemporal) decision(frame uint64) (isForwarded bool, ok bool) { + if !v.initialized || frame > v.highestFrame || v.highestFrame-frame >= ddTemporalFrameWindow { + return false, false + } + bit := uint64(1) << (v.highestFrame - frame) + return v.forwardedMask&bit != 0, v.decidedMask&bit != 0 +} + +func (v *DDTemporal) record(frame uint64, isForwarded bool) bool { + switch { + case !v.initialized || frame >= v.highestFrame+ddTemporalFrameWindow: + v.initialized = true + v.highestFrame = frame + v.decidedMask = 0 + v.forwardedMask = 0 + + case frame > v.highestFrame: + v.decidedMask <<= frame - v.highestFrame + v.forwardedMask <<= frame - v.highestFrame + v.highestFrame = frame + + case v.highestFrame-frame >= ddTemporalFrameWindow: + return false + } + + bit := uint64(1) << (v.highestFrame - frame) + v.decidedMask |= bit + if isForwarded { + v.forwardedMask |= bit + } + return true +} diff --git a/pkg/sfu/codecmunger/ddtemporal_test.go b/pkg/sfu/codecmunger/ddtemporal_test.go new file mode 100644 index 000000000..46973017f --- /dev/null +++ b/pkg/sfu/codecmunger/ddtemporal_test.go @@ -0,0 +1,108 @@ +// 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 codecmunger + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/protocol/codecs/mime" + "github.com/livekit/protocol/logger" +) + +func vp8DDPacket(t *testing.T, frameNum uint64, tid int) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: uint16(frameNum), PayloadSize: 20}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt +} + +func TestDDTemporalUpdateAndGet(t *testing.T) { + t.Run("filters by temporal layer, header unchanged", func(t *testing.T) { + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) + inputSize, _, n, err := v.UpdateAndGet(vp8DDPacket(t, 0, 0), false, false, 0) + require.NoError(t, err) + require.Zero(t, inputSize) + require.Zero(t, n) + + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) + }) + + t.Run("a gap does not exempt a filtered layer", func(t *testing.T) { + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 0, 0), false, false, 0) + require.NoError(t, err) + for frameNum := uint64(2); frameNum < 6; frameNum++ { + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, frameNum, 1), false, true, 0) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) + } + }) + + t.Run("every packet of a frame gets the decision of its first packet", func(t *testing.T) { + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 2, 1), false, false, 1) + require.NoError(t, err) + + // the layer changed in between, late packets keep the decision of their frame + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), true, false, 2) + require.ErrorIs(t, err, ErrFilteredOutOfOrder) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 2, 1), true, false, 0) + require.NoError(t, err) + }) + + t.Run("late packet of a frame older than the window", func(t *testing.T) { + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 100, 0), false, false, 2) + require.NoError(t, err) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 100-ddTemporalFrameWindow, 0), true, false, 2) + require.ErrorIs(t, err, ErrFilteredOutOfOrder) + }) + + t.Run("layer switch forgets decisions", func(t *testing.T) { + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) + v.UpdateOffsets(vp8DDPacket(t, 1, 0)) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 2) + require.NoError(t, err) + }) + + t.Run("padding", func(t *testing.T) { + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) + hdr, err := v.UpdateAndGetPadding(true) + require.NoError(t, err) + require.Equal(t, []byte{0x10}, hdr) + + // other codecs have no payload descriptor to prepend + for _, mimeType := range []mime.MimeType{mime.MimeTypeVP9, mime.MimeTypeAV1} { + hdr, err = NewDDTemporal(mimeType, logger.GetLogger()).UpdateAndGetPadding(true) + require.NoError(t, err) + require.Nil(t, hdr) + } + }) +} diff --git a/pkg/sfu/codecmunger/null.go b/pkg/sfu/codecmunger/null.go index 616786a69..41f5cb405 100644 --- a/pkg/sfu/codecmunger/null.go +++ b/pkg/sfu/codecmunger/null.go @@ -45,8 +45,8 @@ func (n *Null) SetLast(_extPkt *buffer.ExtPacket) { func (n *Null) UpdateOffsets(_extPkt *buffer.ExtPacket) { } -func (n *Null) UpdateAndGet(_extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, []byte, error) { - return 0, nil, nil +func (n *Null) UpdateAndGet(_extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, [MaxHeaderSize]byte, int, error) { + return 0, [MaxHeaderSize]byte{}, 0, nil } func (n *Null) UpdateAndGetPadding(newPicture bool) ([]byte, error) { diff --git a/pkg/sfu/codecmunger/vp8.go b/pkg/sfu/codecmunger/vp8.go index f6f45e1e6..dc1d78b00 100644 --- a/pkg/sfu/codecmunger/vp8.go +++ b/pkg/sfu/codecmunger/vp8.go @@ -104,8 +104,8 @@ func (v *VP8) SeedState(seed any) { } func (v *VP8) SetLast(extPkt *buffer.ExtPacket) { - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return } @@ -129,8 +129,8 @@ func (v *VP8) SetLast(extPkt *buffer.ExtPacket) { } func (v *VP8) UpdateOffsets(extPkt *buffer.ExtPacket) { - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return } @@ -153,10 +153,11 @@ func (v *VP8) UpdateOffsets(extPkt *buffer.ExtPacket) { v.exemptedPictureIds = orderedmap.NewOrderedMap[int32, bool]() } -func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporalLayer int32) (int, []byte, error) { - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { - return 0, nil, ErrNotVP8 +func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { + var hdr [MaxHeaderSize]byte + vp8 := extPkt.VP8 + if vp8 == nil { + return 0, hdr, 0, ErrNotVP8 } extPictureId := v.pictureIdWrapHandler.Unwrap(vp8.PictureID, vp8.M) @@ -165,7 +166,7 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap if snOutOfOrder { pictureIdOffset, ok := v.missingPictureIds.Get(extPictureId) if !ok { - return 0, nil, ErrOutOfOrderVP8PictureIdCacheMiss + return 0, hdr, 0, ErrOutOfOrderVP8PictureIdCacheMiss } // the out-of-order picture id cannot be deleted from the cache @@ -190,11 +191,11 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap IsKeyFrame: vp8.IsKeyFrame, HeaderSize: vp8.HeaderSize + codec.VPxPictureIdSizeDiff(mungedPictureId > 127, vp8.M), } - vp8HeaderBytes, err := vp8Packet.Marshal() + n, err := vp8Packet.MarshalTo(hdr[:]) if err != nil { - return 0, nil, err + return 0, hdr, 0, err } - return vp8.HeaderSize, vp8HeaderBytes, nil + return vp8.HeaderSize, hdr, n, nil } prevMaxPictureId := v.pictureIdWrapHandler.MaxPictureId() @@ -262,7 +263,7 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap v.pictureIdOffset += 1 } - return 0, nil, ErrFilteredVP8TemporalLayer + return 0, hdr, 0, ErrFilteredTemporalLayer } } } @@ -297,11 +298,11 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap IsKeyFrame: vp8.IsKeyFrame, HeaderSize: vp8.HeaderSize + codec.VPxPictureIdSizeDiff(mungedPictureId > 127, vp8.M), } - vp8HeaderBytes, err := vp8Packet.Marshal() + n, err := vp8Packet.MarshalTo(hdr[:]) if err != nil { - return 0, nil, err + return 0, hdr, 0, err } - return vp8.HeaderSize, vp8HeaderBytes, nil + return vp8.HeaderSize, hdr, n, nil } func (v *VP8) UpdateAndGetPadding(newPicture bool) ([]byte, error) { diff --git a/pkg/sfu/codecmunger/vp8_test.go b/pkg/sfu/codecmunger/vp8_test.go index 2c5714bed..79bb6ef8e 100644 --- a/pkg/sfu/codecmunger/vp8_test.go +++ b/pkg/sfu/codecmunger/vp8_test.go @@ -195,11 +195,11 @@ func TestOutOfOrderPictureId(t *testing.T) { vp8.PictureID = 13466 extPkt, _ = testutils.GetTestExtPacketVP8(params, vp8) - nIn, buf, err := v.UpdateAndGet(extPkt, true, false, 2) + nIn, hdr, nOut, err := v.UpdateAndGet(extPkt, true, false, 2) require.Error(t, err) require.ErrorIs(t, err, ErrOutOfOrderVP8PictureIdCacheMiss) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) // create a hole in picture id vp8.PictureID = 13469 @@ -222,10 +222,10 @@ func TestOutOfOrderPictureId(t *testing.T) { } marshalledVP8, err := expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err = v.UpdateAndGet(extPkt, false, true, 2) + nIn, hdr, nOut, err = v.UpdateAndGet(extPkt, false, true, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) // all three, the last, the current and the in-between should have been added to missing picture id cache value, ok := v.PictureIdOffset(13467) @@ -261,10 +261,10 @@ func TestOutOfOrderPictureId(t *testing.T) { } marshalledVP8, err = expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err = v.UpdateAndGet(extPkt, true, false, 2) + nIn, hdr, nOut, err = v.UpdateAndGet(extPkt, true, false, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) } func TestTemporalLayerFiltering(t *testing.T) { @@ -294,11 +294,11 @@ func TestTemporalLayerFiltering(t *testing.T) { v.SetLast(extPkt) // translate - nIn, buf, err := v.UpdateAndGet(extPkt, false, false, 0) + nIn, _, nOut, err := v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) dropped, _ := v.droppedPictureIds.Get(13467) require.True(t, dropped) require.EqualValues(t, 1, v.pictureIdOffset) @@ -308,11 +308,11 @@ func TestTemporalLayerFiltering(t *testing.T) { params.SequenceNumber = 23334 extPkt, _ = testutils.GetTestExtPacketVP8(params, vp8) - nIn, buf, err = v.UpdateAndGet(extPkt, false, false, 0) + nIn, _, nOut, err = v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) dropped, _ = v.droppedPictureIds.Get(13467) require.True(t, dropped) require.EqualValues(t, 1, v.pictureIdOffset) @@ -322,11 +322,11 @@ func TestTemporalLayerFiltering(t *testing.T) { params.SequenceNumber = 23337 extPkt, _ = testutils.GetTestExtPacketVP8(params, vp8) - nIn, buf, err = v.UpdateAndGet(extPkt, false, false, 0) + nIn, _, nOut, err = v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) dropped, _ = v.droppedPictureIds.Get(13467) require.True(t, dropped) require.EqualValues(t, 1, v.pictureIdOffset) @@ -376,10 +376,10 @@ func TestGapInSequenceNumberSamePicture(t *testing.T) { } marshalledVP8, err := expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err := v.UpdateAndGet(extPkt, false, false, 2) + nIn, hdr, nOut, err := v.UpdateAndGet(extPkt, false, false, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) // telling there is a gap in sequence number will add pictures to missing picture cache expectedVP8 = &codec.VP8{ @@ -399,10 +399,10 @@ func TestGapInSequenceNumberSamePicture(t *testing.T) { } marshalledVP8, err = expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err = v.UpdateAndGet(extPkt, false, true, 2) + nIn, hdr, nOut, err = v.UpdateAndGet(extPkt, false, true, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) value, ok := v.PictureIdOffset(13467) require.True(t, ok) diff --git a/pkg/sfu/connectionquality/connectionstats.go b/pkg/sfu/connectionquality/connectionstats.go index f0c71bdbf..9006d7646 100644 --- a/pkg/sfu/connectionquality/connectionstats.go +++ b/pkg/sfu/connectionquality/connectionstats.go @@ -299,6 +299,9 @@ func (cs *ConnectionStats) updateScoreAt(at time.Time) (float32, map[uint32]*buf mos, _ := cs.scorer.GetMOSAndQuality() return mos, streams, false } + // upstream PacketsLost is already net of late arrivals (including RTX repairs), + // so the scorer must not discount out-of-order packets again + agg.PacketsOutOfOrder = 0 return cs.updateScoreWithAggregate(agg, cs.params.ReceiverProvider.GetLastSenderReportTime(), at), streams, false } diff --git a/pkg/sfu/connectionquality/connectionstats_test.go b/pkg/sfu/connectionquality/connectionstats_test.go index 13b61a645..bcb8be6e4 100644 --- a/pkg/sfu/connectionquality/connectionstats_test.go +++ b/pkg/sfu/connectionquality/connectionstats_test.go @@ -901,3 +901,109 @@ func TestConnectionQuality(t *testing.T) { } }) } + +func TestPacketRateMode(t *testing.T) { + type phase struct { + minutes float64 + pps float64 + } + for name, tc := range map[string]struct { + weight float64 + histogram map[int]int // bin -> count + phases []phase + last *windowStat + mode int // pps + quality livekit.ConnectionQuality + }{ + // 120 windows on the 250 pps layer, 600 windows on the 40 pps layer + "most common bin": { + weight: 10.0, + histogram: map[int]int{125: 120, 20: 600}, + last: &windowStat{packets: 200, packetsLost: 8, duration: 5 * time.Second}, + mode: 40, + quality: livekit.ConnectionQuality_GOOD, + }, + // 4% loss on the layer now watched is not EXCELLENT + "follows a layer switch": { + weight: 10.0, + phases: []phase{{10, 250}, {5, 40}}, + last: &windowStat{packets: 200, packetsLost: 8, duration: 5 * time.Second}, + mode: 40, + quality: livekit.ConnectionQuality_GOOD, + }, + // idle and DTX rates do not become the reference, so a lost DTX packet counts less + "keeps the speech rate through a long silence": { + weight: 8.0, + phases: []phase{{5, 50}, {10, 0}, {30, 2.5}}, + last: &windowStat{packets: 12, packetsLost: 1, duration: 5 * time.Second}, + mode: 50, + quality: livekit.ConnectionQuality_EXCELLENT, + }, + } { + t.Run(name, func(t *testing.T) { + q := newQualityScorer(qualityScorerParams{Logger: logger.GetLogger()}) + // a timeline in the past, the mode must follow update times, not the wall clock + at := time.Now().Add(-time.Hour) + q.StartAt(tc.weight, at) + for bin, count := range tc.histogram { + q.ppsHistogram[bin] = count + q.numPPSReadings += count + } + for _, p := range tc.phases { + for range int(p.minutes * 12) { + at = at.Add(5 * time.Second) + q.UpdateAt(&windowStat{packets: uint32(p.pps * 5), duration: 5 * time.Second}, at) + } + } + at = at.Add(tc.last.duration) + q.UpdateAt(tc.last, at) + + require.Equal(t, tc.mode, int(float64(q.ppsMode)*cPPSQuantization)) + _, quality := q.GetScoreAndQuality() + require.Equal(t, tc.quality, quality) + }) + } +} + +func TestUpstreamLossAfterRTXRepair(t *testing.T) { + // 1000 video packets, 100 lost, RTX repairs 70 in the same window, 30 (3%) never arrive + r := rtpstats.NewRTPStatsReceiver(rtpstats.RTPStatsParams{}) + r.SetClockRate(90000) + snapshotID := r.NewSnapshotId() + + packetTime := time.Now().UnixNano() + send := func(sn uint16) { + packetTime += int64(5 * time.Millisecond) + r.Update(packetTime, sn, uint32(sn)*450, false, 12, 1000, 0) + } + for sn := uint16(0); sn < 1000; sn++ { + if sn == 300 { + for repaired := uint16(100); repaired < 170; repaired++ { + send(repaired) + } + } + if sn < 100 || sn >= 200 { + send(sn) + } + } + + delta := r.DeltaInfo(snapshotID) + require.Equal(t, uint32(1000), delta.Packets) + require.Equal(t, uint32(30), delta.PacketsLost) + require.Equal(t, uint32(70), delta.PacketsOutOfOrder) + + trp := newTestReceiverProvider() + trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{1: {RTPStats: delta}}) + cs := NewConnectionStats(ConnectionStatsParams{ + ReceiverProvider: trp, + Logger: logger.GetLogger(), + }) + now := time.Now() + cs.StartAt(mime.MimeTypeVP8, false, now) + cs.updateScoreAt(now.Add(5 * time.Second)) + + // packet score 100 - 3% * 10 = 70, smoothed 0.8 * 70 + 0.2 * 100 = 76 + score, quality := cs.scorer.GetScoreAndQuality() + require.InDelta(t, 76.0, score, 0.01) + require.Equal(t, livekit.ConnectionQuality_GOOD, quality) +} diff --git a/pkg/sfu/connectionquality/scorer.go b/pkg/sfu/connectionquality/scorer.go index 471b291b6..57aa3e2e9 100644 --- a/pkg/sfu/connectionquality/scorer.go +++ b/pkg/sfu/connectionquality/scorer.go @@ -42,6 +42,7 @@ const ( cPPSQuantization = float64(2) cPPSMinReadings = 10 + cPPSModeMinBin = 5 // 10 pps cModeCalculationInterval = 2 * time.Minute ) @@ -235,13 +236,13 @@ func newQualityScorer(params qualityScorerParams) *qualityScorer { layerDistance: utils.NewTimedAggregator[float64](utils.TimedAggregatorParams{ CapNegativeValues: true, }), - modeCalculatedAt: time.Now().Add(-cModeCalculationInterval), } } func (q *qualityScorer) startAtLocked(packetLossWeight float64, at time.Time) { q.packetLossWeight = packetLossWeight q.lastUpdateAt = at + q.modeCalculatedAt = at.Add(-cModeCalculationInterval) } func (q *qualityScorer) StartAt(packetLossWeight float64, at time.Time) { @@ -410,7 +411,7 @@ func (q *qualityScorer) updateAtLocked(stat *windowStat, at time.Time) { return } - aplw := q.getAdjustedPacketLossWeight(stat) + aplw := q.getAdjustedPacketLossWeight(stat, at) reason := "none" var score, packetScore, bitrateScore, layerScore float64 if stat.packets+stat.packetsPadding == 0 { @@ -544,7 +545,7 @@ func (q *qualityScorer) isPaused() bool { return !q.pausedAt.IsZero() && (q.resumedAt.IsZero() || q.pausedAt.After(q.resumedAt)) } -func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat) float64 { +func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat, at time.Time) float64 { if stat == nil || stat.duration <= 0 { return q.packetLossWeight } @@ -566,14 +567,24 @@ func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat) float64 { // calculate mode sparingly, do it under the following conditions // 1. minimum number of readings available (AND) // 2. enough time has elapsed since last calculation - if q.numPPSReadings > cPPSMinReadings && time.Since(q.modeCalculatedAt) > cModeCalculationInterval { - q.ppsMode = 0 - for i := range len(q.ppsHistogram) { - if q.ppsHistogram[i] > q.ppsMode { - q.ppsMode = i + if q.numPPSReadings > cPPSMinReadings && at.Sub(q.modeCalculatedAt) > cModeCalculationInterval { + // the most common rate, ignoring rates as low as DTX or static content so they do not become the reference, + // keep the previous mode through a long stretch of only low rates, for example a long silence + mode, maxCount := 0, 0 + for i := cPPSModeMinBin; i < len(q.ppsHistogram); i++ { + if q.ppsHistogram[i] > maxCount { + mode, maxCount = i, q.ppsHistogram[i] } } - q.modeCalculatedAt = time.Now() + if maxCount > 0 { + q.ppsMode = mode + } + + // halve the counts so that the mode follows a lasting change of rate, for example a layer switch + for i := range q.ppsHistogram { + q.ppsHistogram[i] >>= 1 + } + q.modeCalculatedAt = at q.params.Logger.Debugw("updating pps mode", "expected", stat.packets, "duration", stat.duration.Seconds(), "pps", pps, "ppsMode", q.ppsMode) } diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 1b993158c..29e35747b 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -85,6 +85,9 @@ var ( errPayloadOverflow = errors.New("payload overflow") ) +// padding of a dropped packet's slot, one byte: the count byte itself +const droppedSlotPaddingSize = 1 + var ( VP8KeyFrame8x8 = []byte{ 0x10, 0x02, 0x00, 0x9d, 0x01, 0x2a, 0x08, 0x00, @@ -338,6 +341,7 @@ type DownTrack struct { keyFrameRequesterChMu sync.RWMutex keyFrameRequesterCh chan struct{} keyFrameRequesterChClosed bool + keyFrameRequesterWakeups atomic.Uint32 retransmitChMu sync.RWMutex retransmitCh chan struct{} @@ -953,18 +957,20 @@ func (d *DownTrack) keyFrameRequester() { defer timer.Stop() for !d.IsClosed() { - timer.Reset(getInterval()) - select { case _, more := <-d.keyFrameRequesterCh: if !more { return } if !timer.Stop() { - <-timer.C + select { + case <-timer.C: + default: + } } case <-timer.C: } + d.keyFrameRequesterWakeups.Inc() locked, layer := d.forwarder.CheckSync() if !locked && layer != buffer.InvalidLayerSpatial && d.writable.Load() { @@ -981,6 +987,13 @@ func (d *DownTrack) keyFrameRequester() { sal.OnAvailableLayersChanged(d) } } + + // Re-arm only while there is something to wait for: a requested layer that is not locked yet, + // or a lock state that can change without an event. Once locked, sleep until the + // next event so a locked track does not wake up every interval. + if (!locked && layer != buffer.InvalidLayerSpatial) || d.forwarder.NeedsPeriodicSyncCheck() { + timer.Reset(getInterval()) + } } } @@ -1032,6 +1045,9 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { if err != nil { d.params.Logger.Errorw("could not get translation params", err) } + if tp.padDroppedSlot { + d.writeDroppedSlotPadding(tp.rtp.extSequenceNumber, tp.rtp.extTimestamp, tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder) + } return 0 } @@ -1044,10 +1060,11 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { return 0 } + codecBytes := tp.codecHeader() poolEntity := PacketFactory.Get().(*[]byte) payload := *poolEntity - copy(payload, tp.codecBytes) - n := copy(payload[len(tp.codecBytes):], extPkt.Packet.Payload[tp.incomingHeaderSize:]) + copy(payload, codecBytes) + n := copy(payload[len(codecBytes):], extPkt.Packet.Payload[tp.incomingHeaderSize:]) if n != len(extPkt.Packet.Payload[tp.incomingHeaderSize:]) { d.params.Logger.Errorw( "payload overflow", errPayloadOverflow, @@ -1057,7 +1074,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { PacketFactory.Put(poolEntity) return 0 } - payload = payload[:len(tp.codecBytes)+n] + payload = payload[:len(codecBytes)+n] trailerStripped := 0 if d.params.StripPacketTrailer { @@ -1068,7 +1085,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { // translate RTP header hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: extPkt.Packet.Version, Padding: false, Marker: tp.marker, @@ -1076,11 +1093,30 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { SequenceNumber: uint16(tp.rtp.extSequenceNumber), Timestamp: uint32(tp.rtp.extTimestamp), SSRC: d.ssrc, + }) + + pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) + *pacerPacket = pacer.Packet{ + Header: hdr, + HeaderPool: RTPHeaderFactory, + Payload: payload, + ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), + AbsSendTimeExtID: uint8(d.absSendTimeExtID), + TransportWideExtID: uint8(d.transportWideExtID), + WriteStream: d.writeStream, + Pool: PacketFactory, + PoolEntity: poolEntity, } // add extensions - if d.dependencyDescriptorExtID != 0 && tp.ddBytes != nil { - hdr.SetExtension(uint8(d.dependencyDescriptorExtID), tp.ddBytes) + // + // the header refers to the descriptor until the pacer marshals it, so hold it in the pacer packet, which one send owns + ddBytes := tp.ddBytesSpill + if inline := tp.ddInline(); ddBytes == nil && len(inline) != 0 { + ddBytes = pacerPacket.HoldExtension(inline) + } + if d.dependencyDescriptorExtID != 0 && len(ddBytes) != 0 { + hdr.SetExtension(uint8(d.dependencyDescriptorExtID), ddBytes) } if d.playoutDelayExtID != 0 && d.playoutDelay != nil { if val := d.playoutDelay.GetDelayExtension(hdr.SequenceNumber); val != nil { @@ -1097,30 +1133,37 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { } var actBytes []byte if extPkt.AbsCaptureTimeExt != nil && d.absCaptureTimeExtID != 0 { - // normalize capture time to SFU clock. - // NOTE: even if there is estimated offset populated, just re-map the - // absolute capture time stamp as it should be the same RTCP sender report - // clock domain of publisher. SFU is normalising sender reports of publisher - // to SFU clock before sending to subscribers. So, capture time should be - // normalized to the same clock. Clear out any offset. - _, _, _, refSenderReport := d.forwarder.GetSenderReportParams() - if refSenderReport != nil { - actExtCopy := *extPkt.AbsCaptureTimeExt - if err = actExtCopy.Rewrite( - rtpstats.RTCPSenderReportPropagationDelay( - refSenderReport, - !d.params.DisableSenderReportPassThrough, - ), - ); err == nil { - actBytes, err = actExtCopy.Marshal() - if err == nil { - hdr.SetExtension(uint8(d.absCaptureTimeExtID), actBytes) + if !d.params.DisableSenderReportPassThrough { + // pass through the original publisher capture time verbatim, consistent + // with sender reports also being passed through unchanged. + actBytes, err = extPkt.AbsCaptureTimeExt.Marshal() + if err == nil { + hdr.SetExtension(uint8(d.absCaptureTimeExtID), actBytes) + } + } else { + // normalize capture time to SFU clock. + // NOTE: even if there is estimated offset populated, just re-map the + // absolute capture time stamp as it should be the same RTCP sender report + // clock domain of publisher. SFU is normalising sender reports of publisher + // to SFU clock before sending to subscribers. So, capture time should be + // normalized to the same clock. Clear out any offset. + _, _, _, refSenderReport := d.forwarder.GetSenderReportParams() + if refSenderReport != nil { + actExtCopy := *extPkt.AbsCaptureTimeExt + if err = actExtCopy.Rewrite( + rtpstats.RTCPSenderReportPropagationDelay(refSenderReport), + ); err == nil { + actBytes, err = actExtCopy.Marshal() + if err == nil { + hdr.SetExtension(uint8(d.absCaptureTimeExtID), actBytes) + } } } } } d.addDummyExtensions(hdr) + // the sequencer copies the descriptor, which has to happen before Enqueue frees the scratch if d.sequencer != nil { d.sequencer.push( extPkt.Arrival, @@ -1129,15 +1172,16 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { tp.rtp.extTimestamp, hdr.Marker, int8(layer), - payload[:len(tp.codecBytes)], + payload[:len(codecBytes)], tp.incomingHeaderSize, - tp.ddBytes, + ddBytes, actBytes, trailerStripped, ) } headerSize := hdr.MarshalSize() + pacerPacket.HeaderSize = headerSize d.rtpStats.Update( extPkt.Arrival, tp.rtp.extSequenceNumber, @@ -1148,19 +1192,6 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { 0, extPkt.IsOutOfOrder, ) - pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) - *pacerPacket = pacer.Packet{ - Header: hdr, - HeaderPool: RTPHeaderFactory, - HeaderSize: headerSize, - Payload: payload, - ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), - AbsSendTimeExtID: uint8(d.absSendTimeExtID), - TransportWideExtID: uint8(d.transportWideExtID), - WriteStream: d.writeStream, - Pool: PacketFactory, - PoolEntity: poolEntity, - } d.pacer.Enqueue(pacerPacket) if extPkt.IsKeyFrame { @@ -1176,6 +1207,8 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { if tp.isSwitching { d.postMaxLayerNotifierEvent("switching") + // the current layer moved on the packet path, so the layer lock may have changed + d.postKeyFrameRequestEvent() } if tp.isResuming { @@ -1211,6 +1244,49 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { return 1 } +// writeDroppedSlotPadding sends a padding only packet in the outgoing slot of a dropped +// packet that is out of order or follows a gap. The receiver takes it as padding for that sequence number and +// does not NACK it. The sequencer has no entry for the slot, so a NACK that still comes +// finds nothing to retransmit. +func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimestamp uint64, isOutOfOrder bool) { + hdr := RTPHeaderFactory.Get().(*rtp.Header) + initPooledRTPHeader(hdr, rtp.Header{ + Version: 2, + Padding: true, + PaddingSize: droppedSlotPaddingSize, + Marker: false, + PayloadType: uint8(d.payloadType.Load()), + SequenceNumber: uint16(extSequenceNumber), + Timestamp: uint32(extTimestamp), + SSRC: d.ssrc, + }) + d.addDummyExtensions(hdr) + + hdrSize := hdr.MarshalSize() + d.rtpStats.Update( + mono.UnixNano(), + extSequenceNumber, + extTimestamp, + hdr.Marker, + hdrSize, + 0, + droppedSlotPaddingSize, + isOutOfOrder, + ) + + pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) + *pacerPacket = pacer.Packet{ + Header: hdr, + HeaderPool: RTPHeaderFactory, + HeaderSize: hdrSize, + ProbeClusterId: ccutils.ProbeClusterIdInvalid, // not probe traffic + AbsSendTimeExtID: uint8(d.absSendTimeExtID), + TransportWideExtID: uint8(d.transportWideExtID), + WriteStream: d.writeStream, + } + d.pacer.Enqueue(pacerPacket) +} + // WritePaddingRTP tries to write as many padding only RTP packets as necessary // to satisfy given size to the DownTrack func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMarker bool) int { @@ -1277,10 +1353,9 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa } bytesSent := 0 - payloads := make([]byte, RTPPaddingMaxPayloadSize*len(snts)) for i := range snts { hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: 2, Padding: true, PaddingSize: byte(RTPPaddingMaxPayloadSize), @@ -1289,15 +1364,10 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa SequenceNumber: uint16(snts[i].extSequenceNumber), Timestamp: uint32(snts[i].extTimestamp), SSRC: d.ssrc, - } + }) d.addDummyExtensions(hdr) - payload := payloads[i*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize] - // last byte of padding has padding size including that byte - payload[RTPPaddingMaxPayloadSize-1] = byte(RTPPaddingMaxPayloadSize) - hdrSize := hdr.MarshalSize() - payloadSize := len(payload) d.rtpStats.Update( mono.UnixNano(), snts[i].extSequenceNumber, @@ -1305,7 +1375,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa hdr.Marker, hdrSize, 0, - payloadSize, + RTPPaddingMaxPayloadSize, false, ) @@ -1314,7 +1384,6 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa Header: hdr, HeaderPool: RTPHeaderFactory, HeaderSize: hdrSize, - Payload: payload, ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), IsProbe: true, AbsSendTimeExtID: uint8(d.absSendTimeExtID), @@ -1323,7 +1392,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa } d.pacer.Enqueue(pacerPacket) - bytesSent += hdrSize + payloadSize + bytesSent += hdrSize + RTPPaddingMaxPayloadSize } return bytesSent @@ -1378,6 +1447,8 @@ func (d *DownTrack) handleMute(muted bool, changed bool) { // and that could turn on/off layers on publisher side. // d.postMaxLayerNotifierEvent("mute") + // mute resyncs the forwarder, so the layer lock has to be re-acquired on unmute + d.postKeyFrameRequestEvent() if sal := d.getStreamAllocatorListener(); sal != nil { sal.OnSubscriptionChanged(d) @@ -1433,15 +1504,24 @@ func (d *DownTrack) CloseWithFlush(flush bool, isEnding bool) { if flush { doneFlushing := d.writeBlankFrameRTP(RTPBlankFramesCloseSeconds, d.blankFramesGeneration.Inc()) + // The flush runs in its own goroutine (writeBlankFrameRTP) and is + // cancelled via blankFramesGeneration, so bindLock guards nothing + // during the wait. Release it: bindLock is a control-plane lock + // (Bind/SetConnected/ReceiverRestart), and holding it across the + // up-to-flushTimeout wait serializes all of those behind every + // close. isClosed is already set, so no other close can enter. + d.bindLock.Unlock() + // wait a limited time to flush timer := time.NewTimer(flushTimeout) - defer timer.Stop() - select { case <-doneFlushing: case <-timer.C: d.blankFramesGeneration.Inc() // in case flush is still running } + timer.Stop() + + d.bindLock.Lock() } d.params.Logger.Debugw("closing sender", "kind", d.kind) @@ -1585,6 +1665,8 @@ func (d *DownTrack) UpTrackBitrateAvailabilityChange() { func (d *DownTrack) UpTrackMaxPublishedLayerChange(maxPublishedLayer int32) { if d.forwarder.SetMaxPublishedLayer(maxPublishedLayer) { + // may arm the acquisition grace, which the key frame requester has to expire + d.postKeyFrameRequestEvent() if sal := d.getStreamAllocatorListener(); sal != nil { sal.OnMaxPublishedSpatialChanged(d) } @@ -1730,6 +1812,7 @@ func (d *DownTrack) Pause() VideoAllocation { func (d *DownTrack) Resync() { d.forwarder.Resync() d.flushSequencer() + d.postKeyFrameRequestEvent() } // flushSequencer discards recorded packet metadata on a stream restart so that NACK @@ -1748,6 +1831,10 @@ func (d *DownTrack) ReceiverRestart(rcvr TrackReceiver) { } d.bindLock.Lock() + if d.isClosed.Load() { + d.bindLock.Unlock() + return + } codec := d.codec.Load().(webrtc.RTPCodecCapability) d.bindLock.Unlock() @@ -2160,6 +2247,10 @@ func (d *DownTrack) handleRTCPRTX(bytes []byte) { func (d *DownTrack) SetConnected() { d.bindLock.Lock() + if d.isClosed.Load() { + d.bindLock.Unlock() + return + } if !d.connected.Swap(true) { d.onBindAndConnectedChange() } @@ -2193,7 +2284,7 @@ func (d *DownTrack) retransmitPacket(epm *extPacketMeta, sourcePkt []byte, isPro return 0, errPayloadOverflow } hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: pkt.Header.Version, Padding: false, Marker: epm.marker, @@ -2201,7 +2292,7 @@ func (d *DownTrack) retransmitPacket(epm *extPacketMeta, sourcePkt []byte, isPro SequenceNumber: epm.targetSeqNo, Timestamp: epm.timestamp, SSRC: d.ssrc, - } + }) rtxOffset := 0 var rtxExtSequenceNumber uint64 if rtxPT := d.payloadTypeRTX.Load(); rtxPT != 0 && d.ssrcRTX != 0 { @@ -2429,11 +2520,10 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { return 0 } - payloads := make([]byte, RTPPaddingMaxPayloadSize*num) - for i := range num { + for range num { rtxExtSequenceNumber := d.rtxSequenceNumber.Inc() hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: 2, Padding: true, PaddingSize: byte(RTPPaddingMaxPayloadSize), @@ -2442,15 +2532,10 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { SequenceNumber: uint16(rtxExtSequenceNumber), Timestamp: 0, SSRC: d.ssrcRTX, - } + }) d.addDummyExtensions(hdr) - payload := payloads[i*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize] - // last byte of padding has padding size including that byte - payload[RTPPaddingMaxPayloadSize-1] = byte(RTPPaddingMaxPayloadSize) - hdrSize := hdr.MarshalSize() - payloadSize := len(payload) d.rtpStatsRTX.Update( mono.UnixNano(), rtxExtSequenceNumber, @@ -2458,7 +2543,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { hdr.Marker, hdrSize, 0, - payloadSize, + RTPPaddingMaxPayloadSize, false, ) pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) @@ -2466,7 +2551,6 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { Header: hdr, HeaderPool: RTPHeaderFactory, HeaderSize: hdrSize, - Payload: payload, ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), IsProbe: true, AbsSendTimeExtID: uint8(d.absSendTimeExtID), @@ -2475,7 +2559,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { } d.pacer.Enqueue(pacerPacket) - bytesSent += hdrSize + payloadSize + bytesSent += hdrSize + RTPPaddingMaxPayloadSize } } else { src := PacketFactory.Get().(*[]byte) @@ -2506,6 +2590,12 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { break } } + + // recent packets may be gone from the publisher buffer, for example while + // paused, so probe with padding to keep the probe cluster progressing + if bytesSent == 0 { + return d.WriteProbePackets(bytesToSend, true) + } } return bytesSent diff --git a/pkg/sfu/downtrack_downstream_integration_test.go b/pkg/sfu/downtrack_downstream_integration_test.go index 1f946a531..07fd81880 100644 --- a/pkg/sfu/downtrack_downstream_integration_test.go +++ b/pkg/sfu/downtrack_downstream_integration_test.go @@ -32,6 +32,9 @@ package sfu_test // WritePaddingRTP - padding-only packets // WriteProbePackets - RTX probe packets // +// It also checks that a track paused by congestion resumes once probing finds +// the bandwidth again. +// // pion's rtp.Packet.Unmarshal rejects a packet whose padding bit is set but whose // trailing padding-size byte is invalid/zero (errInvalidRTPPadding), so a // malformed packet is dropped by the receiver and never arrives. Arrival on the @@ -41,7 +44,9 @@ import ( "bytes" "encoding/binary" "io" + "math" "sync" + "sync/atomic" "testing" "time" @@ -50,6 +55,7 @@ import ( "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" + "github.com/livekit/mediatransportutil/pkg/bucket" "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" @@ -176,7 +182,7 @@ type downTrackHarness struct { // newBoundDownTrack builds a real DownTrack, attaches it to a real sender PC, // negotiates with a real subscriber PC over vnet, and makes it writable. -func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, codecParams webrtc.RTPCodecParameters, p pacer.Pacer, meCfg vnettest.MediaEngineConfig) *downTrackHarness { +func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, codecParams webrtc.RTPCodecParameters, p pacer.Pacer, meCfg vnettest.MediaEngineConfig, rcvOpts ...func(*sfufakes.FakeTrackReceiver)) *downTrackHarness { t.Helper() sender := vnettest.NewPeerConnection(t, vnettest.PCConfig{Net: h.OfferNet, MediaEngine: meCfg, BufferFactory: factory.GetOrNew}) @@ -184,6 +190,9 @@ func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, capture := captureTrack(sub) rcv := newFakeTrackReceiver(codecParams) + for _, opt := range rcvOpts { + opt(rcv) + } dt, err := NewDownTrack(DownTrackParams{ Codecs: []webrtc.RTPCodecParameters{codecParams}, Receiver: rcv, @@ -584,10 +593,10 @@ func lktsTrailer() []byte { // the primary SSRC/payload type, carries the padding bit, and declares a full // padding block. // -// A padding-only packet is entirely padding; pion/srtp serializes the padding block -// from header.PaddingSize (via rtp.MarshalPacketTo), and the pion receiver strips it -// and reports both the padding bit and the padding size, so the received payload -// length equals the declared padding size (RTPPaddingMaxPayloadSize). +// A padding-only packet is entirely padding: pion/srtp serializes the padding block +// from header.PaddingSize (via rtp.MarshalPacketTo) after the payload, so the packet +// has no payload. A receiver takes a packet with any payload left after the padding +// as media. func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) { h := vnettest.NewHosts(t) factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory() @@ -665,12 +674,27 @@ func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) { // a padding-only packet: it must declare a full padding block. require.EqualValues(t, RTPPaddingMaxPayloadSize, pkt.Header.PaddingSize, "padding-only packet must declare full padding size (sn=%d)", pkt.SequenceNumber) - require.Equal(t, RTPPaddingMaxPayloadSize, len(pkt.Payload), - "padding-only packet must serialize a full padding block (sn=%d)", pkt.SequenceNumber) + require.Empty(t, pkt.Payload, "padding-only packet must have no payload (sn=%d)", pkt.SequenceNumber) sawPadding++ } require.Equal(t, 1, sawKeyframe, "expected exactly one forwarded media keyframe") require.GreaterOrEqual(t, sawPadding, numPadding, "expected the padding-only packets to be observed") + + // the padding in the slot of a dropped packet has no payload either + dh.dt.WriteDroppedSlotPaddingForTest(6000, 190000) + var pkt *rtp.Packet + require.Eventually(t, func() bool { + for _, p := range dh.capture.all() { + if p.SequenceNumber == 6000 { + pkt = p + return true + } + } + return false + }, 10*time.Second, 20*time.Millisecond, "expected the dropped slot padding to arrive on the far side") + require.True(t, pkt.Header.Padding) + require.EqualValues(t, DroppedSlotPaddingSizeForTest, pkt.Header.PaddingSize) + require.Empty(t, pkt.Payload) } // ----------------------------------------------------------------------------- @@ -681,7 +705,6 @@ func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) { type capturingProbe struct { hdr rtp.Header payloadLen int - lastByte byte } // capturedRTX records what the DownTrack handed the pacer for an RTX packet. @@ -706,9 +729,6 @@ func (c *capturingPacer) Enqueue(p *pacer.Packet) { switch { case p.IsProbe: cp := capturingProbe{hdr: *p.Header, payloadLen: len(p.Payload)} - if len(p.Payload) > 0 { - cp.lastByte = p.Payload[len(p.Payload)-1] - } c.lock.Lock() c.probes = append(c.probes, cp) c.lock.Unlock() @@ -900,16 +920,15 @@ func TestDownTrackSendsProbePackets(t *testing.T) { }, 5*time.Second, 20*time.Millisecond, "expected WriteProbePackets to emit RTX probe packets") // every probe packet must be on the RTX stream, carry the padding bit, declare - // the full padding size, and serialize a full padding block ending in the - // padding count byte. + // the full padding size, and have no payload: pion serializes the padding from + // the header. for _, pr := range cp.probePackets() { require.EqualValues(t, 2, pr.hdr.Version, "probe packet version") require.EqualValues(t, dh.dt.SSRCRTX(), pr.hdr.SSRC, "probe packet must be on the RTX stream") require.EqualValues(t, dh.dt.PayloadTypeRTXForTest(), pr.hdr.PayloadType, "probe packet must use the RTX payload type") require.True(t, pr.hdr.Padding, "probe packet must carry padding bit") require.EqualValues(t, RTPPaddingMaxPayloadSize, pr.hdr.PaddingSize, "probe packet must set header.PaddingSize") - require.Equal(t, RTPPaddingMaxPayloadSize, pr.payloadLen, "probe packet must carry a full padding block") - require.EqualValues(t, RTPPaddingMaxPayloadSize, pr.lastByte, "probe packet trailing byte must be the padding count") + require.Zero(t, pr.payloadLen, "probe packet must have no payload") } // the BWE is wired and exercised (the pacer records every send into it); @@ -919,3 +938,333 @@ func TestDownTrackSendsProbePackets(t *testing.T) { }) } } + +// ----------------------------------------------------------------------------- +// keyFrameRequester: PLI only while a layer lock is pending +// ----------------------------------------------------------------------------- + +// TestDownTrackKeyFrameRequesterArmsOnlyWhilePending verifies that the key frame +// requester keeps sending PLIs while the target layer is not locked, and goes quiet +// once a key frame on the target layer locks it (no periodic wake-ups while locked). +func TestDownTrackKeyFrameRequesterArmsOnlyWhilePending(t *testing.T) { + h := vnettest.NewHosts(t) + factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory() + p := pacer.NewPassThrough(logger.GetLogger(), newNullBWE()) + t.Cleanup(p.Stop) + + dh := newBoundDownTrack(t, h, factory, vp8CodecParams, p, vnettest.MediaEngineConfig{Video: true}) + + // allocate a target layer (spatial 0) without any media, so the lock is pending + var brs Bitrates + brs[0][0] = 100_000 + dh.receiver.GetLayeredBitrateReturns([]int32{0}, brs) + dh.dt.UpTrackMaxPublishedLayerChange(0) + dh.dt.SetMaxSpatialLayer(0) + alloc := dh.dt.AllocateOptimal(false, false) + require.Equal(t, int32(0), alloc.TargetLayer.Spatial, "expected target layer to be allocated") + + // unlocked: PLIs must keep coming at the key frame interval + require.Eventually(t, func() bool { + return dh.receiver.SendPLICallCount() >= 3 + }, 5*time.Second, 20*time.Millisecond, "expected repeated PLIs while layer lock is pending") + + // a key frame on the target layer locks it + vp8 := &codec.VP8{ + FirstByte: 0x10, + S: true, + PictureID: 1, + IsKeyFrame: true, + } + ep, err := testutils.GetTestExtPacketVP8(&testutils.TestExtPacketParams{ + SequenceNumber: 5000, + Timestamp: 180000, + SSRC: 0x33333333, + PayloadType: 96, + PayloadSize: 50, + IsKeyFrame: true, + Marker: true, + }, vp8) + require.NoError(t, err) + ep.Packet.Payload = distinctivePayload(0x40, 50) + require.Equal(t, int32(1), dh.dt.WriteRTP(ep, 0), "keyframe should forward") + + // let an already armed interval fire, then the requester must stay asleep + time.Sleep(2 * KeyFrameIntervalMaxForTest) + before := dh.receiver.SendPLICallCount() + wakeups := dh.dt.KeyFrameRequesterWakeupsForTest() + time.Sleep(2 * KeyFrameIntervalMaxForTest) + require.Equal(t, wakeups, dh.dt.KeyFrameRequesterWakeupsForTest(), "key frame requester must not wake up once the layer is locked") + require.Equal(t, before, dh.receiver.SendPLICallCount(), "no PLI expected once the layer is locked") + + // a resync drops the lock and must wake the requester again + dh.dt.Resync() + require.Eventually(t, func() bool { + return dh.receiver.SendPLICallCount() > before + }, 5*time.Second, 20*time.Millisecond, "expected PLI after resync") +} + +// ----------------------------------------------------------------------------- +// pause/resume: probing resumes a track paused by congestion +// ----------------------------------------------------------------------------- + +// countingPacer counts the bytes handed to the pacer (media, padding and probes), +// which is what a receiver sees arrive on an unconstrained link. +type countingPacer struct { + pacer.Pacer + + lock sync.Mutex + samples []rateSample + + // forwarded media packets, counted here as the far side buffer is unbound and + // stops surfacing packets after it fills up; the pass-through pacer writes + // inside Enqueue, so a counted packet is already on the wire + mediaPackets atomic.Int64 +} + +type rateSample struct { + at time.Time + bytes int +} + +func (c *countingPacer) Enqueue(p *pacer.Packet) { + size := p.HeaderSize + len(p.Payload) + if p.Header != nil { + size += int(p.Header.PaddingSize) + } + if !p.IsProbe && !p.IsRTX && len(p.Payload) != 0 { + c.mediaPackets.Add(1) + } + c.lock.Lock() + c.samples = append(c.samples, rateSample{at: time.Now(), bytes: size}) + c.lock.Unlock() + c.Pacer.Enqueue(p) +} + +// bitrate over the trailing window. +func (c *countingPacer) bitrate(window time.Duration) int64 { + c.lock.Lock() + defer c.lock.Unlock() + cutoff := time.Now().Add(-window) + total := 0 + keep := c.samples[:0] + for _, s := range c.samples { + if s.at.After(cutoff) { + total += s.bytes + keep = append(keep, s) + } + } + c.samples = keep + return int64(float64(total*8) / window.Seconds()) +} + +// TestDownTrackResumesAfterBandwidthPause reproduces livekit/livekit#5009. +// +// Default server congestion control (remote BWE / REMB, pass-through pacer, padding +// probes) drives a real DownTrack over real pion on vnet. A two layer simulcast +// stream is forwarded, the receiver estimate is capped below the lowest layer for +// 20s, then uncapped. The receiver estimate is a libwebrtc-like REMB model: under the +// cap it reports the cap; after it, the estimate only grows (8%/s) while it is below +// 1.5x the incoming rate + 10 kbps, like AimdRateControl does. +// +// With allow pause the video must come back within a bound after the cap is lifted, +// as it does without allow pause. +func TestDownTrackResumesAfterBandwidthPause(t *testing.T) { + if testing.Short() { + t.Skip("takes about 60s") + } + + for _, allowPause := range []bool{false, true} { + name := "allowPause=false" + if allowPause { + name = "allowPause=true" + } + t.Run(name, func(t *testing.T) { + testResumeAfterBandwidthPause(t, allowPause) + }) + } +} + +func testResumeAfterBandwidthPause(t *testing.T, allowPause bool) { + logger.InitFromConfig(&logger.Config{Level: "debug"}, t.Name()) + const ( + layer0Bps = 80_000 + layer1Bps = 170_000 + cappedBps = 60_000 // below the lowest layer, so allow pause pauses + fps = 30 // ~one packet per frame, so a layer is ~30 packets/s like a 360p camera + ) + + h := vnettest.NewHosts(t) + factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory() + + b := remotebwe.NewRemoteBWE(remotebwe.RemoteBWEParams{ + Config: remotebwe.DefaultRemoteBWEConfig, + Logger: logger.GetLogger(), + }) + cp := &countingPacer{Pacer: pacer.NewPassThrough(logger.GetLogger(), nil)} + t.Cleanup(cp.Stop) + + dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, vnettest.MediaEngineConfig{ + Video: true, + HeaderExtensions: true, + }, func(r *sfufakes.FakeTrackReceiver) { + // layer switches without sender reports from the fake publisher + r.VideoLayerModeReturns(livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM_INCOMPLETE_RTCP_SR) + }) + dt := dh.dt + + var brs Bitrates + brs[0][0] = layer0Bps + brs[1][0] = layer1Bps + dh.receiver.GetLayeredBitrateReturns([]int32{0, 1}, brs) + + // publisher side packet buffers, the real bucket at the max video size, so + // retransmissions and RTX probes read the same history a real receiver has + var buckets [2]*bucket.Bucket[uint64, uint16] + var bucketsMu sync.Mutex + for i := range buckets { + buckets[i] = bucket.NewBucket[uint64, uint16](500, bucket.RTPMaxPktSize, bucket.RTPSeqNumOffset) + } + dh.receiver.ReadRTPCalls(func(buf []byte, layer uint8, esn uint64) (int, error) { + bucketsMu.Lock() + defer bucketsMu.Unlock() + return buckets[layer].GetPacket(buf, esn) + }) + + sa := streamallocator.NewStreamAllocator(streamallocator.StreamAllocatorParams{ + Config: streamallocator.DefaultStreamAllocatorConfig, + BWE: b, + Pacer: cp, + RTTGetter: func() (float64, bool) { return 0.05, true }, + Logger: logger.GetLogger(), + }, true, allowPause) + sa.OnStreamStateChange(func(update *streamallocator.StreamStateUpdate) error { + logger.Infow("TEST: stream state update", "update", update) + return nil + }) + sa.AddTrack(dt, streamallocator.AddTrackParams{ + Source: livekit.TrackSource_CAMERA, + PublisherID: "fake-pub", + }) + dt.SetRTPHeaderExtensionsForTest() + abs, _ := dt.ExtIDsForTest() + require.NotZero(t, abs, "abs-send-time must be negotiated for remote BWE") + sa.Start() + t.Cleanup(sa.Stop) + + dt.UpTrackMaxPublishedLayerChange(1) + dt.UpTrackMaxTemporalLayerSeenChange(0) + dt.SetMaxSpatialLayer(1) + dt.SetMaxTemporalLayer(0) + dt.UpTrackLayersChange() + dt.UpTrackBitrateAvailabilityChange() + + // publisher: both simulcast layers, one packet per frame, key frame every second + stop := make(chan struct{}) + var wg sync.WaitGroup + t.Cleanup(func() { close(stop); wg.Wait() }) + wg.Add(1) + go func() { + defer wg.Done() + sizes := []int{layer0Bps / 8 / fps, layer1Bps / 8 / fps} + var sn [2]uint16 + var ts [2]uint32 + var pid [2]uint16 + sn[1] = 30000 + ticker := time.NewTicker(time.Second / fps) + defer ticker.Stop() + frame := 0 + for { + select { + case <-stop: + return + case <-ticker.C: + } + for layer := int32(0); layer < 2; layer++ { + sn[layer]++ + ts[layer] += 90000 / fps + pid[layer]++ + isKey := frame%fps == 0 + ep, err := testutils.GetTestExtPacketVP8(&testutils.TestExtPacketParams{ + SequenceNumber: sn[layer], + Timestamp: ts[layer], + SSRC: 0x1000 + uint32(layer), + PayloadType: 96, + PayloadSize: sizes[layer], + IsKeyFrame: isKey, + Marker: true, + ArrivalTime: time.Now(), + VideoLayer: buffer.VideoLayer{Spatial: layer, Temporal: 0}, + }, &codec.VP8{FirstByte: 0x10, S: true, PictureID: pid[layer], IsKeyFrame: isKey}) + if err != nil { + continue + } + bucketsMu.Lock() + _, _ = buckets[layer].AddPacketWithSequenceNumber(ep.RawPacket, ep.ExtSequenceNumber) + bucketsMu.Unlock() + dt.WriteRTP(ep, layer) + } + frame++ + } + }() + + // receiver: libwebrtc-like REMB + var capped atomic.Bool + wg.Add(1) + go func() { + defer wg.Done() + est := float64(2_000_000) + const interval = 200 * time.Millisecond + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + } + incoming := float64(cp.bitrate(time.Second)) + if capped.Load() { + est = math.Min(est, cappedBps) + } else { + limit := 1.5*incoming + 10_000 + if est < limit { + est = math.Min(est*(1+0.08*interval.Seconds()), limit) + } + } + remb := &rtcp.ReceiverEstimatedMaximumBitrate{SenderSSRC: 1, Bitrate: float32(est), SSRCs: []uint32{dt.SSRC()}} + if raw, err := remb.Marshal(); err == nil { + dt.HandleRTCPForTest(raw) + } + } + }() + + // RR precondition for probes + require.Eventually(t, func() bool { return dh.capture.count() > 0 }, 10*time.Second, 20*time.Millisecond, "no media forwarded") + feedReceiverReport(t, dt, dh.capture.all()[0].SequenceNumber) + + mediaRate := func(window time.Duration) int { + before := cp.mediaPackets.Load() + time.Sleep(window) + return int(cp.mediaPackets.Load() - before) + } + + time.Sleep(3 * time.Second) + require.Greater(t, mediaRate(time.Second), 5, "media should flow before the cap") + + logger.Infow("TEST: capping") + capped.Store(true) + time.Sleep(20 * time.Second) + cappedPackets := mediaRate(2 * time.Second) + logger.Infow("TEST: capped media", "packetsIn2s", cappedPackets, "deficient", dt.IsDeficient()) + if allowPause { + require.Zero(t, cappedPackets, "video should be paused under the cap") + } + + logger.Infow("TEST: uncapping") + capped.Store(false) + uncappedAt := time.Now() + require.Eventually(t, func() bool { + return mediaRate(time.Second) >= fps/2 + }, 20*time.Second, 10*time.Millisecond, "video did not resume within 20s of lifting the cap") + logger.Infow("TEST: resumed", "after", time.Since(uncappedAt)) +} diff --git a/pkg/sfu/export_test.go b/pkg/sfu/export_test.go index 449289e20..9812b2c33 100644 --- a/pkg/sfu/export_test.go +++ b/pkg/sfu/export_test.go @@ -14,7 +14,11 @@ package sfu -import "github.com/livekit/livekit-server/pkg/sfu/buffer" +import ( + "time" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" +) // Test-only seams that expose DownTrack internals to the external sfu_test package. // This file is only compiled into the test binary (its name ends in _test.go), so it @@ -28,6 +32,12 @@ func (d *DownTrack) PayloadTypeRTXForTest() uint32 { return d.payloadTypeRTX.Loa func (d *DownTrack) RTPStatsActiveForTest() bool { return d.rtpStats.IsActive() } +const DroppedSlotPaddingSizeForTest = droppedSlotPaddingSize + +func (d *DownTrack) WriteDroppedSlotPaddingForTest(extSequenceNumber uint64, extTimestamp uint64) { + d.writeDroppedSlotPadding(extSequenceNumber, extTimestamp, false) +} + func (d *DownTrack) LastReceiverReportTimeForTest() int64 { return d.rtpStats.LastReceiverReportTime() } @@ -71,3 +81,9 @@ func (d *DownTrack) RetransmitForTest( } return d.retransmitPacket(&epm, sourcePkt, false) } + +const KeyFrameIntervalMaxForTest = keyFrameIntervalMax * time.Millisecond + +func (d *DownTrack) KeyFrameRequesterWakeupsForTest() uint32 { + return d.keyFrameRequesterWakeups.Load() +} diff --git a/pkg/sfu/flexfec/decoder.go b/pkg/sfu/flexfec/decoder.go index 6523d4867..11427f2f9 100644 --- a/pkg/sfu/flexfec/decoder.go +++ b/pkg/sfu/flexfec/decoder.go @@ -273,7 +273,8 @@ func (d *Decoder) attemptRecovery() []*rtp.Packet { recovered, err := d.recoverPacket(fecPkt, recoveredPackets) if err != nil { d.logger.Debugw("flexfec: failed to recover packet", "error", err) - i++ + d.stats.FECPacketsDiscarded++ + d.removeFECPacketAt(i) continue } diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 6d9069151..8e2786c08 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -23,15 +23,14 @@ import ( "time" "github.com/pion/rtp" - "github.com/pion/rtp/codecs" "github.com/pion/webrtc/v4" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/mono" "github.com/livekit/livekit-server/pkg/sfu/buffer" @@ -199,12 +198,32 @@ type TranslationParams struct { isResuming bool isSwitching bool rtp TranslationParamsRTP - ddBytes []byte + ddBytes [dd.MaxInlineExtensionSize]byte + ddBytesLen int + ddBytesSpill []byte incomingHeaderSize int - codecBytes []byte + codecBytes [codecmunger.MaxHeaderSize]byte + codecBytesLen int marker bool // end of the svc spatial layer frame isEndOfLayerFrame bool + // The packet is dropped, but the RTP munger gave its outgoing sequence number + // away when it saw the gap, so the downtrack sends padding in that slot. Without + // it the subscriber NACKs a sequence number nothing can answer, and a VP8 + // receiver without picture IDs stalls on the hole until a key frame. + padDroppedSlot bool +} + +// codecHeader returns the munged codec header to prepend to the payload. +func (tp *TranslationParams) codecHeader() []byte { + return tp.codecBytes[:tp.codecBytesLen] +} + +// ddInline returns the dependency descriptor extension held inline. It points +// into tp, so it has to be copied before it is attached to anything that +// outlives the write. +func (tp *TranslationParams) ddInline() []byte { + return tp.ddBytes[:tp.ddBytesLen] } // ------------------------------------------------------------------- @@ -262,6 +281,13 @@ type Forwarder struct { rtpMunger *RTPMunger vls videolayerselector.VideoLayerSelector + // set for VP8 with the dependency descriptor, forwards the descriptor to the subscriber + ddForwarder *videolayerselector.DDForwarder + // migrated descriptor state that arrived before the codec was known + ddForwarderSeed *livekit.DependencyDescriptorForwarderState + // temporal layer of the packet being translated, the selector's current layer is + // already the next one on the last packet before a down switch + packetTemporal int32 codecMunger codecmunger.CodecMunger } @@ -339,6 +365,21 @@ func (f *Forwarder) MaybeExpireAcquireGrace() bool { return !f.vls.GetCurrent().IsValid() } +// NeedsPeriodicSyncCheck reports whether the layer lock state can change without an event, so the +// key frame requester has to keep polling it: an initial-acquisition grace deadline is armed +// (MaybeExpireAcquireGrace may still fire), or layers are selected with the dependency descriptor, +// which can lose the lock on the packet path (key frame mismatch, broken frame chain). +func (f *Forwarder) NeedsPeriodicSyncCheck() bool { + f.lock.RLock() + defer f.lock.RUnlock() + + if f.acquireDeadline != 0 { + return true + } + _, isDD := f.vls.(*videolayerselector.DependencyDescriptor) + return isDD +} + func (f *Forwarder) SetMaxTemporalLayerSeen(maxTemporalLayerSeen int32) bool { f.lock.Lock() defer f.lock.Unlock() @@ -379,84 +420,114 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ return false } + prevDDForwarder := f.ddForwarder + f.ddForwarder = nil switch f.mime { case mime.MimeTypeVP8: - f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) - if f.vls != nil { - if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { - f.vls = vls - } else { - f.logger.Errorw("failed to create simulcast on codec change", nil) - } + f.setSimulcastSelector() + if ddAvailable(extensions) { + f.setDDTemporalFilter(prevDDForwarder) } else { - f.vls = videolayerselector.NewSimulcast(f.logger) + f.isDDAvailable = false + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) } - f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) case mime.MimeTypeH264, mime.MimeTypeH265: - f.codecMunger = codecmunger.NewNull(f.logger) - if f.vls != nil { - if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { - f.vls = vls - } else { - f.logger.Errorw("failed to create simulcast on codec change", nil) - } - } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } + f.setSimulcastSelector() + f.setUnfilteredTemporal() case mime.MimeTypeVP9: - f.codecMunger = codecmunger.NewNull(f.logger) if sfuutils.IsSimulcastMode(videoLayerMode) { - if f.vls != nil { - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) + f.setSimulcastSelector() + if ddAvailable(extensions) { + f.setDDTemporalFilter(prevDDForwarder) } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } - } else { - f.isDDAvailable = ddAvailable(extensions) - if f.isDDAvailable { - if f.vls != nil { - f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) - } else { - f.vls = videolayerselector.NewDependencyDescriptor(f.logger) - } - } else { - if f.vls != nil { - f.vls = videolayerselector.NewVP9FromOther(f.vls) - } else { - f.vls = videolayerselector.NewVP9(f.logger) - } + f.setUnfilteredTemporal() } + break } - case mime.MimeTypeAV1: f.codecMunger = codecmunger.NewNull(f.logger) - if sfuutils.IsSimulcastMode(videoLayerMode) { + f.isDDAvailable = ddAvailable(extensions) + if f.isDDAvailable { if f.vls != nil { - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) + f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) } else { - f.vls = videolayerselector.NewSimulcast(f.logger) + f.vls = videolayerselector.NewDependencyDescriptor(f.logger) } } else { - f.isDDAvailable = ddAvailable(extensions) - if f.isDDAvailable { - if f.vls != nil { - f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) - } else { - f.vls = videolayerselector.NewDependencyDescriptor(f.logger) - } + if f.vls != nil { + f.vls = videolayerselector.NewVP9FromOther(f.vls) } else { - if f.vls != nil { - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) - } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } + f.vls = videolayerselector.NewVP9(f.logger) } } + // the svc selectors pick temporal layers themselves + f.vls.SetTemporalLayerSelector(nil) + + case mime.MimeTypeAV1: + if sfuutils.IsSimulcastMode(videoLayerMode) { + f.setSimulcastSelector() + if ddAvailable(extensions) { + f.setDDTemporalFilter(prevDDForwarder) + } else { + f.setUnfilteredTemporal() + } + break + } + + f.codecMunger = codecmunger.NewNull(f.logger) + f.isDDAvailable = ddAvailable(extensions) + if f.isDDAvailable { + if f.vls != nil { + f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) + } else { + f.vls = videolayerselector.NewDependencyDescriptor(f.logger) + } + f.vls.SetTemporalLayerSelector(nil) + } else { + // without the dependency descriptor nothing tells the temporal layers apart + f.setSimulcastSelector() + f.setUnfilteredTemporal() + } } } +// setSimulcastSelector switches to the simulcast selector, keeping the selector state +// across a codec change +func (f *Forwarder) setSimulcastSelector() { + if f.vls == nil { + f.vls = videolayerselector.NewSimulcast(f.logger) + return + } + if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { + f.vls = vls + } else { + f.logger.Errorw("failed to create simulcast on codec change", nil) + } +} + +// setDDTemporalFilter filters the temporal layers of a simulcast stream by frame with the +// dependency descriptor, and forwards the descriptor to the subscriber +func (f *Forwarder) setDDTemporalFilter(prevDDForwarder *videolayerselector.DDForwarder) { + f.isDDAvailable = true + f.codecMunger = codecmunger.NewDDTemporal(f.mime, f.logger) + f.vls.SetTemporalLayerSelector(temporallayerselector.NewDependencyDescriptor(f.logger)) + // the downtrack keeps its SSRC, so the frame numbers continue + f.ddForwarder = videolayerselector.NewDDForwarderFromOther(prevDDForwarder, f.logger) + f.ddForwarder.SeedState(f.ddForwarderSeed) + f.ddForwarderSeed = nil +} + +// setUnfilteredTemporal forwards every temporal layer of a simulcast stream that has +// nothing to filter them with +func (f *Forwarder) setUnfilteredTemporal() { + f.isDDAvailable = false + f.codecMunger = codecmunger.NewNull(f.logger) + f.vls.SetTemporalLayerSelector(temporallayerselector.NewNull()) +} + func (f *Forwarder) GetState() *livekit.RTPForwarderState { f.lock.RLock() defer f.lock.RUnlock() @@ -485,7 +556,13 @@ func (f *Forwarder) GetState() *livekit.RTPForwarderState { state.SenderReportState = make([]*livekit.RTCPSenderReportState, len(f.refInfos)) for layer, refInfo := range f.refInfos { - state.SenderReportState[layer] = utils.CloneProto(refInfo.senderReport) + state.SenderReportState[layer] = proto.CloneOf(refInfo.senderReport) + } + if f.ddForwarder != nil { + state.DependencyDescriptorForwarder = f.ddForwarder.GetState() + } else { + // seeded, but migrating on before the codec was known + state.DependencyDescriptorForwarder = proto.CloneOf(f.ddForwarderSeed) } return state } @@ -500,13 +577,18 @@ func (f *Forwarder) SeedState(state *livekit.RTPForwarderState) { for layer, rtcpSenderReportState := range state.SenderReportState { f.refInfos[layer] = refInfo{} - if senderReport := utils.CloneProto(rtcpSenderReportState); senderReport != nil && senderReport.NtpTimestamp != 0 { + if senderReport := proto.CloneOf(rtcpSenderReportState); senderReport != nil && senderReport.NtpTimestamp != 0 { f.refInfos[layer].senderReport = senderReport } } f.rtpMunger.SeedState(state.RtpMunger) f.codecMunger.SeedState(state.CodecMunger) + if f.ddForwarder != nil { + f.ddForwarder.SeedState(state.DependencyDescriptorForwarder) + } else { + f.ddForwarderSeed = state.DependencyDescriptorForwarder + } f.started = true f.referenceLayerSpatial = state.ReferenceLayerSpatial @@ -1932,7 +2014,7 @@ func (f *Forwarder) processSourceSwitch(extPkt *buffer.ExtPacket, layer int32) ( } if f.rtpStats != nil { - tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(switchingAt) + tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(switchingAt.UnixNano()) if err == nil { extExpectedTS = tsExt if f.lastReferencePayloadType == -1 { @@ -2135,8 +2217,7 @@ func (f *Forwarder) isEndOfLayerFrame(extPkt *buffer.ExtPacket) bool { } if f.mime == mime.MimeTypeVP9 { - vp9, ok := extPkt.Payload.(codecs.VP9Packet) - return ok && vp9.E + return extPkt.VP9 != nil && extPkt.VP9.E } return false @@ -2145,6 +2226,38 @@ func (f *Forwarder) isEndOfLayerFrame(extPkt *buffer.ExtPacket) bool { // should be called with lock held func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer int32) (TranslationParams, error) { tp := TranslationParams{} + if f.ddForwarder != nil && extPkt.DependencyDescriptor == nil && len(extPkt.Packet.Payload) != 0 { + // The buffer stopped parsing the dependency descriptor because the publisher does + // not send it. VP8 switches temporal layers with its payload descriptor, other + // codecs forward every temporal layer. Padding never carries the descriptor, so it + // does not count. + f.logger.Infow("turning off dependency descriptor for simulcast temporal layers", "layer", layer) + f.ddForwarder = nil + if f.mime == mime.MimeTypeVP8 { + f.isDDAvailable = false + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + if f.started { + // the munger takes its picture ID and TL0PICIDX from the first packet it sees + f.codecMunger.SetLast(extPkt) + } + f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) + } else { + f.setUnfilteredTemporal() + } + } + if _, ok := f.codecMunger.(*codecmunger.DDTemporal); ok && f.mime == mime.MimeTypeVP8 && extPkt.DependencyDescriptor != nil { + if extPkt.VP8 != nil && extPkt.VP8.I { + // The publisher sends picture IDs with the dependency descriptor, so they need + // rewriting. The VP8 munger does that, the temporal layers still switch with the + // dependency descriptor. + f.logger.Infow("VP8 picture ID with dependency descriptor, munging picture IDs", "layer", layer) + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + if f.started { + f.codecMunger.SetLast(extPkt) + } + } + } + if !f.vls.GetTarget().IsValid() { // stream is paused by streamallocator tp.shouldDrop = true @@ -2153,7 +2266,8 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in result := f.vls.Select(extPkt, layer) if !result.IsSelected { - if f.isDDAvailable && extPkt.DependencyDescriptor == nil { + // the svc selector, simulcast with the dependency descriptor turns it off above + if f.isDDAvailable && f.ddForwarder == nil && extPkt.DependencyDescriptor == nil { f.logger.Infow( "turning off dependency descriptor", "layer", layer, @@ -2168,15 +2282,22 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in case mime.MimeTypeVP9: f.vls = videolayerselector.NewVP9FromOther(f.vls) case mime.MimeTypeAV1: - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) + f.setSimulcastSelector() + f.setUnfilteredTemporal() } } tp.shouldDrop = true if f.started && result.IsRelevant { // call to update highest incoming sequence number and other internal structures if tpRTP, err := f.rtpMunger.UpdateAndGetSnTs(extPkt, result.RTPMarker); err == nil { - if tpRTP.snOrdering == SequenceNumberOrderingContiguous { + switch tpRTP.snOrdering { + case SequenceNumberOrderingContiguous: f.rtpMunger.PacketDropped(extPkt) + case SequenceNumberOrderingGap, SequenceNumberOrderingOutOfOrder: + // a rollback after a gap moves the last outgoing number below the missing + // packets' slots and their late copies are rejected, so pad the slot instead + tp.rtp = tpRTP + tp.padDroppedSlot = true } } } @@ -2184,9 +2305,10 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in } tp.isResuming = result.IsResuming tp.isSwitching = result.IsSwitching - tp.ddBytes = result.DependencyDescriptorExtension + tp.ddBytes, tp.ddBytesLen, tp.ddBytesSpill = result.DDBytes, result.DDBytesLen, result.DDBytesSpill tp.marker = result.RTPMarker tp.isEndOfLayerFrame = f.isEndOfLayerFrame(extPkt) + f.packetTemporal = f.vls.GetCurrent().Temporal starting, err := f.getTranslationParamsCommon(extPkt, layer, &tp) tp.isStarting = starting @@ -2217,13 +2339,22 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in return tp, nil } + if f.ddForwarder != nil { + // after translateCodecHeader, which picks the packet's temporal layer + var ddResult videolayerselector.VideoLayerSelectorResult + if f.ddForwarder.Forward(extPkt, f.packetTemporal, &ddResult) { + tp.ddBytes, tp.ddBytesLen, tp.ddBytesSpill = ddResult.DDBytes, ddResult.DDBytesLen, ddResult.DDBytesSpill + } + } + return tp, nil } func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *TranslationParams) error { // codec specific forwarding check and any needed packet munging tl := f.vls.SelectTemporal(extPkt) - inputSize, codecBytes, err := f.codecMunger.UpdateAndGet( + f.packetTemporal = tl + inputSize, codecBytes, codecBytesLen, err := f.codecMunger.UpdateAndGet( extPkt, tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder, tp.rtp.snOrdering == SequenceNumberOrderingGap, @@ -2231,10 +2362,16 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati ) if err != nil { tp.shouldDrop = true - if err == codecmunger.ErrFilteredVP8TemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss { - if err == codecmunger.ErrFilteredVP8TemporalLayer { - // filtered temporal layer, update sequence number offset to prevent holes - f.rtpMunger.PacketDropped(extPkt) + if err == codecmunger.ErrFilteredTemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss || err == codecmunger.ErrFilteredOutOfOrder { + switch tp.rtp.snOrdering { + case SequenceNumberOrderingContiguous: + if err == codecmunger.ErrFilteredTemporalLayer { + // filtered temporal layer, update sequence number offset to prevent holes + f.rtpMunger.PacketDropped(extPkt) + } + case SequenceNumberOrderingGap, SequenceNumberOrderingOutOfOrder: + // no rollback after a gap, see getTranslationParamsVideo + tp.padDroppedSlot = true } return nil } @@ -2243,6 +2380,7 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati } tp.incomingHeaderSize = inputSize tp.codecBytes = codecBytes + tp.codecBytesLen = codecBytesLen return nil } @@ -2315,7 +2453,7 @@ func (f *Forwarder) GetSnTsForBlankFrames(frameRate uint32, numPackets int) ([]S extLastTS := f.rtpMunger.GetExtLastTimestamp() extExpectedTS := extLastTS if f.rtpStats != nil { - tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(mono.Now()) + tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(mono.UnixNano()) if err == nil { extExpectedTS = tsExt } diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index b9d8170f8..567a79096 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -15,6 +15,7 @@ package sfu import ( + "encoding/hex" "testing" "github.com/pion/rtp/codecs" @@ -27,8 +28,11 @@ import ( "github.com/livekit/protocol/logger" "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/livekit-server/pkg/sfu/codecmunger" + "github.com/livekit/livekit-server/pkg/sfu/pacer" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/livekit-server/pkg/sfu/videolayerselector" ) func disable(f *Forwarder) { @@ -1598,7 +1602,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), marker: true, } actualTP, err = f.GetTranslationParams(extPkt, 0) @@ -1677,7 +1682,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 0) require.NoError(t, err) @@ -1731,7 +1737,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 0) require.NoError(t, err) @@ -1819,7 +1826,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 0) require.NoError(t, err) @@ -1918,7 +1926,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdf0, }, incomingHeaderSize: 5, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 1) require.NoError(t, err) @@ -1926,6 +1935,415 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { require.Equal(t, f.lastSSRC, params.SSRC) } +func TestForwarderVP8TemporalUpSwitchAtLayerSync(t *testing.T) { + f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + + // a frame without Y may reference a dropped frame of its layer + steps := []struct { + name string + tid uint8 + y bool + expectedThis int32 + expectedCurrent int32 + }{ + {name: "TL2 without Y", tid: 2, expectedThis: 0, expectedCurrent: 0}, + {name: "TL1 without Y", tid: 1, expectedThis: 0, expectedCurrent: 0}, + {name: "TL0", tid: 0, expectedThis: 0, expectedCurrent: 0}, + {name: "TL2 with Y before TL1 sync", tid: 2, y: true, expectedThis: 0, expectedCurrent: 0}, + {name: "TL1 with Y", tid: 1, y: true, expectedThis: 1, expectedCurrent: 1}, + {name: "TL2 without Y after TL1 switch", tid: 2, expectedThis: 1, expectedCurrent: 1}, + {name: "TL2 with Y", tid: 2, y: true, expectedThis: 2, expectedCurrent: 2}, + } + for i, step := range steps { + extPkt, err := testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: uint16(i), PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: uint16(i), T: true, TID: step.tid, Y: step.y}, + ) + require.NoError(t, err) + require.Equal(t, step.expectedThis, f.vls.SelectTemporal(extPkt), step.name) + require.Equal(t, step.expectedCurrent, f.vls.GetCurrent().Temporal, step.name) + } + + // key frame refreshes all buffers, up-switch at it even without Y + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + extPkt, err := testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: 10, PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: 10, T: true, TID: 0, IsKeyFrame: true}, + ) + require.NoError(t, err) + require.Equal(t, int32(2), f.vls.SelectTemporal(extPkt)) + require.Equal(t, int32(2), f.vls.GetCurrent().Temporal) +} + +func TestForwarderVP8TemporalUpSwitchWithDependencyDescriptor(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + + // libwebrtc strips the VP8 payload descriptor when it sends the dependency + // descriptor: no TID and no layer sync bit + packet := func(frameNum uint64, tid int, dtis string, frameDiffs []int, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: uint16(frameNum), PayloadSize: 20, Marker: true, IsKeyFrame: isKeyFrame}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: dtis, + FrameDiffs: frameDiffs, + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + require.Equal(t, int32(0), f.vls.SelectTemporal(packet(0, 0, "SSS", nil, true))) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + require.Equal(t, int32(0), f.vls.SelectTemporal(packet(1, 2, "--D", []int{1}, false))) + require.Equal(t, int32(0), f.vls.GetCurrent().Temporal) + require.Equal(t, int32(2), f.vls.SelectTemporal(packet(2, 1, "-SS", []int{2}, false))) + require.Equal(t, int32(2), f.vls.GetCurrent().Temporal) + + // padding never carries the descriptor and leaves it on + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + padding, err := testutils.GetTestExtPacket(&testutils.TestExtPacketParams{SequenceNumber: 3, PaddingSize: 20}) + require.NoError(t, err) + _, _ = f.getTranslationParamsVideo(padding, 0) + require.True(t, f.isDDAvailable) + + // a packet with a payload and without the descriptor means the buffer stopped + // parsing it, the Y bit then raises the layer + extPkt, err := testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: 4, PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: 4, T: true, TID: 0}, + ) + require.NoError(t, err) + _, _ = f.getTranslationParamsVideo(extPkt, 0) + require.False(t, f.isDDAvailable) + require.IsType(t, &codecmunger.VP8{}, f.codecMunger) + + extPkt, err = testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: 5, PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: 5, T: true, TID: 1, Y: true}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), f.vls.SelectTemporal(extPkt)) + require.Equal(t, int32(1), f.vls.GetCurrent().Temporal) +} + +func TestForwarderVP8DDTemporalFilterAfterGap(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + // one packet per frame, the minimized VP8 payload descriptor has no picture ID + packet := func(sn uint16, frameNum uint64, tid int, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{ + SequenceNumber: sn, + Timestamp: uint32(frameNum) * 3000, + SSRC: 0x12345678, + PayloadSize: 20, + Marker: true, + IsKeyFrame: isKeyFrame, + }, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: "SSS", + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + steps := []struct { + name string + sn uint16 + frameNum uint64 + tid int + isKeyFrame bool + shouldDrop bool + }{ + {name: "key frame", sn: 100, frameNum: 0, tid: 0, isKeyFrame: true}, + {name: "TL2 after a gap", sn: 102, frameNum: 2, tid: 2, shouldDrop: true}, + {name: "TL2", sn: 103, frameNum: 3, tid: 2, shouldDrop: true}, + {name: "TL0", sn: 104, frameNum: 4, tid: 0}, + {name: "TL2 after TL0", sn: 105, frameNum: 5, tid: 2, shouldDrop: true}, + } + for _, step := range steps { + tp, err := f.GetTranslationParams(packet(step.sn, step.frameNum, step.tid, step.isKeyFrame), 0) + require.NoError(t, err, step.name) + require.Equal(t, step.shouldDrop, tp.shouldDrop, step.name) + } +} + +func TestForwarderVP8DDWithPictureID(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) + + // the one byte descriptor keeps the dependency descriptor munger + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: 1, Timestamp: 3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsKeyFrame: true}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: 1, DTIs: "SSS", DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + _, _ = f.GetTranslationParams(extPkt, 0) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) + require.True(t, f.started) + + // a picture ID next to the dependency descriptor needs the VP8 munger + extPkt, err = testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: 2, Timestamp: 6000, SSRC: 0x12345678, PayloadSize: 20, Marker: true}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: 2, TemporalID: 1, DTIs: "-SS", FrameDiffs: []int{1}, DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + extPkt.VP8 = &codec.VP8{S: true, I: true, PictureID: 2} + _, _ = f.GetTranslationParams(extPkt, 0) + require.IsType(t, &codecmunger.VP8{}, f.codecMunger) + require.True(t, f.codecMunger.GetState().(*livekit.VP8MungerState).PictureIdUsed, "seeded from the packet") + require.True(t, f.isDDAvailable, "temporal layers still switch with the dependency descriptor") +} + +// testKeyFrameDescriptor is an L3T3 key frame descriptor with its dependency structure, the +// capture the dependency descriptor package tests with +func testKeyFrameDescriptor(t *testing.T) dd.DependencyDescriptor { + raw, err := hex.DecodeString("c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d") + require.NoError(t, err) + keyFrameDescriptor := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &keyFrameDescriptor}).Unmarshal(raw) + require.NoError(t, err) + require.NotNil(t, keyFrameDescriptor.AttachedStructure) + return keyFrameDescriptor +} + +// testDDPacket is a packet of frame frameNum on temporalID, with keyFrameDescriptor as its +// dependency descriptor, its structure attached only on a key frame +func testDDPacket(t *testing.T, keyFrameDescriptor dd.DependencyDescriptor, sn uint16, frameNum uint64, temporalID int, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef + uint32(frameNum)*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsKeyFrame: isKeyFrame}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: frameNum, TemporalID: temporalID, DTIs: "SSS", DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + descriptor := keyFrameDescriptor + descriptor.FrameNumber = uint16(frameNum) + if !isKeyFrame { + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + } + extPkt.DependencyDescriptor.Descriptor = &descriptor + return extPkt +} + +func TestForwarderVP8ForwardsDependencyDescriptor(t *testing.T) { + keyFrameDescriptor := testKeyFrameDescriptor(t) + packet := func(sn uint16, frameNum uint64, isKeyFrame bool) *buffer.ExtPacket { + return testDDPacket(t, keyFrameDescriptor, sn, frameNum, 0, isKeyFrame) + } + + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + // the key frame carries the structure, too big to hold inline + tp, err := f.GetTranslationParams(packet(10, 100, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.NotEmpty(t, tp.ddBytesSpill) + + tp, err = f.GetTranslationParams(packet(11, 101, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.NotEmpty(t, tp.ddInline()) + + // a restarted upstream receiver sets the codec again, the frame numbers continue on + // the same downtrack + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + tp, err = f.GetTranslationParams(packet(12, 50, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + out := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &out, Structure: keyFrameDescriptor.AttachedStructure}).Unmarshal(tp.ddBytesSpill) + require.NoError(t, err) + require.Equal(t, uint16(102), out.FrameNumber) + + // the last packet of a TL2 frame that starts a down switch to TL0 is forwarded at TL2, + // its descriptor names the TL2 decode target + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + tl2 := packet(13, 51, false) + tl2.Temporal = 2 + tp, err = f.GetTranslationParams(tl2, 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, int32(0), f.vls.GetCurrent().Temporal, "the switch takes effect after this packet") + out = dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &out, Structure: keyFrameDescriptor.AttachedStructure}).Unmarshal(tp.ddInline()) + require.NoError(t, err) + require.Equal(t, uint32(1<<0|1<<1|1<<2), *out.ActiveDecodeTargetsBitmask) + + // a migrated subscriber: the state is seeded before the codec is known, and the new + // node's first key frame continues after the last number sent + state := f.GetState() + require.Equal(t, uint64(103), state.DependencyDescriptorForwarder.ExtLastFrameNumber) + f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.SeedState(state) + + // it migrates on before its codec is known, the next node still gets the number + state = f.GetState() + require.Equal(t, uint64(103), state.DependencyDescriptorForwarder.ExtLastFrameNumber) + f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.SeedState(state) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + tp, err = f.GetTranslationParams(packet(20, 60, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + out = dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &out, Structure: keyFrameDescriptor.AttachedStructure}).Unmarshal(tp.ddBytesSpill) + require.NoError(t, err) + require.Equal(t, uint16(104), out.FrameNumber) + + // without the extension the forwarder writes no descriptor + f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, nil, livekit.VideoLayer_MODE_UNUSED) + require.Nil(t, f.ddForwarder) +} + +func TestForwarderPadDroppedSlot(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + packet := func(sn uint16, frameNum uint64, tid int, isOutOfOrder bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsOutOfOrder: isOutOfOrder}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: "SSS", + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + tp, err := f.GetTranslationParams(packet(10, 0, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + + // a gap, the munger hands out a slot for sequence number 11 + tp, err = f.GetTranslationParams(packet(12, 2, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(12), tp.rtp.extSequenceNumber) + + // the late packet is on a filtered layer, its slot gets padding + tp, err = f.GetTranslationParams(packet(11, 1, 2, true), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + require.True(t, tp.padDroppedSlot) + require.Equal(t, uint64(11), tp.rtp.extSequenceNumber) + require.Equal(t, uint64(0xabcdef), tp.rtp.extTimestamp) + + // an in-order drop rolls the slot back instead + tp, err = f.GetTranslationParams(packet(13, 3, 2, false), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + require.False(t, tp.padDroppedSlot) + tp, err = f.GetTranslationParams(packet(14, 4, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(13), tp.rtp.extSequenceNumber) +} + +// dropSelector drops one packet as relevant, the result a decode target selector gives for a +// frame outside the target +type dropSelector struct { + videolayerselector.VideoLayerSelector + dropSN uint64 +} + +func (d *dropSelector) Select(extPkt *buffer.ExtPacket, layer int32) videolayerselector.VideoLayerSelectorResult { + if extPkt.ExtSequenceNumber == d.dropSN { + return videolayerselector.VideoLayerSelectorResult{IsRelevant: true} + } + return d.VideoLayerSelector.Select(extPkt, layer) +} + +func TestForwarderPadFilteredSlotAfterGap(t *testing.T) { + for _, tc := range []struct { + name string + dropBySelector bool + }{ + {"codec munger", false}, + {"layer selector", true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + if tc.dropBySelector { + f.vls = &dropSelector{VideoLayerSelector: f.vls, dropSN: 12} + } + + packet := func(sn uint16, frameNum uint64, tid int, isOutOfOrder bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef + uint32(frameNum)*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsOutOfOrder: isOutOfOrder}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: "SSS", + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + tp, err := f.GetTranslationParams(packet(10, 0, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + + // 11 is lost, 12 is on a filtered layer: its slot gets padding and is not rolled back + tp, err = f.GetTranslationParams(packet(12, 2, 2, false), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + require.True(t, tp.padDroppedSlot) + require.Equal(t, uint64(12), tp.rtp.extSequenceNumber) + + // the retransmission of 11 still has its slot + tp, err = f.GetTranslationParams(packet(11, 1, 0, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(11), tp.rtp.extSequenceNumber) + + // the next packet follows the padded slot + tp, err = f.GetTranslationParams(packet(13, 3, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(13), tp.rtp.extSequenceNumber) + }) + } +} + func TestForwarderGetSnTsForPadding(t *testing.T) { f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) @@ -2226,26 +2644,250 @@ func TestForwarderIsEndOfLayerFrame(t *testing.T) { vp9Codec := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000} f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) - require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{Payload: codecs.VP9Packet{E: true}})) + require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{VP9: &codecs.VP9Packet{E: true}})) f = newForwarder(vp9Codec, webrtc.RTPCodecTypeVideo) - require.True(t, f.isEndOfLayerFrame(&buffer.ExtPacket{Payload: codecs.VP9Packet{E: true}})) - require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{Payload: codecs.VP9Packet{E: false}})) + require.True(t, f.isEndOfLayerFrame(&buffer.ExtPacket{VP9: &codecs.VP9Packet{E: true}})) + require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{VP9: &codecs.VP9Packet{E: false}})) require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{})) require.True(t, f.isEndOfLayerFrame(&buffer.ExtPacket{ DependencyDescriptor: &buffer.ExtDependencyDescriptor{ Descriptor: &dd.DependencyDescriptor{LastPacketInFrame: true}, }, - Payload: codecs.VP9Packet{E: false}, + VP9: &codecs.VP9Packet{E: false}, })) require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{ DependencyDescriptor: &buffer.ExtDependencyDescriptor{ Descriptor: &dd.DependencyDescriptor{LastPacketInFrame: false}, }, - Payload: codecs.VP9Packet{E: true}, + VP9: &codecs.VP9Packet{E: true}, })) require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{ DependencyDescriptor: &buffer.ExtDependencyDescriptor{}, })) } + +func codecHeaderOf(b []byte) [codecmunger.MaxHeaderSize]byte { + var hdr [codecmunger.MaxHeaderSize]byte + copy(hdr[:], b) + return hdr +} + +// WriteRTP holds the dependency descriptor in pacer scratch sized to pion's one +// byte extension profile payload cap, which has to be the inline size +// TranslationParams carries, or descriptors would take the heap copy fallback +func TestPacerScratchFitsInlineDependencyDescriptor(t *testing.T) { + p := &pacer.Packet{} + + require.Len(t, p.HoldExtension(make([]byte, dd.MaxInlineExtensionSize)), dd.MaxInlineExtensionSize) + require.Nil(t, p.HoldExtension(make([]byte, dd.MaxInlineExtensionSize+1))) +} + +func TestForwarderNeedsPeriodicSyncCheck(t *testing.T) { + f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + require.False(t, f.NeedsPeriodicSyncCheck()) + + // VP8 with the dependency descriptor uses it only for temporal layers + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + require.False(t, f.NeedsPeriodicSyncCheck()) + + // a new max published layer before any layer is forwarded arms the acquisition grace + require.True(t, f.SetMaxPublishedLayer(1)) + require.True(t, f.NeedsPeriodicSyncCheck()) + + // spatial layers selected with the dependency descriptor + f = newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + av1 := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeAV1.String(), ClockRate: 90000} + f.DetermineCodec(av1, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + require.True(t, f.NeedsPeriodicSyncCheck()) +} + +// TestForwarderSimulcastTemporalLayerAfterSwitch checks that, after a spatial layer switch on a +// key frame (which is always on temporal layer 0), the forwarder reaches its target layer so that +// GetNextHigherTransition (and hence stream allocator probing) is not blocked forever. +// +// VP9, AV1 and H.264 simulcast without the dependency descriptor forward every temporal layer of +// the selected spatial layer, so the current temporal layer has to follow the target. VP8 steps +// up through its temporal layer selector. +func TestForwarderSimulcastTemporalLayerAfterSwitch(t *testing.T) { + bitrates := Bitrates{ + {100, 150, 200, 0}, + {300, 450, 600, 0}, + {900, 1350, 1800, 0}, + } + + testCases := []struct { + name string + previousCodec *webrtc.RTPCodecCapability + codec webrtc.RTPCodecCapability + target buffer.VideoLayer + expectedCurrent buffer.VideoLayer + }{ + { + name: "VP9 simulcast, three temporal layers", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + name: "AV1 simulcast, three temporal layers", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeAV1.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + name: "VP9 simulcast, one temporal layer", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + }, + { + name: "H.264 simulcast", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeH264.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + }, + { + // the VP8 temporal layer selector moves to the target on a key frame + name: "VP8 simulcast, three temporal layers", + codec: testutils.TestVP8Codec, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + // the VP8 temporal layer selector must not be carried over on codec change + name: "VP8 to VP9 simulcast, three temporal layers", + previousCodec: &testutils.TestVP8Codec, + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + name: "VP8 to AV1 simulcast, three temporal layers", + previousCodec: &testutils.TestVP8Codec, + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeAV1.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f := NewForwarder( + webrtc.RTPCodecTypeVideo, + logger.GetLogger(), + true, // skipReferenceTS + true, // disableOpportunisticAllocation + false, // enableStartAtDesiredQuality + nil, + ) + if tc.previousCodec != nil { + f.DetermineCodec(*tc.previousCodec, nil, livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM) + } + f.DetermineCodec(tc.codec, nil, livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM) + f.SetMaxSpatialLayer(buffer.DefaultMaxLayerSpatial) + f.SetMaxTemporalLayer(buffer.DefaultMaxLayerTemporal) + f.SetMaxPublishedLayer(buffer.DefaultMaxLayerSpatial) + f.SetMaxTemporalLayerSeen(buffer.DefaultMaxLayerTemporal) + + // down-switched for bandwidth: was on the top layer, target is now lower + f.lastAllocation.IsDeficient = true + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 2, Temporal: tc.target.Temporal}) + f.vls.SetTarget(tc.target) + + // target pending (spatial switch not done yet): no higher transition + _, available := f.GetNextHigherTransition(bitrates, false) + require.False(t, available) + + // key frame of the target spatial layer, temporal layer 0 + params := &testutils.TestExtPacketParams{ + SequenceNumber: 23333, + Timestamp: 0xabcdef, + SSRC: 0x12345678, + PayloadSize: 20, + IsKeyFrame: true, + VideoLayer: buffer.VideoLayer{Spatial: tc.target.Spatial, Temporal: 0}, + } + var extPkt *buffer.ExtPacket + var err error + if tc.codec.MimeType == testutils.TestVP8Codec.MimeType { + extPkt, err = testutils.GetTestExtPacketVP8(params, &codec.VP8{ + FirstByte: 25, + I: true, + M: true, + PictureID: 13467, + L: true, + TL0PICIDX: 233, + T: true, + TID: 0, + Y: true, + K: true, + KEYIDX: 23, + HeaderSize: 6, + IsKeyFrame: true, + }) + } else { + extPkt, err = testutils.GetTestExtPacket(params) + } + require.NoError(t, err) + + tp, err := f.GetTranslationParams(extPkt, tc.target.Spatial) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.True(t, tp.isSwitching) + + require.Equal(t, tc.expectedCurrent, f.vls.GetCurrent()) + // target reached: the next higher transition (and hence probing) is available + _, available = f.GetNextHigherTransition(bitrates, false) + require.True(t, available) + }) + } +} + +// VP9 and AV1 simulcast with the dependency descriptor filter temporal layers by frame and +// forward the descriptor, as VP8 does. A publisher that stops sending the descriptor gets +// every temporal layer forwarded. +func TestForwarderSimulcastDependencyDescriptor(t *testing.T) { + for _, mimeType := range []mime.MimeType{mime.MimeTypeVP9, mime.MimeTypeAV1} { + t.Run(mimeType.String(), func(t *testing.T) { + keyFrameDescriptor := testKeyFrameDescriptor(t) + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec( + webrtc.RTPCodecCapability{MimeType: mimeType.String(), ClockRate: 90000}, + []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, + livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM, + ) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) + require.NotNil(t, f.ddForwarder) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + tp, err := f.GetTranslationParams(testDDPacket(t, keyFrameDescriptor, 10, 100, 0, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.NotEmpty(t, tp.ddBytesSpill) + + // TL2 is above the target and is filtered, its slot is rolled back + tp, err = f.GetTranslationParams(testDDPacket(t, keyFrameDescriptor, 11, 101, 2, false), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + + tp, err = f.GetTranslationParams(testDDPacket(t, keyFrameDescriptor, 12, 102, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(11), tp.rtp.extSequenceNumber) + require.NotEmpty(t, tp.ddInline()) + + // the publisher stops sending the descriptor + noDD, err := testutils.GetTestExtPacket(&testutils.TestExtPacketParams{SequenceNumber: 13, Timestamp: 0xabcdef + 103*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true}) + require.NoError(t, err) + tp, err = f.GetTranslationParams(noDD, 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Nil(t, f.ddForwarder) + require.IsType(t, &codecmunger.Null{}, f.codecMunger) + require.Empty(t, tp.ddInline()) + }) + } +} diff --git a/pkg/sfu/forwardstats.go b/pkg/sfu/forwardstats.go index 2ad8497a7..a7dfbb0dc 100644 --- a/pkg/sfu/forwardstats.go +++ b/pkg/sfu/forwardstats.go @@ -1,7 +1,7 @@ package sfu import ( - "math" + "math/bits" "sync" "time" @@ -13,6 +13,12 @@ import ( const ( cHighForwardingLatency = 20 * time.Millisecond cSkewFactor = 10 + + // forwardLatencyPercentile is the transit quantile reported as the node's + // forwarding latency and read by the overload controller. p90 tracks a broad + // slowdown but ignores the sparse tail (a few packets stalled on goroutine + // scheduling), which shedding cannot fix. + forwardLatencyPercentile = 0.90 ) const ( @@ -94,32 +100,86 @@ 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 +// forwardSummary is a mergeable histogram of forwarding transit over an +// interval. Each power-of-two octave [2^e, 2^(e+1)) us is split into +// forwardHistSub linear sub-buckets, and each bucket keeps the observed +// [min, max] of the samples in it. A percentile interpolates within the +// crossing bucket's observed range rather than its nominal edges, so the +// estimate never leaves the samples and is exact when a bucket's samples +// cluster -- which keeps it accurate near an overload threshold that falls +// mid-bucket, without needing threshold-aware boundaries. Two summaries merge +// by adding their buckets, so the percentile covers the whole report window, +// and a few stalled packets (e.g. from goroutine scheduling latency) cannot +// drag it the way a mean does. +const ( + forwardHistSub = 8 // linear sub-buckets per octave + forwardHistOctaves = 27 // up to 2^27 us (~134 s) + forwardHistBuckets = forwardHistOctaves * forwardHistSub // 216 +) + +// bucketStat is one bucket: how many samples fell in it and their observed +// transit range in nanoseconds. +type bucketStat struct { + count 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} +func (b *bucketStat) add(transitNs int64) { + if b.count == 0 { + b.minNs, b.maxNs = transitNs, transitNs + } else { + b.minNs = min(b.minNs, transitNs) + b.maxNs = max(b.maxNs, transitNs) } + b.count++ +} +func (b *bucketStat) mergeIn(o bucketStat) { + if o.count == 0 { + return + } + if b.count == 0 { + *b = o + return + } + b.count += o.count + b.minNs = min(b.minNs, o.minNs) + b.maxNs = max(b.maxNs, o.maxNs) +} + +type forwardSummary struct { + count int64 + minNs int64 + maxNs int64 + buckets [forwardHistBuckets]bucketStat +} + +// forwardBucket returns the bucket index for a transit in nanoseconds: the +// octave floor(log2(us)) times forwardHistSub, plus the linear sub-bucket +// within the octave. +func forwardBucket(transitNs int64) int { + us := transitNs / 1000 + if us <= 0 { + return 0 + } + e := bits.Len64(uint64(us)) - 1 // floor(log2(us)), us >= 1 so e >= 0 + if e >= forwardHistOctaves { + return forwardHistBuckets - 1 + } + octave := int64(1) << e + return e*forwardHistSub + int((us-octave)*forwardHistSub/octave) +} + +func (s *forwardSummary) addSample(transitNs int64) { + if s.count == 0 { + s.minNs, s.maxNs = transitNs, transitNs + } else { + s.minNs = min(s.minNs, transitNs) + s.maxNs = max(s.maxNs, transitNs) + } 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 + s.buckets[forwardBucket(transitNs)].add(transitNs) } func (s forwardSummary) merge(o forwardSummary) forwardSummary { @@ -129,35 +189,36 @@ func (s forwardSummary) merge(o forwardSummary) forwardSummary { 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), + s.count += o.count + s.minNs = min(s.minNs, o.minNs) + s.maxNs = max(s.maxNs, o.maxNs) + for i := range s.buckets { + s.buckets[i].mergeIn(o.buckets[i]) } + return s } -func (s forwardSummary) meanStdDev() (mean, stdDev time.Duration) { +// percentile returns the p-quantile (0..1) of transit, interpolated within the +// observed [min, max] of the bucket the quantile falls in. +func (s forwardSummary) percentile(p float64) time.Duration { if s.count == 0 { - return 0, 0 + return 0 } - - meanUs := float64(s.sumUs) / float64(s.count) - mean = time.Duration(meanUs * float64(time.Microsecond)) - if s.count < 2 { - return mean, 0 + target := p * float64(s.count) + var cum float64 + for i := range s.buckets { + b := s.buckets[i] + c := float64(b.count) + if c == 0 { + continue + } + if cum+c >= target { + lo, hi := float64(b.minNs), float64(b.maxNs) + return time.Duration(lo + (hi-lo)*(target-cum)/c) + } + cum += c } - - // 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 + return time.Duration(s.maxNs) } type ForwardStats struct { @@ -231,12 +292,12 @@ func (s *ForwardStats) run() { // 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. +// latency gauge. func (s *ForwardStats) flush() { var summ forwardSummary s.samples.drain(func(transitNs int64) { prometheus.RecordForwardLatencySample(transitNs) - summ = summ.addSample(transitNs) + summ.addSample(transitNs) }) s.lock.Lock() @@ -274,33 +335,33 @@ func (s *ForwardStats) summarize(window time.Duration) forwardSummary { 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() +// GetStats returns the reported forwarding-latency percentile +// (forwardLatencyPercentile) 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 { + return s.summarize(duration).percentile(forwardLatencyPercentile) } func (s *ForwardStats) report() { w := s.summarize(0) - latency, jitter := w.meanStdDev() + p90 := w.percentile(forwardLatencyPercentile) if dropped := s.samples.takeDropped(); dropped > 0 { logger.Warnw("forward stats sample buffer overflow", nil, "dropped", dropped) } - if w.count > 0 && jitter > latency*cSkewFactor { + // a max far above p90 means a few packets stalled (e.g. goroutine scheduling + // latency) rather than a broad forwarding slowdown; p90 rides through it. + if w.count > 0 && w.maxNs > p90.Nanoseconds()*cSkewFactor { logger.Infow( - "high jitter in forwarding path", + "high spread in forwarding path", "lowest", time.Duration(w.minNs), "highest", time.Duration(w.maxNs), "count", w.count, - "latency", latency, - "jitter", jitter, + "p90", p90, ) } - prometheus.RecordForwardJitter(uint32(jitter.Nanoseconds())) - prometheus.RecordForwardLatency(uint32(latency.Nanoseconds())) + prometheus.RecordForwardLatency(uint32(p90.Nanoseconds())) } diff --git a/pkg/sfu/forwardstats_test.go b/pkg/sfu/forwardstats_test.go index 152404978..fe145659a 100644 --- a/pkg/sfu/forwardstats_test.go +++ b/pkg/sfu/forwardstats_test.go @@ -24,28 +24,39 @@ func initPrometheus(t *testing.T) { // forwardSummary // --------------------------------------------------------------------------- +// bucketSum totals a summary's per-bucket counts, independent of the bucketing +// scheme, so tests can assert samples landed without hard-coding bucket indices. +func bucketSum(s forwardSummary) int64 { + var n int64 + for _, b := range s.buckets { + n += b.count + } + return n +} + 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 + s.addSample(3000) // 3us + s.addSample(1000) // 1us + 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) + require.Equal(t, s.count, bucketSum(s)) // every sample landed in a bucket } func TestForwardSummary_Merge(t *testing.T) { var empty forwardSummary - a := forwardSummary{}.addSample(1000).addSample(2000) - b := forwardSummary{}.addSample(5000).addSample(3000) + var a, b forwardSummary + a.addSample(1000) + a.addSample(2000) + b.addSample(5000) + b.addSample(3000) // merging with empty is identity, in both directions require.Equal(t, a, a.merge(empty)) @@ -53,34 +64,72 @@ func TestForwardSummary_Merge(t *testing.T) { 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) + // merge sums the per-bucket counts + require.Equal(t, bucketSum(a)+bucketSum(b), bucketSum(m)) + require.Equal(t, m.count, bucketSum(m)) } -func TestForwardSummary_MeanStdDev(t *testing.T) { +func TestForwardSummary_Percentile(t *testing.T) { // empty -> zero - mean, stdDev := forwardSummary{}.meanStdDev() - require.Zero(t, mean) - require.Zero(t, stdDev) + require.Zero(t, forwardSummary{}.percentile(0.5)) - // 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) + // half the samples fast (1us), half a slow tail (100us). A percentile is not + // dragged toward the tail the way the mean is -- why the reported metric is + // p90, not the mean. + var s forwardSummary + for i := 0; i < 5; i++ { + s.addSample(1000) // 1us + } + for i := 0; i < 5; i++ { + s.addSample(100_000) // 100us + } + require.Equal(t, int64(10), s.count) - // 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) + // p50 lands in the fast cluster, unmoved by the 100us tail. + require.Equal(t, 1*time.Microsecond, s.percentile(0.5)) + // p90 crosses into the slow cluster. + require.Equal(t, 100*time.Microsecond, s.percentile(0.9)) - // 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)) + // a single sample reports exactly its latency, not the top edge of its bucket + // (nominal-edge interpolation would report the bucket's upper reach instead). + var single forwardSummary + single.addSample(int64(20 * time.Millisecond)) + require.Equal(t, 20*time.Millisecond, single.percentile(0.9)) + + // identical samples share a bucket but must not invent intra-bucket spread. + var u forwardSummary + for i := 0; i < 8; i++ { + u.addSample(int64(2 * time.Millisecond)) + } + require.Equal(t, 2*time.Millisecond, u.percentile(0.5)) + require.Equal(t, 2*time.Millisecond, u.percentile(0.99)) +} + +func TestForwardSummary_ThresholdResolution(t *testing.T) { + // Nodes whose p90 packets cluster near a 300us overload threshold. + // Interpolating within each bucket's observed range reports a tight tail + // exactly, so each cluster lands on the correct side of 300us. + build := func(tailUs int64) forwardSummary { + var s forwardSummary + for i := 0; i < 850; i++ { + s.addSample(50 * int64(time.Microsecond)) + } + for i := 0; i < 150; i++ { + s.addSample(tailUs * int64(time.Microsecond)) + } + return s + } + + require.Less(t, build(265).percentile(0.9), 300*time.Microsecond) // below -> no trip + require.Greater(t, build(310).percentile(0.9), 300*time.Microsecond) // just above -> trips + require.Greater(t, build(500).percentile(0.9), 300*time.Microsecond) // well above -> trips + + // a tight tail is reported exactly, regardless of where it sits in the bucket. + require.Equal(t, 265*time.Microsecond, build(265).percentile(0.9)) + require.Equal(t, 310*time.Microsecond, build(310).percentile(0.9)) + require.Equal(t, 500*time.Microsecond, build(500).percentile(0.9)) } // --------------------------------------------------------------------------- @@ -284,29 +333,25 @@ func TestForwardStats_GetStats(t *testing.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++ { + // fold five 100ms buckets, one sample each, descending 5ms..1ms so trailing + // windows have distinct maxima. + for i := 5; i >= 1; 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)) + // whole window {1..5ms}: p90 clamps to the observed max, 5ms. + require.Equal(t, 5*time.Millisecond, s.GetStats(0)) + require.Equal(t, 5*time.Millisecond, s.GetStats(time.Second)) - // 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 covers only the two most recent buckets {2ms, 1ms}: a lower window + // than the full one, and above the single most-recent bucket. + require.Less(t, s.GetStats(200*time.Millisecond), 3*time.Millisecond) + require.Greater(t, s.GetStats(200*time.Millisecond), s.GetStats(time.Nanosecond)) - // ~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)) + // a sub-interval yields only the most recent bucket, ~1ms. + require.Equal(t, 1*time.Millisecond, s.GetStats(time.Nanosecond)) } func TestForwardStats_Lifecycle(t *testing.T) { diff --git a/pkg/sfu/pacer/base.go b/pkg/sfu/pacer/base.go index 96e3be250..b93362a00 100644 --- a/pkg/sfu/pacer/base.go +++ b/pkg/sfu/pacer/base.go @@ -58,7 +58,11 @@ func (b *Base) TimeSinceLastSentPacket() time.Duration { func (b *Base) SendPacket(p *Packet) (int, error) { defer func() { if p.HeaderPool != nil && p.Header != nil { + // keep Extensions capacity so the next user does not allocate on SetExtension + exts := p.Header.Extensions + clear(exts) *p.Header = rtp.Header{} + p.Header.Extensions = exts[:0] p.HeaderPool.Put(p.Header) } @@ -95,19 +99,18 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { absSendTimeExt := rtp.AbsSendTimeExtension{ Timestamp: uint64(mediatransportutil.ToNtpTime(sendingAt) >> 14), } - absSendTimeBytes, err := absSendTimeExt.Marshal() - if err != nil { + if _, err := absSendTimeExt.MarshalTo(p.absSendTimeBuf[:]); err != nil { return err } - if err = p.Header.SetExtension(p.AbsSendTimeExtID, absSendTimeBytes); err != nil { + if err := p.Header.SetExtension(p.AbsSendTimeExtID, p.absSendTimeBuf[:]); err != nil { return err } b.lastPacketSentAt.Store(sendingAt.UnixNano()) } - packetSize := p.HeaderSize + len(p.Payload) + packetSize := p.HeaderSize + len(p.Payload) + int(p.Header.PaddingSize) if p.TransportWideExtID != 0 && b.bwe != nil { twccSN := b.bwe.RecordPacketSendAndGetSequenceNumber( sendingAt.UnixMicro(), @@ -119,12 +122,11 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { twccExt := rtp.TransportCCExtension{ TransportSequence: twccSN, } - twccExtBytes, err := twccExt.Marshal() - if err != nil { + if _, err := twccExt.MarshalTo(p.twccBuf[:]); err != nil { return err } - if err = p.Header.SetExtension(p.TransportWideExtID, twccExtBytes); err != nil { + if err := p.Header.SetExtension(p.TransportWideExtID, p.twccBuf[:]); err != nil { return err } diff --git a/pkg/sfu/pacer/base_test.go b/pkg/sfu/pacer/base_test.go new file mode 100644 index 000000000..82599f049 --- /dev/null +++ b/pkg/sfu/pacer/base_test.go @@ -0,0 +1,109 @@ +// 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 pacer + +import ( + "bytes" + "sync" + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/sfu/bwe" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/livekit-server/pkg/sfu/utils" +) + +// bwe.NullBWE is meant to be embedded and lacks Type +type nullBWE struct{ *bwe.NullBWE } + +func (nullBWE) Type() bwe.BWEType { return bwe.BWETypeNone } + +// records extension sizes without allocating so AllocsPerRun measures only SendPacket +type extCheckWriter struct { + writes int + absSendTimeLen int + transportWideLen int +} + +func (w *extCheckWriter) WriteRTP(header *rtp.Header, _ []byte) (int, error) { + w.writes++ + w.absSendTimeLen += len(header.GetExtension(1)) + w.transportWideLen += len(header.GetExtension(2)) + return 0, nil +} + +func (w *extCheckWriter) Write(_ []byte) (int, error) { return 0, nil } + +// SendPacket patches abs-send-time and transport-cc into a pooled header, +// this must not allocate per packet +func TestSendPacketHeaderExtensionsNoAlloc(t *testing.T) { + b := NewBase(logger.GetLogger(), nullBWE{&bwe.NullBWE{}}) + headerPool := &sync.Pool{New: func() any { return &rtp.Header{} }} + w := &extCheckWriter{} + payload := make([]byte, 100) + + send := func() { + hdr := headerPool.Get().(*rtp.Header) + exts := hdr.Extensions[:0] + *hdr = rtp.Header{Version: 2, SequenceNumber: 1, Timestamp: 2, SSRC: 3} + hdr.Extensions = exts + + p := PacketFactory.Get().(*Packet) + *p = Packet{ + Header: hdr, + HeaderPool: headerPool, + HeaderSize: hdr.MarshalSize(), + Payload: payload, + AbsSendTimeExtID: 1, + TransportWideExtID: 2, + WriteStream: w, + } + _, err := b.SendPacket(p) + require.NoError(t, err) + } + + send() // warm the pools; AllocsPerRun also runs once before measuring + allocs := testing.AllocsPerRun(1000, send) + require.Equal(t, 1002, w.writes) + require.Equal(t, 3*w.writes, w.absSendTimeLen) + require.Equal(t, 2*w.writes, w.transportWideLen) + if !utils.RaceEnabled { + require.Equal(t, 0.0, allocs, "allocations per SendPacket") + } +} + +func TestHoldExtension(t *testing.T) { + p := &Packet{} + + ext := []byte{1, 2, 3} + held := p.HoldExtension(ext) + require.Equal(t, ext, held) + require.Same(t, &p.extBuf[0], &held[0], "the extension has to be held in the packet's scratch") + + ext[0] = 9 + require.EqualValues(t, 1, held[0], "the held copy has to be independent of the caller's slice") + + // exactly the one byte extension profile payload cap + exact := bytes.Repeat([]byte{7}, dd.MaxInlineExtensionSize) + held = p.HoldExtension(exact) + require.Equal(t, exact, held) + + // one byte more does not fit + require.Nil(t, p.HoldExtension(bytes.Repeat([]byte{7}, dd.MaxInlineExtensionSize+1))) +} diff --git a/pkg/sfu/pacer/pacer.go b/pkg/sfu/pacer/pacer.go index f23212519..6b89b6d1f 100644 --- a/pkg/sfu/pacer/pacer.go +++ b/pkg/sfu/pacer/pacer.go @@ -19,6 +19,7 @@ import ( "time" "github.com/livekit/livekit-server/pkg/sfu/ccutils" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/pion/rtp" "github.com/pion/webrtc/v4" ) @@ -54,6 +55,20 @@ type Packet struct { WriteStream webrtc.TrackLocalWriter Pool *sync.Pool PoolEntity *[]byte + + // per-packet scratch for header extensions patched at send time, + // the Packet is owned by one send until SendPacket returns + absSendTimeBuf [3]byte + twccBuf [2]byte + extBuf [dd.MaxInlineExtensionSize]byte +} + +// HoldExtension copies ext into scratch the header can point at until SendPacket returns, nil if it does not fit +func (p *Packet) HoldExtension(ext []byte) []byte { + if len(ext) > len(p.extBuf) { + return nil + } + return p.extBuf[:copy(p.extBuf[:], ext)] } type Pacer interface { diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index ecc08d04b..8b3492064 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -25,6 +25,7 @@ import ( "github.com/pion/webrtc/v4" "go.uber.org/atomic" + "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil/pkg/bucket" "github.com/livekit/mediatransportutil/pkg/codec" @@ -164,7 +165,7 @@ func NewReceiverBase(params ReceiverBaseParams, trackInfo *livekit.TrackInfo, co params: params, codecState: codecState, isRED: mime.IsMimeTypeStringRED(params.Codec.MimeType), - trackInfo: utils.CloneProto(trackInfo), + trackInfo: proto.CloneOf(trackInfo), videoLayerMode: buffer.GetVideoLayerModeForMimeType(mime.NormalizeMimeType(params.Codec.MimeType), trackInfo), } @@ -197,7 +198,7 @@ func (r *ReceiverBase) Close(reason string, clearBuffers bool) { } r.streamTrackerManager.Close() - closeTrackSenders(r.downTrackSpreader.ResetAndGetDownTracks()) + closeTrackSenders(r.downTrackSpreader.CloseAndGetDownTracks()) if rt := r.loadREDTransformer(); rt != nil { rt.Close() @@ -256,7 +257,7 @@ func (r *ReceiverBase) TrackInfo() *livekit.TrackInfo { r.bufferMu.RLock() defer r.bufferMu.RUnlock() - return utils.CloneProto(r.trackInfo) + return proto.CloneOf(r.trackInfo) } func (r *ReceiverBase) UpdateTrackInfo(ti *livekit.TrackInfo) { @@ -282,7 +283,7 @@ func (r *ReceiverBase) UpdateTrackInfo(ti *livekit.TrackInfo) { "shouldResync", shouldResync, ) } - r.trackInfo = utils.CloneProto(ti) + r.trackInfo = proto.CloneOf(ti) paused := r.trackInfo.GetMuted() for _, buff := range r.buffers { @@ -491,7 +492,9 @@ func (r *ReceiverBase) AddDownTrack(track TrackSender) error { track.UpTrackMaxPublishedLayerChange(r.streamTrackerManager.GetMaxPublishedLayer()) track.UpTrackMaxTemporalLayerSeenChange(r.streamTrackerManager.GetMaxTemporalLayerSeen()) - r.downTrackSpreader.Store(track) + if !r.downTrackSpreader.TryStore(track) { + return ErrReceiverClosed + } r.params.Logger.Debugw("downtrack added", "subscriberID", track.SubscriberID()) return nil } @@ -663,7 +666,7 @@ func (r *ReceiverBase) GetOrCreateBuffer(layer int32) buffer.BufferProvider { } r.bufferPromises[layer] = bp - ti := utils.CloneProto(r.trackInfo) + ti := proto.CloneOf(r.trackInfo) r.bufferMu.Unlock() defer close(bp.ready) @@ -948,7 +951,7 @@ func (r *ReceiverBase) forwardRTP( } spatialLayer := layer - if extPkt.Spatial >= 0 { + if extPkt.Spatial >= 0 && !sfuutils.IsSimulcastMode(r.videoLayerMode) { // svc packet, take spatial layer info from packet spatialLayer = extPkt.Spatial } @@ -962,12 +965,9 @@ func (r *ReceiverBase) forwardRTP( continue } - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(extPkt, spatialLayer)) - }) + writeCount := sfuutils.BroadcastRTP(r.downTrackSpreader, extPkt, spatialLayer) if rt := r.loadREDTransformer(); rt != nil { - writeCount.Add(rt.ForwardRTP(extPkt, spatialLayer)) + writeCount += rt.ForwardRTP(extPkt, spatialLayer) } // track delay/jitter @@ -977,13 +977,13 @@ func (r *ReceiverBase) forwardRTP( // 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 writeCount > 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", "latency", time.Duration(latency), "queuingLatency", time.Duration(dequeuedAt-extPkt.Arrival), - "writeCount", writeCount.Load(), + "writeCount", writeCount, "isOutOfOrder", extPkt.IsOutOfOrder, "layer", layer, ) diff --git a/pkg/sfu/receiver_test.go b/pkg/sfu/receiver_test.go index 3cfde0768..27e53ae78 100644 --- a/pkg/sfu/receiver_test.go +++ b/pkg/sfu/receiver_test.go @@ -23,8 +23,13 @@ import ( "testing" "github.com/gammazero/workerpool" + "github.com/pion/webrtc/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/atomic" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" ) func TestWebRTCReceiver_OnCloseHandler(t *testing.T) { @@ -52,6 +57,77 @@ func TestWebRTCReceiver_OnCloseHandler(t *testing.T) { } } +type receiverCloseTestSender struct { + TrackSender + subscriberID livekit.ParticipantID + upTrackMaxPublishedLayerChange func(int32) +} + +func (s *receiverCloseTestSender) SubscriberID() livekit.ParticipantID { + return s.subscriberID +} + +func (s *receiverCloseTestSender) UpTrackMaxPublishedLayerChange(layer int32) { + if s.upTrackMaxPublishedLayerChange != nil { + s.upTrackMaxPublishedLayerChange(layer) + } +} + +func (s *receiverCloseTestSender) UpTrackMaxTemporalLayerSeenChange(int32) {} + +func TestReceiverBaseAddDownTrackReturnsClosedWhenCloseWinsRace(t *testing.T) { + receiver := NewReceiverBase( + ReceiverBaseParams{ + TrackID: "track", + StreamID: "stream", + Kind: webrtc.RTPCodecTypeAudio, + Codec: webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + }, + PayloadType: 111, + }, + Logger: logger.GetLogger(), + }, + &livekit.TrackInfo{ + Sid: "track", + Type: livekit.TrackType_AUDIO, + Source: livekit.TrackSource_MICROPHONE, + }, + ReceiverCodecStateNormal, + ) + + enteredAdd := make(chan struct{}) + resumeAdd := make(chan struct{}) + track := &receiverCloseTestSender{ + subscriberID: "subscriber", + upTrackMaxPublishedLayerChange: func(int32) { + close(enteredAdd) + <-resumeAdd + }, + } + + errCh := make(chan error, 1) + go func() { + errCh <- receiver.AddDownTrack(track) + }() + + // AddDownTrack has passed its initial IsClosed check and is paused + // before reaching the final TryStore. + <-enteredAdd + + // Close terminally drains/closes the spreader before AddDownTrack resumes. + receiver.Close("test", false) + close(resumeAdd) + + err := <-errCh + require.ErrorIs(t, err, ErrReceiverClosed) + require.True(t, receiver.IsClosed()) + require.Empty(t, receiver.GetDownTracks()) +} + func BenchmarkWriteRTP(b *testing.B) { cases := []int{1, 2, 5, 10, 100, 250, 500} workers := runtime.NumCPU() diff --git a/pkg/sfu/redprimaryreceiver.go b/pkg/sfu/redprimaryreceiver.go index 17b639abb..fce6ece40 100644 --- a/pkg/sfu/redprimaryreceiver.go +++ b/pkg/sfu/redprimaryreceiver.go @@ -15,9 +15,6 @@ package sfu import ( - "encoding/binary" - "errors" - "go.uber.org/atomic" "github.com/pion/rtp" @@ -25,17 +22,13 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/utils" + "github.com/livekit/mediatransportutil/pkg/red" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) var _ REDTransformer = (*RedPrimaryReceiver)(nil) -var ( - ErrIncompleteRedHeader = errors.New("incomplete red block header") - ErrIncompleteRedBlock = errors.New("incomplete red block payload") -) - type RedPrimaryReceiver struct { TrackReceiver downTrackSpreader *utils.DownTrackSpreader[TrackSender] @@ -43,13 +36,13 @@ type RedPrimaryReceiver struct { closed atomic.Bool redPT uint8 - firstPktReceived bool - lastSeq uint16 + depacketizer red.Depacketizer highestForwardedExtSequenceNumber uint64 highestForwardedExtTimestamp uint64 - // bitset for upstream packet receive history [lastSeq-8, lastSeq-1], bit 1 represents packet received - pktHistory byte + // forwarded packet, reused since ForwardRTP runs on one goroutine and + // down tracks do not keep the packet past WriteRTP + sendExtPkt buffer.ExtPacket } func NewRedPrimaryReceiver(receiver TrackReceiver, dsp utils.DownTrackSpreaderParams) REDTransformer { @@ -73,22 +66,19 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 if pkt.Packet.PayloadType != r.redPT { // forward non-red packet directly - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(pkt, spatialLayer)) - }) - return writeCount.Load() + return utils.BroadcastRTP(r.downTrackSpreader, pkt, spatialLayer) } - pkts, err := r.getSendPktsFromRed(pkt.Packet) + pkts, err := r.depacketizer.Depacketize(pkt.Packet) if err != nil { r.logger.Errorw("get encoding for red failed", err, "payloadtype", pkt.Packet.PayloadType) return 0 } - var writeCount atomic.Int32 + var writeCount int32 for i, sendPkt := range pkts { - pPkt := *pkt + pPkt := &r.sendExtPkt + *pPkt = *pkt if i != len(pkts)-1 { // patch extended sequence number and time stamp for all but the last packet, // last packet is the primary payload @@ -114,10 +104,8 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 "packetESN", pPkt.ExtSequenceNumber, "packetExtractedTS", pkts[i].Timestamp, "packetETS", pPkt.ExtTimestamp, - "pktHistory", r.pktHistory, "redHeader", pkt.Packet.Payload[:min(len(pkt.Packet.Payload), 10)], "payloadSize", len(pkt.Packet.Payload), - "lastSeq", r.lastSeq, "highestFowardedExtSequenceNumber", r.highestForwardedExtSequenceNumber, "highestFowardedExtTimestamp", r.highestForwardedExtTimestamp, ) @@ -129,11 +117,9 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 // not modify the ExtPacket.RawPacket here for performance since it is not used by the DownTrack, // otherwise it should be set to the correct value (marshal the primary rtp packet) - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(&pPkt, spatialLayer)) - }) + writeCount += utils.BroadcastRTP(r.downTrackSpreader, pPkt, spatialLayer) } - return writeCount.Load() + return writeCount } func (r *RedPrimaryReceiver) ForwardRTCPSenderReport( @@ -155,7 +141,9 @@ func (r *RedPrimaryReceiver) AddDownTrack(track TrackSender) error { r.logger.Infow("subscriberID already exists, replacing downtrack", "subscriberID", track.SubscriberID()) } - r.downTrackSpreader.Store(track) + if !r.downTrackSpreader.TryStore(track) { + return ErrReceiverClosed + } r.logger.Debugw("red primary receiver downtrack added", "subscriberID", track.SubscriberID()) return nil } @@ -195,7 +183,7 @@ func (r *RedPrimaryReceiver) CanClose() bool { func (r *RedPrimaryReceiver) Close() { r.closed.Store(true) - closeTrackSenders(r.downTrackSpreader.ResetAndGetDownTracks()) + closeTrackSenders(r.downTrackSpreader.CloseAndGetDownTracks()) } func (r *RedPrimaryReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, error) { @@ -206,7 +194,7 @@ func (r *RedPrimaryReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, var pkt rtp.Packet pkt.Unmarshal(buf[:n]) - payload, err := extractPrimaryEncodingForRED(pkt.Payload) + payload, err := red.ExtractPrimaryEncoding(pkt.Payload) if err != nil { return 0, err } @@ -214,173 +202,3 @@ func (r *RedPrimaryReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, return pkt.MarshalTo(buf) } - -func (r *RedPrimaryReceiver) getSendPktsFromRed(rtp *rtp.Packet) ([]*rtp.Packet, error) { - var needRecover bool - if !r.firstPktReceived { - r.lastSeq = rtp.SequenceNumber - r.pktHistory = 0 - r.firstPktReceived = true - } else { - diff := rtp.SequenceNumber - r.lastSeq - switch { - case diff == 0: // duplicate - break - - case diff > 0x8000: // unorder - // in history - if 65535-diff < 8 { - r.pktHistory |= 1 << (65535 - diff) - needRecover = true - } - - case diff > 8: // long jump - r.lastSeq = rtp.SequenceNumber - r.pktHistory = 0 - needRecover = true - - default: - r.lastSeq = rtp.SequenceNumber - r.pktHistory = (r.pktHistory << byte(diff)) | 1<<(diff-1) - needRecover = true - } - } - - var recoverBits byte - if needRecover { - bitIndex := r.lastSeq - rtp.SequenceNumber - for i := range maxRedCount { - if bitIndex > 7 { - break - } - if r.pktHistory&byte(1<>= 10 - tsOffset := blockHead & 0x3FFF - blockHead >>= 14 - pt := uint8(blockHead & 0x7F) - - blocks = append(blocks, block{pt: pt, length: length, tsOffset: tsOffset}) - - blockLength += length - payload = payload[4:] - } - } - - if len(payload) < blockLength { - return nil, ErrIncompleteRedBlock - } - - pkts := make([]*rtp.Packet, 0, len(blocks)) - for i, b := range blocks { - if b.primary { - header := redPkt.Header - header.PayloadType = b.pt - pkts = append(pkts, &rtp.Packet{Header: header, Payload: payload}) - break - } - - recoverIndex := len(blocks) - i - 1 - if recoverIndex < 1 || recoverBits&(1<<(recoverIndex-1)) == 0 { - // skip past packet/block that does not need recovery - payload = payload[b.length:] - continue - } - - // recover missing packet - header := redPkt.Header - header.SequenceNumber -= uint16(recoverIndex) - header.Timestamp -= b.tsOffset - header.PayloadType = b.pt - pkts = append(pkts, &rtp.Packet{Header: header, Payload: payload[:b.length]}) - - payload = payload[b.length:] - } - - return pkts, nil -} - -func extractPrimaryEncodingForRED(payload []byte) ([]byte, error) { - - /* RED payload https://datatracker.ietf.org/doc/html/rfc2198#section-3 - 0 1 2 3 - 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |F| block PT | timestamp offset | block length | - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - F: 1 bit First bit in header indicates whether another header block - follows. If 1 further header blocks follow, if 0 this is the - last header block. - */ - - var blockLength int - for { - if len(payload) < 1 { - // illegal data, need at least one byte for primary encoding - return nil, ErrIncompleteRedHeader - } - - if payload[0]&0x80 == 0 { - // last block is primary encoding data - payload = payload[1:] - break - } else { - if len(payload) < 4 { - // illegal data - return nil, ErrIncompleteRedHeader - } - - blockLength += int(binary.BigEndian.Uint16(payload[2:]) & 0x03FF) - payload = payload[4:] - } - } - - if len(payload) < blockLength { - return nil, ErrIncompleteRedBlock - } - - return payload[blockLength:], nil -} diff --git a/pkg/sfu/redreceiver.go b/pkg/sfu/redreceiver.go index 959a54e96..1788c9ef3 100644 --- a/pkg/sfu/redreceiver.go +++ b/pkg/sfu/redreceiver.go @@ -17,6 +17,7 @@ package sfu import ( "encoding/binary" "fmt" + "slices" "go.uber.org/atomic" @@ -50,7 +51,15 @@ type RedReceiver struct { logger logger.Logger closed atomic.Bool pktBuff [maxRedCount]*rtp.Packet - redPayloadBuf [mtuSize]byte + // copies that pktBuff points at, as a forwarded packet's payload is in a read buffer reused for the next packet, + // one more slot than pktBuff so that a new copy never overwrites a redundant block of the packet being encoded + histPkts [maxRedCount + 1]rtp.Packet + histPayloads [maxRedCount + 1][maxRedPayload]byte + redPayloadBuf [mtuSize]byte + // forwarded packet, reused like redPayloadBuf since ForwardRTP runs on one goroutine + // and down tracks do not keep the packet past WriteRTP + redExtPkt buffer.ExtPacket + redRtpPkt rtp.Packet } func NewRedReceiver(receiver TrackReceiver, dsp utils.DownTrackSpreaderParams) REDTransformer { @@ -73,11 +82,7 @@ func (r *RedReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int32) int3 // fallback to primary codec if payload size exceeds redundant block length if len(pkt.Packet.Payload) >= maxRedPayload { - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(pkt, spatialLayer)) - }) - return writeCount.Load() + return utils.BroadcastRTP(r.downTrackSpreader, pkt, spatialLayer) } redLen, err := r.encodeRedForPrimary(pkt.Packet, r.redPayloadBuf[:]) @@ -86,19 +91,17 @@ func (r *RedReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int32) int3 return 0 } - pPkt := *pkt - redRtpPacket := *pkt.Packet + redRtpPacket := &r.redRtpPkt + *redRtpPacket = *pkt.Packet redRtpPacket.PayloadType = opusRedPT redRtpPacket.Payload = r.redPayloadBuf[:redLen] - pPkt.Packet = &redRtpPacket + pPkt := &r.redExtPkt + *pPkt = *pkt + pPkt.Packet = redRtpPacket // not modify the ExtPacket.RawPacket here for performance since it is not used by the DownTrack, // otherwise it should be set to the correct value (marshal the primary rtp packet) - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(&pPkt, spatialLayer)) - }) - return writeCount.Load() + return utils.BroadcastRTP(r.downTrackSpreader, pPkt, spatialLayer) } func (r *RedReceiver) ForwardRTCPSenderReport( @@ -120,7 +123,9 @@ func (r *RedReceiver) AddDownTrack(track TrackSender) error { r.logger.Infow("subscriberID already exists, replacing downtrack", "subscriberID", track.SubscriberID()) } - r.downTrackSpreader.Store(track) + if !r.downTrackSpreader.TryStore(track) { + return ErrReceiverClosed + } r.logger.Debugw("red receiver downtrack added", "subscriberID", track.SubscriberID()) return nil } @@ -160,7 +165,7 @@ func (r *RedReceiver) IsClosed() bool { func (r *RedReceiver) Close() { r.closed.Store(true) - closeTrackSenders(r.downTrackSpreader.ResetAndGetDownTracks()) + closeTrackSenders(r.downTrackSpreader.CloseAndGetDownTracks()) } func (r *RedReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, error) { @@ -189,17 +194,22 @@ func (r *RedReceiver) encodeRedForPrimary(pkt *rtp.Packet, redPayload []byte) (i redPkts = append(redPkts, prev) } - // insert primary packet in history buffer - // NOTE: packet is copied from retransmission buffer and used in forwarding path. So, not making another - // copy here and just maintaining pointer to the packet as the forwarding path should not alter the packet. + // insert a copy of the primary packet in history buffer for i := redLength - 1; i >= 0; i-- { if r.pktBuff[i] == nil || // history is empty pkt.SequenceNumber-r.pktBuff[i].SequenceNumber < (1<<15) { // received packet has more recent sequence number + // pick the slot before aging out, as the aged out packet can be a redundant block of this packet + slot := r.freeHistSlot() // age out older ones for j := 0; j < i; j++ { r.pktBuff[j] = r.pktBuff[j+1] } - r.pktBuff[i] = pkt + n := copy(r.histPayloads[slot][:], pkt.Payload) + r.histPkts[slot] = rtp.Packet{ + Header: rtp.Header{SequenceNumber: pkt.SequenceNumber, Timestamp: pkt.Timestamp}, + Payload: r.histPayloads[slot][:n], + } + r.pktBuff[i] = &r.histPkts[slot] break } } @@ -207,6 +217,16 @@ func (r *RedReceiver) encodeRedForPrimary(pkt *rtp.Packet, redPayload []byte) (i return encodeRedForPrimary(redPkts, pkt, redPayload) } +// freeHistSlot returns a history slot that pktBuff does not point at, there is always one as there is a spare slot +func (r *RedReceiver) freeHistSlot() int { + for slot := range r.histPkts { + if !slices.Contains(r.pktBuff[:], &r.histPkts[slot]) { + return slot + } + } + return 0 +} + func encodeRedForPrimary(redPkts []*rtp.Packet, primary *rtp.Packet, redPayload []byte) (int, error) { payloadSize := len(primary.Payload) + 1 for _, p := range redPkts { diff --git a/pkg/sfu/redreceiver_test.go b/pkg/sfu/redreceiver_test.go index 06df4974c..8082b0b17 100644 --- a/pkg/sfu/redreceiver_test.go +++ b/pkg/sfu/redreceiver_test.go @@ -15,6 +15,7 @@ package sfu import ( + "bytes" "testing" "github.com/pion/rtp" @@ -22,6 +23,8 @@ import ( "github.com/stretchr/testify/require" "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/mediatransportutil/pkg/bucket" + "github.com/livekit/mediatransportutil/pkg/red" "github.com/livekit/protocol/logger" ) @@ -234,14 +237,63 @@ func TestRedReceiver(t *testing.T) { }) } -func verifyRedEncodings(t *testing.T, red *rtp.Packet, redPkts []*rtp.Packet) { +// reads every packet into one reused buffer, the way ReceiverBase.forwardRTP does +func TestRedReceiverReusedReadBuffer(t *testing.T) { + opus := webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2}, + PayloadType: 111, + } + buff := buffer.NewBuffer(0x5678, 500, 200) + require.NoError(t, buff.Bind(webrtc.RTPParameters{Codecs: []webrtc.RTPCodecParameters{opus}}, opus.RTPCodecCapability, 0)) + + w := &WebRTCReceiver{ + ReceiverBase: &ReceiverBase{ + params: ReceiverBaseParams{ + Kind: webrtc.RTPCodecTypeAudio, + Logger: logger.GetLogger(), + }, + }, + } + rr := w.GetRedReceiver().(*RedReceiver) + dt := &dummyDowntrack{TrackSender: &DownTrack{}} + require.NoError(t, rr.AddDownTrack(dt)) + + var payloads [][]byte + pktBuf := make([]byte, bucket.RTPMaxPktSize) + for i := range 6 { + payload := bytes.Repeat([]byte{byte(0x10 * (i + 1))}, 60) + payloads = append(payloads, payload) + pkt := rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 111, SequenceNumber: uint16(100 + i), Timestamp: uint32(960 * i), SSRC: 0x5678}, + Payload: payload, + } + raw, err := pkt.Marshal() + require.NoError(t, err) + _, err = buff.Write(raw) + require.NoError(t, err) + extPkt, err := buff.ReadExtended(pktBuf) + require.NoError(t, err) + rr.ForwardRTP(extPkt, 0) + buffer.ReleaseExtPacket(extPkt) + } + + // the last RED packet carries the two packets before it as redundant blocks + blocks, err := red.ExtractPackets(dt.lastReceivedPkt, 0xFF) + require.NoError(t, err) + require.Len(t, blocks, 3) + for i, block := range blocks { + require.Equal(t, payloads[3+i], block.Payload) + } +} + +func verifyRedEncodings(t *testing.T, redPkt *rtp.Packet, redPkts []*rtp.Packet) { solidPkts := make([]*rtp.Packet, 0, len(redPkts)) for _, pkt := range redPkts { if pkt != nil { solidPkts = append(solidPkts, pkt) } } - pktsFromRed, err := extractPktsFromRed(red, 0xFF) + pktsFromRed, err := red.ExtractPackets(redPkt, 0xFF) require.NoError(t, err) require.Len(t, pktsFromRed, len(solidPkts)) for i, pkt := range pktsFromRed { @@ -442,7 +494,7 @@ func TestExtractPrimaryEncodingForRED(t *testing.T) { primaryPkts := make([]*rtp.Packet, 0, len(redPkts)) for _, redPkt := range redPkts { - payload, err := extractPrimaryEncodingForRED(redPkt.Payload) + payload, err := red.ExtractPrimaryEncoding(redPkt.Payload) require.NoError(t, err) primaryHeader := redPkt.Header primaryHeader.PayloadType = 111 diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go index 293482396..3a1bb4760 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go @@ -15,6 +15,7 @@ package dependencydescriptor import ( + "errors" "fmt" "math" "strconv" @@ -23,6 +24,10 @@ import ( // DependencyDescriptorExtension is a extension payload format in // https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension +// ErrBufferTooSmall is returned by MarshalTo when the caller's storage cannot +// hold the marshaled extension. +var ErrBufferTooSmall = errors.New("dependency descriptor buffer too small") + func formatBitmask(b *uint32) string { if b == nil { return "-" @@ -42,18 +47,48 @@ func (d *DependencyDescriptorExtension) Marshal() ([]byte, error) { } func (d *DependencyDescriptorExtension) MarshalWithActiveChains(activeChains uint32) ([]byte, error) { - writer, err := NewDependencyDescriptorWriter(nil, d.Structure, activeChains, d.Descriptor) + writer, size, err := d.newWriter(activeChains) if err != nil { return nil, err } - buf := make([]byte, int(math.Ceil(float64(writer.ValueSizeBits())/8))) - writer.ResetBuf(buf) - if err = writer.Write(); err != nil { + + buf := make([]byte, size) + writer = writer.withBuf(buf) + if err := writer.Write(); err != nil { return nil, err } return buf, nil } +// MarshalTo marshals into caller owned storage and returns the number of bytes +// written, or ErrBufferTooSmall, leaving buf untouched, if the extension does not fit. +func (d *DependencyDescriptorExtension) MarshalTo(buf []byte) (int, error) { + writer, size, err := d.newWriter(^uint32(0)) + if err != nil { + return 0, err + } + if len(buf) < size { + // bare sentinel, key frames take this path and formatting an error there would allocate + return 0, ErrBufferTooSmall + } + + writer = writer.withBuf(buf[:size]) + if err := writer.Write(); err != nil { + return 0, err + } + return size, nil +} + +// newWriter returns a writer for the extension, by value so that it stays on the +// caller's stack, along with the number of bytes the marshaled extension needs. +func (d *DependencyDescriptorExtension) newWriter(activeChains uint32) (DependencyDescriptorWriter, int, error) { + writer := newDependencyDescriptorWriter(nil, d.Structure, activeChains, d.Descriptor) + if err := writer.findBestTemplate(); err != nil { + return writer, 0, err + } + return writer, int(math.Ceil(float64(writer.ValueSizeBits()) / 8)), nil +} + func (d *DependencyDescriptorExtension) Unmarshal(buf []byte) (int, error) { reader := NewDependencyDescriptorReader(buf, d.Structure, d.Descriptor) return reader.Parse() @@ -67,6 +102,9 @@ const ( MaxDecodeTargets = 32 MaxTemplates = 64 + // MaxInlineExtensionSize is the inline storage the forwarding path and the pacer reserve for a marshaled extension, pion's one byte profile cap + MaxInlineExtensionSize = 16 + AllChainsAreActive = uint32(0) ExtensionURI = "https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension" @@ -75,9 +113,10 @@ const ( // ------------------------------------------------------------------------------ type DependencyDescriptor struct { - FirstPacketInFrame bool - LastPacketInFrame bool - FrameNumber uint16 + FirstPacketInFrame bool + LastPacketInFrame bool + FrameNumber uint16 + // Unmarshal writes into FrameDependencies and Resolution when they are set, else it allocates them FrameDependencies *FrameDependencyTemplate Resolution *RenderResolution ActiveDecodeTargetsBitmask *uint32 @@ -163,6 +202,15 @@ func (t *FrameDependencyTemplate) Clone() *FrameDependencyTemplate { return t2 } +// CopyInto copies t into dst and reuses the slices of dst +func (t *FrameDependencyTemplate) CopyInto(dst *FrameDependencyTemplate) { + dst.SpatialId = t.SpatialId + dst.TemporalId = t.TemporalId + dst.DecodeTargetIndications = append(dst.DecodeTargetIndications[:0], t.DecodeTargetIndications...) + dst.FrameDiffs = append(dst.FrameDiffs[:0], t.FrameDiffs...) + dst.ChainDiffs = append(dst.ChainDiffs[:0], t.ChainDiffs...) +} + // ------------------------------------------------------------------------------ type FrameDependencyStructure struct { diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.go new file mode 100644 index 000000000..6e9509c74 --- /dev/null +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.go @@ -0,0 +1,247 @@ +// 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 dependencydescriptor + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/utils" +) + +// dependencyDescriptorMarshalFixture is a dependency descriptor captured from +// traffic. The fixture helper decodes its attached structure once, then returns +// a regular per-packet descriptor that references that structure. +const dependencyDescriptorMarshalFixture = "c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d" + +func newDependencyDescriptorMarshalFixture(tb testing.TB) *DependencyDescriptorExtension { + tb.Helper() + + buf, err := hex.DecodeString(dependencyDescriptorMarshalFixture) + require.NoError(tb, err) + + descriptor := DependencyDescriptor{} + parser := DependencyDescriptorExtension{Descriptor: &descriptor} + _, err = parser.Unmarshal(buf) + require.NoError(tb, err) + require.NotNil(tb, descriptor.AttachedStructure, "fixture did not contain a dependency structure") + + structure := descriptor.AttachedStructure + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + + return &DependencyDescriptorExtension{ + Descriptor: &descriptor, + Structure: structure, + } +} + +func checkDependencyDescriptorMarshal(tb testing.TB, buf []byte, structure *FrameDependencyStructure, frameNumber uint16) { + tb.Helper() + + require.NotEmpty(tb, buf, "marshal returned an empty dependency descriptor") + + decoded := DependencyDescriptor{} + parser := DependencyDescriptorExtension{ + Descriptor: &decoded, + Structure: structure, + } + _, err := parser.Unmarshal(buf) + require.NoError(tb, err) + require.Equal(tb, frameNumber, decoded.FrameNumber) +} + +func TestDependencyDescriptorMarshalRoundTrip(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + + first, err := extension.Marshal() + require.NoError(t, err) + firstCopy := append([]byte(nil), first...) + checkDependencyDescriptorMarshal(t, first, extension.Structure, extension.Descriptor.FrameNumber) + + extension.Descriptor.FrameNumber++ + second, err := extension.Marshal() + require.NoError(t, err) + checkDependencyDescriptorMarshal(t, second, extension.Structure, extension.Descriptor.FrameNumber) + + require.Equal(t, firstCopy, first, "a later marshal modified a previously returned buffer") + require.NotEqual(t, first, second, "different frame numbers produced identical descriptors") +} + +func TestDependencyDescriptorMarshalTo(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + + // every template of the fixture structure, i. e. the per-packet descriptors + // the forwarding path marshals, has to match Marshal byte for byte and fit + // the inline size the forwarding path reserves + for i, template := range extension.Structure.Templates { + extension.Descriptor.FrameDependencies = template + extension.Descriptor.FrameNumber = uint16(i) + + want, err := extension.Marshal() + require.NoError(t, err) + require.LessOrEqual(t, len(want), MaxInlineExtensionSize) + + var scratch [MaxInlineExtensionSize]byte + n, err := extension.MarshalTo(scratch[:]) + require.NoError(t, err) + require.Equal(t, want, scratch[:n]) + checkDependencyDescriptorMarshal(t, scratch[:n], extension.Structure, uint16(i)) + } +} + +func TestDependencyDescriptorMarshalToBufferTooSmall(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + + want, err := extension.Marshal() + require.NoError(t, err) + + _, err = extension.MarshalTo(make([]byte, len(want)-1)) + require.ErrorIs(t, err, ErrBufferTooSmall) + + n, err := extension.MarshalTo(make([]byte, len(want))) + require.NoError(t, err) + require.Equal(t, len(want), n) + + // a descriptor carrying the full dependency structure does not fit inline + extension.Descriptor.AttachedStructure = extension.Structure + _, err = extension.MarshalTo(make([]byte, MaxInlineExtensionSize)) + require.ErrorIs(t, err, ErrBufferTooSmall) + + keyFrame, err := extension.Marshal() + require.NoError(t, err) + require.Greater(t, len(keyFrame), MaxInlineExtensionSize) +} + +// TestDependencyDescriptorMarshalToLargerDescriptors covers the 8 to 16 byte +// band, where the active decode targets bitmask and custom frame dependencies +// land and where the sequencer's own inline copy already spills. +func TestDependencyDescriptorMarshalToLargerDescriptors(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + bitmask := uint32(0x1ff) + + // the widest per-packet descriptor of the fixture, with the bitmask attached + widest := 0 + for _, template := range extension.Structure.Templates { + extension.Descriptor.FrameDependencies = template + extension.Descriptor.ActiveDecodeTargetsBitmask = &bitmask + + want, err := extension.Marshal() + require.NoError(t, err) + widest = max(widest, len(want)) + + var scratch [MaxInlineExtensionSize]byte + n, err := extension.MarshalTo(scratch[:]) + require.NoError(t, err) + require.Equal(t, want, scratch[:n]) + } + require.GreaterOrEqual(t, widest, 8) + + // custom frame diffs and chain diffs, i. e. a frame that matches no template + custom := extension.Structure.Templates[0].Clone() + custom.FrameDiffs = []int{1, 300, 4000} + for i := range custom.ChainDiffs { + custom.ChainDiffs[i] = 200 + i + } + extension.Descriptor.FrameDependencies = custom + extension.Descriptor.ActiveDecodeTargetsBitmask = nil + + want, err := extension.Marshal() + require.NoError(t, err) + require.GreaterOrEqual(t, len(want), 8) + require.LessOrEqual(t, len(want), MaxInlineExtensionSize) + + var scratch [MaxInlineExtensionSize]byte + n, err := extension.MarshalTo(scratch[:]) + require.NoError(t, err) + require.Equal(t, want, scratch[:n]) + checkDependencyDescriptorMarshal(t, scratch[:n], extension.Structure, extension.Descriptor.FrameNumber) +} + +func TestDependencyDescriptorMarshalAllocs(t *testing.T) { + if utils.RaceEnabled { + // the race detector perturbs allocation counts, see #4874 + t.Skip("allocation count is not meaningful under the race detector") + } + + extension := newDependencyDescriptorMarshalFixture(t) + + var ( + scratch [MaxInlineExtensionSize]byte + n int + err error + ) + allocs := testing.AllocsPerRun(100, func() { + n, err = extension.MarshalTo(scratch[:]) + }) + require.NoError(t, err) + require.NotZero(t, n) + require.Zero(t, allocs, "a per-packet descriptor has to marshal without allocating") + + // a key frame descriptor carries the full dependency structure, does not fit + // the inline scratch and spills to exactly one allocation, the Marshal slice + extension.Descriptor.AttachedStructure = extension.Structure + + var buf []byte + allocs = testing.AllocsPerRun(100, func() { + if n, err = extension.MarshalTo(scratch[:]); err != nil { + buf, err = extension.Marshal() + } + }) + require.NoError(t, err) + require.Greater(t, len(buf), MaxInlineExtensionSize) + require.Equal(t, float64(1), allocs, "the spill path has to allocate only the Marshal slice") +} + +func BenchmarkDependencyDescriptorMarshal(b *testing.B) { + extension := newDependencyDescriptorMarshalFixture(b) + frameNumber := extension.Descriptor.FrameNumber + + b.ReportAllocs() + var buf []byte + for b.Loop() { + extension.Descriptor.FrameNumber = frameNumber + frameNumber++ + + var err error + buf, err = extension.Marshal() + require.NoError(b, err) + } + + checkDependencyDescriptorMarshal(b, buf, extension.Structure, frameNumber-1) +} + +func BenchmarkDependencyDescriptorMarshalTo(b *testing.B) { + extension := newDependencyDescriptorMarshalFixture(b) + frameNumber := extension.Descriptor.FrameNumber + + b.ReportAllocs() + var ( + scratch [MaxInlineExtensionSize]byte + n int + ) + for b.Loop() { + extension.Descriptor.FrameNumber = frameNumber + frameNumber++ + + var err error + n, err = extension.MarshalTo(scratch[:]) + require.NoError(b, err) + } + + checkDependencyDescriptorMarshal(b, scratch[:n], extension.Structure, frameNumber-1) +} diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go index 95c3aba35..58efec953 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go @@ -17,6 +17,8 @@ package dependencydescriptor import ( "encoding/hex" "testing" + + "github.com/stretchr/testify/require" ) func TestDependencyDescriptorUnmarshal(t *testing.T) { @@ -63,3 +65,80 @@ func TestDependencyDescriptorUnmarshal(t *testing.T) { t.Log(ddVal.String()) } } + +// Unmarshal into a descriptor whose FrameDependencies and Resolution are set writes into that +// storage, gives the same result as a fresh descriptor, and leaves the structure's templates intact +func TestDependencyDescriptorUnmarshalIntoStorage(t *testing.T) { + hexes := []string{ + "c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d", + "86017340fc", + "46017340fc", + "c3017540fc", + "88017640fc", + "48017640fc", + "c2017840fc", + } + + var structure *FrameDependencyStructure + var fd FrameDependencyTemplate + var res RenderResolution + for _, h := range hexes { + buf, err := hex.DecodeString(h) + require.NoError(t, err) + + var fresh DependencyDescriptor + _, err = (&DependencyDescriptorExtension{Structure: structure, Descriptor: &fresh}).Unmarshal(buf) + require.NoError(t, err) + + reused := DependencyDescriptor{FrameDependencies: &fd, Resolution: &res} + _, err = (&DependencyDescriptorExtension{Structure: structure, Descriptor: &reused}).Unmarshal(buf) + require.NoError(t, err) + + require.Same(t, &fd, reused.FrameDependencies) + require.Equal(t, fresh.FrameDependencies, reused.FrameDependencies) + require.Equal(t, fresh.Resolution, reused.Resolution) + require.Equal(t, fresh.String(), reused.String()) + + if fresh.AttachedStructure != nil { + structure = fresh.AttachedStructure + } + } + + // the templates are copied out, not shared + for _, template := range structure.Templates { + require.NotSame(t, template, &fd) + } +} + +// a caller may hand Unmarshal a descriptor that still points at one of the structure's templates, +// custom fields of the packet must not change that template +func TestDependencyDescriptorUnmarshalDoesNotWriteIntoTemplate(t *testing.T) { + fixture := newDependencyDescriptorMarshalFixture(t) + structure := fixture.Structure + + // a per-packet descriptor using a template with frame diffs, with one custom diff + var template *FrameDependencyTemplate + for _, tmpl := range structure.Templates { + if len(tmpl.FrameDiffs) > 0 { + template = tmpl + break + } + } + require.NotNil(t, template) + want := append([]int(nil), template.FrameDiffs...) + + custom := template.Clone() + custom.FrameDiffs[0] += 2 + buf, err := (&DependencyDescriptorExtension{ + Descriptor: &DependencyDescriptor{FrameNumber: 7, FrameDependencies: custom}, + Structure: structure, + }).Marshal() + require.NoError(t, err) + + parsed := DependencyDescriptor{FrameDependencies: template} + _, err = (&DependencyDescriptorExtension{Structure: structure, Descriptor: &parsed}).Unmarshal(buf) + require.NoError(t, err) + require.Equal(t, custom.FrameDiffs, parsed.FrameDependencies.FrameDiffs) + require.NotSame(t, template, parsed.FrameDependencies) + require.Equal(t, want, template.FrameDiffs) +} diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go index b3baf6f36..0b7d8ac0a 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go @@ -16,6 +16,7 @@ package dependencydescriptor import ( "errors" + "slices" ) var ( @@ -356,8 +357,14 @@ func (r *DependencyDescriptorReader) readFrameDependencyDefinition() error { return ErrDDReaderInvalidTemplateIndex } - // Copy all the fields from the matching template - r.descriptor.FrameDependencies = r.structure.Templates[templateIndex].Clone() + // Copy all the fields from the matching template into caller storage, + // never into one of the structure's templates, custom fields would change the template + dst := r.descriptor.FrameDependencies + if dst == nil || slices.Contains(r.structure.Templates, dst) { + dst = &FrameDependencyTemplate{} + r.descriptor.FrameDependencies = dst + } + r.structure.Templates[templateIndex].CopyInto(dst) if r.customDtisFlag { err := r.readFrameDtis() @@ -389,8 +396,10 @@ func (r *DependencyDescriptorReader) readFrameDependencyDefinition() error { r.buffer.Invalidate() return ErrDDReaderInvalidSpatialLayer } - res := r.structure.Resolutions[r.descriptor.FrameDependencies.SpatialId] - r.descriptor.Resolution = &res + if r.descriptor.Resolution == nil { + r.descriptor.Resolution = &RenderResolution{} + } + *r.descriptor.Resolution = r.structure.Resolutions[r.descriptor.FrameDependencies.SpatialId] } return nil diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go index 3c6ba720d..fac78f036 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go @@ -33,23 +33,28 @@ type DependencyDescriptorWriter struct { descriptor *DependencyDescriptor structure *FrameDependencyStructure activeChains uint32 - writer *BitStreamWriter + writer BitStreamWriter bestTemplate TemplateMatch } -func NewDependencyDescriptorWriter(buf []byte, structure *FrameDependencyStructure, activeChains uint32, descriptor *DependencyDescriptor) (*DependencyDescriptorWriter, error) { - writer := NewBitStreamWriter(buf) - w := &DependencyDescriptorWriter{ +func newDependencyDescriptorWriter(buf []byte, structure *FrameDependencyStructure, activeChains uint32, descriptor *DependencyDescriptor) DependencyDescriptorWriter { + return DependencyDescriptorWriter{ descriptor: descriptor, structure: structure, activeChains: activeChains, - writer: writer, + writer: BitStreamWriter{buf: buf}, } - return w, w.findBestTemplate() } -func (w *DependencyDescriptorWriter) ResetBuf(buf []byte) { - w.writer = NewBitStreamWriter(buf) +func NewDependencyDescriptorWriter(buf []byte, structure *FrameDependencyStructure, activeChains uint32, descriptor *DependencyDescriptor) (*DependencyDescriptorWriter, error) { + w := newDependencyDescriptorWriter(buf, structure, activeChains, descriptor) + return &w, w.findBestTemplate() +} + +// withBuf returns a copy of the writer that writes into buf, value receiver so a caller owned buffer does not escape +func (w DependencyDescriptorWriter) withBuf(buf []byte) DependencyDescriptorWriter { + w.writer = BitStreamWriter{buf: buf} + return w } func (w *DependencyDescriptorWriter) Write() error { diff --git a/pkg/sfu/rtpstats/rtpstats_base.go b/pkg/sfu/rtpstats/rtpstats_base.go index b14a92b8b..fdd92e89e 100644 --- a/pkg/sfu/rtpstats/rtpstats_base.go +++ b/pkg/sfu/rtpstats/rtpstats_base.go @@ -19,6 +19,7 @@ import ( "time" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/livekit/mediatransportutil" @@ -193,11 +194,7 @@ func (w WrappedRTCPSenderReportStateLogger) MarshalLogObject(e zapcore.ObjectEnc // ------------------------------------------------------------------ -func RTCPSenderReportPropagationDelay(rsrs *livekit.RTCPSenderReportState, passThrough bool) time.Duration { - if passThrough { - return 0 - } - +func RTCPSenderReportPropagationDelay(rsrs *livekit.RTCPSenderReportState) time.Duration { return time.Unix(0, rsrs.AtAdjusted).Sub(mediatransportutil.NtpTime(rsrs.NtpTimestamp).Time()) } @@ -302,8 +299,8 @@ func (r *rtpStatsBase) seed(from *rtpStatsBase) bool { r.rtt = from.rtt r.maxRtt = from.maxRtt - r.srFirst = utils.CloneProto(from.srFirst) - r.srNewest = utils.CloneProto(from.srNewest) + r.srFirst = proto.CloneOf(from.srFirst) + r.srNewest = proto.CloneOf(from.srNewest) r.nextSnapshotID = from.nextSnapshotID r.snapshots = make([]snapshot, cap(from.snapshots)) diff --git a/pkg/sfu/rtpstats/rtpstats_receiver.go b/pkg/sfu/rtpstats/rtpstats_receiver.go index dfd127397..193e44363 100644 --- a/pkg/sfu/rtpstats/rtpstats_receiver.go +++ b/pkg/sfu/rtpstats/rtpstats_receiver.go @@ -22,6 +22,7 @@ import ( "github.com/pion/rtcp" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil" "github.com/livekit/mediatransportutil/pkg/latency" @@ -654,7 +655,7 @@ func (r *RTPStatsReceiver) getExtendedSenderReport(srData *livekit.RTCPSenderRep } } - srDataExt := protoutils.CloneProto(srData) + srDataExt := proto.CloneOf(srData) srDataExt.RtpTimestampExt = uint64(srDataExt.RtpTimestamp) + tsCycles return srDataExt } @@ -816,15 +817,16 @@ func (r *RTPStatsReceiver) GetRtcpSenderReportData() *livekit.RTCPSenderReportSt r.lock.RLock() defer r.lock.RUnlock() - return protoutils.CloneProto(r.srNewest) + return proto.CloneOf(r.srNewest) } func (r *RTPStatsReceiver) LastSenderReportTime() time.Time { r.lock.RLock() defer r.lock.RUnlock() - if r.srNewest != nil { - return time.Unix(0, r.srNewest.At) + if r.srNewest != nil && r.srNewest.At != 0 { + // srNewest.At is a mono timestamp, convert to wall time for comparison with time.Now() + return time.Now().Add(-time.Duration(mono.UnixNano() - r.srNewest.At)) } return time.Time{} @@ -867,7 +869,7 @@ func (r *RTPStatsReceiver) GetRtcpReceptionReport(ssrc uint32, proxyFracLost uin if r.srNewest != nil { lastSR = uint32(r.srNewest.NtpTimestamp >> 16) if r.srNewest.At != 0 { - delayUS := time.Since(time.Unix(0, r.srNewest.At)).Microseconds() + delayUS := (mono.UnixNano() - r.srNewest.At) / 1e3 dlsr = uint32(delayUS * 65536 / 1e6) } } diff --git a/pkg/sfu/rtpstats/rtpstats_sender.go b/pkg/sfu/rtpstats/rtpstats_sender.go index eafa7bc7b..578a4a067 100644 --- a/pkg/sfu/rtpstats/rtpstats_sender.go +++ b/pkg/sfu/rtpstats/rtpstats_sender.go @@ -901,7 +901,7 @@ func (r *RTPStatsSender) MaybeAdjustFirstPacketTime(publisherSRData *livekit.RTC } } -func (r *RTPStatsSender) GetExpectedRTPTimestamp(at time.Time) (expectedTSExt uint64, err error) { +func (r *RTPStatsSender) GetExpectedRTPTimestamp(atNano int64) (expectedTSExt uint64, err error) { r.lock.RLock() defer r.lock.RUnlock() @@ -910,7 +910,7 @@ func (r *RTPStatsSender) GetExpectedRTPTimestamp(at time.Time) (expectedTSExt ui return } - timeDiff := at.Sub(time.Unix(0, r.firstTime)) + timeDiff := time.Duration(atNano - r.firstTime) expectedTSExt = r.extStartTS + r.rtpConverter.ToRTPExt(timeDiff) return } diff --git a/pkg/sfu/sfu.go b/pkg/sfu/sfu.go index c8760d069..1cd41d13f 100644 --- a/pkg/sfu/sfu.go +++ b/pkg/sfu/sfu.go @@ -34,3 +34,10 @@ var ( }, } ) + +// initPooledRTPHeader sets hdr to h while keeping the pooled header's +// Extensions capacity, so SetExtension does not allocate per packet. +func initPooledRTPHeader(hdr *rtp.Header, h rtp.Header) { + h.Extensions = hdr.Extensions[:0] + *hdr = h +} diff --git a/pkg/sfu/streamtrackermanager.go b/pkg/sfu/streamtrackermanager.go index 6eb28e5f2..c13ab18ad 100644 --- a/pkg/sfu/streamtrackermanager.go +++ b/pkg/sfu/streamtrackermanager.go @@ -20,11 +20,11 @@ import ( "time" "github.com/frostbyte73/core" + "google.golang.org/protobuf/proto" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/streamtracker" @@ -144,7 +144,7 @@ func NewStreamTrackerManager( logger: logger, mimeType: mimeType, videoLayerMode: buffer.GetVideoLayerModeForMimeType(mimeType, trackInfo), - trackInfo: utils.CloneProto(trackInfo), + trackInfo: proto.CloneOf(trackInfo), maxPublishedLayer: buffer.InvalidLayerSpatial, maxTemporalLayerSeen: buffer.InvalidLayerTemporal, clockRate: clockRate, @@ -374,7 +374,7 @@ func (s *StreamTrackerManager) setPaused(paused bool) { func (s *StreamTrackerManager) UpdateTrackInfo(ti *livekit.TrackInfo) { s.lock.Lock() - s.trackInfo = utils.CloneProto(ti) + s.trackInfo = proto.CloneOf(ti) s.maxExpectedLayerFromTrackInfoLocked(false) paused := s.trackInfo.GetMuted() diff --git a/pkg/sfu/testutils/data.go b/pkg/sfu/testutils/data.go index 9b8a1386e..6ac6a9f2b 100644 --- a/pkg/sfu/testutils/data.go +++ b/pkg/sfu/testutils/data.go @@ -21,6 +21,7 @@ import ( "github.com/pion/webrtc/v4" "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/mediatransportutil/pkg/codec" ) @@ -87,7 +88,8 @@ func GetTestExtPacketVP8(params *TestExtPacketParams, vp8 *codec.VP8) (*buffer.E } ep.IsKeyFrame = vp8.IsKeyFrame - ep.Payload = *vp8 + vp8Copy := *vp8 // keep the packet independent of later changes to vp8 + ep.VP8 = &vp8Copy if ep.DependencyDescriptor == nil { ep.Temporal = int32(vp8.TID) } @@ -96,6 +98,67 @@ func GetTestExtPacketVP8(params *TestExtPacketParams, vp8 *codec.VP8) (*buffer.E // -------------------------------------- +type TestDDParams struct { + FirstPacketInFrame bool + ExtFrameNum uint64 + TemporalID int + // decode target indications as one character per decode target: S, R, D or - + DTIs string + FrameDiffs []int + DecodeTargets []buffer.DependencyDescriptorDecodeTarget +} + +// GetTestExtPacketVP8DD returns a VP8 packet with a dependency descriptor and the +// minimized VP8 payload descriptor that libwebrtc sends with it: no TID, no Y bit and +// no picture ID. +func GetTestExtPacketVP8DD(params *TestExtPacketParams, ddParams *TestDDParams) (*buffer.ExtPacket, error) { + ep, err := GetTestExtPacketVP8(params, &codec.VP8{S: ddParams.FirstPacketInFrame, IsKeyFrame: params.IsKeyFrame}) + if err != nil { + return nil, err + } + + dtis := make([]dd.DecodeTargetIndication, 0, len(ddParams.DTIs)) + for _, c := range ddParams.DTIs { + switch c { + case 'S': + dtis = append(dtis, dd.DecodeTargetSwitch) + case 'R': + dtis = append(dtis, dd.DecodeTargetRequired) + case 'D': + dtis = append(dtis, dd.DecodeTargetDiscardable) + default: + dtis = append(dtis, dd.DecodeTargetNotPresent) + } + } + ep.Temporal = int32(ddParams.TemporalID) + ep.DependencyDescriptor = &buffer.ExtDependencyDescriptor{ + Descriptor: &dd.DependencyDescriptor{ + FirstPacketInFrame: ddParams.FirstPacketInFrame, + LastPacketInFrame: params.Marker, + FrameDependencies: &dd.FrameDependencyTemplate{ + TemporalId: ddParams.TemporalID, + DecodeTargetIndications: dtis, + FrameDiffs: ddParams.FrameDiffs, + }, + }, + DecodeTargets: ddParams.DecodeTargets, + ExtFrameNum: ddParams.ExtFrameNum, + // all decode targets active, as the parser reports after a structure + ActiveDecodeTargetsMask: 1<= end+broadcastStep { + break + } + for i := n - broadcastStep; i < n && i < end; i++ { + written += b.downTracks[i].WriteRTP(b.pkt, b.layer) + } + } + b.written.Add(written) +} + +// BroadcastRTP writes pkt to every down track and returns how many accepted it. +// Below the threshold it runs on the caller without allocating; above it, the +// only allocations are the shared state and the worker funcval. +func BroadcastRTP[T interface { + sender + rtpWriter[P] +}, P any](d *DownTrackSpreader[T], pkt P, layer int32) int32 { + downTracks, threshold := d.snapshot() + if len(downTracks) < threshold { + var written int32 + for _, dt := range downTracks { + written += dt.WriteRTP(pkt, layer) + } + return written + } + + numWorkers := min(runtime.NumCPU(), len(downTracks)) + b := &rtpBroadcast[T, P]{downTracks: downTracks, pkt: pkt, layer: layer} + b.wg.Add(numWorkers) + worker := b.run + for i := 0; i < numWorkers; i++ { + go worker() + } + b.wg.Wait() + + return b.written.Load() +} diff --git a/pkg/sfu/utils/downtrackspreader_test.go b/pkg/sfu/utils/downtrackspreader_test.go new file mode 100644 index 000000000..11557183e --- /dev/null +++ b/pkg/sfu/utils/downtrackspreader_test.go @@ -0,0 +1,110 @@ +// 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 utils + +import ( + "fmt" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +type testPacket struct{ seq int } + +type testSender struct { + id livekit.ParticipantID + writes atomic.Int32 + accept int32 +} + +func (s *testSender) SubscriberID() livekit.ParticipantID { return s.id } + +func (s *testSender) WriteRTP(pkt *testPacket, layer int32) int32 { + s.writes.Add(1) + return s.accept +} + +func newTestSpreader(numDownTracks, threshold int) (*DownTrackSpreader[*testSender], []*testSender) { + d := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{Threshold: threshold}) + senders := make([]*testSender, numDownTracks) + for i := range senders { + senders[i] = &testSender{id: livekit.ParticipantID(fmt.Sprintf("p%d", i)), accept: 1} + d.Store(senders[i]) + } + return d, senders +} + +func TestBroadcastRTP(t *testing.T) { + for _, tc := range []struct { + name string + numDownTracks int + threshold int + maxAllocs float64 + }{ + {"serial", 5, 20, 0}, + {"parallel", 50, 20, 2}, // shared state and worker funcval + } { + t.Run(tc.name, func(t *testing.T) { + d, senders := newTestSpreader(tc.numDownTracks, tc.threshold) + senders[0].accept = 0 // one down track that drops + pkt := &testPacket{} + + written := BroadcastRTP(d, pkt, 2) + require.EqualValues(t, tc.numDownTracks-1, written) + for _, s := range senders { + require.EqualValues(t, 1, s.writes.Load()) + } + + if !RaceEnabled { + allocs := testing.AllocsPerRun(1000, func() { BroadcastRTP(d, pkt, 2) }) + require.LessOrEqual(t, allocs, tc.maxAllocs) + } + }) + } +} + +func TestBroadcastRTPEmpty(t *testing.T) { + d := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{Threshold: 20}) + require.EqualValues(t, 0, BroadcastRTP(d, &testPacket{}, 0)) +} + +func TestDownTrackSpreaderResetRemainsReusable(t *testing.T) { + spreader := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{}) + first := &testSender{id: "first"} + second := &testSender{id: "second"} + + spreader.Store(first) + require.Equal(t, []*testSender{first}, spreader.ResetAndGetDownTracks()) + require.Zero(t, spreader.DownTrackCount()) + + spreader.Store(second) + require.Equal(t, []*testSender{second}, spreader.GetDownTracks()) +} + +func TestDownTrackSpreaderRejectsStoreAfterClose(t *testing.T) { + spreader := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{}) + first := &testSender{id: "first"} + late := &testSender{id: "late"} + + spreader.Store(first) + require.Equal(t, []*testSender{first}, spreader.CloseAndGetDownTracks()) + require.Zero(t, spreader.DownTrackCount()) + + require.False(t, spreader.TryStore(late)) + require.Zero(t, spreader.DownTrackCount()) +} diff --git a/pkg/sfu/utils/norace.go b/pkg/sfu/utils/norace.go new file mode 100644 index 000000000..ba683f0f7 --- /dev/null +++ b/pkg/sfu/utils/norace.go @@ -0,0 +1,6 @@ +//go:build !race + +package utils + +// RaceEnabled reports whether the binary was built with the race detector. +const RaceEnabled = false diff --git a/pkg/sfu/utils/race.go b/pkg/sfu/utils/race.go new file mode 100644 index 000000000..72223acef --- /dev/null +++ b/pkg/sfu/utils/race.go @@ -0,0 +1,8 @@ +//go:build race + +package utils + +// RaceEnabled reports whether the binary was built with the race detector. +// sync.Pool drops a quarter of returned items under it, so allocation counts +// on pooled paths are not meaningful. +const RaceEnabled = true diff --git a/pkg/sfu/videolayerselector/ddforwarder.go b/pkg/sfu/videolayerselector/ddforwarder.go new file mode 100644 index 000000000..2080f34d7 --- /dev/null +++ b/pkg/sfu/videolayerselector/ddforwarder.go @@ -0,0 +1,166 @@ +// 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 ( + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// DDForwarder forwards the dependency descriptor of a simulcast stream. Without it, a +// subscriber of a stream without picture IDs, VP8 from libwebrtc for example, finds frame +// references by sequence number alone and stalls on every sequence number it cannot account +// for. +// +// libwebrtc numbers frames with one counter for all simulcast layers of a sender, so the +// frame numbers of a new layer are close to those of the old one. The numbers sent to the +// subscriber must still never go back: a key frame older than the last structure the +// subscriber received is dropped. So whenever the stream changes, a layer switch, a stream +// restart or a new forwarder, the numbers are moved up past the last one sent if needed. +// Otherwise they pass through with the current offset, so frame diffs and chain diffs stay +// valid. A migrated subscriber continues with the last number sent, which is part of the +// migrated forwarder state. +type DDForwarder struct { + logger logger.Logger + + initialized bool // a key frame with a structure set the stream + ssrc uint32 + restartGeneration int + structure *dd.FrameDependencyStructure + + frameNumOffset uint64 // added to the incoming extended frame number + hasLast bool + extLastFrameNum uint64 // highest outgoing extended frame number + + descriptor dd.DependencyDescriptor + activeDecodeTargetsBitmask uint32 +} + +func NewDDForwarder(logger logger.Logger) *DDForwarder { + return &DDForwarder{ + logger: logger, + } +} + +// NewDDForwarderFromOther continues the frame numbers of a forwarder it replaces on the +// same downtrack, a restarted upstream receiver for example. +func NewDDForwarderFromOther(other *DDForwarder, logger logger.Logger) *DDForwarder { + d := NewDDForwarder(logger) + if other != nil && other.hasLast { + d.hasLast = true + d.extLastFrameNum = other.extLastFrameNum + } + return d +} + +// GetState returns the last frame number sent, nil before the first one. +func (d *DDForwarder) GetState() *livekit.DependencyDescriptorForwarderState { + if !d.hasLast { + return nil + } + return &livekit.DependencyDescriptorForwarderState{ + ExtLastFrameNumber: d.extLastFrameNum, + } +} + +// SeedState continues after the last frame number a forwarder on another node sent to the +// subscriber. Extended numbers there started from a lower or equal base, so the first key +// frame here moves past it, and the 16 bit numbers continue. +func (d *DDForwarder) SeedState(state *livekit.DependencyDescriptorForwarderState) { + if state == nil { + return + } + if !d.hasLast || state.ExtLastFrameNumber > d.extLastFrameNum { + d.hasLast = true + d.extLastFrameNum = state.ExtLastFrameNumber + } +} + +// Forward marshals the descriptor of a forwarded packet into result. temporal is the +// temporal layer the packet is forwarded at. It returns false when there is nothing to +// send, before the first key frame of the stream for example. +func (d *DDForwarder) Forward(extPkt *buffer.ExtPacket, temporal int32, result *VideoLayerSelectorResult) bool { + ddwdt := extPkt.DependencyDescriptor + if ddwdt == nil || ddwdt.Descriptor == nil || ddwdt.Descriptor.FrameDependencies == nil { + return false + } + descriptor := ddwdt.Descriptor + + extFrameNum := ddwdt.ExtFrameNum + // the parser accepts a structure only on the first packet of a frame + hasStructure := descriptor.AttachedStructure != nil + switch { + case !d.initialized || extPkt.Packet.SSRC != d.ssrc: + // a stream starts on a key frame, a late packet of the old layer is not described + if !hasStructure { + return false + } + d.rebase(extFrameNum) + d.initialized = true + d.ssrc = extPkt.Packet.SSRC + d.restartGeneration = ddwdt.RestartGeneration + + case ddwdt.RestartGeneration != d.restartGeneration: + // the restarted stream numbers its frames from a new start and has its own structure, + // the first packet can be a delta frame + d.rebase(extFrameNum) + d.restartGeneration = ddwdt.RestartGeneration + d.structure = nil + } + if hasStructure { + d.structure = descriptor.AttachedStructure + } + if d.structure == nil { + return false + } + + extOutFrameNum := extFrameNum + d.frameNumOffset + if !d.hasLast || extOutFrameNum > d.extLastFrameNum { + d.hasLast = true + d.extLastFrameNum = extOutFrameNum + } + + // tell the subscriber which decode targets to expect: the publisher's active ones up to + // the forwarded temporal layer. On a key frame too, where the structure alone would make + // all of them active. + d.activeDecodeTargetsBitmask = 0 + for _, dt := range ddwdt.DecodeTargets { + if dt.Layer.Temporal <= temporal { + d.activeDecodeTargetsBitmask |= 1 << dt.Target + } + } + d.activeDecodeTargetsBitmask &= ddwdt.ActiveDecodeTargetsMask + d.descriptor = *descriptor + d.descriptor.FrameNumber = uint16(extOutFrameNum) + d.descriptor.ActiveDecodeTargetsBitmask = &d.activeDecodeTargetsBitmask + + if err := result.marshalDependencyDescriptorExtension(&dd.DependencyDescriptorExtension{ + Descriptor: &d.descriptor, + Structure: d.structure, + }); err != nil { + d.logger.Warnw("could not marshal dependency descriptor", err, "extFrameNum", extFrameNum) + return false + } + return true +} + +// rebase moves the frame numbers up when a new stream would go back past the last one sent +func (d *DDForwarder) rebase(extFrameNum uint64) { + if d.hasLast && extFrameNum+d.frameNumOffset <= d.extLastFrameNum { + d.frameNumOffset = d.extLastFrameNum + 1 - extFrameNum + } +} diff --git a/pkg/sfu/videolayerselector/ddforwarder_test.go b/pkg/sfu/videolayerselector/ddforwarder_test.go new file mode 100644 index 000000000..701fe2503 --- /dev/null +++ b/pkg/sfu/videolayerselector/ddforwarder_test.go @@ -0,0 +1,258 @@ +// 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 ( + "encoding/hex" + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// ddForwarderTest builds packets with the L3T3 fixture descriptor and decodes what a +// DDForwarder writes +type ddForwarderTest struct { + t *testing.T + keyFrameDescriptor dd.DependencyDescriptor + structure *dd.FrameDependencyStructure + decodeTargets []buffer.DependencyDescriptorDecodeTarget +} + +func newDDForwarderTest(t *testing.T) *ddForwarderTest { + raw, err := hex.DecodeString(dependencyDescriptorFixture) + require.NoError(t, err) + keyFrameDescriptor := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &keyFrameDescriptor}).Unmarshal(raw) + require.NoError(t, err) + require.NotNil(t, keyFrameDescriptor.AttachedStructure) + + return &ddForwarderTest{ + t: t, + keyFrameDescriptor: keyFrameDescriptor, + structure: keyFrameDescriptor.AttachedStructure, + decodeTargets: []buffer.DependencyDescriptorDecodeTarget{ + {Target: 2, Layer: buffer.VideoLayer{Temporal: 2}}, + {Target: 1, Layer: buffer.VideoLayer{Temporal: 1}}, + {Target: 0, Layer: buffer.VideoLayer{Temporal: 0}}, + }, + } +} + +func (d *ddForwarderTest) packet(ssrc uint32, extFrameNum uint64, isKeyFrame bool, restartGeneration int) *buffer.ExtPacket { + descriptor := d.keyFrameDescriptor + descriptor.FrameNumber = uint16(extFrameNum) + if !isKeyFrame { + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + } + return &buffer.ExtPacket{ + Packet: &rtp.Packet{Header: rtp.Header{SSRC: ssrc}}, + IsKeyFrame: isKeyFrame, + DependencyDescriptor: &buffer.ExtDependencyDescriptor{ + Descriptor: &descriptor, + DecodeTargets: d.decodeTargets, + ExtFrameNum: extFrameNum, + RestartGeneration: restartGeneration, + // all decode targets active, as the parser reports after a structure + ActiveDecodeTargetsMask: 1< s.numEntries { + floor = entity - s.numEntries + } + + for e := max(s.last+1, floor); e != entity; e++ { s.setEntity(e, selectorDecisionUnknown) } @@ -145,6 +151,9 @@ func (s *SelectorDecisionCache) addEntity(entity uint64, sd selectorDecision) { } else { missingEnd = s.base } + if missingEnd > missingStart+s.numEntries { + missingStart = missingEnd - s.numEntries + } if missingEnd > missingStart { for e := missingStart; e != missingEnd; e++ { s.setEntityIfUnknown(e, selectorDecisionMissing) diff --git a/pkg/sfu/videolayerselector/selectordecisioncache_test.go b/pkg/sfu/videolayerselector/selectordecisioncache_test.go new file mode 100644 index 000000000..ed62a6927 --- /dev/null +++ b/pkg/sfu/videolayerselector/selectordecisioncache_test.go @@ -0,0 +1,84 @@ +// 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" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSelectorDecisionCacheLargeJump(t *testing.T) { + s := NewSelectorDecisionCache(256, 80) + s.AddForwarded(10) + s.AddForwarded(11) + + var expected []selectorDecision + require.True(t, s.ExpectDecision(12, func(_ uint64, sd selectorDecision) { + expected = append(expected, sd) + })) + + jumpTo := uint64(11 + 65535) + s.AddDropped(jumpTo) + + // skipped entity that was expected is reported missing + require.Equal(t, []selectorDecision{selectorDecisionMissing}, expected) + + sd, err := s.GetDecision(jumpTo) + require.NoError(t, err) + require.Equal(t, selectorDecisionDropped, sd) + + // the missing range wraps the whole ring, so every other slot reads missing + for e := jumpTo - s.numEntries + 1; e < jumpTo; e++ { + sd, err = s.GetDecision(e) + require.NoError(t, err) + require.Equal(t, selectorDecisionMissing, sd, "entity %d", e) + } + + _, err = s.GetDecision(jumpTo - s.numEntries) + require.Error(t, err) +} + +func TestSelectorDecisionCacheSmallJump(t *testing.T) { + s := NewSelectorDecisionCache(256, 80) + s.AddForwarded(1000) + s.AddDropped(1100) + + // within nack window -> unknown, older skipped -> missing + for e := uint64(1001); e < 1100; e++ { + sd, err := s.GetDecision(e) + require.NoError(t, err) + if e >= 1100-80 { + require.Equal(t, selectorDecisionUnknown, sd, "entity %d", e) + } else { + require.Equal(t, selectorDecisionMissing, sd, "entity %d", e) + } + } +} + +func TestSelectorDecisionCacheJumpIsBounded(t *testing.T) { + s := NewSelectorDecisionCache(256, 80) + entity := uint64(1) + s.AddForwarded(entity) + + // uncapped, each jump walks ~2x the gap (seconds in total), capped it walks ~2x the ring + start := time.Now() + for range 10000 { + entity += 65535 + s.AddDropped(entity) + } + require.Less(t, time.Since(start), time.Second) +} diff --git a/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go new file mode 100644 index 000000000..1c0641669 --- /dev/null +++ b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go @@ -0,0 +1,125 @@ +// 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 temporallayerselector + +import ( + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/logger" +) + +// frames tracked behind the highest frame number, more than temporal references reach +const ddFrameWindow = 64 + +// DependencyDescriptor switches temporal layers with the dependency descriptor alone, for +// a stream with one spatial layer. It only picks the layer, the codec munger drops the +// packets above it. libwebrtc strips the VP8 payload descriptor when it sends the +// dependency descriptor, so for VP8 the temporal ID and the layer sync bit are only here. +type DependencyDescriptor struct { + logger logger.Logger + + initialized bool + highestFrame uint64 + forwardedMask uint64 // bit i: frame highestFrame - i was forwarded +} + +func NewDependencyDescriptor(logger logger.Logger) *DependencyDescriptor { + return &DependencyDescriptor{ + logger: logger, + } +} + +func (d *DependencyDescriptor) Select(extPkt *buffer.ExtPacket, current int32, target int32) (this int32, next int32) { + this = current + next = current + + ddwdt := extPkt.DependencyDescriptor + if ddwdt == nil || ddwdt.Descriptor == nil || ddwdt.Descriptor.FrameDependencies == nil { + return + } + + // A late first packet, a retransmission for example, starts a frame whose other + // packets were already decided. A key frame is the exception: it references nothing, + // and it restarts the frame numbers on a stream restart. + isFrameStart := ddwdt.Descriptor.FirstPacketInFrame && !extPkt.IsOutOfOrder + switch { + case current < target && extPkt.IsKeyFrame: + this = target + next = target + + case current < target && isFrameStart: + // A switch point of a decode target makes all later frames of that target + // decodable if this frame is decodable, so also every frame this one references + // has to have been forwarded. + if !d.areForwarded(ddwdt.ExtFrameNum, ddwdt.Descriptor.FrameDependencies.FrameDiffs) { + break + } + dtis := ddwdt.Descriptor.FrameDependencies.DecodeTargetIndications + for _, dt := range ddwdt.DecodeTargets { // sorted from high to low + if dt.Layer.Temporal <= current || dt.Layer.Temporal > target || dt.Target >= len(dtis) { + continue + } + if dtis[dt.Target] == dd.DecodeTargetSwitch { + this = dt.Layer.Temporal + next = dt.Layer.Temporal + break + } + } + + case current > target && extPkt.Packet.Marker: + next = target + } + + if extPkt.IsKeyFrame || isFrameStart { + d.onFrame(ddwdt.ExtFrameNum, extPkt.IsKeyFrame, extPkt.Temporal <= this) + } + return +} + +func (d *DependencyDescriptor) onFrame(frame uint64, isKeyFrame bool, isForwarded bool) { + switch { + case isKeyFrame || !d.initialized || frame >= d.highestFrame+ddFrameWindow: + d.initialized = true + d.highestFrame = frame + d.forwardedMask = 0 + + case frame > d.highestFrame: + d.forwardedMask <<= frame - d.highestFrame + d.highestFrame = frame + + case d.highestFrame-frame >= ddFrameWindow: + return + } + + if isForwarded { + d.forwardedMask |= 1 << (d.highestFrame - frame) + } +} + +func (d *DependencyDescriptor) areForwarded(frame uint64, frameDiffs []int) bool { + if !d.initialized { + return false + } + for _, diff := range frameDiffs { + if diff <= 0 || uint64(diff) > frame { + return false + } + ref := frame - uint64(diff) + if ref > d.highestFrame || d.highestFrame-ref >= ddFrameWindow || d.forwardedMask&(1<<(d.highestFrame-ref)) == 0 { + return false + } + } + return true +} diff --git a/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go new file mode 100644 index 000000000..319ecc9e6 --- /dev/null +++ b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go @@ -0,0 +1,184 @@ +// 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 temporallayerselector + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/protocol/logger" +) + +type vp8DDFrame struct { + tid int + dtis string + frameDiffs []int + keyFrame bool +} + +// libwebrtc's three layer VP8 pattern: T0 refreshes "last", T1 refreshes "golden", and +// frame diffs point at the frames in those buffers +var vp8DDPattern = []vp8DDFrame{ + {tid: 0, dtis: "SSS", frameDiffs: []int{4}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1}}, + {tid: 1, dtis: "-SS", frameDiffs: []int{2}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1, 3}}, + {tid: 0, dtis: "SRR", frameDiffs: []int{4}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1, 3}}, + {tid: 1, dtis: "-DS", frameDiffs: []int{2, 4}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1, 3}}, +} + +func vp8DDPacket(t *testing.T, frameNum uint64, f vp8DDFrame, first bool, marker bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: uint16(frameNum), PayloadSize: 20, Marker: marker, IsKeyFrame: f.keyFrame}, + &testutils.TestDDParams{ + FirstPacketInFrame: first, + ExtFrameNum: frameNum, + TemporalID: f.tid, + DTIs: f.dtis, + FrameDiffs: f.frameDiffs, + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt +} + +// runDependencyDescriptor sends one packet per frame: a key frame, the pattern at the start target until +// frame switchAt, then at target 2. It returns the frame number of the up-switch. +func runDependencyDescriptor(t *testing.T, startTarget int32, switchAt uint64) (uint64, bool) { + v := NewDependencyDescriptor(logger.GetLogger()) + current := int32(0) + for frameNum := uint64(0); frameNum < 40; frameNum++ { + f := vp8DDPattern[frameNum%uint64(len(vp8DDPattern))] + if frameNum == 0 { + f = vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true} + } + target := startTarget + if frameNum >= switchAt { + target = 2 + } + this, next := v.Select(vp8DDPacket(t, frameNum, f, true, true), current, target) + if frameNum >= switchAt && next == 2 { + require.Equal(t, int32(2), this) + return frameNum, true + } + current = next + } + return 0, false +} + +func TestDependencyDescriptorSelect(t *testing.T) { + t.Run("from TL0, up-switch at the next T0 switch point", func(t *testing.T) { + // frame 14 is the "-DS" T1 frame, it references T1 frame 10 that was dropped + frameNum, ok := runDependencyDescriptor(t, 0, 12) + require.True(t, ok) + require.Equal(t, uint64(16), frameNum) + + // frame 10 is the "-SS" T1 frame, it references only T0 + frameNum, ok = runDependencyDescriptor(t, 0, 10) + require.True(t, ok) + require.Equal(t, uint64(10), frameNum) + }) + + t.Run("from TL1, the -DS frame is a switch point", func(t *testing.T) { + frameNum, ok := runDependencyDescriptor(t, 1, 14) + require.True(t, ok) + require.Equal(t, uint64(14), frameNum) + }) + + t.Run("switch point that references a dropped frame", func(t *testing.T) { + // libwebrtc L1T4: a T2 frame that is a switch point for TL2 but references the + // previous T2 frame + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 1, 1) + _, current = v.Select(vp8DDPacket(t, 1, vp8DDFrame{tid: 2, dtis: "--D", frameDiffs: []int{1}}, true, true), current, 1) + this, next := v.Select(vp8DDPacket(t, 2, vp8DDFrame{tid: 2, dtis: "--S", frameDiffs: []int{1}}, true, true), current, 2) + require.Equal(t, int32(1), this) + require.Equal(t, int32(1), next) + }) + + t.Run("reference lost upstream", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 0, 0) + // frame 1 never arrives + this, next := v.Select(vp8DDPacket(t, 2, vp8DDFrame{tid: 1, dtis: "-SS", frameDiffs: []int{1}}, true, true), current, 2) + require.Equal(t, int32(0), this) + require.Equal(t, int32(0), next) + }) + + t.Run("up-switch only on the first packet of a frame, in order", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 0, 0) + f := vp8DDFrame{tid: 1, dtis: "-SS", frameDiffs: []int{1}} + + _, next := v.Select(vp8DDPacket(t, 1, f, false, true), current, 2) + require.Equal(t, int32(0), next, "second packet") + + late := vp8DDPacket(t, 1, f, true, true) + late.IsOutOfOrder = true + _, next = v.Select(late, current, 2) + require.Equal(t, int32(0), next, "late first packet") + + this, next := v.Select(vp8DDPacket(t, 1, f, true, true), current, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + }) + + t.Run("late key frame packet of a stream restart", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 100, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 0, 0) + + // the restart moves the frame numbers back, and its key frame arrives out of order + keyFrame := vp8DDPacket(t, 5, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true) + keyFrame.IsOutOfOrder = true + _, current = v.Select(keyFrame, current, 0) + + this, next := v.Select(vp8DDPacket(t, 6, vp8DDFrame{tid: 1, dtis: "-SS", frameDiffs: []int{1}}, true, true), current, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + }) + + t.Run("key frame", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + this, next := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, false), 0, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + }) + + t.Run("no dependency descriptor", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + extPkt := vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true) + extPkt.DependencyDescriptor = nil + this, next := v.Select(extPkt, 0, 2) + require.Equal(t, int32(0), this) + require.Equal(t, int32(0), next) + }) + + t.Run("down-switch at the end of a frame", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + f := vp8DDFrame{tid: 2, dtis: "--D", frameDiffs: []int{1}} + this, next := v.Select(vp8DDPacket(t, 1, f, true, false), 2, 0) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + this, next = v.Select(vp8DDPacket(t, 1, f, false, true), 2, 0) + require.Equal(t, int32(2), this) + require.Equal(t, int32(0), next) + }) +} diff --git a/pkg/sfu/videolayerselector/temporallayerselector/null.go b/pkg/sfu/videolayerselector/temporallayerselector/null.go index 51f2fb591..52358b5f1 100644 --- a/pkg/sfu/videolayerselector/temporallayerselector/null.go +++ b/pkg/sfu/videolayerselector/temporallayerselector/null.go @@ -18,14 +18,17 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" ) +// Null is for a stream whose temporal layers cannot be filtered, simulcast without the +// dependency descriptor for example: every temporal layer is forwarded. It reports the +// target as the current layer, so a target below the highest layer does not stay pending +// and block further allocation. The forwarder pairs it with the null codec munger, which +// ignores the temporal layer it returns for the packet. type Null struct{} func NewNull() *Null { return &Null{} } -func Select(_extPkt *buffer.ExtPacket, current int32, _target int32) (this int32, next int32) { - this = current - next = current - return +func (n *Null) Select(_extPkt *buffer.ExtPacket, _current int32, target int32) (this int32, next int32) { + return target, target } diff --git a/pkg/sfu/videolayerselector/temporallayerselector/null_test.go b/pkg/sfu/videolayerselector/temporallayerselector/null_test.go new file mode 100644 index 000000000..a86ec71ca --- /dev/null +++ b/pkg/sfu/videolayerselector/temporallayerselector/null_test.go @@ -0,0 +1,35 @@ +// 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 temporallayerselector + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNullFollowsTarget(t *testing.T) { + n := NewNull() + + // a switch key frame on temporal layer 0 reaches a higher target right away + this, next := n.Select(nil, 0, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + + // and a lower one + this, next = n.Select(nil, 2, 1) + require.Equal(t, int32(1), this) + require.Equal(t, int32(1), next) +} diff --git a/pkg/sfu/videolayerselector/temporallayerselector/vp8.go b/pkg/sfu/videolayerselector/temporallayerselector/vp8.go index 39480c439..57162b2f0 100644 --- a/pkg/sfu/videolayerselector/temporallayerselector/vp8.go +++ b/pkg/sfu/videolayerselector/temporallayerselector/vp8.go @@ -16,7 +16,6 @@ package temporallayerselector import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" - "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/logger" ) @@ -37,14 +36,23 @@ func (v *VP8) Select(extPkt *buffer.ExtPacket, current int32, target int32) (thi return } - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return } tid := extPkt.Temporal if current < target { - if tid > current && tid <= target && vp8.S { + // Up-switch only where nothing dropped can be referenced: a layer sync + // (Y) frame depends only on TL0 (RFC 7741), a key frame refreshes all + // reference buffers. Key frames also cover encoders that never set Y. + // A sync frame raises one layer at a time, the layers in between would + // otherwise forward frames that reference their dropped frames. + switch { + case vp8.IsKeyFrame && tid <= target: + this = target + next = target + case tid == current+1 && tid <= target && vp8.S && vp8.Y: this = tid next = tid } diff --git a/pkg/sfu/videolayerselector/videolayerselector.go b/pkg/sfu/videolayerselector/videolayerselector.go index 72a6e2c81..795a058e4 100644 --- a/pkg/sfu/videolayerselector/videolayerselector.go +++ b/pkg/sfu/videolayerselector/videolayerselector.go @@ -15,18 +15,43 @@ package videolayerselector import ( + "errors" + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/videolayerselector/temporallayerselector" "github.com/livekit/protocol/logger" ) type VideoLayerSelectorResult struct { - IsSelected bool - IsRelevant bool - IsSwitching bool - IsResuming bool - RTPMarker bool - DependencyDescriptorExtension []byte + IsSelected bool + IsRelevant bool + IsSwitching bool + IsResuming bool + RTPMarker bool + + // marshaled dependency descriptor extension, held inline when it fits and + // spilled to the heap when it does not, the way the sequencer keeps its copy + DDBytes [dd.MaxInlineExtensionSize]byte + DDBytesLen int + DDBytesSpill []byte +} + +// marshalDependencyDescriptorExtension marshals ddExtension into the result, +// inline if it fits and on the heap otherwise. +func (v *VideoLayerSelectorResult) marshalDependencyDescriptorExtension(ddExtension *dd.DependencyDescriptorExtension) error { + ddBytesLen, err := ddExtension.MarshalTo(v.DDBytes[:]) + if err == nil { + v.DDBytesLen = ddBytesLen + return nil + } + if !errors.Is(err, dd.ErrBufferTooSmall) { + return err + } + + // descriptors carrying the full dependency structure do not fit inline + v.DDBytesSpill, err = ddExtension.Marshal() + return err } type VideoLayerSelector interface { diff --git a/pkg/sfu/videolayerselector/vp9.go b/pkg/sfu/videolayerselector/vp9.go index 4b11d37ab..7cb5de7cc 100644 --- a/pkg/sfu/videolayerselector/vp9.go +++ b/pkg/sfu/videolayerselector/vp9.go @@ -15,8 +15,6 @@ package videolayerselector import ( - "github.com/pion/rtp/codecs" - "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/protocol/logger" ) @@ -40,8 +38,8 @@ func (v *VP9) IsOvershootOkay() bool { } func (v *VP9) Select(extPkt *buffer.ExtPacket, _layer int32) (result VideoLayerSelectorResult) { - vp9, ok := extPkt.Payload.(codecs.VP9Packet) - if !ok { + vp9 := extPkt.VP9 + if vp9 == nil { return } diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index 989dd870e..f41beda3b 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -57,18 +57,20 @@ func (t *telemetryService) RoomStarted(ctx context.Context, room *livekit.Room) }) } -func (t *telemetryService) RoomEnded(ctx context.Context, room *livekit.Room) { +func (t *telemetryService) RoomEnded(ctx context.Context, room *livekit.Room, reason livekit.RoomEndReason) { t.enqueue(func() { t.NotifyEvent(ctx, &livekit.WebhookEvent{ - Event: webhook.EventRoomFinished, - Room: room, + Event: webhook.EventRoomFinished, + Room: room, + RoomEndReason: reason, }) t.SendEvent(ctx, &livekit.AnalyticsEvent{ - Type: livekit.AnalyticsEventType_ROOM_ENDED, - Timestamp: timestamppb.Now(), - RoomId: room.Sid, - Room: room, + Type: livekit.AnalyticsEventType_ROOM_ENDED, + Timestamp: timestamppb.Now(), + RoomId: room.Sid, + Room: room, + RoomEndReason: reason, }) }) } @@ -92,7 +94,7 @@ func (t *telemetryService) ParticipantJoined( guard, ) if !found { - prometheus.IncrementParticipantRtcConnected(1) + prometheus.IncrementParticipantRtcConnected(1, clientInfo.GetSdk()) prometheus.AddParticipant() } @@ -202,6 +204,7 @@ func (t *telemetryService) ParticipantLeft(ctx context.Context, "participant", participant.Identity, "participantID", participant.Sid, "worker", worker, + "guard", guard, ) } } diff --git a/pkg/telemetry/events_test.go b/pkg/telemetry/events_test.go index 03fca0e4a..ae275c89f 100644 --- a/pkg/telemetry/events_test.go +++ b/pkg/telemetry/events_test.go @@ -243,3 +243,18 @@ func Test_OnTrackSubscribed_EventIsSent(t *testing.T) { require.Equal(t, publisherInfo.Identity, eventTrackSubscribed.Publisher.Identity) } + +func Test_OnRoomEnded_ReasonIsSent(t *testing.T) { + fixture := createFixture() + + room := &livekit.Room{Sid: "RoomSid", Name: "RoomName"} + fixture.sut.RoomEnded(context.Background(), room, livekit.RoomEndReason_ROOM_END_API_DELETE) + + fixture.flush() + + require.Equal(t, 1, fixture.analytics.SendEventCallCount()) + _, event := fixture.analytics.SendEventArgsForCall(0) + require.Equal(t, livekit.AnalyticsEventType_ROOM_ENDED, event.Type) + require.Equal(t, room.Sid, event.RoomId) + require.Equal(t, livekit.RoomEndReason_ROOM_END_API_DELETE, event.RoomEndReason) +} diff --git a/pkg/telemetry/prometheus/node.go b/pkg/telemetry/prometheus/node.go index fb540fb30..915b0322a 100644 --- a/pkg/telemetry/prometheus/node.go +++ b/pkg/telemetry/prometheus/node.go @@ -174,7 +174,6 @@ func GetNodeStats(nodeStartedAt int64, prevStats []*livekit.NodeStats, rateInter 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, diff --git a/pkg/telemetry/prometheus/node_nonwindows.go b/pkg/telemetry/prometheus/node_nonwindows.go index b765ce271..9661e08fa 100644 --- a/pkg/telemetry/prometheus/node_nonwindows.go +++ b/pkg/telemetry/prometheus/node_nonwindows.go @@ -18,39 +18,8 @@ package prometheus -import ( - "runtime" - "sync" - - "github.com/mackerelio/go-osstat/cpu" - "github.com/mackerelio/go-osstat/loadavg" -) - -var ( - cpuStatsLock sync.RWMutex - lastCPUTotal, lastCPUIdle uint64 -) +import "github.com/mackerelio/go-osstat/loadavg" func getLoadAvg() (*loadavg.Stats, error) { return loadavg.Get() } - -func getCPUStats() (cpuLoad float32, numCPUs uint32, err error) { - cpuInfo, err := cpu.Get() - if err != nil { - return - } - - cpuStatsLock.Lock() - if lastCPUTotal > 0 && lastCPUTotal < cpuInfo.Total { - cpuLoad = 1 - float32(cpuInfo.Idle-lastCPUIdle)/float32(cpuInfo.Total-lastCPUTotal) - } - - lastCPUTotal = cpuInfo.Total - lastCPUIdle = cpuInfo.Idle - cpuStatsLock.Unlock() - - numCPUs = uint32(runtime.NumCPU()) - - return -} diff --git a/pkg/telemetry/prometheus/node_windows.go b/pkg/telemetry/prometheus/node_windows.go index eb0ba48a9..507b80437 100644 --- a/pkg/telemetry/prometheus/node_windows.go +++ b/pkg/telemetry/prometheus/node_windows.go @@ -23,7 +23,3 @@ import "github.com/mackerelio/go-osstat/loadavg" func getLoadAvg() (*loadavg.Stats, error) { return &loadavg.Stats{}, nil } - -func getCPUStats() (cpuLoad float32, numCPUs uint32, err error) { - return 1, 1, nil -} diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index 406b6bdbc..ed8646e16 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -51,7 +51,6 @@ var ( participantRTCCanceled atomic.Uint64 participantRTCActive atomic.Uint64 forwardLatency atomic.Uint32 - forwardJitter atomic.Uint32 promPacketLabels = []string{"direction", "transmission", "country"} promPacketTotal *prometheus.CounterVec @@ -73,7 +72,6 @@ var ( promParticipantJoin *prometheus.CounterVec promConnections *prometheus.GaugeVec promForwardLatency prometheus.Gauge - promForwardJitter prometheus.Gauge promForwardLatencyHist prometheus.Histogram ) @@ -166,7 +164,7 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) { Subsystem: "participant_join", Name: "total", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, - }, []string{"state", "warp"}) + }, []string{"state", "warp", "sdk"}) promConnections = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: livekitNamespace, Subsystem: "connection", @@ -179,12 +177,6 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) { Name: "latency", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, }) - promForwardJitter = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: livekitNamespace, - Subsystem: "forward", - Name: "jitter", - ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, - }) promForwardLatencyHist = prometheus.NewHistogram(prometheus.HistogramOpts{ Namespace: livekitNamespace, Subsystem: "forward_latency", @@ -221,7 +213,6 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) { prometheus.MustRegister(promParticipantJoin) prometheus.MustRegister(promConnections) prometheus.MustRegister(promForwardLatency) - prometheus.MustRegister(promForwardJitter) prometheus.MustRegister(promForwardLatencyHist) } @@ -334,72 +325,72 @@ func RecordRTT(country string, direction Direction, trackSource livekit.TrackSou func IncrementParticipantJoin(join uint32) { if join > 0 { participantSignalConnected.Add(uint64(join)) - promParticipantJoin.WithLabelValues("signal_connected", "").Add(float64(join)) + promParticipantJoin.WithLabelValues("signal_connected", "", "").Add(float64(join)) } } func IncrementParticipantJoinFail(fail uint32) { if fail > 0 { - promParticipantJoin.WithLabelValues("signal_failed", "").Add(float64(fail)) + promParticipantJoin.WithLabelValues("signal_failed", "", "").Add(float64(fail)) } } func IncrementParticipantJoinValidationFail(validationFail uint32) { if validationFail > 0 { - promParticipantJoin.WithLabelValues("signal_validation_failed", "").Add(float64(validationFail)) + promParticipantJoin.WithLabelValues("signal_validation_failed", "", "").Add(float64(validationFail)) } } func IncrementParticipantJoinUpgradeFail(upgradeFail uint32) { if upgradeFail > 0 { - promParticipantJoin.WithLabelValues("signal_upgrade_failed", "").Add(float64(upgradeFail)) + promParticipantJoin.WithLabelValues("signal_upgrade_failed", "", "").Add(float64(upgradeFail)) } } func IncrementParticipantJoinWriteInitialResponseFail(writeInitialResponseFail uint32) { if writeInitialResponseFail > 0 { - promParticipantJoin.WithLabelValues("signal_write_initial_response_failed", "").Add(float64(writeInitialResponseFail)) + 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)) + promParticipantJoin.WithLabelValues("rtc_init", "", "").Add(float64(init)) } } -func IncrementParticipantRtcConnected(connected uint32) { +func IncrementParticipantRtcConnected(connected uint32, sdk livekit.ClientInfo_SDK) { if connected > 0 { participantRTCConnected.Add(uint64(connected)) - promParticipantJoin.WithLabelValues("rtc_connected", "").Add(float64(connected)) + promParticipantJoin.WithLabelValues("rtc_connected", "", sdk.String()).Add(float64(connected)) } } func IncrementParticipantRtcActive(active uint32, warp bool) { if active > 0 { participantRTCActive.Add(uint64(active)) - promParticipantJoin.WithLabelValues("rtc_active", strconv.FormatBool(warp)).Add(float64(active)) + promParticipantJoin.WithLabelValues("rtc_active", strconv.FormatBool(warp), "").Add(float64(active)) } } -func IncrementParticipantRtcCanceled(canceled uint64, warp bool) { +func IncrementParticipantRtcCanceled(canceled uint64, warp bool, sdk livekit.ClientInfo_SDK) { if canceled > 0 { participantRTCCanceled.Add(canceled) - promParticipantJoin.WithLabelValues("rtc_canceled", strconv.FormatBool(warp)).Add(float64(canceled)) + promParticipantJoin.WithLabelValues("rtc_canceled", strconv.FormatBool(warp), sdk.String()).Add(float64(canceled)) } } // todo: check if need to record warp to rtcSucc/Failure -func IncrementParticipantRtcSuccess(success uint64, warp bool) { +func IncrementParticipantRtcSuccess(success uint64, warp bool, sdk livekit.ClientInfo_SDK) { if success > 0 { - promParticipantJoin.WithLabelValues("rtc_success", strconv.FormatBool(warp)).Add(float64(success)) + promParticipantJoin.WithLabelValues("rtc_success", strconv.FormatBool(warp), sdk.String()).Add(float64(success)) } } -func IncrementParticipantRtcFailure(failure uint64, warp bool) { +func IncrementParticipantRtcFailure(failure uint64, warp bool, sdk livekit.ClientInfo_SDK) { if failure > 0 { - promParticipantJoin.WithLabelValues("rtc_failure", strconv.FormatBool(warp)).Add(float64(failure)) + promParticipantJoin.WithLabelValues("rtc_failure", strconv.FormatBool(warp), sdk.String()).Add(float64(failure)) } } @@ -415,12 +406,7 @@ func RecordForwardLatencySample(forwardLatency int64) { promForwardLatencyHist.Observe(float64(forwardLatency)) } -func RecordForwardLatency(longTermLatencyAvg uint32) { - forwardLatency.Store(longTermLatencyAvg) - promForwardLatency.Set(float64(longTermLatencyAvg)) -} - -func RecordForwardJitter(longTermJitterAvg uint32) { - forwardJitter.Store(longTermJitterAvg) - promForwardJitter.Set(float64(longTermJitterAvg)) +func RecordForwardLatency(p90 uint32) { + forwardLatency.Store(p90) + promForwardLatency.Set(float64(p90)) } diff --git a/pkg/telemetry/prometheus/rooms.go b/pkg/telemetry/prometheus/rooms.go index 9a7a9c35b..96380f655 100644 --- a/pkg/telemetry/prometheus/rooms.go +++ b/pkg/telemetry/prometheus/rooms.go @@ -113,7 +113,7 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) { Name: "start_time_ms", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, Buckets: prometheus.ExponentialBucketsRange(100, 10000, 15), - }, []string{"protocol_version", "warp"}) + }, []string{"protocol_version", "warp", "sdk"}) promSessionDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: livekitNamespace, Subsystem: "session", @@ -291,8 +291,8 @@ func RecordSessionJoinLatency(protocolVersion int, d time.Duration) { promSessionJoinLatency.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds())) } -func RecordSessionStartTime(protocolVersion int, warp bool, d time.Duration) { - promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion), strconv.FormatBool(warp)).Observe(float64(d.Milliseconds())) +func RecordSessionStartTime(protocolVersion int, warp bool, sdk livekit.ClientInfo_SDK, d time.Duration) { + promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion), strconv.FormatBool(warp), sdk.String()).Observe(float64(d.Milliseconds())) } func RecordSessionDuration(protocolVersion int, d time.Duration) { diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index 59429a0d9..f83e3a8a1 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -18,20 +18,32 @@ import ( "context" "sync" "time" + "unsafe" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/livekit/livekit-server/pkg/utils" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - protoutils "github.com/livekit/protocol/utils" ) type ReferenceGuard struct { activated, released bool } +func (r *ReferenceGuard) MarshalLogObject(e zapcore.ObjectEncoder) error { + if r != nil { + e.AddUintptr("self", uintptr(unsafe.Pointer(r))) + e.AddBool("activated", r.activated) + e.AddBool("released", r.released) + } + return nil +} + +// ---------------------------------------- + type ReferenceCount struct { count int } @@ -116,6 +128,10 @@ func (s *StatsWorker) ParticipantID() livekit.ParticipantID { } func (s *StatsWorker) SetConnected() { + if s == nil { + return + } + s.lock.Lock() s.isConnected = true s.lock.Unlock() @@ -201,6 +217,10 @@ func (s *StatsWorker) collectStats( } func (s *StatsWorker) MarshalLogObject(e zapcore.ObjectEncoder) error { + if s == nil { + return nil + } + s.lock.RLock() defer s.lock.RUnlock() @@ -284,7 +304,7 @@ func coalesce(stats []*livekit.AnalyticsStat) *livekit.AnalyticsStat { for _, videoLayer := range analyticsStream.VideoLayers { coalescedVideoLayer := coalescedVideoLayers[videoLayer.Layer] if coalescedVideoLayer == nil { - coalescedVideoLayer = protoutils.CloneProto(videoLayer) + coalescedVideoLayer = proto.CloneOf(videoLayer) coalescedVideoLayers[videoLayer.Layer] = coalescedVideoLayer } else { coalescedVideoLayer.Packets += videoLayer.Packets diff --git a/pkg/telemetry/statsworker_test.go b/pkg/telemetry/statsworker_test.go index f7ae48816..ce5d02bcd 100644 --- a/pkg/telemetry/statsworker_test.go +++ b/pkg/telemetry/statsworker_test.go @@ -1,9 +1,13 @@ package telemetry import ( + "context" "testing" "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/livekit/protocol/livekit" ) func TestStatsWorker(t *testing.T) { @@ -16,4 +20,39 @@ func TestStatsWorker(t *testing.T) { require.True(t, w.Close(&g1)) require.True(t, w.Closed(&g1)) }) + + t.Run("logging a nil worker does not panic", func(t *testing.T) { + var w *StatsWorker + require.NoError(t, w.MarshalLogObject(zapcore.NewMapObjectEncoder())) + }) +} + +func TestGetOrCreateWorkerReleasedGuard(t *testing.T) { + // ParticipantActive overtaken by the participant's close arrives with a guard that + // ParticipantLeft already released. It must not replace the closed worker with one + // nothing can release. + ts := &telemetryService{workers: make(map[statsWorkerKey]*StatsWorker)} + roomID, pID := livekit.RoomID("room"), livekit.ParticipantID("participant") + + var g ReferenceGuard + w, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) + require.False(t, found) + require.True(t, w.Close(&g)) + + t.Run("closed worker still in the map", func(t *testing.T) { + late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) + require.True(t, found) + require.Same(t, w, late) + require.Same(t, w, ts.workers[statsWorkerKey{roomID, pID}]) + }) + + t.Run("closed worker already reaped", func(t *testing.T) { + delete(ts.workers, statsWorkerKey{roomID, pID}) + + late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) + require.True(t, found) + require.Nil(t, late) + require.Empty(t, ts.workers) + late.SetConnected() + }) } diff --git a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go index 25a241bef..aed5e7cf3 100644 --- a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go +++ b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go @@ -129,11 +129,12 @@ type FakeTelemetryService struct { arg1 context.Context arg2 *livekit.ReportInfo } - RoomEndedStub func(context.Context, *livekit.Room) + RoomEndedStub func(context.Context, *livekit.Room, livekit.RoomEndReason) roomEndedMutex sync.RWMutex roomEndedArgsForCall []struct { arg1 context.Context arg2 *livekit.Room + arg3 livekit.RoomEndReason } RoomProjectReporterStub func(context.Context) roomobs.ProjectReporter roomProjectReporterMutex sync.RWMutex @@ -880,17 +881,18 @@ func (fake *FakeTelemetryService) ReportArgsForCall(i int) (context.Context, *li return argsForCall.arg1, argsForCall.arg2 } -func (fake *FakeTelemetryService) RoomEnded(arg1 context.Context, arg2 *livekit.Room) { +func (fake *FakeTelemetryService) RoomEnded(arg1 context.Context, arg2 *livekit.Room, arg3 livekit.RoomEndReason) { fake.roomEndedMutex.Lock() fake.roomEndedArgsForCall = append(fake.roomEndedArgsForCall, struct { arg1 context.Context arg2 *livekit.Room - }{arg1, arg2}) + arg3 livekit.RoomEndReason + }{arg1, arg2, arg3}) stub := fake.RoomEndedStub - fake.recordInvocation("RoomEnded", []interface{}{arg1, arg2}) + fake.recordInvocation("RoomEnded", []interface{}{arg1, arg2, arg3}) fake.roomEndedMutex.Unlock() if stub != nil { - fake.RoomEndedStub(arg1, arg2) + fake.RoomEndedStub(arg1, arg2, arg3) } } @@ -900,17 +902,17 @@ func (fake *FakeTelemetryService) RoomEndedCallCount() int { return len(fake.roomEndedArgsForCall) } -func (fake *FakeTelemetryService) RoomEndedCalls(stub func(context.Context, *livekit.Room)) { +func (fake *FakeTelemetryService) RoomEndedCalls(stub func(context.Context, *livekit.Room, livekit.RoomEndReason)) { fake.roomEndedMutex.Lock() defer fake.roomEndedMutex.Unlock() fake.RoomEndedStub = stub } -func (fake *FakeTelemetryService) RoomEndedArgsForCall(i int) (context.Context, *livekit.Room) { +func (fake *FakeTelemetryService) RoomEndedArgsForCall(i int) (context.Context, *livekit.Room, livekit.RoomEndReason) { fake.roomEndedMutex.RLock() defer fake.roomEndedMutex.RUnlock() argsForCall := fake.roomEndedArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } func (fake *FakeTelemetryService) RoomProjectReporter(arg1 context.Context) roomobs.ProjectReporter { diff --git a/pkg/telemetry/telemetryservice.go b/pkg/telemetry/telemetryservice.go index bdf9c820c..5b7145892 100644 --- a/pkg/telemetry/telemetryservice.go +++ b/pkg/telemetry/telemetryservice.go @@ -35,7 +35,7 @@ type TelemetryService interface { // events RoomStarted(ctx context.Context, room *livekit.Room) - RoomEnded(ctx context.Context, room *livekit.Room) + RoomEnded(ctx context.Context, room *livekit.Room, reason livekit.RoomEndReason) // ParticipantJoined - a participant establishes signal connection to a room ParticipantJoined(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, clientInfo *livekit.ClientInfo, clientMeta *livekit.AnalyticsClientMeta, shouldSendEvent bool, guard *ReferenceGuard) @@ -105,7 +105,8 @@ type NullTelemetryService struct { func (n NullTelemetryService) TrackStats(roomID livekit.RoomID, roomName livekit.RoomName, key StatsKey, stat *livekit.AnalyticsStat) { } func (n NullTelemetryService) RoomStarted(ctx context.Context, room *livekit.Room) {} -func (n NullTelemetryService) RoomEnded(ctx context.Context, room *livekit.Room) {} +func (n NullTelemetryService) RoomEnded(ctx context.Context, room *livekit.Room, reason livekit.RoomEndReason) { +} func (n NullTelemetryService) ParticipantJoined(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, clientInfo *livekit.ClientInfo, clientMeta *livekit.AnalyticsClientMeta, shouldSendEvent bool, guard *ReferenceGuard) { } func (n NullTelemetryService) ParticipantActive(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, clientMeta *livekit.AnalyticsClientMeta, isMigration bool, isWarp bool, guard *ReferenceGuard) { @@ -281,17 +282,50 @@ func (t *telemetryService) getOrCreateWorker( t.workersMu.Lock() defer t.workersMu.Unlock() + if roomID == "" { + logger.Warnw( + "telemetry stats worker keyed under an empty room id", nil, + "room", roomName, + "participant", participantIdentity, + "participantID", participantID, + "guard", guard, + ) + } + key := statsWorkerKey{roomID, participantID} worker, ok := t.workers[key] if ok && !worker.Closed(guard) { return worker, true } + // only ParticipantLeft releases a guard, so a released guard is a call landing after + // the participant left, e.g. ParticipantActive overtaken by the close. Do not create + // a worker nothing can ever release. The closed worker, if not yet reaped, is returned + // as found, otherwise nil is + if guard != nil && guard.released { + return worker, true + } + existingIsConnected := false if ok { existingIsConnected = worker.IsConnected() } + // a guard references at most once, so a nil or already activated guard leaves the + // new worker with no references and its owner's release drives it negative + if guard == nil || guard.activated { + logger.Infow( + "telemetry stats worker created without a reference", + "room", roomName, + "roomID", roomID, + "participant", participantIdentity, + "participantID", participantID, + "guard", guard, + "replacedClosed", ok, + "existing", worker, + ) + } + worker = newStatsWorker( ctx, t, diff --git a/pkg/utils/protocol.go b/pkg/utils/protocol.go index 77d324b94..0cfab8aca 100644 --- a/pkg/utils/protocol.go +++ b/pkg/utils/protocol.go @@ -17,15 +17,16 @@ package utils import ( + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/utils" ) func ClientInfoWithoutAddress(c *livekit.ClientInfo) *livekit.ClientInfo { if c == nil { return nil } - clone := utils.CloneProto(c) + clone := proto.CloneOf(c) clone.Address = "" return clone } diff --git a/renovate.json b/renovate.json index f2106f3b1..efd16dc9f 100644 --- a/renovate.json +++ b/renovate.json @@ -2,7 +2,7 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", - "helpers:pinGitHubActionDigests" + "helpers:pinGitHubActionDigestsToSemver" ], "minimumReleaseAge": "2 weeks", "commitBody": "Generated by renovateBot", @@ -19,6 +19,14 @@ "matchManagers": ["github-actions"], "groupName": "github workflows" }, + { + "description": "First-party actions, no need to quarantine: we own them, and one referenced by branch would otherwise sit pending until its default branch goes two weeks without a commit", + "matchManagers": ["github-actions"], + "matchPackageNames": [ + "livekit/**" + ], + "minimumReleaseAge": null + }, { "matchManagers": ["dockerfile"], "groupName": "docker deps", diff --git a/test/client/client.go b/test/client/client.go index 1652e8aa3..b103d08da 100644 --- a/test/client/client.go +++ b/test/client/client.go @@ -46,10 +46,10 @@ import ( "github.com/livekit/protocol/signalling" "github.com/livekit/livekit-server/pkg/rtc" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/protocol/datatrack" ) type SignalRequestHandler func(msg *livekit.SignalRequest) error @@ -95,10 +95,12 @@ type RTCClient struct { // remote tracks waiting to be processed pendingRemoteTracks []*webrtc.TrackRemote - pendingTrackWriters []TrackWriter - OnConnected func() - OnDataReceived func(data []byte, sid string) - OnDataUnlabeledReceived func(data []byte) + pendingTrackWriters []TrackWriter + // callbacks are set by tests while the client is already running, and read + // from the signal and data channel goroutines + onConnected atomic.Pointer[func()] + onDataReceived atomic.Pointer[func(data []byte, sid string)] + onDataUnlabeledReceived atomic.Pointer[func(data []byte)] refreshToken string // map of livekit.ParticipantID and last packet @@ -357,8 +359,8 @@ func (c *RTCClient) createTransport(rtcconf webrtc.Configuration) error { } c.pendingDataTrackWriters = nil - if c.OnConnected != nil { - go c.OnConnected() + if f := c.onConnected.Load(); f != nil { + go (*f)() } }) publisherHandler.OnOfferCalls(c.onOffer) @@ -451,8 +453,8 @@ func (c *RTCClient) createTransport(rtcconf webrtc.Configuration) error { } c.pendingDataTrackWriters = nil - if c.OnConnected != nil { - go c.OnConnected() + if f := c.onConnected.Load(); f != nil { + go (*f)() } }) subscriberHandler.OnFullyEstablishedCalls(func() { @@ -483,6 +485,21 @@ func (c *RTCClient) ID() livekit.ParticipantID { return c.id } +// SetOnConnected is safe to call after the client is running. +func (c *RTCClient) SetOnConnected(f func()) { + c.onConnected.Store(&f) +} + +// SetOnDataReceived is safe to call after the client is running. +func (c *RTCClient) SetOnDataReceived(f func(data []byte, sid string)) { + c.onDataReceived.Store(&f) +} + +// SetOnDataUnlabeledReceived is safe to call after the client is running. +func (c *RTCClient) SetOnDataUnlabeledReceived(f func(data []byte)) { + c.onDataUnlabeledReceived.Store(&f) +} + // create an offer for the server func (c *RTCClient) Run() error { c.conn.SetCloseHandler(func(code int, text string) error { @@ -1154,15 +1171,15 @@ func (c *RTCClient) handleDataMessage(kind livekit.DataPacket_Kind, data []byte) } dp.Kind = kind if val, ok := dp.Value.(*livekit.DataPacket_User); ok { - if c.OnDataReceived != nil { - c.OnDataReceived(val.User.Payload, val.User.ParticipantSid) + if f := c.onDataReceived.Load(); f != nil { + (*f)(val.User.Payload, val.User.ParticipantSid) } } } func (c *RTCClient) handleDataMessageUnlabeled(data []byte) { - if c.OnDataUnlabeledReceived != nil { - c.OnDataUnlabeledReceived(data) + if f := c.onDataUnlabeledReceived.Load(); f != nil { + (*f)(data) } } diff --git a/test/client/datatrack_remote.go b/test/client/datatrack_remote.go index 2d2f66365..97097620a 100644 --- a/test/client/datatrack_remote.go +++ b/test/client/datatrack_remote.go @@ -2,7 +2,7 @@ package client import ( "github.com/frostbyte73/core" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "go.uber.org/atomic" diff --git a/test/client/datatrack_writer.go b/test/client/datatrack_writer.go index 82c6a188b..aaadde9f6 100644 --- a/test/client/datatrack_writer.go +++ b/test/client/datatrack_writer.go @@ -19,8 +19,9 @@ import ( "math/rand" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" + "github.com/livekit/protocol/datatrack/datatracktest" "github.com/livekit/protocol/logger" ) @@ -59,7 +60,7 @@ func (d *dataTrackWriter) writeFrames() { return default: - packets := datatrack.GenerateRawDataPackets(d.handle, seqNum, frameNum, 1, rand.Intn(2048)+1, 100*time.Millisecond) + packets := datatracktest.GenerateRawDataPackets(d.handle, seqNum, frameNum, 1, rand.Intn(2048)+1, 100*time.Millisecond) for _, packet := range packets { if err := d.transport.SendDataTrackMessage(packet); err != nil { logger.Errorw("could not send data track packet", err) diff --git a/test/integration_helpers.go b/test/integration_helpers.go index 7ddc8ed68..508d6ee55 100644 --- a/test/integration_helpers.go +++ b/test/integration_helpers.go @@ -63,11 +63,11 @@ const ( var roomClient livekit.RoomService func init() { - conf, err := config.NewConfig("", true, nil, nil) - if err != nil { - panic(err) - } - config.InitLoggerFromConfig(&conf.Logging) + // The logger retains this config and locks it on every component level + // resolution, so it must not be DefaultConfig: NewConfig marshals that + // global from the goroutines these tests spin up. + loggingConf := config.LoggingConfig{PionLevel: config.DefaultConfig.Logging.PionLevel} + config.InitLoggerFromConfig(&loggingConf) prometheus.Init("test", livekit.NodeType_SERVER) } diff --git a/test/scenarios.go b/test/scenarios.go index aee71feb1..809c735b7 100644 --- a/test/scenarios.go +++ b/test/scenarios.go @@ -157,11 +157,11 @@ func scenarioDataPublish(t *testing.T) { payload := "test bytes" received := atomic.NewBool(false) - c2.OnDataReceived = func(data []byte, sid string) { + c2.SetOnDataReceived(func(data []byte, sid string) { if string(data) == payload && livekit.ParticipantID(sid) == c1.ID() { received.Store(true) } - } + }) require.NoError(t, c1.PublishData([]byte(payload), livekit.DataPacket_RELIABLE)) @@ -187,11 +187,11 @@ func scenarioDataUnlabeledPublish(t *testing.T) { payload := "test unlabeled bytes" received := atomic.NewBool(false) - c2.OnDataReceived = func(data []byte, _sid string) { + c2.SetOnDataReceived(func(data []byte, _sid string) { if string(data) == payload { received.Store(true) } - } + }) require.NoError(t, c1.PublishDataUnlabeled([]byte(payload))) diff --git a/test/singlenode_test.go b/test/singlenode_test.go index 08f67fd00..14964d12d 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -1100,30 +1100,30 @@ func TestDataPublishSlowSubscriber(t *testing.T) { // no data should be dropped for fast subscriber var fastDataIndex atomic.Uint64 - fastSub.OnDataReceived = func(data []byte, sid string) { + fastSub.SetOnDataReceived(func(data []byte, sid string) { idx := binary.BigEndian.Uint64(data[len(data)-8:]) require.Equal(t, fastDataIndex.Load()+1, idx) fastDataIndex.Store(idx) - } + }) // no data should be dropped for slow subscriber that is above threshold var slowNoDropDataIndex atomic.Uint64 var drainSlowSubNotDrop atomic.Bool slowNoDropReader := testclient.NewDataChannelReader(dataChannelSlowThreshold * 2) - slowSubNotDrop.OnDataReceived = func(data []byte, sid string) { + slowSubNotDrop.SetOnDataReceived(func(data []byte, sid string) { idx := binary.BigEndian.Uint64(data[len(data)-8:]) require.Equal(t, slowNoDropDataIndex.Load()+1, idx) slowNoDropDataIndex.Store(idx) if !drainSlowSubNotDrop.Load() { slowNoDropReader.Read(data, sid) } - } + }) // data should be dropped for slow subscriber that is below threshold var slowDropDataIndex atomic.Uint64 dropped := make(chan struct{}) slowDropReader := testclient.NewDataChannelReader(dataChannelSlowThreshold / 2) - slowSubDrop.OnDataReceived = func(data []byte, sid string) { + slowSubDrop.SetOnDataReceived(func(data []byte, sid string) { select { case <-dropped: return @@ -1135,7 +1135,7 @@ func TestDataPublishSlowSubscriber(t *testing.T) { } slowDropDataIndex.Store(idx) slowDropReader.Read(data, sid) - } + }) // publisher sends data as fast as possible, it will block by the slowest subscriber above the slow threshold var ( diff --git a/test/webhook_test.go b/test/webhook_test.go index 739c47179..7fff2d226 100644 --- a/test/webhook_test.go +++ b/test/webhook_test.go @@ -111,7 +111,7 @@ func TestWebhooks(t *testing.T) { // room closed rm := server.RoomManager().GetRoom(context.Background(), testRoom) - rm.Close(types.ParticipantCloseReasonNone) + rm.Close(types.RoomCloseReasonAPIDelete) testutils.WithTimeout(t, func() string { if ts.GetEvent(webhook.EventRoomFinished) == nil { return "did not receive RoomFinished" @@ -119,6 +119,7 @@ func TestWebhooks(t *testing.T) { return "" }) require.Equal(t, testRoom, ts.GetEvent(webhook.EventRoomFinished).Room.Name) + require.Equal(t, livekit.RoomEndReason_ROOM_END_API_DELETE, ts.GetEvent(webhook.EventRoomFinished).RoomEndReason) }) } } diff --git a/version/version.go b/version/version.go index 29d306b8c..1790b5fa6 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.6" +const Version = "1.13.10"